Loading files
This file is pinned to the selected release. Download the original to open it in your design tool.
1705e3a47This file is pinned to the selected release. Download the original to open it in your design tool.
import { appendFile, mkdir, readdir, unlink } from 'node:fs/promises'
import { join } from 'node:path'
/**
* The dial's log, as a file somebody can send.
*
* The dial has one USB port and no flash log of its own: every `ESP_LOG` line it prints is framed over
* the cable (type 0x04) and this is where it lands. The daemon's own `cable:` events go into the SAME
* file, prefixed `[daemon]`, so a report reads end to end from one place — what the device said beside
* what the desk did to it (opened the port, pushed a focus, saw silence).
*
* One file per day, `dial-YYYYMMDD.log`, in `~/.harness/logs` — the directory the desktop app already
* keeps its own `app-*.log`/`cli-*.log` in and exports from. Days older than `retainDays` are deleted
* on the first write after midnight; a day past `maxBytesPerDay` gets one `capped` line and then
* nothing, so a firmware stuck in a log loop cannot fill the disk.
*
* Firmware lines keep their own `I (ticks) tag:` prefix beside the wall time, so a reboot shows as
* ticks going back to zero. The firmware's 60s `alive` heartbeat is watched here too: cable lines
* continuing while that one stops is what a wedged LVGL task looks like, and its ABSENCE is the
* evidence, so this marks the gap in the file rather than leaving it to be inferred.
*/
export interface DialLogOptions {
/** Days of `dial-*.log` to keep. Default 7. */
retainDays?: number
/** Bytes per day before the file is capped. Default 20 MB. */
maxBytesPerDay?: number
/** How long without an `alive` line from the dial before the gap is marked. Default 90s. */
heartbeatGapMs?: number
/** Wall clock, for tests. */
now?: () => number
}
/** The firmware's liveness line — `ui_screens.c` prints one a minute from the LVGL task. */
const HEARTBEAT = /\balive up=/
export class DialLog {
private readonly retainDays: number
private readonly maxBytesPerDay: number
private readonly heartbeatGapMs: number
private readonly now: () => number
private day: string | null = null
private bytesToday = 0
private capped = false
/** Writes are chained so lines land in the order they were logged, and so tests can `flush()`. */
private chain: Promise<void> = Promise.resolve()
private lastHeartbeat: number | null = null
private gapMarked = false
constructor(readonly dir: string, opts: DialLogOptions = {}) {
this.retainDays = opts.retainDays ?? 7
this.maxBytesPerDay = opts.maxBytesPerDay ?? 20 * 1024 * 1024
this.heartbeatGapMs = opts.heartbeatGapMs ?? 90_000
this.now = opts.now ?? Date.now
}
/** `dial-YYYYMMDD.log` for a moment, in local time like the app's own files. */
static fileName(at: number | Date): string {
const d = new Date(at)
const y = d.getFullYear()
const m = String(d.getMonth() + 1).padStart(2, '0')
const day = String(d.getDate()).padStart(2, '0')
return `dial-${y}${m}${day}.log`
}
/** The file the next line would land in. */
get currentPath(): string {
return join(this.dir, DialLog.fileName(this.now()))
}
/** A line the dial printed, verbatim. */
device(line: string): void {
if (HEARTBEAT.test(line)) this.heartbeat()
this.write(line)
}
/** Something the daemon did to, or saw from, the dial. */
daemon(line: string): void {
this.write(`[daemon] ${line}`)
}
/**
* Called on the session's tick while the port is open. Marks a missing heartbeat once, and again
* when it comes back; a closed port resets the watch, since a dial that is not there owes no beat.
*/
tick(connected: boolean): void {
if (!connected) {
this.lastHeartbeat = null
this.gapMarked = false
return
}
if (this.lastHeartbeat === null || this.gapMarked) return
const gap = this.now() - this.lastHeartbeat
if (gap >= this.heartbeatGapMs) {
this.gapMarked = true
this.daemon(`no dial heartbeat for ${Math.round(gap / 1000)}s — the cable is up, the UI task may be wedged`)
}
}
/**
* The dial greeted — a fresh boot, or a reconnect. Its first `alive` comes a minute after boot, so the
* watch restarts from now rather than from the last beat of the previous boot; without this an OTA
* reboot (port never closed, ticks back to zero) read as "no heartbeat for 90s" every time.
*/
greeted(): void {
this.lastHeartbeat = this.now()
this.gapMarked = false
}
/** Every queued write has reached the file. */
flush(): Promise<void> {
return this.chain
}
private heartbeat(): void {
const now = this.now()
if (this.gapMarked && this.lastHeartbeat !== null) {
this.daemon(`dial heartbeat back after ${Math.round((now - this.lastHeartbeat) / 1000)}s`)
}
this.gapMarked = false
this.lastHeartbeat = now
}
private write(text: string): void {
const at = this.now()
const file = DialLog.fileName(at)
const rolled = file !== this.day
if (rolled) {
this.day = file
this.bytesToday = 0
this.capped = false
}
if (this.capped) return
const line = `${new Date(at).toISOString()} ${text}\n`
this.bytesToday += Buffer.byteLength(line)
let out = line
if (this.bytesToday > this.maxBytesPerDay) {
this.capped = true
out = `${new Date(at).toISOString()} [daemon] dial log capped for today at ${this.maxBytesPerDay} B — dropping the rest\n`
}
const path = join(this.dir, file)
this.chain = this.chain
.then(async () => {
await mkdir(this.dir, { recursive: true })
if (rolled) await this.prune(at)
await appendFile(path, out)
})
.catch(() => {})
}
/** Delete `dial-*.log` files older than `retainDays` before `at`. Best effort. */
private async prune(at: number): Promise<void> {
if (this.retainDays <= 0) return
const cutoff = DialLog.fileName(at - this.retainDays * 86_400_000)
let names: string[]
try {
names = await readdir(this.dir)
} catch {
return
}
for (const name of names) {
if (!/^dial-\d{8}\.log$/.test(name) || name >= cutoff) continue
await unlink(join(this.dir, name)).catch(() => {})
}
}
}