diff --git a/installer/README.md b/installer/README.md index 4d54097..b4869d5 100644 --- a/installer/README.md +++ b/installer/README.md @@ -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. diff --git a/installer/entry.ts b/installer/entry.ts index c78b92a..c5c1c0f 100644 --- a/installer/entry.ts +++ b/installer/entry.ts @@ -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'; @@ -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(), ); diff --git a/installer/flasher.ts b/installer/flasher.ts index 2d7a31f..645ee0c 100644 --- a/installer/flasher.ts +++ b/installer/flasher.ts @@ -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 }>; @@ -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, @@ -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) { @@ -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 || diff --git a/installer/protocol.ts b/installer/protocol.ts index fa61329..5ba4b08 100644 --- a/installer/protocol.ts +++ b/installer/protocol.ts @@ -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 }); @@ -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'; @@ -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 ?? '') || @@ -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, diff --git a/installer/real-service.ts b/installer/real-service.ts index 995a424..9905518 100644 --- a/installer/real-service.ts +++ b/installer/real-service.ts @@ -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; @@ -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 { if (!this.port) throw new Error('Choose the USB device first.'); @@ -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(); @@ -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); @@ -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) @@ -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', diff --git a/installer/release.ts b/installer/release.ts index a50ebd1..1a70620 100644 --- a/installer/release.ts +++ b/installer/release.ts @@ -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>; +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; @@ -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; @@ -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 ) @@ -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 || @@ -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) ) @@ -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], @@ -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.'); @@ -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 }; @@ -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(); for (const part of manifest.parts) inputs.set(part.file, await get(part.file, part.bytes)); return verifyBundle(manifest, inputs); diff --git a/installer/service.ts b/installer/service.ts index c8584ae..6efda15 100644 --- a/installer/service.ts +++ b/installer/service.ts @@ -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; - install(confirmed: boolean, progress: (percent: number) => void): Promise; + install( + confirmed: boolean, + progress: (percent: number) => void, + hardware?: Hardware, + ): Promise; discardUpdate(): Promise; firmware(): Promise; checkUpdate(): Promise; diff --git a/installer/simulator.ts b/installer/simulator.ts index d7e33d5..8b2873e 100644 --- a/installer/simulator.ts +++ b/installer/simulator.ts @@ -1,3 +1,4 @@ +import { HARDWARE, WAVESHARE, type Hardware } from './release'; /** Deliberately has no dependency on Web Serial, release files or the flasher. */ import { decodeFields, encodeFrame, FrameDecoder, rpcPayload } from './protocol'; import { ProvisioningSession, type ByteTransport } from './session'; @@ -6,6 +7,8 @@ import type { Connected, InstallerService } from './service'; export const scenarios = [ 'success', 'new-device', + 'waveshare', + 'waveshare-new', 'unreadable', 'denied', 'busy', @@ -77,6 +80,21 @@ export class SimulatedTransport implements ByteTransport { ]); return; } + if (extension && command === 0x10) { + this.send(true, 0x10, [ + '1', + 'v0.4.0', + 'a'.repeat(40), + 'unsupported', + 'idle', + 'confirmed', + '', + '', + '0', + this.scenario === 'waveshare' ? WAVESHARE : HARDWARE, + ]); + return; + } if (extension && command === 1) { this.send(true, 1, [ '1', @@ -126,6 +144,8 @@ export class SimulatedTransport implements ByteTransport { } export class SimulatorService implements InstallerService { + readonly installHardware: Hardware[] = [HARDWARE, WAVESHARE]; + private hardware: Hardware = HARDWARE; readonly simulated = true; readonly installAvailable = true; private session?: ProvisioningSession; @@ -141,7 +161,7 @@ export class SimulatorService implements InstallerService { await this.close(); if (this.scenario === 'denied') throw new DOMException('Permission dismissed', 'NotFoundError'); if (this.scenario === 'busy') throw new DOMException('Port busy', 'InvalidStateError'); - if (['new-device', 'unknown', 'flash-failed'].includes(this.scenario)) { + if (['new-device', 'waveshare-new', 'unknown', 'flash-failed'].includes(this.scenario)) { this.installationCandidate = true; return { kind: 'unrecognized' }; } @@ -153,12 +173,18 @@ export class SimulatorService implements InstallerService { 'OpenAthan was recognized, but its status could not be read. Reconnect your speaker; do not reinstall.', ); } - return { kind: 'existing', status: await session.status() }; + this.hardware = this.scenario === 'waveshare' ? WAVESHARE : HARDWARE; + return { kind: 'existing', status: await session.status(), hardware: this.hardware }; } - async install(confirmed: boolean, progress: (percent: number) => void) { + async install( + confirmed: boolean, + progress: (percent: number) => void, + hardware: Hardware = HARDWARE, + ) { if (!confirmed || !this.installationCandidate) throw new Error('Confirm the simulated installation first.'); this.installationCandidate = false; + this.hardware = hardware; for (const percent of [15, 40, 75, 100]) { await new Promise((resolve) => setTimeout(resolve, 100)); progress(percent); @@ -172,6 +198,7 @@ export class SimulatorService implements InstallerService { async discardUpdate() {} async firmware(): Promise { return { + hardware: this.hardware, version: 'v0.4.0', commit: 'a'.repeat(40), supported: true, diff --git a/installer/ui.ts b/installer/ui.ts index 740e331..7c94600 100644 --- a/installer/ui.ts +++ b/installer/ui.ts @@ -1,3 +1,4 @@ +import { HARDWARE, WAVESHARE, HARDWARE_PROFILES, isHardware, type Hardware } from './release'; import { validatePassword, validateWifi, type DeviceStatus } from './protocol'; import { DeviceError, UncertainOutcome } from './session'; import type { InstallerService } from './service'; @@ -29,6 +30,7 @@ export function mountInstaller( offered: UpgradeOffer | undefined, installed = false, updateWritten = false; + let hardware: Hardware = HARDWARE; let operation: 'wifi' | 'password' | 'update' | 'none' = 'none'; let notice = '', mutationBlocked = false, @@ -64,14 +66,14 @@ export function mountInstaller( if (unsupported) { render( 'Use a computer for USB setup', - `

${escape(unsupported)}

Setup instructions · Troubleshooting

USB recovery can fix Wi-Fi or your device password without resetting prayer settings or history.

${!service.installAvailable ? unavailable : ''}

Supported hardware: AtomS3R C126 + Pyramid A167. Use a USB data cable.

`, + `

${escape(unsupported)}

Setup instructions · Troubleshooting

USB recovery can fix Wi-Fi or your device password without resetting prayer settings or history.

${!service.installAvailable ? unavailable : ''}

Supported hardware: AtomS3R C126 + Pyramid A167 and Waveshare Box V2. Use a USB data cable.

`, 0, true, ); } else { render( 'Connect speaker', - `

Use a USB data cable to connect your computer to the Atom’s USB-C port. Leave the Pyramid’s bottom power cable unplugged.

ComputerAtom USB-C

Use desktop Chrome or Edge. Close other serial tools first. Choose the USB JTAG/serial device in your browser’s connection window.

We’ll check for OpenAthan, then show settings and recovery options or ask you to confirm a new installation. Connecting does not install or erase firmware.

${!service.installAvailable ? unavailable : ''}
${button(service.simulated ? 'Connect simulated device' : 'Choose USB device', 'connect')}Setup instructionsTroubleshooting
`, + `

Use a USB data cable to connect your computer to the speaker’s USB-C port. For Atom, use its USB-C port and leave the Pyramid’s bottom power cable unplugged. For Waveshare Box V2, use the rear USB-C port.

ComputerSpeaker USB-C

Use desktop Chrome or Edge. Close other serial tools first. Choose the USB JTAG/serial device in your browser’s connection window.

We’ll check for OpenAthan, then show settings and recovery options or ask you to confirm a new installation. Connecting does not install or erase firmware.

${!service.installAvailable ? unavailable : ''}
${button(service.simulated ? 'Connect simulated device' : 'Choose USB device', 'connect')}Setup instructionsTroubleshooting
`, 0, true, ); @@ -81,9 +83,15 @@ export function mountInstaller( function confirmation() { path = 'install'; const available = service.installAvailable; + const choices = service.installHardware ?? [HARDWARE]; + hardware = choices[0] ?? HARDWARE; + const modelChoice = + choices.length > 1 + ? `

Check the model printed on your device. Waveshare V1 is not supported.

` + : ''; render( available ? 'Confirm a new installation' : 'New installation is unavailable', - `

OpenAthan was not recognized. This does not prove the device is new or empty. If you expected an existing OpenAthan, check the cable and retry before considering installation.

${available ? '
This erases the device.

Existing firmware, saved Wi-Fi, prayer settings and prayer history will be replaced. This is not an update or a password recovery.

' : '

No installation release is available. Check the connection or follow the troubleshooting instructions.

'}
${available ? '' : ''}${button('Back to connection', 'start', true)}Troubleshooting
`, + `

OpenAthan was not recognized. This does not prove the device is new or empty. If you expected an existing OpenAthan, check the cable and retry before considering installation.

${available ? `${modelChoice}
This erases the device.

Existing firmware, saved Wi-Fi, prayer settings and prayer history will be replaced. This is not an update or a password recovery.

` : '

No installation release is available. Check the connection or follow the troubleshooting instructions.

'}
${available ? '' : ''}${button('Back to connection', 'start', true)}Troubleshooting
`, 0, ); } @@ -153,7 +161,7 @@ export function mountInstaller( : button('Continue to device settings', 'finish'); render( 'Your OpenAthan is connected', - `

${escape(status?.hostname ?? '')}

${wifiMissing ? 'Wi-Fi is not connected.' : 'Wi-Fi is connected.'} ${passwordMissing ? 'A device password is needed.' : 'Your device password is set.'}

+ `

${escape(status?.hostname ?? '')}

${HARDWARE_PROFILES[hardware].label}

${wifiMissing ? 'Wi-Fi is not connected.' : 'Wi-Fi is connected.'} ${passwordMissing ? 'A device password is needed.' : 'Your device password is set.'}

Compatible firmware updates preserve settings, prayer history and recordings. Recovery changes only the Wi-Fi or password you choose.

${primary}${!recoveryBlocked && wifiMissing ? button('Refresh status', 'refresh-status', true) : ''}
${recoveryBlocked ? row('Firmware', firmwareText) : row('Firmware', firmwareText, upgradeCheck?.state === 'available' ? 'Review update' : 'Check for updates', upgradeCheck?.state === 'available' ? 'review-update' : 'check-update')} @@ -180,9 +188,13 @@ export function mountInstaller( } function reviewUpdate() { if (!offered || mutationBlocked || recoveryBlocked) return; + const powerInstructions = + hardware === WAVESHARE + ? 'Keep the Waveshare rear USB-C cable connected during transfer. After verification, press RESET to restart and complete startup checks.' + : 'Keep Atom USB connected during transfer. After verification, you’ll switch to Pyramid bottom power.'; render( 'Review firmware update', - `

Install ${escape(offered.version)} over USB.

Your Wi-Fi, device password, prayer settings, playback history and recordings stay on the speaker. The current application is retained for startup rollback.

Read the release notes

Keep Atom USB connected during transfer. After verification, you’ll switch to Pyramid bottom power.

${button('Install update', 'update')}${button('Back', 'recovery', true)}
`, + `

Install ${escape(offered.version)} over USB.

Your Wi-Fi, device password, prayer settings, playback history and recordings stay on the speaker. The current application is retained for startup rollback.

Read the release notes

${powerInstructions}

${button('Install update', 'update')}${button('Back', 'recovery', true)}
`, 1, ); } @@ -216,7 +228,9 @@ export function mountInstaller( const needsRecovery = passwordAbsent || wifiDisconnected; const urls = needsRecovery ? [] : device.urls; const recoveryGuidance = needsRecovery - ? `
${passwordAbsent ? '

Your device password still needs to be created through USB setup.

' : ''}${wifiDisconnected ? '

The speaker may reconnect using its saved Wi-Fi settings. If it stays offline, check or change Wi-Fi through USB setup.

' : ''}

For USB recovery after startup:

  1. After the speaker starts, unplug the Pyramid’s bottom power cable before connecting the Atom’s USB-C port to your computer.
  2. Select Back to connection, reconnect, then choose Check for updates.
  3. Follow any remaining update instructions before changing Wi-Fi or creating a device password.
  4. After recovery, unplug Atom USB and return to Pyramid bottom-only power, then open the device’s settings.
` + ? hardware === WAVESHARE + ? `

After restarting, connect the rear USB-C port to your computer and reconnect through USB setup. Check firmware status before changing Wi-Fi or the device password. Keep your saved prayer settings and history.

` + : `
${passwordAbsent ? '

Your device password still needs to be created through USB setup.

' : ''}${wifiDisconnected ? '

The speaker may reconnect using its saved Wi-Fi settings. If it stays offline, check or change Wi-Fi through USB setup.

' : ''}

For USB recovery after startup:

  1. After the speaker starts, unplug the Pyramid’s bottom power cable before connecting the Atom’s USB-C port to your computer.
  2. Select Back to connection, reconnect, then choose Check for updates.
  3. Follow any remaining update instructions before changing Wi-Fi or creating a device password.
  4. After recovery, unplug Atom USB and return to Pyramid bottom-only power, then open the device’s settings.
` : ''; render( updateWritten @@ -224,7 +238,7 @@ export function mountInstaller( : path === 'install' ? 'Ready for device setup' : 'Continue on speaker power', - `

${updateWritten ? 'The signed application was verified and selected. Switch to normal speaker power so it can start and complete its health checks.' : path === 'install' ? 'Wi-Fi and your device password are saved. Finish your prayer settings on the device.' : 'Your device is ready to open. Its prayer settings and history were not reset.'}

  1. Unplug the Atom’s USB cable.
  2. Connect only the Pyramid’s bottom USB-C power.
  3. ${needsRecovery ? 'Let the speaker start and complete its startup checks.' : 'Wait for it to reconnect, then open its settings on the same home network.'}
${recoveryGuidance}${urls.length && !service.simulated ? '

Device links open in a new tab.

' : ''}

${needsRecovery ? '' : 'Sign in as admin with the password you chose. '}${path === 'install' && !updateWritten ? 'Review your location, timezone and timetable, then select Finish setup.' : updateWritten ? 'Check the firmware version and update result on the device’s settings page. Startup success is confirmed there; an interrupted connection alone cannot establish success or rollback.' : 'Continue with your saved prayer settings.'} Athan playback waits until the device has synchronized its clock.

${!urls.length && !needsRecovery ? '

A device address was not returned. Reconnect through USB setup to read it again.

' : ''}
${button('Back to connection', 'start', true)}Setup instructions
`, + `

${updateWritten ? 'The signed application was verified and selected. Switch to normal speaker power so it can start and complete its health checks.' : path === 'install' ? 'Wi-Fi and your device password are saved. Finish your prayer settings on the device.' : 'Your device is ready to open. Its prayer settings and history were not reset.'}

${hardware === WAVESHARE ? '
  1. Use the Waveshare rear USB-C port for normal power.
  2. Press RESET to restart after a verified update.
  3. ' : '
    1. Unplug the Atom’s USB cable.
    2. Connect only the Pyramid’s bottom USB-C power.
    3. '}
    4. ${needsRecovery ? 'Let the speaker start and complete its startup checks.' : 'Wait for it to reconnect, then open its settings on the same home network.'}
    ${recoveryGuidance}${urls.length && !service.simulated ? '

    Device links open in a new tab.

    ' : ''}

    ${needsRecovery ? '' : 'Sign in as admin with the password you chose. '}${path === 'install' && !updateWritten ? 'Review your location, timezone and timetable, then select Finish setup.' : updateWritten ? 'Check the firmware version and update result on the device’s settings page. Startup success is confirmed there; an interrupted connection alone cannot establish success or rollback.' : 'Continue with your saved prayer settings.'} Athan playback waits until the device has synchronized its clock.

    ${!urls.length && !needsRecovery ? '

    A device address was not returned. Reconnect through USB setup to read it again.

    ' : ''}
    ${button('Back to connection', 'start', true)}Setup instructions
    `, 3, ); } @@ -250,7 +264,9 @@ export function mountInstaller( await service.close(); } catch { notify( - 'The browser could not close the USB connection. Unplug the Atom’s USB cable and follow the power steps below.', + hardware === WAVESHARE + ? 'The browser could not close the USB connection. Disconnect the data cable, then follow the restart and power steps below.' + : 'The browser could not close the USB connection. Unplug the Atom’s USB cable and follow the power steps below.', ); } } else { @@ -348,6 +364,19 @@ export function mountInstaller( }; root.addEventListener('change', (event) => { const target = event.target as HTMLInputElement; + if (target.id === 'hardware-choice') { + const valid = + isHardware(target.value) && (service.installHardware ?? [HARDWARE]).includes(target.value); + const checkbox = panel.querySelector('#confirm-install')!; + checkbox.checked = false; + checkbox.disabled = !valid; + panel.querySelector('[data-action="install"]')!.disabled = true; + if (valid) { + hardware = target.value as Hardware; + panel.querySelector('#model-confirmation')!.textContent = + `I have ${hardware === HARDWARE ? 'an' : 'a'} ${HARDWARE_PROFILES[hardware].label} and want to erase it for a new installation.`; + } + } if (target.id === 'confirm-install') panel.querySelector('[data-action="install"]')!.disabled = !target.checked; if (target.id === 'network-choice') @@ -374,6 +403,7 @@ export function mountInstaller( notify(''); if (result.kind === 'unrecognized') confirmation(); else { + hardware = result.hardware ?? HARDWARE; status = result.status; path = 'recovery'; recoveryMenu(); @@ -387,11 +417,15 @@ export function mountInstaller( 0, ); try { - status = await service.install(confirmed, (percent) => { - panel.querySelector('progress')!.value = percent; - panel.querySelector('[data-progress]')!.textContent = - `${percent}% · ${percent === 100 ? 'Verifying…' : 'Writing firmware…'}`; - }); + status = await service.install( + confirmed, + (percent) => { + panel.querySelector('progress')!.value = percent; + panel.querySelector('[data-progress]')!.textContent = + `${percent}% · ${percent === 100 ? 'Verifying…' : 'Writing firmware…'}`; + }, + hardware, + ); installed = true; wifiScreen(); } catch (error) { @@ -449,7 +483,7 @@ export function mountInstaller( const selected = offered; render( 'Updating firmware', - '

    Keep Atom USB connected while the application is written and verified. You will switch to speaker power afterwards.

    Preparing signed update…

    ', + `

    Keep ${hardware === WAVESHARE ? 'Waveshare USB' : 'Atom USB'} connected while the application is written and verified. You will switch to speaker power afterwards.

    Preparing signed update…

    `, 1, ); try { diff --git a/installer/upgrade.ts b/installer/upgrade.ts index dca4772..ee3ef27 100644 --- a/installer/upgrade.ts +++ b/installer/upgrade.ts @@ -1,6 +1,9 @@ /** Signed application-only updates. Does not import the destructive flasher. */ import { HARDWARE, + assetName, + isHardware, + type Hardware, LAYOUT, checkedManifest, sha256, @@ -8,6 +11,7 @@ import { type ReleaseManifest, } from './release.ts'; export interface FirmwareInfo { + hardware?: Hardware; version: string; commit: string; supported: boolean; @@ -18,6 +22,7 @@ export interface FirmwareInfo { received: number; } export interface UpgradeOffer { + hardware?: Hardware; version: string; commit: string; bytes: number; @@ -72,9 +77,11 @@ export function newerVersion(offered: string, installed: string): boolean { return false; } export function parseFirmware(fields: string[]): FirmwareInfo { - const [protocol, version, commit, support, state, boot, result, offered, received] = fields; + const [protocol, version, commit, support, state, boot, result, offered, received, hardware] = + fields; if ( - fields.length !== 9 || + ![9, 10].includes(fields.length) || + (fields.length === 10 && !isHardware(hardware)) || protocol !== '1' || !stable.test(version ?? '') || !/^(?:[a-f0-9]{40}|development)$/.test(commit ?? '') || @@ -114,6 +121,7 @@ export function parseFirmware(fields: string[]): FirmwareInfo { result: result!, offered: offered!, received: Number(received), + hardware: (hardware ?? HARDWARE) as Hardware, }; } /** Strict DER ECDSA to WebCrypto's fixed-width P-256 r || s representation. */ @@ -184,7 +192,7 @@ export async function verifyDescriptor( p.version !== manifest.tag || p.commit !== manifest.commit || !stable.test(p.version) || - p.hardware !== HARDWARE || + p.hardware !== manifest.hardware || p.layout !== LAYOUT || p.storageFormat !== 1 || p.audioFormat !== 1 || @@ -197,6 +205,7 @@ export async function verifyDescriptor( throw new Error('Incompatible firmware update.'); newerVersion(p.version, p.version); return { + hardware: manifest.hardware, version: p.version, commit: p.commit, bytes: p.bytes, @@ -267,10 +276,19 @@ async function bounded(url: string, maximum: number): Promise { } export async function loadUpgrade(pin: ReleasePin): Promise { const base = `/releases/${pin.tag}/`; - const manifest = await checkedManifest(await bounded(base + 'manifest.json', 16384), pin); - const descriptor = await bounded(base + 'upgrade.json', 8192), + const manifest = await checkedManifest( + await bounded(base + assetName(pin.hardware ?? HARDWARE, 'manifest.json'), 16384), + pin, + ); + const descriptor = await bounded( + base + assetName(pin.hardware ?? HARDWARE, 'upgrade.json'), + 8192, + ), offer = await verifyDescriptor(descriptor, manifest); - const application = await bounded(base + 'firmware.ota.bin', offer.bytes); + const application = await bounded( + base + assetName(pin.hardware ?? HARDWARE, 'firmware.ota.bin'), + offer.bytes, + ); await verifyApplication(offer, application); return { offer, descriptor, application }; } diff --git a/site/docs/getting-started.md b/site/docs/getting-started.md index f9a1f99..5231090 100644 --- a/site/docs/getting-started.md +++ b/site/docs/getting-started.md @@ -22,3 +22,21 @@ development simulator lets reviewers explore the flow without hardware and is excluded from the production build. USB setup has one connection entry. Choose the USB device, then read-only discovery opens existing-speaker controls or installation confirmation. An unrecognized result is not proof of a blank device. Installation requires explicit hardware and full-erasure confirmation; recognized OpenAthan with unreadable status receives troubleshooting, never installation. + +## Waveshare Box V2 + +Support is being prepared for v0.5.0. It becomes available in the browser installer +when that qualified release is published. Use the **ESP32-S3-Touch-LCD-1.85C-BOX V2**; +V1 is not supported. Connect a USB data cable to its rear USB-C port. + +The installer recognizes hardware already running compatible OpenAthan. For a +new installation, select **Waveshare Box V2** and confirm erasure. Installation +replaces existing firmware, Wi-Fi credentials, settings and prayer history. +After installation, save Wi-Fi and a device password, open the device page and +finish location, prayer settings and volume. Use the same rear USB-C port for +normal power. BOOT stops playback, skips the next prayer or cancels that skip; +use RESET to restart. Touch and microphone features are not part of this release. + +The RTC can retain synchronized time across powered restarts. The first boot +still needs internet time. Battery retention, unplugged retention and drift +have not been qualified. diff --git a/site/pages/docs/getting-started.astro b/site/pages/docs/getting-started.astro index 0208837..6dc6c53 100644 --- a/site/pages/docs/getting-started.astro +++ b/site/pages/docs/getting-started.astro @@ -2,7 +2,9 @@ import Layout from '../../layouts/Layout.astro'; import CableDiagram from '../../components/CableDiagram.astro'; import { siteSelection } from '../../release-selection'; -const { release } = await siteSelection(); +import { WAVESHARE } from '../../../installer/release'; +const { release, hardwareReleases } = await siteSelection(); +const waveshareRelease = hardwareReleases?.[WAVESHARE]; --- M5Stack AtomS3R C126{' '} and a Voice Pyramid A167 speaker base separately. A167 has also been called “Echo Pyramid”; check the SKU when buying. These instructions - and the browser installer currently support only this combination of parts. + describe this combination of parts. See the Waveshare Box V2 instructions below for that model.

    You will need a computer with desktop Chrome or Edge, a USB-C data cable, and a 2.4 GHz WPA2/WPA3 personal Wi-Fi network. A charging-only cable will not work for USB setup. @@ -96,8 +100,8 @@ const { release } = await siteSelection(); > to enable the Athan. If the clock has not synchronized yet, Athan playback will wait.

    Everyday use

    Your speaker calculates prayer times and plays recordings stored on the device. The current - build needs internet access to set its clock after a restart, before it can play the Athan. - To open its settings, connect your phone or computer to the same home Wi-Fi. Keep the + Atom build needs internet access to set its clock after a restart, before it can play the + Athan. To open its settings, connect your phone or computer to the same home Wi-Fi. Keep the settings page on your trusted home network; do not make it accessible from the internet.

    Update your firmware

    @@ -153,7 +157,7 @@ const { release } = await siteSelection();

    Additional hardware integrations require development and testing. The reusable core and ESPHome integration provide a starting point; broader support for optional peripherals is - still planned. The browser installer currently targets only our first reference build. + still planned. The browser installer offers qualified releases for supported boards.

    For implementation details, see:

      @@ -172,4 +176,32 @@ const { release } = await siteSelection();
    +
    +
    +

    Waveshare Box V2

    +

    + { + waveshareRelease + ? `OpenAthan ${waveshareRelease.tag} is available for this model.` + : 'Support is being prepared for v0.5.0 and becomes available after its qualified release is published.' + } + Use ESP32-S3-Touch-LCD-1.85C-BOX V2; V1 is not supported. +

    +

    + Connect its rear USB-C port using a USB data cable. Existing OpenAthan reports its hardware + automatically. For a new installation, choose Waveshare Box V2 and confirm that existing + firmware and saved data will be erased. +

    +

    + Save Wi-Fi and a device password, then open device settings to choose your location, prayer + settings and volume. The rear USB-C port also supplies normal power; RESET restarts the + device. Touch and microphones are not supported. +

    +

    + The onboard RTC restores previously synchronized time across powered restarts. First use + needs internet time. Retention after unplugging, battery behavior and clock drift remain + unqualified. +

    +
    +
    diff --git a/site/pages/install/index.astro b/site/pages/install/index.astro index ab3aeea..7894c52 100644 --- a/site/pages/install/index.astro +++ b/site/pages/install/index.astro @@ -7,7 +7,7 @@ const selection = await siteSelection();

    Set up OpenAthan

    @@ -17,9 +17,8 @@ const selection = await siteSelection();

    - AtomS3R C126 + Pyramid A167USB setup: desktop Chrome or EdgeConnection trouble? + AtomS3R + Pyramid · Waveshare Box V2USB setup: desktop Chrome or EdgeConnection trouble?
    { import.meta.env.DEV && (