Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 42 additions & 2 deletions carwatch/obdwatch.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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:

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Detect data loss despite the stored successful reading

When a successful poll is followed by result["ok"] == False while the adapter path remains present, last_post_readings still contains the successful line, so this condition is false regardless of seen_data. Because the persistent rfcomm service can leave /dev/rfcomm0 bound while the gateway stops answering, the intended data-to-lost transition can occur without any reconnect clearing this value, and the new alert is never posted.

Useful? React with 👍 / 👎.

# `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)"
Comment on lines +502 to +505

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Keep the first success eligible after suppressing boot noise

On the targeted cold-boot path, an asleep car reaches this branch and stores the truthy "(failed)" sentinel. If the ignition subsequently wakes the gateway without /dev/rfcomm0 disappearing, the first successful poll has first == False, no previous DTC with which to detect a change, and no battery baseline for a milestone; therefore it is not posted, seen_data remains false, and the stamp is not cleared. Ordinary later successes remain in the same state, suppressing both the expected initial engine reading and future loss notifications for the lifetime of the process.

Useful? React with 👍 / 👎.

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
Expand Down
60 changes: 60 additions & 0 deletions tests/test_no_data_post.py
Original file line number Diff line number Diff line change
@@ -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()
Loading