Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions installer/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -131,3 +131,26 @@ These simulations establish software behavior only. They do not establish USB re
6. Before enabling USB updates: capable confirmed firmware and approved rollback bootloader; desktop Chrome/Edge streaming and exact version confirmation; Atom-only transfer then bottom-power startup; preservation readback; disconnect/power cuts before and after selection; failed startup rollback; invalid/truncated/out-of-order traffic; competing Wi-Fi/USB/settings actions; runtime heap, fragmentation and prayer/audio responsiveness. Prior fresh-install or Wi-Fi update evidence does not establish this new transport.

Use the firmware repository's current hardware runbooks and fresh private backups for coordinated acceptance. Historical full-flash images can roll back prayer history and are not routine credential recovery. Keep private diagnostics, credentials and recovery images outside this repository. Published recordings are imported only through the reviewed release manifest; never commit generated binaries.

## Waveshare Box V2 release preparation

The installer supports hardware-specific schema-1 bundles when a published stable
release provides them. Atom retains its existing release fields and filenames.
`hardwareReleases` adds a Waveshare pin under
`waveshare-esp32-s3-touch-lcd-1_85c-box-v2`; automatic selection adopts the prefixed
manifest only when present and source-matched. Legacy Atom-only releases continue
to work. Additional pins must use the same release tag; manual pins and disabling
installation retain their existing policy. Static metadata and freshness checks
include all selected hardware manifests.

Recognized OpenAthan reports hardware through USB status; older responses retain
Atom compatibility. Factory/blank devices require explicit model selection,
followed by full-erasure confirmation. Changing models clears that confirmation.
The flasher checks unlocked ESP32-S3 and matching 8/16 MiB capacity; capacity alone
cannot identify the board or distinguish Waveshare V1 from V2. V1 is unsupported.

Waveshare bundles use `waveshare-box-v2.` names except for shared
`athan-audio.bin`. Signed USB updates verify the selected hardware, and recovery
continues to preserve settings/history. Public USB update writes remain disabled
by the existing policy until physical qualification. New simulator scenarios
`waveshare` and `waveshare-new` exercise existing and fresh-install flows.
5 changes: 4 additions & 1 deletion installer/entry.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { parsePin } from './release';
import { parsePin, parseHardwareReleases } from './release';
import { usbSupport } from './browser-transport';
import { RealService } from './real-service';
import { mountInstaller } from './ui';
Expand All @@ -11,6 +11,9 @@ if (root) {
new RealService(
parsePin(JSON.parse(root.dataset.releaseSelection ?? 'null')),
JSON.parse(root.dataset.releaseSelection ?? 'null')?.usbUpdateEnabled === true,
parseHardwareReleases(
JSON.parse(root.dataset.releaseSelection ?? 'null')?.hardwareReleases,
),
),
usbSupport(),
);
Expand Down
13 changes: 9 additions & 4 deletions installer/flasher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { ESPLoader } from 'esptool-js';
import { md5 } from '@noble/hashes/legacy.js';
import { bytesToHex } from '@noble/hashes/utils.js';
import { FlashTransport } from './flash-transport';
import { FLASH_BYTES, parseManifest, verifyBundle, type ReleaseBundle } from './release';
import { HARDWARE_PROFILES, parseManifest, verifyBundle, type ReleaseBundle } from './release';

export interface Programmer {
inspect(): Promise<{ chip: string; flashBytes: number; secured: boolean }>;
Expand All @@ -22,6 +22,7 @@ export async function flashBundle(
try {
if (!confirmed) throw new Error('Confirm a new installation before continuing.');
const manifest = parseManifest(input.manifest, {
hardware: input.manifest.hardware,
tag: input.manifest.tag,
manifestSha256: '0'.repeat(64),
mediaReviewed: true,
Expand All @@ -32,9 +33,13 @@ export async function flashBundle(
new Map(input.parts.map((part) => [part.metadata.file, part.bytes])),
);
const device = await programmer.inspect();
if (device.chip !== 'ESP32-S3' || device.flashBytes !== FLASH_BYTES || device.secured)
if (
device.chip !== 'ESP32-S3' ||
device.flashBytes !== HARDWARE_PROFILES[manifest.hardware].flashBytes ||
device.secured
)
throw new Error(
'This device does not match the supported unlocked 8 MB ESP32-S3 hardware. Nothing was installed.',
'This device does not match the selected unlocked ESP32-S3 hardware. Nothing was installed.',
);
await programmer.write(bundle, progress);
for (const part of bundle.parts) {
Expand Down Expand Up @@ -76,7 +81,7 @@ export function serialProgrammer(port: SerialPort): Programmer {
const size = await loader.detectFlashSize();
return {
chip: loader.chip.CHIP_NAME,
flashBytes: size === '8MB' ? FLASH_BYTES : 0,
flashBytes: size === '8MB' ? 0x800000 : size === '16MB' ? 0x1000000 : 0,
secured:
loader.secureDownloadMode ||
info.parsedFlags.SECURE_BOOT_EN ||
Expand Down
7 changes: 7 additions & 0 deletions installer/protocol.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { HARDWARE, isHardware, type Hardware } from './release';
/** Improv Serial v1 and the OpenAthan v1 extension. No transport or browser imports. */
const encoder = new TextEncoder();
const decoder = new TextDecoder('utf-8', { fatal: true });
Expand Down Expand Up @@ -124,6 +125,7 @@ export function validatePassword(password: string): void {
}

export interface DeviceStatus {
hardware?: Hardware;
wifi: '2' | '3' | '4';
password: 'absent' | 'ready' | 'fault';
setup: 'incomplete' | 'active' | 'storage_fault';
Expand Down Expand Up @@ -166,6 +168,10 @@ export function localDeviceUrls(urls: string[], hostname: string): string[] {

export function parseStatus(fields: string[]): DeviceStatus {
const [version, wifi, password, setup, revision, hostname, storage, ...urls] = fields;
const hardwareFields = urls.filter((value) => value.startsWith('hardware='));
const hardware = hardwareFields[0]?.slice(9) ?? HARDWARE;
if (hardwareFields.length > 1 || !isHardware(hardware))
throw new Error('Unsupported connected hardware.');
if (
version !== '1' ||
!['2', '3', '4'].includes(wifi ?? '') ||
Expand All @@ -179,6 +185,7 @@ export function parseStatus(fields: string[]): DeviceStatus {
throw new Error('This device returned an unsupported OpenAthan status.');
}
return {
hardware,
wifi,
password,
setup,
Expand Down
34 changes: 28 additions & 6 deletions installer/real-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,12 +10,20 @@ import {
type UpgradeOffer,
type UpgradeCheck,
} from './upgrade';
import { loadRelease, type ReleasePin } from './release';
import {
loadRelease,
HARDWARE,
type Hardware,
type HardwareReleases,
type ReleasePin,
} from './release';
import type { InstallerService, Connected } from './service';

export class RealService implements InstallerService {
readonly simulated = false;
readonly installAvailable: boolean;
readonly installHardware: Hardware[];
private hardware: Hardware = HARDWARE;
private port?: SerialPort;
private session?: ProvisioningSession;
private installationCandidate = false;
Expand All @@ -26,8 +34,10 @@ export class RealService implements InstallerService {
constructor(
private pin: ReleasePin | null,
private usbUpdateEnabled = false,
private hardwareReleases: HardwareReleases = {},
) {
this.installAvailable = pin !== null;
this.installHardware = pin ? [HARDWARE, ...(Object.keys(hardwareReleases) as Hardware[])] : [];
}
private async open(): Promise<ProvisioningSession> {
if (!this.port) throw new Error('Choose the USB device first.');
Expand Down Expand Up @@ -62,7 +72,8 @@ export class RealService implements InstallerService {
const status = await session.status(remaining);
recognized = true;
this.installationCandidate = false;
return { kind: 'existing', status };
this.hardware = status.hardware ?? HARDWARE;
return { kind: 'existing', status, hardware: this.hardware };
} catch {
if (recognized || this.connectionLost) {
await this.close();
Expand All @@ -80,11 +91,19 @@ export class RealService implements InstallerService {
return { kind: 'unrecognized' };
}
}
async install(confirmed: boolean, progress: (percent: number) => void) {
async install(
confirmed: boolean,
progress: (percent: number) => void,
hardware: Hardware = HARDWARE,
) {
if (!confirmed || !this.installationCandidate || !this.port || !this.pin || this.session)
throw new Error('A confirmed new-device connection is required.');
this.installationCandidate = false;
const bundle = await loadRelease(this.pin);
const selected = hardware === HARDWARE ? this.pin : this.hardwareReleases[hardware];
if (!selected || !this.installHardware.includes(hardware))
throw new Error('No reviewed release for this hardware.');
this.hardware = hardware;
const bundle = await loadRelease(selected);
const { flashBundle, serialProgrammer } = await import('./flasher');
try {
await flashBundle(serialProgrammer(this.port), bundle, true, progress);
Expand Down Expand Up @@ -170,7 +189,7 @@ export class RealService implements InstallerService {
: undefined,
detail:
info.state === 'awaiting_power'
? 'An update is verified. Switch to Pyramid bottom power to finish.'
? 'An update is verified. Switch to normal speaker power to finish.'
: 'The device has an update or startup check in progress. Check its status before another update.',
};
if (!this.usbUpdateEnabled || !this.pin)
Expand All @@ -181,7 +200,10 @@ export class RealService implements InstallerService {
'USB updating is awaiting physical qualification. Use firmware updates on the device’s settings page.',
};
try {
const bundle = await loadUpgrade(this.pin);
const selected =
(info.hardware ?? HARDWARE) === HARDWARE ? this.pin : this.hardwareReleases[info.hardware!];
if (!selected) throw new Error('No reviewed update for this hardware.');
const bundle = await loadUpgrade(selected);
if (!newerVersion(bundle.offer.version, info.version))
return {
state: 'current',
Expand Down
46 changes: 39 additions & 7 deletions installer/release.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,36 @@
/** Versioned contract consumed from the firmware repository; no device access. */
export const HARDWARE = 'atoms3r-c126-pyramid-a167';
export const WAVESHARE = 'waveshare-esp32-s3-touch-lcd-1_85c-box-v2';
export const HARDWARE_PROFILES = {
[HARDWARE]: { label: 'AtomS3R C126 + Pyramid A167', flashBytes: 0x800000, prefix: '' },
[WAVESHARE]: { label: 'Waveshare Box V2', flashBytes: 0x1000000, prefix: 'waveshare-box-v2.' },
} as const;
export type Hardware = keyof typeof HARDWARE_PROFILES;
export function isHardware(value: unknown): value is Hardware {
return value === HARDWARE || value === WAVESHARE;
}
export function assetName(hardware: Hardware, name: string): string {
return name === 'athan-audio.bin' ? name : HARDWARE_PROFILES[hardware].prefix + name;
}
export type HardwareReleases = Partial<Record<Hardware, ReleasePin>>;
export function parseHardwareReleases(value: unknown): HardwareReleases {
if (value === undefined) return {};
const releases = object(value),
result: HardwareReleases = {};
for (const [hardware, pin] of Object.entries(releases)) {
if (hardware !== WAVESHARE) throw new Error('Unsupported additional release hardware.');
const parsed = parsePin({ schema: 1, release: { ...object(pin), hardware } });
if (!parsed) throw new Error('Invalid hardware release.');
result[hardware] = parsed;
}
return result;
}
export const LAYOUT = 'dual-2m-audio-3_5m-v1';
export const FLASH_BYTES = 0x800000;
export const AUDIO_OFFSET = 0x410000;
export const AUDIO_BYTES = 0x380000;
export interface ReleasePin {
hardware?: Hardware;
tag: string;
manifestSha256: string;
mediaReviewed: true;
Expand All @@ -22,7 +48,7 @@ export interface ReleaseManifest {
repository: 'OpenAthan-Project/openathan';
tag: string;
commit: string;
hardware: typeof HARDWARE;
hardware: Hardware;
chip: 'ESP32-S3';
flashBytes: number;
layout: typeof LAYOUT;
Expand Down Expand Up @@ -51,6 +77,7 @@ export function parsePin(value: unknown): ReleasePin | null {
!tagPattern.test(pin.tag) ||
typeof pin.manifestSha256 !== 'string' ||
!digestPattern.test(pin.manifestSha256) ||
(pin.hardware !== undefined && !isHardware(pin.hardware)) ||
pin.mediaReviewed !== true ||
pin.hardwareQualified !== true
)
Expand All @@ -67,9 +94,9 @@ export function parseManifest(value: unknown, pin: ReleasePin): ReleaseManifest
!tagPattern.test(pin.tag) ||
typeof m.commit !== 'string' ||
!/^[a-f0-9]{40}$/.test(m.commit) ||
m.hardware !== HARDWARE ||
m.hardware !== (pin.hardware ?? HARDWARE) ||
m.chip !== 'ESP32-S3' ||
m.flashBytes !== FLASH_BYTES ||
m.flashBytes !== HARDWARE_PROFILES[pin.hardware ?? HARDWARE].flashBytes ||
m.layout !== LAYOUT ||
m.provisioningProtocol !== 1 ||
media.redistributionApproved !== true ||
Expand All @@ -92,7 +119,7 @@ export function parseManifest(value: unknown, pin: ReleasePin): ReleaseManifest
!Number.isSafeInteger(p.bytes) ||
Number(p.bytes) <= 0 ||
Number(p.offset) < 0 ||
Number(p.offset) + Number(p.bytes) > FLASH_BYTES ||
Number(p.offset) + Number(p.bytes) > Number(m.flashBytes) ||
typeof p.sha256 !== 'string' ||
!digestPattern.test(p.sha256)
)
Expand Down Expand Up @@ -130,7 +157,7 @@ export async function checkedManifest(
return parseManifest(JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(bytes)), pin);
}
/** Check the real factory partition table and app header, not just manifest claims. */
export function verifyFactory(bytes: Uint8Array): void {
export function verifyFactory(bytes: Uint8Array, hardware: Hardware = HARDWARE): void {
const expected = [
['nvs', 1, 2, 0x9000, 0x5000],
['otadata', 1, 0, 0xe000, 0x2000],
Expand All @@ -140,6 +167,8 @@ export function verifyFactory(bytes: Uint8Array): void {
];
if (bytes.length < 0x10020 || bytes[0] !== 0xe9 || bytes[0x10000] !== 0xe9)
throw new Error('Invalid factory image header.');
if (hardware === WAVESHARE && (bytes[3]! >> 4 !== 4 || bytes[0x10003]! >> 4 !== 4))
throw new Error('Factory flash capacity differs from hardware.');
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
if (view.getUint16(12, true) !== 9 || view.getUint16(0x1000c, true) !== 9)
throw new Error('Factory image is not for ESP32-S3.');
Expand Down Expand Up @@ -169,7 +198,7 @@ export async function verifyBundle(
const bytes = inputs.get(metadata.file);
if (!bytes || bytes.length !== metadata.bytes || (await sha256(bytes)) !== metadata.sha256)
throw new Error(`Image verification failed: ${metadata.role}.`);
if (metadata.role === 'factory') verifyFactory(bytes);
if (metadata.role === 'factory') verifyFactory(bytes, manifest.hardware);
parts.push({ metadata, bytes });
}
return { manifest, parts };
Expand Down Expand Up @@ -211,7 +240,10 @@ export async function loadRelease(
reader.releaseLock();
}
};
const manifest = await checkedManifest(await get('manifest.json', 16_384), pin);
const manifest = await checkedManifest(
await get(assetName(pin.hardware ?? HARDWARE, 'manifest.json'), 16_384),
pin,
);
const inputs = new Map<string, Uint8Array>();
for (const part of manifest.parts) inputs.set(part.file, await get(part.file, part.bytes));
return verifyBundle(manifest, inputs);
Expand Down
12 changes: 10 additions & 2 deletions installer/service.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,20 @@
import type { Hardware } from './release';
import type { DeviceStatus } from './protocol';
import type { FirmwareInfo, UpgradeCheck, UpgradeOffer } from './upgrade';
export type Connected = { kind: 'unrecognized' } | { kind: 'existing'; status: DeviceStatus };
export type Connected =
| { kind: 'unrecognized' }
| { kind: 'existing'; status: DeviceStatus; hardware?: Hardware };
export interface InstallerService {
readonly simulated: boolean;
readonly installAvailable: boolean;
readonly installHardware?: Hardware[];
onDisconnect?: () => void;
connect(): Promise<Connected>;
install(confirmed: boolean, progress: (percent: number) => void): Promise<DeviceStatus>;
install(
confirmed: boolean,
progress: (percent: number) => void,
hardware?: Hardware,
): Promise<DeviceStatus>;
discardUpdate(): Promise<void>;
firmware(): Promise<FirmwareInfo>;
checkUpdate(): Promise<UpgradeCheck>;
Expand Down
Loading
Loading