Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/api/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -214,6 +214,7 @@ curl "http://localhost:8000/api/devices/scan?targets=192.168.1.1-10"
"device_type": "shelly1pm",
"device_name": "Living Room Light",
"firmware_version": "20230913-112003",
"available_firmware_version": "1.2.0",
"response_time": 0.123,
"last_seen": "2024-01-15T10:30:00Z"
}
Expand Down
1 change: 1 addition & 0 deletions packages/api/src/api/controllers/devices.py
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,7 @@ async def scan_devices(
"device_type": device.device_type,
"device_name": device.device_name,
"firmware_version": device.firmware_version,
"available_firmware_version": device.available_firmware_version,
"response_time": device.response_time,
"error_message": device.error_message,
"last_seen": device.last_seen.isoformat() if device.last_seen else None,
Expand Down
2 changes: 2 additions & 0 deletions packages/api/tests/unit/controllers/test_devices.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ async def execute(self, scan_request):
device_type="Shelly 1",
device_name="Test Device",
firmware_version="1.0.0",
available_firmware_version="1.2.0",
response_time=0.5,
last_seen=datetime.now(),
)
Expand All @@ -62,6 +63,7 @@ async def execute(self, scan_request):
assert len(data) == 1
assert data[0]["ip"] == "192.168.1.100"
assert data[0]["status"] == "detected"
assert data[0]["available_firmware_version"] == "1.2.0"

def test_scan_without_targets_returns_400(self):
from core.domain.entities.exceptions import ValidationError
Expand Down
3 changes: 3 additions & 0 deletions packages/core/src/core/domain/entities/discovered_device.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,9 @@ class DiscoveredDevice(BaseModel):
None, description="Device app name firmware is looked up by, e.g. 'Plus2PM'"
)
firmware_version: str | None = Field(None, description="Current firmware version")
available_firmware_version: str | None = Field(
None, description="Version an available update would install"
)
device_name: str | None = Field(None, description="User-defined device name")
auth_required: bool = Field(
False, description="Whether device requires authentication"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -107,7 +107,11 @@ async def discover_device(
stable_update = update_data.get("stable", {}) if update_data else {}
beta_update = update_data.get("beta", {}) if update_data else {}

if stable_update.get("version") or beta_update.get("version"):
available_version = stable_update.get("version") or beta_update.get(
"version"
)
if available_version:
device.available_firmware_version = available_version
device.status = Status.UPDATE_AVAILABLE
else:
device.status = Status.NO_UPDATE_NEEDED
Expand Down
10 changes: 5 additions & 5 deletions packages/core/src/core/use_cases/scan_devices.py
Original file line number Diff line number Diff line change
Expand Up @@ -106,11 +106,11 @@ async def _settle_update_status(self, devices: list[DiscoveredDevice]) -> None:
if release is None:
continue
for device in pending[app]:
device.status = (
Status.NO_UPDATE_NEEDED
if release.is_installed_on(device.firmware_version)
else Status.UPDATE_AVAILABLE
)
if release.is_installed_on(device.firmware_version):
device.status = Status.NO_UPDATE_NEEDED
else:
device.status = Status.UPDATE_AVAILABLE
device.available_firmware_version = release.version

async def _lookup_release(
self, firmware_gateway: FirmwareGateway, app_name: str
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -821,6 +821,44 @@ async def test_it_handles_update_info_without_versions(

assert result is not None
assert result.status == Status.NO_UPDATE_NEEDED
assert result.available_firmware_version is None

async def test_it_captures_the_available_update_version(
self, gateway, mock_rpc_client
):
device_info = {"id": "test-device", "model": "SHSW-1", "fw_id": "1.0.0"}
update_info = {
"stable": {"version": "2.6.0"},
"beta": {"version": "2.7.0-beta1"},
}
mock_rpc_client.make_rpc_request = AsyncMock()
mock_rpc_client.make_rpc_request.side_effect = [
(device_info, 0.1),
(update_info, 0.05),
]

result = await gateway.discover_device("192.168.1.100")

assert result is not None
assert result.status == Status.UPDATE_AVAILABLE
assert result.available_firmware_version == "2.6.0"

async def test_it_captures_a_beta_only_update_version(
self, gateway, mock_rpc_client
):
device_info = {"id": "test-device", "model": "SHSW-1", "fw_id": "1.0.0"}
update_info = {"stable": {}, "beta": {"version": "2.7.0-beta1"}}
mock_rpc_client.make_rpc_request = AsyncMock()
mock_rpc_client.make_rpc_request.side_effect = [
(device_info, 0.1),
(update_info, 0.05),
]

result = await gateway.discover_device("192.168.1.100")

assert result is not None
assert result.status == Status.UPDATE_AVAILABLE
assert result.available_firmware_version == "2.7.0-beta1"

async def test_it_handles_null_update_info(self, gateway, mock_rpc_client):
device_info = {"id": "test-device", "model": "SHSW-1", "fw_id": "1.0.0"}
Expand Down
19 changes: 19 additions & 0 deletions packages/core/tests/unit/use_cases/test_scan_devices.py
Original file line number Diff line number Diff line change
Expand Up @@ -285,6 +285,7 @@ async def test_it_marks_an_update_the_index_publishes(
result = await use_case.execute(single_ip_request)

assert result[0].status == Status.UPDATE_AVAILABLE
assert result[0].available_firmware_version == "1.8.0"
firmware_gateway.get_latest.assert_awaited_once_with("Plus2PM")

async def test_it_marks_a_device_already_on_the_published_build(
Expand All @@ -307,6 +308,7 @@ async def test_it_marks_a_device_already_on_the_published_build(
result = await use_case.execute(single_ip_request)

assert result[0].status == Status.NO_UPDATE_NEEDED
assert result[0].available_firmware_version is None

async def test_it_leaves_detected_when_the_index_has_no_release(
self, mock_device_gateway, single_ip_request
Expand Down Expand Up @@ -341,6 +343,23 @@ async def test_it_leaves_a_settled_status_alone(
assert result[0].status == Status.NO_UPDATE_NEEDED
firmware_gateway.get_latest.assert_not_awaited()

async def test_it_keeps_the_device_reported_available_version(
self, mock_device_gateway, single_ip_request
):
mock_device_gateway.discover_device = AsyncMock(
return_value=self._device(
status=Status.UPDATE_AVAILABLE,
available_firmware_version="1.9.0",
)
)
use_case, firmware_gateway = self._use_case(mock_device_gateway, None)

result = await use_case.execute(single_ip_request)

assert result[0].status == Status.UPDATE_AVAILABLE
assert result[0].available_firmware_version == "1.9.0"
firmware_gateway.get_latest.assert_not_awaited()

async def test_it_skips_a_device_without_an_app_name(
self, mock_device_gateway, single_ip_request
):
Expand Down
28 changes: 20 additions & 8 deletions packages/web/src/components/dashboard/device-table.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -82,12 +82,16 @@ export function DeviceTable({ devices, onBulkAction }: DeviceTableProps) {
initialSettings.tableDensity,
);

const getStatusBadge = (status: string) => {
const statusLower = status?.toLowerCase() || "";
const getStatusBadge = (device: Device) => {
const statusLower = device.status?.toLowerCase() || "";
let variant: "default" | "secondary" | "destructive" | "outline" =
"default";

if (statusLower.includes("detected") || statusLower.includes("online")) {
if (
statusLower.includes("detected") ||
statusLower.includes("online") ||
statusLower === "update_available"
) {
variant = "default";
} else if (statusLower.includes("updating")) {
variant = "secondary";
Expand All @@ -100,9 +104,16 @@ export function DeviceTable({ devices, onBulkAction }: DeviceTableProps) {
variant = "outline";
}

return (
<Badge variant={variant}>{t(`status.${statusLower}`, status)}</Badge>
);
return <Badge variant={variant}>{getStatusLabel(device)}</Badge>;
};

const getStatusLabel = (device: Device) => {
const statusLower = device.status?.toLowerCase() || "";
const label = t(`status.${statusLower}`, device.status);
return statusLower === "update_available" &&
device.available_firmware_version
? `${label} (${device.available_firmware_version})`
: label;
};

const columns: ColumnDef<Device>[] = [
Expand Down Expand Up @@ -142,7 +153,8 @@ export function DeviceTable({ devices, onBulkAction }: DeviceTableProps) {
),
},
{
accessorKey: "status",
id: "status",
accessorFn: (device) => getStatusLabel(device),
header: ({ column }) => (
<Button
variant="ghost"
Expand All @@ -153,7 +165,7 @@ export function DeviceTable({ devices, onBulkAction }: DeviceTableProps) {
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
),
cell: ({ row }) => getStatusBadge(row.getValue("status")),
cell: ({ row }) => getStatusBadge(row.original),
},
{
accessorKey: "device_type",
Expand Down
5 changes: 4 additions & 1 deletion packages/web/src/i18n/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,7 @@
"title": "Discovered Devices",
"description": "Found {{count}} device(s). Click on any row to view device details.",
"ip": "IP Address",
"status": "Status",
"status": "Firmware Status",
"deviceType": "Device Type",
"deviceName": "Device Name",
"firmwareVersion": "Firmware Version",
Expand Down Expand Up @@ -486,6 +486,9 @@
"updating": "Updating",
"unknown": "Unknown",
"detected": "Detected",
"update_available": "Update Available",
"no_update_needed": "Up to Date",
"auth_required": "Auth Required",
"error": "Error"
},
"errors": {
Expand Down
1 change: 1 addition & 0 deletions packages/web/src/types/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ export interface Device {
device_type: string;
device_name: string;
firmware_version: string;
available_firmware_version?: string | null;
response_time: number | null;
error_message?: string | null;
last_seen?: string | null;
Expand Down
Loading