Skip to content

Commit 5dd7bd4

Browse files
committed
feat: add ability to abort running OTA
1 parent 6b0b5c2 commit 5dd7bd4

3 files changed

Lines changed: 112 additions & 21 deletions

File tree

src/controller/helpers/ota.ts

Lines changed: 22 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -445,7 +445,7 @@ export class OtaSession {
445445
);
446446
}
447447

448-
public async run(): Promise<TZclFrame<"genOta", "upgradeEndRequest">> {
448+
public async run(abortSignal: AbortSignal): Promise<TZclFrame<"genOta", "upgradeEndRequest">> {
449449
// can take a long time, use max (int32 - 1), ~24 days
450450
const upgradeEndRequest = this.waitForOtaCommand<"upgradeEndRequest">(this.endpoint.ID, UPGRADE_END_REQUEST_ID, undefined, 2147483647);
451451

@@ -470,15 +470,20 @@ export class OtaSession {
470470
request.header.transactionSequenceNumber,
471471
pageOffset,
472472
pagePayload.pageSize,
473+
abortSignal.aborted,
473474
);
475+
476+
abortSignal.throwIfAborted();
474477
}
475478
} else {
476479
await this.sendImageBlockResponse(
477480
request.payload as TClusterPayload<"genOta", "imageBlockRequest">,
478481
request.header.transactionSequenceNumber,
479482
0,
480483
0,
484+
abortSignal.aborted,
481485
);
486+
abortSignal.throwIfAborted();
482487
}
483488
/* v8 ignore start */
484489
}
@@ -494,9 +499,12 @@ export class OtaSession {
494499
upgradeEndRequest.cancel();
495500

496501
const err = error as Error;
497-
err.message = `Device ${this.ieeeAddr} did not start/finish firmware download after being notified. (${err.message})`;
498502

499-
throw err;
503+
if (err.name === "AbortError") {
504+
throw new Error(`OTA for device ${this.ieeeAddr} was aborted`);
505+
}
506+
507+
throw new Error(`Device ${this.ieeeAddr} did not start/finish firmware download after being notified. (${err.message})`);
500508
}
501509
}
502510

@@ -533,6 +541,7 @@ export class OtaSession {
533541
requestTsn: number,
534542
pageOffset: number,
535543
pageSize: number,
544+
abort: boolean,
536545
): Promise<number> {
537546
// throttle if needed
538547
let callNow = performance.now();
@@ -548,6 +557,16 @@ export class OtaSession {
548557

549558
this.#lastBlockResponseTime = callNow;
550559

560+
if (abort) {
561+
try {
562+
await this.endpoint.commandResponse("genOta", "imageBlockResponse", {status: Zcl.Status.ABORT}, undefined, requestTsn);
563+
} catch (error) {
564+
logger.debug(() => `Abort image block response failed for ${this.ieeeAddr}: ${(error as Error).message}`, NS);
565+
}
566+
567+
return 0;
568+
}
569+
551570
try {
552571
const blockPayload = buildImageBlockPayload(this.image, requestPayload, pageOffset, pageSize, this.dataSettings.baseSize);
553572

src/controller/model/device.ts

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,7 @@ export class Device extends Entity<ControllerEventMap> {
7878
private _gpSecurityKey?: number[];
7979
#scheduledOta: OtaSource | undefined;
8080
#otaInProgress = false;
81+
#otaAbortController: AbortController | undefined;
8182

8283
// Getters/setters
8384
get ieeeAddr(): string {
@@ -1716,9 +1717,12 @@ export class Device extends Entity<ControllerEventMap> {
17161717
let endResult: TZclFrame<"genOta", "upgradeEndRequest">;
17171718

17181719
try {
1719-
endResult = await session.run();
1720+
this.#otaAbortController = new AbortController();
1721+
1722+
endResult = await session.run(this.#otaAbortController.signal);
17201723
} finally {
17211724
this.#otaInProgress = false;
1725+
this.#otaAbortController = undefined;
17221726
}
17231727

17241728
logger.debug(() => `Received upgrade end request for ${this.ieeeAddr}: ${JSON.stringify(endResult.payload)}`, NS);
@@ -1808,6 +1812,13 @@ export class Device extends Entity<ControllerEventMap> {
18081812
}
18091813
}
18101814

1815+
/**
1816+
* Abort running OTA if any. Send `ABORT` with next block response to device.
1817+
*/
1818+
abortOta(): void {
1819+
this.#otaAbortController?.abort();
1820+
}
1821+
18111822
scheduleOta(source: OtaSource): void {
18121823
assert(
18131824
this.endpoints.some((e) => e.supportsOutputCluster("genOta")),

test/device-ota.test.ts

Lines changed: 78 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -25,31 +25,33 @@ const IMAGE_PAGE_REQUEST_ID = Zcl.Clusters.genOta.commands.imagePageRequest.ID;
2525

2626
type OtaDeviceBehavior = {
2727
baseSize: number;
28-
/** Mimick the device sending image page requests instead of image block requests */
28+
/** Mimic the device sending image page requests instead of image block requests */
2929
usePageRequests?: boolean;
3030
pageSize?: number;
31-
/** Mimick the device sending non-value (0xff) as `maximumDataSize` */
31+
/** Mimic the device sending non-value (0xff) as `maximumDataSize` */
3232
useNonValueDataSize?: boolean;
33-
/** Mimick the device stopping image block/page requests at specified block (i.e. stalling) */
33+
/** Mimic the device stopping image block/page requests at specified block (i.e. stalling) */
3434
stopAfterBlocks?: number;
35-
/** Mimick a received default response after block 1. Will repeat last block. */
35+
/** Mimic a received default response after block 1. Will repeat last block. */
3636
triggerDefaultResponse?: Zcl.Status;
37-
/** Mimick the device sending out-of-order offset for block/page request, block 2 swapped with block 3 */
37+
/** Trigger abort after block 1. */
38+
abort?: boolean;
39+
/** Mimic the device sending out-of-order offset for block/page request, block 2 swapped with block 3 */
3840
shuffleOffsets?: boolean;
3941
/**
4042
* TODO: implement this
41-
* Mimick the device sending block/page request with an offset that is lower or higher than expected flow of "previous offset+data size" at block 2:
43+
* Mimic the device sending block/page request with an offset that is lower or higher than expected flow of "previous offset+data size" at block 2:
4244
* - normal flow would be something like: block1=[offset=0, dataSize=50], block2=[offset=50, dataSize=50], block3=[offset=100, dataSize=50]
4345
* - with this block2 has this applied to offset: block1=[offset=0, dataSize=50], block2=[offset=(dataSize-misalignedOffset), dataSize=50], block3=[offset=(dataSize*2-misalignedOffset), dataSize=50]
4446
*/
4547
misalignedOffset?: number;
46-
/** Mimick failing block 2 response (mimick device sending new image block/page request for same offset) */
47-
failBlockResponse?: boolean;
48-
/** Mimick the device sending or not of `upgradeEndRequest` (at end of block/page requests, or as specified by other behaviors) */
48+
/** Mimic failing given block response (mimic device sending new image block/page request for same offset OR failure to abort) */
49+
failBlockResponse?: number;
50+
/** Mimic the device sending or not of `upgradeEndRequest` (at end of block/page requests, or as specified by other behaviors) */
4951
sendUpgradeEnd?: boolean;
50-
/** Mimick the device sending that specific status in `upgradeEndRequest` */
52+
/** Mimic the device sending that specific status in `upgradeEndRequest` */
5153
upgradeEndStatus?: Zcl.Status;
52-
/** Mimick the device sending `upgradeEndRequest` after that specific block/page request */
54+
/** Mimic the device sending `upgradeEndRequest` after that specific block/page request */
5355
upgradeEndAfterBlocks?: number;
5456
};
5557

@@ -68,6 +70,7 @@ const createEndpointStub = () => {
6870
};
6971

7072
const createOtaDeviceWaitFor = (
73+
device: Device,
7174
endpoint: Endpoint,
7275
image: OtaImage,
7376
current: TClusterCommandPayload<"genOta", "queryNextImageRequest">,
@@ -113,10 +116,10 @@ const createOtaDeviceWaitFor = (
113116
});
114117
};
115118

116-
if (settings.failBlockResponse) {
119+
if (settings.failBlockResponse !== undefined) {
117120
endpoint.commandResponse = vi.fn((_clusterKey, commandKey, _payload, _options, _transactionSequenceNumber) => {
118121
if (commandKey === "imageBlockResponse") {
119-
if (blocksServed === 1) {
122+
if (blocksServed === settings.failBlockResponse) {
120123
repeatLastBlock = true;
121124
return Promise.reject(new Error("block-fail"));
122125
}
@@ -220,6 +223,10 @@ const createOtaDeviceWaitFor = (
220223
maybeScheduleUpgradeEnd();
221224
}
222225

226+
if (blocksServed === 2 && settings.abort) {
227+
device.abortOta();
228+
}
229+
223230
return {
224231
promise: Promise.resolve({
225232
clusterID: OTA_CLUSTER_ID,
@@ -567,19 +574,18 @@ const createDevice = ({
567574
database.write = () => {};
568575
Entity.injectDatabase(database);
569576

577+
const device = Device.create("Router", "0x1", 0x1001, 1, manufacturerName, "Mains", modelID, InterviewState.Successful, undefined);
570578
const endpoint = createEndpointStub();
571579
const currentPayload: TClusterCommandPayload<"genOta", "queryNextImageRequest"> = requestPayload ?? {
572580
fieldControl: 0,
573581
manufacturerCode: image.header.manufacturerCode,
574582
imageType: image.header.imageType,
575583
fileVersion: image.header.fileVersion + (source?.downgrade ? 1 : -1),
576584
};
577-
const waitFor = createOtaDeviceWaitFor(endpoint, image, currentPayload, {baseSize: dataSettings.baseSize, ...behavior});
585+
const waitFor = createOtaDeviceWaitFor(device, endpoint, image, currentPayload, {baseSize: dataSettings.baseSize, ...behavior});
578586
const adapter = {waitFor, hasZdoMessageOverhead: false} as unknown as Adapter;
579587
Entity.injectAdapter(adapter);
580588

581-
const device = Device.create("Router", "0x1", 0x1001, 1, manufacturerName, "Mains", modelID, InterviewState.Successful, undefined);
582-
583589
if (autoAnnounce) {
584590
const originalOnce = device.once.bind(device);
585591

@@ -1200,6 +1206,61 @@ describe("Device OTA", () => {
12001206
expect(device.otaInProgress).toStrictEqual(false);
12011207
});
12021208

1209+
it("aborts an in-progress update", async () => {
1210+
const fileName = OTA_FILES[0];
1211+
const [image] = await loadImage(fileName);
1212+
firmwareBuffer = image.raw;
1213+
const requestPayload: TClusterCommandPayload<"genOta", "queryNextImageRequest"> = {
1214+
fieldControl: 0,
1215+
manufacturerCode: image.header.manufacturerCode,
1216+
imageType: image.header.imageType,
1217+
fileVersion: image.header.fileVersion - 1,
1218+
};
1219+
const baseSize = 55;
1220+
1221+
const {device, endpoint, run} = createDevice({
1222+
image,
1223+
source: {},
1224+
requestPayload,
1225+
dataSettings: {requestTimeout: 1000, responseDelay: 0, baseSize},
1226+
behavior: {baseSize, sendUpgradeEnd: true, abort: true},
1227+
});
1228+
1229+
await expect(run()).rejects.toThrow(/OTA.*was aborted/);
1230+
const calls = getResponses(endpoint, "imageBlockResponse");
1231+
expect(getResponses(endpoint, "imageBlockResponse").length).toStrictEqual(2);
1232+
expect(calls[1][2]).toStrictEqual({status: Zcl.Status.ABORT});
1233+
expect(device.otaInProgress).toStrictEqual(false);
1234+
});
1235+
1236+
it("fails to abort an in-progress update", async () => {
1237+
// this is same as success for ZH, only the device may not have aborted itself
1238+
const fileName = OTA_FILES[0];
1239+
const [image] = await loadImage(fileName);
1240+
firmwareBuffer = image.raw;
1241+
const requestPayload: TClusterCommandPayload<"genOta", "queryNextImageRequest"> = {
1242+
fieldControl: 0,
1243+
manufacturerCode: image.header.manufacturerCode,
1244+
imageType: image.header.imageType,
1245+
fileVersion: image.header.fileVersion - 1,
1246+
};
1247+
const baseSize = 55;
1248+
1249+
const {device, endpoint, run} = createDevice({
1250+
image,
1251+
source: {},
1252+
requestPayload,
1253+
dataSettings: {requestTimeout: 1000, responseDelay: 0, baseSize},
1254+
behavior: {baseSize, sendUpgradeEnd: true, abort: true, failBlockResponse: 2},
1255+
});
1256+
1257+
await expect(run()).rejects.toThrow(/OTA.*was aborted/);
1258+
const calls = getResponses(endpoint, "imageBlockResponse");
1259+
expect(getResponses(endpoint, "imageBlockResponse").length).toStrictEqual(2);
1260+
expect(calls[1][2]).toStrictEqual({status: Zcl.Status.ABORT});
1261+
expect(device.otaInProgress).toStrictEqual(false);
1262+
});
1263+
12031264
it("considers an upgrade successful even if no device announce", async () => {
12041265
const fileName = OTA_FILES[0];
12051266
const [image] = await loadImage(fileName);
@@ -2202,7 +2263,7 @@ describe("Device OTA", () => {
22022263
image,
22032264
source: {},
22042265
dataSettings,
2205-
behavior: {baseSize, sendUpgradeEnd: true, failBlockResponse: true},
2266+
behavior: {baseSize, sendUpgradeEnd: true, failBlockResponse: 1},
22062267
});
22072268

22082269
const [from, to] = await run();

0 commit comments

Comments
 (0)