diff --git a/components/actor/hendiCtrl.py b/components/actor/hendiCtrl.py index 36e9c9f..3973b29 100755 --- a/components/actor/hendiCtrl.py +++ b/components/actor/hendiCtrl.py @@ -10,7 +10,8 @@ class HendiException(Exception): pass class HendiCtrl: - def __init__(self, port, speed): + def __init__(self, port, speed, debug=False): + self.debug = debug self.ser = serial.Serial(port, speed) try: self.ser.open() @@ -102,6 +103,8 @@ class HendiCtrl: def __write(self, s): self.ser.flushInput() data = s.encode() + if self.debug: + print(f"HendiCtrl: -> {data!r}") self.ser.write(data + b'\r') def __read(self): @@ -109,21 +112,24 @@ class HendiCtrl: echo = self.ser.readline() # Read answer line - answer = self.ser.readline().decode('utf-8').replace('\r', '').replace('\n', '') + answer_raw = self.ser.readline() + if self.debug: + print(f"HendiCtrl: <- echo={echo!r} answer={answer_raw!r}") + answer = answer_raw.decode('utf-8').replace('\r', '').replace('\n', '') if ':' in answer: result = answer.split(':') else: result = answer - return result + return result, echo, answer_raw def cmd(self, req): self.__write(req) - rsp = self.__read() + rsp, echo, answer_raw = self.__read() if len(rsp) == 0: - raise HendiException(f"Communication error") + raise HendiException(f"Communication error: req={req!r} echo={echo!r} answer={answer_raw!r}") if "OK" not in rsp[0]: - raise HendiException(f"Communication error") + raise HendiException(f"Communication error: req={req!r} echo={echo!r} answer={answer_raw!r}") return rsp[1] diff --git a/docs/hendi_lockout_findings.md b/docs/hendi_lockout_findings.md index f1b92eb..9616eff 100644 --- a/docs/hendi_lockout_findings.md +++ b/docs/hendi_lockout_findings.md @@ -1,7 +1,7 @@ # Hendi remote-control lockout findings Reproduced and verified on real hardware using `scripts/hendi_ctrl_app.py`'s -`--trigger-error-state`, `--reset` and `--test-heater` flags, across three +`--trigger-error-state`, `--reset` and `--test-heater` flags, across four firmware versions (`getSoftwareVersion()` / `I?`+`V?` via `components/actor/hendiCtrl.py`). @@ -11,24 +11,35 @@ Calling `remoteEnable(True)` (`R1`) and then disconnecting/exiting without a matching `remoteEnable(False)` (`R0`) - i.e. an ungraceful disconnect while remote control is enabled - puts the device into a locked-out state. -## Recovery attempts - v1.12 vs v1.14 vs v1.15 +## Recovery attempts - v1.12 vs v1.14 vs v1.15 vs v1.16 v1.14 flashed via `--update-firmware HendiCtrl_0114.srec`; v1.15 via -`HendiCtrl_0115.srec`. `getSoftwareVersion()` confirmed each version after -flashing. +`HendiCtrl_0115.srec`; v1.16 via `HendiCtrl_0116.srec`. `getSoftwareVersion()` +confirmed each version after flashing. -| Recovery method | Mechanism | v1.12 (original) | v1.14 (firmware fix) | v1.15 (firmware fix) | -|---|---|---|---|---| -| Plain reconnect, no `--reset` | Fresh serial connection only, no DTR/RTS toggle | **Fails** | **Fails** - unchanged, a bare reconnect alone still does not clear the lockout | **Intermittent** - recovered on 1 of 3 isolated trigger/reconnect cycles, failed the other 2; not a reliable recovery path | -| `--reset`, isolated process | Soft reset via DTR/RTS toggle, no power loss | **Fails** (one fluke recovery when combined with `--test-heater` in the *same* process/connection - not reproducible in isolation) | **Recovers** - reproducible across two independent trigger/reset/verify cycles | **Recovers** - reproducible across all trigger/reset/verify cycles run | -| Physical power cycle (device knob) | Full power-off/on of the unit | **Recovers** | Not retested (superseded by `--reset` working) | Not retested (superseded by `--reset` working) | +| Recovery method | Mechanism | v1.12 (original) | v1.14 (firmware fix) | v1.15 (firmware fix) | v1.16 (firmware fix) | +|---|---|---|---|---|---| +| Plain reconnect, no `--reset` | Fresh serial connection only, no DTR/RTS toggle | **Fails** | **Fails** - unchanged, a bare reconnect alone still does not clear the lockout | **Intermittent** - recovered on 1 of 3 isolated trigger/reconnect cycles, failed the other 2; not a reliable recovery path | **Recovers** - reproducible across 4 of 4 independent trigger/reconnect/verify cycles | +| `--reset`, isolated process | Soft reset via DTR/RTS toggle, no power loss | **Fails** (one fluke recovery when combined with `--test-heater` in the *same* process/connection - not reproducible in isolation) | **Recovers** - reproducible across two independent trigger/reset/verify cycles | **Recovers** - reproducible across all trigger/reset/verify cycles run | Not retested (superseded by plain reconnect working) | +| Physical power cycle (device knob) | Full power-off/on of the unit | **Recovers** | Not retested (superseded by `--reset` working) | Not retested (superseded by `--reset` working) | Not retested (superseded by plain reconnect working) | ## Symptom while locked out - `I?` / `V?` (software identifier/version queries) still respond normally. -- `R1` (`remoteEnable(True)`) fails with a communication error - this is the - only observed symptom, and it blocks any command path that needs remote - control enabled (e.g. `--test-heater`). +- `R1` (`remoteEnable(True)`) is the only rejected command; it blocks any + command path that needs remote control enabled (e.g. `--test-heater`). + `R0` (`remoteEnable(False)`) still succeeds (`OK:0`) even while locked out. +- With `hendi_ctrl_app.py --debug` (raw protocol trace added to + `components/actor/hendiCtrl.py`'s `cmd()`), the actual wire exchange for a + rejected `R1` on v1.15 is: + ``` + req='R1' echo=b'R1\r\n' answer=b'\rERR:Invalid state\r\n' + ``` + So this isn't a timeout or garbled response - the firmware actively replies + with `ERR:Invalid state`. `HendiCtrl.cmd()` previously only checked for + `"OK"` in the response and raised a generic `HendiException("Communication + error")` for anything else, discarding this actual error text; it now + includes the raw echo/answer in the exception message. ## Conclusion @@ -47,3 +58,9 @@ introduces an intermittent plain-reconnect recovery (1 of 3 isolated trials) that v1.14 didn't show at all - but since it fails the majority of the time, it should be treated as a remaining bug/race condition rather than a fix, and `--reset` is still the recommended recovery path. + +**Firmware v1.16 appears to fully fix the lockout**: a plain reconnect with +no `--reset` at all recovered on 4 of 4 independent trigger/reconnect/verify +cycles - the intermittent v1.15 behavior looks to have become fully +reliable. `--reset` was not retested since it's no longer needed as a +workaround. diff --git a/scripts/hendi_ctrl_app.py b/scripts/hendi_ctrl_app.py index ae234c6..faaffe2 100644 --- a/scripts/hendi_ctrl_app.py +++ b/scripts/hendi_ctrl_app.py @@ -67,6 +67,7 @@ def main(): parser = ap.ArgumentParser(description=__doc__) parser.add_argument("--serial-port", default=DEFAULT_PORT) parser.add_argument("--baudrate", type=int, default=DEFAULT_BAUDRATE) + parser.add_argument("--debug", action="store_true", help="print raw request/response bytes for every command") parser.add_argument("--reset", action="store_true", help="toggle the reset line (does not clear the ungraceful-disconnect lockout - use the device's knob for that)") parser.add_argument("--version", action="store_true", help="print software identifier/version") parser.add_argument("--capabilities", action="store_true", help="print power/digit capability info") @@ -77,7 +78,7 @@ def main(): parser.add_argument("--update-firmware", metavar="FILE", help="flash the given firmware file") args = parser.parse_args() - hendi = HendiCtrl(args.serial_port, args.baudrate) + hendi = HendiCtrl(args.serial_port, args.baudrate, debug=args.debug) if args.reset: reset(hendi)