Skip to content

Commit da1fd57

Browse files
committed
Fix scan form and minor UI improvements
1 parent 0238972 commit da1fd57

8 files changed

Lines changed: 236 additions & 56 deletions

File tree

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

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -42,14 +42,15 @@ async def discover_device(self, ip: str) -> DiscoveredDevice | None:
4242
device_info, response_time = await self._rpc_client.make_rpc_request(
4343
ip, "Shelly.GetDeviceInfo", timeout=self.timeout
4444
)
45+
device_data = device_info.get("result", device_info)
4546

4647
device = DiscoveredDevice(
4748
ip=ip,
4849
status=Status.DETECTED,
49-
device_id=device_info.get("id"),
50-
device_type=device_info.get("model"),
51-
device_name=device_info.get("name"),
52-
firmware_version=device_info.get("fw_id"),
50+
device_id=device_data.get("id"),
51+
device_type=device_data.get("model"),
52+
device_name=device_data.get("name"),
53+
firmware_version=device_data.get("fw_id"),
5354
response_time=response_time,
5455
last_seen=datetime.now(),
5556
)
@@ -58,9 +59,10 @@ async def discover_device(self, ip: str) -> DiscoveredDevice | None:
5859
update_info, _ = await self._rpc_client.make_rpc_request(
5960
ip, "Shelly.CheckForUpdate", timeout=self.timeout
6061
)
62+
update_data = update_info.get("result", update_info)
6163

62-
stable_update = update_info.get("stable", {}) if update_info else {}
63-
beta_update = update_info.get("beta", {}) if update_info else {}
64+
stable_update = update_data.get("stable", {}) if update_data else {}
65+
beta_update = update_data.get("beta", {}) if update_data else {}
6466

6567
if stable_update.get("version") or beta_update.get("version"):
6668
device.status = Status.UPDATE_AVAILABLE

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

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -102,8 +102,7 @@ async def _scan_single_device(
102102
) -> DiscoveredDevice | None:
103103
async with semaphore:
104104
try:
105-
device = await self._discover_device(ip, timeout)
106-
return device
105+
return await self._discover_device(ip, timeout)
107106
except Exception:
108107
return DiscoveredDevice(
109108
ip=ip,

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -560,4 +560,4 @@ async def test_it_handles_null_update_info(self, gateway, mock_rpc_client):
560560
result = await gateway.discover_device("192.168.1.100")
561561

562562
assert result is not None
563-
assert result.status == Status.NO_UPDATE_NEEDED
563+
assert result.status == Status.DETECTED

packages/web/src/components/dashboard/scan-form.tsx

Lines changed: 95 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { zodResolver } from "@hookform/resolvers/zod";
22
import { useForm } from "react-hook-form";
33
import { z } from "zod";
44
import { useTranslation } from "react-i18next";
5+
import { useEffect } from "react";
56
import { Search } from "lucide-react";
67
import { Button } from "@/components/ui/button";
78
import {
@@ -22,13 +23,66 @@ import {
2223
import { Input } from "@/components/ui/input";
2324
import { Checkbox } from "@/components/ui/checkbox";
2425

25-
const scanFormSchema = z.object({
26-
start_ip: z.string().optional(),
27-
end_ip: z.string().optional(),
28-
use_predefined: z.boolean(),
29-
timeout: z.number().min(1).max(30),
30-
max_workers: z.number().min(1).max(100),
31-
});
26+
// IP address validation regex
27+
const ipRegex =
28+
/^(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$/;
29+
30+
const scanFormSchema = z
31+
.object({
32+
start_ip: z.string().optional(),
33+
end_ip: z.string().optional(),
34+
use_predefined: z.boolean(),
35+
timeout: z.number().min(1).max(30),
36+
max_workers: z.number().min(1).max(100),
37+
})
38+
.refine(
39+
(data) => {
40+
// If not using predefined, both IP fields are required
41+
if (!data.use_predefined) {
42+
return (
43+
data.start_ip &&
44+
data.start_ip.trim() !== "" &&
45+
data.end_ip &&
46+
data.end_ip.trim() !== ""
47+
);
48+
}
49+
return true;
50+
},
51+
{
52+
message: "Start IP and End IP are required when not using predefined IPs",
53+
path: ["start_ip"],
54+
},
55+
)
56+
.refine(
57+
(data) => {
58+
// Validate start IP format if provided and not using predefined
59+
if (
60+
!data.use_predefined &&
61+
data.start_ip &&
62+
data.start_ip.trim() !== ""
63+
) {
64+
return ipRegex.test(data.start_ip.trim());
65+
}
66+
return true;
67+
},
68+
{
69+
message: "Please enter a valid IP address (e.g., 192.168.1.1)",
70+
path: ["start_ip"],
71+
},
72+
)
73+
.refine(
74+
(data) => {
75+
// Validate end IP format if provided and not using predefined
76+
if (!data.use_predefined && data.end_ip && data.end_ip.trim() !== "") {
77+
return ipRegex.test(data.end_ip.trim());
78+
}
79+
return true;
80+
},
81+
{
82+
message: "Please enter a valid IP address (e.g., 192.168.1.254)",
83+
path: ["end_ip"],
84+
},
85+
);
3286

3387
export type ScanFormData = z.infer<typeof scanFormSchema>;
3488

@@ -53,11 +107,21 @@ export function ScanForm({ onSubmit, isLoading = false }: ScanFormProps) {
53107

54108
const usePredefined = form.watch("use_predefined");
55109

110+
// Clear IP fields when switching to predefined mode
111+
useEffect(() => {
112+
if (usePredefined) {
113+
form.setValue("start_ip", "");
114+
form.setValue("end_ip", "");
115+
// Clear any validation errors
116+
form.clearErrors(["start_ip", "end_ip"]);
117+
}
118+
}, [usePredefined, form]);
119+
56120
const handleSubmit = (data: ScanFormData) => {
57121
const cleanData = {
58122
...data,
59-
start_ip: data.start_ip || undefined,
60-
end_ip: data.end_ip || undefined,
123+
start_ip: data.start_ip?.trim() || undefined,
124+
end_ip: data.end_ip?.trim() || undefined,
61125
};
62126
onSubmit(cleanData);
63127
};
@@ -106,9 +170,18 @@ export function ScanForm({ onSubmit, isLoading = false }: ScanFormProps) {
106170
name="start_ip"
107171
render={({ field }) => (
108172
<FormItem>
109-
<FormLabel>{t("dashboard.scanForm.startIp")}</FormLabel>
173+
<FormLabel>
174+
{t("dashboard.scanForm.startIp")}
175+
{!usePredefined && (
176+
<span className="text-red-500 ml-1">*</span>
177+
)}
178+
</FormLabel>
110179
<FormControl>
111-
<Input placeholder="192.168.1.1" {...field} />
180+
<Input
181+
placeholder="192.168.1.1"
182+
{...field}
183+
value={field.value || ""}
184+
/>
112185
</FormControl>
113186
<FormMessage />
114187
</FormItem>
@@ -120,9 +193,18 @@ export function ScanForm({ onSubmit, isLoading = false }: ScanFormProps) {
120193
name="end_ip"
121194
render={({ field }) => (
122195
<FormItem>
123-
<FormLabel>{t("dashboard.scanForm.endIp")}</FormLabel>
196+
<FormLabel>
197+
{t("dashboard.scanForm.endIp")}
198+
{!usePredefined && (
199+
<span className="text-red-500 ml-1">*</span>
200+
)}
201+
</FormLabel>
124202
<FormControl>
125-
<Input placeholder="192.168.1.254" {...field} />
203+
<Input
204+
placeholder="192.168.1.254"
205+
{...field}
206+
value={field.value || ""}
207+
/>
126208
</FormControl>
127209
<FormMessage />
128210
</FormItem>

packages/web/src/components/device-detail/device-components.tsx

Lines changed: 26 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,24 @@ const PRIORITY_COMPONENT_TYPES = [
4848
"ble",
4949
];
5050

51+
const TYPE_ORDER = ["switch", "cover", "cloud", "sys", "zigbee", "ble"];
52+
53+
function sortComponentsByType(components: Component[]): Component[] {
54+
return [...components].sort((a, b) => {
55+
const aIndex = TYPE_ORDER.indexOf(a.type);
56+
const bIndex = TYPE_ORDER.indexOf(b.type);
57+
58+
if (aIndex !== -1 && bIndex !== -1) {
59+
return aIndex - bIndex;
60+
}
61+
62+
if (aIndex !== -1) return -1;
63+
if (bIndex !== -1) return 1;
64+
65+
return a.type.localeCompare(b.type);
66+
});
67+
}
68+
5169
export function DeviceComponents({
5270
deviceStatus,
5371
isLoading,
@@ -168,7 +186,6 @@ export function DeviceComponents({
168186
);
169187
}
170188

171-
// Generic component fallback
172189
return (
173190
<GenericComponent
174191
key={component.key}
@@ -179,13 +196,18 @@ export function DeviceComponents({
179196
);
180197
};
181198

182-
const priorityComponents = deviceStatus.components.filter((component) =>
183-
PRIORITY_COMPONENT_TYPES.includes(component.type),
199+
const priorityComponentsUnsorted = deviceStatus.components.filter(
200+
(component) => PRIORITY_COMPONENT_TYPES.includes(component.type),
184201
);
185-
const additionalComponents = deviceStatus.components.filter(
202+
const additionalComponentsUnsorted = deviceStatus.components.filter(
186203
(component) => !PRIORITY_COMPONENT_TYPES.includes(component.type),
187204
);
188205

206+
const priorityComponents = sortComponentsByType(priorityComponentsUnsorted);
207+
const additionalComponents = sortComponentsByType(
208+
additionalComponentsUnsorted,
209+
);
210+
189211
const hasAdditionalComponents = additionalComponents.length > 0;
190212

191213
return (

packages/web/src/components/device-detail/device-header.tsx

Lines changed: 81 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import {
66
HardDrive,
77
Clock,
88
Zap,
9+
AlertTriangle,
910
} from "lucide-react";
1011
import { useTranslation } from "react-i18next";
1112

@@ -102,22 +103,94 @@ export function DeviceHeader({ deviceStatus, isLoading }: DeviceHeaderProps) {
102103
</CardHeader>
103104
<CardContent>
104105
<div className="grid grid-cols-2 gap-4 text-sm">
106+
{/* Firmware Section - Full Left Column */}
105107
<div className="space-y-2">
106108
<div className="flex items-center space-x-2">
107109
<HardDrive className="h-4 w-4 text-muted-foreground" />
108110
<span>{t("deviceDetail.deviceInfo.firmware")}</span>
109111
</div>
110-
<div className="font-mono text-xs pl-6">
111-
{summary.firmware_version ||
112-
t("deviceDetail.deviceInfo.unknown")}
112+
<div className="pl-6 space-y-2">
113+
{/* Current Version */}
114+
<div className="space-y-1">
115+
<span className="text-xs text-muted-foreground">
116+
Current:
117+
</span>
118+
<div className="font-mono text-xs break-all">
119+
{summary.firmware_version ||
120+
t("deviceDetail.deviceInfo.unknown")}
121+
</div>
122+
</div>
123+
124+
{/* Available Updates */}
125+
{deviceStatus.firmware.available_updates &&
126+
Object.keys(deviceStatus.firmware.available_updates).length >
127+
0 && (
128+
<div className="space-y-1">
129+
<div className="text-xs text-muted-foreground">
130+
{t("deviceDetail.deviceInfo.availableUpdates")}:
131+
</div>
132+
<div className="space-y-1">
133+
{Object.entries(
134+
deviceStatus.firmware.available_updates,
135+
).map(([channel, update]) => (
136+
<div
137+
key={channel}
138+
className="flex items-center space-x-2"
139+
>
140+
<Badge
141+
variant="secondary"
142+
className="text-xs px-2 py-0"
143+
>
144+
{channel}
145+
</Badge>
146+
<span className="text-xs font-mono">
147+
{update.version}
148+
</span>
149+
{update.name && (
150+
<span className="text-xs text-muted-foreground">
151+
({update.name})
152+
</span>
153+
)}
154+
</div>
155+
))}
156+
</div>
157+
</div>
158+
)}
113159
</div>
114160
</div>
115-
<div className="space-y-2">
116-
<div className="flex items-center space-x-2">
117-
<Clock className="h-4 w-4 text-muted-foreground" />
118-
<span>{t("deviceDetail.status.uptime")}</span>
161+
162+
{/* Right Column - Uptime and Restart Required */}
163+
<div className="space-y-4">
164+
{/* Uptime Section */}
165+
<div className="space-y-2">
166+
<div className="flex items-center space-x-2">
167+
<Clock className="h-4 w-4 text-muted-foreground" />
168+
<span>{t("deviceDetail.status.uptime")}</span>
169+
</div>
170+
<div className="pl-6">{formatUptime(summary.uptime)}</div>
171+
</div>
172+
173+
{/* Restart Required Section */}
174+
<div className="space-y-2">
175+
<div className="flex items-center space-x-2">
176+
<AlertTriangle className="h-4 w-4 text-muted-foreground" />
177+
<span>{t("deviceDetail.status.restartRequired")}</span>
178+
</div>
179+
<div className="pl-6">
180+
<Badge
181+
variant={
182+
deviceStatus.firmware.restart_required
183+
? "destructive"
184+
: "outline"
185+
}
186+
className="text-xs"
187+
>
188+
{deviceStatus.firmware.restart_required
189+
? t("deviceDetail.components.input.yes")
190+
: t("deviceDetail.components.input.no")}
191+
</Badge>
192+
</div>
119193
</div>
120-
<div className="pl-6">{formatUptime(summary.uptime)}</div>
121194
</div>
122195
</div>
123196
</CardContent>

packages/web/src/i18n/en.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -135,6 +135,7 @@
135135
"unnamedDevice": "Unnamed Device",
136136
"unknown": "Unknown",
137137
"firmware": "Firmware",
138+
"availableUpdates": "Available Updates",
138139
"overview": "Device Overview",
139140
"overviewDescription": "Component and power information",
140141
"switches": "Switches",

0 commit comments

Comments
 (0)