Skip to content

Report decryption failure instead of "No HAN data received", and HAN as a service - #1241

Merged
gskjold merged 2 commits into
mainfrom
fix/decrypt-error-not-no-data
Aug 27, 2026
Merged

Report decryption failure instead of "No HAN data received", and HAN as a service#1241
gskjold merged 2 commits into
mainfrom
fix/decrypt-error-not-no-data

Conversation

@gskjold

@gskjold gskjold commented Aug 27, 2026

Copy link
Copy Markdown
Member

Fixes #1245

Problem

A support case reported "No HAN data received last 30s" filling the verbose telnet log while the meter was in fact transmitting perfectly. The attached log showed 21 consecutive GCM frames, invocation counter 0xA680 → 0xA694 with every delta exactly 1, roughly 10 s apart — and 34 "no HAN data" warnings in the same window. The real fault was a wrong encryption key. The misleading error sent the diagnosis toward HAN port, cable and hardware for a month.

Two independent defects produce this.

1. The watchdog is fed by successful decode, not by frame reception

errorBlink() tests lastErrorBlink - meterState.getLastUpdateMillis() > 30000. lastUpdateMillis is only ever assigned inside the successful-decode paths (IEC6205675.cpp, LNG.cpp, LNG2.cpp). If decryption or parsing fails it stays 0 forever, so the condition is true from 30 s uptime onward and fires on the 3 s errorBlink cadence. The red LED gives the single blink that means "no data" while frames arrive every 10 s.

2. A wrong encryption key is silently undetectable without an authentication key

The frames carried SC = 0x30, so the authentication bit is set. But GCMParser only verifies the GCM tag when the configured authentication key is non-blank. With a blank key the ESP32 path takes the mbedtls_gcm_starts/update branch — no auth_decrypt, no tag check. A wrong key therefore decrypts to noise, and unwrapData hits its default: case with a different random tag every frame (D5, 75, 90, 6F, 3E, D8, 04, …). That was reported as DATA_PARSE_UNKNOWN_DATA"Unknown data received, check meter config", which points at the meter instead of the key.

Blanking the authentication key is standard advice for meters that don't use SC+AK authentication, so this path is commonly taken.

Commit 1 — report decryption failure instead of "No HAN data received"

  • MeterCommunicator gains getLastFrameMillis(), defaulting to 0 (unsupported).
  • PassiveMeterCommunicator records the uptime of the last checksum-verified HDLC or MBUS frame — a verified link layer frame proves the meter is transmitting, regardless of what happens to the payload.
  • errorBlink() case 0 now distinguishes the two situations. Silence on the HAN port keeps METER_ERROR_NO_DATA (90) and the existing message. Frames arriving but nothing decoding logs "HAN frames received, but no data could be decoded" and falls back to METER_ERROR_UNKNOWN_DATA (89) only if no more specific error is already set. The red single blink is unchanged in both cases — it is still an error.
  • New parser error code GCM_DECRYPT_UNVERIFIED (-54), returned by unwrapData when garbage appears immediately after the GCM layer and no authentication key is configured. It stays distinct from GCM_DECRYPT_FAILED (-52), which GCMParser also returns for malformed frames, so the new code specifically means "the key is what you should check". With an authentication key set the tag was verified, the plaintext is genuine, and an unrecognised payload really is unknown data — -9 is kept for that.
  • errors.han["-54"] added: "Decryption failed and could not be verified, check encryption key". It flows through the existing error table lookup, so the header line and the Services tile both pick it up with no new UI mechanism.

Effect on the reported scenario

Before:

(E) Ended up in default case while unwrapping...(tag D5)
(W) Unknown data received
(W) No HAN data received last 30s, single blink

Header: HAN: Unknown data received, check meter config

After:

(E) Ended up in default case while unwrapping...(tag D5)
(W) Decryption failed, no authentication key to verify with
(W) HAN frames received, but no data could be decoded, single blink

Header: HAN: Decryption failed and could not be verified, check encryption key

Commit 2 — HAN as a service

There were two generations of error reporting. The older one is a hardcoded slot per domain — data.he/me/ee plus status bytes, rendered by three copy-pasted bd-red divs in Header.svelte. The newer one is the generic sysinfo.services[] of {k, s, e, d, n}, built by buildServicesJson(), rendered by ServicesTile.svelte, aggregated into data.sa. MQTT and price report through both. HAN was the last domain with only the older path.

  • HAN now emits a service entry, {"k":"han","s":…,"e":…,"d":"<meter model>"}, picking up the four-state colouring, the detail field and the Services badge aggregate. The older he/hm fields are untouched, so Header.svelte and the cloud payload keep working unchanged.
  • hanState() replaces the status logic that was inline in dataJson(), now shared by the data feed, the services array and the aggregate.
  • UI side is two lines: han added to defaultLabels and to errorNamespace in ServicesTile.svelte. No new rendering mechanism.

Behaviour notes

  • hanState() returns connecting rather than disabled while waiting for the first frame after boot. State 0 renders as "Disabled" and the HAN port is never disabled. Visible effect: the header HAN badge is yellow instead of gray for at most 30 s post-boot, and sysinfo.booting already forces gray over most of that window.
  • The Services badge now also turns red on a HAN error, since HAN counts toward the aggregate.

Translations

localazy/source/en.json gains errors.han["-54"] and status.services.han. Everything else reuses existing strings.

Verification

  • pio test -e native — 17/17 pass, golden master unchanged.
  • cd ui && npm run build — clean.
  • pio run -e esp32 — SUCCESS, flash 71.7%.
  • Not exercised on hardware: the end-to-end check with a deliberately wrong encryption key needs a real meter.

🤖 Generated with Claude Code

@gskjold
gskjold force-pushed the fix/decrypt-error-not-no-data branch from e94ab16 to 7058e38 Compare August 27, 2026 07:33
@gskjold gskjold changed the title Report decryption failure instead of "No HAN data received" Report decryption failure instead of "No HAN data received", and HAN as a service Aug 27, 2026
A wrong encryption key made the device report "No HAN data received last
30s" while the meter was transmitting normally every 10 seconds. Two
separate causes:

The "no HAN data" watchdog is fed by getLastUpdateMillis(), which is only
assigned by the successful-decode paths. When decryption fails it stays 0
forever, so the check fires from 30s uptime onward on the errorBlink
cadence. PassiveMeterCommunicator now records the uptime of the last
checksum-verified HDLC/MBUS frame, and errorBlink distinguishes silence on
the HAN port from frames that arrive but cannot be decoded.

When no authentication key is configured, GCMParser decrypts without
verifying the GCM tag, so a wrong encryption key yields garbage plaintext
and unwrapData hits its default case with a different random tag every
frame. That was reported as DATA_PARSE_UNKNOWN_DATA ("Unknown data
received, check meter config"), pointing at the meter rather than the key.
It now returns the new GCM_DECRYPT_UNVERIFIED, which names the encryption
key as the thing to check and stays distinct from GCM_DECRYPT_FAILED, which
GCMParser also returns for malformed frames. With an authentication key set
the tag is verified, so the plaintext is genuine and the unknown-data
result is kept.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@gskjold
gskjold force-pushed the fix/decrypt-error-not-no-data branch from 7058e38 to 3a6dec1 Compare August 27, 2026 09:53
HAN was the last domain reporting only through the older per-domain error
slots (data.he / data.hm), which allow one code per domain and carry no
detail. It now also appears in the generic services array, so it gets the
four-state colouring, the detail field and the Services badge aggregate for
free. The older he/hm fields are untouched, so the header line and the cloud
payload keep working unchanged.

hanState() replaces the status logic that was inline in dataJson, and is now
shared by the data feed, the services array and the aggregate. Its one
behaviour change: waiting for the first frame after boot reports connecting
rather than disabled, since state 0 renders as "Disabled" and the HAN port
is never disabled.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@gskjold
gskjold force-pushed the fix/decrypt-error-not-no-data branch from 3a6dec1 to 95e45c8 Compare August 27, 2026 10:00
@github-actions

Copy link
Copy Markdown

🔧 PR Build Artifacts

Version: 95e45c8

All environments built successfully. Download the zip files:

Artifacts expire after 7 days. View workflow run

@gskjold
gskjold merged commit 466ffc3 into main Aug 27, 2026
8 checks passed
@gskjold
gskjold deleted the fix/decrypt-error-not-no-data branch August 27, 2026 10:14
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Wrong encryption key reported as "No HAN data received last 30s"

1 participant