|
| 1 | +# Issue 88: Null values in sys.status break device detail and local updates |
| 2 | + |
| 3 | +GitHub issue: https://github.com/jfmlima/shelly-manager/issues/88 |
| 4 | + |
| 5 | +## Problem |
| 6 | + |
| 7 | +Gen2 devices on firmware 1.4.0 show two symptoms: |
| 8 | + |
| 9 | +1. The device detail page fails with "Device not found or unreachable" even though the device answers RPC calls. |
| 10 | +2. Firmware updates through the manager fail for the same devices. |
| 11 | + |
| 12 | +Devices on firmware 1.7.5 work. The firmware version is an indirect signal. The real trigger is that these devices report literal nulls in `sys.status` when their clock never synced (common on isolated IoT VLANs without NTP or internet access): |
| 13 | + |
| 14 | +```json |
| 15 | +"sys": { |
| 16 | + "status": { "time": null, "unixtime": null, "uptime": 3539695, ... } |
| 17 | +} |
| 18 | +``` |
| 19 | + |
| 20 | +## Root cause (verified with the payload attached to the issue) |
| 21 | + |
| 22 | +`SystemComponent.from_raw_data` uses `status.get("unixtime", 0)`. When the key is present with a null value, `get` returns `None`, not the default. The field is declared `unixtime: int` (non optional), so Pydantic raises `ValidationError`. |
| 23 | + |
| 24 | +- Crash site: `packages/core/src/core/domain/entities/components/system.py:45` (field declared at line 21). |
| 25 | +- The same pattern applies to `uptime`, `ram_size`, `ram_free`, `fs_size`, `fs_free`, `restart_required`, and `available_updates` at lines 38 to 44. Any of them can be null on old firmware. |
| 26 | +- `InputComponent.from_raw_data` already guards against this with `or <default>` (`packages/core/src/core/domain/entities/components/input.py:23-27`). `SystemComponent` does not. |
| 27 | + |
| 28 | +### Failure chain |
| 29 | + |
| 30 | +1. `DeviceStatus.from_raw_response` calls `ComponentFactory.create_component` per component and propagates the `ValidationError` (`packages/core/src/core/domain/entities/device_status.py:69`). |
| 31 | +2. `ShellyDeviceGateway.get_device_status` catches the generic exception and falls back to the legacy Gen1 gateway (`packages/core/src/core/gateways/device/shelly_device_gateway.py:166-181`). A Gen2 device does not serve the Gen1 `/status` endpoint, so the fallback returns `None`. |
| 32 | +3. The devices controller raises `DeviceNotFoundError`, which the API maps to a 404 with the message "Device not found or unreachable" (`packages/api/src/api/presentation/handlers.py`). The web detail page renders that message verbatim. |
| 33 | +4. The update path fails through the same read: `UpdateDeviceFromLocal.execute` raises `DeviceNotFoundError` when `get_device_status` returns `None` (`packages/core/src/core/use_cases/update_device_from_local.py:62-64`), and `GetLocalFirmwareReleases` fails the same way (`packages/core/src/core/use_cases/get_local_firmware_releases.py:41-43`). |
| 34 | + |
| 35 | +One bug therefore explains both symptoms. |
| 36 | + |
| 37 | +## Fix |
| 38 | + |
| 39 | +Both changes are in the core domain layer. |
| 40 | + |
| 41 | +### 1. Null-guard the fields in `SystemComponent.from_raw_data` |
| 42 | + |
| 43 | +In `packages/core/src/core/domain/entities/components/system.py`, apply the same guard `InputComponent` uses: |
| 44 | + |
| 45 | +```python |
| 46 | +uptime=status.get("uptime", 0) or 0, |
| 47 | +restart_required=status.get("restart_required", False) or False, |
| 48 | +ram_total=status.get("ram_size", 0) or 0, |
| 49 | +ram_free=status.get("ram_free", 0) or 0, |
| 50 | +fs_total=status.get("fs_size", 0) or 0, |
| 51 | +fs_free=status.get("fs_free", 0) or 0, |
| 52 | +available_updates=status.get("available_updates", {}) or {}, |
| 53 | +unixtime=status.get("unixtime", 0) or 0, |
| 54 | +``` |
| 55 | + |
| 56 | +### 2. Make `ComponentFactory.create_component` degrade instead of raise |
| 57 | + |
| 58 | +In `packages/core/src/core/domain/entities/components/factory.py:13`, catch `pydantic.ValidationError` from the typed model and fall back to `Component.from_raw_data(component_data)`, with a warning log. One malformed component must never turn a reachable device into a 404 and a dead update path. Without this, the next firmware quirk reproduces the whole issue. |
| 59 | + |
| 60 | +Note: `Component.from_raw_data` itself can raise if `key` is missing, but the factory is only called with dicts that carry a `key`, so guarding the typed-model call is enough. |
| 61 | + |
| 62 | +## Out of scope (file separately) |
| 63 | + |
| 64 | +- `Shelly.GetComponents` pagination: the gateway always requests `{"offset": 0}` and never loops to `total` (`packages/core/src/core/gateways/device/shelly_device_gateway.py:375-380` and `get_component_keys` at 184-198). Masked today because `DeviceStatus.from_raw_response` backfills missing components from `Shelly.GetStatus` keys, but backfilled components have empty config and `get_component_keys` silently misses page-two components. Separate issue. |
| 65 | +- Device-side internet updates failing on these devices: their own `available_updates` is empty because they cannot reach the Shelly cloud. Environmental, not a manager bug. The via-manager local update path is the designated answer, and this fix restores it. |
| 66 | + |
| 67 | +## Tests |
| 68 | + |
| 69 | +All in `packages/core/tests/unit/domain/entities/`: |
| 70 | + |
| 71 | +1. `test_components.py`: `SystemComponent.from_raw_data` with the exact `sys` component payload from the issue (`"time": null, "unixtime": null`) parses and yields `unixtime == 0`. Add a variant with nulls in the other guarded fields. |
| 72 | +2. `test_device_status.py`: `DeviceStatus.from_raw_response` with the issue's full `GetComponents` capture returns a `DeviceStatus` instead of raising. This is the regression test for the 404 chain. |
| 73 | +3. `test_components.py` (factory): a component dict whose status payload makes the typed model raise `ValidationError` returns a base `Component` with the right `key` instead of raising. |
| 74 | + |
| 75 | +## Verification |
| 76 | + |
| 77 | +- `make test-core` |
| 78 | +- `make lint` |
| 79 | +- Manual, if a 1.4.0 device is available: open the device detail page and run a via-manager update. |
0 commit comments