2 Commits

Author SHA1 Message Date
dxd
4ced7418cd Merge pull request 'feat(wavelog): push station frequency/mode to Wavelog per operator' (#1) from feat/wavelog-cat-frequency into main
Reviewed-on: #1
Reviewed-by: dxd <oe6dxd@arcg.at>
2026-10-22 20:53:24 +02:00
ARCG Admin (Claude)
0488f6e25d feat(wavelog): push station frequency/mode to Wavelog per operator
Add a CAT integration so the station's current frequency and mode are
reported to each operator's personal Wavelog logbook via Wavelog's radio
API (POST <baseUrl>/index.php/api/radio).

- New plugin rms.wavelog.cat (capability logging.cat.publish): posts
  {key, radio, frequency, mode}. Base URL / radio name come from plugin
  settings or env (WAVELOG_BASE_URL, WAVELOG_RADIO_NAME); no secrets in repo.
- Core hook in the OpenWebRX band-select flow pushes centerFreqHz + startMod
  for the active operator, best-effort (never breaks the band change).
- Per-user Wavelog API key stored on the user record; PUT /v1/me/wavelog-key
  to set it, returned by GET /v1/me and the OpenWebRX session issue response.
- UI: Wavelog API key field in the OpenWebRX panel (visible when generating
  the session link), with i18n (de/en).
- Docs, .env.example entries, and a unit test for the mode mapping.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-22 20:47:58 +02:00
11 changed files with 308 additions and 2 deletions

View File

@@ -86,3 +86,10 @@ MICROHAM_PTT_DOWN_CMD=
MICROHAM_PTT_UP_CMD=
OPENWEBRX_ACCESS_POLICY_FILE=./data/openwebrx-access-policy.txt
OPENWEBRX_PERSISTENT_USERS_FILE=./data/openwebrx-persistent-users.txt
# rms.wavelog.cat: push current frequency/mode to Wavelog (CAT radio API).
# Base URL of the Wavelog instance (no trailing slash). Leave empty to disable.
WAVELOG_BASE_URL=
# Radio name shown in Wavelog's Radios list.
WAVELOG_RADIO_NAME=RMS Remote
# HTTP timeout for the Wavelog API call (ms).
WAVELOG_TIMEOUT_MS=8000

View File

@@ -18,3 +18,4 @@
- `rms-vswr-report-reader.md`
- `rms-vswr-nanovna.md`
- `rms-station-shell.md`
- `rms-wavelog-cat.md`

View File

@@ -0,0 +1,18 @@
# rms.wavelog.cat
- Zweck: Aktuelle Stationsfrequenz/-mode an ein Wavelog-Logbuch melden (CAT Radio API).
Pushed `POST <baseUrl>/index.php/api/radio` mit dem **per-Operator** Wavelog-API-Key, sodass die
Frequenz im persoenlichen Wavelog-Konto des aktiven Operators landet (`cat.user_id` = Key-Besitzer).
- Capabilities: `logging.cat.publish`.
- Aktionen: `publish` mit Input `{ frequencyHz, mode, apiKey, baseUrl?, radioName? }`.
- Wichtige Settings/ENV:
- `baseUrl` / `WAVELOG_BASE_URL` – URL der Wavelog-Instanz (ohne Slash am Ende). Leer = deaktiviert.
- `radioName` / `WAVELOG_RADIO_NAME` – Anzeigename in Wavelog (Default `RMS Remote`).
- `timeoutMs` / `WAVELOG_TIMEOUT_MS` – HTTP-Timeout (Default 8000).
- Ausloeser: Der Core ruft die Capability beim Bandwechsel (`/v1/openwebrx/bands/select`) auf und
uebergibt `centerFreqHz` + `startMod` sowie den Wavelog-Key des aktiven Operators. Best-effort:
Fehler brechen den Bandwechsel nie ab.
- Key-Ablage: pro Benutzer in `users.json` (`wavelogApiKey`), setzbar via `PUT /v1/me/wavelog-key`
und sichtbar im OpenWebRX-Panel. **Kein** Key im Repo – der Key ist Laufzeit-/Benutzerdatum.
- Mode-Mapping: `usb|lsb|ssb → SSB`, `cw|cwr → CW`, `fm|nfm|wfm → FM`, `am|sam → AM`, sonst Rohwert
in Grossbuchstaben (Wavelog mappt gaengige Rig-Aliase selbst).

View File

@@ -0,0 +1,116 @@
// rms.wavelog.cat
//
// Publishes the station's current frequency/mode to a Wavelog logbook via its
// CAT radio API (`POST <baseUrl>/index.php/api/radio`). The caller passes the
// per-operator Wavelog API key, so each operator's frequency lands in their own
// Wavelog account (cat.user_id = key owner).
//
// Generic by design (public repo): the Wavelog base URL and radio name come from
// plugin settings or runtime env, never hardcoded. No secrets are stored here.
const ADIF_MODE_BY_STARTMOD = {
usb: "SSB",
lsb: "SSB",
ssb: "SSB",
cw: "CW",
cwr: "CW",
fm: "FM",
nfm: "FM",
wfm: "FM",
am: "AM",
sam: "AM",
dmr: "DMR",
rtty: "RTTY",
ft8: "FT8",
ft4: "FT4",
psk: "PSK",
psk31: "PSK"
};
function toAdifMode(mode) {
const key = String(mode || "").trim().toLowerCase();
if (!key) {
return "";
}
if (ADIF_MODE_BY_STARTMOD[key]) {
return ADIF_MODE_BY_STARTMOD[key];
}
// Fall back to the raw value in upper case; Wavelog maps common rig aliases itself.
return String(mode).trim().toUpperCase();
}
async function createPlugin(ctx) {
return {
async execute(action, input) {
if (action !== "publish") {
throw new Error(`Unknown action: ${action}`);
}
return publish(ctx, input || {});
},
async health() {
const baseUrl = resolveBaseUrl(ctx, {});
return { ok: true, configured: Boolean(baseUrl) };
}
};
}
function resolveBaseUrl(ctx, input) {
return String(input.baseUrl || ctx.getSetting("baseUrl", ctx.env.WAVELOG_BASE_URL || ""))
.trim()
.replace(/\/+$/, "");
}
async function publish(ctx, input) {
const baseUrl = resolveBaseUrl(ctx, input);
const apiKey = String(input.apiKey || "").trim();
const radio = String(input.radioName || ctx.getSetting("radioName", ctx.env.WAVELOG_RADIO_NAME || "RMS Remote")).trim();
const frequencyHz = Math.round(Number(input.frequencyHz));
const mode = toAdifMode(input.mode);
if (!baseUrl) {
return { ok: false, skipped: true, reason: "wavelog.baseUrl_missing" };
}
if (!apiKey) {
return { ok: false, skipped: true, reason: "wavelog.apiKey_missing" };
}
if (!Number.isFinite(frequencyHz) || frequencyHz <= 0) {
return { ok: false, skipped: true, reason: "wavelog.frequency_missing" };
}
const payload = {
key: apiKey,
radio: radio || "RMS Remote",
frequency: frequencyHz
};
if (mode) {
payload.mode = mode;
}
const timeoutMs = Number(ctx.getSetting("timeoutMs", ctx.env.WAVELOG_TIMEOUT_MS || 8000));
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), Number.isFinite(timeoutMs) ? timeoutMs : 8000);
try {
const resp = await fetch(`${baseUrl}/index.php/api/radio`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
signal: controller.signal
});
const text = await resp.text().catch(() => "");
if (!resp.ok) {
// Never surface the API key; only status + a short body excerpt.
return { ok: false, status: resp.status, body: String(text).slice(0, 200) };
}
return { ok: true, status: resp.status, radio: payload.radio, frequency: frequencyHz, mode: mode || null };
} catch (error) {
return { ok: false, error: String(error && error.message ? error.message : error) };
} finally {
clearTimeout(timer);
}
}
module.exports = {
createPlugin,
// exported for unit tests
toAdifMode
};

View File

@@ -0,0 +1,18 @@
{
"id": "rms.wavelog.cat",
"name": "Wavelog CAT",
"version": "1.0.0",
"apiVersion": "1.0",
"capabilities": [
"logging.cat.publish"
],
"settingsSchema": {
"type": "object",
"properties": {
"baseUrl": { "type": "string" },
"radioName": { "type": "string" },
"timeoutMs": { "type": "number", "minimum": 1000, "maximum": 60000 }
},
"additionalProperties": false
}
}

View File

@@ -135,6 +135,9 @@ const els = {
openwebrxSessionLink: document.getElementById("openwebrxSessionLink"),
openwebrxCopyLinkBtn: document.getElementById("openwebrxCopyLinkBtn"),
openwebrxSessionTicket: document.getElementById("openwebrxSessionTicket"),
wavelogKeyInput: document.getElementById("wavelogKeyInput"),
wavelogKeySaveBtn: document.getElementById("wavelogKeySaveBtn"),
wavelogKeyMessage: document.getElementById("wavelogKeyMessage"),
openwebrxTxStatePill: document.getElementById("openwebrxTxStatePill"),
rotorCurrent: document.getElementById("rotorCurrent"),
rotorCompass: document.getElementById("rotorCompass"),
@@ -690,6 +693,11 @@ function bindEvents() {
await copyOpenWebRxLink();
});
}
if (els.wavelogKeySaveBtn) {
els.wavelogKeySaveBtn.addEventListener("click", async () => {
await saveWavelogKey();
});
}
if (els.openwebrxEnableTxBtn) {
els.openwebrxEnableTxBtn.addEventListener("click", async (event) => {
if (event) {
@@ -1496,6 +1504,8 @@ async function refreshCurrentUser() {
try {
const result = await api("/v1/me", { authRequired: true });
state.user = result.user;
state.wavelogApiKey = typeof result.wavelogApiKey === "string" ? result.wavelogApiKey : "";
applyWavelogKeyToInput();
renderUserSettingsAuthMethods();
await ensureLanguageFromUserPreference();
} catch {
@@ -1684,12 +1694,37 @@ async function cancelReservationByUserId(userId) {
}
}
function applyWavelogKeyToInput() {
if (els.wavelogKeyInput) {
els.wavelogKeyInput.value = String(state.wavelogApiKey || "");
}
}
async function saveWavelogKey() {
if (!els.wavelogKeyInput) {
return;
}
const apiKey = String(els.wavelogKeyInput.value || "").trim();
try {
const result = await api("/v1/me/wavelog-key", { method: "PUT", body: { apiKey } });
state.wavelogApiKey = result && typeof result.wavelogApiKey === "string" ? result.wavelogApiKey : apiKey;
applyWavelogKeyToInput();
renderMessage(els.wavelogKeyMessage, translateLiteral("Wavelog API Key gespeichert."), false);
} catch (error) {
renderMessage(els.wavelogKeyMessage, error.message, true);
}
}
async function openOpenWebRxSession() {
clearMessages("status");
setOpenWebRxBusy(true);
try {
const result = await requestOpenWebRxSessionWithRetry();
const session = result && result.session ? result.session : null;
if (result && typeof result.wavelogApiKey === "string") {
state.wavelogApiKey = result.wavelogApiKey;
applyWavelogKeyToInput();
}
const sessionTicket = session && session.ticket ? String(session.ticket) : "";
state.openWebRx.sessionUrl = buildOpenWebRxSessionUrl(session && session.iframeUrl ? session.iframeUrl : "", sessionTicket);
state.openWebRx.sessionTicket = sessionTicket;
@@ -1715,6 +1750,10 @@ async function openOpenWebRxExternal() {
try {
const result = await requestOpenWebRxSessionWithRetry();
const session = result && result.session ? result.session : null;
if (result && typeof result.wavelogApiKey === "string") {
state.wavelogApiKey = result.wavelogApiKey;
applyWavelogKeyToInput();
}
const sessionTicket = session && session.ticket ? String(session.ticket) : "";
const sessionUrl = buildOpenWebRxSessionUrl(session && session.iframeUrl ? String(session.iframeUrl) : "", sessionTicket);
if (!sessionUrl) {

View File

@@ -52,6 +52,10 @@
"OpenWebRX laden": "OpenWebRX laden",
"SDR oeffnen": "SDR oeffnen",
"Session Key (Ticket):": "Session Key (Ticket):",
"Wavelog API Key (Rig-Key)": "Wavelog API Key (Rig-Key)",
"Key speichern": "Key speichern",
"Frequenz und Mode der Station werden beim Bandwechsel an dein Wavelog-Logbuch gesendet.": "Frequenz und Mode der Station werden beim Bandwechsel an dein Wavelog-Logbuch gesendet.",
"Wavelog API Key gespeichert.": "Wavelog API Key gespeichert.",
"Reservierungen": "Reservierungen",
"Naechsten Slot reservieren": "Naechsten Slot reservieren",
"SWR Uebersicht": "SWR Uebersicht",

View File

@@ -52,6 +52,10 @@
"OpenWebRX laden": "Load OpenWebRX",
"SDR oeffnen": "Open SDR",
"Session Key (Ticket):": "Session key (ticket):",
"Wavelog API Key (Rig-Key)": "Wavelog API Key (Rig Key)",
"Key speichern": "Save key",
"Frequenz und Mode der Station werden beim Bandwechsel an dein Wavelog-Logbuch gesendet.": "The station's frequency and mode are sent to your Wavelog logbook on band change.",
"Wavelog API Key gespeichert.": "Wavelog API key saved.",
"Reservierungen": "Reservations",
"Naechsten Slot reservieren": "Reserve next slot",
"SWR Uebersicht": "SWR Overview",

View File

@@ -166,6 +166,15 @@
<div id="openwebrxSessionAccess" class="stack" hidden>
<p class="muted">Session Key (Ticket): <code id="openwebrxSessionTicket">-</code></p>
</div>
<div id="wavelogKeyBox" class="stack">
<label for="wavelogKeyInput">Wavelog API Key (Rig-Key)</label>
<input id="wavelogKeyInput" type="text" autocomplete="off" spellcheck="false" placeholder="Wavelog API Key" />
<div class="actions">
<button id="wavelogKeySaveBtn" type="button" class="ghost-btn">Key speichern</button>
</div>
<p id="wavelogKeyMessage" class="message"></p>
<p class="muted">Frequenz und Mode der Station werden beim Bandwechsel an dein Wavelog-Logbuch gesendet.</p>
</div>
</section>
<section id="reservationPanel" class="links-wrap" hidden>

View File

@@ -358,7 +358,7 @@ async function routeRequest(req, res) {
if (method === "GET" && url.pathname === "/v1/me") {
const auth = requireAuth(req, res);
if (!auth) return;
return sendJson(res, 200, { user: sanitizeUser(auth.user), capabilities: userCapabilities(auth.user) });
return sendJson(res, 200, { user: sanitizeUser(auth.user), wavelogApiKey: auth.user.wavelogApiKey || "", capabilities: userCapabilities(auth.user) });
}
if (method === "GET" && url.pathname === "/v1/auth/session/diag") {
@@ -387,6 +387,13 @@ async function routeRequest(req, res) {
return handleSelfLanguageUpdate(res, auth.user, body);
}
if (method === "PUT" && url.pathname === "/v1/me/wavelog-key") {
const auth = requireAuth(req, res);
if (!auth) return;
const body = await readJsonBody(req);
return handleSelfWavelogKeyUpdate(res, auth.user, body);
}
if (method === "GET" && url.pathname === "/v1/station/status") {
const auth = requireAuth(req, res);
if (!auth) return;
@@ -1547,6 +1554,22 @@ async function handleSelfLanguageUpdate(res, user, body) {
return sendJson(res, 200, { ok: true, user: sanitizeUser(user) });
}
async function handleSelfWavelogKeyUpdate(res, user, body) {
const raw = body && typeof body.apiKey === "string" ? body.apiKey.trim() : "";
if (raw.length > 128) {
return sendError(res, 400, "user.wavelog_key.invalid", "API Key zu lang");
}
// Allow empty (clears the key). Otherwise keep it to a conservative token charset.
if (raw && !/^[A-Za-z0-9._-]+$/.test(raw)) {
return sendError(res, 400, "user.wavelog_key.invalid", "API Key enthaelt ungueltige Zeichen");
}
user.wavelogApiKey = raw;
await writeJson(files.users, runtime.users);
// Audit the change WITHOUT storing the key value itself.
await appendAudit("user.wavelog_key.update", user, { set: Boolean(raw) });
return sendJson(res, 200, { ok: true, wavelogApiKey: raw });
}
async function handleAdminUserAuthMethodsUpdate(res, actor, pathname, body) {
const match = pathname.match(/^\/v1\/admin\/users\/([^/]+)\/auth-methods$/);
if (!match) {
@@ -3615,7 +3638,8 @@ async function handleOpenWebRxSessionIssue(res, user) {
return sendJson(res, 200, {
ok: true,
session: result
session: result,
wavelogApiKey: user.wavelogApiKey || ""
});
}
@@ -3682,6 +3706,7 @@ async function handleOpenWebRxBandSelect(res, user, body) {
runtime.txFollowRoute = antennaRoute;
runtime.openWebRxAntennaRoute = antennaRoute;
await appendAudit("openwebrx.band.set", user, { band, result: { centerFreqHz: result.centerFreqHz || null, skipped: Boolean(result.skipped) } });
await maybePublishWavelogCat(user, result);
return sendJson(res, 200, { ok: true, result });
} catch (error) {
const message = String(error && error.message ? error.message : error);
@@ -3692,6 +3717,42 @@ async function handleOpenWebRxBandSelect(res, user, body) {
}
}
// Push current frequency/mode to the active operator's Wavelog logbook (CAT API).
// Best-effort: never throws into the band-select flow.
async function maybePublishWavelogCat(user, bandResult) {
try {
if (!user || !user.wavelogApiKey) {
return;
}
if (!runtime.pluginState.enabled["rms.wavelog.cat"]) {
return;
}
if (!runtime.pluginState.providers["logging.cat.publish"]) {
return;
}
const frequencyHz = Number(bandResult && bandResult.centerFreqHz);
if (!Number.isFinite(frequencyHz) || frequencyHz <= 0) {
return;
}
const outcome = await executeCapability("logging.cat.publish", "publish", {
frequencyHz,
mode: bandResult && bandResult.startMod ? bandResult.startMod : null,
apiKey: user.wavelogApiKey
}, { user, skipTxSafety: true });
if (!outcome || outcome.ok !== true) {
console.warn("wavelog.cat publish not ok", {
userId: user.id,
reason: outcome && (outcome.reason || outcome.error || outcome.status)
});
}
} catch (error) {
console.warn("wavelog.cat publish error", {
userId: user.id,
error: String(error && error.message ? error.message : error)
});
}
}
async function handleOpenWebRxTxEnable(res, user) {
if (!runtime.station.isInUse || runtime.station.activeByUserId !== user.id) {
return sendError(res, 403, "openwebrx.not_owner", "OpenWebRX TX nur fuer aktiven Stationsbenutzer");

29
test/wavelog-cat.test.js Normal file
View File

@@ -0,0 +1,29 @@
const test = require("node:test");
const assert = require("node:assert");
const { toAdifMode } = require("../plugins/rms.wavelog.cat/index.js");
test("toAdifMode maps phone submodes to SSB", () => {
assert.strictEqual(toAdifMode("usb"), "SSB");
assert.strictEqual(toAdifMode("LSB"), "SSB");
assert.strictEqual(toAdifMode("ssb"), "SSB");
});
test("toAdifMode maps cw/fm/am variants", () => {
assert.strictEqual(toAdifMode("cw"), "CW");
assert.strictEqual(toAdifMode("cwr"), "CW");
assert.strictEqual(toAdifMode("nfm"), "FM");
assert.strictEqual(toAdifMode("fm"), "FM");
assert.strictEqual(toAdifMode("am"), "AM");
});
test("toAdifMode passes unknown modes through in upper case", () => {
assert.strictEqual(toAdifMode("ft8"), "FT8");
assert.strictEqual(toAdifMode("c4fm"), "C4FM");
});
test("toAdifMode returns empty string for empty input", () => {
assert.strictEqual(toAdifMode(""), "");
assert.strictEqual(toAdifMode(null), "");
assert.strictEqual(toAdifMode(undefined), "");
});