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
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ Visit [openathan.com](https://openathan.com) for supported hardware, installatio

Connect once through USB setup: the website checks the speaker and shows existing-device controls or installation confirmation. The browser installer supports fresh installation on **AtomS3R C126 + Pyramid A167** and Wi-Fi/password recovery for existing devices. **Fresh installation erases saved data.** Existing owners should [update from the device page or follow the preserving USB transition](https://openathan.com/docs/getting-started/#firmware-updates).

Existing speakers show one main task, a firmware status row and expandable Wi-Fi/password recovery. A separate signed application-only USB updater is implemented for new firmware that explicitly supports it. Its public activation flag remains **off** pending physical qualification and publication of a capable release. Existing releases continue to use the device page or maintainer transition.

The installer uses the latest approved stable OpenAthan release. The website is scheduled to check for releases twice hourly and deploys only after artifact verification and website tests pass. See the [deployment guide](deployment/README.md) for timing limitations, release selection, and rollback.

Prayer settings and activation happen on the device's own local page. The speaker operates independently of this website and Home Assistant. The optional [location helper](https://openathan.com/location/) uses browser location with a GeoJS IP-location fallback; users review the suggestion before saving it on the device.
Expand Down
33 changes: 29 additions & 4 deletions installer/README.md

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions installer/catalog.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
{
"schema": 1,
"automatic": true,
"usbUpdateEnabled": false,
"release": {
"tag": "v0.2.1",
"manifestSha256": "b6ef1cd38e8feb75ed90d0aeb454ea7ae033d49ded439ab293b29ec510eeb437",
Expand Down
5 changes: 4 additions & 1 deletion installer/entry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,10 @@ if (root) {
try {
mountInstaller(
root,
new RealService(parsePin(JSON.parse(root.dataset.releaseSelection ?? 'null'))),
new RealService(
parsePin(JSON.parse(root.dataset.releaseSelection ?? 'null')),
JSON.parse(root.dataset.releaseSelection ?? 'null')?.usbUpdateEnabled === true,
),
usbSupport(),
);
} catch {
Expand Down
158 changes: 156 additions & 2 deletions installer/real-service.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,15 @@
import { BrowserTransport, requestDevicePort } from './browser-transport';
import { ProvisioningSession } from './session';
import { ProvisioningSession, DeviceError, UncertainOutcome } from './session';
import {
loadUpgrade,
UpgradeDownloadTimeout,
newerVersion,
UPGRADE,
CHUNK_BYTES,
type UpgradeBundle,
type UpgradeOffer,
type UpgradeCheck,
} from './upgrade';
import { loadRelease, type ReleasePin } from './release';
import type { InstallerService, Connected } from './service';

Expand All @@ -11,8 +21,12 @@ export class RealService implements InstallerService {
private installationCandidate = false;
private reloadRequired = false;
private connectionLost = false;
private updateBundle?: UpgradeBundle;
onDisconnect?: () => void;
constructor(private pin: ReleasePin | null) {
constructor(
private pin: ReleasePin | null,
private usbUpdateEnabled = false,
) {
this.installAvailable = pin !== null;
}
private async open(): Promise<ProvisioningSession> {
Expand Down Expand Up @@ -95,6 +109,145 @@ export class RealService implements InstallerService {
'Installation was verified, but setup could not reconnect. Reconnect through USB setup to check the speaker. Do not reinstall.',
);
}
async discardUpdate() {
const info = await this.firmware();
if (info.state !== 'usb_interrupted' || info.boot !== 'confirmed')
throw new Error('Read update status before discarding an incomplete transfer.');
const [reply] = await this.current().command(true, UPGRADE.abort, ['interrupted'], {
mutation: true,
});
if (reply?.length !== 1 || reply[0] !== 'aborted') {
this.current().uncertain = true;
throw new UncertainOutcome();
}
}
firmware() {
return this.current().firmware();
}
async checkUpdate(): Promise<UpgradeCheck> {
this.updateBundle = undefined;
let info;
try {
info = await this.firmware();
} catch (error) {
if (error instanceof DeviceError && error.code === 2)
return {
state: 'unsupported',
recoveryBlocked: false,
detail:
'This firmware needs an initial Wi-Fi or maintainer update before USB updates are supported.',
};
return {
state: 'failed',
detail: 'Firmware status could not be read. Reconnect and check again.',
};
}
const recoveryBlocked = [
'usb_descriptor',
'usb_receiving',
'usb_interrupted',
'usb_selection_uncertain',
'awaiting_power',
].includes(info.state);
if (!info.supported && !recoveryBlocked)
return {
state: 'unsupported',
recoveryBlocked,
detail: 'This speaker needs a maintainer bootloader transition before preserving updates.',
};
if (
info.boot !== 'confirmed' ||
!['idle', 'current', 'available', 'success', 'rolled_back', 'failed'].includes(info.state)
)
return {
state: 'busy',
recoveryBlocked,
action:
info.state === 'awaiting_power'
? 'power'
: info.state === 'usb_interrupted' && info.boot === 'confirmed'
? 'discard'
: undefined,
detail:
info.state === 'awaiting_power'
? 'An update is verified. Switch to Pyramid bottom power to finish.'
: 'The device has an update or startup check in progress. Check its status before another update.',
};
if (!this.usbUpdateEnabled || !this.pin)
return {
state: 'disabled',
recoveryBlocked,
detail:
'USB updating is awaiting physical qualification. Use firmware updates on the device’s settings page.',
};
try {
const bundle = await loadUpgrade(this.pin);
if (!newerVersion(bundle.offer.version, info.version))
return {
state: 'current',
recoveryBlocked,
detail: `Firmware ${info.version} is current for this website’s selected release.`,
};
this.updateBundle = bundle;
return { state: 'available', offer: bundle.offer, recoveryBlocked };
} catch (error) {
return {
state: 'failed',
recoveryBlocked,
detail:
error instanceof UpgradeDownloadTimeout
? error.message
: 'The selected update could not be verified. Installed firmware has not changed.',
};
}
}
async update(offer: UpgradeOffer, progress: (percent: number) => void): Promise<void> {
const bundle = this.updateBundle;
this.updateBundle = undefined;
if (!this.usbUpdateEnabled || !bundle || JSON.stringify(offer) !== JSON.stringify(bundle.offer))
throw new Error('Check for updates and review the offered version first.');
const session = this.current(),
info = await session.firmware();
if (
!info.supported ||
info.boot !== 'confirmed' ||
!['idle', 'current', 'available', 'success', 'rolled_back', 'failed'].includes(info.state) ||
!newerVersion(offer.version, info.version)
)
throw new Error('The speaker is not ready for this update. Check its status.');
const [begin] = await session.command(true, UPGRADE.begin, [String(bundle.descriptor.length)], {
mutation: true,
});
const token = begin?.[0];
const uncertain = () => {
session.uncertain = true;
return new UncertainOutcome();
};
if (begin?.length !== 1 || !/^[a-f0-9]{16}$/.test(token ?? '') || token === '0000000000000000')
throw uncertain();
const send = async (kind: number, bytes: Uint8Array) => {
for (let offset = 0; offset < bytes.length; offset += CHUNK_BYTES) {
await session.writeChunk(token!, kind, offset, bytes.slice(offset, offset + CHUNK_BYTES));
if (kind === 1)
progress(Math.floor((Math.min(offset + CHUNK_BYTES, bytes.length) * 100) / bytes.length));
}
};
await send(0, bundle.descriptor);
const [accepted] = await session.command(true, UPGRADE.verify, [token!], { mutation: true });
if (accepted?.length !== 2 || accepted[0] !== 'accepted' || accepted[1] !== offer.version)
throw uncertain();
await send(1, bundle.application);
try {
const [finished] = await session.command(true, UPGRADE.finish, [token!], { mutation: true });
if (finished?.length !== 2 || finished[0] !== 'verified' || finished[1] !== offer.version)
throw uncertain();
const after = await session.firmware();
if (after.state !== 'awaiting_power' || after.offered !== offer.version) throw uncertain();
} catch {
// Selection may have succeeded even when the reply or status read failed.
throw uncertain();
}
}
private current() {
if (!this.session) throw new Error('Connect the device first.');
return this.session;
Expand All @@ -117,6 +270,7 @@ export class RealService implements InstallerService {
return this.current().password(password);
}
async close() {
this.updateBundle = undefined;
this.port = undefined;
this.installationCandidate = false;
await this.releaseSession();
Expand Down
5 changes: 5 additions & 0 deletions installer/service.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,16 @@
import type { DeviceStatus } from './protocol';
import type { FirmwareInfo, UpgradeCheck, UpgradeOffer } from './upgrade';
export type Connected = { kind: 'unrecognized' } | { kind: 'existing'; status: DeviceStatus };
export interface InstallerService {
readonly simulated: boolean;
readonly installAvailable: boolean;
onDisconnect?: () => void;
connect(): Promise<Connected>;
install(confirmed: boolean, progress: (percent: number) => void): Promise<DeviceStatus>;
discardUpdate(): Promise<void>;
firmware(): Promise<FirmwareInfo>;
checkUpdate(): Promise<UpgradeCheck>;
update(offer: UpgradeOffer, progress: (percent: number) => void): Promise<void>;
status(): Promise<DeviceStatus>;
scan(): Promise<{ ssid: string; signal: number; secured: boolean }[]>;
wifi(ssid: string, password: string): Promise<void>;
Expand Down
71 changes: 67 additions & 4 deletions installer/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ import {
type DeviceStatus,
type Frame,
} from './protocol';
import { encodeFrame } from './protocol';
import { parseFirmware, UPGRADE, chunkPayload } from './upgrade';

/** A byte stream; implementations must never log payloads, which can contain passwords. */
export interface ByteTransport {
Expand Down Expand Up @@ -51,6 +53,12 @@ interface Pending {
export class ProvisioningSession {
private decoder = new FrameDecoder();
private pending?: Pending;
private chunkPending?: {
expected: Uint8Array;
resolve: () => void;
reject: (error: Error) => void;
timer: ReturnType<typeof setTimeout>;
};
private stopped = false;
private readonly unlisten: () => void;
/** Set after any uncertain write; no further mutation until the session is reopened. */
Expand All @@ -67,7 +75,7 @@ export class ProvisioningSession {
() => {
this.stopped = true;
this.fail(
this.pending?.mutation
this.pending?.mutation || this.chunkPending
? new UncertainOutcome()
: new Error('Device disconnected. Reconnect the USB data cable.'),
);
Expand All @@ -76,14 +84,41 @@ export class ProvisioningSession {
);
}
private fail(error: Error): void {
const chunk = this.chunkPending;
if (chunk) {
clearTimeout(chunk.timer);
this.chunkPending = undefined;
chunk.reject(error);
}
const pending = this.pending;
if (!pending) return;
if (error instanceof UncertainOutcome) this.uncertain = true;
if (!pending) return;
clearTimeout(pending.timer);
this.pending = undefined;
pending.reject(error);
}
private receive(frame: Frame): void {
const chunk = this.chunkPending;
if (chunk && frame.extension) {
if (frame.type === 2) {
if (frame.payload.length !== 1) this.fail(new UncertainOutcome());
else if (frame.payload[0]) this.fail(new DeviceError(frame.payload[0]));
return;
}
if (frame.type === 6) {
if (
frame.payload.length !== 13 ||
!frame.payload.every((b, i) => b === chunk.expected[i])
) {
this.fail(new UncertainOutcome());
return;
}
clearTimeout(chunk.timer);
this.chunkPending = undefined;
chunk.resolve();
return;
}
}
const pending = this.pending;
if (!pending || frame.extension !== pending.extension) return;
if (frame.type === 2 && frame.payload.length === 1 && frame.payload[0]) {
Expand Down Expand Up @@ -118,7 +153,8 @@ export class ProvisioningSession {
options: { scan?: boolean; mutation?: boolean; timeoutMs?: number } = {},
): Promise<string[][]> {
if (this.stopped) throw new Error('Connect the device first.');
if (this.pending) throw new Error('Another device action is still running.');
if (this.pending || this.chunkPending)
throw new Error('Another device action is still running.');
if (options.mutation && this.uncertain)
throw new Error('Reconnect and check status before another change.');
const packet = encodeRequest(extension, command, fields);
Expand Down Expand Up @@ -156,6 +192,29 @@ export class ProvisioningSession {
.finally(() => packet.fill(0));
});
}
async firmware() {
const [fields] = await this.command(true, UPGRADE.info, [], { timeoutMs: 5000 });
return parseFirmware(fields!);
}
async writeChunk(token: string, kind: number, offset: number, bytes: Uint8Array): Promise<void> {
if (this.stopped || this.uncertain || this.pending || this.chunkPending)
throw new Error('Reconnect and check before another update write.');
const payload = chunkPayload(token, kind, offset, bytes),
expected = payload.slice(0, 13);
new DataView(expected.buffer).setUint32(9, offset + bytes.length, true);
const packet = encodeFrame(true, 5, payload);
await new Promise<void>((resolve, reject) => {
const timer = setTimeout(() => this.fail(new UncertainOutcome()), this.timeout);
const pending = { expected, resolve, reject, timer };
this.chunkPending = pending;
void this.transport
.write(packet)
.catch(() => {
if (this.chunkPending === pending) this.fail(new UncertainOutcome());
})
.finally(() => packet.fill(0));
});
}
async identify(timeoutMs?: number): Promise<void> {
const [fields] = await this.command(false, 3, [], { timeoutMs });
if (fields?.length !== 4 || fields[0] !== 'OpenAthan' || fields[2] !== 'ESP32-S3') {
Expand Down Expand Up @@ -211,7 +270,11 @@ export class ProvisioningSession {
}
async close(): Promise<void> {
this.stopped = true;
this.fail(this.pending?.mutation ? new UncertainOutcome() : new Error('Connection closed.'));
this.fail(
this.pending?.mutation || this.chunkPending
? new UncertainOutcome()
: new Error('Connection closed.'),
);
this.unlisten();
this.decoder.reset();
await this.transport.close();
Expand Down
Loading
Loading