Skip to content

Commit 467bbb4

Browse files
gskjoldclaude
andauthored
Detect repeated whole-hour meter registers, and announce state over MQTT (#1247)
* Detect repeated whole-hour meter registers, and announce state over MQTT 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> * Move the shared services JSON generator into AmsJsonGenerator 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> * Drop the HAN error sensor and the duplicated top-level status fields 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> * Add a Home Assistant event entity, and move "problem" to the system payload 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> --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 0389407 commit 467bbb4

17 files changed

Lines changed: 816 additions & 198 deletions

src/AmsData.cpp

Lines changed: 57 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,13 @@ void AmsData::apply(AmsData& other) {
4141
}
4242
counterEstimated = true;
4343
}
44+
45+
// The flag describes the packet most recently applied. Sub-hour traffic
46+
// means the stale whole-hour repeat is no longer the newest thing we know,
47+
// so clear it: otherwise the state snapshots that the MQTT handlers
48+
// compose from this state would inherit it for the rest of the hour and
49+
// withhold counters that are perfectly good.
50+
this->counterStale = false;
4451
}
4552

4653
this->lastUpdateMillis = other.getLastUpdateMillis();
@@ -70,21 +77,17 @@ void AmsData::apply(AmsData& other) {
7077
this->l3activeExportCounter = other.getL3ActiveExportCounter();
7178
case 3:
7279
this->meterTimestamp = other.getMeterTimestamp();
73-
// Aidon tends to sometime send the same counter as last hour by accident
74-
if(meterType == AmsTypeAidon && counterEstimated && lastKnownCounter == other.getActiveImportCounter()-other.getActiveExportCounter()) {
75-
double diff = activeImportCounter - activeExportCounter - lastKnownCounter;
76-
if(diff < 1.0) { // In case a very low value have been calculated, use the new values
77-
this->activeImportCounter = other.getActiveImportCounter();
78-
this->activeImportCounterTariff1 = other.getActiveImportCounterTariff1();
79-
this->activeImportCounterTariff2 = other.getActiveImportCounterTariff2();
80-
this->activeExportCounter = other.getActiveExportCounter();
81-
this->activeExportCounterTariff1 = other.getActiveExportCounterTariff1();
82-
this->activeExportCounterTariff2 = other.getActiveExportCounterTariff2();
83-
this->reactiveImportCounter = other.getReactiveImportCounter();
84-
this->reactiveExportCounter = other.getReactiveExportCounter();
85-
this->lastKnownCounter = activeImportCounter - activeExportCounter;
86-
}
80+
// Some meters repeat the previous whole hour's register snapshot at the
81+
// next whole hour (#1119). Keep the integrated estimate and stay flagged
82+
// as estimated, so the repeat is neither stored nor published as a new
83+
// hourly reading. The flag is set by the pipeline entry point via
84+
// isStaleCounter() before this point, and propagates through apply().
85+
if(other.isCounterStale()) {
86+
this->counterStale = true;
87+
if(this->staleCounterCount < 0xFF) this->staleCounterCount++;
8788
} else {
89+
this->counterStale = false;
90+
this->staleCounterCount = 0;
8891
this->activeImportCounter = other.getActiveImportCounter();
8992
this->activeImportCounterTariff1 = other.getActiveImportCounterTariff1();
9093
this->activeImportCounterTariff2 = other.getActiveImportCounterTariff2();
@@ -94,8 +97,14 @@ void AmsData::apply(AmsData& other) {
9497
this->reactiveImportCounter = other.getReactiveImportCounter();
9598
this->reactiveExportCounter = other.getReactiveExportCounter();
9699
this->lastKnownCounter = activeImportCounter - activeExportCounter;
100+
if(other.getMeterTimestamp() != 0) {
101+
if(this->lastAcceptedMeterTimestamp != 0 && other.getMeterTimestamp() > this->lastAcceptedMeterTimestamp) {
102+
this->lastAcceptedMeterTimestampStep = (int32_t) (other.getMeterTimestamp() - this->lastAcceptedMeterTimestamp);
103+
}
104+
this->lastAcceptedMeterTimestamp = other.getMeterTimestamp();
105+
}
106+
this->counterEstimated = false;
97107
}
98-
this->counterEstimated = false;
99108
case 2:
100109
strncpy(this->listId, other.listId, sizeof(this->listId) - 1);
101110
strncpy(this->meterId, other.meterId, sizeof(this->meterId) - 1);
@@ -469,6 +478,39 @@ bool AmsData::isCounterEstimated() {
469478
return this->counterEstimated;
470479
}
471480

481+
bool AmsData::isStaleCounter(AmsData& other) {
482+
if(other.getListType() < 3) return false;
483+
484+
// Never suppress more than two consecutive readings. A meter with a frozen
485+
// clock must not be able to blackhole history indefinitely.
486+
if(this->staleCounterCount >= 2) return false;
487+
488+
time_t ts = other.getMeterTimestamp();
489+
if(ts == 0 || this->lastAcceptedMeterTimestamp == 0) return false; // no clock to compare against
490+
if(ts != this->lastAcceptedMeterTimestamp) return false; // clock advanced, genuine new reading
491+
492+
// Only meters that publish their registers on a long cycle can exhibit this.
493+
// A meter sending List 3 every few seconds repeats its clock between updates
494+
// as a matter of course, so require an observed hourly cadence first. The
495+
// step is learned from the meter itself rather than compared against the
496+
// system clock: adjustForKnownIssues() gives some meters a fixed offset that
497+
// is only correct outside DST, so absolute comparison is not reliable.
498+
if(this->lastAcceptedMeterTimestampStep < 1800) return false;
499+
500+
// Clock repeated. Require the registers to be identical too - both signals
501+
// are present in every captured case, so demanding both avoids false
502+
// positives on meters with an unreliable clock.
503+
return this->lastKnownCounter == other.getActiveImportCounter() - other.getActiveExportCounter();
504+
}
505+
506+
bool AmsData::isCounterStale() {
507+
return this->counterStale;
508+
}
509+
510+
void AmsData::setCounterStale(bool stale) {
511+
this->counterStale = stale;
512+
}
513+
472514
bool AmsData::isL2currentMissing() {
473515
return this->l2currentMissing;
474516
}

src/AmsData.h

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,13 @@ class AmsData {
2929
void apply(AmsData& other);
3030
void apply(const OBIS_code_t obis, double value, uint64_t millis64);
3131

32+
// True when other's List >= 3 accumulated registers are a verbatim repeat of
33+
// the last set we accepted: same meter clock AND same counters. Some meters
34+
// (confirmed on Aidon, #1119) publish the previous hour's register snapshot
35+
// again at the next whole hour, which would otherwise store a zero hour
36+
// followed by a double hour. Pure query - apply() does the bookkeeping.
37+
bool isStaleCounter(AmsData& other);
38+
3239
uint64_t getLastUpdateMillis();
3340

3441
time_t getPackageTimestamp();
@@ -85,6 +92,13 @@ class AmsData {
8592
double getActiveExportCounterTariff2();
8693
double getReactiveExportCounter();
8794

95+
// Whether the most recently applied packet was a stale whole-hour repeat.
96+
// Set from isStaleCounter() by the pipeline entry point, cleared again by the
97+
// next sub-hour packet, and consulted by the MQTT handlers to withhold the
98+
// repeated accumulated registers.
99+
bool isCounterStale();
100+
void setCounterStale(bool);
101+
88102
bool isThreePhase();
89103
bool isTwoPhase();
90104
bool isCounterEstimated();
@@ -112,7 +126,11 @@ class AmsData {
112126
float powerFactor = 0, l1PowerFactor = 0, l2PowerFactor = 0, l3PowerFactor = 0;
113127
double activeImportCounter = 0, activeImportCounterTariff1 = 0, activeImportCounterTariff2 = 0, reactiveImportCounter = 0, activeExportCounter = 0, activeExportCounterTariff1 = 0, activeExportCounterTariff2 = 0, reactiveExportCounter = 0;
114128
double lastKnownCounter = 0;
129+
time_t lastAcceptedMeterTimestamp = 0;
130+
int32_t lastAcceptedMeterTimestampStep = 0;
131+
uint8_t staleCounterCount = 0;
115132
bool threePhase = false, twoPhase = false, counterEstimated = false, l2currentMissing = false;;
133+
bool counterStale = false;
116134

117135
int8_t lastError = 0x00;
118136
uint8_t lastErrorCount = 0;

src/AmsJsonGenerator.cpp

Lines changed: 167 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,173 @@
88
#include "AmsStorage.h"
99
#include "LittleFS.h"
1010
#include "FirmwareVersion.h"
11+
#include "AmsData.h"
12+
#include "PriceService.h"
13+
#include "NtpStatus.h"
14+
#include "Uptime.h"
15+
#include "mqtt/AmsMqttHandler.h"
16+
#if defined(AMS_CLOUD)
17+
#include "cloud/CloudConnector.h"
18+
#endif
19+
#if defined(ZMART_CHARGE)
20+
#include "cloud/ZmartChargeCloudConnector.h"
21+
#endif
22+
23+
// SNTP resyncs roughly hourly; flag the NTP service as degraded if no sync has
24+
// landed in this long, allowing a couple of missed cycles before warning.
25+
#define NTP_STALE_AFTER_SECONDS 10800
26+
27+
uint8_t AmsJsonGenerator::hanState(AmsData* meterState) {
28+
if(meterState == NULL) return 2;
29+
uint64_t millis = millis64();
30+
if(meterState->getLastError() != 0) return 3;
31+
// State 0 means disabled, which the HAN port never is. Waiting for the first
32+
// frame after boot is the connecting state.
33+
if(meterState->getLastUpdateMillis() == 0 && millis < 30000) return 2;
34+
if(millis - meterState->getLastUpdateMillis() < 15000) return 1;
35+
if(millis - meterState->getLastUpdateMillis() < 30000) return 2;
36+
return 3;
37+
}
38+
39+
uint8_t AmsJsonGenerator::mqttHandlerState(AmsMqttHandler* h) {
40+
if(h == NULL) return 2;
41+
if(h->connected()) return 1;
42+
return h->lastError() == 0 ? 2 : 3;
43+
}
44+
45+
// Formats one entry of the services array. Kept in one place so the web payload
46+
// and the MQTT payload (#1128) cannot drift apart.
47+
static void appendServiceEntry(String& out, const char* key, uint8_t state, int16_t err, const char* detail, const char* name) {
48+
char entry[320];
49+
snprintf_P(entry, sizeof(entry), PSTR("{\"k\":\"%s\",\"s\":%d,\"e\":%d%s%s%s,\"d\":\"%s\"}"),
50+
key, state, err,
51+
name != NULL ? ",\"n\":\"" : "", name != NULL ? name : "", name != NULL ? "\"" : "",
52+
detail == NULL ? "" : detail);
53+
if(!out.isEmpty()) out += ",";
54+
out += entry;
55+
}
56+
57+
String AmsJsonGenerator::generateServicesJson(const ServiceStatusContext& ctx) {
58+
String out = "";
59+
if(ctx.config == NULL) return out;
60+
61+
{
62+
String meterModel = ctx.meterState == NULL ? String("") : String(ctx.meterState->getMeterModel());
63+
if(!meterModel.isEmpty())
64+
meterModel.replace(F("\\"), F("\\\\"));
65+
appendServiceEntry(out, "han", hanState(ctx.meterState),
66+
ctx.meterState == NULL ? 0 : ctx.meterState->getLastError(), meterModel.c_str(), NULL);
67+
}
68+
69+
MqttConfig mqttConfig;
70+
bool haveMqttConfig = ctx.config->getMqttConfig(mqttConfig);
71+
if(haveMqttConfig && strlen(mqttConfig.host) > 0) {
72+
uint8_t s;
73+
int16_t err = 0;
74+
if(!ctx.mqttEnabled) {
75+
s = 0;
76+
} else {
77+
s = mqttHandlerState(ctx.mqttHandler);
78+
if(ctx.mqttHandler != NULL) err = (int16_t) ctx.mqttHandler->lastError();
79+
}
80+
appendServiceEntry(out, "mqtt", s, err, mqttConfig.host, NULL);
81+
}
82+
83+
#if defined(CUSTOM_MQTT_HOST)
84+
{
85+
uint8_t s = mqttHandlerState(ctx.customMqttHandler);
86+
int16_t err = ctx.customMqttHandler == NULL ? 0 : (int16_t) ctx.customMqttHandler->lastError();
87+
#if defined(CUSTOM_MQTT_NAME)
88+
appendServiceEntry(out, "mqtt_c", s, err, CUSTOM_MQTT_HOST, CUSTOM_MQTT_NAME);
89+
#else
90+
appendServiceEntry(out, "mqtt_c", s, err, CUSTOM_MQTT_HOST, NULL);
91+
#endif
92+
}
93+
#endif
94+
95+
#if defined(ESP32) && defined(ENERGY_SPEEDOMETER_PASS)
96+
{
97+
SystemConfig sys;
98+
ctx.config->getSystemConfig(sys);
99+
if(sys.energyspeedometer == 7) {
100+
uint8_t s = mqttHandlerState(ctx.energySpeedometer);
101+
int16_t err = ctx.energySpeedometer == NULL ? 0 : (int16_t) ctx.energySpeedometer->lastError();
102+
appendServiceEntry(out, "mqtt_es", s, err, "", NULL);
103+
}
104+
}
105+
#endif
106+
107+
PriceServiceConfig priceCfg;
108+
if(ctx.config->getPriceServiceConfig(priceCfg) && priceCfg.enabled && strlen(priceCfg.area) > 0) {
109+
uint8_t s;
110+
int16_t err = ctx.ps == NULL ? 0 : ctx.ps->getLastError();
111+
if(ctx.ps == NULL) {
112+
s = 2;
113+
} else if(err != 0) {
114+
s = 3;
115+
} else if(ctx.ps->hasPrice()) {
116+
s = 1;
117+
} else {
118+
s = 2;
119+
}
120+
appendServiceEntry(out, "price", s, err, priceCfg.area, NULL);
121+
}
122+
123+
{
124+
NtpConfig ntp;
125+
if(ctx.config->getNtpConfig(ntp) && ntp.enable) {
126+
const char* server = strlen(ntp.server) > 0 ? ntp.server : "pool.ntp.org";
127+
// A set-but-stale clock (NTP stopped resyncing) silently corrupts
128+
// day-boundary accounting, so flag staleness rather than only
129+
// reporting whether the clock was ever set.
130+
uint64_t lastSync = ntpLastSyncMillis();
131+
uint8_t s;
132+
if(lastSync == 0) {
133+
s = 2; // No SNTP sync since boot yet
134+
} else {
135+
uint32_t ageSec = (uint32_t) ((millis64() - lastSync) / 1000);
136+
s = ageSec > NTP_STALE_AFTER_SECONDS ? 2 : 1;
137+
}
138+
appendServiceEntry(out, "ntp", s, 0, server, NULL);
139+
}
140+
}
141+
142+
#if defined(AMS_CLOUD)
143+
{
144+
CloudConfig cc;
145+
if(ctx.config->getCloudConfig(cc) && cc.enabled) {
146+
uint8_t s;
147+
int16_t err = ctx.cloud == NULL ? 0 : ctx.cloud->getLastError();
148+
if(ctx.cloud == NULL || !ctx.cloud->isInitialized()) {
149+
s = 2;
150+
} else {
151+
unsigned long since = millis() - ctx.cloud->getLastUpdate();
152+
uint32_t maxAge = ((uint32_t) cc.interval) * 3000;
153+
s = (ctx.cloud->getLastUpdate() > 0 && since > maxAge) ? 3 : 1;
154+
}
155+
appendServiceEntry(out, "cloud", s, err, cc.hostname, NULL);
156+
}
157+
}
158+
#endif
159+
160+
#if defined(ZMART_CHARGE)
161+
{
162+
ZmartChargeConfig zc;
163+
if(ctx.config->getZmartChargeConfig(zc) && zc.enabled) {
164+
uint8_t s;
165+
int16_t err = ctx.zcloud == NULL ? 0 : ctx.zcloud->getLastError();
166+
if(ctx.zcloud == NULL || ctx.zcloud->getLastUpdate() == 0) {
167+
s = 2;
168+
} else {
169+
s = ctx.zcloud->isLastFailed() ? 3 : 1;
170+
}
171+
appendServiceEntry(out, "zc", s, err, zc.baseUrl, NULL);
172+
}
173+
}
174+
#endif
175+
176+
return out;
177+
}
11178

12179
void AmsJsonGenerator::generateDayPlotJson(AmsDataStorage* ds, char* buf, size_t bufSize) {
13180
uint16_t pos = snprintf_P(buf, bufSize, PSTR("{\"unit\":\"kwh\""));

src/AmsJsonGenerator.h

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,38 @@ class BufferJsonSink : public JsonSink {
5050
bool overflowed_ = false;
5151
};
5252

53+
class AmsData;
54+
class PriceService;
55+
class AmsMqttHandler;
56+
#if defined(AMS_CLOUD)
57+
class CloudConnector;
58+
#endif
59+
#if defined(ZMART_CHARGE)
60+
class ZmartChargeCloudConnector;
61+
#endif
62+
63+
/**
64+
* Everything the services array reports on. Both the web UI and the MQTT
65+
* announcements render from this, so the two cannot report different state for
66+
* the same device. Callers fill in whatever they have; NULL members are reported
67+
* as not-yet-connected rather than skipped.
68+
*/
69+
struct ServiceStatusContext {
70+
AmsConfiguration* config = NULL;
71+
AmsData* meterState = NULL;
72+
PriceService* ps = NULL;
73+
AmsMqttHandler* mqttHandler = NULL;
74+
bool mqttEnabled = false;
75+
AmsMqttHandler* customMqttHandler = NULL;
76+
AmsMqttHandler* energySpeedometer = NULL;
77+
#if defined(AMS_CLOUD)
78+
CloudConnector* cloud = NULL;
79+
#endif
80+
#if defined(ZMART_CHARGE)
81+
ZmartChargeCloudConnector* zcloud = NULL;
82+
#endif
83+
};
84+
5385
class AmsJsonGenerator {
5486
public:
5587
static void generateDayPlotJson(AmsDataStorage* ds, char* buf, size_t bufSize);
@@ -58,4 +90,16 @@ class AmsJsonGenerator {
5890
static void generateConfigurationJson(AmsConfiguration* config, JsonSink& sink);
5991
// Convenience wrapper for callers that need the whole document in one buffer.
6092
static void generateConfigurationJson(AmsConfiguration* config, char* buf, size_t bufSize);
93+
94+
// Four-state health of a single service: 0 disabled, 1 ok, 2 connecting, 3 error.
95+
static uint8_t hanState(AmsData* meterState);
96+
static uint8_t mqttHandlerState(AmsMqttHandler* h);
97+
98+
// The contents of the services array, without the enclosing brackets, since
99+
// both callers embed it in a larger document.
100+
//
101+
// Returns a String rather than streaming to a JsonSink: the array is small,
102+
// both call sites need it as one contiguous value, and a fixed buffer would
103+
// risk truncating to invalid JSON when hostnames are long.
104+
static String generateServicesJson(const ServiceStatusContext& ctx);
61105
};

0 commit comments

Comments
 (0)