@altronix/cli 0.13.0 → 0.15.0
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/dist/build.d.ts +2 -0
- package/dist/build.js +443 -0
- package/dist/build.ui.d.ts +27 -0
- package/dist/build.ui.js +95 -0
- package/dist/index.js +10 -127
- package/dist/keys.d.ts +7 -0
- package/dist/keys.js +14 -0
- package/dist/plugin.js +1 -1
- package/dist/progress.ui.js +1 -1
- package/dist/update.d.ts +41 -1
- package/dist/update.js +155 -53
- package/dist/update.ui.d.ts +27 -0
- package/dist/update.ui.js +119 -0
- package/dist/useStdoutDimensions.d.ts +1 -0
- package/dist/useStdoutDimensions.js +17 -0
- package/package.json +18 -18
package/dist/build.d.ts
ADDED
package/dist/build.js
ADDED
|
@@ -0,0 +1,443 @@
|
|
|
1
|
+
import dotenv from 'dotenv';
|
|
2
|
+
import { Ajv } from 'ajv';
|
|
3
|
+
import { parse as parseJsonc } from 'jsonc-parser';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import fs from 'node:fs';
|
|
6
|
+
import cp from 'node:child_process';
|
|
7
|
+
import inquirer from '@inquirer/confirm';
|
|
8
|
+
import { concat, concatMap, EMPTY, EmptyError, from, last, lastValueFrom, map, merge, mergeMap, Observable, of, share, tap, toArray } from 'rxjs';
|
|
9
|
+
import { PassThrough } from 'node:stream';
|
|
10
|
+
import { render } from 'ink';
|
|
11
|
+
import React from 'react';
|
|
12
|
+
import Ui from './build.ui.js';
|
|
13
|
+
import { keys } from './keys.js';
|
|
14
|
+
const schemaBoard = {
|
|
15
|
+
type: 'object',
|
|
16
|
+
required: ['images'],
|
|
17
|
+
properties: {
|
|
18
|
+
version: { type: 'string', nullable: true },
|
|
19
|
+
soc: { type: 'string', nullable: true },
|
|
20
|
+
cpu: { type: 'string', nullable: true },
|
|
21
|
+
variant: { type: 'string', nullable: true },
|
|
22
|
+
images: {
|
|
23
|
+
type: 'object',
|
|
24
|
+
required: [],
|
|
25
|
+
additionalProperties: {
|
|
26
|
+
type: 'object',
|
|
27
|
+
properties: {
|
|
28
|
+
configs: {
|
|
29
|
+
type: 'array',
|
|
30
|
+
items: { type: 'string' },
|
|
31
|
+
nullable: true
|
|
32
|
+
},
|
|
33
|
+
overlays: {
|
|
34
|
+
type: 'array',
|
|
35
|
+
items: { type: 'string' },
|
|
36
|
+
nullable: true
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
};
|
|
43
|
+
const schemaBuild = {
|
|
44
|
+
type: 'object',
|
|
45
|
+
required: [],
|
|
46
|
+
additionalProperties: {
|
|
47
|
+
type: 'object',
|
|
48
|
+
required: ['sourceDir', 'binaryDir', 'installDir', 'boards'],
|
|
49
|
+
properties: {
|
|
50
|
+
sourceDir: { type: 'string' },
|
|
51
|
+
binaryDir: { type: 'string' },
|
|
52
|
+
installDir: { type: 'string' },
|
|
53
|
+
boards: {
|
|
54
|
+
type: 'object',
|
|
55
|
+
required: [],
|
|
56
|
+
additionalProperties: {
|
|
57
|
+
anyOf: [{ ...schemaBoard }, { type: 'array', items: schemaBoard }]
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
};
|
|
63
|
+
const schema = {
|
|
64
|
+
type: 'object',
|
|
65
|
+
required: ['applications', 'bootloaders'],
|
|
66
|
+
additionalProperties: false,
|
|
67
|
+
properties: {
|
|
68
|
+
applications: schemaBuild,
|
|
69
|
+
bootloaders: schemaBuild
|
|
70
|
+
}
|
|
71
|
+
};
|
|
72
|
+
const ajv = new Ajv({ allErrors: true, verbose: true });
|
|
73
|
+
const validate = ajv.compile(schema);
|
|
74
|
+
async function stat(path) {
|
|
75
|
+
return new Promise((resolve) => fs.stat(path, (err, stat) => {
|
|
76
|
+
if (err) {
|
|
77
|
+
resolve(false);
|
|
78
|
+
}
|
|
79
|
+
else {
|
|
80
|
+
resolve(stat);
|
|
81
|
+
}
|
|
82
|
+
}));
|
|
83
|
+
}
|
|
84
|
+
async function resolver(cwd) {
|
|
85
|
+
// NOTE this assumes atx-zdk is named in the west.yml file as either atx or
|
|
86
|
+
// atx-zdk. The default case is atx-zdk. Most people never rename the
|
|
87
|
+
// yaml. However, if somebody wants to rename atx-zdk repo in their
|
|
88
|
+
// workspace. We can have atx.json() pass this name in and resolve it
|
|
89
|
+
// that way
|
|
90
|
+
const project = path.resolve(cwd);
|
|
91
|
+
const workspace = path.resolve(cwd, '..');
|
|
92
|
+
const atx0 = path.resolve(cwd, '..', 'atx');
|
|
93
|
+
const atx1 = path.resolve(cwd, '..', 'atx-zdk');
|
|
94
|
+
const atx = (await stat(atx0)) ? atx0 : (await stat(atx1)) ? atx1 : project;
|
|
95
|
+
return (dir, from) => {
|
|
96
|
+
if (dir.startsWith('<workspace>')) {
|
|
97
|
+
return path.join(workspace, dir.substring(12));
|
|
98
|
+
}
|
|
99
|
+
else if (dir.startsWith('<project>')) {
|
|
100
|
+
return path.join(project, dir.substring(10));
|
|
101
|
+
}
|
|
102
|
+
else if (dir.startsWith('<atx>')) {
|
|
103
|
+
return path.join(atx, dir.substring(6));
|
|
104
|
+
}
|
|
105
|
+
else if (path.isAbsolute(dir)) {
|
|
106
|
+
return dir;
|
|
107
|
+
}
|
|
108
|
+
else {
|
|
109
|
+
return path.resolve(from || cwd, dir);
|
|
110
|
+
}
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
async function parseAppVersion(v) {
|
|
114
|
+
const data = await fs.promises.readFile(v, 'ascii');
|
|
115
|
+
const major = data.matchAll(/^VERSION_MAJOR = ([0-9]+)/gm).next();
|
|
116
|
+
const minor = data.matchAll(/^VERSION_MINOR = ([0-9]+)/gm).next();
|
|
117
|
+
const patch = data.matchAll(/^PATCHLEVEL = ([0-9]+)/gm).next();
|
|
118
|
+
const tweak = data.matchAll(/^VERSION_TWEAK = ([0-9]+)/gm).next();
|
|
119
|
+
const extra = data.matchAll(/^EXTRAVERSION = ([\.a-zA-Z0-9-]+)/gm).next();
|
|
120
|
+
return {
|
|
121
|
+
major: (major.value && major.value[1] && parseInt(major.value[1])) || 0,
|
|
122
|
+
minor: (minor.value && minor.value[1] && parseInt(minor.value[1])) || 0,
|
|
123
|
+
patch: (patch.value && patch.value[1] && parseInt(patch.value[1])) || 0,
|
|
124
|
+
tweak: (tweak.value && tweak.value[1] && parseInt(tweak.value[1])) || 0,
|
|
125
|
+
extra: (extra.value && extra.value[1]) || ''
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
function formatVersion(ver) {
|
|
129
|
+
const { major, minor, patch, tweak, extra } = ver;
|
|
130
|
+
return extra.length
|
|
131
|
+
? `${major}-${minor}-${patch}-${tweak}-${extra}`
|
|
132
|
+
: `${major}-${minor}-${patch}-${tweak}`;
|
|
133
|
+
}
|
|
134
|
+
async function westOptionsNormalize(board, config, build, cwd, verbose) {
|
|
135
|
+
const resolve = await resolver(cwd);
|
|
136
|
+
const name = build.__key;
|
|
137
|
+
const boardTarget = [board.__key, board.soc, board.cpu, board.variant]
|
|
138
|
+
.filter((item) => !!item)
|
|
139
|
+
.join('/');
|
|
140
|
+
const installDir = resolve(build.installDir);
|
|
141
|
+
const sourceDir = resolve(build.sourceDir);
|
|
142
|
+
const binaryDir = path.join(resolve(build.binaryDir), boardTarget.replaceAll('/', '-'), config.__key);
|
|
143
|
+
const versionFile = path.join(sourceDir, 'VERSION');
|
|
144
|
+
const version = formatVersion(await parseAppVersion(versionFile));
|
|
145
|
+
const confs = config.configs
|
|
146
|
+
? config.configs.map((f) => resolve(f, sourceDir))
|
|
147
|
+
: [];
|
|
148
|
+
const overlays = config.overlays
|
|
149
|
+
? config.overlays.map((f) => resolve(f, sourceDir))
|
|
150
|
+
: [];
|
|
151
|
+
return {
|
|
152
|
+
name,
|
|
153
|
+
board: boardTarget.replaceAll('/', '-'),
|
|
154
|
+
boardTarget,
|
|
155
|
+
config: config.__key,
|
|
156
|
+
cwd,
|
|
157
|
+
version,
|
|
158
|
+
sourceDir,
|
|
159
|
+
binaryDir,
|
|
160
|
+
installDir,
|
|
161
|
+
outputFile: path.join(binaryDir, 'build.log'),
|
|
162
|
+
errorFile: path.join(binaryDir, 'build.err'),
|
|
163
|
+
confs,
|
|
164
|
+
overlays,
|
|
165
|
+
verbose
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
function westItem(opts) {
|
|
169
|
+
const { board, name, config, version } = opts;
|
|
170
|
+
return {
|
|
171
|
+
kind: 'west',
|
|
172
|
+
item: `${board}-${name}-${config}-${version}`
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
function west(args) {
|
|
176
|
+
const { cwd, boardTarget, sourceDir, binaryDir, confs, overlays } = args;
|
|
177
|
+
const { item } = westItem(args);
|
|
178
|
+
const expect = path.join(binaryDir, 'zephyr', 'zephyr.bin');
|
|
179
|
+
return of([
|
|
180
|
+
`build`,
|
|
181
|
+
`-b ${boardTarget}`,
|
|
182
|
+
`-s ${sourceDir}`,
|
|
183
|
+
`-d ${binaryDir}`,
|
|
184
|
+
`--`,
|
|
185
|
+
`-DEXTRA_CONF_FILE="${[...confs].join(';')}"`,
|
|
186
|
+
`-DEXTRA_DTC_OVERLAY_FILE="${[...overlays].join(';')}"`
|
|
187
|
+
]).pipe(mergeMap((westArgs) => new Observable((subscriber) => {
|
|
188
|
+
const west = cp.spawn('west', westArgs, { cwd, shell: true });
|
|
189
|
+
const fout = fs.createWriteStream(args.outputFile);
|
|
190
|
+
const ferr = fs.createWriteStream(args.errorFile);
|
|
191
|
+
const out = new PassThrough();
|
|
192
|
+
const err = new PassThrough();
|
|
193
|
+
let error = '';
|
|
194
|
+
west.stdout.pipe(fout);
|
|
195
|
+
west.stdout.pipe(out);
|
|
196
|
+
west.stderr.pipe(ferr);
|
|
197
|
+
west.stderr.pipe(err);
|
|
198
|
+
west.on('error', (e) => {
|
|
199
|
+
fout.close();
|
|
200
|
+
ferr.close();
|
|
201
|
+
out.destroy();
|
|
202
|
+
err.destroy();
|
|
203
|
+
subscriber.next({ item, error: e.name });
|
|
204
|
+
});
|
|
205
|
+
west.on('exit', (_code) => {
|
|
206
|
+
fout.close();
|
|
207
|
+
ferr.close();
|
|
208
|
+
out.destroy();
|
|
209
|
+
err.destroy();
|
|
210
|
+
fs.stat(expect, (e) => {
|
|
211
|
+
e
|
|
212
|
+
? subscriber.next({ item, error })
|
|
213
|
+
: subscriber.next({ item, complete: true });
|
|
214
|
+
subscriber.complete();
|
|
215
|
+
});
|
|
216
|
+
});
|
|
217
|
+
out.on('data', (d) => subscriber.next({ item, output: d.toString() }));
|
|
218
|
+
err.on('data', (d) => (error += d));
|
|
219
|
+
})));
|
|
220
|
+
}
|
|
221
|
+
function emulateBytePages(board) {
|
|
222
|
+
return (board.startsWith('same54_xpro') ||
|
|
223
|
+
board.startsWith('netway') ||
|
|
224
|
+
board.startsWith('oa2b'));
|
|
225
|
+
}
|
|
226
|
+
function extraAppConfs(extraConfs, config) {
|
|
227
|
+
const key = process.env['ALTRONIX_RELEASE_KEY'];
|
|
228
|
+
if (!key)
|
|
229
|
+
throw new Error('missing ALTRONIX_RELEASE_KEY from environment');
|
|
230
|
+
// Custom TLVs for partition identification embedded in the MCUboot image header.
|
|
231
|
+
// - 0x8001: partition index (1 = slot1_partition, where firmware updates are written)
|
|
232
|
+
// - 0x8002: partition name string
|
|
233
|
+
// Note: INT32 values use little-endian hex (1 -> 0x01000000) per imgtool convention.
|
|
234
|
+
const imgtoolArgs = [
|
|
235
|
+
'--custom-tlv 0x8001 0x01000000',
|
|
236
|
+
'--custom-tlv 0x8002 slot1_partition'
|
|
237
|
+
].join(' ');
|
|
238
|
+
const extraConfsData = [
|
|
239
|
+
`CONFIG_MCUBOOT_SIGNATURE_KEY_FILE="${key}"`,
|
|
240
|
+
`CONFIG_BOOTLOADER_MCUBOOT=y`,
|
|
241
|
+
`CONFIG_ATX_UPDATE_ENABLE=y`,
|
|
242
|
+
`CONFIG_ATX_CONFIG="${config}"`,
|
|
243
|
+
`CONFIG_MCUBOOT_EXTRA_IMGTOOL_ARGS="${imgtoolArgs}"`
|
|
244
|
+
].join('\r\n');
|
|
245
|
+
return from(fs.promises.writeFile(extraConfs, extraConfsData));
|
|
246
|
+
}
|
|
247
|
+
function extraBootConfs(board, extraConfs) {
|
|
248
|
+
const key = process.env['ALTRONIX_RELEASE_KEY'];
|
|
249
|
+
if (!key)
|
|
250
|
+
throw new Error('missing ALTRONIX_RELEASE_KEY from environment');
|
|
251
|
+
const extraConfsData = emulateBytePages(board)
|
|
252
|
+
? [`CONFIG_BOOT_SIGNATURE_KEY_FILE="${key}"`]
|
|
253
|
+
: [`CONFIG_BOOT_SIGNATURE_KEY_FILE="${key}"`];
|
|
254
|
+
return from(fs.promises.writeFile(extraConfs, extraConfsData.join('\r\n')));
|
|
255
|
+
}
|
|
256
|
+
function copy() {
|
|
257
|
+
return (obs$) => obs$.pipe(mergeMap((opts) => {
|
|
258
|
+
const { src, dst } = opts;
|
|
259
|
+
const promise = fs.promises
|
|
260
|
+
.copyFile(src, dst)
|
|
261
|
+
.then(() => opts)
|
|
262
|
+
.catch(() => ({ ...opts, err: dst }));
|
|
263
|
+
return from(promise);
|
|
264
|
+
}));
|
|
265
|
+
}
|
|
266
|
+
function mkdir() {
|
|
267
|
+
return (obs$) => obs$.pipe(mergeMap((dir) => new Observable((subscriber) => {
|
|
268
|
+
fs.promises
|
|
269
|
+
.mkdir(dir, { recursive: true })
|
|
270
|
+
.then(() => subscriber.next())
|
|
271
|
+
.catch((e) => subscriber.error(e))
|
|
272
|
+
.finally(() => subscriber.complete());
|
|
273
|
+
})));
|
|
274
|
+
}
|
|
275
|
+
function rmdir(dir) {
|
|
276
|
+
return new Observable((subscriber) => {
|
|
277
|
+
fs.promises
|
|
278
|
+
.rm(dir, { recursive: true })
|
|
279
|
+
.then(() => subscriber.next(dir))
|
|
280
|
+
.catch((e) => subscriber.error(e))
|
|
281
|
+
.finally(() => subscriber.complete());
|
|
282
|
+
});
|
|
283
|
+
}
|
|
284
|
+
function exists(dir) {
|
|
285
|
+
return new Observable((subscriber) => {
|
|
286
|
+
fs.promises
|
|
287
|
+
.stat(dir)
|
|
288
|
+
.then(() => subscriber.next(true))
|
|
289
|
+
.catch(() => subscriber.next(false))
|
|
290
|
+
.finally(() => subscriber.complete());
|
|
291
|
+
});
|
|
292
|
+
}
|
|
293
|
+
function confirm(force) {
|
|
294
|
+
return (obs$) => force
|
|
295
|
+
? of(true)
|
|
296
|
+
: obs$.pipe(concatMap((dir) => from(inquirer({ message: `Delete ${dir}?` }))));
|
|
297
|
+
}
|
|
298
|
+
function throwIf(predicate, message) {
|
|
299
|
+
return tap((v) => {
|
|
300
|
+
if (predicate(v))
|
|
301
|
+
throw new Error(message);
|
|
302
|
+
});
|
|
303
|
+
}
|
|
304
|
+
function clean(force) {
|
|
305
|
+
return (obs$) => obs$.pipe(concatMap((dir) => exists(dir).pipe(concatMap((exists) => {
|
|
306
|
+
return exists
|
|
307
|
+
? of(dir).pipe(confirm(force), throwIf((confirm) => !confirm, 'User rejected delete'), mergeMap((_) => rmdir(dir)), map(() => dir))
|
|
308
|
+
: of(dir);
|
|
309
|
+
}))));
|
|
310
|
+
}
|
|
311
|
+
function tovoid() {
|
|
312
|
+
return (obs$) => obs$.pipe(map(() => void 0));
|
|
313
|
+
}
|
|
314
|
+
function filterExists(getPath) {
|
|
315
|
+
return (obs$) => obs$.pipe(mergeMap((item) => from(stat(getPath(item))).pipe(mergeMap((s) => (s ? of(item) : EMPTY)))));
|
|
316
|
+
}
|
|
317
|
+
function concatFiles(opts) {
|
|
318
|
+
const { inputs, output } = opts;
|
|
319
|
+
return from(Promise.all(inputs.map((f) => fs.promises.readFile(f)))
|
|
320
|
+
.then((buffers) => Buffer.concat(buffers))
|
|
321
|
+
.then((data) => fs.promises.writeFile(output, data)));
|
|
322
|
+
}
|
|
323
|
+
export async function build() {
|
|
324
|
+
const config = this.opts()['config'] || path.resolve('./', 'atx.json');
|
|
325
|
+
const verbose = this.optsWithGlobals()['verbose'];
|
|
326
|
+
const mBoard = new RegExp(this.opts()['matchesBoard'] || '(.*)');
|
|
327
|
+
const mApplication = new RegExp(this.opts()['matchesApplication'] || '(.*)');
|
|
328
|
+
const mConfig = new RegExp(this.opts()['matchesConfig'] || '(.*)');
|
|
329
|
+
const cwd = path.resolve(path.dirname(config));
|
|
330
|
+
const data = await fs.promises.readFile(config, 'ascii');
|
|
331
|
+
const atx = parseJsonc(data);
|
|
332
|
+
const extraAppConfFile = 'application.conf';
|
|
333
|
+
const extraBootConfFile = 'bootloader.conf';
|
|
334
|
+
if (!validate(atx))
|
|
335
|
+
throw validate.errors;
|
|
336
|
+
const env = path.resolve(cwd, '.env');
|
|
337
|
+
dotenv.config({ path: env });
|
|
338
|
+
const apps = keys(atx.applications)
|
|
339
|
+
.filter(({ __key }) => __key.match(mApplication))
|
|
340
|
+
.flatMap((app) => {
|
|
341
|
+
return keys(app.boards)
|
|
342
|
+
.filter(({ __key }) => __key.match(mBoard))
|
|
343
|
+
.flatMap((board) => {
|
|
344
|
+
return keys(board.images)
|
|
345
|
+
.filter(({ __key }) => __key.match(mConfig))
|
|
346
|
+
.map((config) => {
|
|
347
|
+
return westOptionsNormalize(board, config, app, cwd, verbose);
|
|
348
|
+
});
|
|
349
|
+
});
|
|
350
|
+
});
|
|
351
|
+
const bootloaders = keys(atx.bootloaders).flatMap((app) => {
|
|
352
|
+
return keys(app.boards)
|
|
353
|
+
.filter(({ __key }) => __key.match(mBoard))
|
|
354
|
+
.flatMap((board) => {
|
|
355
|
+
return keys(board.images).map((config) => westOptionsNormalize(board, config, app, cwd, verbose));
|
|
356
|
+
});
|
|
357
|
+
});
|
|
358
|
+
let concurrent = this.opts()['concurrent']
|
|
359
|
+
? parseInt(this.opts()['concurrent'])
|
|
360
|
+
: Infinity;
|
|
361
|
+
let buildApps = this.opts()['application'];
|
|
362
|
+
let buildBoot = this.opts()['bootloader'];
|
|
363
|
+
if (!(buildApps || buildBoot)) {
|
|
364
|
+
buildApps = buildBoot = true;
|
|
365
|
+
}
|
|
366
|
+
const apps$ = buildApps ? from(await Promise.all(apps)) : EMPTY;
|
|
367
|
+
const boot$ = buildBoot ? from(await Promise.all(bootloaders)) : EMPTY;
|
|
368
|
+
// Handle all the preliminary stuff before building
|
|
369
|
+
const ready$ = concat(merge(apps$.pipe(mergeMap(({ installDir: s, binaryDir: b }) => from([s, b]))), boot$.pipe(mergeMap(({ installDir: s, binaryDir: b }) => from([s, b])))).pipe(toArray(), mergeMap((arr) => from([...new Set(arr)])), // dedupe
|
|
370
|
+
clean(this.opts()['yes']), mkdir()), apps$.pipe(mergeMap(({ binaryDir, config }) => extraAppConfs(path.join(binaryDir, extraAppConfFile), config))), boot$.pipe(mergeMap(({ board, binaryDir }) => extraBootConfs(board, path.join(binaryDir, extraBootConfFile))))).pipe(last(), share());
|
|
371
|
+
// Get an array of every build item.
|
|
372
|
+
const items$ = merge(apps$.pipe(map(westItem)), boot$.pipe(map(westItem))).pipe(toArray());
|
|
373
|
+
// Run build commands for all bootloaders and applications concurrently
|
|
374
|
+
const build$ = merge(apps$.pipe(map(west)), boot$.pipe(map(west))).pipe(mergeMap((obs) => obs, concurrent), share());
|
|
375
|
+
// Create update.bin by concatenating signed binaries in the build dir
|
|
376
|
+
const update$ = apps$.pipe(mergeMap(({ binaryDir }) => {
|
|
377
|
+
const zephyrDir = path.join(binaryDir, 'zephyr');
|
|
378
|
+
const required = path.join(zephyrDir, 'zephyr.signed.bin');
|
|
379
|
+
const optional = [
|
|
380
|
+
path.join(zephyrDir, 'www.signed.bin'),
|
|
381
|
+
path.join(zephyrDir, 'packedfs.signed.bin')
|
|
382
|
+
];
|
|
383
|
+
return from(optional).pipe(filterExists((f) => f), toArray(), mergeMap((extras) => concatFiles({
|
|
384
|
+
inputs: [required, ...extras],
|
|
385
|
+
output: path.join(zephyrDir, 'update.bin')
|
|
386
|
+
})));
|
|
387
|
+
}), tovoid());
|
|
388
|
+
// Install everything into installDir
|
|
389
|
+
const install$ = merge(apps$.pipe(mergeMap(({ name, config, version: ver, board, binaryDir, installDir: d }) => {
|
|
390
|
+
const prefix = `${board}-${name}-${config}-${ver}`;
|
|
391
|
+
const zephyrDir = path.join(binaryDir, 'zephyr');
|
|
392
|
+
// Required files (error if missing)
|
|
393
|
+
const required = [
|
|
394
|
+
{
|
|
395
|
+
src: path.join(zephyrDir, 'zephyr.signed.bin'),
|
|
396
|
+
dst: path.join(d, `${prefix}.signed.bin`)
|
|
397
|
+
},
|
|
398
|
+
{
|
|
399
|
+
src: path.join(zephyrDir, 'update.bin'),
|
|
400
|
+
dst: path.join(d, `${prefix}.update.bin`)
|
|
401
|
+
}
|
|
402
|
+
];
|
|
403
|
+
// Optional files (skip if missing)
|
|
404
|
+
const optional = [
|
|
405
|
+
{
|
|
406
|
+
src: path.join(zephyrDir, 'www.bin'),
|
|
407
|
+
dst: path.join(d, `${prefix}.www.bin`)
|
|
408
|
+
},
|
|
409
|
+
{
|
|
410
|
+
src: path.join(zephyrDir, 'www.signed.bin'),
|
|
411
|
+
dst: path.join(d, `${prefix}.www.signed.bin`)
|
|
412
|
+
},
|
|
413
|
+
{
|
|
414
|
+
src: path.join(zephyrDir, 'packedfs.bin'),
|
|
415
|
+
dst: path.join(d, `${prefix}.packedfs.bin`)
|
|
416
|
+
},
|
|
417
|
+
{
|
|
418
|
+
src: path.join(zephyrDir, 'packedfs.signed.bin'),
|
|
419
|
+
dst: path.join(d, `${prefix}.packedfs.signed.bin`)
|
|
420
|
+
}
|
|
421
|
+
];
|
|
422
|
+
const optional$ = from(optional).pipe(filterExists((opt) => opt.src));
|
|
423
|
+
return merge(from(required), optional$);
|
|
424
|
+
})), boot$.pipe(map(({ name, config, board, binaryDir, installDir: d }) => {
|
|
425
|
+
return {
|
|
426
|
+
src: path.join(binaryDir, 'zephyr', 'zephyr.bin'),
|
|
427
|
+
dst: path.join(d, `${board}-${name}-${config}.bin`)
|
|
428
|
+
};
|
|
429
|
+
}))).pipe(copy(), tovoid());
|
|
430
|
+
try {
|
|
431
|
+
await lastValueFrom(ready$);
|
|
432
|
+
const items = await lastValueFrom(items$);
|
|
433
|
+
const renderer = render(React.createElement(Ui, { items: items, "progress$": build$, onComplete: () => renderer.unmount() }));
|
|
434
|
+
await renderer.waitUntilExit();
|
|
435
|
+
renderer.cleanup();
|
|
436
|
+
return lastValueFrom(concat(update$, install$));
|
|
437
|
+
}
|
|
438
|
+
catch (e) {
|
|
439
|
+
if (!(e instanceof EmptyError))
|
|
440
|
+
throw e;
|
|
441
|
+
console.log('nothing to build');
|
|
442
|
+
}
|
|
443
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { Observable } from 'rxjs';
|
|
3
|
+
export declare class BuildError extends Error implements BuildItem {
|
|
4
|
+
item: string;
|
|
5
|
+
kind: 'west';
|
|
6
|
+
constructor(item: string, kind: 'west', message: string);
|
|
7
|
+
}
|
|
8
|
+
export interface BuildItem {
|
|
9
|
+
item: string;
|
|
10
|
+
kind: 'west';
|
|
11
|
+
}
|
|
12
|
+
export interface BuildProgress<K extends string = string> {
|
|
13
|
+
item: K;
|
|
14
|
+
output?: string;
|
|
15
|
+
error?: string;
|
|
16
|
+
complete?: boolean;
|
|
17
|
+
}
|
|
18
|
+
export interface Options {
|
|
19
|
+
items: BuildItem[];
|
|
20
|
+
progress$: Observable<BuildProgress>;
|
|
21
|
+
onComplete: BuildEffectCallback;
|
|
22
|
+
}
|
|
23
|
+
export default function ({ items, progress$, onComplete }: Options): React.JSX.Element;
|
|
24
|
+
interface BuildEffectCallback<E extends Error = Error> {
|
|
25
|
+
(e?: E): void;
|
|
26
|
+
}
|
|
27
|
+
export {};
|
package/dist/build.ui.js
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import React, { useLayoutEffect, useState } from 'react';
|
|
2
|
+
import { Box, Text } from 'ink';
|
|
3
|
+
import { scan } from 'rxjs';
|
|
4
|
+
import useStdoutDimensions from './useStdoutDimensions.js';
|
|
5
|
+
export class BuildError extends Error {
|
|
6
|
+
constructor(item, kind, message) {
|
|
7
|
+
super(message);
|
|
8
|
+
Object.defineProperty(this, "item", {
|
|
9
|
+
enumerable: true,
|
|
10
|
+
configurable: true,
|
|
11
|
+
writable: true,
|
|
12
|
+
value: item
|
|
13
|
+
});
|
|
14
|
+
Object.defineProperty(this, "kind", {
|
|
15
|
+
enumerable: true,
|
|
16
|
+
configurable: true,
|
|
17
|
+
writable: true,
|
|
18
|
+
value: kind
|
|
19
|
+
});
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
export default function ({ items, progress$, onComplete }) {
|
|
23
|
+
const [col, _rows] = useStdoutDimensions();
|
|
24
|
+
const [width, setWidth] = useState(0);
|
|
25
|
+
const progress = useBuildEffect(progress$, items.map(({ item: i }) => i), onComplete);
|
|
26
|
+
useLayoutEffect(() => {
|
|
27
|
+
const width = items.map(({ item }) => item).reduce(calculateItemWidth, 0);
|
|
28
|
+
setWidth(width);
|
|
29
|
+
}, [items]);
|
|
30
|
+
return (React.createElement(Box, { flexDirection: "column" }, items.map(({ item, kind }) => (React.createElement(Box, { key: item },
|
|
31
|
+
React.createElement(Box, { width: width, marginRight: 1 },
|
|
32
|
+
React.createElement(Text, { wrap: "truncate", bold: true }, item.padStart(width))),
|
|
33
|
+
React.createElement(Box, { width: 4, marginRight: 1 },
|
|
34
|
+
React.createElement(Text, { wrap: "truncate", bold: true, color: buildColor(kind) }, kind.toUpperCase().padStart(4))),
|
|
35
|
+
React.createElement(Box, { width: 2, marginRight: 1 }, progress[item] && React.createElement(Text, { color: "cyan" }, "=>")),
|
|
36
|
+
React.createElement(Box, { width: col - width - 4 - 2 - 1 - 1 - 1 },
|
|
37
|
+
React.createElement(Text, { wrap: "truncate", dimColor: !progressComplete(progress[item]), color: progressColor(progress[item]) }, progress[item])))))));
|
|
38
|
+
}
|
|
39
|
+
function buildColor(_kind) {
|
|
40
|
+
return 'blue';
|
|
41
|
+
}
|
|
42
|
+
function progressComplete(progress) {
|
|
43
|
+
return progress === 'OK!';
|
|
44
|
+
}
|
|
45
|
+
function progressColor(progress) {
|
|
46
|
+
return !progress ||
|
|
47
|
+
progress.charAt(0) == '|' ||
|
|
48
|
+
progress.charAt(0) == '/' ||
|
|
49
|
+
progress.charAt(0) == '-' ||
|
|
50
|
+
progress.charAt(0) == '\\'
|
|
51
|
+
? 'white'
|
|
52
|
+
: progressComplete(progress)
|
|
53
|
+
? 'green'
|
|
54
|
+
: 'red';
|
|
55
|
+
}
|
|
56
|
+
function initProgress(items) {
|
|
57
|
+
return items.reduce((acc, curr) => ({ ...acc, [curr]: '' }), {});
|
|
58
|
+
}
|
|
59
|
+
function calculateItemWidth(acc, next) {
|
|
60
|
+
return next.length > acc ? next.length : acc;
|
|
61
|
+
}
|
|
62
|
+
function progressInc(progress) {
|
|
63
|
+
if (progress.charAt(0) == '|') {
|
|
64
|
+
return '/';
|
|
65
|
+
}
|
|
66
|
+
else if (progress.charAt(0) == '/') {
|
|
67
|
+
return '-';
|
|
68
|
+
}
|
|
69
|
+
else if (progress.charAt(0) == '-') {
|
|
70
|
+
return '\\';
|
|
71
|
+
}
|
|
72
|
+
else {
|
|
73
|
+
return '|';
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
function progressReducer(acc, next) {
|
|
77
|
+
acc[next.item] = next.complete
|
|
78
|
+
? 'OK!'
|
|
79
|
+
: next.error
|
|
80
|
+
? next.error.replace(/\r?\n/g, '')
|
|
81
|
+
: progressInc(acc[next.item]);
|
|
82
|
+
return acc;
|
|
83
|
+
}
|
|
84
|
+
function useBuildEffect(obs$, items, cb) {
|
|
85
|
+
const [progress, setProgress] = useState(initProgress(items));
|
|
86
|
+
useLayoutEffect(() => {
|
|
87
|
+
const s = obs$.pipe(scan(progressReducer, progress)).subscribe({
|
|
88
|
+
next: (progress) => setProgress({ ...progress }),
|
|
89
|
+
complete: cb,
|
|
90
|
+
error: cb
|
|
91
|
+
});
|
|
92
|
+
return () => s.unsubscribe();
|
|
93
|
+
}, [obs$]);
|
|
94
|
+
return progress;
|
|
95
|
+
}
|
package/dist/index.js
CHANGED
|
@@ -2,7 +2,8 @@
|
|
|
2
2
|
import { program } from 'commander';
|
|
3
3
|
import path from 'path';
|
|
4
4
|
import fs from 'fs';
|
|
5
|
-
import { build } from '
|
|
5
|
+
import { build } from './build.js';
|
|
6
|
+
import { update } from './update.js';
|
|
6
7
|
import { fileURLToPath } from 'url';
|
|
7
8
|
// https://stackoverflow.com/questions/8817423/why-is-dirname-not-defined-in-node-repl
|
|
8
9
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
@@ -16,17 +17,6 @@ program
|
|
|
16
17
|
.version(ver)
|
|
17
18
|
.option('-Q, --quiet', 'no logs')
|
|
18
19
|
.option('-VV, --verbose', 'extra logging');
|
|
19
|
-
// Scan command
|
|
20
|
-
/*
|
|
21
|
-
const ignore = 'node_modules;target;build;.git';
|
|
22
|
-
program
|
|
23
|
-
.command('scan')
|
|
24
|
-
.description('scan for *.cddl files')
|
|
25
|
-
.option('-p, --path <PATH>', 'root directory to start scan', cwd)
|
|
26
|
-
.option('-m, --matches <REGEX>', 'match expression', '.*cddl$')
|
|
27
|
-
.option('-i, --ignores <REGEX>', 'ignore directories', ignore)
|
|
28
|
-
.action(seedle.scan);
|
|
29
|
-
*/
|
|
30
20
|
// Build command
|
|
31
21
|
// TODO - detect if west is available and fail early
|
|
32
22
|
program
|
|
@@ -36,123 +26,16 @@ program
|
|
|
36
26
|
.option('-C, --concurrent <NUMBER>', 'how many builds to run concurrently')
|
|
37
27
|
.option('-A, --application', 'build applications')
|
|
38
28
|
.option('-B, --bootloader', 'build bootloaders')
|
|
39
|
-
.option('-W, --wasm', 'build wasm')
|
|
40
29
|
.option('--matches-board [regex]', 'board regex match expr')
|
|
41
30
|
.option('--matches-application [regex]', 'application regex match expr')
|
|
42
31
|
.option('--matches-config [regex]', 'config regex match expr')
|
|
43
32
|
.option('-y, --yes', 'answer yes automatically')
|
|
44
33
|
.action(build);
|
|
45
|
-
//
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
const { getNet, setNet, setDhcp } = await import('./net.js');
|
|
54
|
-
const { getCloud, setCloud } = await import('./cloud.js');
|
|
55
|
-
const { stress } = await import('./stress.js');
|
|
56
|
-
const { update } = await import('./update.js');
|
|
57
|
-
const { listen } = await import('./listen.js');
|
|
58
|
-
const { getDemo, getHello } = await import('./sample.js');
|
|
59
|
-
const { concurrent } = await import('./concurrent.js');
|
|
60
|
-
const device = program
|
|
61
|
-
.command('device')
|
|
62
|
-
.description('manage zephyr device settings')
|
|
63
|
-
.option('-p, --port <PORT>', 'listen port for incoming device connections')
|
|
64
|
-
.option('-v, --verbose', 'print extra debug informating during request')
|
|
65
|
-
.option('-f, --first', 'perform request on first device we see (any device)');
|
|
66
|
-
device
|
|
67
|
-
.command('get-about')
|
|
68
|
-
.description('get about data on the device')
|
|
69
|
-
.action(getAbout);
|
|
70
|
-
device
|
|
71
|
-
.command('get-site')
|
|
72
|
-
.description('get the site ID of the device')
|
|
73
|
-
.action(getSite);
|
|
74
|
-
device
|
|
75
|
-
.command('set-site')
|
|
76
|
-
.description('set the site ID of the device')
|
|
77
|
-
.argument('<site>', 'new site id')
|
|
78
|
-
.action(setSite);
|
|
79
|
-
device
|
|
80
|
-
.command('save')
|
|
81
|
-
.description('save data to persistant storage. (does not reboot)')
|
|
82
|
-
.action(save);
|
|
83
|
-
device
|
|
84
|
-
.command('reboot')
|
|
85
|
-
.description('reboot the device. (does not save)')
|
|
86
|
-
.action(reboot);
|
|
87
|
-
device
|
|
88
|
-
.command('save-reboot')
|
|
89
|
-
.description('save data to persistant storage and reboot the device')
|
|
90
|
-
.action(saveAndReboot);
|
|
91
|
-
device
|
|
92
|
-
.command('get-net')
|
|
93
|
-
.description('get network configuration from the device')
|
|
94
|
-
.action(getNet);
|
|
95
|
-
device
|
|
96
|
-
.command('set-net')
|
|
97
|
-
.description('set network interface into static ip mode')
|
|
98
|
-
.argument('<ip>', 'the new IP address')
|
|
99
|
-
.argument('<sn>', 'the new SUBNET mask')
|
|
100
|
-
.argument('<gw>', 'the new GATEWAY address')
|
|
101
|
-
.action(setNet);
|
|
102
|
-
device
|
|
103
|
-
.command('set-dhcp')
|
|
104
|
-
.description('set network interface into DHCP mode')
|
|
105
|
-
.action(setDhcp);
|
|
106
|
-
device
|
|
107
|
-
.command('get-cloud')
|
|
108
|
-
.description('get cloud endpoint on the device')
|
|
109
|
-
.action(getCloud);
|
|
110
|
-
device
|
|
111
|
-
.command('set-cloud')
|
|
112
|
-
.description('set cloud endpoint on the device')
|
|
113
|
-
.argument('<endpoint>', 'cloud service location')
|
|
114
|
-
.action(setCloud);
|
|
115
|
-
device
|
|
116
|
-
.command('stress')
|
|
117
|
-
.description('run a stress test on a device')
|
|
118
|
-
.argument('<count>', 'how many requests to make')
|
|
119
|
-
.action(stress);
|
|
120
|
-
device
|
|
121
|
-
.command('update')
|
|
122
|
-
.description('run firmware update from file')
|
|
123
|
-
.argument('<file>', 'file to update device with')
|
|
124
|
-
.action(update);
|
|
125
|
-
device
|
|
126
|
-
.command('listen')
|
|
127
|
-
.description('listen for alerts and heartbeats')
|
|
128
|
-
.argument('<duration>', 'how long to listen')
|
|
129
|
-
.action(listen);
|
|
130
|
-
device
|
|
131
|
-
.command('concurrent')
|
|
132
|
-
.description('make a number of requests concurrently')
|
|
133
|
-
.argument('<count>', 'how many requests to make concurrently')
|
|
134
|
-
.action(concurrent);
|
|
135
|
-
const sample = program
|
|
136
|
-
.command('sample')
|
|
137
|
-
.description('manage zephyr device settings')
|
|
138
|
-
.option('-p, --port <PORT>', 'listen port for incoming device connections')
|
|
139
|
-
.option('-v, --verbose', 'print extra debug informating during request')
|
|
140
|
-
.option('-f, --first', 'perform request on first device we see (any device)');
|
|
141
|
-
sample
|
|
142
|
-
.command('get-demo')
|
|
143
|
-
.description('read the demo data from the sample')
|
|
144
|
-
.action(getDemo);
|
|
145
|
-
sample
|
|
146
|
-
.command('get-hello')
|
|
147
|
-
.description('read the hello data from the sample')
|
|
148
|
-
.action(getHello);
|
|
149
|
-
}
|
|
150
|
-
catch {
|
|
151
|
-
// Device package not available - device commands will not be registered
|
|
152
|
-
}
|
|
153
|
-
}
|
|
154
|
-
// Load plugins
|
|
155
|
-
// (await plugins()).forEach(({ plugin: _, path: __, description: ___ }) => {});
|
|
156
|
-
registerDeviceCommands()
|
|
157
|
-
.then(() => program.parseAsync())
|
|
158
|
-
.catch((e) => console.error(e));
|
|
34
|
+
// Update command
|
|
35
|
+
program
|
|
36
|
+
.command('update')
|
|
37
|
+
.description('inspect or flash firmware update')
|
|
38
|
+
.argument('<url>', 'target device URL (e.g., https://192.168.1.1:8008)')
|
|
39
|
+
.argument('<file>', 'path to update binary')
|
|
40
|
+
.action(update);
|
|
41
|
+
program.parseAsync().catch((e) => console.error(e));
|
package/dist/keys.d.ts
ADDED
package/dist/keys.js
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
export function keys(map) {
|
|
2
|
+
let ret = [];
|
|
3
|
+
Object.keys(map)
|
|
4
|
+
.filter((key) => key !== '__key')
|
|
5
|
+
.forEach((key) => {
|
|
6
|
+
if (Array.isArray(map[key])) {
|
|
7
|
+
map[key].forEach((item) => ret.push({ ...item, __key: key }));
|
|
8
|
+
}
|
|
9
|
+
else {
|
|
10
|
+
ret.push({ ...map[key], __key: key });
|
|
11
|
+
}
|
|
12
|
+
});
|
|
13
|
+
return ret;
|
|
14
|
+
}
|
package/dist/plugin.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import cp from 'node:child_process';
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
import { filter, from, lastValueFrom, map, merge, mergeMap, Observable, of, toArray } from 'rxjs';
|
|
4
|
-
import { keys } from '
|
|
4
|
+
import { keys } from './keys.js';
|
|
5
5
|
function npmls(g) {
|
|
6
6
|
return of(['ls', `${g ? '-g' : ''}`, '-l', '--json', '--depth 0']).pipe(mergeMap((args) => new Observable((subscriber) => {
|
|
7
7
|
const ls = cp.spawn('npm', args, { shell: true });
|
package/dist/progress.ui.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import React, { useLayoutEffect, useState } from 'react';
|
|
2
2
|
import { scan } from 'rxjs';
|
|
3
3
|
import { Box, Text } from 'ink';
|
|
4
|
-
import
|
|
4
|
+
import useDimensions from './useStdoutDimensions.js';
|
|
5
5
|
export default function Progress({ total, response$, onComplete }) {
|
|
6
6
|
const [cols] = useDimensions();
|
|
7
7
|
const [width, setWidth] = useState(Math.min(cols - 4, 76));
|
package/dist/update.d.ts
CHANGED
|
@@ -1,2 +1,42 @@
|
|
|
1
1
|
import { Command } from 'commander';
|
|
2
|
-
|
|
2
|
+
import { UpdateProgress } from './update.ui.js';
|
|
3
|
+
/**
|
|
4
|
+
* Start flash operation - erases the partition
|
|
5
|
+
* GET /api/flash/start?partition=<name>
|
|
6
|
+
*/
|
|
7
|
+
export declare function flashStart(baseUrl: string, partition: string): Promise<void>;
|
|
8
|
+
/**
|
|
9
|
+
* Transfer a single chunk
|
|
10
|
+
* PUT /api/flash/transfer?partition=<name>
|
|
11
|
+
* Body: CBOR-encoded [offset, data]
|
|
12
|
+
*/
|
|
13
|
+
export declare function flashTransfer(baseUrl: string, partition: string, offset: number, data: Uint8Array): Promise<void>;
|
|
14
|
+
/**
|
|
15
|
+
* Complete the flash operation - triggers reboot
|
|
16
|
+
* GET /api/flash/transfer_complete?partition=<name>
|
|
17
|
+
*/
|
|
18
|
+
export declare function flashTransferComplete(baseUrl: string, partition: string): Promise<void>;
|
|
19
|
+
/**
|
|
20
|
+
* Split image into 512-byte chunks, padding last chunk with 0xFF
|
|
21
|
+
*/
|
|
22
|
+
export declare function chunkImage(image: Uint8Array): Array<{
|
|
23
|
+
offset: number;
|
|
24
|
+
data: Uint8Array;
|
|
25
|
+
}>;
|
|
26
|
+
export type OnProgress = (progress: UpdateProgress) => void;
|
|
27
|
+
export interface UploadImage {
|
|
28
|
+
partition: string;
|
|
29
|
+
image: Uint8Array;
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Upload firmware to device
|
|
33
|
+
*
|
|
34
|
+
* For each partition:
|
|
35
|
+
* 1. Erase partition (GET /api/flash/start)
|
|
36
|
+
* 2. Transfer chunks (PUT /api/flash/transfer with CBOR [offset, data])
|
|
37
|
+
*
|
|
38
|
+
* After all partitions:
|
|
39
|
+
* 3. Finalize update (GET /api/flash/transfer_complete)
|
|
40
|
+
*/
|
|
41
|
+
export declare function uploadFirmware(baseUrl: string, images: UploadImage[], onProgress: OnProgress): Promise<void>;
|
|
42
|
+
export declare function update(this: Command, url: string, file: string): Promise<void>;
|
package/dist/update.js
CHANGED
|
@@ -1,59 +1,161 @@
|
|
|
1
|
-
import
|
|
2
|
-
import
|
|
3
|
-
import
|
|
4
|
-
import
|
|
5
|
-
import
|
|
6
|
-
import {
|
|
7
|
-
import
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import https from 'node:https';
|
|
3
|
+
import http from 'node:http';
|
|
4
|
+
import { parseMcubootImages, AltronixImage } from '@altronix/webtobin';
|
|
5
|
+
import * as CBOR from 'cbor2';
|
|
6
|
+
import { render } from 'ink';
|
|
7
|
+
import React from 'react';
|
|
8
|
+
import UpdateUi from './update.ui.js';
|
|
9
|
+
const CHUNK_SIZE = 512;
|
|
10
|
+
// =============================================================================
|
|
11
|
+
// OTA API
|
|
12
|
+
// =============================================================================
|
|
13
|
+
/**
|
|
14
|
+
* Make an HTTP/HTTPS request to the OTA API
|
|
15
|
+
*/
|
|
16
|
+
function otaRequest(method, baseUrl, path, partition, body) {
|
|
17
|
+
return new Promise((resolve, reject) => {
|
|
18
|
+
const url = new URL(path, baseUrl);
|
|
19
|
+
url.searchParams.set('partition', partition);
|
|
20
|
+
const isHttps = url.protocol === 'https:';
|
|
21
|
+
const transport = isHttps ? https : http;
|
|
22
|
+
const req = transport.request(url, {
|
|
23
|
+
method,
|
|
24
|
+
headers: body
|
|
25
|
+
? {
|
|
26
|
+
'Content-Type': 'application/cbor',
|
|
27
|
+
'Content-Length': body.length,
|
|
28
|
+
}
|
|
29
|
+
: undefined,
|
|
30
|
+
rejectUnauthorized: false,
|
|
31
|
+
}, (res) => {
|
|
32
|
+
res.resume();
|
|
33
|
+
if (res.statusCode && res.statusCode >= 200 && res.statusCode < 300) {
|
|
34
|
+
resolve();
|
|
35
|
+
}
|
|
36
|
+
else {
|
|
37
|
+
reject(new Error(`HTTP ${res.statusCode}: ${res.statusMessage}`));
|
|
38
|
+
}
|
|
39
|
+
});
|
|
40
|
+
req.on('error', reject);
|
|
41
|
+
if (body)
|
|
42
|
+
req.write(body);
|
|
43
|
+
req.end();
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* Start flash operation - erases the partition
|
|
48
|
+
* GET /api/flash/start?partition=<name>
|
|
49
|
+
*/
|
|
50
|
+
export function flashStart(baseUrl, partition) {
|
|
51
|
+
return otaRequest('GET', baseUrl, '/api/flash/start', partition);
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* Transfer a single chunk
|
|
55
|
+
* PUT /api/flash/transfer?partition=<name>
|
|
56
|
+
* Body: CBOR-encoded [offset, data]
|
|
57
|
+
*/
|
|
58
|
+
export function flashTransfer(baseUrl, partition, offset, data) {
|
|
59
|
+
const payload = new Uint8Array(CBOR.encode([offset, data]));
|
|
60
|
+
return otaRequest('PUT', baseUrl, '/api/flash/transfer', partition, payload);
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* Complete the flash operation - triggers reboot
|
|
64
|
+
* GET /api/flash/transfer_complete?partition=<name>
|
|
65
|
+
*/
|
|
66
|
+
export function flashTransferComplete(baseUrl, partition) {
|
|
67
|
+
return otaRequest('GET', baseUrl, '/api/flash/transfer_complete', partition);
|
|
68
|
+
}
|
|
69
|
+
// =============================================================================
|
|
70
|
+
// Chunking
|
|
71
|
+
// =============================================================================
|
|
72
|
+
/**
|
|
73
|
+
* Split image into 512-byte chunks, padding last chunk with 0xFF
|
|
74
|
+
*/
|
|
75
|
+
export function chunkImage(image) {
|
|
76
|
+
const chunks = [];
|
|
77
|
+
for (let offset = 0; offset < image.length; offset += CHUNK_SIZE) {
|
|
78
|
+
const slice = image.subarray(offset, offset + CHUNK_SIZE);
|
|
79
|
+
const data = new Uint8Array(CHUNK_SIZE);
|
|
80
|
+
data.fill(0xff);
|
|
81
|
+
data.set(slice);
|
|
82
|
+
chunks.push({ offset, data });
|
|
16
83
|
}
|
|
17
|
-
|
|
18
|
-
|
|
84
|
+
return chunks;
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* Upload firmware to device
|
|
88
|
+
*
|
|
89
|
+
* For each partition:
|
|
90
|
+
* 1. Erase partition (GET /api/flash/start)
|
|
91
|
+
* 2. Transfer chunks (PUT /api/flash/transfer with CBOR [offset, data])
|
|
92
|
+
*
|
|
93
|
+
* After all partitions:
|
|
94
|
+
* 3. Finalize update (GET /api/flash/transfer_complete)
|
|
95
|
+
*/
|
|
96
|
+
export async function uploadFirmware(baseUrl, images, onProgress) {
|
|
97
|
+
for (const { partition, image } of images) {
|
|
98
|
+
// Erase
|
|
99
|
+
onProgress({ partition, phase: 'erase' });
|
|
100
|
+
await flashStart(baseUrl, partition);
|
|
101
|
+
// Transfer chunks
|
|
102
|
+
const chunks = chunkImage(image);
|
|
103
|
+
for (let i = 0; i < chunks.length; i++) {
|
|
104
|
+
const { offset, data } = chunks[i];
|
|
105
|
+
await flashTransfer(baseUrl, partition, offset, data);
|
|
106
|
+
onProgress({
|
|
107
|
+
partition,
|
|
108
|
+
phase: 'transfer',
|
|
109
|
+
current: offset + CHUNK_SIZE,
|
|
110
|
+
total: image.length
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
// Mark complete
|
|
114
|
+
onProgress({ partition, phase: 'complete' });
|
|
19
115
|
}
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
slicer(n - 1);
|
|
24
|
-
let last = new Uint8Array(size);
|
|
25
|
-
last.set(data.slice(end));
|
|
26
|
-
arr.push(last);
|
|
116
|
+
// Finalize (once, after all partitions)
|
|
117
|
+
if (images.length > 0) {
|
|
118
|
+
await flashTransferComplete(baseUrl, images[0].partition);
|
|
27
119
|
}
|
|
28
|
-
return arr;
|
|
29
|
-
}
|
|
30
|
-
function updateMap(data, idx) {
|
|
31
|
-
let update = new Update({ data, offset: idx * 512 });
|
|
32
|
-
// NOTE: Have to initialize the Uint8Array across binding this way
|
|
33
|
-
// https://gitlab.altronix.com/software-engineering/sdk/atx-zdk/-/issues/1
|
|
34
|
-
update.data = data;
|
|
35
|
-
return update;
|
|
36
120
|
}
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
const
|
|
45
|
-
|
|
46
|
-
.
|
|
47
|
-
.
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
const
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
121
|
+
// =============================================================================
|
|
122
|
+
// CLI Command
|
|
123
|
+
// =============================================================================
|
|
124
|
+
export async function update(url, file) {
|
|
125
|
+
// Parse firmware file
|
|
126
|
+
const buffer = await fs.promises.readFile(file);
|
|
127
|
+
const bin = new Uint8Array(buffer);
|
|
128
|
+
const mcubootImages = parseMcubootImages(bin);
|
|
129
|
+
if (mcubootImages.length === 0) {
|
|
130
|
+
console.error('No valid MCUboot images found in file');
|
|
131
|
+
process.exit(1);
|
|
132
|
+
}
|
|
133
|
+
// Extract partition data
|
|
134
|
+
const images = mcubootImages.map((img) => {
|
|
135
|
+
const altronix = AltronixImage.fromMcubootImage(img);
|
|
136
|
+
return {
|
|
137
|
+
partition: altronix.partitionName,
|
|
138
|
+
image: altronix.signedImage
|
|
139
|
+
};
|
|
140
|
+
});
|
|
141
|
+
// Build UI items
|
|
142
|
+
const items = mcubootImages.map((img) => {
|
|
143
|
+
const altronix = AltronixImage.fromMcubootImage(img);
|
|
144
|
+
const { major, minor, revision, build } = altronix.version;
|
|
145
|
+
return {
|
|
146
|
+
partition: altronix.partitionName,
|
|
147
|
+
version: `${major}.${minor}.${revision}+${build}`,
|
|
148
|
+
size: altronix.signedImage.length
|
|
149
|
+
};
|
|
58
150
|
});
|
|
151
|
+
// Render UI - it handles the upload internally
|
|
152
|
+
const renderer = render(React.createElement(UpdateUi, { baseUrl: url, items: items, images: images, onComplete: (error) => {
|
|
153
|
+
renderer.unmount();
|
|
154
|
+
if (error) {
|
|
155
|
+
console.error(`\nUpdate failed: ${error.message}`);
|
|
156
|
+
process.exit(1);
|
|
157
|
+
}
|
|
158
|
+
console.log('\nUpdate complete. Device is rebooting...');
|
|
159
|
+
} }));
|
|
160
|
+
await renderer.waitUntilExit();
|
|
59
161
|
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { UploadImage } from './update.js';
|
|
3
|
+
/**
|
|
4
|
+
* Partition metadata for display
|
|
5
|
+
*/
|
|
6
|
+
export interface UpdateItem {
|
|
7
|
+
partition: string;
|
|
8
|
+
version: string;
|
|
9
|
+
size: number;
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* Progress event from upload
|
|
13
|
+
*/
|
|
14
|
+
export interface UpdateProgress {
|
|
15
|
+
partition: string;
|
|
16
|
+
phase: 'erase' | 'transfer' | 'complete';
|
|
17
|
+
current?: number;
|
|
18
|
+
total?: number;
|
|
19
|
+
}
|
|
20
|
+
interface UpdateUiProps {
|
|
21
|
+
baseUrl: string;
|
|
22
|
+
items: UpdateItem[];
|
|
23
|
+
images: UploadImage[];
|
|
24
|
+
onComplete: (error?: Error) => void;
|
|
25
|
+
}
|
|
26
|
+
export default function UpdateUi({ baseUrl, items, images, onComplete }: UpdateUiProps): React.JSX.Element;
|
|
27
|
+
export {};
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import React, { useEffect, useState } from 'react';
|
|
2
|
+
import { Box, Text } from 'ink';
|
|
3
|
+
import useStdoutDimensions from './useStdoutDimensions.js';
|
|
4
|
+
import { uploadFirmware } from './update.js';
|
|
5
|
+
export default function UpdateUi({ baseUrl, items, images, onComplete }) {
|
|
6
|
+
const [col] = useStdoutDimensions();
|
|
7
|
+
const [state, setState] = useState(() => initState(items));
|
|
8
|
+
useEffect(() => {
|
|
9
|
+
uploadFirmware(baseUrl, images, (progress) => {
|
|
10
|
+
setState((prev) => updateState(prev, progress));
|
|
11
|
+
})
|
|
12
|
+
.then(() => onComplete())
|
|
13
|
+
.catch((e) => onComplete(e));
|
|
14
|
+
}, []);
|
|
15
|
+
const maxNameLen = Math.max(...items.map((i) => i.partition.length));
|
|
16
|
+
const barWidth = Math.min(col - maxNameLen - 25, 40);
|
|
17
|
+
return (React.createElement(Box, { flexDirection: "column" },
|
|
18
|
+
React.createElement(Box, { marginBottom: 1 },
|
|
19
|
+
React.createElement(Text, { bold: true }, "OTA Update")),
|
|
20
|
+
items.map((item) => {
|
|
21
|
+
const s = state[item.partition];
|
|
22
|
+
const percent = s && s.total > 0 ? Math.floor((100 * s.current) / s.total) : 0;
|
|
23
|
+
const filled = s && s.total > 0 ? Math.floor((barWidth * s.current) / s.total) : 0;
|
|
24
|
+
const empty = barWidth - filled;
|
|
25
|
+
return (React.createElement(Box, { key: item.partition },
|
|
26
|
+
React.createElement(Box, { width: maxNameLen + 1 },
|
|
27
|
+
React.createElement(Text, { bold: true }, item.partition.padEnd(maxNameLen))),
|
|
28
|
+
React.createElement(Box, { marginLeft: 1 },
|
|
29
|
+
React.createElement(Text, null, "["),
|
|
30
|
+
React.createElement(Text, { color: "green" }, '#'.repeat(filled)),
|
|
31
|
+
React.createElement(Text, { color: "gray" }, '-'.repeat(empty)),
|
|
32
|
+
React.createElement(Text, null, "]")),
|
|
33
|
+
React.createElement(Box, { marginLeft: 1, width: 5 },
|
|
34
|
+
React.createElement(Text, null,
|
|
35
|
+
percent.toString().padStart(3),
|
|
36
|
+
"%")),
|
|
37
|
+
React.createElement(Box, { marginLeft: 1 },
|
|
38
|
+
React.createElement(Text, { color: phaseColor(s?.phase) }, phaseText(s?.phase)))));
|
|
39
|
+
}),
|
|
40
|
+
React.createElement(Box, { marginTop: 1 },
|
|
41
|
+
React.createElement(Text, { dimColor: true },
|
|
42
|
+
formatSize(items.reduce((acc, i) => acc + i.size, 0)),
|
|
43
|
+
" total"))));
|
|
44
|
+
}
|
|
45
|
+
function initState(items) {
|
|
46
|
+
return items.reduce((acc, item) => ({
|
|
47
|
+
...acc,
|
|
48
|
+
[item.partition]: {
|
|
49
|
+
phase: 'pending',
|
|
50
|
+
current: 0,
|
|
51
|
+
total: item.size
|
|
52
|
+
}
|
|
53
|
+
}), {});
|
|
54
|
+
}
|
|
55
|
+
function updateState(prev, progress) {
|
|
56
|
+
const current = prev[progress.partition];
|
|
57
|
+
if (!current)
|
|
58
|
+
return prev;
|
|
59
|
+
switch (progress.phase) {
|
|
60
|
+
case 'erase':
|
|
61
|
+
return {
|
|
62
|
+
...prev,
|
|
63
|
+
[progress.partition]: { ...current, phase: 'erase' }
|
|
64
|
+
};
|
|
65
|
+
case 'transfer':
|
|
66
|
+
return {
|
|
67
|
+
...prev,
|
|
68
|
+
[progress.partition]: {
|
|
69
|
+
...current,
|
|
70
|
+
phase: 'transfer',
|
|
71
|
+
current: progress.current ?? current.current
|
|
72
|
+
}
|
|
73
|
+
};
|
|
74
|
+
case 'complete':
|
|
75
|
+
return {
|
|
76
|
+
...prev,
|
|
77
|
+
[progress.partition]: {
|
|
78
|
+
...current,
|
|
79
|
+
phase: 'done',
|
|
80
|
+
current: current.total
|
|
81
|
+
}
|
|
82
|
+
};
|
|
83
|
+
default:
|
|
84
|
+
return prev;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
function phaseColor(phase) {
|
|
88
|
+
switch (phase) {
|
|
89
|
+
case 'erase':
|
|
90
|
+
return 'yellow';
|
|
91
|
+
case 'transfer':
|
|
92
|
+
return 'cyan';
|
|
93
|
+
case 'done':
|
|
94
|
+
return 'green';
|
|
95
|
+
default:
|
|
96
|
+
return 'gray';
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
function phaseText(phase) {
|
|
100
|
+
switch (phase) {
|
|
101
|
+
case 'pending':
|
|
102
|
+
return 'waiting';
|
|
103
|
+
case 'erase':
|
|
104
|
+
return 'erasing';
|
|
105
|
+
case 'transfer':
|
|
106
|
+
return 'uploading';
|
|
107
|
+
case 'done':
|
|
108
|
+
return 'OK';
|
|
109
|
+
default:
|
|
110
|
+
return '';
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
function formatSize(bytes) {
|
|
114
|
+
if (bytes < 1024)
|
|
115
|
+
return `${bytes} B`;
|
|
116
|
+
if (bytes < 1024 * 1024)
|
|
117
|
+
return `${(bytes / 1024).toFixed(1)} KB`;
|
|
118
|
+
return `${(bytes / (1024 * 1024)).toFixed(2)} MB`;
|
|
119
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export default function useStdoutDimensions(): [number, number];
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { useState, useEffect } from 'react';
|
|
2
|
+
import { useStdout } from 'ink';
|
|
3
|
+
export default function useStdoutDimensions() {
|
|
4
|
+
const { stdout } = useStdout();
|
|
5
|
+
const [dimensions, setDimensions] = useState([
|
|
6
|
+
stdout.columns,
|
|
7
|
+
stdout.rows
|
|
8
|
+
]);
|
|
9
|
+
useEffect(() => {
|
|
10
|
+
const handler = () => setDimensions([stdout.columns, stdout.rows]);
|
|
11
|
+
stdout.on('resize', handler);
|
|
12
|
+
return () => {
|
|
13
|
+
stdout.off('resize', handler);
|
|
14
|
+
};
|
|
15
|
+
}, [stdout]);
|
|
16
|
+
return dimensions;
|
|
17
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@altronix/cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.15.0",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -13,35 +13,35 @@
|
|
|
13
13
|
"dist"
|
|
14
14
|
],
|
|
15
15
|
"dependencies": {
|
|
16
|
-
"@altronix/
|
|
16
|
+
"@altronix/webtobin": "^0.8.0",
|
|
17
|
+
"@inquirer/confirm": "^3.2.0",
|
|
18
|
+
"ajv": "^8.17.1",
|
|
19
|
+
"cbor2": "^2.2.1",
|
|
17
20
|
"commander": "^10.0.1",
|
|
18
|
-
"
|
|
21
|
+
"dotenv": "^16.6.1",
|
|
22
|
+
"ink": "^4.4.1",
|
|
19
23
|
"jsonc-parser": "^3.3.1",
|
|
20
|
-
"react": "^18.
|
|
21
|
-
"rxjs": "^7.8.
|
|
22
|
-
"winston": "^3.
|
|
23
|
-
"@altronix/build": "0.13.0"
|
|
24
|
-
},
|
|
25
|
-
"optionalDependencies": {
|
|
26
|
-
"@altronix/device": "0.9.0"
|
|
24
|
+
"react": "^18.3.1",
|
|
25
|
+
"rxjs": "^7.8.2",
|
|
26
|
+
"winston": "^3.19.0"
|
|
27
27
|
},
|
|
28
28
|
"devDependencies": {
|
|
29
29
|
"@sindresorhus/tsconfig": "^3.0.1",
|
|
30
|
-
"@types/react": "^18.
|
|
30
|
+
"@types/react": "^18.3.27",
|
|
31
31
|
"@typescript-eslint/eslint-plugin": "~5.44.0",
|
|
32
32
|
"@typescript-eslint/parser": "~5.44.0",
|
|
33
|
-
"ava": "^5.
|
|
34
|
-
"chalk": "^5.2
|
|
33
|
+
"ava": "^5.3.1",
|
|
34
|
+
"chalk": "^5.6.2",
|
|
35
35
|
"eslint": "~8.28.0",
|
|
36
36
|
"eslint-config-prettier": "~8.5.0",
|
|
37
37
|
"eslint-config-xo-react": "^0.27.0",
|
|
38
38
|
"eslint-plugin-jest": "~27.1.7",
|
|
39
|
-
"eslint-plugin-react": "^7.
|
|
40
|
-
"eslint-plugin-react-hooks": "^4.6.
|
|
39
|
+
"eslint-plugin-react": "^7.37.5",
|
|
40
|
+
"eslint-plugin-react-hooks": "^4.6.2",
|
|
41
41
|
"ink-testing-library": "^3.0.0",
|
|
42
|
-
"prettier": "^2.8.
|
|
43
|
-
"ts-node": "^10.9.
|
|
44
|
-
"typescript": "^5.
|
|
42
|
+
"prettier": "^2.8.8",
|
|
43
|
+
"ts-node": "^10.9.2",
|
|
44
|
+
"typescript": "^5.9.3",
|
|
45
45
|
"xo": "^0.53.1"
|
|
46
46
|
},
|
|
47
47
|
"ava": {
|