@intomind/sdk 0.0.0-stage → 1.4.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CLA.md +71 -0
- package/CONTRIBUTING.md +53 -0
- package/LICENSE +662 -0
- package/LICENSING.md +45 -0
- package/README.md +147 -2
- package/dist/digest.d.ts +22 -0
- package/dist/digest.d.ts.map +1 -0
- package/dist/digest.js +109 -0
- package/dist/digest.js.map +1 -0
- package/dist/index.d.ts +38 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +33 -0
- package/dist/index.js.map +1 -0
- package/dist/protocol.d.ts +1433 -0
- package/dist/protocol.d.ts.map +1 -0
- package/dist/protocol.js +2227 -0
- package/dist/protocol.js.map +1 -0
- package/dist/session.d.ts +284 -0
- package/dist/session.d.ts.map +1 -0
- package/dist/session.js +523 -0
- package/dist/session.js.map +1 -0
- package/dist/timebase.d.ts +65 -0
- package/dist/timebase.d.ts.map +1 -0
- package/dist/timebase.js +114 -0
- package/dist/timebase.js.map +1 -0
- package/dist/transfer.d.ts +139 -0
- package/dist/transfer.d.ts.map +1 -0
- package/dist/transfer.js +269 -0
- package/dist/transfer.js.map +1 -0
- package/dist/web-bluetooth.d.ts +172 -0
- package/dist/web-bluetooth.d.ts.map +1 -0
- package/dist/web-bluetooth.js +310 -0
- package/dist/web-bluetooth.js.map +1 -0
- package/package.json +74 -4
- package/src/digest.ts +115 -0
- package/src/index.ts +103 -0
- package/src/protocol.ts +2987 -0
- package/src/session.ts +675 -0
- package/src/timebase.ts +138 -0
- package/src/transfer.ts +313 -0
- package/src/web-bluetooth.ts +419 -0
package/src/timebase.ts
ADDED
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Device time onto a host clock.
|
|
3
|
+
*
|
|
4
|
+
* A device's clock is monotonic and free running. A host's is a different
|
|
5
|
+
* crystal. The fit between them is a line, and the point of taking several
|
|
6
|
+
* exchanges rather than one is that a single exchange cannot tell a clock
|
|
7
|
+
* difference from a slow answer.
|
|
8
|
+
*
|
|
9
|
+
* The fit is refused rather than believed when it is absurd. Two crystals
|
|
10
|
+
* do not differ by a thousand parts per million, and a measurement that
|
|
11
|
+
* says they do is measuring something else, usually a link that stalled in
|
|
12
|
+
* the middle of an exchange.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
/** Beyond this, a skew is not two crystals disagreeing. */
|
|
16
|
+
export const MAX_SKEW_PPM = 1000.0;
|
|
17
|
+
/** And it has to be measured rather than drawn through noise. */
|
|
18
|
+
export const MAX_SKEW_STDERR_PPM = 100.0;
|
|
19
|
+
|
|
20
|
+
/** One time exchange, read on both clocks. */
|
|
21
|
+
export class Exchange {
|
|
22
|
+
/** Host time before the request, in seconds. */
|
|
23
|
+
readonly before: number;
|
|
24
|
+
/** Host time after the answer. */
|
|
25
|
+
readonly after: number;
|
|
26
|
+
/** The device's own time, in its ticks, captured when it read the request. */
|
|
27
|
+
readonly deviceTicks: bigint;
|
|
28
|
+
|
|
29
|
+
/** One exchange: the host clock either side of the request, and the device's own answer in its ticks. */
|
|
30
|
+
constructor(before: number, after: number, deviceTicks: bigint) {
|
|
31
|
+
this.before = before;
|
|
32
|
+
this.after = after;
|
|
33
|
+
this.deviceTicks = deviceTicks;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** The middle of the window the device's answer must lie in. */
|
|
37
|
+
get host(): number {
|
|
38
|
+
return (this.before + this.after) / 2;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* How wide that window is. Half the round trip, which is the uncertainty
|
|
43
|
+
* in this one exchange.
|
|
44
|
+
*/
|
|
45
|
+
get uncertainty(): number {
|
|
46
|
+
return (this.after - this.before) / 2;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** The line through the exchanges. */
|
|
51
|
+
export interface Fit {
|
|
52
|
+
/** host = deviceSeconds * (1 + skew) + offset. */
|
|
53
|
+
readonly offsetS: number;
|
|
54
|
+
/** The measured clock skew, in parts per million. 0 when `skewUsed` is false. */
|
|
55
|
+
readonly skewPpm: number;
|
|
56
|
+
/** Exchanges this fit was computed from. */
|
|
57
|
+
readonly exchanges: number;
|
|
58
|
+
/** How far the exchanges sit from the line, in microseconds. */
|
|
59
|
+
readonly residualUs: number;
|
|
60
|
+
/** Whether the skew was measured well enough to be used. */
|
|
61
|
+
readonly skewUsed: boolean;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Device time onto host time, and the exchanges it was learned from. */
|
|
65
|
+
export class Timebase {
|
|
66
|
+
/** The device's own tick rate, from device info. */
|
|
67
|
+
readonly tickHz: number;
|
|
68
|
+
#exchanges: Exchange[] = [];
|
|
69
|
+
#fit: Fit | null = null;
|
|
70
|
+
|
|
71
|
+
/** `tickHz` is the device's own, from device info. */
|
|
72
|
+
constructor(tickHz: number) {
|
|
73
|
+
this.tickHz = tickHz;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Take one exchange. The fit is redone from everything so far. */
|
|
77
|
+
observe(exchange: Exchange): Fit | null {
|
|
78
|
+
this.#exchanges.push(exchange);
|
|
79
|
+
this.#refit();
|
|
80
|
+
return this.#fit;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Every exchange taken so far. */
|
|
84
|
+
get exchanges(): readonly Exchange[] {
|
|
85
|
+
return this.#exchanges;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** The current fit, or null before the first exchange. */
|
|
89
|
+
get fit(): Fit | null {
|
|
90
|
+
return this.#fit;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Host time for a device time, in seconds. Null until there is a fit. */
|
|
94
|
+
hostTime(deviceTicks: bigint): number | null {
|
|
95
|
+
if (this.#fit === null) return null;
|
|
96
|
+
const d = Number(deviceTicks) / this.tickHz;
|
|
97
|
+
return d * (1 + this.#fit.skewPpm * 1e-6) + this.#fit.offsetS;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
#refit(): void {
|
|
101
|
+
const n = this.#exchanges.length;
|
|
102
|
+
if (n === 0) {
|
|
103
|
+
this.#fit = null;
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
const xs = this.#exchanges.map((e) => Number(e.deviceTicks) / this.tickHz);
|
|
107
|
+
const ys = this.#exchanges.map((e) => e.host);
|
|
108
|
+
if (n === 1) {
|
|
109
|
+
// One exchange fixes an offset and says nothing about rate. Claiming
|
|
110
|
+
// a skew from it would be claiming a measurement that was never made.
|
|
111
|
+
this.#fit = {
|
|
112
|
+
offsetS: ys[0]! - xs[0]!,
|
|
113
|
+
skewPpm: 0,
|
|
114
|
+
exchanges: 1,
|
|
115
|
+
residualUs: this.#exchanges[0]!.uncertainty * 1e6,
|
|
116
|
+
skewUsed: false,
|
|
117
|
+
};
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
const meanX = xs.reduce((a, b) => a + b, 0) / n;
|
|
121
|
+
const meanY = ys.reduce((a, b) => a + b, 0) / n;
|
|
122
|
+
const sxx = xs.reduce((acc, x) => acc + (x - meanX) ** 2, 0);
|
|
123
|
+
const sxy = xs.reduce((acc, x, i) => acc + (x - meanX) * (ys[i]! - meanY), 0);
|
|
124
|
+
const slope = sxx > 0 ? sxy / sxx : 1;
|
|
125
|
+
const intercept = meanY - slope * meanX;
|
|
126
|
+
const rss = xs.reduce((acc, x, i) => acc + (ys[i]! - (slope * x + intercept)) ** 2, 0);
|
|
127
|
+
const residualUs = Math.sqrt(rss / n) * 1e6;
|
|
128
|
+
const skewPpm = (slope - 1) * 1e6;
|
|
129
|
+
// The standard error of the slope, in the same units.
|
|
130
|
+
const stderrPpm = n > 2 && sxx > 0 ? Math.sqrt(rss / (n - 2) / sxx) * 1e6 : Infinity;
|
|
131
|
+
const believable = Math.abs(skewPpm) <= MAX_SKEW_PPM && stderrPpm <= MAX_SKEW_STDERR_PPM;
|
|
132
|
+
this.#fit = believable
|
|
133
|
+
? { offsetS: intercept, skewPpm, exchanges: n, residualUs, skewUsed: true }
|
|
134
|
+
: // Keep the offset, which is measured well, and drop the rate, which
|
|
135
|
+
// is not. A wrong rate is worse than no rate: it grows.
|
|
136
|
+
{ offsetS: meanY - meanX, skewPpm: 0, exchanges: n, residualUs, skewUsed: false };
|
|
137
|
+
}
|
|
138
|
+
}
|
package/src/transfer.ts
ADDED
|
@@ -0,0 +1,313 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Carrying an image or a head to a device, without doing any input or
|
|
3
|
+
* output.
|
|
4
|
+
*
|
|
5
|
+
* The same three things go over this path: an application image, the
|
|
6
|
+
* model weights, and a head. The first two are ours, signed and
|
|
7
|
+
* encrypted, and the device checks the signature against the key in its
|
|
8
|
+
* own flash. A head is the user's, carries its own hash, and is checked
|
|
9
|
+
* for that hash and for its shape. Nothing here can forge any of them,
|
|
10
|
+
* which is the point: a host carries an update without being able to
|
|
11
|
+
* read or write one.
|
|
12
|
+
*
|
|
13
|
+
* Nothing is activated by a transfer. The image lands in the idle slot
|
|
14
|
+
* and is verified there, and putting it in force is a separate act.
|
|
15
|
+
*
|
|
16
|
+
* This is a state machine, like the session. Ask it what to do, do that,
|
|
17
|
+
* and hand back what came of it. `Connection.transfer` drives it for you
|
|
18
|
+
* in a browser.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import { crc32, sha256 } from "./digest.ts";
|
|
22
|
+
import {
|
|
23
|
+
CAPABILITIES,
|
|
24
|
+
FILL,
|
|
25
|
+
decodeHead,
|
|
26
|
+
decodeUpdateQuery,
|
|
27
|
+
decodeUpdateResponse,
|
|
28
|
+
decodeUpdateStart,
|
|
29
|
+
encodeUpdateOp,
|
|
30
|
+
encodeUpdateStart,
|
|
31
|
+
type UpdateResponse,
|
|
32
|
+
type UpdateTargetName,
|
|
33
|
+
} from "./protocol.ts";
|
|
34
|
+
import type { Command } from "./session.ts";
|
|
35
|
+
import { NotCapable, Session } from "./session.ts";
|
|
36
|
+
|
|
37
|
+
/** How many writes go out before the device is asked what it has. */
|
|
38
|
+
const WRITES_PER_CHECK = 32;
|
|
39
|
+
|
|
40
|
+
/** The largest write this contract allows, whatever a device claims. */
|
|
41
|
+
const CHUNK_CEILING = 244;
|
|
42
|
+
|
|
43
|
+
/** What a transfer needs done next. */
|
|
44
|
+
export type Step =
|
|
45
|
+
/** Write this and hand the answer to `onAnswer`. */
|
|
46
|
+
| { readonly type: "send"; readonly command: Command }
|
|
47
|
+
/**
|
|
48
|
+
* Write `image.subarray(from, to)` to this characteristic without a
|
|
49
|
+
* response, then call `sent` with how many bytes went out.
|
|
50
|
+
*/
|
|
51
|
+
| { readonly type: "data"; readonly characteristic: number; readonly from: number; readonly to: number }
|
|
52
|
+
/** The device has the image and has checked it. Nothing was activated. */
|
|
53
|
+
| { readonly type: "verified"; readonly result: string | null };
|
|
54
|
+
|
|
55
|
+
/** Why a transfer stopped. */
|
|
56
|
+
export class TransferError extends Error {
|
|
57
|
+
/** Always "TransferError". */
|
|
58
|
+
override readonly name: string = "TransferError";
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** The device refused a request. */
|
|
62
|
+
export class Refused extends TransferError {
|
|
63
|
+
/** Always "Refused". */
|
|
64
|
+
override readonly name = "Refused";
|
|
65
|
+
/** Which operation the device refused. */
|
|
66
|
+
readonly op: string;
|
|
67
|
+
/** The device's status for that operation, in words. */
|
|
68
|
+
readonly status: string;
|
|
69
|
+
/** Build one naming the refused operation and the device's status. */
|
|
70
|
+
constructor(op: string, status: string) {
|
|
71
|
+
super(`the device refused ${op}: ${status}`);
|
|
72
|
+
this.op = op;
|
|
73
|
+
this.status = status;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** The device holds a different number of bytes than were sent. */
|
|
78
|
+
export class OffsetMismatch extends TransferError {
|
|
79
|
+
/** Always "OffsetMismatch". */
|
|
80
|
+
override readonly name = "OffsetMismatch";
|
|
81
|
+
/** Bytes this side has sent. */
|
|
82
|
+
readonly sent: number;
|
|
83
|
+
/** Bytes the device says it holds. */
|
|
84
|
+
readonly device: number;
|
|
85
|
+
/** Build one with what was sent and what the device reports holding. */
|
|
86
|
+
constructor(sent: number, device: number) {
|
|
87
|
+
super(`the device accepted ${device} bytes where ${sent} were sent`);
|
|
88
|
+
this.sent = sent;
|
|
89
|
+
this.device = device;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** The bytes arrived changed. */
|
|
94
|
+
export class ChecksumMismatch extends TransferError {
|
|
95
|
+
/** Always "ChecksumMismatch". */
|
|
96
|
+
override readonly name = "ChecksumMismatch";
|
|
97
|
+
/** Bytes checked. */
|
|
98
|
+
readonly sent: number;
|
|
99
|
+
/** This side's checksum over those bytes. */
|
|
100
|
+
readonly ours: number;
|
|
101
|
+
/** The device's checksum over the same bytes. */
|
|
102
|
+
readonly device: number;
|
|
103
|
+
/** Build one with the bytes checked and the two checksums. */
|
|
104
|
+
constructor(sent: number, ours: number, device: number) {
|
|
105
|
+
super(
|
|
106
|
+
`over ${sent} bytes our checksum is ${hex32(ours)} and the device's is ${hex32(device)}`,
|
|
107
|
+
);
|
|
108
|
+
this.sent = sent;
|
|
109
|
+
this.ours = ours;
|
|
110
|
+
this.device = device;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** The device refused the finished image, and said why. */
|
|
115
|
+
export class Rejected extends TransferError {
|
|
116
|
+
/** Always "Rejected". */
|
|
117
|
+
override readonly name = "Rejected";
|
|
118
|
+
/** Why the device refused the image, or null when it gave no reason. */
|
|
119
|
+
readonly result: string | null;
|
|
120
|
+
/** Build one with the device's reason, or null when it gave none. */
|
|
121
|
+
constructor(result: string | null) {
|
|
122
|
+
super(`the device refused the image: ${result ?? "no reason given"}`);
|
|
123
|
+
this.result = result;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** An answer arrived that this transfer did not ask for. */
|
|
128
|
+
export class Unexpected extends TransferError {
|
|
129
|
+
/** Always "Unexpected". */
|
|
130
|
+
override readonly name = "Unexpected";
|
|
131
|
+
/** Build one, with a default message or one of your own. */
|
|
132
|
+
constructor(message = "an answer arrived that this transfer did not ask for") {
|
|
133
|
+
super(message);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function hex32(n: number): string {
|
|
138
|
+
return `0x${(n >>> 0).toString(16).padStart(8, "0")}`;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
type Phase = "starting" | "sending" | "checking" | "finishing" | "done";
|
|
142
|
+
|
|
143
|
+
/** One image, on its way to one device. */
|
|
144
|
+
export class Transfer {
|
|
145
|
+
readonly #target: UpdateTargetName;
|
|
146
|
+
readonly #slot: number;
|
|
147
|
+
readonly #total: number;
|
|
148
|
+
readonly #id: Uint8Array;
|
|
149
|
+
#offset = 0;
|
|
150
|
+
#chunk: number;
|
|
151
|
+
#sinceCheck = 0;
|
|
152
|
+
#phase: Phase = "starting";
|
|
153
|
+
#result: string | null = null;
|
|
154
|
+
|
|
155
|
+
private constructor(target: UpdateTargetName, slot: number, image: Uint8Array, chunk: number) {
|
|
156
|
+
this.#target = target;
|
|
157
|
+
this.#slot = slot;
|
|
158
|
+
this.#total = image.length;
|
|
159
|
+
// A transfer names itself by what is being sent, so an interrupted
|
|
160
|
+
// one resumes only against the same bytes and never against
|
|
161
|
+
// different ones that happen to be the same length.
|
|
162
|
+
this.#id = sha256(image).subarray(0, 8);
|
|
163
|
+
this.#chunk = chunk;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** An application image, for the slot the device is not running from. */
|
|
167
|
+
static app(session: Session, slot: number, image: Uint8Array): Transfer {
|
|
168
|
+
return new Transfer("app", slot, image, chunkFor(session, image));
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** The model weights. There is one copy of them, so no slot. */
|
|
172
|
+
static weights(session: Session, image: Uint8Array): Transfer {
|
|
173
|
+
return new Transfer("weights", 0xff, image, chunkFor(session, image));
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* A head, into one of the device's head slots.
|
|
178
|
+
*
|
|
179
|
+
* The blob is checked here against the width of the embedding this
|
|
180
|
+
* device's model produces, so a head trained for a different encoder is
|
|
181
|
+
* refused before any of it goes on the wire.
|
|
182
|
+
*/
|
|
183
|
+
static head(session: Session, slot: number, blob: Uint8Array): Transfer {
|
|
184
|
+
const info = session.info;
|
|
185
|
+
if (info === null) throw new NotCapable("heads");
|
|
186
|
+
if ((info.capabilities & CAPABILITIES.heads) === 0) throw new NotCapable("heads");
|
|
187
|
+
if (info.modelEmbedDim === null || info.headMaxOutputs === null) throw new NotCapable("heads");
|
|
188
|
+
const head = decodeHead(blob);
|
|
189
|
+
if (head.inDim !== info.modelEmbedDim) {
|
|
190
|
+
throw new TransferError(
|
|
191
|
+
`this head takes an embedding of ${head.inDim} and the device's model produces ${info.modelEmbedDim}`,
|
|
192
|
+
);
|
|
193
|
+
}
|
|
194
|
+
if (head.outDim > info.headMaxOutputs) {
|
|
195
|
+
throw new TransferError(
|
|
196
|
+
`this head has ${head.outDim} outputs and the device takes at most ${info.headMaxOutputs}`,
|
|
197
|
+
);
|
|
198
|
+
}
|
|
199
|
+
return new Transfer("head", slot, blob, chunkFor(session, blob));
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/** How much the device has taken, of how much there is. */
|
|
203
|
+
get progress(): { taken: number; total: number } {
|
|
204
|
+
return { taken: this.#offset, total: this.#total };
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/** What to do next. */
|
|
208
|
+
step(image: Uint8Array): Step {
|
|
209
|
+
switch (this.#phase) {
|
|
210
|
+
case "starting":
|
|
211
|
+
return send(encodeUpdateStart(this.#target, this.#slot, this.#total, this.#id));
|
|
212
|
+
case "checking":
|
|
213
|
+
return send(encodeUpdateOp("query"));
|
|
214
|
+
case "finishing":
|
|
215
|
+
return send(encodeUpdateOp("finish"));
|
|
216
|
+
case "done":
|
|
217
|
+
return { type: "verified", result: this.#result };
|
|
218
|
+
case "sending": {
|
|
219
|
+
const to = Math.min(this.#offset + this.#chunk, image.length);
|
|
220
|
+
return { type: "data", characteristic: FILL.UPDATE_DATA, from: this.#offset, to };
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/** Say how many bytes went out, after writing what `step` asked for. */
|
|
226
|
+
sent(n: number): void {
|
|
227
|
+
if (this.#phase !== "sending") return;
|
|
228
|
+
this.#offset = Math.min(this.#offset + n, this.#total);
|
|
229
|
+
this.#sinceCheck += 1;
|
|
230
|
+
if (this.#sinceCheck >= WRITES_PER_CHECK || this.#offset === this.#total) {
|
|
231
|
+
this.#sinceCheck = 0;
|
|
232
|
+
this.#phase = "checking";
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/** Hand in the answer to whatever `step` last asked to be sent. */
|
|
237
|
+
onAnswer(bytes: Uint8Array, image: Uint8Array): void {
|
|
238
|
+
let answer: UpdateResponse;
|
|
239
|
+
try {
|
|
240
|
+
answer = decodeUpdateResponse(bytes);
|
|
241
|
+
} catch (e) {
|
|
242
|
+
throw new Unexpected(`that is not an update answer: ${(e as Error).message}`);
|
|
243
|
+
}
|
|
244
|
+
if (this.#phase === "starting" && answer.opName === "start") {
|
|
245
|
+
this.#refuse(answer);
|
|
246
|
+
const start = decodeUpdateStart(answer.payload);
|
|
247
|
+
// The device may already hold part of this transfer, and says so.
|
|
248
|
+
// Believe it, and test the claim at the next checkpoint like any
|
|
249
|
+
// other.
|
|
250
|
+
this.#offset = Math.min(start.resumeOffset, this.#total);
|
|
251
|
+
if (start.chunkMax > 0) this.#chunk = Math.min(this.#chunk, start.chunkMax);
|
|
252
|
+
this.#sinceCheck = 0;
|
|
253
|
+
this.#phase = this.#offset === this.#total ? "checking" : "sending";
|
|
254
|
+
return;
|
|
255
|
+
}
|
|
256
|
+
if (this.#phase === "checking" && answer.opName === "query") {
|
|
257
|
+
this.#refuse(answer);
|
|
258
|
+
const q = decodeUpdateQuery(answer.payload);
|
|
259
|
+
if (q.offset !== this.#offset) throw new OffsetMismatch(this.#offset, q.offset);
|
|
260
|
+
const ours = crc32(image.subarray(0, this.#offset));
|
|
261
|
+
if ((q.crc32 >>> 0) !== (ours >>> 0)) {
|
|
262
|
+
throw new ChecksumMismatch(this.#offset, ours, q.crc32);
|
|
263
|
+
}
|
|
264
|
+
this.#phase = this.#offset === this.#total ? "finishing" : "sending";
|
|
265
|
+
return;
|
|
266
|
+
}
|
|
267
|
+
if (this.#phase === "finishing" && answer.opName === "finish") {
|
|
268
|
+
if (!answer.ok) throw new Rejected(answer.verifyResultName);
|
|
269
|
+
this.#result = answer.verifyResultName;
|
|
270
|
+
this.#phase = "done";
|
|
271
|
+
return;
|
|
272
|
+
}
|
|
273
|
+
throw new Unexpected();
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/** Give up. The device drops what it has. */
|
|
277
|
+
abort(): Command {
|
|
278
|
+
return send(encodeUpdateOp("abort")).command;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
#refuse(answer: UpdateResponse): void {
|
|
282
|
+
if (!answer.ok) throw new Refused(answer.opName ?? "an update", answer.statusName);
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/**
|
|
287
|
+
* Put a transferred image in force.
|
|
288
|
+
*
|
|
289
|
+
* The device resets, so the link drops and everything a caller knows
|
|
290
|
+
* about it is stale. An application comes back on trial and rolls itself
|
|
291
|
+
* back if it cannot confirm. Weights are verified at every boot, so
|
|
292
|
+
* restarting is what puts new ones in force.
|
|
293
|
+
*/
|
|
294
|
+
export function activate(): Command {
|
|
295
|
+
return send(encodeUpdateOp("activate")).command;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
function send(bytes: Uint8Array): { type: "send"; command: Command } {
|
|
299
|
+
return {
|
|
300
|
+
type: "send",
|
|
301
|
+
command: { characteristic: FILL.UPDATE_CONTROL, bytes, withResponse: true },
|
|
302
|
+
};
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
function chunkFor(session: Session, image: Uint8Array): number {
|
|
306
|
+
const info = session.info;
|
|
307
|
+
if (info === null) throw new NotCapable("update");
|
|
308
|
+
if ((info.capabilities & CAPABILITIES.update) === 0) throw new NotCapable("update");
|
|
309
|
+
if (image.length === 0) throw new TransferError("nothing is not an image");
|
|
310
|
+
const claimed = info.updateChunkMax ?? CHUNK_CEILING;
|
|
311
|
+
return Math.min(claimed > 0 ? claimed : CHUNK_CEILING, CHUNK_CEILING);
|
|
312
|
+
}
|
|
313
|
+
|