feat: add --debug tracing to hendi_ctrl_app.py, document v1.16 fix
HendiCtrl.cmd() previously raised a bare "Communication error" for any non-OK response, discarding the firmware's actual reply. It now includes the raw echo/answer bytes, and --debug prints every request/response. Using this, the rejected R1 during lockout turned out to be an explicit "ERR:Invalid state" reply, not a timeout. Also flashed and retested v1.16: plain reconnect (no --reset) now recovers from the lockout reliably (4/4 trials), fixing the intermittent behavior seen on v1.15. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GpePKZiEZWbGo9HrfuML6U
This commit is contained in:
@@ -10,7 +10,8 @@ class HendiException(Exception):
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
class HendiCtrl:
|
class HendiCtrl:
|
||||||
def __init__(self, port, speed):
|
def __init__(self, port, speed, debug=False):
|
||||||
|
self.debug = debug
|
||||||
self.ser = serial.Serial(port, speed)
|
self.ser = serial.Serial(port, speed)
|
||||||
try:
|
try:
|
||||||
self.ser.open()
|
self.ser.open()
|
||||||
@@ -102,6 +103,8 @@ class HendiCtrl:
|
|||||||
def __write(self, s):
|
def __write(self, s):
|
||||||
self.ser.flushInput()
|
self.ser.flushInput()
|
||||||
data = s.encode()
|
data = s.encode()
|
||||||
|
if self.debug:
|
||||||
|
print(f"HendiCtrl: -> {data!r}")
|
||||||
self.ser.write(data + b'\r')
|
self.ser.write(data + b'\r')
|
||||||
|
|
||||||
def __read(self):
|
def __read(self):
|
||||||
@@ -109,21 +112,24 @@ class HendiCtrl:
|
|||||||
echo = self.ser.readline()
|
echo = self.ser.readline()
|
||||||
|
|
||||||
# Read answer line
|
# 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:
|
if ':' in answer:
|
||||||
result = answer.split(':')
|
result = answer.split(':')
|
||||||
else:
|
else:
|
||||||
result = answer
|
result = answer
|
||||||
|
|
||||||
return result
|
return result, echo, answer_raw
|
||||||
|
|
||||||
def cmd(self, req):
|
def cmd(self, req):
|
||||||
self.__write(req)
|
self.__write(req)
|
||||||
rsp = self.__read()
|
rsp, echo, answer_raw = self.__read()
|
||||||
if len(rsp) == 0:
|
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]:
|
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]
|
return rsp[1]
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
# Hendi remote-control lockout findings
|
# Hendi remote-control lockout findings
|
||||||
|
|
||||||
Reproduced and verified on real hardware using `scripts/hendi_ctrl_app.py`'s
|
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
|
firmware versions (`getSoftwareVersion()` / `I?`+`V?` via
|
||||||
`components/actor/hendiCtrl.py`).
|
`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
|
matching `remoteEnable(False)` (`R0`) - i.e. an ungraceful disconnect while
|
||||||
remote control is enabled - puts the device into a locked-out state.
|
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
|
v1.14 flashed via `--update-firmware HendiCtrl_0114.srec`; v1.15 via
|
||||||
`HendiCtrl_0115.srec`. `getSoftwareVersion()` confirmed each version after
|
`HendiCtrl_0115.srec`; v1.16 via `HendiCtrl_0116.srec`. `getSoftwareVersion()`
|
||||||
flashing.
|
confirmed each version after flashing.
|
||||||
|
|
||||||
| Recovery method | Mechanism | v1.12 (original) | v1.14 (firmware fix) | v1.15 (firmware fix) |
|
| 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 |
|
| 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 |
|
| `--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) |
|
| 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
|
## Symptom while locked out
|
||||||
|
|
||||||
- `I?` / `V?` (software identifier/version queries) still respond normally.
|
- `I?` / `V?` (software identifier/version queries) still respond normally.
|
||||||
- `R1` (`remoteEnable(True)`) fails with a communication error - this is the
|
- `R1` (`remoteEnable(True)`) is the only rejected command; it blocks any
|
||||||
only observed symptom, and it blocks any command path that needs remote
|
command path that needs remote control enabled (e.g. `--test-heater`).
|
||||||
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
|
## 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,
|
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,
|
it should be treated as a remaining bug/race condition rather than a fix,
|
||||||
and `--reset` is still the recommended recovery path.
|
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.
|
||||||
|
|||||||
@@ -67,6 +67,7 @@ def main():
|
|||||||
parser = ap.ArgumentParser(description=__doc__)
|
parser = ap.ArgumentParser(description=__doc__)
|
||||||
parser.add_argument("--serial-port", default=DEFAULT_PORT)
|
parser.add_argument("--serial-port", default=DEFAULT_PORT)
|
||||||
parser.add_argument("--baudrate", type=int, default=DEFAULT_BAUDRATE)
|
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("--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("--version", action="store_true", help="print software identifier/version")
|
||||||
parser.add_argument("--capabilities", action="store_true", help="print power/digit capability info")
|
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")
|
parser.add_argument("--update-firmware", metavar="FILE", help="flash the given firmware file")
|
||||||
args = parser.parse_args()
|
args = parser.parse_args()
|
||||||
|
|
||||||
hendi = HendiCtrl(args.serial_port, args.baudrate)
|
hendi = HendiCtrl(args.serial_port, args.baudrate, debug=args.debug)
|
||||||
|
|
||||||
if args.reset:
|
if args.reset:
|
||||||
reset(hendi)
|
reset(hendi)
|
||||||
|
|||||||
Reference in New Issue
Block a user