mirror of
https://github.com/bckelley/tconnectsync.git
synced 2026-08-24 03:34:12 -05:00
Fix #156: handle LidMalfunctionActivated in ProcessAlarm; add typed guardrail
ProcessAlarm.skip_event() read event.alarmId on every EventClass.ALARM event, but LidMalfunctionActivated (a sibling of LidAlarmActivated in that class) has no alarmId, so a malfunction alarm crashed the sync with AttributeError. - Narrow with isinstance before reading alarmId; malfunction events now upload as "Malfunction" as intended, and sync continues. - Type the alarm handlers against an explicit AlarmEvent union and add an assert_never exhaustiveness guard, so a type checker rejects unguarded subtype attribute access and flags any newly added ALARM event type. - Fix a latent None-leak: alarm_to_nsentry now always returns a dict. - Add mypy as a gradual-typing beachhead (setup.cfg [mypy], CI step, Pipfile typecheck script), scoped to process_alarm.py. This configuration fails on exactly the #156 class of bug. - Add regression tests: malfunction processing, mixed alarm batches, the event shape, and an AlarmEvent/EventClass.ALARM sync guard. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LpMTd5LzkqFLZTUKd3H8em
This commit is contained in:
@@ -27,7 +27,7 @@ jobs:
|
||||
run: |
|
||||
python -m venv .venv
|
||||
.venv/bin/python -m pip install --upgrade pip
|
||||
.venv/bin/python -m pip install -e . flake8 pytest coverage
|
||||
.venv/bin/python -m pip install -e . flake8 pytest coverage mypy
|
||||
# - name: Run pipenv check
|
||||
# run: |
|
||||
# # DDoS attacks in wheel and setuptools packages, not relevant
|
||||
@@ -53,6 +53,9 @@ jobs:
|
||||
.venv/bin/flake8 . --exclude=.venv --count --select=E9,F63,F7,F82 --ignore=F824 --show-source --statistics
|
||||
# exit-zero treats all errors as warnings. The GitHub editor is 127 chars wide
|
||||
.venv/bin/flake8 . --exclude=.venv --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics
|
||||
- name: Type-check annotated modules with mypy
|
||||
run: |
|
||||
.venv/bin/mypy
|
||||
- name: Run tconnectsync --help
|
||||
run: |
|
||||
.venv/bin/tconnectsync --help
|
||||
|
||||
@@ -8,6 +8,7 @@ ptpython = "*"
|
||||
flake8 = "*"
|
||||
pytest = "*"
|
||||
coverage = "*"
|
||||
mypy = "*"
|
||||
|
||||
[packages]
|
||||
tconnectsync = {path = "."}
|
||||
@@ -17,3 +18,4 @@ tconnectsync = "python3 main.py"
|
||||
test = "python3 -m unittest discover -vv"
|
||||
build_events = "bash -c 'cd tconnectsync/eventparser && python3 build_events.py > events.py'"
|
||||
lint = "bash -c 'flake8 . --count --select=E9,F63,F7,F82 && flake8 . --count --exit-zero --max-complexity=10 --max-line-length=127 && echo PASS'"
|
||||
typecheck = "mypy"
|
||||
|
||||
@@ -47,3 +47,9 @@ exclude =
|
||||
[options.entry_points]
|
||||
console_scripts =
|
||||
tconnectsync = tconnectsync:main
|
||||
|
||||
[mypy]
|
||||
files =
|
||||
tconnectsync/sync/tandemsource/process_alarm.py
|
||||
follow_imports = silent
|
||||
ignore_missing_imports = True
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import logging
|
||||
import arrow
|
||||
|
||||
from typing import Iterable, List, Optional, TYPE_CHECKING
|
||||
from typing import Iterable, List, Union, TYPE_CHECKING
|
||||
from typing_extensions import assert_never
|
||||
if TYPE_CHECKING:
|
||||
from ...api import TConnectApi
|
||||
from ...nightscout import NightscoutApi
|
||||
from ...eventparser.raw_event import BaseEvent
|
||||
|
||||
from ...features import DEFAULT_FEATURES
|
||||
from ... import features
|
||||
@@ -20,6 +20,8 @@ from ...parser.nightscout import (
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
AlarmOrMalfunction = Union[eventtypes.LidAlarmActivated, eventtypes.LidMalfunctionActivated]
|
||||
|
||||
class ProcessAlarm:
|
||||
def __init__(self, tconnect: "TConnectApi", nightscout: "NightscoutApi", tconnect_device_id: str, pretend: bool, features: List[str] = DEFAULT_FEATURES) -> None:
|
||||
self.tconnect = tconnect
|
||||
@@ -54,7 +56,10 @@ class ProcessAlarm:
|
||||
|
||||
return ns_entries
|
||||
|
||||
def skip_event(self, event: "BaseEvent") -> bool:
|
||||
def skip_event(self, event: AlarmOrMalfunction) -> bool:
|
||||
if not isinstance(event, eventtypes.LidAlarmActivated):
|
||||
return False
|
||||
|
||||
return event.alarmId in (
|
||||
eventtypes.LidAlarmActivated.AlarmidEnum.ResumePumpAlarm,
|
||||
eventtypes.LidAlarmActivated.AlarmidEnum.ResumePumpAlarm2
|
||||
@@ -73,16 +78,19 @@ class ProcessAlarm:
|
||||
return count
|
||||
|
||||
|
||||
def alarm_to_nsentry(self, event: "BaseEvent") -> Optional[dict]:
|
||||
if type(event) == eventtypes.LidAlarmActivated:
|
||||
def alarm_to_nsentry(self, event: AlarmOrMalfunction) -> dict:
|
||||
if isinstance(event, eventtypes.LidAlarmActivated):
|
||||
alarmId = event.alarmId
|
||||
reason = alarmId.name if alarmId is not None else "Alarm%s" % event.alarmIdRaw
|
||||
return NightscoutEntry.alarm(
|
||||
created_at = event.eventTimestamp.format(),
|
||||
reason = "%s" % event.alarmId.name,
|
||||
reason = reason,
|
||||
pump_event_id = "%s" % event.seqNum
|
||||
)
|
||||
elif type(event) == eventtypes.LidMalfunctionActivated:
|
||||
elif isinstance(event, eventtypes.LidMalfunctionActivated):
|
||||
return NightscoutEntry.alarm(
|
||||
created_at = event.eventTimestamp.format(),
|
||||
reason = "Malfunction",
|
||||
pump_event_id = "%s" % event.seqNum
|
||||
)
|
||||
assert_never(event)
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
#!/usr/bin/env python3
|
||||
|
||||
import json
|
||||
import unittest
|
||||
|
||||
from tconnectsync.eventparser.generic import Event
|
||||
from tconnectsync.eventparser import events as eventtypes
|
||||
from tconnectsync.eventparser.raw_event import RawEvent
|
||||
|
||||
|
||||
class TestLidMalfunctionActivated(unittest.TestCase):
|
||||
maxDiff = None
|
||||
|
||||
def setUp(self):
|
||||
self.fixture = {
|
||||
"deviceAssignmentId": "4ff6bebc-d4d6-4423-b123-eecfcf5a4238",
|
||||
"eventCode": 6,
|
||||
"sequenceGroup": 0,
|
||||
"sequenceNumber": 500123,
|
||||
"pumpDateTime": "2026-05-16T00:07:00",
|
||||
"eventProperties": {"malfId": 7, "faultLocatorData": 8311, "param1": 42, "param2": 0},
|
||||
"estimatedDateTime": "2026-05-16T00:07:00Z",
|
||||
}
|
||||
|
||||
def test_dispatches_to_correct_class(self):
|
||||
self.assertIsInstance(Event(self.fixture), eventtypes.LidMalfunctionActivated)
|
||||
self.assertNotIsInstance(Event(self.fixture), RawEvent)
|
||||
|
||||
def test_has_no_alarmid_attribute(self):
|
||||
ev = Event(self.fixture)
|
||||
self.assertFalse(hasattr(ev, 'alarmId'))
|
||||
self.assertEqual(ev.malfIdRaw, 7)
|
||||
|
||||
def test_envelope_fields(self):
|
||||
ev = Event(self.fixture)
|
||||
self.assertEqual(ev.eventId, 6)
|
||||
self.assertEqual(ev.seqNum, 500123)
|
||||
|
||||
def test_timestamp_preserves_wall_clock(self):
|
||||
ev = Event(self.fixture)
|
||||
self.assertEqual(ev.eventTimestamp.format('YYYY-MM-DDTHH:mm:ss'), "2026-05-16T00:07:00")
|
||||
|
||||
def test_plain_fields(self):
|
||||
ev = Event(self.fixture)
|
||||
self.assertEqual(ev.faultLocatorData, 8311)
|
||||
self.assertEqual(ev.param1, 42)
|
||||
self.assertEqual(ev.param2, 0)
|
||||
|
||||
def test_todict_is_json_serializable(self):
|
||||
ev = Event(self.fixture)
|
||||
d = ev.todict()
|
||||
json.dumps(d) # must not raise
|
||||
self.assertEqual(d["id"], 6)
|
||||
self.assertEqual(d["name"], "LID_MALFUNCTION_ACTIVATED")
|
||||
self.assertEqual(d["malfIdRaw"], 7)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -92,6 +92,16 @@ ALARM_RESUME = {
|
||||
"eventProperties": {"alarmId": 18, "faultLocatorData": 8311, "param1": 5228339, "param2": 0},
|
||||
}
|
||||
|
||||
MALFUNCTION = {
|
||||
"deviceAssignmentId": "00000000-0000-0000-0000-000000000000",
|
||||
"eventCode": 6,
|
||||
"sequenceGroup": 0,
|
||||
"sequenceNumber": 500123,
|
||||
"pumpDateTime": "2026-05-16T00:07:00",
|
||||
"estimatedDateTime": "2026-05-16T00:07:00Z",
|
||||
"eventProperties": {"malfId": 7, "faultLocatorData": 8311, "param1": 42, "param2": 0},
|
||||
}
|
||||
|
||||
|
||||
class TestProcessAlarmJson(unittest.TestCase):
|
||||
maxDiff = None
|
||||
@@ -127,6 +137,41 @@ class TestProcessAlarmJson(unittest.TestCase):
|
||||
p = self.process.process(list(Events([dict(ALARM_RESUME)])), None, None)
|
||||
self.assertEqual(p, [])
|
||||
|
||||
def test_malfunction_alarm_uploaded(self):
|
||||
event = Event(dict(MALFUNCTION))
|
||||
self.assertEqual(type(event), eventtypes.LidMalfunctionActivated)
|
||||
self.assertFalse(hasattr(event, 'alarmId'))
|
||||
|
||||
p = self.process.process([event], None, None)
|
||||
|
||||
self.assertEqual(len(p), 1)
|
||||
self.assertDictEqual(p[0], {
|
||||
'eventType': 'Alarm',
|
||||
'reason': 'Malfunction',
|
||||
'notes': 'Malfunction',
|
||||
'created_at': '2026-05-16 00:07:00-04:00',
|
||||
'enteredBy': 'Pump (tconnectsync)',
|
||||
'pump_event_id': '500123'
|
||||
})
|
||||
|
||||
def test_alarm_and_malfunction_mixed(self):
|
||||
# A batch mixing both ALARM-class event types must not crash and must
|
||||
# emit an entry for each.
|
||||
p = self.process.process(list(Events([dict(ALARM_PUMP_RESET), dict(MALFUNCTION)])), None, None)
|
||||
|
||||
self.assertEqual(len(p), 2)
|
||||
reasons = {entry['reason'] for entry in p}
|
||||
self.assertEqual(reasons, {'PumpResetAlarm', 'Malfunction'})
|
||||
|
||||
|
||||
class TestAlarmOrMalfunctionUnion(unittest.TestCase):
|
||||
def test_union_matches_eventclass(self):
|
||||
from typing import get_args
|
||||
from tconnectsync.sync.tandemsource.process_alarm import AlarmOrMalfunction
|
||||
from tconnectsync.domain.tandemsource.event_class import EventClass
|
||||
|
||||
self.assertEqual(set(get_args(AlarmOrMalfunction)), set(EventClass.ALARM))
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user