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:
2GT-Rich
2026-08-23 13:06:28 -07:00
co-authored by Claude Opus 5
parent 57d650c159
commit 2625fea8de
19 changed files with 2153 additions and 15 deletions
+1 -1
View File
@@ -6,7 +6,7 @@
</p>
<p align="center">
<img src="https://img.shields.io/badge/version-0.23.11_Beta-blue" alt="Version" />
<img src="https://img.shields.io/badge/version-0.23.12_Beta-blue" alt="Version" />
<img src="https://img.shields.io/badge/license-AGPLv3-blue" alt="License" />
<img src="https://img.shields.io/badge/docker-compose-2496ED?logo=docker&logoColor=white" alt="Docker" />
<img src="https://img.shields.io/badge/TypeScript-5.3-3178C6?logo=typescript&logoColor=white" alt="TypeScript" />
+1 -1
View File
@@ -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": {
+89
View File
@@ -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 = [
+146 -2
View File
@@ -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.
+451
View File
@@ -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);
});
});
+459
View File
@@ -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);
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "mikrotik-manager-frontend",
"version": "0.23.11-beta",
"version": "0.23.12-beta",
"private": true,
"type": "module",
"scripts": {
@@ -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<LteInterface['quality']>;
const QUALITY_STYLE: Record<Quality, string> = {
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<Quality, string> = {
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 (
<div className="rounded-lg border border-gray-200 dark:border-slate-700 p-3">
<div className="text-xs text-gray-500 dark:text-slate-400">{label}</div>
<div className="text-lg font-semibold text-gray-900 dark:text-white tabular-nums">
{value == null || value === '' ? '—' : value}
{value != null && value !== '' && unit
? <span className="text-xs font-normal text-gray-500 dark:text-slate-400 ml-1">{unit}</span>
: null}
</div>
{hint && <div className="text-[11px] text-gray-400 dark:text-slate-500 mt-0.5">{hint}</div>}
</div>
);
}
/** 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 <span className="text-sm text-gray-500 dark:text-slate-400">No band reported</span>;
}
return (
<div className="flex flex-wrap gap-2">
{carriers.map((c, i) => (
<div
key={`${c.band}-${c.earfcn ?? i}`}
className={`rounded-lg border px-3 py-1.5 ${
c.primary
? 'border-blue-300 bg-blue-50 dark:border-blue-700 dark:bg-blue-900/30'
: 'border-gray-200 bg-gray-50 dark:border-slate-700 dark:bg-slate-800'
}`}
>
<div className="flex items-baseline gap-2">
<span className="font-semibold text-gray-900 dark:text-white">B{c.band}</span>
{c.bandwidthMhz != null && (
<span className="text-xs text-gray-600 dark:text-slate-300">{c.bandwidthMhz} MHz</span>
)}
</div>
<div className="text-[11px] text-gray-500 dark:text-slate-400">
{c.primary ? 'Primary' : 'Aggregated'}
{c.earfcn != null && ` · EARFCN ${c.earfcn}`}
</div>
</div>
))}
</div>
);
}
const EVENT_ICON = {
'handover': ArrowLeftRight,
'session-reset': RotateCcw,
'band-change': Radio,
} as const;
const EVENT_LABEL = {
'handover': 'Handover',
'session-reset': 'Session restarted',
'band-change': 'Bands changed',
} as const;
function HistoryList({ events }: { events: LteHistoryEvent[] }) {
if (events.length === 0) {
return (
<p className="text-sm text-gray-500 dark:text-slate-400">
No tower or band changes recorded in this window — a stable link looks exactly like this.
</p>
);
}
return (
<ul className="space-y-2">
{events.map((e, i) => {
const Icon = EVENT_ICON[e.kind] ?? Radio;
return (
<li key={i} className="flex gap-3 text-sm">
<Icon className="w-4 h-4 mt-0.5 shrink-0 text-gray-400 dark:text-slate-500" />
<div className="min-w-0">
<span className="font-medium text-gray-900 dark:text-white">
{EVENT_LABEL[e.kind] ?? e.kind}
</span>
<span className="text-gray-500 dark:text-slate-400"> · {formatTime(e.at, false)}</span>
{e.detail && (
<div className="text-gray-600 dark:text-slate-300 break-words">{e.detail}</div>
)}
{(e.rsrp != null || e.sinr != null) && (
<div className="text-[11px] text-gray-400 dark:text-slate-500">
{e.rsrp != null && `RSRP ${e.rsrp} dBm`}
{e.rsrp != null && e.sinr != null && ' · '}
{e.sinr != null && `SINR ${e.sinr} dB`}
</div>
)}
</div>
</li>
);
})}
</ul>
);
}
export default function LteTab({ deviceId }: Props) {
const [range, setRange] = useState<string>('6h');
const { data: state, isLoading } = useQuery({
queryKey: ['lte', deviceId],
queryFn: () => lteApi.state(deviceId).then(r => r.data),
refetchInterval: 30_000,
});
const { data: metrics } = useQuery({
queryKey: ['lte-metrics', deviceId, range],
queryFn: () => lteApi.metrics(deviceId, range).then(r => r.data),
refetchInterval: 60_000,
});
const { data: history } = useQuery({
queryKey: ['lte-history', deviceId, range],
queryFn: () => lteApi.history(deviceId, range === '1h' || range === '6h' ? '24h' : range)
.then(r => r.data),
refetchInterval: 60_000,
});
if (isLoading) {
return <div className="text-sm text-gray-500 dark:text-slate-400">Loading cellular state…</div>;
}
const interfaces = state?.interfaces ?? [];
if (interfaces.length === 0) {
return (
<div className="text-sm text-gray-500 dark:text-slate-400">
No cellular data yet. It appears after the next poll of an LTE interface.
</div>
);
}
const compact = range === '1h' || range === '6h';
const chartData = (metrics?.metrics ?? []).map(p => ({
time: formatTime(p.time, compact),
rsrp: p.rsrp, sinr: p.sinr, rsrq: p.rsrq, rssi: p.rssi,
}));
return (
<div className="space-y-6">
{interfaces.map(iface => {
const quality = (iface.quality ?? 'unknown') as Quality;
const connected = iface.status === 'running' || iface.status === 'connected';
const carriers = iface.ca_bands.length + (iface.primary_band ? 1 : 0);
const uptime = iface.session_uptime_s == null ? null : Number(iface.session_uptime_s);
return (
<div
key={iface.interface_name}
className="bg-white dark:bg-slate-800 rounded-lg border border-gray-200 dark:border-slate-700 p-4 space-y-4"
>
{/* Header: what the link is doing, in words before numbers */}
<div className="flex flex-wrap items-start justify-between gap-3">
<div className="flex items-start gap-3">
<Signal className="w-5 h-5 mt-1 text-gray-400 dark:text-slate-500" />
<div>
<div className="flex items-center gap-2 flex-wrap">
<h3 className="font-semibold text-gray-900 dark:text-white">
{iface.interface_name}
</h3>
<span className={`px-2 py-0.5 rounded text-xs font-medium ${QUALITY_STYLE[quality]}`}>
{connected ? quality : 'not connected'}
</span>
{carriers > 1 && (
<span className="px-2 py-0.5 rounded text-xs font-medium bg-purple-100 text-purple-800 dark:bg-purple-900/40 dark:text-purple-300">
{carriers}× carrier aggregation
{iface.total_bandwidth_mhz > 0 && ` · ${iface.total_bandwidth_mhz} MHz`}
</span>
)}
</div>
<p className="text-sm text-gray-600 dark:text-slate-300 mt-0.5">
{connected ? QUALITY_TEXT[quality] : 'The modem is not attached to a network.'}
</p>
<p className="text-xs text-gray-500 dark:text-slate-400 mt-1">
{[iface.operator, iface.data_class, iface.modem_model]
.filter(Boolean).join(' · ') || 'Modem details not reported'}
</p>
</div>
</div>
<div className="text-right text-xs text-gray-500 dark:text-slate-400">
<div>Session up {formatUptime(uptime)}</div>
<div>Updated {formatTime(iface.updated_at, true)}</div>
</div>
</div>
{/* Signal figures. Each shows only what the modem actually reported. */}
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-6 gap-3">
<Metric label="RSRP" value={iface.rsrp} unit="dBm" hint="Signal power" />
<Metric label="SINR" value={iface.sinr} unit="dB" hint="Signal vs noise" />
<Metric label="RSRQ" value={iface.rsrq} unit="dB" hint="Signal quality" />
<Metric label="RSSI" value={iface.rssi} unit="dBm" hint="Wideband power" />
<Metric label="CQI" value={iface.cqi} hint="Reported 0–15" />
<Metric
label="Streams"
value={iface.rank_indicator}
hint={iface.dl_modulation ? `${iface.dl_modulation.toUpperCase()} downlink` : 'MIMO rank'}
/>
</div>
{/* Bands */}
<div>
<div className="flex items-center gap-2 mb-2">
<RadioTower className="w-4 h-4 text-gray-400 dark:text-slate-500" />
<h4 className="text-sm font-medium text-gray-900 dark:text-white">Carriers in use</h4>
</div>
<BandChips iface={iface} />
{iface.uplink_anchor && (
<div className="mt-3 flex gap-2 rounded-lg border border-amber-300 bg-amber-50 p-3 dark:border-amber-700/60 dark:bg-amber-900/20">
<ArrowUpFromLine className="mt-0.5 h-4 w-4 shrink-0 text-amber-600 dark:text-amber-400" />
<div className="text-xs">
<div className="font-medium text-amber-900 dark:text-amber-200">
Upload is limited by the primary carrier
</div>
<p className="mt-0.5 text-amber-800 dark:text-amber-300">
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.
</p>
</div>
</div>
)}
<div className="mt-3 grid sm:grid-cols-2 gap-3 text-xs">
<div>
<span className="text-gray-500 dark:text-slate-400">Allowed by configuration: </span>
<span className="text-gray-900 dark:text-white font-medium">
{iface.allowed_bands.length > 0
? iface.allowed_bands.map(b => `B${b}`).join(', ')
: 'Any (modem chooses)'}
</span>
</div>
<div>
<span className="text-gray-500 dark:text-slate-400">Seen serving this device: </span>
<span className="text-gray-900 dark:text-white font-medium">
{iface.observed_bands.length > 0
? iface.observed_bands.map(o => `B${o.band}`).join(', ')
: 'Nothing recorded yet'}
</span>
</div>
</div>
{iface.observed_bands.length > 0 && (
<p className="text-[11px] text-gray-400 dark:text-slate-500 mt-1">
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.
</p>
)}
</div>
{/* Cell */}
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
<Metric label="Cell ID" value={iface.cell_id} />
<Metric label="eNB" value={iface.enb_id} />
<Metric label="Sector" value={iface.sector_id} />
<Metric label="PCI" value={iface.phy_cell_id} />
</div>
</div>
);
})}
{/* Signal over time */}
<div className="bg-white dark:bg-slate-800 rounded-lg border border-gray-200 dark:border-slate-700 p-4">
<div className="flex items-center justify-between mb-4 gap-3 flex-wrap">
<h3 className="font-semibold text-gray-900 dark:text-white">Signal over time</h3>
<div className="flex gap-1">
{RANGES.map(r => (
<button
key={r}
onClick={() => setRange(r)}
className={`px-2 py-1 text-xs rounded font-medium transition-colors ${
range === r
? 'bg-blue-600 text-white'
: 'bg-gray-100 dark:bg-slate-700 text-gray-600 dark:text-slate-300 hover:bg-gray-200 dark:hover:bg-slate-600'
}`}
>
{r}
</button>
))}
</div>
</div>
{chartData.length === 0 ? (
<p className="text-sm text-gray-500 dark:text-slate-400">
No samples in this window yet.
</p>
) : (
<ResponsiveContainer width="100%" height={280}>
<LineChart data={chartData}>
<CartesianGrid strokeDasharray="3 3" className="stroke-gray-200 dark:stroke-slate-700" />
<XAxis dataKey="time" tick={{ fontSize: 11 }} minTickGap={24} />
{/* dBm and dB share a chart but not a scale. */}
<YAxis yAxisId="dbm" tick={{ fontSize: 11 }} width={44}
label={{ value: 'dBm', angle: -90, position: 'insideLeft', fontSize: 11 }} />
<YAxis yAxisId="db" orientation="right" tick={{ fontSize: 11 }} width={40}
label={{ value: 'dB', angle: 90, position: 'insideRight', fontSize: 11 }} />
<Tooltip />
<Legend />
<Line yAxisId="dbm" type="monotone" dataKey="rsrp" name="RSRP (dBm)"
stroke="#3b82f6" dot={false} strokeWidth={2} connectNulls />
<Line yAxisId="dbm" type="monotone" dataKey="rssi" name="RSSI (dBm)"
stroke="#94a3b8" dot={false} strokeWidth={1} connectNulls />
<Line yAxisId="db" type="monotone" dataKey="sinr" name="SINR (dB)"
stroke="#10b981" dot={false} strokeWidth={2} connectNulls />
<Line yAxisId="db" type="monotone" dataKey="rsrq" name="RSRQ (dB)"
stroke="#f59e0b" dot={false} strokeWidth={1} connectNulls />
</LineChart>
</ResponsiveContainer>
)}
</div>
{/* Movement */}
<div className="bg-white dark:bg-slate-800 rounded-lg border border-gray-200 dark:border-slate-700 p-4">
<h3 className="font-semibold text-gray-900 dark:text-white mb-1">Tower and band changes</h3>
<p className="text-xs text-gray-500 dark:text-slate-400 mb-3">
Reconstructed by comparing consecutive polls — a session uptime that runs backwards
means the modem re-registered, and a changed cell means it handed over.
</p>
<HistoryList events={history?.events ?? []} />
</div>
</div>
);
}
+17 -6
View File
@@ -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 (
<div>
<div className="flex items-baseline justify-between mb-1">
@@ -79,12 +84,18 @@ function SpectrumBandRow({ band, radios }: { band: RfBandRange; radios: RfChanne
</div>
<div className="relative h-3 mt-0.5">
{ticks.map((f) => (
<span key={f} className="absolute text-[9px] leading-none text-gray-400 dark:text-slate-500"
style={{ left: `${pct(f)}%`, transform: 'translateX(-50%)' }}>
{band.band === '2.4' ? (channelOf(f) ?? f) : f}
</span>
))}
{labelledTicks.map((f) => {
const at = pct(f);
// A centred label on a tick at either extreme hangs half its width past
// the card. Anchor the outermost labels inward instead of centring them.
const anchor = at <= 3 ? 'translateX(0)' : at >= 97 ? 'translateX(-100%)' : 'translateX(-50%)';
return (
<span key={f} className="absolute text-[9px] leading-none text-gray-400 dark:text-slate-500"
style={{ left: `${at}%`, transform: anchor }}>
{band.band === '2.4' ? (channelOf(f) ?? f) : f}
</span>
);
})}
</div>
</div>
);
+8 -2
View File
@@ -17,12 +17,13 @@ import ConfigHistoryTab from '../components/device-detail/ConfigHistoryTab';
import HardwareTab from '../components/device-detail/HardwareTab';
import ToolsTab from '../components/device-detail/ToolsTab';
import RadiosTab from '../components/device-detail/RadiosTab';
import LteTab from '../components/device-detail/LteTab';
import ConnectionsTab from '../components/device-detail/ConnectionsTab';
import QueuesTab from '../components/device-detail/QueuesTab';
import SecurityTab from '../components/device-detail/SecurityTab';
import clsx from 'clsx';
type TabKey = 'overview' | 'ports' | 'vlans' | 'routing' | 'firewall' | 'security' | 'queues' | 'connections' | 'config' | 'config-history' | 'hardware' | 'tools' | 'radios';
type TabKey = 'overview' | 'ports' | 'vlans' | 'routing' | 'firewall' | 'security' | 'queues' | 'connections' | 'config' | 'config-history' | 'hardware' | 'tools' | 'radios' | 'lte';
function formatUptime(raw: string): string {
if (!raw) return '—';
@@ -50,7 +51,7 @@ export default function DeviceDetailPage() {
const queryClient = useQueryClient();
const canWrite = useCanWrite();
const [searchParams] = useSearchParams();
const VALID_TABS: TabKey[] = ['overview', 'ports', 'vlans', 'routing', 'firewall', 'security', 'queues', 'connections', 'config', 'config-history', 'hardware', 'tools', 'radios'];
const VALID_TABS: TabKey[] = ['overview', 'ports', 'vlans', 'routing', 'firewall', 'security', 'queues', 'connections', 'config', 'config-history', 'hardware', 'tools', 'radios', 'lte'];
const requestedTab = searchParams.get('tab') as TabKey | null;
const [activeTab, setActiveTab] = useState<TabKey>(requestedTab && VALID_TABS.includes(requestedTab) ? requestedTab : 'overview');
const [autoOpenBridge, setAutoOpenBridge] = useState<string | null>(null);
@@ -125,6 +126,10 @@ export default function DeviceDetailPage() {
{ key: 'config-history', label: 'Config History' },
{ key: 'hardware', label: 'Hardware' },
...(isWirelessAP ? [{ key: 'radios' as TabKey, label: 'Radios' }] : []),
// Cellular is a property of the hardware, not the device type: a modem turns
// up on routers, CPE and travel gear alike, so the flag is set from the
// interface list rather than inferred (discussion #85).
...(device.has_lte ? [{ key: 'lte' as TabKey, label: 'LTE' }] : []),
{ key: 'tools', label: 'Tools' },
];
@@ -396,6 +401,7 @@ export default function DeviceDetailPage() {
{activeTab === 'config-history' && <ConfigHistoryTab deviceId={deviceId} />}
{activeTab === 'hardware' && <HardwareTab deviceId={deviceId} />}
{activeTab === 'radios' && <RadiosTab deviceId={deviceId} deviceStatus={device.status} />}
{activeTab === 'lte' && <LteTab deviceId={deviceId} />}
{activeTab === 'tools' && <ToolsTab deviceId={deviceId} />}
{showTerminal && (
+89
View File
@@ -212,6 +212,95 @@ export interface ConfigHealthResponse {
checked_at: string | null;
}
// ─── LTE / cellular ───────────────────────────────────────────────────────────
/** One aggregated carrier as the modem reported it. */
export interface LteBandInfo {
band: number;
bandwidthMhz: number | null;
earfcn: number | null;
phyCellId: number | null;
raw: string;
}
export interface LteInterface {
interface_name: string;
status: string | null;
data_class: string | null;
modem_model: string | null;
modem_revision: string | null;
operator: string | null;
cell_id: string | null;
enb_id: string | null;
sector_id: string | null;
phy_cell_id: string | null;
session_uptime_s: string | number | null;
primary_band: LteBandInfo | null;
ca_bands: LteBandInfo[];
rssi: number | null;
rsrp: number | null;
rsrq: number | null;
sinr: number | null;
cqi: number | null;
rank_indicator: number | null;
mcs: number | null;
dl_modulation: string | null;
quality: 'excellent' | 'good' | 'fair' | 'poor' | 'unknown' | null;
/** Configured allow-list; empty means the modem picks freely. */
allowed_bands: number[];
/** Primary plus every aggregated carrier. */
total_bandwidth_mhz: number;
/**
* Set when the uplink is anchored to a narrower carrier than the downlink is
* aggregating — the reason operators lock bands, and invisible in RouterOS.
*/
uplink_anchor: {
primaryMhz: number; primaryBand: number;
widestMhz: number; widestBand: number;
} | null;
network_mode: string | null;
apn_profiles: string | null;
allow_roaming: boolean | null;
updated_at: string;
/** Bands actually seen serving this device — the evidence behind a band lock. */
observed_bands: { band: number; observations: number; last_seen: string }[];
}
export interface LteHistoryEvent {
interface_name: string;
at: string;
kind: 'session-reset' | 'handover' | 'band-change';
detail: string | null;
cell_id: string | null;
enb_id: string | null;
bands: string | null;
rsrp: number | null;
sinr: number | null;
}
export interface LteMetricPoint {
time: string;
interface: string;
rsrp?: number;
rsrq?: number;
sinr?: number;
rssi?: number;
cqi?: number;
carriers?: number;
}
export const lteApi = {
state: (deviceId: number) =>
api.get<{ interfaces: LteInterface[] }>(`/devices/${deviceId}/lte`),
history: (deviceId: number, range = '24h') =>
api.get<{ events: LteHistoryEvent[] }>(`/devices/${deviceId}/lte/history`, { params: { range } }),
metrics: (deviceId: number, range = '6h', iface?: string) =>
api.get<{ metrics: LteMetricPoint[] }>(`/devices/${deviceId}/lte/metrics`, {
params: { range, ...(iface ? { iface } : {}) },
}),
};
export const devicesApi = {
list: () => api.get<Device[]>('/devices'),
discovered: () => api.get<DiscoveredDevice[]>('/devices/discovered'),
+1
View File
@@ -71,6 +71,7 @@ export interface Device {
rack_slot?: string;
/** Detected wireless role: none | standalone | cap | controller | controller_cap. */
wifi_role?: string | null;
has_lte?: boolean;
created_at: string;
updated_at?: string;
tags?: { id: number; name: string; color: string }[];
+1 -1
View File
@@ -1 +1 @@
export const APP_VERSION = 'v0.23.11 Beta';
export const APP_VERSION = 'v0.23.12 Beta';
+5
View File
@@ -0,0 +1,5 @@
-- Remove everything seed-lte-fixture.sql created. The device cascades to its
-- interfaces, lte_interfaces, lte_cell_history and lte_observed_bands rows.
DELETE FROM devices WHERE name LIKE 'fixture-lte-%';
SELECT 'cleaned' AS status,
(SELECT count(*) FROM devices WHERE name LIKE 'fixture-lte-%') AS remaining;
+87
View File
@@ -0,0 +1,87 @@
-- A cellular device for local development, reproducing a real field capture.
--
-- The fleet this platform is developed against contains no LTE hardware at all
-- (49 ethernet interfaces, 7 wifi, zero cellular), which is exactly the position
-- CAPsMAN support was written from before two bugs shipped. This seeds the shape
-- of a real modem so the LTE paths can be exercised against actual endpoints.
--
-- Values come from a MikroTik ATL 18 (Quectel EG18-EA, Cat-18) on VIVACOM,
-- contributed by @trackersoft in discussion #85. See
-- backend/src/utils/__tests__/fixtures/lte-atl18.md for the raw capture.
--
-- What makes this shape worth reproducing:
--
-- * RSRP −97 dBm — "fair" on its own — on a link running 256QAM at CQI 15 with
-- two spatial streams, which is the modem at its ceiling. Any UI that grades
-- on RSRP alone gets this device visibly wrong.
-- * a primary carrier plus an aggregated one, so carrier-aggregation rendering
-- is exercised rather than assumed
-- * a configured band allow-list (1,3,7) that is wider than the bands actually
-- observed, which is the state a band-lock warning has to reason about
-- * tower movement history, which cannot be recovered after the fact
--
-- Everything is prefixed `fixture-` and removed by seed-lte-fixture-clean.sql.
BEGIN;
-- A cellular CPE. Deliberately device_type 'router': cellular is a property of
-- the hardware, and gating the LTE tab on device_type would hide it here.
INSERT INTO devices (name, ip_address, api_username, api_password_encrypted,
device_type, status, model, has_lte)
VALUES ('fixture-lte-cpe-001', '10.251.0.1', 'admin', 'x',
'router', 'online', 'ATL18', TRUE)
ON CONFLICT DO NOTHING;
-- The interface row is what collectLte keys off, so the modem is only contacted
-- on devices that actually have one.
INSERT INTO interfaces (device_id, name, type, mtu, running, disabled, updated_at)
SELECT id, 'lte1', 'lte', 1500, TRUE, FALSE, NOW()
FROM devices WHERE name = 'fixture-lte-cpe-001'
ON CONFLICT (device_id, name) DO UPDATE SET type = 'lte', running = TRUE;
-- Current state, verbatim from the capture.
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, updated_at)
SELECT id, 'lte1', 'running', 'LTE', 'EG18-EA', 'EG18EAPAR01A14M4G',
'VIVACOM', '123456', '1234', '12', '190', 285479,
'{"band":1,"bandwidthMhz":20,"earfcn":500,"phyCellId":190,
"raw":"B1@20Mhz earfcn: 500 phy-cellid: 190"}'::jsonb,
'[{"band":3,"bandwidthMhz":20,"earfcn":1800,"phyCellId":190,
"raw":"B3@20Mhz earfcn: 1800 phy-cellid: 190"}]'::jsonb,
-69, -97, -9, 17, 15, 2, 20,
'256qam', 'good', '1,3,7', 'lte', 'default', FALSE, NOW()
FROM devices WHERE name = 'fixture-lte-cpe-001'
ON CONFLICT (device_id, interface_name) DO UPDATE SET updated_at = NOW();
-- Bands seen serving this device. Note B7 is *allowed* but has never been
-- observed — locking to it is the case a future pre-flight has to catch.
INSERT INTO lte_observed_bands (device_id, interface_name, band, observations, first_seen, last_seen)
SELECT d.id, 'lte1', b.band, b.n, NOW() - INTERVAL '6 days', NOW()
FROM devices d, (VALUES (1, 4120), (3, 3980)) AS b(band, n)
WHERE d.name = 'fixture-lte-cpe-001'
ON CONFLICT (device_id, interface_name, band) DO NOTHING;
-- Movement over the last day: a handover, a re-registration, and an aggregated
-- carrier dropping away and returning.
INSERT INTO lte_cell_history (device_id, interface_name, at, kind, detail, cell_id, enb_id, bands, rsrp, sinr)
SELECT d.id, 'lte1', NOW() - h.ago, h.kind, h.detail, h.cell, '1234', h.bands, h.rsrp, h.sinr
FROM devices d, (VALUES
(INTERVAL '35 minutes', 'band-change', 'Bands changed from 1 to 1,3', '123456', '1,3', -97::real, 17::real),
(INTERVAL '2 hours', 'band-change', 'Bands changed from 1,3 to 1', '123456', '1', -101::real, 12::real),
(INTERVAL '7 hours', 'handover', 'Cell changed from 998877 to 123456', '123456', '1,3', -95::real, 18::real),
(INTERVAL '7 hours', 'session-reset', 'Session restarted — uptime fell from 210433s to 12s',
'998877', '1', -104::real, 9::real),
(INTERVAL '19 hours', 'handover', 'Cell changed from 123456 to 998877', '998877', '1', -103::real, 10::real)
) AS h(ago, kind, detail, cell, bands, rsrp, sinr)
WHERE d.name = 'fixture-lte-cpe-001';
COMMIT;
SELECT 'seeded' AS status,
(SELECT count(*) FROM lte_interfaces) AS lte_interfaces,
(SELECT count(*) FROM lte_cell_history) AS history_rows,
(SELECT count(*) FROM lte_observed_bands) AS observed_bands;