@altronix/cli 0.12.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/about.js CHANGED
@@ -1,4 +1,4 @@
1
- import { About, cborDecodeStr } from '@altronix/zdk';
1
+ import { About, cborDecodeStr } from '@altronix/zdk/common';
2
2
  import { Linq } from '@altronix/device';
3
3
  import { EmptyError, finalize, firstValueFrom, map, switchMap } from 'rxjs';
4
4
  import logger from './logger.js';
package/dist/build.js CHANGED
@@ -1,14 +1,15 @@
1
1
  import dotenv from 'dotenv';
2
2
  import { Ajv } from 'ajv';
3
+ import { parse as parseJsonc } from 'jsonc-parser';
3
4
  import path from 'node:path';
4
5
  import fs from 'node:fs';
5
6
  import cp from 'node:child_process';
6
7
  import inquirer from '@inquirer/confirm';
7
- import { catchError, concat, concatMap, EMPTY, EmptyError, from, last, lastValueFrom, map, merge, mergeMap, Observable, of, share, tap, toArray } from 'rxjs';
8
+ import { concat, concatMap, EMPTY, EmptyError, from, last, lastValueFrom, map, merge, mergeMap, Observable, of, share, tap, toArray } from 'rxjs';
8
9
  import { PassThrough } from 'node:stream';
9
10
  import { render } from 'ink';
10
11
  import React from 'react';
11
- import Ui, { BuildError } from './build.ui.js';
12
+ import Ui from './build.ui.js';
12
13
  import { keys } from './keys.js';
13
14
  const schemaBoard = {
14
15
  type: 'object',
@@ -59,28 +60,13 @@ const schemaBuild = {
59
60
  }
60
61
  }
61
62
  };
62
- const schemaSeedle = {
63
- type: 'object',
64
- required: [],
65
- additionalProperties: {
66
- type: 'object',
67
- required: ['installDir', 'files'],
68
- properties: {
69
- namespace: { type: 'string', nullable: true },
70
- prefix: { type: 'string', nullable: true },
71
- installDir: { type: 'string' },
72
- files: { type: 'array', items: { type: 'string' } }
73
- }
74
- }
75
- };
76
63
  const schema = {
77
64
  type: 'object',
78
- required: ['applications', 'bootloaders', 'wasm'],
65
+ required: ['applications', 'bootloaders'],
79
66
  additionalProperties: false,
80
67
  properties: {
81
68
  applications: schemaBuild,
82
- bootloaders: schemaBuild,
83
- wasm: schemaSeedle
69
+ bootloaders: schemaBuild
84
70
  }
85
71
  };
86
72
  const ajv = new Ajv({ allErrors: true, verbose: true });
@@ -126,17 +112,17 @@ async function resolver(cwd) {
126
112
  }
127
113
  async function parseAppVersion(v) {
128
114
  const data = await fs.promises.readFile(v, 'ascii');
129
- const reMajor = data.matchAll(/^VERSION_MAJOR = ([0-9]+)/gm).next();
130
- const reMinor = data.matchAll(/^VERSION_MINOR = ([0-9]+)/gm).next();
131
- const rePatch = data.matchAll(/^PATCHLEVEL = ([0-9]+)/gm).next();
132
- const reTweak = data.matchAll(/^VERSION_TWEAK = ([0-9]+)/gm).next();
133
- const reExtra = data.matchAll(/^EXTRAVERSION = ([\.a-zA-Z0-9-]+)/gm).next();
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();
134
120
  return {
135
- major: reMajor.value ? parseInt(reMajor.value[1]) : 0,
136
- minor: reMinor.value ? parseInt(reMinor.value[1]) : 0,
137
- patch: rePatch.value ? parseInt(rePatch.value[1]) : 0,
138
- tweak: reTweak.value ? parseInt(reTweak.value[1]) : 0,
139
- extra: reExtra.value ? reExtra.value[1] : ''
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]) || ''
140
126
  };
141
127
  }
142
128
  function formatVersion(ver) {
@@ -232,129 +218,6 @@ function west(args) {
232
218
  err.on('data', (d) => (error += d));
233
219
  })));
234
220
  }
235
- async function seedleOptionsNormalize(seedle, cwd, verbose) {
236
- const name = seedle.__key;
237
- const resolve = await resolver(cwd);
238
- const types = resolve('<atx>/lib/atx/types.cddl');
239
- const installDir = resolve(seedle.installDir);
240
- const buildDir = path.join(installDir, name);
241
- const files = [...seedle.files, types].map((file) => path.resolve(cwd, file));
242
- return {
243
- name: seedle.__key,
244
- cwd,
245
- installDir,
246
- buildDir,
247
- files,
248
- cddl: path.join(installDir, name, `${name}.cddl`),
249
- outputFile: path.join(installDir, `${name}-build.log`),
250
- errorFile: path.join(installDir, `${name}-build.err`),
251
- prefix: seedle.prefix || '',
252
- namespace: seedle.namespace || 'altronix',
253
- verbose
254
- };
255
- }
256
- function seedle(opts) {
257
- const { name, namespace, cddl, cwd, installDir } = opts;
258
- const templatePath = path.resolve(cwd, '..', 'seedle-template');
259
- const seedlePath = path.resolve(cwd, '..', 'seedle', 'seedle');
260
- return of([
261
- 'generate',
262
- '--force',
263
- `--destination=${installDir}`,
264
- `--path=${templatePath}`,
265
- `--name=${name}`,
266
- '--overwrite',
267
- `-dnamespace=${namespace}`,
268
- `-dseedle-manifest-path=${seedlePath.replace(/\\/g, '\\\\')}`,
269
- `-dcddl=${cddl.replace(/\\/g, '\\\\')}`
270
- ]).pipe(mergeMap((seedleArgs) => new Observable((subscriber) => {
271
- const wasm = cp.spawn('cargo', seedleArgs, {
272
- cwd: installDir,
273
- shell: true
274
- });
275
- const fout = fs.createWriteStream(opts.outputFile);
276
- const ferr = fs.createWriteStream(opts.errorFile);
277
- wasm.stdout.pipe(fout);
278
- wasm.stderr.pipe(ferr);
279
- wasm.on('error', (e) => {
280
- subscriber.error(e);
281
- fout.close();
282
- ferr.close();
283
- });
284
- wasm.on('exit', () => {
285
- fout.close();
286
- ferr.close();
287
- subscriber.next();
288
- subscriber.complete();
289
- });
290
- })));
291
- }
292
- function cmakeItem(opts) {
293
- const { name: item } = opts;
294
- return { kind: 'wasm', item };
295
- }
296
- function cmakeConfigure(opts) {
297
- const { buildDir, outputFile, errorFile } = opts;
298
- const { item } = cmakeItem(opts);
299
- return of([`-B${buildDir}`, `-S${buildDir}`]).pipe(mergeMap((cmakeArgs) => new Observable((subscriber) => {
300
- const wasm = cp.spawn('cmake', cmakeArgs, {
301
- cwd: buildDir,
302
- shell: true
303
- });
304
- const fout = fs.createWriteStream(outputFile);
305
- const ferr = fs.createWriteStream(errorFile);
306
- const out = new PassThrough();
307
- wasm.stdout.pipe(fout);
308
- wasm.stdout.pipe(out);
309
- wasm.stderr.pipe(ferr);
310
- wasm.on('error', (e) => {
311
- fout.close();
312
- ferr.close();
313
- out.destroy();
314
- subscriber.error(new BuildError(item, 'wasm', e.name));
315
- });
316
- wasm.on('exit', () => {
317
- fout.close();
318
- ferr.close();
319
- out.destroy();
320
- subscriber.complete();
321
- });
322
- out.on('data', (data) => subscriber.next({ item, output: data.toString() }));
323
- })));
324
- }
325
- function cmakeBuild(opts) {
326
- const { buildDir, outputFile, errorFile } = opts;
327
- const { item } = cmakeItem(opts);
328
- return of([`--build`, `${buildDir}`, `--target`, ` wasm`]).pipe(mergeMap((cmakeArgs) => new Observable((subscriber) => {
329
- const wasm = cp.spawn('cmake', cmakeArgs, {
330
- cwd: buildDir,
331
- shell: true
332
- });
333
- const fout = fs.createWriteStream(outputFile);
334
- const ferr = fs.createWriteStream(errorFile);
335
- const out = new PassThrough();
336
- wasm.stdout.pipe(fout);
337
- wasm.stdout.pipe(out);
338
- wasm.stderr.pipe(ferr);
339
- wasm.on('error', (e) => {
340
- fout.close();
341
- ferr.close();
342
- out.destroy();
343
- subscriber.error(new BuildError(item, 'wasm', e.name));
344
- });
345
- wasm.on('exit', () => {
346
- fout.close();
347
- ferr.close();
348
- out.destroy();
349
- subscriber.complete();
350
- });
351
- out.on('data', (data) => subscriber.next({ item, output: data.toString() }));
352
- })));
353
- }
354
- function cmake(opts) {
355
- const { item } = cmakeItem(opts);
356
- return concat(cmakeConfigure(opts), cmakeBuild(opts), of({ item, complete: true })).pipe(catchError(of));
357
- }
358
221
  function emulateBytePages(board) {
359
222
  return (board.startsWith('same54_xpro') ||
360
223
  board.startsWith('netway') ||
@@ -364,11 +227,20 @@ function extraAppConfs(extraConfs, config) {
364
227
  const key = process.env['ALTRONIX_RELEASE_KEY'];
365
228
  if (!key)
366
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(' ');
367
238
  const extraConfsData = [
368
239
  `CONFIG_MCUBOOT_SIGNATURE_KEY_FILE="${key}"`,
369
240
  `CONFIG_BOOTLOADER_MCUBOOT=y`,
370
241
  `CONFIG_ATX_UPDATE_ENABLE=y`,
371
- `CONFIG_ATX_CONFIG="${config}"`
242
+ `CONFIG_ATX_CONFIG="${config}"`,
243
+ `CONFIG_MCUBOOT_EXTRA_IMGTOOL_ARGS="${imgtoolArgs}"`
372
244
  ].join('\r\n');
373
245
  return from(fs.promises.writeFile(extraConfs, extraConfsData));
374
246
  }
@@ -391,9 +263,6 @@ function copy() {
391
263
  return from(promise);
392
264
  }));
393
265
  }
394
- function concatFiles(src, dest) {
395
- return from(src).pipe(concatMap((src) => from(fs.promises.readFile(src, 'ascii'))), toArray(), mergeMap((arr) => from(fs.promises.writeFile(dest, arr.join('\r\n')))));
396
- }
397
266
  function mkdir() {
398
267
  return (obs$) => obs$.pipe(mergeMap((dir) => new Observable((subscriber) => {
399
268
  fs.promises
@@ -442,6 +311,15 @@ function clean(force) {
442
311
  function tovoid() {
443
312
  return (obs$) => obs$.pipe(map(() => void 0));
444
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
+ }
445
323
  export async function build() {
446
324
  const config = this.opts()['config'] || path.resolve('./', 'atx.json');
447
325
  const verbose = this.optsWithGlobals()['verbose'];
@@ -450,7 +328,7 @@ export async function build() {
450
328
  const mConfig = new RegExp(this.opts()['matchesConfig'] || '(.*)');
451
329
  const cwd = path.resolve(path.dirname(config));
452
330
  const data = await fs.promises.readFile(config, 'ascii');
453
- const atx = JSON.parse(data);
331
+ const atx = parseJsonc(data);
454
332
  const extraAppConfFile = 'application.conf';
455
333
  const extraBootConfFile = 'bootloader.conf';
456
334
  if (!validate(atx))
@@ -477,32 +355,72 @@ export async function build() {
477
355
  return keys(board.images).map((config) => westOptionsNormalize(board, config, app, cwd, verbose));
478
356
  });
479
357
  });
480
- const wasm = keys(atx.wasm).map((w) => seedleOptionsNormalize(w, cwd, verbose));
481
358
  let concurrent = this.opts()['concurrent']
482
359
  ? parseInt(this.opts()['concurrent'])
483
360
  : Infinity;
484
361
  let buildApps = this.opts()['application'];
485
362
  let buildBoot = this.opts()['bootloader'];
486
- let buildWasm = this.opts()['wasm'];
487
- if (!(buildApps || buildBoot || buildWasm)) {
488
- buildApps = buildBoot = buildWasm = true;
363
+ if (!(buildApps || buildBoot)) {
364
+ buildApps = buildBoot = true;
489
365
  }
490
366
  const apps$ = buildApps ? from(await Promise.all(apps)) : EMPTY;
491
367
  const boot$ = buildBoot ? from(await Promise.all(bootloaders)) : EMPTY;
492
- const wasm$ = buildWasm ? from(await Promise.all(wasm)) : EMPTY;
493
368
  // Handle all the preliminary stuff before building
494
- const ready$ = concat(merge(apps$.pipe(mergeMap(({ installDir: s, binaryDir: b }) => from([s, b]))), boot$.pipe(mergeMap(({ installDir: s, binaryDir: b }) => from([s, b]))), wasm$.pipe(map(({ installDir }) => installDir))).pipe(toArray(), mergeMap((arr) => from([...new Set(arr)])), // dedupe
495
- 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)))), wasm$.pipe(mergeMap(seedle)), wasm$.pipe(mergeMap(({ files, cddl: dest }) => concatFiles(files, dest)))).pipe(last(), share());
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());
496
371
  // Get an array of every build item.
497
- const items$ = merge(apps$.pipe(map(westItem)), boot$.pipe(map(westItem)), wasm$.pipe(map(cmakeItem))).pipe(toArray());
498
- // Run build commands for all bootloaders, applications and wasm concurrently
499
- const build$ = merge(apps$.pipe(map(west)), boot$.pipe(map(west)), wasm$.pipe(map(cmake))).pipe(mergeMap((obs) => obs, concurrent), share());
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());
500
388
  // Install everything into installDir
501
- const install$ = merge(apps$.pipe(map(({ name, config, version: ver, board, binaryDir, installDir: d }) => {
502
- return {
503
- src: path.join(binaryDir, 'zephyr', 'zephyr.signed.bin'),
504
- dst: path.join(d, `${board}-${name}-${config}-${ver}.signed.bin`)
505
- };
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$);
506
424
  })), boot$.pipe(map(({ name, config, board, binaryDir, installDir: d }) => {
507
425
  return {
508
426
  src: path.join(binaryDir, 'zephyr', 'zephyr.bin'),
@@ -515,7 +433,7 @@ export async function build() {
515
433
  const renderer = render(React.createElement(Ui, { items: items, "progress$": build$, onComplete: () => renderer.unmount() }));
516
434
  await renderer.waitUntilExit();
517
435
  renderer.cleanup();
518
- return lastValueFrom(install$);
436
+ return lastValueFrom(concat(update$, install$));
519
437
  }
520
438
  catch (e) {
521
439
  if (!(e instanceof EmptyError))
@@ -2,12 +2,12 @@ import React from 'react';
2
2
  import { Observable } from 'rxjs';
3
3
  export declare class BuildError extends Error implements BuildItem {
4
4
  item: string;
5
- kind: 'west' | 'wasm';
6
- constructor(item: string, kind: 'west' | 'wasm', message: string);
5
+ kind: 'west';
6
+ constructor(item: string, kind: 'west', message: string);
7
7
  }
8
8
  export interface BuildItem {
9
9
  item: string;
10
- kind: 'west' | 'wasm';
10
+ kind: 'west';
11
11
  }
12
12
  export interface BuildProgress<K extends string = string> {
13
13
  item: K;
package/dist/build.ui.js CHANGED
@@ -36,8 +36,8 @@ export default function ({ items, progress$, onComplete }) {
36
36
  React.createElement(Box, { width: col - width - 4 - 2 - 1 - 1 - 1 },
37
37
  React.createElement(Text, { wrap: "truncate", dimColor: !progressComplete(progress[item]), color: progressColor(progress[item]) }, progress[item])))))));
38
38
  }
39
- function buildColor(kind) {
40
- return kind === 'west' ? 'blue' : 'magenta';
39
+ function buildColor(_kind) {
40
+ return 'blue';
41
41
  }
42
42
  function progressComplete(progress) {
43
43
  return progress === 'OK!';
package/dist/cloud.js CHANGED
@@ -1,4 +1,4 @@
1
- import { cborDecodeNum, NetCloud } from '@altronix/zdk';
1
+ import { cborDecodeNum, NetCloud } from '@altronix/zdk/common';
2
2
  import { Linq } from '@altronix/device';
3
3
  import { concat, EmptyError, finalize, lastValueFrom, map, reduce, switchMap } from 'rxjs';
4
4
  import logger from './logger.js';
package/dist/index.js CHANGED
@@ -3,16 +3,8 @@ import { program } from 'commander';
3
3
  import path from 'path';
4
4
  import fs from 'fs';
5
5
  import { build } from './build.js';
6
- import { getAbout, getSite, setSite } from './about.js';
7
- import { reboot, save, saveAndReboot } from './exe.js';
8
- import { getNet, setNet, setDhcp } from './net.js';
9
- import { fileURLToPath } from 'url';
10
- import { getCloud, setCloud } from './cloud.js';
11
- import { stress } from './stress.js';
12
6
  import { update } from './update.js';
13
- import { listen } from './listen.js';
14
- import { getDemo, getHello } from './sample.js';
15
- import { concurrent } from './concurrent.js';
7
+ import { fileURLToPath } from 'url';
16
8
  // https://stackoverflow.com/questions/8817423/why-is-dirname-not-defined-in-node-repl
17
9
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
18
10
  // Parse package.json to get version
@@ -25,17 +17,6 @@ program
25
17
  .version(ver)
26
18
  .option('-Q, --quiet', 'no logs')
27
19
  .option('-VV, --verbose', 'extra logging');
28
- // Scan command
29
- /*
30
- const ignore = 'node_modules;target;build;.git';
31
- program
32
- .command('scan')
33
- .description('scan for *.cddl files')
34
- .option('-p, --path <PATH>', 'root directory to start scan', cwd)
35
- .option('-m, --matches <REGEX>', 'match expression', '.*cddl$')
36
- .option('-i, --ignores <REGEX>', 'ignore directories', ignore)
37
- .action(seedle.scan);
38
- */
39
20
  // Build command
40
21
  // TODO - detect if west is available and fail early
41
22
  program
@@ -45,101 +26,16 @@ program
45
26
  .option('-C, --concurrent <NUMBER>', 'how many builds to run concurrently')
46
27
  .option('-A, --application', 'build applications')
47
28
  .option('-B, --bootloader', 'build bootloaders')
48
- .option('-W, --wasm', 'build wasm')
49
29
  .option('--matches-board [regex]', 'board regex match expr')
50
30
  .option('--matches-application [regex]', 'application regex match expr')
51
31
  .option('--matches-config [regex]', 'config regex match expr')
52
32
  .option('-y, --yes', 'answer yes automatically')
53
33
  .action(build);
54
- const device = program
55
- .command('device')
56
- .description('manage zephyr device settings')
57
- .option('-p, --port <PORT>', 'listen port for incoming device connections')
58
- .option('-v, --verbose', 'print extra debug informating during request')
59
- .option('-f, --first', 'perform request on first device we see (any device)');
60
- device
61
- .command('get-about')
62
- .description('get about data on the device')
63
- .action(getAbout);
64
- device
65
- .command('get-site')
66
- .description('get the site ID of the device')
67
- .action(getSite);
68
- device
69
- .command('set-site')
70
- .description('set the site ID of the device')
71
- .argument('<site>', 'new site id')
72
- .action(setSite);
73
- device
74
- .command('save')
75
- .description('save data to persistant storage. (does not reboot)')
76
- .action(save);
77
- device
78
- .command('reboot')
79
- .description('reboot the device. (does not save)')
80
- .action(reboot);
81
- device
82
- .command('save-reboot')
83
- .description('save data to persistant storage and reboot the device')
84
- .action(saveAndReboot);
85
- device
86
- .command('get-net')
87
- .description('get network configuration from the device')
88
- .action(getNet);
89
- device
90
- .command('set-net')
91
- .description('set network interface into static ip mode')
92
- .argument('<ip>', 'the new IP address')
93
- .argument('<sn>', 'the new SUBNET mask')
94
- .argument('<gw>', 'the new GATEWAY address')
95
- .action(setNet);
96
- device
97
- .command('set-dhcp')
98
- .description('set network interface into DHCP mode')
99
- .action(setDhcp);
100
- device
101
- .command('get-cloud')
102
- .description('get cloud endpoint on the device')
103
- .action(getCloud);
104
- device
105
- .command('set-cloud')
106
- .description('set cloud endpoint on the device')
107
- .argument('<endpoint>', 'cloud service location')
108
- .action(setCloud);
109
- device
110
- .command('stress')
111
- .description('run a stress test on a device')
112
- .argument('<count>', 'how many requests to make')
113
- .action(stress);
114
- device
34
+ // Update command
35
+ program
115
36
  .command('update')
116
- .description('run firmware update from file')
117
- .argument('<file>', 'file to update device with')
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')
118
40
  .action(update);
119
- device
120
- .command('listen')
121
- .description('listen for alerts and heartbeats')
122
- .argument('<duration>', 'how long to listen')
123
- .action(listen);
124
- device
125
- .command('concurrent')
126
- .description('make a number of requests concurrently')
127
- .argument('<count>', 'how many requests to make concurrently')
128
- .action(concurrent);
129
- const sample = program
130
- .command('sample')
131
- .description('manage zephyr device settings')
132
- .option('-p, --port <PORT>', 'listen port for incoming device connections')
133
- .option('-v, --verbose', 'print extra debug informating during request')
134
- .option('-f, --first', 'perform request on first device we see (any device)');
135
- sample
136
- .command('get-demo')
137
- .description('read the demo data from the sample')
138
- .action(getDemo);
139
- sample
140
- .command('get-hello')
141
- .description('read the hello data from the sample')
142
- .action(getHello);
143
- // Load plugins
144
- // (await plugins()).forEach(({ plugin: _, path: __, description: ___ }) => {});
145
41
  program.parseAsync().catch((e) => console.error(e));
package/dist/net.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import { concat, EmptyError, finalize, firstValueFrom, lastValueFrom, map, switchMap } from 'rxjs';
2
2
  import { Linq } from '@altronix/device';
3
- import { NetIp } from '@altronix/zdk';
3
+ import { NetIp } from '@altronix/zdk/common';
4
4
  import logger from './logger.js';
5
5
  import { select, first } from './select.js';
6
6
  export async function getNet() {
@@ -1,4 +1,4 @@
1
- import { AboutProps } from '@altronix/zdk';
1
+ import { AboutProps } from '@altronix/zdk/common';
2
2
  import React from 'react';
3
3
  import { Observable } from 'rxjs';
4
4
  export interface OnSelectCallback {
package/dist/stress.js CHANGED
@@ -1,4 +1,4 @@
1
- import { METH_CONSTANTS } from '@altronix/zdk';
1
+ import { METH_CONSTANTS } from '@altronix/zdk/common';
2
2
  import { Linq } from '@altronix/device';
3
3
  import { EmptyError, finalize, lastValueFrom, switchMap, pipe, repeat } from 'rxjs';
4
4
  import progress from './progress.js';
package/dist/update.d.ts CHANGED
@@ -1,2 +1,42 @@
1
1
  import { Command } from 'commander';
2
- export declare function update(this: Command, file: string): Promise<void>;
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 { Linq } from '@altronix/device';
2
- import { Update } from '@altronix/zdk';
3
- import { concat, defer, EmptyError, finalize, from, lastValueFrom, map, mergeScan, switchMap } from 'rxjs';
4
- import fs from 'fs';
5
- import logger from './logger.js';
6
- import { select, first } from './select.js';
7
- import progress from './progress.js';
8
- function chunks(data, size) {
9
- let arr = [];
10
- let remainder = data.length % size;
11
- function slicer(n) {
12
- for (let i = 0; i < n; i++) {
13
- let idx = i * size;
14
- arr.push(data.slice(idx, idx + size));
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
- if (remainder == 0) {
18
- slicer(data.length / size);
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
- else {
21
- let n = Math.ceil(data.length / size);
22
- let end = size * (n - 1);
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
- function updater(linq, serial) {
38
- return (acc, update) => linq
39
- .put(serial, '/api/v1/update/transfer', update.cbor())
40
- .pipe(map(({ meta }) => ({ ...meta, remaining: acc.remaining - 1 })));
41
- }
42
- export async function update(file) {
43
- logger(this.optsWithGlobals()['verbose'] ? 'silly' : 'info');
44
- const linq = new Linq();
45
- const dev$ = linq
46
- .connections()
47
- .pipe(this.optsWithGlobals()['first'] ? first() : select());
48
- const obs = from(fs.promises.readFile(file)).pipe(switchMap((bin) => dev$.pipe(map((serial) => ({ serial, bin })))), switchMap(({ serial, bin }) => {
49
- const update = chunks(bin, 512).map(updateMap);
50
- const start$ = defer(() => linq.get(serial, '/api/v1/update/start'));
51
- const finish$ = defer(() => linq.get(serial, '/api/v1/update/finish'));
52
- const transfer$ = from(update).pipe(mergeScan(updater(linq, serial), { remaining: update.length }, 1));
53
- return concat(start$, transfer$, finish$).pipe(progress(update.length));
54
- }), finalize(() => linq.shutdown()));
55
- return lastValueFrom(obs).catch((e) => {
56
- if (!(e instanceof EmptyError))
57
- throw e;
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
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@altronix/cli",
3
- "version": "0.12.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
- "@inquirer/confirm": "^3.1.14",
17
- "ajv": "^8.16.0",
16
+ "@altronix/webtobin": "^0.8.0",
17
+ "@inquirer/confirm": "^3.2.0",
18
+ "ajv": "^8.17.1",
19
+ "cbor2": "^2.2.1",
18
20
  "commander": "^10.0.1",
19
- "dotenv": "^16.4.5",
20
- "ink": "^4.1.0",
21
+ "dotenv": "^16.6.1",
22
+ "ink": "^4.4.1",
21
23
  "jsonc-parser": "^3.3.1",
22
- "react": "^18.2.0",
23
- "rxjs": "^7.8.1",
24
- "winston": "^3.13.0",
25
- "@altronix/device": "0.9.0",
26
- "@altronix/zdk": "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.0.32",
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.2.0",
34
- "chalk": "^5.2.0",
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.32.2",
40
- "eslint-plugin-react-hooks": "^4.6.0",
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.7",
43
- "ts-node": "^10.9.1",
44
- "typescript": "^5.0.3",
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": {