-
+
diff --git a/backend/package.json b/backend/package.json
index 914c849..93bf77b 100644
--- a/backend/package.json
+++ b/backend/package.json
@@ -1,6 +1,6 @@
{
"name": "mikrotik-manager-backend",
- "version": "0.23.11-beta",
+ "version": "0.23.12-beta",
"description": "Mikrotik Network Management Platform - Backend API",
"main": "dist/index.js",
"scripts": {
diff --git a/backend/src/db/migrate.ts b/backend/src/db/migrate.ts
index e9f6c5e..aca2014 100644
--- a/backend/src/db/migrate.ts
+++ b/backend/src/db/migrate.ts
@@ -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 = [
diff --git a/backend/src/routes/devices.ts b/backend/src/routes/devices.ts
index 835257e..c64c5de 100644
--- a/backend/src/routes/devices.ts
+++ b/backend/src/routes/devices.ts
@@ -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>(
+ `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 = {
+ '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 = {
+ '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 = {
+ '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[] = [];
+ await new Promise((resolve, reject) => {
+ queryApi.queryRows(flux, {
+ next(row, tableMeta) {
+ const obj = tableMeta.toObject(row) as Record;
+ 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 };
diff --git a/backend/src/services/mikrotik/DeviceCollector.ts b/backend/src/services/mikrotik/DeviceCollector.ts
index f68980f..1da3a2b 100644
--- a/backend/src/services/mikrotik/DeviceCollector.ts
+++ b/backend/src/services/mikrotik/DeviceCollector.ts
@@ -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 {
+ 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[]);
+ 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[]);
+ 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 {
+ 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 {
+ 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 {
+ 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(
diff --git a/backend/src/services/mikrotik/RouterOSClient.ts b/backend/src/services/mikrotik/RouterOSClient.ts
index f1757dc..47e7501 100644
--- a/backend/src/services/mikrotik/RouterOSClient.ts
+++ b/backend/src/services/mikrotik/RouterOSClient.ts
@@ -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;
@@ -363,17 +371,33 @@ export class RouterOSClient extends EventEmitter {
const type = words[0];
const parsed: Record = {};
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 | 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 };
}
diff --git a/backend/src/services/mikrotik/__tests__/RouterOSClient.test.ts b/backend/src/services/mikrotik/__tests__/RouterOSClient.test.ts
new file mode 100644
index 0000000..de35cb7
--- /dev/null
+++ b/backend/src/services/mikrotik/__tests__/RouterOSClient.test.ts
@@ -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);
+ });
+});
diff --git a/backend/src/utils/__tests__/fixtures/lte-atl18.md b/backend/src/utils/__tests__/fixtures/lte-atl18.md
new file mode 100644
index 0000000..cd360c5
--- /dev/null
+++ b/backend/src/utils/__tests__/fixtures/lte-atl18.md
@@ -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.
diff --git a/backend/src/utils/__tests__/lte.test.ts b/backend/src/utils/__tests__/lte.test.ts
new file mode 100644
index 0000000..104fd89
--- /dev/null
+++ b/backend/src/utils/__tests__/lte.test.ts
@@ -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 = {
+ '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 = {
+ '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 = {
+ '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 = {
+ '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 = { '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>): LteStatus =>
+ parseLteMonitor({ ...ATL18, ...over } as Record);
+
+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);
+ });
+});
diff --git a/backend/src/utils/lte.ts b/backend/src/utils/lte.ts
new file mode 100644
index 0000000..eb5a0b1
--- /dev/null
+++ b/backend/src/utils/lte.ts
@@ -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 = { 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@` recovers the second case without
+ * disturbing the first.
+ */
+export function parseBandSpecs(
+ row: Record,
+ key: string,
+): LteBandInfo[] {
+ const values: string[] = [];
+
+ const repeatedRaw = row[REPEATED_KEY];
+ if (repeatedRaw) {
+ try {
+ const repeated = JSON.parse(repeatedRaw) as Record;
+ 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();
+ 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): 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, 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[])
+ .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();
+ 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;
+
+/** 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);
+}
diff --git a/frontend/package.json b/frontend/package.json
index c56ead0..f03d72e 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -1,6 +1,6 @@
{
"name": "mikrotik-manager-frontend",
- "version": "0.23.11-beta",
+ "version": "0.23.12-beta",
"private": true,
"type": "module",
"scripts": {
diff --git a/frontend/src/components/device-detail/LteTab.tsx b/frontend/src/components/device-detail/LteTab.tsx
new file mode 100644
index 0000000..84dd903
--- /dev/null
+++ b/frontend/src/components/device-detail/LteTab.tsx
@@ -0,0 +1,390 @@
+import { useState } from 'react';
+import { useQuery } from '@tanstack/react-query';
+import {
+ LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, Legend,
+} from 'recharts';
+import { Signal, RadioTower, ArrowLeftRight, RotateCcw, Radio, ArrowUpFromLine } from 'lucide-react';
+import { lteApi, type LteInterface, type LteHistoryEvent } from '../../services/api';
+
+interface Props {
+ deviceId: number;
+}
+
+const RANGES = ['1h', '6h', '24h', '7d', '30d'] as const;
+
+type Quality = NonNullable;
+
+const QUALITY_STYLE: Record = {
+ excellent: 'bg-green-100 text-green-800 dark:bg-green-900/40 dark:text-green-300',
+ good: 'bg-blue-100 text-blue-800 dark:bg-blue-900/40 dark:text-blue-300',
+ fair: 'bg-amber-100 text-amber-800 dark:bg-amber-900/40 dark:text-amber-300',
+ poor: 'bg-red-100 text-red-800 dark:bg-red-900/40 dark:text-red-300',
+ unknown: 'bg-gray-100 text-gray-700 dark:bg-slate-700 dark:text-slate-300',
+};
+
+/**
+ * What the grade means, in words.
+ *
+ * A raw figure like −97 dBm tells most people nothing, and taken alone it
+ * actively misleads: the field capture this was built against showed −97 dBm on
+ * a link running 256QAM at maximum reported channel quality. SINR drives the
+ * grade for that reason, with RSRP able to pull it down but not define it.
+ */
+const QUALITY_TEXT: Record = {
+ excellent: 'The modem is operating near its ceiling.',
+ good: 'Full throughput expected under normal load.',
+ fair: 'Usable, but expect degradation at peak hours.',
+ poor: 'Throughput and stability are likely affected.',
+ unknown: 'The modem does not report enough detail to judge quality.',
+};
+
+function formatUptime(seconds: number | null): string {
+ if (seconds == null || !Number.isFinite(seconds)) return '—';
+ const d = Math.floor(seconds / 86400);
+ const h = Math.floor((seconds % 86400) / 3600);
+ const m = Math.floor((seconds % 3600) / 60);
+ if (d > 0) return `${d}d ${h}h ${m}m`;
+ if (h > 0) return `${h}h ${m}m`;
+ return `${m}m`;
+}
+
+function formatTime(iso: string, compact: boolean): string {
+ const d = new Date(iso);
+ return compact
+ ? d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
+ : d.toLocaleString([], { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' });
+}
+
+function Metric({ label, value, unit, hint }: {
+ label: string; value: number | string | null; unit?: string; hint?: string;
+}) {
+ return (
+
+
{label}
+
+ {value == null || value === '' ? '—' : value}
+ {value != null && value !== '' && unit
+ ? {unit}
+ : null}
+
+ {hint &&
{hint}
}
+
+ );
+}
+
+/** Primary carrier plus every aggregated one, which is the shape of the link. */
+function BandChips({ iface }: { iface: LteInterface }) {
+ const carriers = [
+ ...(iface.primary_band ? [{ ...iface.primary_band, primary: true }] : []),
+ ...iface.ca_bands.map(b => ({ ...b, primary: false })),
+ ];
+
+ if (carriers.length === 0) {
+ return No band reported;
+ }
+
+ return (
+
+
+ {/* Signal figures. Each shows only what the modem actually reported. */}
+
+
+
+
+
+
+
+
+
+ {/* Bands */}
+
+
+
+
Carriers in use
+
+
+
+ {iface.uplink_anchor && (
+
+
+
+
+ Upload is limited by the primary carrier
+
+
+ The uplink runs on B{iface.uplink_anchor.primaryBand} alone at{' '}
+ {iface.uplink_anchor.primaryMhz} MHz, while B{iface.uplink_anchor.widestBand} is
+ being aggregated at {iface.uplink_anchor.widestMhz} MHz for download. Left on
+ automatic, a modem anchors to whichever band it hears loudest rather than the
+ widest one — excluding the narrow bands moves the anchor and raises upload.
+
+ Seen serving this device:
+
+ {iface.observed_bands.length > 0
+ ? iface.observed_bands.map(o => `B${o.band}`).join(', ')
+ : 'Nothing recorded yet'}
+
+
+
+ {iface.observed_bands.length > 0 && (
+
+ Observed bands accumulate from polling. They are the evidence a future band
+ lock will be checked against — scanning costs service to run and comes back
+ empty where a site has only one base station, so history is the safer source.
+
+ )}
+
+
+ {/* Cell */}
+
+
+
+
+
+
+
+ );
+ })}
+
+ {/* Signal over time */}
+
+
+
Signal over time
+
+ {RANGES.map(r => (
+
+ ))}
+
+
+
+ {chartData.length === 0 ? (
+
+ No samples in this window yet.
+
+ ) : (
+
+
+
+
+ {/* dBm and dB share a chart but not a scale. */}
+
+
+
+
+
+
+
+
+
+
+ )}
+
+
+ {/* Movement */}
+
+
Tower and band changes
+
+ Reconstructed by comparing consecutive polls — a session uptime that runs backwards
+ means the modem re-registered, and a changed cell means it handed over.
+
+
+
+
+ );
+}
diff --git a/frontend/src/components/wireless/RfHealth.tsx b/frontend/src/components/wireless/RfHealth.tsx
index c936735..7da2c84 100644
--- a/frontend/src/components/wireless/RfHealth.tsx
+++ b/frontend/src/components/wireless/RfHealth.tsx
@@ -45,6 +45,11 @@ function SpectrumBandRow({ band, radios }: { band: RfBandRange; radios: RfChanne
const ticks: number[] = [];
for (let f = Math.ceil(band.startMhz / step) * step; f < band.endMhz; f += step) ticks.push(f);
+ // The 2.4 GHz axis is labelled by channel, so a tick outside the channel plan
+ // (2400 and 2480 both fall there) would print a bare frequency and break the
+ // sequence — reading "2400, 3, 7, 11, 2480" rather than as channels.
+ const labelledTicks = band.band === '2.4' ? ticks.filter((f) => channelOf(f) != null) : ticks;
+
return (