diff --git a/CHANGELOG.md b/CHANGELOG.md index c12eaf6..0c5d639 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ - added `client.sms.get_by_id()` and `client.sms.save_draft()` from normalized public contracts; draft create/update preserves the historically live-verified wire distinction (string id/type/protocol, boolean gsm7), enforces the save success triple, and redacts message content from SDK-generated errors ### Added +- added read-only `client.lan.static_reservations()` for the upstream live-verified normal-admin `router/router_get_dhcp_static_ip` contract; the complete firmware JSON response is preserved raw because the public API does not freeze a stable nested reservation schema, and the disruptive reservation setter remains outside this block - added read-only `client.device.work_mode()` for the upstream live-verified normal-admin `router/router_get_work_mode` contract; source-known normal values are `router` and `bridge`, while the SDK preserves raw `mode`/`result` values and deliberately does not expose the disruptive work-mode setter in this block - added read-only `client.mobile.network_select_mode()` for the upstream live-verified normal-admin `util_wan/get_network_select_mode` contract; the raw `nw_sel_mode`/`result` response is preserved without semantic aliases, and operator scan/manual selection remain intentionally outside this helper - added read-only `client.ota.updated_status()` and `client.ota.query_state()` wrappers for the upstream live-verified OTA status/state contracts; `query_state()` sends exactly `{"type": 1}`, preserves raw state strings, and does not reinterpret `idle` as proof that firmware is current @@ -56,6 +57,7 @@ ### Physical validation +- DHCP static-reservation read physically validated on ACIY.3 on 2026-09-08: targeted `test_lan_dns_reads` exercised the existing LAN/DNS reads plus `router/router_get_dhcp_static_ip` through `client.lan.static_reservations()` and passed `1/1` in 0.92 s; no DHCP/LAN write occurred and no concrete reservation IP/MAC values were printed - router work-mode read physically validated on ACIY.3 on 2026-09-08: targeted `test_device_health_reads` exercised the existing safe device-health reads plus `router/router_get_work_mode` through `client.device.work_mode()` and passed `1/1` in 3.43 s; no work-mode write, bridge transition, reboot or connectivity mutation occurred - WAN network selection mode read physically validated on ACIY.3 on 2026-09-08: targeted `test_mobile_status_reads` exercised the existing mobile read group plus `util_wan/get_network_select_mode` through `client.mobile.network_select_mode()` and passed `1/1` in 0.61 s; no operator scan, network-selection write or connectivity transition occurred - OTA read-only namespace physically validated on ACIY.3 on 2026-09-08: targeted `test_ota_reads` exercised `ota/get_updated_status` and `ota/new_query` with exactly `{"type": 1}` through `client.ota` and passed `1/1` in 0.47 s; no manual update check, download, install, state clear or cancellation action was invoked diff --git a/docs/dhcp-static-reservations-read.md b/docs/dhcp-static-reservations-read.md new file mode 100644 index 0000000..e916152 --- /dev/null +++ b/docs/dhcp-static-reservations-read.md @@ -0,0 +1,32 @@ +# DHCP static-reservation read coverage + +This SDK block exposes only the already live-verified normal-admin read contract `router/router_get_dhcp_static_ip` as `client.lan.static_reservations()`. + +## Scope + +- SDK helper: `client.lan.static_reservations()` +- API method: `router/router_get_dhcp_static_ip` +- transport: GET, no request body +- the complete firmware JSON response is preserved raw + +The upstream public contract intentionally does not freeze a stable nested schema for this response, so the SDK does not invent reservation field names or normalize the returned structure. + +The SDK deliberately does **not** expose or exercise `router/router_set_dhcp_static_ip` in this block. + +## Privacy + +DHCP reservations can contain private LAN addresses and device MAC addresses. The physical smoke validates only that the helper returns a mapping and does not print or assert concrete reservation values. + +## Physical evidence — 2026-09-08 + +Target: Zyxel NR2301, tested firmware family ACIY.3, Python 3.13.5. + +The targeted read-only LAN/DNS integration selection exercised the existing LAN/DNS reads plus `client.lan.static_reservations()` and completed successfully: + +```text +1 passed, 10 deselected in 0.92 s +``` + +No DHCP/LAN configuration write occurred, and no reservation IP/MAC values were printed. + +No `nr2301-api` change was required by this run because `router/router_get_dhcp_static_ip` was already `LIVE_VERIFIED`, `ADMIN_OK` and `READ_OR_LOW_SIDE_EFFECT`; this adds public-SDK physical-path evidence only. diff --git a/src/nr2301/namespaces/lan.py b/src/nr2301/namespaces/lan.py index 8780209..9b045c4 100644 --- a/src/nr2301/namespaces/lan.py +++ b/src/nr2301/namespaces/lan.py @@ -110,6 +110,20 @@ def dhcp(self, *, timeout: float | None = None) -> DHCPSettings: ) return cast(DHCPSettings, dict(self._extract_dhcp(response))) + def static_reservations(self, *, timeout: float | None = None) -> dict[str, Any]: + """Return the raw DHCP static-reservation response. + + The upstream API marks `router_get_dhcp_static_ip` as live-verified but + does not freeze a stable nested response schema. Preserve the complete + firmware JSON object without inventing field names or normalizations. + """ + + return self._client.call( + "router", + "router_get_dhcp_static_ip", + timeout=timeout, + ) + def dns(self, *, timeout: float | None = None) -> DNSSettings: """Return the five DNS fields from the combined DHCP object.""" diff --git a/tests/integration/test_readonly_router.py b/tests/integration/test_readonly_router.py index 08e1873..db348d5 100644 --- a/tests/integration/test_readonly_router.py +++ b/tests/integration/test_readonly_router.py @@ -79,6 +79,9 @@ def test_mobile_status_reads(router): def test_lan_dns_reads(router): _assert_mapping(router.lan.address()) _assert_mapping(router.lan.dns()) + # Static DHCP reservations can contain private IP/MAC identifiers. Exercise + # only the read contract and never print or assert concrete reservation data. + _assert_mapping(router.lan.static_reservations()) def test_firewall_reads(router): diff --git a/tests/test_lan_namespace.py b/tests/test_lan_namespace.py index 49ac89d..585f709 100644 --- a/tests/test_lan_namespace.py +++ b/tests/test_lan_namespace.py @@ -58,6 +58,25 @@ def test_lan_read_helpers_use_live_verified_get_methods(): assert session.calls[3][2]["params"]["method"] == "router_get_lan_ip" +def test_static_reservations_uses_live_verified_getter_and_preserves_raw_response(): + payload = { + "synthetic_nested_shape": [ + {"unknown_future_field": "preserve-me"}, + ], + "result": 0, + } + client, session = authenticated_client([(payload, 200)]) + + assert client.lan.static_reservations() == payload + + assert len(session.calls) == 1 + method, _, kwargs = session.calls[0] + assert method == "GET" + assert kwargs["params"]["path"] == "router" + assert kwargs["params"]["method"] == "router_get_dhcp_static_ip" + assert "json" not in kwargs + + def test_set_dns_preserves_combined_settings_and_verifies_readback(): before = dhcp_payload() after = dhcp_payload(