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
@@ -703,6 +703,95 @@ CREATE TABLE IF NOT EXISTS wireless_security_profiles (
|
||||
updated_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
UNIQUE(device_id, name)
|
||||
);
|
||||
|
||||
-- LTE / cellular support (discussion #85).
|
||||
--
|
||||
-- has_lte is set during interface collection rather than inferred from
|
||||
-- device_type: a cellular modem turns up on routers, CPE and travel gear alike,
|
||||
-- and probing every device on every fast poll to find out costs more than
|
||||
-- remembering the answer.
|
||||
ALTER TABLE devices ADD COLUMN IF NOT EXISTS has_lte BOOLEAN NOT NULL DEFAULT FALSE;
|
||||
|
||||
-- Latest state of each LTE interface. One row per interface, overwritten on each
|
||||
-- poll; the time series lives in InfluxDB and the movement history below.
|
||||
--
|
||||
-- Almost every column is nullable on purpose. What an LTE interface reports
|
||||
-- depends on the modem chipset rather than on RouterOS — the Quectel EG18-EA
|
||||
-- this was built against reports no registration-status, no pin-status and no
|
||||
-- tracking area, while others do — so absence is a normal state, not an error.
|
||||
CREATE TABLE IF NOT EXISTS lte_interfaces (
|
||||
id SERIAL PRIMARY KEY,
|
||||
device_id INTEGER NOT NULL REFERENCES devices(id) ON DELETE CASCADE,
|
||||
interface_name VARCHAR(64) NOT NULL,
|
||||
status VARCHAR(32),
|
||||
data_class VARCHAR(32),
|
||||
modem_model VARCHAR(64),
|
||||
modem_revision VARCHAR(64),
|
||||
operator VARCHAR(64),
|
||||
cell_id VARCHAR(32),
|
||||
enb_id VARCHAR(32),
|
||||
sector_id VARCHAR(32),
|
||||
phy_cell_id VARCHAR(32),
|
||||
session_uptime_s BIGINT,
|
||||
primary_band JSONB,
|
||||
ca_bands JSONB NOT NULL DEFAULT '[]'::jsonb,
|
||||
rssi REAL,
|
||||
rsrp REAL,
|
||||
rsrq REAL,
|
||||
sinr REAL,
|
||||
cqi INTEGER,
|
||||
rank_indicator INTEGER,
|
||||
mcs INTEGER,
|
||||
dl_modulation VARCHAR(32),
|
||||
quality VARCHAR(16),
|
||||
-- Configured allow-list from /interface/lte, e.g. "1,3,7". Empty means auto.
|
||||
allowed_bands VARCHAR(128),
|
||||
network_mode VARCHAR(64),
|
||||
apn_profiles VARCHAR(128),
|
||||
allow_roaming BOOLEAN,
|
||||
config_json JSONB,
|
||||
updated_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
UNIQUE(device_id, interface_name)
|
||||
);
|
||||
|
||||
-- Tower and band movement, reconstructed by comparing consecutive polls.
|
||||
--
|
||||
-- Scanning costs service to run and finds nothing at a site served by a single
|
||||
-- base station, and the modem may log nothing at all, so polling is the only
|
||||
-- vantage point that always exists. session-uptime
|
||||
-- running backwards means the modem re-registered; a changed cell id means it
|
||||
-- handed over. Neither can be recovered after the fact, so it is recorded live.
|
||||
CREATE TABLE IF NOT EXISTS lte_cell_history (
|
||||
id SERIAL PRIMARY KEY,
|
||||
device_id INTEGER NOT NULL REFERENCES devices(id) ON DELETE CASCADE,
|
||||
interface_name VARCHAR(64) NOT NULL,
|
||||
at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
kind VARCHAR(24) NOT NULL,
|
||||
detail TEXT,
|
||||
cell_id VARCHAR(32),
|
||||
enb_id VARCHAR(32),
|
||||
bands VARCHAR(64),
|
||||
rsrp REAL,
|
||||
sinr REAL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_lte_cell_history_device
|
||||
ON lte_cell_history(device_id, at DESC);
|
||||
|
||||
-- Bands actually observed serving a device, which is what makes it possible to
|
||||
-- warn before a band lock. Scanning cannot answer "is this band available here?"
|
||||
-- dependably: it interrupts service, and comes back empty at exactly the remote
|
||||
-- sites that most need the warning. A band the modem has already used at this
|
||||
-- location demonstrably works there, and locking to one never once seen is how a
|
||||
-- device on a mast is lost.
|
||||
CREATE TABLE IF NOT EXISTS lte_observed_bands (
|
||||
device_id INTEGER NOT NULL REFERENCES devices(id) ON DELETE CASCADE,
|
||||
interface_name VARCHAR(64) NOT NULL,
|
||||
band INTEGER NOT NULL,
|
||||
first_seen TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
last_seen TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
observations INTEGER NOT NULL DEFAULT 1,
|
||||
PRIMARY KEY (device_id, interface_name, band)
|
||||
);
|
||||
`;
|
||||
|
||||
const DEFAULT_SETTINGS = [
|
||||
|
||||
@@ -19,6 +19,7 @@ import { runConfigHealth } from '../services/changeGuard/configHealth';
|
||||
import { isMultiVlanSpec } from '../utils/vlan';
|
||||
import { redis } from '../config/redis';
|
||||
import { enqueueBulkAddJob, getBulkAddJobState } from '../services/DeviceBulkAddWorker';
|
||||
import { parseBandList, uplinkAnchor, totalBandwidthMhz, type LteBandInfo } from '../utils/lte';
|
||||
|
||||
// Resolve a credential preset id into decrypted credentials. Returns null if
|
||||
// no id was provided; throws a readable error if the id is invalid.
|
||||
@@ -345,7 +346,7 @@ router.get('/:id', async (req: Request, res: Response) => {
|
||||
notes, location_address,
|
||||
location_lat::float8 AS location_lat,
|
||||
location_lng::float8 AS location_lng,
|
||||
rack_name, rack_slot, wifi_role, created_at, updated_at
|
||||
rack_name, rack_slot, wifi_role, has_lte, created_at, updated_at
|
||||
FROM devices WHERE id = $1`,
|
||||
[req.params.id]
|
||||
);
|
||||
@@ -386,7 +387,7 @@ router.patch('/:id/location', requireWrite, async (req: Request, res: Response)
|
||||
notes, location_address,
|
||||
location_lat::float8 AS location_lat,
|
||||
location_lng::float8 AS location_lng,
|
||||
rack_name, rack_slot, wifi_role, created_at, updated_at
|
||||
rack_name, rack_slot, wifi_role, has_lte, created_at, updated_at
|
||||
FROM devices WHERE id = $1`,
|
||||
[req.params.id]
|
||||
);
|
||||
@@ -2201,6 +2202,149 @@ router.get('/:id/wireless', async (req: Request, res: Response) => {
|
||||
return res.json(rows);
|
||||
});
|
||||
|
||||
// ─── LTE / cellular (discussion #85) ─────────────────────────────────────────
|
||||
|
||||
// GET /api/devices/:id/lte — current state of every LTE interface
|
||||
router.get('/:id/lte', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const interfaces = await query<Record<string, unknown>>(
|
||||
`SELECT 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, updated_at
|
||||
FROM lte_interfaces WHERE device_id = $1 ORDER BY interface_name`,
|
||||
[req.params.id],
|
||||
);
|
||||
|
||||
// Bands seen serving this device. This is what a band lock gets checked
|
||||
// against, since a scan interrupts service and finds nothing at a site with
|
||||
// a single base station.
|
||||
const observed = await query<{ interface_name: string; band: number; observations: number; last_seen: string }>(
|
||||
`SELECT interface_name, band, observations, last_seen
|
||||
FROM lte_observed_bands WHERE device_id = $1 ORDER BY band`,
|
||||
[req.params.id],
|
||||
);
|
||||
|
||||
res.json({
|
||||
interfaces: interfaces.map(i => {
|
||||
const carriers = {
|
||||
primaryBand: (i.primary_band as LteBandInfo | null) ?? null,
|
||||
caBands: (i.ca_bands as LteBandInfo[] | null) ?? [],
|
||||
};
|
||||
return {
|
||||
...i,
|
||||
// Reconstruct the interpreted view so the client never re-derives it.
|
||||
allowed_bands: parseBandList(i.allowed_bands as string | null),
|
||||
total_bandwidth_mhz: totalBandwidthMhz(carriers),
|
||||
// Uplink rides the primary carrier alone, so an anchor narrower than
|
||||
// what is being aggregated caps upload however wide the downlink is.
|
||||
uplink_anchor: uplinkAnchor(carriers),
|
||||
observed_bands: observed
|
||||
.filter(o => o.interface_name === i.interface_name)
|
||||
.map(o => ({ band: o.band, observations: o.observations, last_seen: o.last_seen })),
|
||||
};
|
||||
}),
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error fetching LTE state:', error);
|
||||
res.status(500).json({ error: 'Failed to fetch LTE state' });
|
||||
}
|
||||
});
|
||||
|
||||
// GET /api/devices/:id/lte/history — handovers, re-registrations and band changes
|
||||
router.get('/:id/lte/history', async (req: Request, res: Response) => {
|
||||
const { range = '24h', limit = '200' } = req.query as { range?: string; limit?: string };
|
||||
const windows: Record<string, string> = {
|
||||
'1h': '1 hour', '24h': '24 hours', '7d': '7 days', '30d': '30 days',
|
||||
};
|
||||
const window = windows[range] || '24 hours';
|
||||
const cap = Math.min(Math.max(parseInt(limit, 10) || 200, 1), 1000);
|
||||
|
||||
try {
|
||||
const events = await query(
|
||||
`SELECT interface_name, at, kind, detail, cell_id, enb_id, bands, rsrp, sinr
|
||||
FROM lte_cell_history
|
||||
WHERE device_id = $1 AND at > NOW() - $2::interval
|
||||
ORDER BY at DESC LIMIT $3`,
|
||||
[req.params.id, window, cap],
|
||||
);
|
||||
res.json({ events });
|
||||
} catch (error) {
|
||||
console.error('Error fetching LTE history:', error);
|
||||
res.status(500).json({ error: 'Failed to fetch LTE history' });
|
||||
}
|
||||
});
|
||||
|
||||
// GET /api/devices/:id/lte/metrics — InfluxDB lte_signal time series
|
||||
router.get('/:id/lte/metrics', async (req: Request, res: Response) => {
|
||||
const { iface, range = '6h' } = req.query as { iface?: string; range?: string };
|
||||
|
||||
const ranges: Record<string, string> = {
|
||||
'1h': '1h', '6h': '6h', '24h': '24h', '7d': '7d', '30d': '30d',
|
||||
};
|
||||
const fluxRange = ranges[range] || '6h';
|
||||
// Keep roughly 200 points regardless of window, so a month is as readable as an hour.
|
||||
const windows: Record<string, string> = {
|
||||
'1h': '30s', '6h': '2m', '24h': '10m', '7d': '1h', '30d': '4h',
|
||||
};
|
||||
|
||||
const { getQueryApi } = await import('../config/influxdb');
|
||||
const queryApi = getQueryApi();
|
||||
const bucket = process.env.INFLUXDB_BUCKET || 'mikrotik';
|
||||
|
||||
const ifaceFilter = iface
|
||||
? `|> filter(fn: (r) => r["interface"] == "${iface.replace(/["\\]/g, '')}")`
|
||||
: '';
|
||||
|
||||
const flux = `
|
||||
from(bucket: "${bucket}")
|
||||
|> range(start: -${fluxRange})
|
||||
|> filter(fn: (r) => r["_measurement"] == "lte_signal")
|
||||
|> filter(fn: (r) => r["device_id"] == "${String(req.params.id).replace(/[^0-9]/g, '')}")
|
||||
${ifaceFilter}
|
||||
|> filter(fn: (r) => r["_field"] == "rsrp" or r["_field"] == "rsrq"
|
||||
or r["_field"] == "sinr" or r["_field"] == "rssi"
|
||||
or r["_field"] == "cqi" or r["_field"] == "carriers")
|
||||
|> aggregateWindow(every: ${windows[fluxRange] || '2m'}, fn: mean, createEmpty: false)
|
||||
// Drop the tag columns before pivoting. Serving band is a tag, so leaving
|
||||
// it in splits the result into one table per band — which streams back as
|
||||
// a run of B1 rows followed by a run of B3 rows, and draws a chart that
|
||||
// jumps backwards in time at the seam.
|
||||
|> keep(columns: ["_time", "_field", "_value", "interface"])
|
||||
|> group(columns: ["interface"])
|
||||
|> pivot(rowKey:["_time","interface"], columnKey: ["_field"], valueColumn: "_value")
|
||||
|> sort(columns: ["_time"])
|
||||
`;
|
||||
|
||||
try {
|
||||
const num = (v: unknown) => (v == null ? undefined : Math.round(Number(v) * 10) / 10);
|
||||
const rows: Record<string, unknown>[] = [];
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
queryApi.queryRows(flux, {
|
||||
next(row, tableMeta) {
|
||||
const obj = tableMeta.toObject(row) as Record<string, unknown>;
|
||||
rows.push({
|
||||
time: String(obj['_time'] || ''),
|
||||
interface: String(obj['interface'] || ''),
|
||||
rsrp: num(obj['rsrp']), rsrq: num(obj['rsrq']),
|
||||
sinr: num(obj['sinr']), rssi: num(obj['rssi']),
|
||||
cqi: num(obj['cqi']), carriers: num(obj['carriers']),
|
||||
});
|
||||
},
|
||||
error: reject,
|
||||
complete: resolve,
|
||||
});
|
||||
});
|
||||
// One table per interface still streams sequentially, so order globally.
|
||||
rows.sort((a, b) => String(a.time).localeCompare(String(b.time)));
|
||||
res.json({ metrics: rows });
|
||||
} catch (error) {
|
||||
console.error('Error fetching LTE metrics:', error);
|
||||
res.status(500).json({ error: 'Failed to fetch LTE metrics' });
|
||||
}
|
||||
});
|
||||
|
||||
// GET /api/devices/:id/wireless/metrics — InfluxDB wireless_stats time series
|
||||
router.get('/:id/wireless/metrics', async (req: Request, res: Response) => {
|
||||
const { iface, range = '6h' } = req.query as { iface?: string; range?: string };
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,97 @@
|
||||
# Field capture — MikroTik ATL 18 (Quectel EG18-EA, Cat-18)
|
||||
|
||||
Contributed by @trackersoft (Bulgaria, VIVACOM) in discussion #85, 2026-08-23.
|
||||
Two captures: a CLI session, and a WinBox view of the same device carrying four
|
||||
aggregated carriers.
|
||||
Sensitive identifiers were redacted by the contributor. RouterOS 7.24 stable, arm64.
|
||||
|
||||
Kept verbatim because every LTE parsing decision in `utils/lte.ts` is calibrated
|
||||
against it. Field *names* here differ from MikroTik's documentation in several
|
||||
places (`data-class` not `access-technology`, `iccid` not `uicc`, `status: running`
|
||||
not `connected`), and several documented fields are absent entirely — what a modem
|
||||
reports depends on the chipset, not on RouterOS.
|
||||
|
||||
```
|
||||
[user@MikroTik-LTE] > /interface/lte/monitor lte1 once
|
||||
status: running
|
||||
model: EG18-EA
|
||||
revision: EG18EAPAR01A14M4G
|
||||
current-operator: VIVACOM
|
||||
current-cellid: xxxxxx
|
||||
enb-id: xxxx
|
||||
sector-id: xx
|
||||
phy-cellid: xxx
|
||||
data-class: LTE
|
||||
session-uptime: 3d7h17m59s
|
||||
imei: xxxxxxxxxxxxxxx
|
||||
imsi: xxxxxxxxxxxxxxx
|
||||
iccid: xxxxxxxxxxxxxxxxxxxx
|
||||
primary-band: B1@20Mhz earfcn: 500 phy-cellid: 190
|
||||
ca-band: B3@20Mhz earfcn: 1800 phy-cellid: 190
|
||||
dl-modulation: 256qam
|
||||
cqi: 15
|
||||
ri: 2
|
||||
mcs: 20
|
||||
rssi: -69dBm
|
||||
rsrp: -97dBm
|
||||
rsrq: -9dB
|
||||
sinr: 17dB
|
||||
|
||||
[user@MikroTik-LTE] > /interface/lte/print detail
|
||||
Flags: R - RUNNING
|
||||
0 R default-name="lte1" name="lte1" mtu=1500 advertised-mtu=1500 apn-profiles=default
|
||||
allow-roaming=no sms-read=no sms-protocol=auto network-mode=lte band=1,3,7
|
||||
|
||||
[user@MikroTik-LTE] > /system/resource/print
|
||||
board-name: ATLGM version: 7.24 (stable) cpu: ARM64
|
||||
free-memory: 157.8MiB total-memory: 256.0MiB
|
||||
free-hdd-space: 1160.0KiB total-hdd-space: 16.0MiB
|
||||
|
||||
[user@MikroTik-LTE] > /interface/lte/scan lte1 duration=10
|
||||
(empty)
|
||||
```
|
||||
|
||||
## Second capture — four aggregated carriers (WinBox)
|
||||
|
||||
The same interface at a moment of 4-carrier aggregation. Reproduced here because
|
||||
it is the case that proves repeated attributes must survive parsing.
|
||||
|
||||
```
|
||||
Current Operator: VIVACOM
|
||||
Data Class: LTE
|
||||
Primary Band: B3@20Mhz earfcn: 1800 phy-cellid: 36
|
||||
CA Band: B1@20Mhz earfcn: 500 phy-cellid: 36
|
||||
B7@20Mhz earfcn: 3150 phy-cellid: 36
|
||||
B20@10Mhz earfcn: 6300 phy-cellid: 36
|
||||
Session Uptime: 00:02:51
|
||||
RSSI: -40 dBm RSRP: -70 dBm
|
||||
SINR: 13 dB RSRQ: -9.0 dB
|
||||
CQI: 14 RI: 2 MCS: 0
|
||||
```
|
||||
|
||||
- **`ca-band` repeats once per carrier.** Keeping only the last value yields
|
||||
`B3 + B20` — 30 MHz — on a device actually running 70 MHz, and the narrowest
|
||||
carrier is the one that arrives last, so the error is severe rather than subtle.
|
||||
- **`Session Uptime` renders as a clock here** but as `3d7h17m59s` over the CLI,
|
||||
so both forms are parsed.
|
||||
- **`MCS: 0` is a reading, not a blank**, on an otherwise healthy link. It must
|
||||
not be treated as missing, and must not feed the quality grade.
|
||||
- **Uplink rides the primary carrier alone.** Left on automatic this modem anchors
|
||||
to whichever band is loudest — often a 10 MHz B20 or B28 — and aggregates the
|
||||
wider bands for downlink only, which caps upload. Excluding the narrow bands to
|
||||
move the anchor is the contributor's actual reason for locking bands, and the
|
||||
reason `uplinkAnchor()` exists.
|
||||
|
||||
## Notes that shaped the implementation
|
||||
|
||||
- **Units live inside values** (`-69dBm`, `-9dB`, `17dB`), so every signal read strips them.
|
||||
- **`primary-band` / `ca-band` are composite strings**, not structured fields.
|
||||
- **The scan returned nothing because the site has one base station**, 10 km away —
|
||||
not because the modem cannot scan. Scanning is still not a dependable source: it
|
||||
interrupts service and finds nothing at exactly the remote sites that most need a
|
||||
band-lock warning. Observed-band history replaces it.
|
||||
- **`session-uptime` resets on re-registration**, which makes session drops and
|
||||
handovers detectable by polling alone — this modem may log nothing.
|
||||
- RSRP −97 dBm reads as mediocre, yet CQI 15 / RI 2 / 256QAM is the modem at its
|
||||
ceiling. Raw RSRP alone misleads; the interpreted view exists because of this.
|
||||
- 1160 KiB free of 16 MiB flash: a Change Guard restore point cannot be assumed to fit.
|
||||
@@ -0,0 +1,451 @@
|
||||
import {
|
||||
parseSignalValue, parseIntField, parseDurationSeconds, parseBandList, formatBandList,
|
||||
parseBandSpec, parseBandSpecs, parseLteMonitor, isConnected,
|
||||
classifyRsrp, classifyRsrq, classifySinr, overallQuality, describeQuality,
|
||||
detectChanges, observedBands, unprovenBands, totalBandwidthMhz, uplinkAnchor,
|
||||
REPEATED_KEY, LteStatus,
|
||||
} from '../lte';
|
||||
|
||||
/**
|
||||
* Verbatim from a MikroTik ATL 18 (Quectel EG18-EA, Cat-18) on VIVACOM,
|
||||
* RouterOS 7.24 — see fixtures/lte-atl18.md. Identifiers redacted at source.
|
||||
*/
|
||||
const ATL18: Record<string, string> = {
|
||||
'status': 'running',
|
||||
'model': 'EG18-EA',
|
||||
'revision': 'EG18EAPAR01A14M4G',
|
||||
'current-operator': 'VIVACOM',
|
||||
'current-cellid': '123456',
|
||||
'enb-id': '1234',
|
||||
'sector-id': '12',
|
||||
'phy-cellid': '190',
|
||||
'data-class': 'LTE',
|
||||
'session-uptime': '3d7h17m59s',
|
||||
'primary-band': 'B1@20Mhz earfcn: 500 phy-cellid: 190',
|
||||
'ca-band': 'B3@20Mhz earfcn: 1800 phy-cellid: 190',
|
||||
'dl-modulation': '256qam',
|
||||
'cqi': '15',
|
||||
'ri': '2',
|
||||
'mcs': '20',
|
||||
'rssi': '-69dBm',
|
||||
'rsrp': '-97dBm',
|
||||
'rsrq': '-9dB',
|
||||
'sinr': '17dB',
|
||||
};
|
||||
|
||||
/**
|
||||
* The same device carrying four aggregated carriers, from a WinBox capture in
|
||||
* discussion #85. Primary B3 with three CA rows — the shape that proves repeated
|
||||
* attributes must survive parsing, since the *last* of them is the narrowest.
|
||||
*/
|
||||
const FOUR_CARRIER: Record<string, string> = {
|
||||
'status': 'running',
|
||||
'data-class': 'LTE',
|
||||
'current-operator': 'VIVACOM',
|
||||
'primary-band': 'B3@20Mhz earfcn: 1800 phy-cellid: 36',
|
||||
'ca-band': 'B20@10Mhz earfcn: 6300 phy-cellid: 36',
|
||||
[REPEATED_KEY]: JSON.stringify({
|
||||
'ca-band': [
|
||||
'B1@20Mhz earfcn: 500 phy-cellid: 36',
|
||||
'B7@20Mhz earfcn: 3150 phy-cellid: 36',
|
||||
'B20@10Mhz earfcn: 6300 phy-cellid: 36',
|
||||
],
|
||||
}),
|
||||
'rssi': '-40dBm', 'rsrp': '-70dBm', 'rsrq': '-9.0dB', 'sinr': '13dB',
|
||||
'cqi': '14', 'ri': '2', 'mcs': '0',
|
||||
'session-uptime': '00:02:51',
|
||||
};
|
||||
|
||||
// ── scalars ───────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('parseSignalValue', () => {
|
||||
it('strips the unit glued to the value', () => {
|
||||
expect(parseSignalValue('-97dBm')).toBe(-97);
|
||||
expect(parseSignalValue('-9dB')).toBe(-9);
|
||||
expect(parseSignalValue('17dB')).toBe(17);
|
||||
});
|
||||
|
||||
it('accepts a bare number and decimals', () => {
|
||||
expect(parseSignalValue('-69')).toBe(-69);
|
||||
expect(parseSignalValue('17.5dB')).toBe(17.5);
|
||||
});
|
||||
|
||||
it('returns null rather than 0 for what an idle modem reports', () => {
|
||||
// Zero would draw a signal stronger than any transmitter produces.
|
||||
for (const junk of ['', ' ', '-', 'unknown', 'n/a', undefined, null]) {
|
||||
expect(parseSignalValue(junk as string)).toBeNull();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseIntField', () => {
|
||||
it('reads plain integers', () => {
|
||||
expect(parseIntField('15')).toBe(15);
|
||||
expect(parseIntField('0')).toBe(0);
|
||||
});
|
||||
it('rejects blanks and non-numerics', () => {
|
||||
expect(parseIntField('')).toBeNull();
|
||||
expect(parseIntField('none')).toBeNull();
|
||||
expect(parseIntField(undefined)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseDurationSeconds', () => {
|
||||
it('parses the captured session uptime', () => {
|
||||
// 3d7h17m59s
|
||||
expect(parseDurationSeconds('3d7h17m59s')).toBe(3 * 86400 + 7 * 3600 + 17 * 60 + 59);
|
||||
});
|
||||
it('handles weeks and partial forms', () => {
|
||||
expect(parseDurationSeconds('1w2d')).toBe(604800 + 172800);
|
||||
expect(parseDurationSeconds('45s')).toBe(45);
|
||||
});
|
||||
it('accepts the clock form WinBox renders', () => {
|
||||
expect(parseDurationSeconds('00:02:51')).toBe(171);
|
||||
expect(parseDurationSeconds('13:45:00')).toBe(49500);
|
||||
});
|
||||
|
||||
it('rejects junk', () => {
|
||||
expect(parseDurationSeconds('soon')).toBeNull();
|
||||
expect(parseDurationSeconds('')).toBeNull();
|
||||
expect(parseDurationSeconds('99:99:99')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseBandList / formatBandList', () => {
|
||||
it('parses the configured allow-list', () => {
|
||||
expect(parseBandList('1,3,7')).toEqual([1, 3, 7]);
|
||||
});
|
||||
it('tolerates spacing and B prefixes', () => {
|
||||
expect(parseBandList(' B1 , b3 ,7 ')).toEqual([1, 3, 7]);
|
||||
});
|
||||
it('is empty when unset — meaning auto, not none', () => {
|
||||
expect(parseBandList('')).toEqual([]);
|
||||
expect(parseBandList(undefined)).toEqual([]);
|
||||
});
|
||||
it('round-trips sorted and deduplicated', () => {
|
||||
expect(formatBandList([7, 1, 3, 1])).toBe('1,3,7');
|
||||
});
|
||||
});
|
||||
|
||||
// ── bands ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('parseBandSpec', () => {
|
||||
it('parses the captured primary band', () => {
|
||||
expect(parseBandSpec('B1@20Mhz earfcn: 500 phy-cellid: 190')).toEqual({
|
||||
band: 1, bandwidthMhz: 20, earfcn: 500, phyCellId: 190,
|
||||
raw: 'B1@20Mhz earfcn: 500 phy-cellid: 190',
|
||||
});
|
||||
});
|
||||
|
||||
it('parses a bare band number, since only that is universal', () => {
|
||||
const b = parseBandSpec('B7')!;
|
||||
expect(b.band).toBe(7);
|
||||
expect(b.bandwidthMhz).toBeNull();
|
||||
expect(b.earfcn).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null when there is no band at all', () => {
|
||||
expect(parseBandSpec('')).toBeNull();
|
||||
expect(parseBandSpec('unknown')).toBeNull();
|
||||
expect(parseBandSpec(undefined)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseBandSpecs — carrier aggregation', () => {
|
||||
it('reads the single CA carrier in the capture', () => {
|
||||
const ca = parseBandSpecs(ATL18, 'ca-band');
|
||||
expect(ca).toHaveLength(1);
|
||||
expect(ca[0].band).toBe(3);
|
||||
expect(ca[0].earfcn).toBe(1800);
|
||||
});
|
||||
|
||||
it('recovers every carrier from repeated attributes', () => {
|
||||
// A Cat-18 modem aggregating three carriers: the API client preserves the
|
||||
// repeats, which a plain object would otherwise collapse to the last one.
|
||||
const row: Record<string, string> = {
|
||||
'ca-band': 'B7@20Mhz earfcn: 3350 phy-cellid: 191',
|
||||
[REPEATED_KEY]: JSON.stringify({
|
||||
'ca-band': [
|
||||
'B3@20Mhz earfcn: 1800 phy-cellid: 190',
|
||||
'B7@20Mhz earfcn: 3350 phy-cellid: 191',
|
||||
],
|
||||
}),
|
||||
};
|
||||
expect(parseBandSpecs(row, 'ca-band').map(b => b.band)).toEqual([3, 7]);
|
||||
});
|
||||
|
||||
it('recovers carriers concatenated into one value', () => {
|
||||
const row = { 'ca-band': 'B3@20Mhz earfcn: 1800 B7@10Mhz earfcn: 3350' };
|
||||
const ca = parseBandSpecs(row, 'ca-band');
|
||||
expect(ca.map(b => b.band)).toEqual([3, 7]);
|
||||
expect(ca[1].bandwidthMhz).toBe(10);
|
||||
});
|
||||
|
||||
it('deduplicates a carrier reported through both channels', () => {
|
||||
const row: Record<string, string> = {
|
||||
'ca-band': 'B3@20Mhz earfcn: 1800 phy-cellid: 190',
|
||||
[REPEATED_KEY]: JSON.stringify({ 'ca-band': ['B3@20Mhz earfcn: 1800 phy-cellid: 190'] }),
|
||||
};
|
||||
expect(parseBandSpecs(row, 'ca-band')).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('falls back to the plain value when the side-channel is malformed', () => {
|
||||
const row: Record<string, string> = { 'ca-band': 'B3@20Mhz', [REPEATED_KEY]: '{not json' };
|
||||
expect(parseBandSpecs(row, 'ca-band').map(b => b.band)).toEqual([3]);
|
||||
});
|
||||
|
||||
it('is empty when the modem reports no aggregation', () => {
|
||||
expect(parseBandSpecs({ 'primary-band': 'B1@20Mhz' }, 'ca-band')).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
// ── monitor ───────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('parseLteMonitor', () => {
|
||||
const s = parseLteMonitor(ATL18);
|
||||
|
||||
it('reads the capture end to end', () => {
|
||||
expect(s.status).toBe('running');
|
||||
expect(s.modemModel).toBe('EG18-EA');
|
||||
expect(s.operator).toBe('VIVACOM');
|
||||
expect(s.rsrp).toBe(-97);
|
||||
expect(s.rsrq).toBe(-9);
|
||||
expect(s.sinr).toBe(17);
|
||||
expect(s.rssi).toBe(-69);
|
||||
expect(s.cqi).toBe(15);
|
||||
expect(s.ri).toBe(2);
|
||||
expect(s.mcs).toBe(20);
|
||||
expect(s.dlModulation).toBe('256qam');
|
||||
expect(s.primaryBand!.band).toBe(1);
|
||||
expect(s.caBands.map(b => b.band)).toEqual([3]);
|
||||
expect(s.sessionUptimeSeconds).toBe(285479);
|
||||
});
|
||||
|
||||
it('takes the access technology from data-class, not the documented name', () => {
|
||||
expect(s.dataClass).toBe('LTE');
|
||||
});
|
||||
|
||||
it('still accepts access-technology from modems that use it', () => {
|
||||
expect(parseLteMonitor({ 'access-technology': 'Evolved 3G' }).dataClass).toBe('Evolved 3G');
|
||||
});
|
||||
|
||||
it('survives a reply with nothing in it', () => {
|
||||
const empty = parseLteMonitor({});
|
||||
expect(empty.rsrp).toBeNull();
|
||||
expect(empty.primaryBand).toBeNull();
|
||||
expect(empty.caBands).toEqual([]);
|
||||
expect(empty.status).toBeNull();
|
||||
});
|
||||
|
||||
it('survives a modem that is registering — keys present, values blank', () => {
|
||||
const registering = parseLteMonitor({
|
||||
'status': 'searching', 'rsrp': '', 'rsrq': '', 'sinr': '',
|
||||
'primary-band': '', 'session-uptime': '',
|
||||
});
|
||||
expect(registering.status).toBe('searching');
|
||||
expect(registering.rsrp).toBeNull();
|
||||
expect(registering.primaryBand).toBeNull();
|
||||
expect(isConnected(registering)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isConnected', () => {
|
||||
it('accepts running, which is what this modem family reports', () => {
|
||||
expect(isConnected(parseLteMonitor(ATL18))).toBe(true);
|
||||
});
|
||||
it('accepts connected, which others report', () => {
|
||||
expect(isConnected(parseLteMonitor({ status: 'connected' }))).toBe(true);
|
||||
});
|
||||
it('rejects anything else', () => {
|
||||
expect(isConnected(parseLteMonitor({ status: 'searching' }))).toBe(false);
|
||||
expect(isConnected(parseLteMonitor({}))).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ── quality ───────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('signal classification', () => {
|
||||
it('places RSRP on the standard operating bands', () => {
|
||||
expect(classifyRsrp(-75)).toBe('excellent');
|
||||
expect(classifyRsrp(-85)).toBe('good');
|
||||
expect(classifyRsrp(-97)).toBe('fair');
|
||||
expect(classifyRsrp(-110)).toBe('poor');
|
||||
expect(classifyRsrp(null)).toBe('unknown');
|
||||
});
|
||||
|
||||
it('places RSRQ and SINR likewise', () => {
|
||||
expect(classifyRsrq(-9)).toBe('excellent');
|
||||
expect(classifyRsrq(-18)).toBe('fair');
|
||||
expect(classifySinr(25)).toBe('excellent');
|
||||
expect(classifySinr(17)).toBe('good');
|
||||
expect(classifySinr(5)).toBe('fair');
|
||||
expect(classifySinr(-3)).toBe('poor');
|
||||
});
|
||||
});
|
||||
|
||||
describe('overallQuality', () => {
|
||||
it('does not let a quiet cell alone condemn a clean link', () => {
|
||||
// The captured device: RSRP -97 ("fair") but SINR 17 with CQI 15, RI 2 and
|
||||
// 256QAM — the modem at its ceiling. Reporting "fair" would misinform.
|
||||
expect(overallQuality(parseLteMonitor(ATL18))).toBe('good');
|
||||
});
|
||||
|
||||
it('still reports poorly when the signal really is poor', () => {
|
||||
expect(overallQuality(parseLteMonitor({ rsrp: '-115dBm', sinr: '-2dB' }))).toBe('poor');
|
||||
});
|
||||
|
||||
it('falls back to whichever measure exists', () => {
|
||||
expect(overallQuality(parseLteMonitor({ rsrp: '-85dBm' }))).toBe('good');
|
||||
expect(overallQuality(parseLteMonitor({ sinr: '25dB' }))).toBe('excellent');
|
||||
expect(overallQuality(parseLteMonitor({}))).toBe('unknown');
|
||||
});
|
||||
|
||||
it('is dragged down by a genuinely weak cell', () => {
|
||||
// A clean channel with almost no signal has no fading margin, so however
|
||||
// good the SINR, this cannot be graded above one step over the RSRP.
|
||||
expect(overallQuality(parseLteMonitor({ rsrp: '-112dBm', sinr: '22dB' }))).toBe('fair');
|
||||
});
|
||||
});
|
||||
|
||||
describe('describeQuality', () => {
|
||||
it('counts the primary carrier in the aggregation total', () => {
|
||||
expect(describeQuality(parseLteMonitor(ATL18))).toContain('Aggregating 2 carriers');
|
||||
});
|
||||
it('says so plainly when the modem is not attached', () => {
|
||||
expect(describeQuality(parseLteMonitor({ status: 'searching' }))).toBe('Not connected');
|
||||
});
|
||||
});
|
||||
|
||||
// ── change detection ──────────────────────────────────────────────────────────
|
||||
|
||||
const at = (over: Partial<Record<string, string>>): LteStatus =>
|
||||
parseLteMonitor({ ...ATL18, ...over } as Record<string, string>);
|
||||
|
||||
describe('detectChanges', () => {
|
||||
it('reports nothing on the first poll', () => {
|
||||
expect(detectChanges(null, at({}))).toEqual([]);
|
||||
});
|
||||
|
||||
it('reports nothing when the link is steady', () => {
|
||||
expect(detectChanges(at({}), at({ 'session-uptime': '3d7h18m29s' }))).toEqual([]);
|
||||
});
|
||||
|
||||
it('sees a re-registration when uptime runs backwards', () => {
|
||||
const changes = detectChanges(at({}), at({ 'session-uptime': '45s' }));
|
||||
expect(changes.map(c => c.kind)).toContain('session-reset');
|
||||
});
|
||||
|
||||
it('sees a handover when the cell id changes', () => {
|
||||
const changes = detectChanges(at({}), at({ 'current-cellid': '999999' }));
|
||||
expect(changes.map(c => c.kind)).toContain('handover');
|
||||
});
|
||||
|
||||
it('sees a band change, including one that only drops a CA carrier', () => {
|
||||
const dropped = parseLteMonitor(
|
||||
Object.fromEntries(Object.entries(ATL18).filter(([k]) => k !== 'ca-band')),
|
||||
);
|
||||
const changes = detectChanges(at({}), dropped);
|
||||
expect(changes.map(c => c.kind)).toEqual(['band-change']);
|
||||
expect(changes[0].detail).toContain('from 1,3 to 1');
|
||||
});
|
||||
|
||||
it('reports a reset and a handover together when the modem moves tower', () => {
|
||||
const kinds = detectChanges(at({}), at({ 'session-uptime': '10s', 'current-cellid': '888888' }))
|
||||
.map(c => c.kind);
|
||||
expect(kinds).toEqual(['session-reset', 'handover']);
|
||||
});
|
||||
});
|
||||
|
||||
// ── observed bands ────────────────────────────────────────────────────────────
|
||||
|
||||
describe('observedBands / unprovenBands', () => {
|
||||
it('collects every band seen, primary and aggregated', () => {
|
||||
const history = [at({}), at({ 'primary-band': 'B7@10Mhz earfcn: 3350' })];
|
||||
expect(observedBands(history)).toEqual([1, 3, 7]);
|
||||
});
|
||||
|
||||
it('passes a lock that history supports', () => {
|
||||
expect(unprovenBands([1, 3], [1, 3, 7])).toEqual([]);
|
||||
});
|
||||
|
||||
it('flags a band never once seen serving this device', () => {
|
||||
// Locking to B20 here strands a device that has only ever used 1, 3 and 7.
|
||||
expect(unprovenBands([1, 20], [1, 3, 7])).toEqual([20]);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
// ── four-carrier aggregation ──────────────────────────────────────────────────
|
||||
|
||||
describe('four-carrier capture', () => {
|
||||
const s = parseLteMonitor(FOUR_CARRIER);
|
||||
|
||||
it('recovers every aggregated carrier, not just the last reported', () => {
|
||||
expect(s.primaryBand!.band).toBe(3);
|
||||
expect(s.caBands.map(b => b.band)).toEqual([1, 7, 20]);
|
||||
});
|
||||
|
||||
it('reports the true aggregate bandwidth', () => {
|
||||
// 20 + 20 + 20 + 10. Keeping only the final `ca-band` would show 30 MHz —
|
||||
// and it is the narrowest carrier that arrives last, so the error is severe.
|
||||
expect(totalBandwidthMhz(s)).toBe(70);
|
||||
});
|
||||
|
||||
it('keeps MCS 0, which is a reading rather than a blank', () => {
|
||||
expect(s.mcs).toBe(0);
|
||||
expect(s.cqi).toBe(14);
|
||||
});
|
||||
|
||||
it('parses the clock-form session uptime', () => {
|
||||
expect(s.sessionUptimeSeconds).toBe(171);
|
||||
});
|
||||
|
||||
it('grades a loud but noisy cell on its SINR', () => {
|
||||
// RSRP -70 is excellent, SINR 13 only good; the weaker measure must lead.
|
||||
expect(overallQuality(s)).toBe('good');
|
||||
});
|
||||
|
||||
it('counts all four carriers in the description', () => {
|
||||
expect(describeQuality(s)).toContain('Aggregating 4 carriers');
|
||||
});
|
||||
});
|
||||
|
||||
describe('uplinkAnchor', () => {
|
||||
it('says nothing when the anchor is already the widest carrier', () => {
|
||||
// The primary here is B3@20 and no aggregated carrier beats it.
|
||||
expect(uplinkAnchor(parseLteMonitor(FOUR_CARRIER))).toBeNull();
|
||||
expect(uplinkAnchor(parseLteMonitor(ATL18))).toBeNull();
|
||||
});
|
||||
|
||||
it('flags an anchor narrower than what is being aggregated', () => {
|
||||
// Left on auto this modem anchors to the loudest band — often a 10 MHz one —
|
||||
// and uplink rides the primary alone, so upload is capped at 10 MHz.
|
||||
const anchored = parseLteMonitor({
|
||||
'primary-band': 'B20@10Mhz earfcn: 6300',
|
||||
'ca-band': 'B3@20Mhz earfcn: 1800',
|
||||
[REPEATED_KEY]: JSON.stringify({
|
||||
'ca-band': ['B1@20Mhz earfcn: 500', 'B3@20Mhz earfcn: 1800'],
|
||||
}),
|
||||
});
|
||||
expect(uplinkAnchor(anchored)).toEqual({
|
||||
primaryMhz: 10, primaryBand: 20, widestMhz: 20, widestBand: 1,
|
||||
});
|
||||
});
|
||||
|
||||
it('says nothing when bandwidth is unreported', () => {
|
||||
expect(uplinkAnchor(parseLteMonitor({ 'primary-band': 'B3', 'ca-band': 'B1' }))).toBeNull();
|
||||
});
|
||||
|
||||
it('says nothing when there is no aggregation at all', () => {
|
||||
expect(uplinkAnchor(parseLteMonitor({ 'primary-band': 'B3@20Mhz' }))).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('totalBandwidthMhz', () => {
|
||||
it('sums primary and aggregated carriers', () => {
|
||||
expect(totalBandwidthMhz(parseLteMonitor(ATL18))).toBe(40);
|
||||
});
|
||||
it('is zero when no bandwidth is reported', () => {
|
||||
expect(totalBandwidthMhz(parseLteMonitor({}))).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,459 @@
|
||||
/**
|
||||
* LTE modem telemetry: parsing, quality interpretation and change detection.
|
||||
*
|
||||
* Everything here is calibrated against a real field capture from a MikroTik
|
||||
* ATL 18 (Quectel EG18-EA, Cat-18) — see `__tests__/fixtures/lte-atl18.md`.
|
||||
* That capture matters because **what an LTE interface reports depends on the
|
||||
* modem chipset, not on RouterOS**: the documented `access-technology` arrives
|
||||
* as `data-class`, the documented `uicc` as `iccid`, and `registration-status`,
|
||||
* `pin-status`, `functionality` and `lac`/`tac` are simply absent. Any field
|
||||
* read here is therefore optional, always.
|
||||
*
|
||||
* Two shapes need real parsing rather than a cast:
|
||||
*
|
||||
* **Units live inside values.** `rsrp: -97dBm`, `rsrq: -9dB`, `sinr: 17dB` —
|
||||
* the suffix is part of the string, and differs per field.
|
||||
*
|
||||
* **Band information is a composite string**, not a structured record:
|
||||
* `primary-band: B1@20Mhz earfcn: 500 phy-cellid: 190`. Band, bandwidth, EARFCN
|
||||
* and physical cell id all have to be pulled out of one value.
|
||||
*
|
||||
* Carrier aggregation is the subtle one. A Cat-18 modem aggregates several
|
||||
* carriers and reports one `ca-band` per carrier, so CA is inherently
|
||||
* multi-valued while an API sentence parsed into a plain object is not. Both
|
||||
* shapes are accepted here — repeated keys surfaced by the client, and several
|
||||
* carriers concatenated into a single value — because under-reporting
|
||||
* aggregation would misrepresent the link exactly when it is performing best.
|
||||
*/
|
||||
|
||||
/** One aggregated carrier, parsed out of a `primary-band` / `ca-band` value. */
|
||||
export interface LteBandInfo {
|
||||
/** E-UTRA band number, e.g. 1 for `B1@20Mhz`. */
|
||||
band: number;
|
||||
/** Channel bandwidth in MHz, when reported. */
|
||||
bandwidthMhz: number | null;
|
||||
/** E-UTRA absolute radio frequency channel number. */
|
||||
earfcn: number | null;
|
||||
/** Physical cell identity of this carrier. */
|
||||
phyCellId: number | null;
|
||||
/** The value as the modem wrote it, for display and for debugging. */
|
||||
raw: string;
|
||||
}
|
||||
|
||||
export type SignalQuality = 'excellent' | 'good' | 'fair' | 'poor' | 'unknown';
|
||||
|
||||
export interface LteStatus {
|
||||
status: string | null;
|
||||
/** Radio access technology — `data-class` on this modem family. */
|
||||
dataClass: string | null;
|
||||
modemModel: string | null;
|
||||
modemRevision: string | null;
|
||||
operator: string | null;
|
||||
cellId: string | null;
|
||||
enbId: string | null;
|
||||
sectorId: string | null;
|
||||
phyCellId: string | null;
|
||||
sessionUptimeSeconds: number | null;
|
||||
primaryBand: LteBandInfo | null;
|
||||
caBands: LteBandInfo[];
|
||||
rssi: number | null;
|
||||
rsrp: number | null;
|
||||
rsrq: number | null;
|
||||
sinr: number | null;
|
||||
cqi: number | null;
|
||||
/** Rank indicator — spatial streams currently in use. */
|
||||
ri: number | null;
|
||||
mcs: number | null;
|
||||
dlModulation: string | null;
|
||||
}
|
||||
|
||||
/** Reserved key under which the API client surfaces repeated attributes. */
|
||||
export const REPEATED_KEY = '.repeated';
|
||||
|
||||
// ─── Scalars ──────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Read a signal figure whose unit is glued to the number (`-97dBm`, `17dB`).
|
||||
*
|
||||
* Deliberately strict about what counts as a reading. A modem that has not
|
||||
* registered reports blanks, dashes or `unknown` rather than omitting the key,
|
||||
* and treating those as 0 would draw a client sitting at 0 dBm — a signal
|
||||
* stronger than any transmitter produces.
|
||||
*/
|
||||
export function parseSignalValue(raw: string | undefined | null): number | null {
|
||||
if (raw == null) return null;
|
||||
const m = String(raw).trim().match(/^(-?\d+(?:\.\d+)?)\s*(?:dBm|dB)?$/i);
|
||||
if (!m) return null;
|
||||
const n = parseFloat(m[1]);
|
||||
return Number.isFinite(n) ? n : null;
|
||||
}
|
||||
|
||||
/** Read a plain integer field, rejecting the blanks an idle modem reports. */
|
||||
export function parseIntField(raw: string | undefined | null): number | null {
|
||||
if (raw == null) return null;
|
||||
const m = String(raw).trim().match(/^-?\d+$/);
|
||||
if (!m) return null;
|
||||
const n = parseInt(m[0], 10);
|
||||
return Number.isFinite(n) ? n : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* RouterOS duration to seconds.
|
||||
*
|
||||
* Two forms are accepted. The API returns the compact `3d7h17m59s`, but the same
|
||||
* value renders as a `00:02:51` clock in WinBox, and a field that reads one way
|
||||
* in the GUI and another over the wire is not worth betting a blank display on.
|
||||
*/
|
||||
export function parseDurationSeconds(raw: string | undefined | null): number | null {
|
||||
if (raw == null) return null;
|
||||
const s = String(raw).trim();
|
||||
|
||||
const clock = s.match(/^(\d+):([0-5]\d):([0-5]\d)$/);
|
||||
if (clock) {
|
||||
return parseInt(clock[1], 10) * 3600 + parseInt(clock[2], 10) * 60 + parseInt(clock[3], 10);
|
||||
}
|
||||
|
||||
if (!/^(\d+[wdhms])+$/.test(s)) return null;
|
||||
const units: Record<string, number> = { w: 604800, d: 86400, h: 3600, m: 60, s: 1 };
|
||||
let total = 0;
|
||||
for (const [, n, u] of s.matchAll(/(\d+)([wdhms])/g)) total += parseInt(n, 10) * units[u];
|
||||
return total;
|
||||
}
|
||||
|
||||
/** Allowed-band list as configured on the interface: `band=1,3,7`. */
|
||||
export function parseBandList(raw: string | undefined | null): number[] {
|
||||
if (raw == null) return [];
|
||||
return String(raw)
|
||||
.split(',')
|
||||
.map(p => parseInt(p.trim().replace(/^[bB]/, ''), 10))
|
||||
.filter(n => Number.isFinite(n) && n > 0);
|
||||
}
|
||||
|
||||
/** Render a band list back into the form RouterOS expects. */
|
||||
export function formatBandList(bands: number[]): string {
|
||||
return [...new Set(bands)].sort((a, b) => a - b).join(',');
|
||||
}
|
||||
|
||||
// ─── Bands ────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Parse one carrier out of `B1@20Mhz earfcn: 500 phy-cellid: 190`.
|
||||
*
|
||||
* Bandwidth, EARFCN and cell id are all optional: the band number is the only
|
||||
* part every modem reports, so a value of bare `B7` still yields a carrier.
|
||||
*/
|
||||
export function parseBandSpec(raw: string | undefined | null): LteBandInfo | null {
|
||||
if (raw == null) return null;
|
||||
const text = String(raw).trim();
|
||||
if (!text) return null;
|
||||
|
||||
const band = text.match(/\bB(\d+)\b/i);
|
||||
if (!band) return null;
|
||||
|
||||
const bandwidth = text.match(/@\s*([\d.]+)\s*mhz/i);
|
||||
const earfcn = text.match(/earfcn:?\s*(\d+)/i);
|
||||
const phy = text.match(/phy-cellid:?\s*(\d+)/i);
|
||||
|
||||
return {
|
||||
band: parseInt(band[1], 10),
|
||||
bandwidthMhz: bandwidth ? parseFloat(bandwidth[1]) : null,
|
||||
earfcn: earfcn ? parseInt(earfcn[1], 10) : null,
|
||||
phyCellId: phy ? parseInt(phy[1], 10) : null,
|
||||
raw: text,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Collect every carrier reported under one key.
|
||||
*
|
||||
* A Cat-18 modem aggregating three carriers reports three `ca-band` values, but
|
||||
* an API sentence flattened into an object holds one value per key. Two shapes
|
||||
* therefore have to be handled: the repeated attributes the client preserves
|
||||
* under {@link REPEATED_KEY}, and several carriers concatenated into a single
|
||||
* value. Splitting before each `B<n>@` recovers the second case without
|
||||
* disturbing the first.
|
||||
*/
|
||||
export function parseBandSpecs(
|
||||
row: Record<string, string>,
|
||||
key: string,
|
||||
): LteBandInfo[] {
|
||||
const values: string[] = [];
|
||||
|
||||
const repeatedRaw = row[REPEATED_KEY];
|
||||
if (repeatedRaw) {
|
||||
try {
|
||||
const repeated = JSON.parse(repeatedRaw) as Record<string, string[]>;
|
||||
if (Array.isArray(repeated?.[key])) values.push(...repeated[key]);
|
||||
} catch {
|
||||
// A malformed side-channel must never cost us the primary value below.
|
||||
}
|
||||
}
|
||||
if (values.length === 0 && row[key] != null) values.push(row[key]);
|
||||
|
||||
const out: LteBandInfo[] = [];
|
||||
const seen = new Set<string>();
|
||||
for (const value of values) {
|
||||
// `B1@20Mhz earfcn: 500 B3@20Mhz earfcn: 1800` → one entry per carrier.
|
||||
for (const part of String(value).split(/\s*(?=B\d+@)/)) {
|
||||
const parsed = parseBandSpec(part);
|
||||
if (!parsed) continue;
|
||||
const dedupe = `${parsed.band}:${parsed.earfcn ?? ''}:${parsed.phyCellId ?? ''}`;
|
||||
if (seen.has(dedupe)) continue;
|
||||
seen.add(dedupe);
|
||||
out.push(parsed);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// ─── Monitor ──────────────────────────────────────────────────────────────────
|
||||
|
||||
/** Parse a `/interface/lte/monitor` reply. Every field is treated as optional. */
|
||||
export function parseLteMonitor(row: Record<string, string>): LteStatus {
|
||||
const caBands = parseBandSpecs(row, 'ca-band');
|
||||
const primary = parseBandSpecs(row, 'primary-band');
|
||||
|
||||
return {
|
||||
status: row['status'] || null,
|
||||
// `data-class` on Quectel-based modems; `access-technology` is the
|
||||
// documented name and appears on others. Accept either.
|
||||
dataClass: row['data-class'] || row['access-technology'] || null,
|
||||
modemModel: row['model'] || null,
|
||||
modemRevision: row['revision'] || null,
|
||||
operator: row['current-operator'] || row['operator-name'] || null,
|
||||
cellId: row['current-cellid'] || null,
|
||||
enbId: row['enb-id'] || null,
|
||||
sectorId: row['sector-id'] || null,
|
||||
phyCellId: row['phy-cellid'] || null,
|
||||
sessionUptimeSeconds: parseDurationSeconds(row['session-uptime']),
|
||||
primaryBand: primary[0] ?? null,
|
||||
caBands,
|
||||
rssi: parseSignalValue(row['rssi']),
|
||||
rsrp: parseSignalValue(row['rsrp']),
|
||||
rsrq: parseSignalValue(row['rsrq']),
|
||||
sinr: parseSignalValue(row['sinr']),
|
||||
cqi: parseIntField(row['cqi']),
|
||||
ri: parseIntField(row['ri']),
|
||||
mcs: parseIntField(row['mcs']),
|
||||
dlModulation: row['dl-modulation'] || null,
|
||||
};
|
||||
}
|
||||
|
||||
/** Is the modem attached and carrying traffic? */
|
||||
export function isConnected(status: LteStatus): boolean {
|
||||
const s = (status.status || '').toLowerCase();
|
||||
// `running` on this modem family, `connected` on others.
|
||||
return s === 'running' || s === 'connected';
|
||||
}
|
||||
|
||||
// ─── Quality interpretation ───────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Signal thresholds. These are the widely used 3GPP-derived operating bands, and
|
||||
* the whole point of carrying them: `-97 dBm` tells a user nothing, while "fair"
|
||||
* alongside it tells them where they stand.
|
||||
*/
|
||||
export function classifyRsrp(rsrp: number | null): SignalQuality {
|
||||
if (rsrp == null) return 'unknown';
|
||||
if (rsrp >= -80) return 'excellent';
|
||||
if (rsrp >= -90) return 'good';
|
||||
if (rsrp >= -100) return 'fair';
|
||||
return 'poor';
|
||||
}
|
||||
|
||||
export function classifyRsrq(rsrq: number | null): SignalQuality {
|
||||
if (rsrq == null) return 'unknown';
|
||||
if (rsrq >= -10) return 'excellent';
|
||||
if (rsrq >= -15) return 'good';
|
||||
if (rsrq >= -20) return 'fair';
|
||||
return 'poor';
|
||||
}
|
||||
|
||||
export function classifySinr(sinr: number | null): SignalQuality {
|
||||
if (sinr == null) return 'unknown';
|
||||
if (sinr >= 20) return 'excellent';
|
||||
if (sinr >= 13) return 'good';
|
||||
if (sinr >= 0) return 'fair';
|
||||
return 'poor';
|
||||
}
|
||||
|
||||
const QUALITY_RANK: Record<Exclude<SignalQuality, 'unknown'>, number> = {
|
||||
poor: 0, fair: 1, good: 2, excellent: 3,
|
||||
};
|
||||
|
||||
/**
|
||||
* Overall link quality.
|
||||
*
|
||||
* Deliberately **not** RSRP alone. The field capture that shaped this module
|
||||
* showed RSRP −97 dBm — "fair" — on a link running 256QAM at CQI 15 with two
|
||||
* spatial streams, which is the modem at its ceiling. RSRP measures how loud the
|
||||
* tower is; SINR measures whether the signal is usable, and it is the better
|
||||
* predictor of throughput. So SINR leads, RSRP moderates, and a modem reporting
|
||||
* neither stays honest about not knowing.
|
||||
*/
|
||||
export function overallQuality(status: LteStatus): SignalQuality {
|
||||
const sinr = classifySinr(status.sinr);
|
||||
const rsrp = classifyRsrp(status.rsrp);
|
||||
if (sinr === 'unknown') return rsrp;
|
||||
if (rsrp === 'unknown') return sinr;
|
||||
// SINR sets the grade; a quiet cell pulls it down but only one step, so a
|
||||
// clean link at −97 dBm reads "good" while a clean link at −112 reads "fair".
|
||||
const rank = Math.min(QUALITY_RANK[sinr], QUALITY_RANK[rsrp] + 1);
|
||||
return (Object.keys(QUALITY_RANK) as Exclude<SignalQuality, 'unknown'>[])
|
||||
.find(k => QUALITY_RANK[k] === rank) ?? 'unknown';
|
||||
}
|
||||
|
||||
/** Plain-language reading of the link, for users who don't speak dBm. */
|
||||
export function describeQuality(status: LteStatus): string {
|
||||
if (!isConnected(status)) return 'Not connected';
|
||||
const q = overallQuality(status);
|
||||
const carriers = status.caBands.length;
|
||||
const agg = carriers > 0
|
||||
? ` Aggregating ${carriers + 1} carriers.`
|
||||
: '';
|
||||
switch (q) {
|
||||
case 'excellent':
|
||||
return `Excellent — the modem is operating near its ceiling.${agg}`;
|
||||
case 'good':
|
||||
return `Good — full throughput expected under normal load.${agg}`;
|
||||
case 'fair':
|
||||
return `Fair — usable, but expect degradation at peak hours.${agg}`;
|
||||
case 'poor':
|
||||
return `Poor — throughput and stability are likely affected.${agg}`;
|
||||
default:
|
||||
return `Connected. The modem does not report enough detail to judge quality.${agg}`;
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Change detection ─────────────────────────────────────────────────────────
|
||||
|
||||
export type LteChangeKind = 'session-reset' | 'handover' | 'band-change';
|
||||
|
||||
export interface LteChange {
|
||||
kind: LteChangeKind;
|
||||
detail: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compare consecutive polls to recover what happened between them.
|
||||
*
|
||||
* Scanning is not a usable substitute: it costs service to run, and at the site
|
||||
* that prompted this work it returns nothing because there is only one base
|
||||
* station within reach. Polling is the vantage point that always exists.
|
||||
* Two signals carry most of the story: `session-uptime` running *backwards*
|
||||
* means the modem re-registered, and a changed cell id means it handed over.
|
||||
*/
|
||||
export function detectChanges(prev: LteStatus | null, next: LteStatus): LteChange[] {
|
||||
if (!prev) return [];
|
||||
const changes: LteChange[] = [];
|
||||
|
||||
const before = prev.sessionUptimeSeconds;
|
||||
const after = next.sessionUptimeSeconds;
|
||||
if (before != null && after != null && after < before) {
|
||||
changes.push({
|
||||
kind: 'session-reset',
|
||||
detail: `Session restarted — uptime fell from ${before}s to ${after}s`,
|
||||
});
|
||||
}
|
||||
|
||||
if (prev.cellId && next.cellId && prev.cellId !== next.cellId) {
|
||||
changes.push({
|
||||
kind: 'handover',
|
||||
detail: `Cell changed from ${prev.cellId} to ${next.cellId}`,
|
||||
});
|
||||
}
|
||||
|
||||
const bandsOf = (s: LteStatus) =>
|
||||
[s.primaryBand, ...s.caBands].filter(Boolean).map(b => b!.band).sort((a, b) => a - b).join(',');
|
||||
const bandsBefore = bandsOf(prev);
|
||||
const bandsAfter = bandsOf(next);
|
||||
if (bandsBefore && bandsAfter && bandsBefore !== bandsAfter) {
|
||||
changes.push({
|
||||
kind: 'band-change',
|
||||
detail: `Bands changed from ${bandsBefore} to ${bandsAfter}`,
|
||||
});
|
||||
}
|
||||
|
||||
return changes;
|
||||
}
|
||||
|
||||
/**
|
||||
* Bands seen serving this device, for warning before a band lock.
|
||||
*
|
||||
* Scanning is not a dependable answer to "is band N available here?". It costs
|
||||
* service to run, is not offered by every modem, and comes back empty in exactly
|
||||
* the deployments that most need the warning — the field report behind this code
|
||||
* is a fixed antenna 10 km from its only base station, where a scan finds nothing
|
||||
* because there is nothing else to find.
|
||||
*
|
||||
* History answers it passively instead: a band the modem has actually used at
|
||||
* this location demonstrably works there, and locking to one never once observed
|
||||
* is the shape of a change that strands a device on a mast.
|
||||
*/
|
||||
export function observedBands(statuses: LteStatus[]): number[] {
|
||||
const seen = new Set<number>();
|
||||
for (const s of statuses) {
|
||||
if (s.primaryBand) seen.add(s.primaryBand.band);
|
||||
for (const c of s.caBands) seen.add(c.band);
|
||||
}
|
||||
return [...seen].sort((a, b) => a - b);
|
||||
}
|
||||
|
||||
/** The carriers alone, so callers reading stored band data need no full status. */
|
||||
export type CarrierSet = Pick<LteStatus, 'primaryBand' | 'caBands'>;
|
||||
|
||||
/** Aggregate spectrum in use: the primary carrier plus every aggregated one. */
|
||||
export function totalBandwidthMhz(status: CarrierSet): number {
|
||||
return [status.primaryBand, ...status.caBands]
|
||||
.filter(Boolean)
|
||||
.reduce((mhz, c) => mhz + (c!.bandwidthMhz ?? 0), 0);
|
||||
}
|
||||
|
||||
export interface UplinkAnchor {
|
||||
/** Bandwidth of the carrier the uplink actually rides on. */
|
||||
primaryMhz: number;
|
||||
primaryBand: number;
|
||||
/** The widest carrier being aggregated for downlink. */
|
||||
widestMhz: number;
|
||||
widestBand: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Is the uplink pinned to a narrower carrier than the downlink is using?
|
||||
*
|
||||
* Left to itself a modem anchors to whichever band it hears best, then aggregates
|
||||
* the rest for downlink. That optimises the wrong thing when the loudest band is
|
||||
* also the narrowest: uplink rides the primary carrier alone, so a 10 MHz anchor
|
||||
* caps upload however much downlink is stacked on top of it. Operators hit this
|
||||
* and fix it by excluding the narrow bands, which is the actual reason to lock
|
||||
* bands — not coverage.
|
||||
*
|
||||
* Returns the comparison only when a wider carrier is genuinely being aggregated,
|
||||
* so a link that is already anchored to its widest carrier says nothing.
|
||||
*/
|
||||
export function uplinkAnchor(status: CarrierSet): UplinkAnchor | null {
|
||||
const primary = status.primaryBand;
|
||||
if (!primary?.bandwidthMhz) return null;
|
||||
|
||||
let widest = primary;
|
||||
for (const c of status.caBands) {
|
||||
if ((c.bandwidthMhz ?? 0) > (widest.bandwidthMhz ?? 0)) widest = c;
|
||||
}
|
||||
if (widest === primary) return null;
|
||||
|
||||
return {
|
||||
primaryMhz: primary.bandwidthMhz,
|
||||
primaryBand: primary.band,
|
||||
widestMhz: widest.bandwidthMhz!,
|
||||
widestBand: widest.band,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Which of the bands about to be locked have never been seen serving this device?
|
||||
* An empty result means the lock is supported by evidence.
|
||||
*/
|
||||
export function unprovenBands(requested: number[], observed: number[]): number[] {
|
||||
const known = new Set(observed);
|
||||
return [...new Set(requested)].filter(b => !known.has(b)).sort((a, b) => a - b);
|
||||
}
|
||||
Reference in New Issue
Block a user