Skip to content

Commit a978f8a

Browse files
authored
Respect the selected channel when updating firmware (#75)
* Gate the update button on the selected channel The confirm button was gated on the device-wide has_updates flag while the release panel was gated per channel, so picking a channel with no firmware left the button live and sent the device an update it had nothing to install. Gate both on the same per-channel lookup, and show a message where the release panel would be when the channel is empty. The local source stays exempt: it resolves the release from the Shelly firmware index rather than from what the device reported. * Treat a channel entry without a version as no update A channel the device advertises without naming a build installs nothing, but the dialog read the raw entry as truthy, so it offered a release with a blank version and left the confirm button live. Read every channel through one helper that requires a version, and use it for the release panel, the confirm button, the channel labels and the header badges so they cannot drift apart again. The empty-channel message now separates a channel with no update from a device with no updates at all, and stays hidden until the status has loaded rather than claiming there is nothing while the request is still in flight. Check for Update now refreshes the cached status it invalidates, so a channel that gained a release stops reading as empty. UpdateInfo now marks the device-reported fields optional, and the filtered summary shape gets its own type, since it renames desc to description and is not interchangeable.
1 parent 48bfc7e commit a978f8a

6 files changed

Lines changed: 148 additions & 67 deletions

File tree

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

Lines changed: 54 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,12 @@ import {
3535
SelectValue,
3636
} from "@/components/ui/select";
3737
import { deviceApi, handleApiError } from "@/lib/api";
38+
import {
39+
UPDATE_CHANNELS,
40+
getChannelRelease,
41+
hasAnyRelease,
42+
type UpdateChannel,
43+
} from "@/lib/firmware-updates";
3844
import type { DeviceStatus, UpdateSource } from "@/types/api";
3945

4046
interface DeviceActionsProps {
@@ -51,9 +57,7 @@ export function DeviceActions({
5157
isRefreshing = false,
5258
}: DeviceActionsProps) {
5359
const { t } = useTranslation();
54-
const [updateChannel, setUpdateChannel] = useState<"stable" | "beta">(
55-
"stable",
56-
);
60+
const [updateChannel, setUpdateChannel] = useState<UpdateChannel>("stable");
5761
const [updateSource, setUpdateSource] = useState<UpdateSource>("internet");
5862
const [rebootDialogOpen, setRebootDialogOpen] = useState(false);
5963
const [updateDialogOpen, setUpdateDialogOpen] = useState(false);
@@ -64,7 +68,7 @@ export function DeviceActions({
6468
channel,
6569
source,
6670
}: {
67-
channel: "stable" | "beta";
71+
channel: UpdateChannel;
6872
source: UpdateSource;
6973
}) =>
7074
deviceApi.updateDevice(
@@ -123,8 +127,9 @@ export function DeviceActions({
123127
},
124128
});
125129

126-
const hasUpdates = deviceStatus?.summary.has_updates || false;
127-
const availableUpdates = deviceStatus?.firmware.available_updates || {};
130+
const availableUpdates = deviceStatus?.firmware.available_updates;
131+
const selectedRelease = getChannelRelease(availableUpdates, updateChannel);
132+
const hasUpdates = hasAnyRelease(availableUpdates);
128133

129134
return (
130135
<Card>
@@ -222,43 +227,62 @@ export function DeviceActions({
222227
</label>
223228
<Select
224229
value={updateChannel}
225-
onValueChange={(value: "stable" | "beta") =>
230+
onValueChange={(value: UpdateChannel) =>
226231
setUpdateChannel(value)
227232
}
228233
>
229234
<SelectTrigger>
230235
<SelectValue />
231236
</SelectTrigger>
232237
<SelectContent>
233-
<SelectItem value="stable">
234-
{t("bulkActions.stable")}
235-
</SelectItem>
236-
<SelectItem value="beta">
237-
{t("bulkActions.beta")}
238-
</SelectItem>
238+
{UPDATE_CHANNELS.map((channel) => {
239+
const release = getChannelRelease(
240+
availableUpdates,
241+
channel,
242+
);
243+
const label = t(`bulkActions.${channel}`);
244+
return (
245+
<SelectItem key={channel} value={channel}>
246+
{release
247+
? `${label} (${release.version})`
248+
: label}
249+
</SelectItem>
250+
);
251+
})}
239252
</SelectContent>
240253
</Select>
241254
</div>
242255
)}
243256

257+
{updateSource === "internet" && selectedRelease && (
258+
<div className="p-3 bg-muted rounded-lg space-y-2">
259+
<div className="text-sm font-medium">
260+
{selectedRelease.name ||
261+
t("deviceDetail.dialogs.updateFirmware.firmwareUpdate")}
262+
</div>
263+
<div className="text-sm text-muted-foreground">
264+
{t("deviceDetail.dialogs.updateFirmware.version")}:{" "}
265+
{selectedRelease.version}
266+
</div>
267+
{selectedRelease.desc && (
268+
<div className="text-xs text-muted-foreground">
269+
{selectedRelease.desc}
270+
</div>
271+
)}
272+
</div>
273+
)}
274+
244275
{updateSource === "internet" &&
245-
availableUpdates[updateChannel] && (
246-
<div className="p-3 bg-muted rounded-lg space-y-2">
247-
<div className="text-sm font-medium">
248-
{availableUpdates[updateChannel].name ||
249-
t(
250-
"deviceDetail.dialogs.updateFirmware.firmwareUpdate",
276+
deviceStatus &&
277+
!selectedRelease && (
278+
<div className="p-3 bg-muted rounded-lg text-sm text-muted-foreground">
279+
{hasUpdates
280+
? t(
281+
"deviceDetail.dialogs.updateFirmware.noUpdateOnChannel",
282+
)
283+
: t(
284+
"deviceDetail.dialogs.updateFirmware.noUpdatesAvailable",
251285
)}
252-
</div>
253-
<div className="text-sm text-muted-foreground">
254-
{t("deviceDetail.dialogs.updateFirmware.version")}:{" "}
255-
{availableUpdates[updateChannel].version}
256-
</div>
257-
{availableUpdates[updateChannel].desc && (
258-
<div className="text-xs text-muted-foreground">
259-
{availableUpdates[updateChannel].desc}
260-
</div>
261-
)}
262286
</div>
263287
)}
264288
</div>
@@ -279,7 +303,7 @@ export function DeviceActions({
279303
}
280304
disabled={
281305
updateMutation.isPending ||
282-
(updateSource === "internet" && !hasUpdates)
306+
(updateSource === "internet" && !selectedRelease)
283307
}
284308
>
285309
{updateMutation.isPending

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

Lines changed: 30 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import {
1919
CardHeader,
2020
CardTitle,
2121
} from "@/components/ui/card";
22+
import { getChannelReleases } from "@/lib/firmware-updates";
2223
import type { DeviceStatus } from "@/types/api";
2324

2425
interface DeviceHeaderProps {
@@ -57,6 +58,9 @@ export function DeviceHeader({ deviceStatus, isLoading }: DeviceHeaderProps) {
5758
}
5859

5960
const { summary, ip } = deviceStatus;
61+
const channelReleases = getChannelReleases(
62+
deviceStatus.firmware.available_updates,
63+
);
6064

6165
const getStatusBadge = () => {
6266
return (
@@ -133,40 +137,36 @@ export function DeviceHeader({ deviceStatus, isLoading }: DeviceHeaderProps) {
133137
</div>
134138

135139
{/* Available Updates */}
136-
{deviceStatus.firmware.available_updates &&
137-
Object.keys(deviceStatus.firmware.available_updates).length >
138-
0 && (
140+
{channelReleases.length > 0 && (
141+
<div className="space-y-1">
142+
<div className="text-xs text-muted-foreground">
143+
{t("deviceDetail.deviceInfo.availableUpdates")}:
144+
</div>
139145
<div className="space-y-1">
140-
<div className="text-xs text-muted-foreground">
141-
{t("deviceDetail.deviceInfo.availableUpdates")}:
142-
</div>
143-
<div className="space-y-1">
144-
{Object.entries(
145-
deviceStatus.firmware.available_updates,
146-
).map(([channel, update]) => (
147-
<div
148-
key={channel}
149-
className="flex items-center space-x-2"
146+
{channelReleases.map(({ channel, release }) => (
147+
<div
148+
key={channel}
149+
className="flex items-center space-x-2"
150+
>
151+
<Badge
152+
variant="secondary"
153+
className="text-xs px-2 py-0"
150154
>
151-
<Badge
152-
variant="secondary"
153-
className="text-xs px-2 py-0"
154-
>
155-
{channel}
156-
</Badge>
157-
<span className="text-xs font-mono">
158-
{update.version}
155+
{channel}
156+
</Badge>
157+
<span className="text-xs font-mono">
158+
{release.version}
159+
</span>
160+
{release.name && (
161+
<span className="text-xs text-muted-foreground">
162+
({release.name})
159163
</span>
160-
{update.name && (
161-
<span className="text-xs text-muted-foreground">
162-
({update.name})
163-
</span>
164-
)}
165-
</div>
166-
))}
167-
</div>
164+
)}
165+
</div>
166+
))}
168167
</div>
169-
)}
168+
</div>
169+
)}
170170
</div>
171171
</div>
172172

packages/web/src/hooks/useComponentActions.ts

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,10 @@ const GET_ACTIONS = [
2929
"ListAPClients",
3030
];
3131

32+
/** Reads that still change what a cached status would say, so they refresh it
33+
* despite being in GET_ACTIONS. */
34+
const STATUS_REFRESHING_ACTIONS = ["CheckForUpdate"];
35+
3236
interface ExecuteComponentActionParams {
3337
deviceIp: string;
3438
componentKey: string;
@@ -92,9 +96,7 @@ export function useExecuteComponentAction(
9296
parameters,
9397
),
9498
onSuccess: (result, variables) => {
95-
if (
96-
!GET_ACTIONS.some((getAction) => variables.action.includes(getAction))
97-
) {
99+
if (shouldRefreshStatus(variables.action)) {
98100
queryClient.invalidateQueries({
99101
queryKey: queryKeys.devices.status(variables.deviceIp),
100102
});
@@ -313,6 +315,13 @@ export function shouldShowResponseData(action: string): boolean {
313315
);
314316
}
315317

318+
function shouldRefreshStatus(action: string): boolean {
319+
if (STATUS_REFRESHING_ACTIONS.some((refresh) => action.includes(refresh))) {
320+
return true;
321+
}
322+
return !GET_ACTIONS.some((getAction) => action.includes(getAction));
323+
}
324+
316325
/**
317326
* Checks if a response contains meaningful data to display
318327
*/

packages/web/src/i18n/en.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -410,7 +410,9 @@
410410
"version": "Version",
411411
"firmwareUpdate": "Firmware Update",
412412
"startingUpdate": "Starting Update...",
413-
"startUpdate": "Start Update"
413+
"startUpdate": "Start Update",
414+
"noUpdateOnChannel": "No update available on this channel.",
415+
"noUpdatesAvailable": "This device reports no firmware updates available."
414416
},
415417
"rebootDevice": {
416418
"title": "Reboot Device",
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
import type { UpdateInfo } from "@/types/api";
2+
3+
export const UPDATE_CHANNELS = ["stable", "beta"] as const;
4+
5+
export type UpdateChannel = (typeof UPDATE_CHANNELS)[number];
6+
7+
export type FirmwareRelease = UpdateInfo & { version: string };
8+
9+
type AvailableUpdates = Record<string, UpdateInfo | undefined>;
10+
11+
export function getChannelRelease(
12+
availableUpdates: AvailableUpdates | undefined,
13+
channel: string,
14+
): FirmwareRelease | null {
15+
const release = availableUpdates?.[channel];
16+
return isRelease(release) ? release : null;
17+
}
18+
19+
export function getChannelReleases(
20+
availableUpdates: AvailableUpdates | undefined,
21+
): { channel: string; release: FirmwareRelease }[] {
22+
return Object.entries(availableUpdates ?? {}).flatMap(([channel, release]) =>
23+
isRelease(release) ? [{ channel, release }] : [],
24+
);
25+
}
26+
27+
export function hasAnyRelease(
28+
availableUpdates: AvailableUpdates | undefined,
29+
): boolean {
30+
return Object.values(availableUpdates ?? {}).some(isRelease);
31+
}
32+
33+
function isRelease(
34+
release: UpdateInfo | undefined,
35+
): release is FirmwareRelease {
36+
return typeof release?.version === "string" && release.version.length > 0;
37+
}

packages/web/src/types/api.ts

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -280,12 +280,21 @@ export interface EM1DataComponent extends Component {
280280
}
281281

282282
export interface UpdateInfo {
283-
version: string;
284-
build_id: string;
283+
version?: string;
284+
build_id?: string;
285285
name?: string;
286286
desc?: string;
287287
}
288288

289+
/** Summary entries rename desc to description, so this is not interchangeable
290+
* with UpdateInfo. */
291+
export interface SummaryUpdateInfo {
292+
version: string;
293+
build_id: string | null;
294+
name: string | null;
295+
description: string | null;
296+
}
297+
289298
export interface DeviceSummary {
290299
device_name: string | null;
291300
mac_address: string | null;
@@ -298,7 +307,7 @@ export interface DeviceSummary {
298307
total_power: number;
299308
any_switch_on: boolean;
300309
has_updates: boolean;
301-
available_updates: Record<string, UpdateInfo>;
310+
available_updates: Record<string, SummaryUpdateInfo>;
302311
restart_required: boolean;
303312
last_updated: string;
304313
}

0 commit comments

Comments
 (0)