feat: add high-water mark state tracking to ach_server + fix monitoring flag

ach_server.py:
- Add ach_state.json per-unit state tracking (keyed by serial number)
- count_events() before any download; skip session if no new events since last call-home
- Download only events beyond the previous high-water mark (all_events[last_count:])
- --max-events N safety cap for first-run units with many stored events
- state_path and max_events wired through AchSession constructor and serve()

client.py (_decode_monitor_status):
- Revert monitoring flag to section[1] == 0x10 (was incorrectly changed to section[6])
- Fix battery/memory offsets to section[-10:-8], [-8:-4], [-4:] (no trailing checksum byte)
- Both confirmed by full byte diff of all 144 0xE3 data frames in 4-8-26/2ndtry capture

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-09 14:38:44 -04:00
committed by serversdown
parent cf7d838bf4
commit 0358acb51d
4 changed files with 210 additions and 107 deletions
+28 -27
View File
@@ -1755,17 +1755,20 @@ def _decode_monitor_status(data: bytes) -> MonitorStatus:
data is the raw S3 frame .data attribute (includes the 11-byte section
header, so field offsets below are relative to data[11]).
Monitoring flag (confirmed 4-8-26/2ndtry, full byte diff analysis):
section[6] == 0x00 → idle
section[6] == 0x10 → monitoring
NOTE: frame.data has the checksum byte already stripped by S3FrameParser
(_finalise returns raw_payload[5:] where raw_payload = body[:-1]).
There is NO trailing checksum byte in section.
The payload size varies (5255+ bytes) but the battery/memory block is
always the last 10 bytes before the trailing checksum byte:
Monitoring flag (confirmed 4-8-26/2ndtry, byte diff of all 144 data frames):
section[1] == 0x00 → idle
section[1] == 0x10 → monitoring
section[-11:-9] battery × 100 uint16 BE (0x02A8 = 6.80 V)
section[-9 :-5] memory_total uint32 BE bytes
section[-5 :-1] memory_free uint32 BE bytes
section[-1] checksum (not data)
The payload length varies (4649 bytes) — IDLE is 46-47, MONITORING is 48-49.
The battery/memory block is always the last 10 bytes of section (no checksum):
section[-10:-8] battery × 100 uint16 BE (0x02A8 = 6.80 V)
section[-8 :-4] memory_total uint32 BE bytes
section[-4:] memory_free uint32 BE bytes
Values confirmed from 4-8-26/2ndtry capture (BE11529):
battery 0x02A8 = 680 → 6.80 V
@@ -1780,32 +1783,30 @@ def _decode_monitor_status(data: bytes) -> MonitorStatus:
len(data), len(section), section.hex(),
)
# Monitoring flag: section[6] (CORRECTED 2026-04-08 — was wrongly section[1]).
# Byte diff of 2ndtry BW-S3 captures confirms section[6] flips 0x00↔0x10
# exactly at the start/stop monitoring transitions (0xE3 frame #36 / #132).
is_monitoring = len(section) > 6 and section[6] == 0x10
# Monitoring flag: section[1] == 0x10.
# Confirmed from byte diff of all 144 0xE3 data frames in 4-8-26/2ndtry capture:
# section[1] = 0x00 in all IDLE frames, 0x10 in all MONITORING frames.
# (section[6] also changes but has non-binary values 0xea/0x07 — device-specific.)
is_monitoring = len(section) > 1 and section[1] == 0x10
battery_v = None
memory_total = None
memory_free = None
# Battery and memory offsets are RELATIVE TO THE END of the section.
# The payload length varies (5255+ bytes) depending on monitoring state and
# internal counters, but the battery/memory block is always the last 10 bytes
# before the checksum (section[-1]).
# Battery and memory at relative-from-end offsets.
# Payload length varies (4649 bytes) but the battery/memory block is always
# the last 10 bytes. No checksum byte — it was stripped by S3FrameParser.
#
# section[-11:-9] battery × 100 uint16 BE 0x02A8 = 6.80 V
# section[-9 :-5] memory_total uint32 BE ≈ 960 KB on BE11529
# section[-5 :-1] memory_free uint32 BE decreases as events fill
# section[-1] frame checksum (not data)
# section[-10:-8] battery × 100 uint16 BE 0x02A8 = 6.80 V
# section[-8 :-4] memory_total uint32 BE ≈ 960 KB on BE11529
# section[-4:] memory_free uint32 BE decreases as events fill
#
# Confirmed stable across IDLE (52b), MONITORING (55b), and counter-jitter
# IDLE variants (53b) from 4-8-26/2ndtry full capture analysis.
if len(section) >= 11:
batt_raw = struct.unpack(">H", section[-11:-9])[0]
# Confirmed stable across IDLE (46b), MONITORING (48-49b) variants.
if len(section) >= 10:
batt_raw = struct.unpack(">H", section[-10:-8])[0]
battery_v = batt_raw / 100.0
memory_total = struct.unpack(">I", section[-9:-5])[0]
memory_free = struct.unpack(">I", section[-5:-1])[0]
memory_total = struct.unpack(">I", section[-8:-4])[0]
memory_free = struct.unpack(">I", section[-4:])[0]
return MonitorStatus(
is_monitoring=is_monitoring,