Commit Graph
1 Commits
Author SHA1 Message Date
gemuandGitHub f290dd99c9 sml updates (#24716)
# SML driver: catch-up of accumulated fork changes

This PR rolls up the SML driver work that's accumulated on a downstream fork
over the last ~2 years. **One file** (`tasmota/tasmota_xsns_sensor/xsns_53_sml.ino`,
+288 / −27), squashed for review. Three logical groups, each with its own
opt-out path so existing builds get bit-for-bit-identical behaviour where
nothing actually changed.

## TL;DR

| Group | What | Gating | Default |
|---|---|---|---|
| **A** — bug fixes | 10 narrow correctness fixes | always-on | enabled |
| **B** — scripter-free operation | drive descriptor from `/sml_meter.def` without the Tasmota Scripter | existing `#ifdef USE_SCRIPT` / `USE_UFILESYS` guards | inherits build config |
| **C** — `USE_BAT_CTRL` (Modbus write queue, TCP-reset cleanup, dynamic MBAP SIZE) | new feature for Modbus-write-heavy descriptors | new `#ifdef USE_BAT_CTRL` | ESP32 ON, ESP8266 OFF |

If you want to merge only Group A, the gate-out for B and C is a 2-line
revert (drop the `USE_BAT_CTRL` auto-define and the missing-`USE_SCRIPT`
fallbacks). Happy to split into 3 separate PRs if that's preferred.

## Group A — bug fixes (always-on)

Each is a documented repro on a real or emulated meter; failure modes
listed are observed not theoretical.

### A1. FC-aware Modbus RTU response framing
`mlen = sbuff[2] + 5` was used for ALL RTU responses. Correct for FC01-04
reads but wrong for FC05/06/15/16 write echoes (fixed 8 B with `reg_hi`
in `sbuff[2]`) and exception replies (`FC|0x80`, fixed 5 B). Mixing reads
with writes corrupted receive-buffer alignment for one cycle after each
write. Now switches on the FC byte. Inline comment block documents all
four framing cases.

### A2. `@bN:iM:` bit-extract dropped when combined with mbus index filter
The `b` block updated `ebus_dval` only; the `@i` path immediately below
took `dval = mbus_dval`, so the bit was thrown away. Visible on FC01
coil descriptors that fan one response byte across 8 per-coil JSON
fields — all read 17 (raw byte) instead of 1/0/0/1. One added line:
`mbus_dval = ebus_dval;` inside the `b` block.

### A3. Heap corruption when loading descriptor from `/sml_meter.def`
Without scripter, `lp1` points into `file_md` whose lines end with
`SCRIPT_EOL`, not `'\0'`. The descriptor copy loop only checked `'\0'`,
so it ran past line end and clobbered adjacent heap (observed reliably
with 30+-row meter defs on SD card). Added `*lp1 == SCRIPT_EOL` to the
loop terminator.

### A4. `SML_SRCBSIZE` 256 → 512
With 30+ register requests per meter, the `+1` line easily exceeds
256 chars. Pre-fix, line-buffer overran → parser saw mid-line content
as new line → `maxvars=0` → "sml memory error".

### A5. Reverted PR #24587's early-bail in `SML_Immediate_MQTT`
PR #24587 added `if (!sml_globs.dvalid[index]) return;` at the top of
`SML_Immediate_MQTT`. Three reasons it was wrong:
- the math `@`-chain branch sets `dvalid` AFTER calling
  `SML_Immediate_MQTT` → first emission lost on every meter
- the Modbus/eBus/PZEM/VBus/raw value branch never sets `dvalid` in
  this code path at all → those meters lost ALL immediate-MQTT
  emissions
- encrypted SML decoders feed `SML_Decode` → match fires →
  `SML_Immediate_MQTT`, but encrypted descriptors don't hit the
  same `dvalid`-set sites that PR #24587 assumed

`SML_Immediate_MQTT` is by definition called immediately after a fresh
value has been parsed, so the gate adds no safety and breaks legitimate
use. The gate in `SML_Show` (TelePeriod JSON path) is correct and is
**preserved** — that one needs to suppress slots that haven't seen
data yet because it fires regardless of whether new data arrived.

### A6. OBIS literal-pattern `/n` and `/r` escape support
OBIS descriptors that match line terminators (CR/LF) had no way to
express them. Now `/n` → `\n` and `/r` → `\r` in the literal-byte
match block (only for type `o`/`c`).

### A7. ESP32 hardware-serial validation
Reject configurations where `rx_pin == trx_pin`, fall back to software
serial when `srcpin < 0`, and log which path was taken. Pre-fix, an
accidental same-pin config would silently fail at `Serial.begin()` and
the meter would just stay quiet.

### A8. Sanitised log messages
Several `AddLog` lines didn't have an `SML:` prefix → meter errors got
lost in the general log stream. Now consistent.

### A9. Math evaluation moved to its own 1-second timer
Pre-fix used `if (*mp == 'm' && !sb_counter)` — relied on a global byte
counter being zero, which it usually isn't. Math expressions in
descriptors fired sporadically. Now `lastmath` global + `math_run` flag
set once per second at top of `SML_Decode`. Math runs exactly once per
second per pattern, deterministically.

### A10. `sb_counter` overflow protection
Reset to 0 when > 10. Was used by the old math gate (#A9); could grow
unbounded across long runtime. Harmless but cleaner with a cap.

## Group B — scripter-free operation

The driver historically required the Tasmota Scripter for descriptor
parsing. With `USE_UFILESYS`, a standalone `/sml_meter.def` file can
now drive the whole pipeline — useful for builds that ship without
scripter.

Changes are guarded so existing `USE_SCRIPT` builds get identical
behaviour:
- `SML_REPLACE_VARS` now requires `USE_SCRIPT` (var substitution only
  makes sense with scripter)
- `SML_Init`: file-md path is the fallback when `meter_script != 99`,
  OR the only path when `USE_SCRIPT` is undefined
- `SML_getlinelen` moved out of `#ifdef SML_REPLACE_VARS` so the
  non-scripter path can use it
- Removed `#ifdef USE_SCRIPT` wrapper around `SML_Send_Seq` and
  `SML_Check_Send` (TX dispatch is universal)
- Provide `SCRIPT_EOL` fallback (= `'\n'`) when scripter isn't
  compiled (was an exported symbol from `xdrv_10_scripter.ino`)
- `SML_Init` robustness: filesystem availability check before `open()`,
  `special_malloc` return check with clear log instead of crash,
  null-terminate file content after read, close file handle on error
  paths, prefix all error messages with `SML:`

## Group C — `USE_BAT_CTRL` (ESP32 only)

Originally added for an SMA SunnyBoy battery-control descriptor where
the coexistence of FC03 reads, FC16 writes, and a tight TCP-session
policy stressed the driver. The underlying fixes are useful for any
Modbus-write-heavy descriptor, so I'm exposing them as a feature flag
rather than burying them. Auto-defined for ESP32 (off for ESP8266
where there's no TCP meter use case worth supporting); a one-line
override in `user_config_override.h` opts out.

### C1. Two-slot write queue
`SML_Write()` for type `m`/`M`/`k` now queues into `sml_write_buf[0..1]`
instead of calling `SML_Send_Seq` directly. `SML_Check_Send()` picks up
queued writes between read requests. Prevents read/write collision when
an MQTT command arrives mid-cycle. OBIS (`o`) and other timing-critical
types bypass the queue — IEC 62056-21 mode-A handshake needs deterministic
timing that a 100-ms queue hop would break.

### C2. Meter-switch cooldown (500 ms)
When `sml_desc_cnt` advances to a different meter, hold off transmission
for 5 × 100 ms. Several Modbus slaves (notably SMA-class inverters) need
~200-500 ms between session-end and next-session-start; without it,
back-to-back requests land on a slave still tearing down the previous
response and return CRC errors.

### C3. Modbus-TCP MBAP `SIZE` field made dynamic
Pre-fix, `tcph.SIZE = sml_swap(6)` was hardcoded for FC03 reads (PDU =
6 bytes: `addr + fc + reg_hi + reg_lo + cnt_hi + cnt_lo`). FC16 multi-
register write has a longer PDU (7 + 2*N bytes, up to 26+), and TCP
servers reject frames whose MBAP header `SIZE` doesn't match the actual
PDU length. Now: `sml_swap(slen - 2)`. Also bumped
`MODBUS_TCP_HEADER.payload[8]` → `[48]` for the larger PDU.

### C4. `SML_Clean_Meters()` — force TCP RST before restart
Wired into `FUNC_SAVE_BEFORE_RESTART`. Necessary for slaves that allow
only one TCP session (notably SMA Tripower 10.0SE): without forcing RST,
the slave's session table holds the dead connection in `ESTABLISHED`
state for several minutes after our reboot, rejecting reconnects from
the rebooted ESP. Uses `SO_LINGER {1, 0}` to send RST instead of FIN.
ESP32 only — ESP8266's `WiFiClient` has no `setSocketOption()`; plain
`stop()` is the best it can do there.

### C5. `reset_sml_vars()` forces TCP cleanup on script reload
Same `SO_LINGER` trick as C4 — pre-fix, a script reload would leak the
active TCP connection until natural keepalive timeout (often >30 s).

### C6. Cleanup of write-queue state on script reload
`head`/`tail`/`buf` reset so a re-init can't pick up stale queued writes.

## Footprint

```
diff --stat:
  tasmota/tasmota_xsns_sensor/xsns_53_sml.ino | 315 ++++++++++++++++++++++---
  1 file changed, 288 insertions(+), 27 deletions(-)
```

No new files, no new headers, no new dependencies, no other files touched.

## Validation

- ESP32-S3 devkit + emulated Modbus slave covering FC01/FC02/FC03/FC04/
  FC05/FC15. Every fix in Group A has a documented repro that passes
  after the change.
- Group C has been in production on the fork's SMA installations for
  ~18 months across ~50 devices.

## For the maintainer

If you'd prefer 3 PRs (one per group), I can split this without further
work — the commits are already organised that way locally. Let me know.

🤖 Generated with [Claude Code](https://claude.com/claude-code)
2026-05-07 09:01:22 +02:00