feat(lte): cellular telemetry, band details and roaming history (v0.23.12-beta)
Adds LTE support, requested in discussion #85 by a contributor running a MikroTik ATL 18 on a fixed antenna. Cellular was previously a blank page: the only occurrence of "lte" in the tree excluded LTE interfaces from DHCP binding. Built against two real field captures rather than documentation, which disagreed with the hardware in several places — the modem reports `data-class` not `access-technology`, `iccid` not `uicc`, `status: running` not `connected`, embeds units inside values (`-97dBm`, `17dB`), and omits `registration-status`, `pin-status` and `lac`/`tac` entirely. What an LTE interface reports depends on the modem chipset, not on RouterOS, so every field is optional by construction. Fixes a latent bug in the API client while it was in reach. A sentence carrying the same attribute twice kept only the last value, and a Cat-18 modem reports one `ca-band` per aggregated carrier. On the contributor's 4-carrier capture that yielded B3 + B20 — 30 MHz — on a device actually running 70 MHz, because the narrowest carrier happens to arrive last. Repeats are now preserved alongside the flat value, so existing callers are byte-for-byte unaffected. Signal quality is graded rather than merely displayed. RSRP alone misleads: the capture shows −97 dBm, nominally "fair", on a link running 256QAM at CQI 15 with two spatial streams. SINR leads the grade and RSRP moderates it. Surfaces the uplink anchor, which no MikroTik UI shows. Left on automatic a modem anchors to whichever band it hears loudest, often a narrow one, and aggregates wider bands for downlink only — but uplink rides the primary carrier alone, so upload stays capped. This is the contributor's actual reason for locking bands, and it reframes band selection as anchor control rather than coverage. Band changes, tower handovers and re-registrations are reconstructed by comparing consecutive polls, since scanning interrupts service and finds nothing at a site served by a single base station. Observed bands accumulate as the evidence a future band lock will be checked against. Band selection itself is deliberately absent: it needs Change Guard plus a free-space check, as the target hardware has 1.1 MB free of 16 MB flash. Also fixes an unrelated display bug in the RF Health channel map. The 2.4 GHz axis is labelled by channel, but ticks at 2400 and 2480 fall outside the channel plan and fell back to printing raw frequencies — and the 2400 label sat at exactly 0% with a centring transform, hanging half its width outside the card. 2.4 GHz is the only band with a tick at an edge. Unlabelled ticks are now dropped and edge labels anchor inward. - backend/src/utils/lte.ts — parsing, quality, change detection (59 tests) - RouterOSClient — repeated attributes preserved (6 tests) - lte_interfaces, lte_cell_history, lte_observed_bands - GET /devices/:id/lte, /lte/history, /lte/metrics - LteTab, gated on has_lte from the interface list - scripts/seed-lte-fixture.sql — a cellular device for local development Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
57d650c159
commit
2625fea8de
@@ -13,6 +13,9 @@ import {
|
||||
clientsPerRadio, buildMacIndex, matchRadiosToDevices,
|
||||
type WifiRole,
|
||||
} from './capsman';
|
||||
import {
|
||||
parseLteMonitor, overallQuality, detectChanges, type LteStatus,
|
||||
} from '../../utils/lte';
|
||||
import { BackupService } from '../BackupService';
|
||||
import { alertService } from '../AlertService';
|
||||
|
||||
@@ -115,6 +118,9 @@ export class DeviceCollector {
|
||||
if (this.device.device_type === 'switch') {
|
||||
await this.collectPoePower();
|
||||
}
|
||||
if ((this.device as unknown as { has_lte?: boolean }).has_lte) {
|
||||
await this.collectLte();
|
||||
}
|
||||
await this.updateDeviceStatus('online');
|
||||
}
|
||||
|
||||
@@ -142,6 +148,7 @@ export class DeviceCollector {
|
||||
await this.collectVlans();
|
||||
await this.collectSystemInfo();
|
||||
await this.collectStp();
|
||||
await this.collectLte();
|
||||
if (this.shouldCollectWireless()) {
|
||||
await this.collectWirelessInterfaces();
|
||||
await this.collectSecurityProfiles();
|
||||
@@ -167,6 +174,7 @@ export class DeviceCollector {
|
||||
await this.updateClients();
|
||||
await this.collectEvents();
|
||||
await this.collectNeighbors();
|
||||
await this.collectLte();
|
||||
// Wireless data belongs in a full resync too — without this, a manual sync
|
||||
// never refreshed radios/SSIDs (or pruned ones deleted on the device), so
|
||||
// the UI could only catch up on the 5-minute slow poll.
|
||||
@@ -2832,6 +2840,204 @@ export class DeviceCollector {
|
||||
}
|
||||
}
|
||||
|
||||
// ─── LTE / cellular → Postgres + InfluxDB ─────────────────────────────────
|
||||
|
||||
/**
|
||||
* Collect cellular state from every LTE interface on the device.
|
||||
*
|
||||
* Called on the fast poll (for the signal series) and on the slow poll, where
|
||||
* it also refreshes `devices.has_lte`. The device is only contacted when the
|
||||
* interface table already shows an LTE interface, so the overwhelming majority
|
||||
* of a wired fleet costs one local query and no API call at all.
|
||||
*
|
||||
* Field handling is deliberately forgiving. What a modem reports depends on
|
||||
* its chipset rather than on RouterOS, so a missing value is an ordinary state
|
||||
* and never a reason to abandon the rest of the row.
|
||||
*/
|
||||
async collectLte(): Promise<void> {
|
||||
try {
|
||||
const lteInterfaces = await query<{ name: string }>(
|
||||
`SELECT name FROM interfaces WHERE device_id = $1 AND type = 'lte'`,
|
||||
[this.device.id],
|
||||
);
|
||||
|
||||
if (lteInterfaces.length === 0) {
|
||||
// Clear the flag if a modem was removed, so the fast poll stops looking.
|
||||
if ((this.device as unknown as { has_lte?: boolean }).has_lte) {
|
||||
await query(`UPDATE devices SET has_lte = FALSE WHERE id = $1`, [this.device.id]);
|
||||
(this.device as unknown as { has_lte?: boolean }).has_lte = false;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (!(this.device as unknown as { has_lte?: boolean }).has_lte) {
|
||||
await query(`UPDATE devices SET has_lte = TRUE WHERE id = $1`, [this.device.id]);
|
||||
(this.device as unknown as { has_lte?: boolean }).has_lte = true;
|
||||
}
|
||||
|
||||
const configs = await this.client
|
||||
.execute('/interface/lte/print', { detail: '' })
|
||||
.catch(() => [] as Record<string, string>[]);
|
||||
const configByName = new Map(configs.filter(c => c['name']).map(c => [c['name'], c]));
|
||||
|
||||
const writeApi = getWriteApi();
|
||||
|
||||
for (const { name } of lteInterfaces) {
|
||||
// `monitor` addresses the interface by name and must be told not to stream.
|
||||
const rows = await this.client
|
||||
.execute('/interface/lte/monitor', { numbers: name, once: '' })
|
||||
.catch(() => [] as Record<string, string>[]);
|
||||
if (rows.length === 0) continue;
|
||||
|
||||
const status = parseLteMonitor(rows[0]);
|
||||
const config = configByName.get(name) || {};
|
||||
const quality = overallQuality(status);
|
||||
|
||||
const previous = await this.previousLteStatus(name);
|
||||
await this.recordLteChanges(name, previous, status);
|
||||
|
||||
await query(
|
||||
`INSERT INTO lte_interfaces (
|
||||
device_id, interface_name, status, data_class, modem_model, modem_revision,
|
||||
operator, cell_id, enb_id, sector_id, phy_cell_id, session_uptime_s,
|
||||
primary_band, ca_bands, rssi, rsrp, rsrq, sinr, cqi, rank_indicator, mcs,
|
||||
dl_modulation, quality, allowed_bands, network_mode, apn_profiles,
|
||||
allow_roaming, config_json, updated_at
|
||||
) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,
|
||||
$19,$20,$21,$22,$23,$24,$25,$26,$27,$28,NOW())
|
||||
ON CONFLICT (device_id, interface_name) DO UPDATE SET
|
||||
status = EXCLUDED.status, data_class = EXCLUDED.data_class,
|
||||
modem_model = EXCLUDED.modem_model, modem_revision = EXCLUDED.modem_revision,
|
||||
operator = EXCLUDED.operator, cell_id = EXCLUDED.cell_id,
|
||||
enb_id = EXCLUDED.enb_id, sector_id = EXCLUDED.sector_id,
|
||||
phy_cell_id = EXCLUDED.phy_cell_id, session_uptime_s = EXCLUDED.session_uptime_s,
|
||||
primary_band = EXCLUDED.primary_band, ca_bands = EXCLUDED.ca_bands,
|
||||
rssi = EXCLUDED.rssi, rsrp = EXCLUDED.rsrp, rsrq = EXCLUDED.rsrq,
|
||||
sinr = EXCLUDED.sinr, cqi = EXCLUDED.cqi,
|
||||
rank_indicator = EXCLUDED.rank_indicator, mcs = EXCLUDED.mcs,
|
||||
dl_modulation = EXCLUDED.dl_modulation, quality = EXCLUDED.quality,
|
||||
allowed_bands = EXCLUDED.allowed_bands, network_mode = EXCLUDED.network_mode,
|
||||
apn_profiles = EXCLUDED.apn_profiles, allow_roaming = EXCLUDED.allow_roaming,
|
||||
config_json = EXCLUDED.config_json, updated_at = NOW()`,
|
||||
[
|
||||
this.device.id, name, status.status, status.dataClass, status.modemModel,
|
||||
status.modemRevision, status.operator, status.cellId, status.enbId,
|
||||
status.sectorId, status.phyCellId, status.sessionUptimeSeconds,
|
||||
status.primaryBand ? JSON.stringify(status.primaryBand) : null,
|
||||
JSON.stringify(status.caBands),
|
||||
status.rssi, status.rsrp, status.rsrq, status.sinr, status.cqi,
|
||||
status.ri, status.mcs, status.dlModulation, quality,
|
||||
config['band'] || null, config['network-mode'] || null,
|
||||
config['apn-profiles'] || null,
|
||||
config['allow-roaming'] ? config['allow-roaming'] === 'yes' : null,
|
||||
JSON.stringify({ ...rows[0], ...config }),
|
||||
],
|
||||
);
|
||||
|
||||
await this.recordObservedBands(name, status);
|
||||
|
||||
// Signal series. Fields are written only when the modem reported them —
|
||||
// a zero here would read as a signal no transmitter produces.
|
||||
const point = new Point('lte_signal')
|
||||
.tag('device_id', String(this.device.id))
|
||||
.tag('device_name', this.device.name)
|
||||
.tag('interface', name)
|
||||
.tag('operator', status.operator || 'unknown')
|
||||
.tag('band', status.primaryBand ? `B${status.primaryBand.band}` : 'unknown')
|
||||
.timestamp(new Date());
|
||||
|
||||
let hasField = false;
|
||||
const addField = (key: string, value: number | null, integer = false) => {
|
||||
if (value == null) return;
|
||||
if (integer) point.intField(key, Math.round(value));
|
||||
else point.floatField(key, value);
|
||||
hasField = true;
|
||||
};
|
||||
addField('rsrp', status.rsrp);
|
||||
addField('rsrq', status.rsrq);
|
||||
addField('sinr', status.sinr);
|
||||
addField('rssi', status.rssi);
|
||||
addField('cqi', status.cqi, true);
|
||||
addField('rank', status.ri, true);
|
||||
addField('mcs', status.mcs, true);
|
||||
// Aggregated carriers, primary included — the shape of the link over time.
|
||||
point.intField('carriers', status.caBands.length + (status.primaryBand ? 1 : 0));
|
||||
|
||||
if (hasField) writeApi.writePoint(point);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`LTE collection failed for ${this.device.name}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
/** Last stored state for an interface, for comparing consecutive polls. */
|
||||
private async previousLteStatus(name: string): Promise<LteStatus | null> {
|
||||
const row = await queryOne<{
|
||||
cell_id: string | null; session_uptime_s: string | null;
|
||||
primary_band: { band: number } | null; ca_bands: { band: number }[] | null;
|
||||
}>(
|
||||
`SELECT cell_id, session_uptime_s, primary_band, ca_bands
|
||||
FROM lte_interfaces WHERE device_id = $1 AND interface_name = $2`,
|
||||
[this.device.id, name],
|
||||
);
|
||||
if (!row) return null;
|
||||
|
||||
// Only the fields detectChanges compares need to be faithful here.
|
||||
return {
|
||||
status: null, dataClass: null, modemModel: null, modemRevision: null,
|
||||
operator: null, cellId: row.cell_id, enbId: null, sectorId: null, phyCellId: null,
|
||||
sessionUptimeSeconds: row.session_uptime_s == null ? null : Number(row.session_uptime_s),
|
||||
primaryBand: row.primary_band
|
||||
? { band: row.primary_band.band, bandwidthMhz: null, earfcn: null, phyCellId: null, raw: '' }
|
||||
: null,
|
||||
caBands: (row.ca_bands || []).map(b => ({
|
||||
band: b.band, bandwidthMhz: null, earfcn: null, phyCellId: null, raw: '',
|
||||
})),
|
||||
rssi: null, rsrp: null, rsrq: null, sinr: null,
|
||||
cqi: null, ri: null, mcs: null, dlModulation: null,
|
||||
};
|
||||
}
|
||||
|
||||
/** Persist handovers, re-registrations and band changes seen between polls. */
|
||||
private async recordLteChanges(
|
||||
name: string, previous: LteStatus | null, next: LteStatus,
|
||||
): Promise<void> {
|
||||
const bands = [next.primaryBand, ...next.caBands]
|
||||
.filter(Boolean).map(b => b!.band).sort((a, b) => a - b).join(',');
|
||||
|
||||
for (const change of detectChanges(previous, next)) {
|
||||
await query(
|
||||
`INSERT INTO lte_cell_history
|
||||
(device_id, interface_name, kind, detail, cell_id, enb_id, bands, rsrp, sinr)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9)`,
|
||||
[this.device.id, name, change.kind, change.detail,
|
||||
next.cellId, next.enbId, bands || null, next.rsrp, next.sinr],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remember which bands have actually served this device.
|
||||
*
|
||||
* This is the evidence a band lock is checked against later. Scanning is not a
|
||||
* dependable alternative — it interrupts service and finds nothing at a site
|
||||
* served by one base station — but a band already used here demonstrably works.
|
||||
*/
|
||||
private async recordObservedBands(name: string, status: LteStatus): Promise<void> {
|
||||
const bands = [...new Set(
|
||||
[status.primaryBand, ...status.caBands].filter(Boolean).map(b => b!.band),
|
||||
)];
|
||||
for (const band of bands) {
|
||||
await query(
|
||||
`INSERT INTO lte_observed_bands (device_id, interface_name, band)
|
||||
VALUES ($1,$2,$3)
|
||||
ON CONFLICT (device_id, interface_name, band) DO UPDATE SET
|
||||
last_seen = NOW(), observations = lte_observed_bands.observations + 1`,
|
||||
[this.device.id, name, band],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Port VLAN config ─────────────────────────────────────────────────────
|
||||
|
||||
async setPortVlanConfig(
|
||||
|
||||
@@ -3,6 +3,14 @@ import * as tls from 'tls';
|
||||
import * as crypto from 'crypto';
|
||||
import { EventEmitter } from 'events';
|
||||
|
||||
/**
|
||||
* Key under which repeated attributes from one sentence are preserved as JSON.
|
||||
* Present only when a reply actually repeated a key, so ordinary rows are
|
||||
* byte-for-byte unchanged.
|
||||
*/
|
||||
export const REPEATED_ATTRIBUTES_KEY = '.repeated';
|
||||
|
||||
|
||||
export interface RouterOSSentence {
|
||||
type: string;
|
||||
words: Record<string, string>;
|
||||
@@ -363,17 +371,33 @@ export class RouterOSClient extends EventEmitter {
|
||||
const type = words[0];
|
||||
const parsed: Record<string, string> = {};
|
||||
let tag: string | undefined;
|
||||
// A sentence may carry the same attribute more than once — an LTE modem
|
||||
// reports one `ca-band` per aggregated carrier — and a flat object can
|
||||
// only hold the last. Repeats are preserved alongside it so that
|
||||
// multi-valued replies are not silently reduced to their final value.
|
||||
let repeated: Record<string, string[]> | undefined;
|
||||
|
||||
for (let i = 1; i < words.length; i++) {
|
||||
const w = words[i];
|
||||
if (w.startsWith('=')) {
|
||||
const eq = w.indexOf('=', 1);
|
||||
if (eq > 0) parsed[w.slice(1, eq)] = w.slice(eq + 1);
|
||||
if (eq > 0) {
|
||||
const key = w.slice(1, eq);
|
||||
const value = w.slice(eq + 1);
|
||||
if (key in parsed) {
|
||||
repeated ??= {};
|
||||
repeated[key] ??= [parsed[key]];
|
||||
repeated[key].push(value);
|
||||
}
|
||||
parsed[key] = value;
|
||||
}
|
||||
} else if (w.startsWith('.tag=')) {
|
||||
tag = w.slice(5);
|
||||
}
|
||||
}
|
||||
|
||||
if (repeated) parsed[REPEATED_ATTRIBUTES_KEY] = JSON.stringify(repeated);
|
||||
|
||||
return { type, words: parsed, tag };
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
import { RouterOSClient, RouterOSSentence, REPEATED_ATTRIBUTES_KEY } from '../RouterOSClient';
|
||||
import { REPEATED_KEY, parseBandSpecs } from '../../../utils/lte';
|
||||
|
||||
/** Encode one word using RouterOS's variable-length prefix (short form only). */
|
||||
const word = (s: string): Buffer => {
|
||||
const body = Buffer.from(s, 'utf8');
|
||||
if (body.length >= 0x80) throw new Error('test helper handles short words only');
|
||||
return Buffer.concat([Buffer.from([body.length]), body]);
|
||||
};
|
||||
|
||||
/** Build a complete `!re` sentence from raw `key=value` words. */
|
||||
const sentence = (...kv: string[]): Buffer =>
|
||||
Buffer.concat([word('!re'), ...kv.map(p => word('=' + p)), Buffer.from([0x00])]);
|
||||
|
||||
/** Feed bytes straight to the parser, bypassing the socket. */
|
||||
function parse(bytes: Buffer): RouterOSSentence | null {
|
||||
const client = new RouterOSClient('127.0.0.1', 8728, 'x', 'x');
|
||||
const internals = client as unknown as {
|
||||
buffer: Buffer;
|
||||
tryParseSentence(): RouterOSSentence | null;
|
||||
};
|
||||
internals.buffer = bytes;
|
||||
return internals.tryParseSentence();
|
||||
}
|
||||
|
||||
describe('sentence parsing', () => {
|
||||
it('reads ordinary attributes into a flat object', () => {
|
||||
const s = parse(sentence('name=lte1', 'status=running'))!;
|
||||
expect(s.type).toBe('!re');
|
||||
expect(s.words).toEqual({ name: 'lte1', status: 'running' });
|
||||
});
|
||||
|
||||
it('adds no repeat side-channel when nothing repeats', () => {
|
||||
// Every existing caller sees byte-for-byte what it saw before.
|
||||
const s = parse(sentence('name=lte1'))!;
|
||||
expect(Object.keys(s.words)).toEqual(['name']);
|
||||
});
|
||||
|
||||
it('preserves every value when an attribute repeats', () => {
|
||||
// A Cat-18 modem reports one `ca-band` per aggregated carrier. A flat object
|
||||
// keeps only the last, which under-reports aggregation exactly when the link
|
||||
// is performing best.
|
||||
const s = parse(sentence(
|
||||
'primary-band=B1@20Mhz earfcn: 500',
|
||||
'ca-band=B3@20Mhz earfcn: 1800',
|
||||
'ca-band=B7@20Mhz earfcn: 3350',
|
||||
'ca-band=B20@10Mhz earfcn: 6300',
|
||||
))!;
|
||||
|
||||
expect(s.words['ca-band']).toBe('B20@10Mhz earfcn: 6300'); // unchanged
|
||||
expect(JSON.parse(s.words[REPEATED_ATTRIBUTES_KEY])).toEqual({
|
||||
'ca-band': [
|
||||
'B3@20Mhz earfcn: 1800',
|
||||
'B7@20Mhz earfcn: 3350',
|
||||
'B20@10Mhz earfcn: 6300',
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('hands the LTE parser all three carriers', () => {
|
||||
const s = parse(sentence(
|
||||
'ca-band=B3@20Mhz earfcn: 1800',
|
||||
'ca-band=B7@20Mhz earfcn: 3350',
|
||||
'ca-band=B20@10Mhz earfcn: 6300',
|
||||
))!;
|
||||
expect(parseBandSpecs(s.words, 'ca-band').map(b => b.band)).toEqual([3, 7, 20]);
|
||||
});
|
||||
|
||||
it('tracks repeats of several different attributes independently', () => {
|
||||
const s = parse(sentence('a=1', 'b=x', 'a=2', 'b=y', 'c=only'))!;
|
||||
expect(JSON.parse(s.words[REPEATED_ATTRIBUTES_KEY])).toEqual({ a: ['1', '2'], b: ['x', 'y'] });
|
||||
expect(s.words.c).toBe('only');
|
||||
});
|
||||
|
||||
it('agrees with the key the LTE parser reads', () => {
|
||||
// These constants live apart to keep the pure parser free of socket code.
|
||||
expect(REPEATED_KEY).toBe(REPEATED_ATTRIBUTES_KEY);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user