[feat](trx-rs): keep a station log, and a layout to work the bands from
The logbook of issue #54, in the shape the proposal settled on. A new crate, trx-logbook, holds the contact, the ADIF reader and writer, the file, and the rules for telling one contact from two. ADIF because it is the only thing the ecosystem reads: LoTW, eQSL, Club Log, QRZ and every other logger take it and nothing else, so a log that cannot write .adi cannot be uploaded, confirmed or moved. The reader is forgiving in the ways real files are irregular -- lowercase tags, CRLF, missing header, unknown fields, a declared length that is the only thing ending a value -- and carries what it does not model through to the export, so a round trip does not strip what another program wrote. The file is JSON Lines, appended one line per contact. A log is the one thing here that cannot be regenerated, and the bookmark store's whole-file dump would rewrite megabytes to log one contact and lose all of them if the power went halfway; an append costs the record being written and no more, which a test tears a line in half to prove. Edits append revisions, deletes append tombstones, and the file compacts when the superseded outnumber the live. The panel is its own tab and stands in every layout. An entry opens with six fields and no more -- frequency, mode, rig name, time, and the callsign and locator of whatever decode it was started from. A report stays empty: an FT8 SNR is not what was sent. Times come from the server, because the browser may be a phone in another timezone, and the panel says so when the two disagree by more than a second. Worked-before answers as a callsign is typed. A decode is not a contact, so the Log button on an FT8 or APRS row opens an entry and logs nothing by itself. The ham layout is the fifth operator layout, opening on the logbook with the radio controls around it, offered only where the rig can transmit. Two bugs found on the way, both in code written here: a frequency of a whole number of megahertz ending in a zero rendered as a tenth of itself, in Rust and in TypeScript alike, because trimming trailing zeros from "20.000000" walks back through the point. The API also sits under /api/logbook rather than /logbook, so it cannot shadow its own page the way /bookmarks does. Closes #54 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SyX26FCpMQxiBoC7r5K1A7 Signed-off-by: Stan Grams <sjg@haxx.space>
This commit is contained in:
@@ -0,0 +1,162 @@
|
||||
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
|
||||
//
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
// The station log: the panel opens an entry pre-filled from the radio and the
|
||||
// server's clock, writes a contact, keeps it, and says whether the station has
|
||||
// been worked before. And the ham layout, which is offered only where a rig can
|
||||
// transmit -- a log is of contacts made.
|
||||
|
||||
import assert from "node:assert/strict";
|
||||
import { chromium } from "playwright-core";
|
||||
import { startBrowser, startWebFixture } from "./web-fixture.mjs";
|
||||
|
||||
/* global document, window, location */
|
||||
|
||||
const HELD = [
|
||||
{
|
||||
id: "q1", started_at: "2026-08-06T14:22:00Z", call: "DL3ABC", freq_hz: 14_074_000,
|
||||
band: "20m", mode: "FT8", rst_sent: "-07", rst_rcvd: "-12", gridsquare: "JO31",
|
||||
my_rig: "Primary fixture",
|
||||
},
|
||||
{
|
||||
id: "q2", started_at: "2026-08-06T13:05:00Z", call: "SP9XYZ", freq_hz: 7_120_000,
|
||||
band: "40m", mode: "SSB", submode: "LSB", rst_sent: "59", rst_rcvd: "57",
|
||||
gridsquare: "JO90", my_rig: "Primary fixture",
|
||||
},
|
||||
];
|
||||
|
||||
const fixture = await startWebFixture({ spectrum: true, tx: true, logbook: [...HELD] });
|
||||
const { browser, page, runtimeErrors } = await startBrowser(chromium);
|
||||
|
||||
const readPanel = () => page.evaluate(() => ({
|
||||
rows: [...(document.getElementById("log-rows")?.children ?? [])].map((row) => ({
|
||||
call: row.querySelector(".log-cell-call")?.textContent ?? "",
|
||||
cells: [...row.querySelectorAll("td")].map((cell) => cell.textContent),
|
||||
})),
|
||||
summary: document.getElementById("log-summary")?.textContent ?? "",
|
||||
freq: document.getElementById("log-freq")?.value ?? "",
|
||||
mode: document.getElementById("log-mode")?.value ?? "",
|
||||
call: document.getElementById("log-call")?.value ?? "",
|
||||
grid: document.getElementById("log-grid")?.value ?? "",
|
||||
rig: document.getElementById("log-station-rig-name")?.textContent ?? "",
|
||||
clock: document.getElementById("log-clock")?.textContent ?? "",
|
||||
worked: document.getElementById("log-worked-before")?.textContent ?? "",
|
||||
}));
|
||||
|
||||
try {
|
||||
await page.setViewportSize({ width: 1400, height: 950 });
|
||||
await page.goto(`${fixture.origin}/logbook`, { waitUntil: "domcontentloaded" });
|
||||
await page.locator("#tab-logbook").waitFor({ state: "visible" });
|
||||
await page.waitForTimeout(2500);
|
||||
|
||||
// The log is there, newest first, with the band and mode of each contact.
|
||||
const opened = await readPanel();
|
||||
assert.deepEqual(opened.rows.map((row) => row.call), ["DL3ABC", "SP9XYZ"]);
|
||||
assert.equal(opened.summary, "2 contacts");
|
||||
// A phone contact shows the sideband it was worked on, not just "SSB".
|
||||
assert.ok(opened.rows[1].cells.includes("LSB"), `the row reads ${JSON.stringify(opened.rows[1].cells)}`);
|
||||
|
||||
// The entry opened pre-filled: frequency and mode from the rig, the rig's
|
||||
// name, and the time from the server.
|
||||
assert.equal(opened.freq, "100", `the frequency pre-filled as "${opened.freq}"`);
|
||||
assert.equal(opened.mode, "USB", `the mode pre-filled as "${opened.mode}"`);
|
||||
assert.equal(opened.rig, "Primary fixture");
|
||||
assert.match(opened.clock, /^\d{2}:\d{2}:\d{2}Z$/, `the clock reads "${opened.clock}"`);
|
||||
// ...and nothing else was: a report is the operator's to give.
|
||||
assert.equal(opened.call, "");
|
||||
assert.equal(await page.evaluate(() => document.getElementById("log-rst-sent")?.value), "");
|
||||
|
||||
// Worked-before answers as the callsign is typed.
|
||||
await page.locator("#log-call").fill("DL3ABC");
|
||||
await page.waitForTimeout(600);
|
||||
assert.match((await readPanel()).worked, /Worked before: 20m FT8/);
|
||||
await page.locator("#log-call").fill("OZ1NEW");
|
||||
await page.waitForTimeout(600);
|
||||
assert.match((await readPanel()).worked, /Not worked before/);
|
||||
|
||||
// Logging writes the contact and clears the entry for the next one.
|
||||
await page.locator("#log-rst-sent").fill("59");
|
||||
await page.locator("#log-rst-rcvd").fill("57");
|
||||
await page.locator("#log-save-btn").click();
|
||||
await page.waitForTimeout(900);
|
||||
const afterLogging = await readPanel();
|
||||
assert.deepEqual(afterLogging.rows.map((row) => row.call), ["OZ1NEW", "DL3ABC", "SP9XYZ"]);
|
||||
assert.equal(afterLogging.summary, "3 contacts");
|
||||
assert.equal(afterLogging.call, "", "the entry kept the callsign after logging it");
|
||||
assert.equal(afterLogging.freq, "100", "the entry did not re-open pre-filled");
|
||||
|
||||
// A decode row starts an entry rather than logging one: what was heard goes
|
||||
// into the form, and the operator finishes it.
|
||||
await page.evaluate(() => { window.navigateToTab("digital-modes"); });
|
||||
await page.waitForTimeout(800);
|
||||
await page.locator('#tab-digital-modes .sub-tab[data-subtab="ft8"]').click();
|
||||
await page.waitForTimeout(400);
|
||||
await page.evaluate(() => {
|
||||
window.trxPluginRuntime.dispatch("ft8", {
|
||||
message: "CQ SP2SJG JO94", snr_db: -7, dt_s: 0.2, freq_hz: 1200,
|
||||
ts_ms: Date.now(), rig_id: "rig-a",
|
||||
});
|
||||
});
|
||||
await page.waitForTimeout(600);
|
||||
await page.locator("#ft8-messages .ft8-log-btn").first().click();
|
||||
await page.waitForTimeout(900);
|
||||
const fromDecode = await page.evaluate(() => ({
|
||||
path: location.pathname,
|
||||
call: document.getElementById("log-call")?.value ?? "",
|
||||
grid: document.getElementById("log-grid")?.value ?? "",
|
||||
mode: document.getElementById("log-mode")?.value ?? "",
|
||||
rows: document.getElementById("log-rows")?.children.length ?? 0,
|
||||
}));
|
||||
assert.equal(fromDecode.path, "/logbook", `the Log button landed on ${fromDecode.path}`);
|
||||
assert.equal(fromDecode.call, "SP2SJG", "the heard callsign did not reach the entry");
|
||||
assert.equal(fromDecode.grid, "JO94", "the heard locator did not reach the entry");
|
||||
// The decoder answers for the mode, because a rig in DIG cannot.
|
||||
assert.equal(fromDecode.mode, "FT8", `the mode came through as "${fromDecode.mode}"`);
|
||||
assert.equal(fromDecode.rows, 3, "a decode logged itself instead of opening an entry");
|
||||
|
||||
// The ham layout is on offer, because this rig can transmit.
|
||||
const layouts = await page.evaluate(() =>
|
||||
[...document.querySelectorAll("#operator-layout-select option")].map((option) => option.value));
|
||||
assert.ok(layouts.includes("ham"), `the layouts are ${JSON.stringify(layouts)}`);
|
||||
// Choosing it the way the picker does. The <select> itself measures zero
|
||||
// wide in a headless window, so Playwright will not click it; the change
|
||||
// event is what the picker is wired to either way.
|
||||
await page.evaluate(() => { window.navigateToTab("main"); });
|
||||
await page.waitForTimeout(600);
|
||||
await page.evaluate(() => {
|
||||
const select = document.getElementById("operator-layout-select");
|
||||
select.value = "ham";
|
||||
select.dispatchEvent(new Event("change", { bubbles: true }));
|
||||
});
|
||||
await page.waitForTimeout(900);
|
||||
const inHamLayout = await page.evaluate(() => ({
|
||||
layout: document.body.dataset.operatorLayout,
|
||||
path: location.pathname,
|
||||
}));
|
||||
assert.equal(inHamLayout.layout, "ham");
|
||||
assert.equal(inHamLayout.path, "/logbook", "the ham layout opened somewhere else");
|
||||
|
||||
assert.deepEqual(runtimeErrors, []);
|
||||
} finally {
|
||||
await browser.close();
|
||||
await fixture.close();
|
||||
}
|
||||
|
||||
// Without a transmitter there is nothing to log, so the layout is not offered.
|
||||
const listenerFixture = await startWebFixture({ spectrum: true, tx: false });
|
||||
const listener = await startBrowser(chromium);
|
||||
try {
|
||||
await listener.page.setViewportSize({ width: 1400, height: 950 });
|
||||
await listener.page.goto(`${listenerFixture.origin}/`, { waitUntil: "domcontentloaded" });
|
||||
await listener.page.locator("#content").waitFor({ state: "visible" });
|
||||
await listener.page.waitForTimeout(2000);
|
||||
const layouts = await listener.page.evaluate(() =>
|
||||
[...document.querySelectorAll("#operator-layout-select option")].map((option) => option.value));
|
||||
assert.ok(!layouts.includes("ham"), `a receiver was offered the ham layout: ${JSON.stringify(layouts)}`);
|
||||
} finally {
|
||||
await listener.browser.close();
|
||||
await listenerFixture.close();
|
||||
}
|
||||
|
||||
console.log("logbook tests passed");
|
||||
@@ -140,6 +140,7 @@ export async function startWebFixture({
|
||||
history = {},
|
||||
bookmarks = [],
|
||||
backgroundDecode = null,
|
||||
logbook = [],
|
||||
bandplan = {},
|
||||
bandplanEnabled = false,
|
||||
bandplanUnauthorizedFirst = false,
|
||||
@@ -279,6 +280,83 @@ export async function startWebFixture({
|
||||
response.writeHead(200).end();
|
||||
return;
|
||||
}
|
||||
// The logbook: an in-memory station log, enough for the panel to open an
|
||||
// entry, write it, read it back and export it.
|
||||
if (url.pathname.startsWith("/api/logbook")) {
|
||||
const tail = url.pathname.slice("/api/logbook".length);
|
||||
if (tail === "/prefill") {
|
||||
response.writeHead(200, { "content-type": "application/json" });
|
||||
response.end(JSON.stringify({
|
||||
started_at: new Date().toISOString(),
|
||||
epoch_ms: Date.now(),
|
||||
freq_hz: status.status.freq.hz,
|
||||
band: "20m",
|
||||
mode: url.searchParams.get("decoder") === "ft8" ? "FT8" : "SSB",
|
||||
submode: url.searchParams.get("decoder") === "ft8" ? null : "USB",
|
||||
rig_id: url.searchParams.get("remote") ?? "rig-a",
|
||||
my_rig: "Primary fixture",
|
||||
call: url.searchParams.get("call"),
|
||||
gridsquare: url.searchParams.get("gridsquare"),
|
||||
}));
|
||||
return;
|
||||
}
|
||||
if (tail.startsWith("/worked/")) {
|
||||
const call = decodeURIComponent(tail.slice("/worked/".length)).toUpperCase();
|
||||
const worked = logbook
|
||||
.filter((qso) => qso.call === call)
|
||||
.map((qso) => ({ band: qso.band, mode: qso.mode }));
|
||||
response.writeHead(200, { "content-type": "application/json" });
|
||||
response.end(JSON.stringify({ call, worked }));
|
||||
return;
|
||||
}
|
||||
if (tail === "/export.adi") {
|
||||
const body = logbook
|
||||
.map((qso) => `<CALL:${qso.call.length}>${qso.call}<EOR>\n`)
|
||||
.join("");
|
||||
response.writeHead(200, { "content-type": "text/plain" });
|
||||
response.end(body);
|
||||
return;
|
||||
}
|
||||
if (request.method === "POST" && tail === "") {
|
||||
const raw = await new Promise((resolve) => {
|
||||
let text = "";
|
||||
request.on("data", (chunk) => { text += chunk; });
|
||||
request.on("end", () => resolve(text));
|
||||
});
|
||||
const input = JSON.parse(raw);
|
||||
const qso = {
|
||||
id: `qso-${String(logbook.length + 1)}`,
|
||||
started_at: input.started_at ?? new Date().toISOString(),
|
||||
call: String(input.call).toUpperCase(),
|
||||
freq_hz: input.freq_hz,
|
||||
band: "20m",
|
||||
mode: input.mode,
|
||||
submode: input.submode,
|
||||
rst_sent: input.rst_sent,
|
||||
rst_rcvd: input.rst_rcvd,
|
||||
gridsquare: input.gridsquare ? String(input.gridsquare).toUpperCase() : null,
|
||||
my_rig: input.my_rig,
|
||||
operator: input.operator,
|
||||
};
|
||||
logbook.unshift(qso);
|
||||
response.writeHead(200, { "content-type": "application/json" });
|
||||
response.end(JSON.stringify(qso));
|
||||
return;
|
||||
}
|
||||
if (request.method === "DELETE") {
|
||||
const id = tail.replace("/", "");
|
||||
const index = logbook.findIndex((qso) => qso.id === id);
|
||||
if (index >= 0) logbook.splice(index, 1);
|
||||
response.writeHead(200, { "content-type": "application/json" });
|
||||
response.end(JSON.stringify({ deleted: index >= 0 }));
|
||||
return;
|
||||
}
|
||||
const wanted = (url.searchParams.get("call") ?? "").toUpperCase();
|
||||
const matching = wanted ? logbook.filter((qso) => qso.call.includes(wanted)) : logbook;
|
||||
response.writeHead(200, { "content-type": "application/json" });
|
||||
response.end(JSON.stringify({ total: logbook.length, qsos: matching }));
|
||||
return;
|
||||
}
|
||||
// Background decode: the panel reads its config, its status, and the
|
||||
// bookmark list, and writes the config back.
|
||||
if (url.pathname.startsWith("/background-decode/")) {
|
||||
|
||||
Reference in New Issue
Block a user