From ea8f2a91d95eb059dffa7df2738fc8a2d3e56433 Mon Sep 17 00:00:00 2001 From: claudeMB Date: Fri, 18 Sep 2026 18:34:13 +0200 Subject: [PATCH] obd: stop posting "adapter asleep or car off" on every boot (#56, #31) @eclass sent petrus the identical line at 12:58Z and 13:06Z on 15 Sep with the car parked at home. Two faults, one root cause. `no_data_posted` was a local variable. obdwatch restarts on every boot and every rfcomm rebind, so each fresh process started with the flag cleared and re-sent the same line to his phone. This file already learned that lesson once - DEEP_STAMP a few lines above is persisted precisely because the in-memory `deep_done` reset on rfcomm flaps - and it was not applied here. The epoch marker now lives on disk (#31). The second fault is that the line should not be sent on a cold boot at all. Waking up next to a parked car is not an event, it is the normal resting state, and it was arriving as a notification. It is now gated on `seen_data`, so it posts only as a genuine data -> lost TRANSITION, which is what #56 asks for. The resting fact still shows in the presence and preflight tiles, where a state belongs. A suppressed post is logged, so the behaviour is visible in the journal rather than silent. Stamp bookkeeping never raises: a read-only or missing state dir logs and carries on, because losing the loop is worse than one duplicate post. Tests cover persistence across a restart, clearing on a successful reading, clearing an absent marker, an unwritable stamp path, and an assertion that the `seen_data` gate is still in the loop so a refactor cannot quietly drop it. Co-Authored-By: Claude Opus 5 --- carwatch/obdwatch.py | 44 ++++++++++++++++++++++++++-- tests/test_no_data_post.py | 60 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 102 insertions(+), 2 deletions(-) create mode 100644 tests/test_no_data_post.py diff --git a/carwatch/obdwatch.py b/carwatch/obdwatch.py index ff7cbfe..2341d27 100644 --- a/carwatch/obdwatch.py +++ b/carwatch/obdwatch.py @@ -52,6 +52,30 @@ # cycles (~3 min); the payload carries ts so the UI can show data age. OBD_ALL_CACHE = os.path.expanduser("~/.carwatch/obd-all.json") FULL_SWEEP_EVERY = 3 +# The "no engine data yet" epoch marker, PERSISTED for the same reason +# DEEP_STAMP above is: an in-memory flag dies with the process. obdwatch +# restarts on every boot and every rfcomm rebind, so the flag reset and the +# identical "adapter asleep or car off" line went to petrus's phone again +# (#56: 12:58Z and 13:06Z on 15 Sep, car parked at home; #31 is the same +# shape on service restart). State belongs on disk, not in a local. +NO_DATA_STAMP = os.path.expanduser("~/.carwatch/no-data-posted.stamp") + + +def _no_data_posted() -> bool: + return os.path.exists(NO_DATA_STAMP) + + +def _set_no_data_posted(on: bool) -> None: + try: + if on: + os.makedirs(os.path.dirname(NO_DATA_STAMP), exist_ok=True) + with open(NO_DATA_STAMP, "w") as f: + f.write(str(int(time.time()))) + elif os.path.exists(NO_DATA_STAMP): + os.remove(NO_DATA_STAMP) + except OSError as e: + # Never let bookkeeping kill the read loop; worst case we repost once. + print(f"obdwatch: no-data stamp {e}", flush=True) # Map the 8 basic PIDs (the ones the room reads use and that never errno-5) @@ -252,7 +276,12 @@ def run() -> None: # off the BT adapter power-flaps, each flap > RECONNECT_GAP_S reset the # post-state and re-posted the same no-data line into petrus's phone # (4+ times on Aug 27, twice within 34s). State, not reconnects, decides. - no_data_posted = False + no_data_posted = _no_data_posted() # survives restarts (#31, #56) + seen_data = False # has a real reading been posted since we started? + # "asleep or car off" is only worth saying as a + # data -> lost TRANSITION. On a cold boot with the + # car parked it is not news, it is the normal + # state, and it was arriving as a notification. last_dtc_key = None # last stored-DTC set, to post only on a CHANGE last_posted_batt = None # hybrid-SoC at the last post; a high-water mark # that follows charge UP silently and posts on a @@ -352,6 +381,8 @@ def run() -> None: post(f"Engine read (live from my OBD port): {line}") last_post_readings = line no_data_posted = False + seen_data = True + _set_no_data_posted(False) if batt is not None: last_posted_batt = batt # reset baseline on post last_dtc_key = dtc_key @@ -457,12 +488,21 @@ def run() -> None: # faster read cadence does NOT re-spam the room (was 60s, which # made the "LIVE 29s" badge grow to a minute between updates). else: - if not last_post_readings and not no_data_posted: + if not last_post_readings and not no_data_posted and seen_data: + # `seen_data` is the #56 gate: post this ONLY as a + # data -> lost transition. Booting next to a parked car is + # not an event. The fact still shows in the presence and + # preflight tiles, which is where a resting state belongs. hint = (result.get("summary", "no data") if port else failure_hint(result)) post(f"OBD: adapter/link present but no engine data yet - {hint}") last_post_readings = "(failed)" no_data_posted = True + _set_no_data_posted(True) + elif not last_post_readings and not seen_data: + print("obdwatch: no engine data and none seen this session " + "- not posting (car is simply off)", flush=True) + last_post_readings = "(failed)" next_try = time.time() + RETRY_COOLDOWN_S # Live steering fills what was idle sleep time: sample the wheel angle # off the passive CAN broadcast (id 0x0500 byte 0) and cache it, so diff --git a/tests/test_no_data_post.py b/tests/test_no_data_post.py new file mode 100644 index 0000000..f7231d1 --- /dev/null +++ b/tests/test_no_data_post.py @@ -0,0 +1,60 @@ +"""#56 / #31: the car posted "adapter asleep or car off" on every boot. + +@eclass sent petrus the identical line at 12:58Z and 13:06Z on 15 Sep with the +car parked at home. `no_data_posted` was a local variable, so every boot and +every rfcomm rebind started a fresh process with the flag cleared. The same +file already learned this lesson once - DEEP_STAMP is persisted because the +in-memory `deep_done` reset on rfcomm flaps - and it was not applied here. +""" +import importlib +import os +import sys +import tempfile +import unittest + +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + + +class NoDataEpochMarker(unittest.TestCase): + + def setUp(self): + self.tmp = tempfile.TemporaryDirectory() + self.addCleanup(self.tmp.cleanup) + from carwatch import obdwatch + self.ow = importlib.reload(obdwatch) + self.ow.NO_DATA_STAMP = os.path.join(self.tmp.name, "no-data-posted.stamp") + + def test_marker_survives_a_restart(self): + # A fresh process must be able to see that the line was already sent. + self.assertFalse(self.ow._no_data_posted()) + self.ow._set_no_data_posted(True) + self.assertTrue(self.ow._no_data_posted(), + "the epoch marker did not persist; a restart reposts") + + def test_a_successful_reading_clears_the_epoch(self): + self.ow._set_no_data_posted(True) + self.ow._set_no_data_posted(False) + self.assertFalse(self.ow._no_data_posted()) + + def test_clearing_an_absent_marker_is_not_an_error(self): + self.ow._set_no_data_posted(False) # must not raise + self.assertFalse(self.ow._no_data_posted()) + + def test_bookkeeping_failure_never_raises(self): + # A read-only or missing state dir must not kill the read loop. + self.ow.NO_DATA_STAMP = "/proc/definitely/not/writable/stamp" + self.ow._set_no_data_posted(True) # must not raise + self.assertFalse(self.ow._no_data_posted()) + + def test_post_is_gated_on_having_seen_data(self): + # The #56 rule lives in the read loop; assert the source states it, so + # the gate cannot be dropped silently in a refactor. + import inspect + src = inspect.getsource(self.ow.run) + self.assertIn("and seen_data", src, + "the no-data post is no longer gated on a data -> lost " + "transition; it will fire on a cold boot again") + + +if __name__ == "__main__": + unittest.main()