Skip to content

Commit 51082cc

Browse files
authored
fix(upgrade): correct appendData params to {"length": N} (hardware-validated) (#3)
* fix(upgrade): correct appendData params to {"length": N} (hardware-validated) Reversed from the hunter CUpgradeService::appendData handler and confirmed by a live UART-free flash on a GK7205V510: appendData expects params {"length": N} with an N-byte binary payload (the handler checks params.length == actual binary length), NOT {"Offset","Length"} — which returned 400 "param error". prepare ({"Type":"System"}) and execute params are ignored. State machine: prepare(0->2) -> appendData(2/4, repeatable) -> execute(4->0), with a ~60s idle timeout. With this, DahuaClient.upgrade_firmware() successfully flashed an OpenIPC package over DHIP end-to-end (device rebooted into the new firmware). const comment updated to record the byte-proven param shape. * review: refresh upgrade docs to validated state; assert appendData params in test Addresses Qodo review on #3: - upgrade_firmware docstring + confirm ValueError no longer claim the path is byte-unproven / never run on hardware (it is validated end-to-end on a GK7205V510); docstring now states the {"length":N}+binary contract and the prepare/appendData/execute state machine. - test_upgrade_streams_file_in_chunks now records each appendData params and the real trailing-binary length and asserts params == {"length": N} with N equal to the payload for every chunk. FakeDHIPServer exposes the binary payload (__data__) so handlers can verify it. A revert to {"Offset","Length"} now fails the test (previously it passed). * review: README upgrade status reflects hardware validation Finding 1 (Qodo) also pointed at README: the API table said 'mock-validated only' and the device-support section said upgrade_firmware is 'intentionally never run against a device' — both now contradicted the validated flow. Moved firmware upgrade into 'Verified on hardware' (flashed OpenIPC on a GK7205V510 end-to-end), kept the destructive/confirm=True warning.
1 parent 2d25671 commit 51082cc

5 files changed

Lines changed: 43 additions & 21 deletions

File tree

‎README.md‎

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -156,7 +156,7 @@ dhip 10.0.0.10 -u admin -P admin54321 -m configManager.getConfig --params '{"nam
156156
| `get_channel_titles` / `set_channel_title(text, ch)` | `configManager` `ChannelTitle` | OSD title overlay |
157157
| `get_osd(ch)` / `set_osd(data, ch)` | `configManager` `VideoWidget` | OSD overlay layout/covers |
158158
| `find_files(start, end, ch)` | `mediaFileFind.*` | list recordings (returns `FilePath`…) |
159-
| `firmware_state()` / `upgrade_firmware(path, confirm=True)` | `upgrader.*` | ⚠️ upgrade is reconstructed + can brick; mock-validated only |
159+
| `firmware_state()` / `upgrade_firmware(path, confirm=True)` | `upgrader.*` | ⚠️ writes device partitions (can brick); flashed OpenIPC end-to-end on a GK7205V510 |
160160
| `dahua.discover(timeout)` | `DHDiscover.search` (multicast) | LAN device discovery (needs L2 adjacency) |
161161
| `HttpMediaClient.record(...)` / `download_file(path, out)` | HTTP `streamReader.*` / `RPC_Loadfile` | live video → DHAV / recorded-file download (HTTP transport) |
162162

@@ -222,15 +222,19 @@ what is reconstructed and covered only by the offline test suite.
222222
converts via the configurable `ptz_location_fullscale` / `ptz_tilt_span_deg`.
223223
- Snapshot (HTTP CGI) and **RTSP** live capture (`record_rtsp`).
224224
- `eventManager.attach` handshake.
225+
- Firmware upgrade over DHIP: `firmware_state()` and `upgrade_firmware()`
226+
(`upgrader.prepare` → chunked `appendData` → `execute`) flashed an OpenIPC
227+
package onto a GK7205V510 end-to-end and rebooted into it. **Destructive** —
228+
requires `confirm=True` and a package built for the exact target.
225229

226230
**Reconstructed / covered by the offline tests only**
227231

228232
- Event *delivery* parsing (`client.notifyEventStream`), multi-fragment binary
229233
reassembly, and `HttpMediaClient` (`RPC_Loadfile`) — exercised against the
230234
fake servers in `tests/`, since not all firmwares expose these paths.
231-
- `find_files` / `download_file`, `discover` (needs L2 adjacency), and
232-
`upgrade_firmware` — the last is **destructive**, requires `confirm=True`, and
233-
is intentionally never run against a device.
235+
- `find_files` / `download_file` and `discover` (needs L2 adjacency) —
236+
exercised against the fake servers in `tests/`, since not all firmwares expose
237+
these paths.
234238

235239
Feature-specific wire details that may vary between firmwares (the RTSP path
236240
template, the `RPC_Loadfile` request) are kept in one place each so they are easy

‎dahua/client.py‎

Lines changed: 14 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -472,18 +472,23 @@ def upgrade_firmware(self, path: str, *, confirm: bool = False,
472472
to). *path* is the vendor upgrade package (a Dahua "zzip" — see
473473
``tools/zzip.py`` in the zenointel project), not a raw partition image.
474474
475+
``appendData`` is sent params ``{"length": len(chunk)}`` (lowercase) with
476+
the chunk as the trailing binary payload — the handler rejects the call
477+
with ``400 "param error"`` unless ``params.length`` equals the actual
478+
payload length. State machine: ``prepare`` (0→2) → ``appendData`` (2/4,
479+
repeatable) → ``execute`` (4→0), with a ~60 s idle timeout.
480+
475481
.. danger::
476-
This can permanently **brick** the device. The orchestration is
477-
validated against a mock and the method names are reversed from
478-
firmware, but the JSON param names are not byte-proven and this has
479-
deliberately never been run on hardware. Probe :meth:`firmware_state`
480-
first, keep a UART/backup recovery path ready, and pass
481-
``confirm=True`` to proceed.
482+
This can permanently **brick** the device. The flow is validated
483+
end-to-end on hardware (a GK7205V510 flashed an OpenIPC package and
484+
rebooted into it), but any wrong package for the target still bricks
485+
it. Probe :meth:`firmware_state` first, keep a UART/backup recovery
486+
path ready, and pass ``confirm=True`` to proceed.
482487
"""
483488
if not confirm:
484489
raise ValueError(
485-
"upgrade_firmware can brick the device and is untested on "
486-
"hardware; pass confirm=True to proceed")
490+
"upgrade_firmware writes device partitions and can brick the "
491+
"device; pass confirm=True to proceed")
487492
import os
488493
total = os.path.getsize(path)
489494
self.call(const.UPGRADER_PREPARE, {"Type": fw_type})
@@ -494,7 +499,7 @@ def upgrade_firmware(self, path: str, *, confirm: bool = False,
494499
if not chunk:
495500
break
496501
resp, _ = self.request(const.UPGRADER_APPEND,
497-
{"Offset": sent, "Length": len(chunk)},
502+
{"length": len(chunk)},
498503
data=chunk)
499504
self._check(resp, const.UPGRADER_APPEND)
500505
sent += len(chunk)

‎dahua/const.py‎

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -69,10 +69,10 @@
6969
# -- firmware upgrade -------------------------------------------------------
7070
# The `hunter` daemon's RPC upgrade handlers (reversed on a Zenointel GK7205
7171
# camera): prepare -> appendData(chunk) -> execute; getState is read-only. These
72-
# are the same handlers the web /cgi-bin/upgrader.cgi bridges to. The JSON param
73-
# names below (Type / Offset+Length) match the reversed "append upgrade data"
74-
# stream but are not byte-proven — verify against upgrader.getState / a web
75-
# capture before trusting a real flash.
72+
# are the same handlers the web /cgi-bin/upgrader.cgi bridges to. Validated on a
73+
# GK7205V510 (hunter decompile + live flash): appendData takes params {"length": N}
74+
# with an N-byte binary payload; prepare/execute params are ignored. State machine:
75+
# prepare(0->2) -> appendData(2/4, chunks) -> execute(4->0); ~60s idle timeout.
7676
UPGRADER_STATE = "upgrader.getState"
7777
UPGRADER_PREPARE = "upgrader.prepare"
7878
UPGRADER_APPEND = "upgrader.appendData"

‎tests/fake_server.py‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,12 @@ def _read_frame(self, sock):
5757
_, magic, sess, rid, pkg, idx, mlen, dlen = struct.unpack(HEADER_FMT, hdr)
5858
assert magic == DHIP_MAGIC
5959
body = self._recv(sock, pkg) if pkg else b""
60-
return json.loads(body[:mlen].decode()), sess
60+
obj = json.loads(body[:mlen].decode())
61+
if dlen:
62+
# expose the trailing binary payload so handlers can verify a request
63+
# whose params must describe it (e.g. upgrader.appendData length).
64+
obj["__data__"] = body[mlen:mlen + dlen]
65+
return obj, sess
6166

6267
def _send(self, sock, obj, data=b"", index=0):
6368
body = json.dumps(obj, separators=(",", ":")).encode()

‎tests/test_dahua.py‎

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -316,18 +316,20 @@ def test_upgrade_requires_confirm(self):
316316
cam.upgrade_firmware(__file__) # no confirm=True
317317

318318
def test_upgrade_streams_file_in_chunks(self):
319-
received = bytearray()
319+
append_params = []
320320

321321
def send(req):
322+
# The device rejects appendData unless params is exactly
323+
# {"length": <payload length>}. Record params + the actual binary
324+
# payload length so a regression to {"Offset","Length"} is caught.
325+
append_params.append((req.get("params"), len(req.get("__data__", b""))))
322326
return {"result": True}
323327

324328
handlers = {
325329
"upgrader.prepare": lambda r: {"result": True},
326330
"upgrader.appendData": send,
327331
"upgrader.execute": lambda r: {"result": True},
328332
}
329-
# The fake server doesn't expose binary bodies to handlers, so assert
330-
# the orchestration (start/send*/execute) and chunk count instead.
331333
with FakeDHIPServer(handlers) as srv:
332334
with DahuaClient("127.0.0.1", srv.port) as cam:
333335
cam.login(USER, PASS, keep_alive=False)
@@ -344,6 +346,12 @@ def send(req):
344346
self.assertIn("upgrader.prepare", srv.received)
345347
self.assertIn("upgrader.execute", srv.received)
346348
self.assertEqual(seen[-1], (10000, 10000))
349+
# params must be exactly {"length": N} with N == the real payload
350+
# length for every chunk (4096, 4096, 1808) — nothing else.
351+
self.assertEqual([p for p, _ in append_params],
352+
[{"length": 4096}, {"length": 4096}, {"length": 1808}])
353+
self.assertEqual([(p["length"], n) for p, n in append_params],
354+
[(4096, 4096), (4096, 4096), (1808, 1808)])
347355

348356

349357
class TestDiscovery(unittest.TestCase):

0 commit comments

Comments
 (0)