Detect repeated whole-hour meter registers, and announce state over MQTT - #1247
Open
gskjold wants to merge 4 commits into
Open
Detect repeated whole-hour meter registers, and announce state over MQTT#1247gskjold wants to merge 4 commits into
gskjold wants to merge 4 commits into
Conversation
Two coupled changes: the fix for #1119, and the MQTT announcements from #1128 that make the correction visible instead of silent. Reject repeated whole-hour registers (#1119) Some meters publish the previous whole hour's accumulated registers again at the next whole hour: identical registers and an identical meter clock, while the instantaneous values in the same payload are current. Confirmed on three Aidon meters; the four captured payloads are the gh1119-1..4 fixtures. Stored history was credited with a zero hour followed by a double hour, which destroys the month's tariff peak until the month rolls over. The 2.3 workaround in AmsData::apply() never took effect on history at all: it substituted the integrated estimate into meterState, but handleDataSuccess passes the raw packet to ds.update(), and meterState is never used to build history. AmsData::isStaleCounter() now detects the repeat, meter-agnostically, from the meter clock: the timestamp equals the last accepted List >= 3 timestamp, the registers are unchanged, and the meter's own observed register cadence is at least 30 minutes. It gives up after two consecutive rejections so a frozen clock cannot suppress history indefinitely. The cadence is learned from the meter rather than compared against the system clock, because adjustForKnownIssues() applies a flat -3600 for Aidon that is only correct outside DST; learning the step also keeps meters that publish registers every few seconds out of scope. For the affected hour, handleDataSuccess stores the power-integrated estimate once the device has been up for an hour, so the next hour's delta is measured against the estimate and the two-hour total closes exactly on the meter's own register. Before that there is no reliable baseline, so nothing is stored and the next whole hour falls into AmsDataStorage's existing average branch, which distributes the two-hour delta evenly across both hours. AmsDataStorage itself is unchanged. The Aidon-specific gate, the exact-double counter compare and the diff < 1.0 case are gone. That last one accepted the stale value whenever the hour's estimate was below 1 kWh, which is most nights. The four MQTT handlers withhold the repeated accumulated registers; the instantaneous values in the same payload are current and still go out. The flag describes the packet most recently applied and is cleared by the next sub-hour packet, so the state snapshots the handlers compose in stateUpdate mode do not inherit it for the rest of the hour. Announce service state and events over MQTT (#1128) A degraded service was only visible in the web UI and as red LED blinks. Nothing reached MQTT, so a Home Assistant user could not be told that the reader stopped receiving meter data, that an hourly reading had to be substituted, or that price fetching was failing (#1120). <topic>/services carries a retained state snapshot, built from the same buildServicesJson() the web UI is served from so there is one vocabulary rather than two. It is republished on change or once a minute. The detail fields are omitted: the MQTT packet buffer is 256 bytes on ESP8266 and a meter model plus a broker hostname plus an NTP server would not fit. buildServicesJson() gained a withDetail flag, and its nine duplicated entry blocks were folded into one helper so the two shapes cannot drift; the web payload is unchanged. <topic>/event carries one message per transition, not retained: hourly_data_substituted, han_no_data, han_undecodable, han_error, han_restored, price_error and price_ok. The raw error code travels with the event rather than being mapped to a category in firmware, since the codes are already documented and translated for the UI. HAN events are rate limited to one per 30 s on top of the debounce already in AmsData::getLastError(). <topic>/status was already the retained online/offline availability payload backed by the last will, which is why the snapshot uses /services - and why adding avty_t to the Home Assistant discovery template was a single line. Home Assistant also gets a device_class:problem diagnostic binary sensor, which is the one entity a notification automation can trigger on, plus a sensor for the raw HAN error code. Verified with six native tests over the gh1119 fixtures (pio test -e native, 23 cases) and builds for esp8266 and all six ESP32 targets. ESP8266 flash goes from 98.0% to 98.3% of the 1 MB budget. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
buildServicesJson() stopped being web-only when the MQTT announcements started rendering from it, which left the MQTT path reaching through the web server for state it does not own. It now lives with the other shared generators, alongside hanState() and mqttHandlerState(). A ServiceStatusContext carries the services to report on, so both callers fill in what they have rather than the generator depending on either. AmsWebServer keeps computeServicesAggregate(), which is web-only, but builds it from the moved helpers so the badge, the services array and the MQTT announcements cannot disagree. No behaviour change: the web payload is byte for byte what it was. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The services snapshot carried up/han/hanError beside the array, and a "HAN error" sensor reading the raw code. han and hanError restated services[0], up restated the /state payload, and the sensor restated the problem binary sensor with less meaning attached. Only "problem" stays outside the array. It is computed rather than duplicated: it saves every subscriber from indexing into the array by position to find out whether anything is wrong, and it is what the Home Assistant binary sensor templates on. A four-service payload is now 126 bytes plus the topic, well inside the 256 byte MQTT packet buffer on ESP8266. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ayload Three changes, one of them a correction. Correct the ESP8266 packet buffer claim The services payload was trimmed of its "d" detail fields on the belief that the 256 byte MQTTClient buffer on ESP8266 caps outbound payloads. It does not. lwmqtt_encode_publish() writes only the fixed header and the topic into the write buffer; lwmqtt_write_to_network() then streams the payload straight to the socket. The 256 bytes cap *inbound* messages, which is what dropOverflow(true) is for. That is also why the ~400 byte Home Assistant discovery configs have always published fine from ESP8266. So the withDetail flag is gone and MQTT gets the same array the web UI does, detail included. Move "problem" to the system payload It was the only field outside the services array, which left the array topic carrying a field that is not a service. It now rides along with the other device-level health fields in the system payload, so <topic>/services is nothing but the array. The Home Assistant binary sensor follows it to <topic>/state, alongside the rssi/vcc/uptime sensors that already read from there. The JSON payload format carries the same field in its own system payload. Handlers reach the meter state through a new setMeterState(), matching the existing setDataStorage() wiring. Cadence drops from at most every five seconds to the 60 second system publish, comfortably inside the sensor's 300 s expire_after. Add the event entity Home Assistant's MQTT event platform reads "event_type" out of the payload natively and turns the remaining keys into attributes, so publishEvent() names the field that way and no value_template is needed. The entity declares all seven event types and lands under Diagnostic on the device page; the transitions are now visible in Home Assistant rather than only to an mqtt trigger. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
🔧 PR Build ArtifactsVersion: All environments built successfully. Download the zip files:
|
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes #1119. Implements #1128, which covers the notification asked for in #1120.
Reject repeated whole-hour registers (#1119)
Some meters publish the previous whole hour's accumulated registers again at the next whole hour — identical registers and an identical meter clock, while the instantaneous values in the same payload are current. Confirmed on three Aidon meters; the four captured payloads are already in the corpus as
gh1119-1..4.hex:Stored history got a zero hour followed by a double hour, which destroys the month's tariff peak until the month rolls over.
The 2.3 workaround never took effect on history.
AmsData::apply()substituted the integrated estimate intometerState, buthandleDataSuccess()passes the raw packet tods.update()andmeterStateis never used to build history. That is why #1119 reports the workaround "does not work".Detection
AmsData::isStaleCounter(), meter-agnostic. A List ≥ 3 packet is a repeat when its meter timestamp equals the last accepted one, the registers are unchanged, and the meter's own observed register cadence is at least 30 minutes. After two consecutive rejections the reading is accepted anyway, so a frozen clock cannot suppress history indefinitely.The cadence is learned from the meter rather than compared against the system clock:
adjustForKnownIssues()applies a flat-3600for Aidon that is only correct outside DST, so an absolute comparison would misfire for half the year. Learning the step also keeps meters that publish registers every few seconds out of scope — their clock advances between updates, so they never match.Value used for the affected hour
meterStatecovers the whole hour, so the storage update is fed the estimate. The next hour's delta is measured against the estimate, so the two-hour total closes exactly on the meter's own register and the estimation error is absorbed by the second hour.AmsDataStorage's< 4000test and lands in its existing "average" branch, distributing the delta evenly across both hours.AmsDataStorageis unchanged. If the estimate ever overshoots by more than the whole of the next hour's consumption, its existingday.activeImport > importCounterguard stores that hour as 0 and snaps the counter back — bounded, and it heals on the following hour.The Aidon-specific gate, the exact-
doublecounter compare and thediff < 1.0case are gone. That last one accepted the stale value whenever the hour's estimate was below 1 kWh, i.e. most nights.MQTT
All four handlers withhold the repeated accumulated registers. The instantaneous values in the same payload are current and still go out —
RawMqttHandlerskipspublishList3(),JsonMqttHandlerpublishes as List 2, HA and Domoticz extend their existing estimated-counter gate.The flag describes the packet most recently applied and is cleared by the next sub-hour packet, so the state snapshots the handlers compose in
stateUpdatemode do not inherit it for the rest of the hour.Announce service state and events over MQTT (#1128)
<topic>/services— retained state snapshot, built from the sameAmsJsonGenerator::generateServicesJson()the web UI is served from, republished on change or once a minute. Byte for byte what the web UI receives:{"services":[{"k":"han","s":3,"e":90,"d":"AIDON_V0001"}, {"k":"mqtt","s":1,"e":0,"d":"192.168.1.10"}, ...]}The aggregate
problemflag rides in the system payload (<topic>/statefor HA,<topic>/systemfor JSON) rather than on the services topic, so that topic is nothing but the array.<topic>/event— one message per transition, not retained:hourly_data_substitutedfill:estimateorsplithan_no_datahan_undecodablehan_errorhan_restoredprice_error/price_okPriceServicetransitionsThe raw error code travels with the event rather than being mapped to a category in firmware — the codes are already documented and translated for the UI. HAN events are rate limited to one per 30 s on top of the debounce already in
AmsData::getLastError().Home Assistant
avty_ton every discovered entity, so all entities go unavailable when the reader drops off the broker.device_class: problem,entity_category: diagnosticbinary sensor readingproblemfrom<topic>/state— the one entity a notification automation can trigger on, which is what Feature request: Notification in HA payload when no data from the meter #1120 is really asking for.<topic>/eventdeclaring all seven event types, so the transitions are visible in HA rather than only to anmqtttrigger. The MQTT event platform readsevent_typenatively and turns the remaining keys into attributes, which is why the payload names the field that way — novalue_templateneeded.Two things that differ from the plan in #1128
<topic>/statuswas already taken by the retainedonline/offlineavailability payload backed by the last will, so the snapshot uses/services. The upside:avty_twas a single line, since the availability topic already existed.AmsWebServer.buildServicesJson()stopped being web-only once the announcements rendered from it, which left the MQTT path reaching through the web server for state it does not own. It is nowAmsJsonGenerator::generateServicesJson()alongsidehanState()andmqttHandlerState(), with aServiceStatusContextcarrying the services to report on so neither caller depends on the other.AmsWebServerkeepscomputeServicesAggregate(), which is web-only, but builds it from the moved helpers.One correction made along the way
An earlier revision trimmed the
ddetail fields from the MQTT services payload, on the belief that the 256 byteMQTTClientbuffer on ESP8266 caps outbound payloads. It does not.lwmqtt_encode_publish()writes only the fixed header and topic into the write buffer, andlwmqtt_write_to_network()then streams the payload straight to the socket — the 256 bytes cap inbound messages, which is whatdropOverflow(true)is for. It is also why the ~400 byte HA discovery configs have always published fine from ESP8266. The trimming and itswithDetailflag are gone.Verification
Six new native tests over the
gh1119fixtures intest/test_decoder/test_stale_counter.cpp:Assertions are relative between fixtures —
decodeCosemDateTime()usesmktime(), so absolute values depend on the host timezone.pio test -e native: 23 cases, all pass, golden sweep unchanged.Builds:
esp8266,esp32,esp32s2,esp32s2psram,esp32c3,esp32s3,esp32solo. TheCUSTOM_MQTT_HOSTandENERGY_SPEEDOMETER_PASSbranches were compile-checked with forced defines.ESP8266 flash: 98.0% → 98.4% of the 1 MB budget.
Not covered
EnergyAccountingoffers onlynow - 3600toupdateMax()when it rolls the hour. When the affected hour straddles local midnight the earlier hour belongs to the previous day and its corrected value is never offered as a peak candidate for that day. Left as a known gap in #1119 —updateMax()only ever raises a peak, so re-offering would be idempotent if we want to close it.AmsDataStorageandEnergyAccountingare Arduino-only, so the storage behaviour needs a device check: after a stale hour the day plot should show a plausible value for both hours instead of a zero followed by a double, and the month peak should not jump.🤖 Generated with Claude Code