Skip to content

Commit 7e4173a

Browse files
feat: add a beta firmware update visibility setting
Beta and stable releases were shown identically everywhere (dashboard, device detail, update dialog), with no way to hide beta-only updates by default. Adds a "Show beta updates" toggle (off by default) backed by a new available_firmware_channel field threaded from the scan gateways (RPC and legacy Gen1) through the API to the frontend, so the UI can tell which channel an update came from and filter/label it consistently across the dashboard table, device header, and device actions card. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VApHaH9KZ6BPV9SwvUAVLH
1 parent b55f917 commit 7e4173a

17 files changed

Lines changed: 298 additions & 35 deletions

File tree

packages/api/src/api/controllers/devices.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,7 @@ async def scan_devices(
8686
"device_name": device.device_name,
8787
"firmware_version": device.firmware_version,
8888
"available_firmware_version": device.available_firmware_version,
89+
"available_firmware_channel": device.available_firmware_channel,
8990
"response_time": device.response_time,
9091
"error_message": device.error_message,
9192
"last_seen": device.last_seen.isoformat() if device.last_seen else None,

packages/api/tests/unit/controllers/test_devices.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ async def execute(self, scan_request):
4343
device_name="Test Device",
4444
firmware_version="1.0.0",
4545
available_firmware_version="1.2.0",
46+
available_firmware_channel="stable",
4647
response_time=0.5,
4748
last_seen=datetime.now(),
4849
)
@@ -64,6 +65,7 @@ async def execute(self, scan_request):
6465
assert data[0]["ip"] == "192.168.1.100"
6566
assert data[0]["status"] == "detected"
6667
assert data[0]["available_firmware_version"] == "1.2.0"
68+
assert data[0]["available_firmware_channel"] == "stable"
6769
assert data[0]["device_type"] == "SHSW-PM"
6870
assert data[0]["model_name"] == "Shelly 1PM"
6971

packages/core/src/core/domain/entities/discovered_device.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,10 @@ class DiscoveredDevice(BaseModel):
2525
available_firmware_version: str | None = Field(
2626
None, description="Version an available update would install"
2727
)
28+
available_firmware_channel: str | None = Field(
29+
None,
30+
description='Channel the available update was found on ("stable" or "beta")',
31+
)
2832
device_name: str | None = Field(None, description="User-defined device name")
2933
auth_required: bool = Field(
3034
False, description="Whether device requires authentication"

packages/core/src/core/gateways/device/legacy_device_gateway.py

Lines changed: 45 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -157,16 +157,23 @@ async def discover_device(
157157
)
158158

159159
has_update_flag = self._parse_update_flag(status_data)
160-
if has_update_flag is None:
160+
update_version = self._parse_update_version(status_data)
161+
available_version, available_channel = (
162+
update_version if update_version is not None else (None, None)
163+
)
164+
165+
if has_update_flag is None and available_version is None:
161166
device_status = Status.DETECTED
162167
has_update_value = False
163168
else:
169+
has_update_value = (
170+
bool(has_update_flag) or available_version is not None
171+
)
164172
device_status = (
165173
Status.UPDATE_AVAILABLE
166-
if has_update_flag
174+
if has_update_value
167175
else Status.NO_UPDATE_NEEDED
168176
)
169-
has_update_value = has_update_flag
170177

171178
return DiscoveredDevice(
172179
ip=ip,
@@ -175,6 +182,8 @@ async def discover_device(
175182
device_type=device_info.get("model") or device_info.get("type"),
176183
device_name=device_name,
177184
firmware_version=firmware_version,
185+
available_firmware_version=available_version,
186+
available_firmware_channel=available_channel,
178187
response_time=response_time,
179188
last_seen=datetime.now(),
180189
has_update=has_update_value,
@@ -532,3 +541,36 @@ def _parse_update_flag(self, status_data: dict[str, Any] | None) -> bool | None:
532541
return new_version != old_version
533542

534543
return None
544+
545+
def _parse_update_version(
546+
self, status_data: dict[str, Any] | None
547+
) -> tuple[str, str] | None:
548+
"""Parse the version and channel of an available update, if any.
549+
550+
Stable takes priority over beta, mirroring the RPC (Gen2+) gateway.
551+
Returns ``None`` when no version-bearing update is reported (the
552+
boolean-only ``has_update``/``update.has_update`` shorthand some
553+
Gen1 firmwares report carries no version and isn't captured here).
554+
"""
555+
if not isinstance(status_data, dict):
556+
return None
557+
558+
update_block = status_data.get("update")
559+
if not isinstance(update_block, dict):
560+
return None
561+
562+
new_version = update_block.get("new_version")
563+
old_version = update_block.get("old_version")
564+
has_stable = bool(update_block.get("has_update")) or (
565+
isinstance(new_version, str)
566+
and isinstance(old_version, str)
567+
and new_version != old_version
568+
)
569+
if has_stable and isinstance(new_version, str) and new_version:
570+
return new_version, "stable"
571+
572+
beta_version = update_block.get("beta_version")
573+
if isinstance(beta_version, str) and beta_version:
574+
return beta_version, "beta"
575+
576+
return None

packages/core/src/core/gateways/device/shelly_device_gateway.py

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -107,11 +107,14 @@ async def discover_device(
107107
stable_update = update_data.get("stable", {}) if update_data else {}
108108
beta_update = update_data.get("beta", {}) if update_data else {}
109109

110-
available_version = stable_update.get("version") or beta_update.get(
111-
"version"
112-
)
110+
stable_version = stable_update.get("version")
111+
beta_version = beta_update.get("version")
112+
available_version = stable_version or beta_version
113113
if available_version:
114114
device.available_firmware_version = available_version
115+
device.available_firmware_channel = (
116+
"stable" if stable_version else "beta"
117+
)
115118
device.status = Status.UPDATE_AVAILABLE
116119
else:
117120
device.status = Status.NO_UPDATE_NEEDED

packages/core/src/core/use_cases/scan_devices.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,7 @@ async def _settle_update_status(self, devices: list[DiscoveredDevice]) -> None:
111111
else:
112112
device.status = Status.UPDATE_AVAILABLE
113113
device.available_firmware_version = release.version
114+
device.available_firmware_channel = "stable"
114115

115116
async def _lookup_release(
116117
self, firmware_gateway: FirmwareGateway, app_name: str

packages/core/tests/unit/gateways/device/test_legacy_device_gateway.py

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,8 @@ async def test_it_discovers_device_successfully(
7373
assert device.device_name == "Custom Name"
7474
assert device.status == Status.NO_UPDATE_NEEDED
7575
assert device.has_update is False
76+
assert device.available_firmware_version is None
77+
assert device.available_firmware_channel is None
7678

7779
async def test_it_handles_discovery_failure(self, gateway, mock_http_client):
7880
mock_http_client.fetch_json.side_effect = Exception("Connection error")
@@ -94,6 +96,75 @@ async def test_it_detects_update_available(
9496

9597
assert device.status == Status.UPDATE_AVAILABLE
9698
assert device.has_update is True
99+
assert device.available_firmware_version is None
100+
assert device.available_firmware_channel is None
101+
102+
async def test_it_captures_the_stable_version_from_the_update_block(
103+
self, gateway, mock_http_client, sample_device_info
104+
):
105+
mock_http_client.fetch_json.return_value = sample_device_info
106+
mock_http_client.fetch_json_optional.side_effect = [
107+
{
108+
"update": {
109+
"has_update": True,
110+
"new_version": "20240101-000000/v1.14.1-g1234567",
111+
"old_version": "20230913-112003/v1.14.0-gCB16476",
112+
}
113+
},
114+
{},
115+
]
116+
117+
device = await gateway.discover_device("192.168.1.100")
118+
119+
assert device.status == Status.UPDATE_AVAILABLE
120+
assert device.has_update is True
121+
assert device.available_firmware_version == "20240101-000000/v1.14.1-g1234567"
122+
assert device.available_firmware_channel == "stable"
123+
124+
async def test_it_captures_a_beta_only_update_from_the_update_block(
125+
self, gateway, mock_http_client, sample_device_info
126+
):
127+
mock_http_client.fetch_json.return_value = sample_device_info
128+
mock_http_client.fetch_json_optional.side_effect = [
129+
{
130+
"update": {
131+
"has_update": False,
132+
"beta_version": "20231107-162940/v1.14.1-rc1-g0617c15",
133+
}
134+
},
135+
{},
136+
]
137+
138+
device = await gateway.discover_device("192.168.1.100")
139+
140+
assert device.status == Status.UPDATE_AVAILABLE
141+
assert device.has_update is True
142+
assert (
143+
device.available_firmware_version == "20231107-162940/v1.14.1-rc1-g0617c15"
144+
)
145+
assert device.available_firmware_channel == "beta"
146+
147+
async def test_it_prefers_stable_over_beta_when_both_are_reported(
148+
self, gateway, mock_http_client, sample_device_info
149+
):
150+
mock_http_client.fetch_json.return_value = sample_device_info
151+
mock_http_client.fetch_json_optional.side_effect = [
152+
{
153+
"update": {
154+
"has_update": True,
155+
"new_version": "20240101-000000/v1.14.1-g1234567",
156+
"old_version": "20230913-112003/v1.14.0-gCB16476",
157+
"beta_version": "20231107-162940/v1.14.1-rc1-g0617c15",
158+
}
159+
},
160+
{},
161+
]
162+
163+
device = await gateway.discover_device("192.168.1.100")
164+
165+
assert device.status == Status.UPDATE_AVAILABLE
166+
assert device.available_firmware_version == "20240101-000000/v1.14.1-g1234567"
167+
assert device.available_firmware_channel == "stable"
97168

98169
async def test_it_gets_device_status_successfully(
99170
self,

packages/core/tests/unit/gateways/device/test_shelly_device_gateway.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -822,6 +822,7 @@ async def test_it_handles_update_info_without_versions(
822822
assert result is not None
823823
assert result.status == Status.NO_UPDATE_NEEDED
824824
assert result.available_firmware_version is None
825+
assert result.available_firmware_channel is None
825826

826827
async def test_it_captures_the_available_update_version(
827828
self, gateway, mock_rpc_client
@@ -842,6 +843,7 @@ async def test_it_captures_the_available_update_version(
842843
assert result is not None
843844
assert result.status == Status.UPDATE_AVAILABLE
844845
assert result.available_firmware_version == "2.6.0"
846+
assert result.available_firmware_channel == "stable"
845847

846848
async def test_it_captures_a_beta_only_update_version(
847849
self, gateway, mock_rpc_client
@@ -859,6 +861,7 @@ async def test_it_captures_a_beta_only_update_version(
859861
assert result is not None
860862
assert result.status == Status.UPDATE_AVAILABLE
861863
assert result.available_firmware_version == "2.7.0-beta1"
864+
assert result.available_firmware_channel == "beta"
862865

863866
async def test_it_handles_null_update_info(self, gateway, mock_rpc_client):
864867
device_info = {"id": "test-device", "model": "SHSW-1", "fw_id": "1.0.0"}

packages/core/tests/unit/use_cases/test_scan_devices.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -286,6 +286,7 @@ async def test_it_marks_an_update_the_index_publishes(
286286

287287
assert result[0].status == Status.UPDATE_AVAILABLE
288288
assert result[0].available_firmware_version == "1.8.0"
289+
assert result[0].available_firmware_channel == "stable"
289290
firmware_gateway.get_latest.assert_awaited_once_with("Plus2PM")
290291

291292
async def test_it_marks_a_device_already_on_the_published_build(
@@ -358,6 +359,7 @@ async def test_it_keeps_the_device_reported_available_version(
358359

359360
assert result[0].status == Status.UPDATE_AVAILABLE
360361
assert result[0].available_firmware_version == "1.9.0"
362+
assert result[0].available_firmware_channel is None
361363
firmware_gateway.get_latest.assert_not_awaited()
362364

363365
async def test_it_skips_a_device_without_an_app_name(

packages/web/src/components/dashboard/device-table.tsx

Lines changed: 26 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -82,8 +82,19 @@ export function DeviceTable({ devices, onBulkAction }: DeviceTableProps) {
8282
initialSettings.tableDensity,
8383
);
8484

85+
const getEffectiveStatus = (device: Device): string => {
86+
if (
87+
!initialSettings.showBetaUpdates &&
88+
device.status === "update_available" &&
89+
device.available_firmware_channel === "beta"
90+
) {
91+
return "no_update_needed";
92+
}
93+
return device.status;
94+
};
95+
8596
const getStatusBadge = (device: Device) => {
86-
const statusLower = device.status?.toLowerCase() || "";
97+
const statusLower = getEffectiveStatus(device).toLowerCase();
8798
let variant: "default" | "secondary" | "destructive" | "outline" =
8899
"default";
89100

@@ -104,13 +115,23 @@ export function DeviceTable({ devices, onBulkAction }: DeviceTableProps) {
104115
variant = "outline";
105116
}
106117

107-
return <Badge variant={variant}>{getStatusLabel(device)}</Badge>;
118+
return (
119+
<div className="flex items-center gap-1.5">
120+
<Badge variant={variant}>{getStatusLabel(device)}</Badge>
121+
{initialSettings.showBetaUpdates &&
122+
device.available_firmware_channel === "beta" && (
123+
<Badge className="text-xs px-1.5 py-0 bg-orange-100 text-orange-800 dark:bg-orange-900 dark:text-orange-300">
124+
{t("bulkActions.beta")}
125+
</Badge>
126+
)}
127+
</div>
128+
);
108129
};
109130

110131
const getStatusLabel = (device: Device) => {
111-
const statusLower = device.status?.toLowerCase() || "";
112-
const label = t(`status.${statusLower}`, device.status);
113-
return statusLower === "update_available" &&
132+
const effectiveStatus = getEffectiveStatus(device).toLowerCase();
133+
const label = t(`status.${effectiveStatus}`, device.status);
134+
return effectiveStatus === "update_available" &&
114135
device.available_firmware_version
115136
? `${label} (${device.available_firmware_version})`
116137
: label;

0 commit comments

Comments
 (0)