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>
This commit is contained in:
ARCG Admin (Claude)
2026-10-22 20:47:58 +02:00
parent 2bcf6ab1e5
commit 0488f6e25d
11 changed files with 308 additions and 2 deletions

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");