7 Commits

Author SHA1 Message Date
ARCG Admin (Claude)
487c801bab feat(ui): add Impressum/Datenschutz footer links
remote.arcg.at has the same Medieninhaber (ARCG) as the homepage, so link the
legal pages there: Impressum -> amateurradioclubgraz.at/imprint/, Datenschutz ->
amateurradioclubgraz.at/datenschutz/ (the homepage DSE now covers the remote
station processing).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-23 11:19:52 +02:00
dxd
1acbb569dd Merge pull request 'feat(wavelog): link to the Wavelog instance next to the API key field' (#3) from feat/wavelog-instance-link into main
Reviewed-on: #3
2026-10-22 21:46:20 +02:00
ARCG Admin (Claude)
9862f751a2 feat(wavelog): link to the Wavelog instance next to the API key field
Show an "Open Wavelog" link beside the Wavelog API Key heading in the
OpenWebRX panel. The URL comes from the runtime WAVELOG_BASE_URL (exposed
via GET /v1/me and the OpenWebRX session response), so the private hostname
is never hardcoded in this public repo. The link is hidden when no base URL
is configured.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-22 21:21:27 +02:00
dxd
3387ded386 Merge pull request 'fix(wavelog): push live tuned frequency, not just band center' (#2) from fix/wavelog-cat-live-frequency into main
Reviewed-on: #2
2026-10-22 21:17:36 +02:00
ARCG Admin (Claude)
061045999b fix(wavelog): push live tuned frequency, not just band center
The CAT push only fired on band select (band center). Fine-tuning inside
OpenWebRX did not update Wavelog. Hook the per-operator live-state updates
(/v1/openwebrx/plugin/live-state) so the actual tuned frequency/mode is
pushed, trailing-debounced (WAVELOG_MIN_INTERVAL_MS, default 3000 ms) so
tuning does not flood the Wavelog API. Band-select push kept as immediate
feedback on band change.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-22 21:16:17 +02:00
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
12 changed files with 430 additions and 2 deletions

View File

@@ -86,3 +86,12 @@ MICROHAM_PTT_DOWN_CMD=
MICROHAM_PTT_UP_CMD= MICROHAM_PTT_UP_CMD=
OPENWEBRX_ACCESS_POLICY_FILE=./data/openwebrx-access-policy.txt OPENWEBRX_ACCESS_POLICY_FILE=./data/openwebrx-access-policy.txt
OPENWEBRX_PERSISTENT_USERS_FILE=./data/openwebrx-persistent-users.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
# Min interval between live-tuning pushes per operator (ms, trailing-debounced).
WAVELOG_MIN_INTERVAL_MS=3000

View File

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

View File

@@ -0,0 +1,22 @@
# 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:
- **Bandwechsel** (`/v1/openwebrx/bands/select`): sofortiger Push der Band-Mittenfrequenz
(`centerFreqHz` + `startMod`).
- **Live-Tuning in OpenWebRX** (`/v1/openwebrx/plugin/live-state`): Push der tatsaechlich getunten
Frequenz/Mode des aktiven Operators, **gedrosselt** (Trailing-Debounce, `WAVELOG_MIN_INTERVAL_MS`,
Default 3000 ms) damit Feintunen die API nicht flutet.
- Beides best-effort: Fehler brechen den jeweiligen Flow 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,10 @@ const els = {
openwebrxSessionLink: document.getElementById("openwebrxSessionLink"), openwebrxSessionLink: document.getElementById("openwebrxSessionLink"),
openwebrxCopyLinkBtn: document.getElementById("openwebrxCopyLinkBtn"), openwebrxCopyLinkBtn: document.getElementById("openwebrxCopyLinkBtn"),
openwebrxSessionTicket: document.getElementById("openwebrxSessionTicket"), openwebrxSessionTicket: document.getElementById("openwebrxSessionTicket"),
wavelogKeyInput: document.getElementById("wavelogKeyInput"),
wavelogKeySaveBtn: document.getElementById("wavelogKeySaveBtn"),
wavelogKeyMessage: document.getElementById("wavelogKeyMessage"),
wavelogInstanceLink: document.getElementById("wavelogInstanceLink"),
openwebrxTxStatePill: document.getElementById("openwebrxTxStatePill"), openwebrxTxStatePill: document.getElementById("openwebrxTxStatePill"),
rotorCurrent: document.getElementById("rotorCurrent"), rotorCurrent: document.getElementById("rotorCurrent"),
rotorCompass: document.getElementById("rotorCompass"), rotorCompass: document.getElementById("rotorCompass"),
@@ -690,6 +694,11 @@ function bindEvents() {
await copyOpenWebRxLink(); await copyOpenWebRxLink();
}); });
} }
if (els.wavelogKeySaveBtn) {
els.wavelogKeySaveBtn.addEventListener("click", async () => {
await saveWavelogKey();
});
}
if (els.openwebrxEnableTxBtn) { if (els.openwebrxEnableTxBtn) {
els.openwebrxEnableTxBtn.addEventListener("click", async (event) => { els.openwebrxEnableTxBtn.addEventListener("click", async (event) => {
if (event) { if (event) {
@@ -1496,6 +1505,9 @@ async function refreshCurrentUser() {
try { try {
const result = await api("/v1/me", { authRequired: true }); const result = await api("/v1/me", { authRequired: true });
state.user = result.user; state.user = result.user;
state.wavelogApiKey = typeof result.wavelogApiKey === "string" ? result.wavelogApiKey : "";
state.wavelogBaseUrl = typeof result.wavelogBaseUrl === "string" ? result.wavelogBaseUrl : "";
applyWavelogKeyToInput();
renderUserSettingsAuthMethods(); renderUserSettingsAuthMethods();
await ensureLanguageFromUserPreference(); await ensureLanguageFromUserPreference();
} catch { } catch {
@@ -1684,12 +1696,50 @@ async function cancelReservationByUserId(userId) {
} }
} }
function applyWavelogKeyToInput() {
if (els.wavelogKeyInput) {
els.wavelogKeyInput.value = String(state.wavelogApiKey || "");
}
if (els.wavelogInstanceLink) {
const url = String(state.wavelogBaseUrl || "").trim();
if (url) {
els.wavelogInstanceLink.href = url;
els.wavelogInstanceLink.hidden = false;
} else {
els.wavelogInstanceLink.removeAttribute("href");
els.wavelogInstanceLink.hidden = true;
}
}
}
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() { async function openOpenWebRxSession() {
clearMessages("status"); clearMessages("status");
setOpenWebRxBusy(true); setOpenWebRxBusy(true);
try { try {
const result = await requestOpenWebRxSessionWithRetry(); const result = await requestOpenWebRxSessionWithRetry();
const session = result && result.session ? result.session : null; const session = result && result.session ? result.session : null;
if (result && typeof result.wavelogApiKey === "string") {
state.wavelogApiKey = result.wavelogApiKey;
if (typeof result.wavelogBaseUrl === "string") {
state.wavelogBaseUrl = result.wavelogBaseUrl;
}
applyWavelogKeyToInput();
}
const sessionTicket = session && session.ticket ? String(session.ticket) : ""; const sessionTicket = session && session.ticket ? String(session.ticket) : "";
state.openWebRx.sessionUrl = buildOpenWebRxSessionUrl(session && session.iframeUrl ? session.iframeUrl : "", sessionTicket); state.openWebRx.sessionUrl = buildOpenWebRxSessionUrl(session && session.iframeUrl ? session.iframeUrl : "", sessionTicket);
state.openWebRx.sessionTicket = sessionTicket; state.openWebRx.sessionTicket = sessionTicket;
@@ -1715,6 +1765,13 @@ async function openOpenWebRxExternal() {
try { try {
const result = await requestOpenWebRxSessionWithRetry(); const result = await requestOpenWebRxSessionWithRetry();
const session = result && result.session ? result.session : null; const session = result && result.session ? result.session : null;
if (result && typeof result.wavelogApiKey === "string") {
state.wavelogApiKey = result.wavelogApiKey;
if (typeof result.wavelogBaseUrl === "string") {
state.wavelogBaseUrl = result.wavelogBaseUrl;
}
applyWavelogKeyToInput();
}
const sessionTicket = session && session.ticket ? String(session.ticket) : ""; const sessionTicket = session && session.ticket ? String(session.ticket) : "";
const sessionUrl = buildOpenWebRxSessionUrl(session && session.iframeUrl ? String(session.iframeUrl) : "", sessionTicket); const sessionUrl = buildOpenWebRxSessionUrl(session && session.iframeUrl ? String(session.iframeUrl) : "", sessionTicket);
if (!sessionUrl) { if (!sessionUrl) {

View File

@@ -52,6 +52,11 @@
"OpenWebRX laden": "OpenWebRX laden", "OpenWebRX laden": "OpenWebRX laden",
"SDR oeffnen": "SDR oeffnen", "SDR oeffnen": "SDR oeffnen",
"Session Key (Ticket):": "Session Key (Ticket):", "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.",
"Wavelog öffnen": "Wavelog öffnen",
"Reservierungen": "Reservierungen", "Reservierungen": "Reservierungen",
"Naechsten Slot reservieren": "Naechsten Slot reservieren", "Naechsten Slot reservieren": "Naechsten Slot reservieren",
"SWR Uebersicht": "SWR Uebersicht", "SWR Uebersicht": "SWR Uebersicht",

View File

@@ -52,6 +52,11 @@
"OpenWebRX laden": "Load OpenWebRX", "OpenWebRX laden": "Load OpenWebRX",
"SDR oeffnen": "Open SDR", "SDR oeffnen": "Open SDR",
"Session Key (Ticket):": "Session key (ticket):", "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.",
"Wavelog öffnen": "Open Wavelog",
"Reservierungen": "Reservations", "Reservierungen": "Reservations",
"Naechsten Slot reservieren": "Reserve next slot", "Naechsten Slot reservieren": "Reserve next slot",
"SWR Uebersicht": "SWR Overview", "SWR Uebersicht": "SWR Overview",

View File

@@ -166,6 +166,18 @@
<div id="openwebrxSessionAccess" class="stack" hidden> <div id="openwebrxSessionAccess" class="stack" hidden>
<p class="muted">Session Key (Ticket): <code id="openwebrxSessionTicket">-</code></p> <p class="muted">Session Key (Ticket): <code id="openwebrxSessionTicket">-</code></p>
</div> </div>
<div id="wavelogKeyBox" class="stack">
<div class="section-head">
<label for="wavelogKeyInput">Wavelog API Key (Rig-Key)</label>
<a id="wavelogInstanceLink" target="_blank" rel="noopener noreferrer" hidden>Wavelog öffnen</a>
</div>
<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>
<section id="reservationPanel" class="links-wrap" hidden> <section id="reservationPanel" class="links-wrap" hidden>
@@ -539,6 +551,12 @@
</nav> </nav>
</main> </main>
<footer class="site-footer">
<a href="https://amateurradioclubgraz.at/imprint/" target="_blank" rel="noopener noreferrer">Impressum</a>
<span class="site-footer-sep" aria-hidden="true">·</span>
<a href="https://amateurradioclubgraz.at/datenschutz/" target="_blank" rel="noopener noreferrer">Datenschutz</a>
</footer>
<script src="/app.js" defer></script> <script src="/app.js" defer></script>
</body> </body>
</html> </html>

View File

@@ -885,3 +885,24 @@ html[data-theme="light"] .message.domain-notice {
display: grid; display: grid;
} }
} }
/* Legal footer links (Impressum / Datenschutz -> homepage) */
.site-footer {
margin: 2rem auto 1.25rem;
padding: 1rem;
text-align: center;
font-size: 0.85rem;
opacity: 0.8;
}
.site-footer a {
color: inherit;
text-decoration: underline;
}
.site-footer a:hover,
.site-footer a:focus-visible {
opacity: 1;
}
.site-footer-sep {
margin: 0 0.5rem;
opacity: 0.6;
}

View File

@@ -44,6 +44,7 @@ const config = {
actionRateLimit: Number(process.env.ACTION_RATE_LIMIT || 30), actionRateLimit: Number(process.env.ACTION_RATE_LIMIT || 30),
openWebRxPttLiveFreqToleranceHz: Number(process.env.OPENWEBRX_PTT_LIVE_FREQ_TOLERANCE_HZ || 50), openWebRxPttLiveFreqToleranceHz: Number(process.env.OPENWEBRX_PTT_LIVE_FREQ_TOLERANCE_HZ || 50),
openWebRxLiveStateTtlMs: Number(process.env.OPENWEBRX_LIVE_STATE_TTL_MS || 10000), openWebRxLiveStateTtlMs: Number(process.env.OPENWEBRX_LIVE_STATE_TTL_MS || 10000),
wavelogMinIntervalMs: Number(process.env.WAVELOG_MIN_INTERVAL_MS || 3000),
openWebRxPttBlockedBandConfigIdsRaw: String(process.env.OPENWEBRX_PTT_BLOCKED_BAND_CONFIG_IDS || "27,6,24"), openWebRxPttBlockedBandConfigIdsRaw: String(process.env.OPENWEBRX_PTT_BLOCKED_BAND_CONFIG_IDS || "27,6,24"),
openWebRxTxPollMs: Number(process.env.OPENWEBRX_TX_POLL_MS || 3000), openWebRxTxPollMs: Number(process.env.OPENWEBRX_TX_POLL_MS || 3000),
txAudioEnabled: String(process.env.TX_AUDIO_ENABLED || "true").trim().toLowerCase(), txAudioEnabled: String(process.env.TX_AUDIO_ENABLED || "true").trim().toLowerCase(),
@@ -196,6 +197,7 @@ const runtime = {
lastEnsureSdrAtMs: 0 lastEnsureSdrAtMs: 0
}, },
openWebRxLiveStateByUserId: {}, openWebRxLiveStateByUserId: {},
wavelogCatByUserId: {},
rotor: { rotor: {
azimuth: null, azimuth: null,
rawAzimuth: null, rawAzimuth: null,
@@ -358,7 +360,7 @@ async function routeRequest(req, res) {
if (method === "GET" && url.pathname === "/v1/me") { if (method === "GET" && url.pathname === "/v1/me") {
const auth = requireAuth(req, res); const auth = requireAuth(req, res);
if (!auth) return; 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 || "", wavelogBaseUrl: wavelogBaseUrlPublic(), capabilities: userCapabilities(auth.user) });
} }
if (method === "GET" && url.pathname === "/v1/auth/session/diag") { if (method === "GET" && url.pathname === "/v1/auth/session/diag") {
@@ -387,6 +389,13 @@ async function routeRequest(req, res) {
return handleSelfLanguageUpdate(res, auth.user, body); 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") { if (method === "GET" && url.pathname === "/v1/station/status") {
const auth = requireAuth(req, res); const auth = requireAuth(req, res);
if (!auth) return; if (!auth) return;
@@ -1547,6 +1556,22 @@ async function handleSelfLanguageUpdate(res, user, body) {
return sendJson(res, 200, { ok: true, user: sanitizeUser(user) }); 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) { async function handleAdminUserAuthMethodsUpdate(res, actor, pathname, body) {
const match = pathname.match(/^\/v1\/admin\/users\/([^/]+)\/auth-methods$/); const match = pathname.match(/^\/v1\/admin\/users\/([^/]+)\/auth-methods$/);
if (!match) { if (!match) {
@@ -3615,7 +3640,9 @@ async function handleOpenWebRxSessionIssue(res, user) {
return sendJson(res, 200, { return sendJson(res, 200, {
ok: true, ok: true,
session: result session: result,
wavelogApiKey: user.wavelogApiKey || "",
wavelogBaseUrl: wavelogBaseUrlPublic()
}); });
} }
@@ -3682,6 +3709,7 @@ async function handleOpenWebRxBandSelect(res, user, body) {
runtime.txFollowRoute = antennaRoute; runtime.txFollowRoute = antennaRoute;
runtime.openWebRxAntennaRoute = antennaRoute; runtime.openWebRxAntennaRoute = antennaRoute;
await appendAudit("openwebrx.band.set", user, { band, result: { centerFreqHz: result.centerFreqHz || null, skipped: Boolean(result.skipped) } }); 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 }); return sendJson(res, 200, { ok: true, result });
} catch (error) { } catch (error) {
const message = String(error && error.message ? error.message : error); const message = String(error && error.message ? error.message : error);
@@ -3692,6 +3720,104 @@ async function handleOpenWebRxBandSelect(res, user, body) {
} }
} }
// Public Wavelog base URL (for the UI link). Comes from runtime env only, so the
// private hostname is never baked into this (public) repo.
function wavelogBaseUrlPublic() {
return String(process.env.WAVELOG_BASE_URL || "").trim().replace(/\/+$/, "");
}
// Push a frequency/mode to the active operator's Wavelog logbook (CAT API).
// Best-effort: never throws into the caller's flow.
async function sendWavelogCat(user, frequencyHz, mode) {
try {
if (!user || !user.wavelogApiKey) {
return;
}
if (!runtime.pluginState.enabled["rms.wavelog.cat"]) {
return;
}
if (!runtime.pluginState.providers["logging.cat.publish"]) {
return;
}
const freq = Number(frequencyHz);
if (!Number.isFinite(freq) || freq <= 0) {
return;
}
const outcome = await executeCapability("logging.cat.publish", "publish", {
frequencyHz: freq,
mode: mode || 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)
});
}
}
// Band select: push the band center immediately (best-effort).
async function maybePublishWavelogCat(user, bandResult) {
await sendWavelogCat(user, bandResult && bandResult.centerFreqHz, bandResult && bandResult.startMod ? bandResult.startMod : null);
}
// Live tuning in OpenWebRX: push the actual tuned frequency/mode, throttled so
// fine-tuning doesn't flood the Wavelog API. Trailing-edge: the latest value is
// always sent once the throttle window elapses.
function scheduleWavelogCatLive(user, frequencyHz, mode) {
try {
if (!user || !user.wavelogApiKey || !runtime.pluginState.enabled["rms.wavelog.cat"]) {
return;
}
const freq = Number(frequencyHz);
if (!Number.isFinite(freq) || freq <= 0) {
return;
}
const key = String(user.id || "");
if (!key) {
return;
}
const minIntervalMs = Number.isFinite(config.wavelogMinIntervalMs) ? Math.max(500, config.wavelogMinIntervalMs) : 3000;
const rec = runtime.wavelogCatByUserId[key] || { lastFreq: null, lastMode: null, lastPushMs: 0, timer: null };
runtime.wavelogCatByUserId[key] = rec;
rec.pendingFreq = freq;
rec.pendingMode = mode || null;
rec.pendingUser = user;
if (rec.lastFreq === freq && rec.lastMode === (mode || null)) {
return;
}
const fire = () => {
rec.timer = null;
rec.lastFreq = rec.pendingFreq;
rec.lastMode = rec.pendingMode;
rec.lastPushMs = Date.now();
sendWavelogCat(rec.pendingUser, rec.pendingFreq, rec.pendingMode);
};
const elapsed = Date.now() - (rec.lastPushMs || 0);
if (!rec.timer && elapsed >= minIntervalMs) {
fire();
} else if (!rec.timer) {
rec.timer = setTimeout(fire, Math.max(250, minIntervalMs - elapsed));
if (rec.timer && typeof rec.timer.unref === "function") {
rec.timer.unref();
}
}
// If a timer is already pending, pendingFreq/Mode were updated above so the
// trailing push sends the latest tuned frequency.
} catch (error) {
console.warn("wavelog.cat live schedule error", {
userId: user && user.id,
error: String(error && error.message ? error.message : error)
});
}
}
async function handleOpenWebRxTxEnable(res, user) { async function handleOpenWebRxTxEnable(res, user) {
if (!runtime.station.isInUse || runtime.station.activeByUserId !== user.id) { if (!runtime.station.isInUse || runtime.station.activeByUserId !== user.id) {
return sendError(res, 403, "openwebrx.not_owner", "OpenWebRX TX nur fuer aktiven Stationsbenutzer"); return sendError(res, 403, "openwebrx.not_owner", "OpenWebRX TX nur fuer aktiven Stationsbenutzer");
@@ -4972,6 +5098,7 @@ async function handleOpenWebRxPluginLiveState(req, res, url, body) {
updatedAtMs: Date.now(), updatedAtMs: Date.now(),
source: "plugin" source: "plugin"
}); });
scheduleWavelogCatLive(user, parsed.frequencyHz, parsed.mode);
return sendJson(res, 200, { return sendJson(res, 200, {
ok: true, ok: true,
liveState: { liveState: {

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