feat(lte): daily data-cap tracking and carrier reset SMS (v0.23.23-beta)
Requested in discussion #85. Several European carriers sell "unlimited" mobile data that is throttled hard past a daily allowance, and lift the throttle when the subscriber texts a short code — 10 GB then 64 kbps until you text 1237, in the reporter's case. Doing that by hand every evening is exactly the sort of thing this should handle. Two facts from the contributor's hardware shaped the whole design. **Interface byte counters do not survive a reboot.** So there is no running total to read; usage has to be accumulated from poll to poll on our side. That makes our figure a permanent undercount — traffic during a reboot, before the device was adopted, or between a missed poll and the next is invisible. An undercount fires late, and late means the user is already throttled, so the trigger deliberately fires below the configured allowance (5% by default, and adjustable). Firing early costs one SMS; firing late costs the evening. **The carrier's own counter resets at local midnight.** That anchors the period and, more usefully, bounds the error: both counters reset together, so our drift cannot accumulate past a single day. Periods are keyed by local date string rather than timestamp, which detects the rollover without converting local wall-clock back to UTC — where daylight saving usually goes wrong. A counter that reads *lower* than the previous sample is treated as a reboot and the new value taken as the delta, rather than as negative usage that would corrupt the running total for the rest of the day. The manual send is deliberately independent of everything else: not gated on the rule being enabled, on a threshold, or on our usage estimate. It is the half of this feature that is trustworthy — one known action, for a reason the operator already has — and it is what makes the automatic half optional rather than load-bearing. The automatic trigger carries a cooldown because usage sits *at* the cap once reached rather than above it; without one, a link parked on its limit would text the carrier on every poll. Sending twice is a nuisance, forty times is an incident. Every send is recorded with the counter reading at the time, so a misfire is explainable rather than mysterious. Accounting logic lives in utils/dataCap.ts with 18 tests, covering the reboot case, the local-midnight rollover across zones and a non-midnight reset, the margin, and the cooldown. Failures in tracking are logged and swallowed: a convenience feature must never break the collection it rides on. Also fixes an error path found while testing. DeviceCollector decrypts the stored credential in its constructor, which sat outside the route's try block, so an unreadable credential escaped as a raw 500 carrying an internal message and left no record of the attempt. It now returns 502, says the credentials could not be read rather than quoting a cipher error at someone hunting a modem fault, and logs the failure like any other. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
4ff8eec200
commit
a06ebd4d71
@@ -812,6 +812,56 @@ CREATE TABLE IF NOT EXISTS device_poll_stats (
|
||||
PRIMARY KEY (device_id, kind)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_poll_stats_attempt ON device_poll_stats(last_attempt_at DESC);
|
||||
|
||||
-- Cellular data-cap SMS (discussion #85).
|
||||
--
|
||||
-- Several EU carriers throttle "unlimited" plans past a daily allowance and lift
|
||||
-- the throttle when the subscriber texts a short code. period_bytes is our own
|
||||
-- reconstruction of usage, accumulated from poll to poll, because the interface
|
||||
-- byte counters do not survive a reboot; last_rx/last_tx hold the previous
|
||||
-- sample so a counter that goes backwards is read as a reboot rather than as
|
||||
-- negative usage.
|
||||
--
|
||||
-- period_key is a local date string rather than a timestamp. Comparing keys is
|
||||
-- how a reset is detected without converting local wall-clock back to UTC, which
|
||||
-- is where daylight saving usually goes wrong.
|
||||
CREATE TABLE IF NOT EXISTS lte_data_cap_rules (
|
||||
id SERIAL PRIMARY KEY,
|
||||
device_id INTEGER NOT NULL REFERENCES devices(id) ON DELETE CASCADE,
|
||||
interface_name VARCHAR(64) NOT NULL,
|
||||
enabled BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
phone_number VARCHAR(32) NOT NULL,
|
||||
message TEXT NOT NULL DEFAULT '',
|
||||
threshold_bytes BIGINT NOT NULL,
|
||||
-- We fire below the configured allowance on purpose; our count undercounts.
|
||||
margin_pct INTEGER NOT NULL DEFAULT 5,
|
||||
reset_hour INTEGER NOT NULL DEFAULT 0,
|
||||
reset_minute INTEGER NOT NULL DEFAULT 0,
|
||||
timezone VARCHAR(64) NOT NULL DEFAULT 'UTC',
|
||||
cooldown_minutes INTEGER NOT NULL DEFAULT 60,
|
||||
period_key VARCHAR(10),
|
||||
period_bytes BIGINT NOT NULL DEFAULT 0,
|
||||
last_rx BIGINT,
|
||||
last_tx BIGINT,
|
||||
last_sent_at TIMESTAMPTZ,
|
||||
updated_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
UNIQUE(device_id, interface_name)
|
||||
);
|
||||
|
||||
-- Every send, with the counter reading at the time, so a misfire is explainable
|
||||
-- rather than mysterious.
|
||||
CREATE TABLE IF NOT EXISTS lte_data_cap_sends (
|
||||
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(),
|
||||
trigger VARCHAR(16) NOT NULL,
|
||||
phone_number VARCHAR(32),
|
||||
period_bytes BIGINT,
|
||||
ok BOOLEAN NOT NULL,
|
||||
error TEXT
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_data_cap_sends ON lte_data_cap_sends(device_id, at DESC);
|
||||
`;
|
||||
|
||||
const DEFAULT_SETTINGS = [
|
||||
|
||||
@@ -2315,6 +2315,135 @@ router.get('/:id/lte', async (req: Request, res: Response) => {
|
||||
}
|
||||
});
|
||||
|
||||
// ─── Cellular data-cap SMS (discussion #85) ──────────────────────────────────
|
||||
|
||||
// GET /api/devices/:id/lte/data-cap — rules and recent sends
|
||||
router.get('/:id/lte/data-cap', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const rules = await query(
|
||||
`SELECT id, interface_name, enabled, phone_number, message, threshold_bytes,
|
||||
margin_pct, reset_hour, reset_minute, timezone, cooldown_minutes,
|
||||
period_key, period_bytes, last_sent_at, updated_at
|
||||
FROM lte_data_cap_rules WHERE device_id = $1 ORDER BY interface_name`,
|
||||
[req.params.id]
|
||||
);
|
||||
const sends = await query(
|
||||
`SELECT interface_name, at, trigger, phone_number, period_bytes, ok, error
|
||||
FROM lte_data_cap_sends WHERE device_id = $1 ORDER BY at DESC LIMIT 20`,
|
||||
[req.params.id]
|
||||
);
|
||||
res.json({ rules, sends });
|
||||
} catch (error) {
|
||||
console.error('Error fetching data-cap rules:', error);
|
||||
res.status(500).json({ error: 'Failed to fetch data-cap rules' });
|
||||
}
|
||||
});
|
||||
|
||||
// PUT /api/devices/:id/lte/data-cap/:iface — create or update a rule
|
||||
router.put('/:id/lte/data-cap/:iface', requireWrite, async (req: Request, res: Response) => {
|
||||
const b = req.body as Record<string, unknown>;
|
||||
const phone = String(b.phone_number ?? '').trim();
|
||||
if (!phone) return res.status(400).json({ error: 'phone_number is required' });
|
||||
|
||||
const threshold = Number(b.threshold_bytes);
|
||||
if (!Number.isFinite(threshold) || threshold <= 0) {
|
||||
return res.status(400).json({ error: 'threshold_bytes must be a positive number' });
|
||||
}
|
||||
// An unknown zone would silently park the counter in the wrong day, so reject
|
||||
// it here rather than discovering it at midnight.
|
||||
const tz = String(b.timezone ?? 'UTC');
|
||||
try { new Intl.DateTimeFormat('en-CA', { timeZone: tz }); }
|
||||
catch { return res.status(400).json({ error: `Unknown timezone "${tz}"` }); }
|
||||
|
||||
const clamp = (v: unknown, lo: number, hi: number, dflt: number) => {
|
||||
const n = Number(v);
|
||||
return Number.isFinite(n) ? Math.min(hi, Math.max(lo, Math.floor(n))) : dflt;
|
||||
};
|
||||
|
||||
try {
|
||||
await query(
|
||||
`INSERT INTO lte_data_cap_rules
|
||||
(device_id, interface_name, enabled, phone_number, message, threshold_bytes,
|
||||
margin_pct, reset_hour, reset_minute, timezone, cooldown_minutes)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11)
|
||||
ON CONFLICT (device_id, interface_name) DO UPDATE SET
|
||||
enabled = EXCLUDED.enabled, phone_number = EXCLUDED.phone_number,
|
||||
message = EXCLUDED.message, threshold_bytes = EXCLUDED.threshold_bytes,
|
||||
margin_pct = EXCLUDED.margin_pct, reset_hour = EXCLUDED.reset_hour,
|
||||
reset_minute = EXCLUDED.reset_minute, timezone = EXCLUDED.timezone,
|
||||
cooldown_minutes = EXCLUDED.cooldown_minutes, updated_at = NOW()`,
|
||||
[req.params.id, req.params.iface, b.enabled === true, phone,
|
||||
String(b.message ?? ''), Math.floor(threshold),
|
||||
clamp(b.margin_pct, 0, 50, 5), clamp(b.reset_hour, 0, 23, 0),
|
||||
clamp(b.reset_minute, 0, 59, 0), tz, clamp(b.cooldown_minutes, 1, 1440, 60)]
|
||||
);
|
||||
res.json({ message: 'Data-cap rule saved' });
|
||||
} catch (error) {
|
||||
console.error('Error saving data-cap rule:', error);
|
||||
res.status(500).json({ error: 'Failed to save rule' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/devices/:id/lte/data-cap/:iface/send — send the SMS now.
|
||||
*
|
||||
* Deliberately independent of the rule being enabled, or of any threshold. This
|
||||
* is the half of the feature that is trustworthy: the automatic trigger depends
|
||||
* on a usage figure we reconstruct and therefore undercount, whereas pressing
|
||||
* the button does exactly one thing for a reason the operator already knows.
|
||||
*/
|
||||
router.post('/:id/lte/data-cap/:iface/send', requireWrite, async (req: Request, res: Response) => {
|
||||
const deviceRow = await queryOne<any>(`SELECT * FROM devices WHERE id = $1`, [req.params.id]);
|
||||
if (!deviceRow) return res.status(404).json({ error: 'Device not found' });
|
||||
|
||||
const rule = await queryOne<{ phone_number: string; message: string; period_bytes: string }>(
|
||||
`SELECT phone_number, message, period_bytes FROM lte_data_cap_rules
|
||||
WHERE device_id = $1 AND interface_name = $2`,
|
||||
[req.params.id, req.params.iface]
|
||||
);
|
||||
const phone = String((req.body as { phone_number?: string })?.phone_number || rule?.phone_number || '').trim();
|
||||
if (!phone) return res.status(400).json({ error: 'No phone number configured for this interface' });
|
||||
|
||||
// Construction is inside the try on purpose: DeviceCollector decrypts the
|
||||
// stored credential in its constructor, so a corrupted or missing one throws
|
||||
// before a single line of the body runs. Left outside, that surfaced as a raw
|
||||
// 500 with an internal message and no record of the attempt.
|
||||
let collector: DeviceCollector | null = null;
|
||||
try {
|
||||
collector = new DeviceCollector(deviceRow);
|
||||
await collector.connect();
|
||||
await collector.sendSms(req.params.iface, phone, rule?.message ?? '');
|
||||
await query(
|
||||
`INSERT INTO lte_data_cap_sends (device_id, interface_name, trigger, phone_number, period_bytes, ok)
|
||||
VALUES ($1,$2,'manual',$3,$4,TRUE)`,
|
||||
[deviceRow.id, req.params.iface, phone, rule ? Number(rule.period_bytes) : null]
|
||||
).catch(() => {});
|
||||
await query(
|
||||
`UPDATE lte_data_cap_rules SET last_sent_at = NOW()
|
||||
WHERE device_id = $1 AND interface_name = $2`,
|
||||
[deviceRow.id, req.params.iface]
|
||||
).catch(() => {});
|
||||
res.json({ message: `SMS sent to ${phone}.` });
|
||||
} catch (error) {
|
||||
const raw = (error as Error).message;
|
||||
// Credential problems are ours, not the modem's, and saying "could not send
|
||||
// SMS: invalid encrypted text format" would send someone hunting the wrong
|
||||
// fault entirely.
|
||||
const msg = /encrypt|decrypt/i.test(raw)
|
||||
? 'stored credentials for this device could not be read'
|
||||
: raw;
|
||||
await query(
|
||||
`INSERT INTO lte_data_cap_sends (device_id, interface_name, trigger, phone_number, ok, error)
|
||||
VALUES ($1,$2,'manual',$3,FALSE,$4)`,
|
||||
[deviceRow.id, req.params.iface, phone, msg]
|
||||
).catch(() => {});
|
||||
console.error('Error sending data-cap SMS:', raw);
|
||||
res.status(502).json({ error: `Could not send SMS: ${msg}` });
|
||||
} finally {
|
||||
collector?.disconnect();
|
||||
}
|
||||
});
|
||||
|
||||
// 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 };
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
import {
|
||||
parseLteMonitor, overallQuality, detectChanges, type LteStatus,
|
||||
} from '../../utils/lte';
|
||||
import { bytesSince, periodKey, shouldSend } from '../../utils/dataCap';
|
||||
import { BackupService } from '../BackupService';
|
||||
import { alertService } from '../AlertService';
|
||||
|
||||
@@ -2850,6 +2851,33 @@ export class DeviceCollector {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send an SMS through the modem.
|
||||
*
|
||||
* Used for carrier data-cap resets, where texting a short code lifts a daily
|
||||
* throttle. The message is deliberately allowed to be empty — that is exactly
|
||||
* what several carriers expect, and an API that refused it would be useless
|
||||
* for the case it exists to serve.
|
||||
*/
|
||||
async sendSms(port: string, phoneNumber: string, message = ''): Promise<void> {
|
||||
await this.client.execute('/tool/sms/send', {
|
||||
port, 'phone-number': phoneNumber, message,
|
||||
});
|
||||
}
|
||||
|
||||
/** Current byte counters for one interface, for data-cap accounting. */
|
||||
async getInterfaceCounters(name: string): Promise<{ rxBytes: number; txBytes: number } | null> {
|
||||
const rows = await this.client
|
||||
.execute('/interface/print', { stats: '', '?name': name })
|
||||
.catch(() => [] as Record<string, string>[]);
|
||||
const row = rows.find((r) => r['name'] === name) || rows[0];
|
||||
if (!row) return null;
|
||||
return {
|
||||
rxBytes: parseInt(row['rx-byte'] || '0', 10),
|
||||
txBytes: parseInt(row['tx-byte'] || '0', 10),
|
||||
};
|
||||
}
|
||||
|
||||
// ─── LTE / cellular → Postgres + InfluxDB ─────────────────────────────────
|
||||
|
||||
/**
|
||||
@@ -2945,6 +2973,7 @@ export class DeviceCollector {
|
||||
);
|
||||
|
||||
await this.recordObservedBands(name, status);
|
||||
await this.trackDataCap(name);
|
||||
|
||||
// Signal series. Fields are written only when the modem reported them —
|
||||
// a zero here would read as a signal no transmitter produces.
|
||||
@@ -2980,6 +3009,93 @@ export class DeviceCollector {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Accumulate cellular usage and fire the carrier's reset SMS when due.
|
||||
*
|
||||
* Runs on every LTE poll rather than on a timer of its own, because the byte
|
||||
* delta is only meaningful between consecutive samples — a separate schedule
|
||||
* would either double-count or miss traffic depending on how the two drifted.
|
||||
*
|
||||
* Failures here are logged and swallowed. A data-cap rule is a convenience;
|
||||
* it must never be able to break the collection it rides on.
|
||||
*/
|
||||
private async trackDataCap(interfaceName: string): Promise<void> {
|
||||
try {
|
||||
const rule = await queryOne<{
|
||||
id: number; enabled: boolean; phone_number: string; message: string;
|
||||
threshold_bytes: string; margin_pct: number; reset_hour: number; reset_minute: number;
|
||||
timezone: string; cooldown_minutes: number; period_key: string | null;
|
||||
period_bytes: string; last_rx: string | null; last_tx: string | null;
|
||||
last_sent_at: string | null;
|
||||
}>(
|
||||
`SELECT * FROM lte_data_cap_rules WHERE device_id = $1 AND interface_name = $2`,
|
||||
[this.device.id, interfaceName]
|
||||
);
|
||||
if (!rule) return;
|
||||
|
||||
const counters = await this.getInterfaceCounters(interfaceName);
|
||||
if (!counters) return;
|
||||
|
||||
const now = new Date();
|
||||
const key = periodKey(now, rule.reset_hour, rule.reset_minute, rule.timezone);
|
||||
|
||||
// A new period starts fresh, but the counter baseline carries over —
|
||||
// otherwise the first sample after midnight would be discarded and the
|
||||
// traffic between it and the previous poll would vanish.
|
||||
const rolled = rule.period_key !== key;
|
||||
const prev = rule.last_rx == null || rule.last_tx == null
|
||||
? null
|
||||
: { rxBytes: Number(rule.last_rx), txBytes: Number(rule.last_tx) };
|
||||
|
||||
const delta = bytesSince(prev, counters);
|
||||
const periodBytes = (rolled ? 0 : Number(rule.period_bytes)) + delta;
|
||||
|
||||
const decision = shouldSend({
|
||||
periodBytes,
|
||||
thresholdBytes: Number(rule.threshold_bytes),
|
||||
marginPct: rule.margin_pct,
|
||||
lastSentAt: rolled ? null : (rule.last_sent_at ? new Date(rule.last_sent_at) : null),
|
||||
cooldownMinutes: rule.cooldown_minutes,
|
||||
enabled: rule.enabled,
|
||||
}, now);
|
||||
|
||||
let sentAt: Date | null = rolled ? null : (rule.last_sent_at ? new Date(rule.last_sent_at) : null);
|
||||
if (decision.send) {
|
||||
try {
|
||||
await this.sendSms(interfaceName, rule.phone_number, rule.message);
|
||||
sentAt = now;
|
||||
await this.logDataCapSend(interfaceName, 'threshold', rule.phone_number, periodBytes, true, null);
|
||||
console.log(`[DataCap] ${this.device.name}/${interfaceName}: reset SMS sent to ${rule.phone_number}`);
|
||||
} catch (e) {
|
||||
await this.logDataCapSend(interfaceName, 'threshold', rule.phone_number, periodBytes, false, (e as Error).message);
|
||||
console.error(`[DataCap] ${this.device.name}/${interfaceName}: SMS failed:`, (e as Error).message);
|
||||
}
|
||||
}
|
||||
|
||||
await query(
|
||||
`UPDATE lte_data_cap_rules
|
||||
SET period_key = $2, period_bytes = $3, last_rx = $4, last_tx = $5,
|
||||
last_sent_at = $6, updated_at = NOW()
|
||||
WHERE id = $1`,
|
||||
[rule.id, key, periodBytes, counters.rxBytes, counters.txBytes, sentAt]
|
||||
);
|
||||
} catch (e) {
|
||||
console.error(`[DataCap] ${this.device.name}: tracking failed:`, (e as Error).message);
|
||||
}
|
||||
}
|
||||
|
||||
private async logDataCapSend(
|
||||
interfaceName: string, trigger: string, phone: string,
|
||||
periodBytes: number, ok: boolean, error: string | null,
|
||||
): Promise<void> {
|
||||
await query(
|
||||
`INSERT INTO lte_data_cap_sends
|
||||
(device_id, interface_name, trigger, phone_number, period_bytes, ok, error)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7)`,
|
||||
[this.device.id, interfaceName, trigger, phone, periodBytes, ok, error]
|
||||
).catch(() => {});
|
||||
}
|
||||
|
||||
/** Last stored state for an interface, for comparing consecutive polls. */
|
||||
private async previousLteStatus(name: string): Promise<LteStatus | null> {
|
||||
const row = await queryOne<{
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
import { bytesSince, periodKey, effectiveThreshold, shouldSend } from '../dataCap';
|
||||
|
||||
const GB = 1024 ** 3;
|
||||
|
||||
describe('bytesSince', () => {
|
||||
it('establishes a baseline on first sight rather than counting everything', () => {
|
||||
expect(bytesSince(null, { rxBytes: 5_000, txBytes: 1_000 })).toBe(0);
|
||||
});
|
||||
|
||||
it('sums both directions, as the carrier does', () => {
|
||||
expect(bytesSince({ rxBytes: 100, txBytes: 50 }, { rxBytes: 400, txBytes: 150 })).toBe(400);
|
||||
});
|
||||
|
||||
it('treats a counter that went backwards as a reboot, not negative usage', () => {
|
||||
// Confirmed on the target hardware: LTE byte counters do not survive a reboot.
|
||||
expect(bytesSince({ rxBytes: 9_000, txBytes: 9_000 }, { rxBytes: 10, txBytes: 5 })).toBe(15);
|
||||
});
|
||||
|
||||
it('handles one direction resetting while the other does not', () => {
|
||||
expect(bytesSince({ rxBytes: 9_000, txBytes: 100 }, { rxBytes: 10, txBytes: 400 })).toBe(310);
|
||||
});
|
||||
|
||||
it('ignores nonsense counters instead of poisoning the total', () => {
|
||||
expect(bytesSince({ rxBytes: 100, txBytes: 100 }, { rxBytes: NaN, txBytes: -5 })).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('periodKey', () => {
|
||||
const SOFIA = 'Europe/Sofia'; // UTC+3 in September
|
||||
|
||||
it('groups an instant into the local day', () => {
|
||||
expect(periodKey(new Date('2026-09-02T09:00:00Z'), 0, 0, SOFIA)).toBe('2026-09-02');
|
||||
});
|
||||
|
||||
it('puts the hour before local midnight in the previous period', () => {
|
||||
// 21:30 UTC is 00:30 on the 3rd in Sofia — a new period.
|
||||
expect(periodKey(new Date('2026-09-02T21:30:00Z'), 0, 0, SOFIA)).toBe('2026-09-03');
|
||||
// 20:30 UTC is 23:30 on the 2nd — still the old one.
|
||||
expect(periodKey(new Date('2026-09-02T20:30:00Z'), 0, 0, SOFIA)).toBe('2026-09-02');
|
||||
});
|
||||
|
||||
it('respects a non-midnight reset time', () => {
|
||||
// Reset at 04:00 local: 02:00 local still belongs to the previous day.
|
||||
expect(periodKey(new Date('2026-09-02T23:00:00Z'), 4, 0, SOFIA)).toBe('2026-09-02');
|
||||
expect(periodKey(new Date('2026-09-03T01:30:00Z'), 4, 0, SOFIA)).toBe('2026-09-03');
|
||||
});
|
||||
|
||||
it('rolls the month and year correctly', () => {
|
||||
expect(periodKey(new Date('2027-01-01T00:30:00Z'), 4, 0, 'UTC')).toBe('2026-12-31');
|
||||
});
|
||||
|
||||
it('falls back to UTC for an unknown zone rather than throwing', () => {
|
||||
expect(periodKey(new Date('2026-09-02T09:00:00Z'), 0, 0, 'Mars/Base')).toBe('2026-09-02');
|
||||
});
|
||||
});
|
||||
|
||||
describe('effectiveThreshold', () => {
|
||||
it('fires below the configured allowance, because our count undercounts', () => {
|
||||
expect(effectiveThreshold(10 * GB, 5)).toBe(Math.floor(10 * GB * 0.95));
|
||||
});
|
||||
it('allows no margin when asked', () => {
|
||||
expect(effectiveThreshold(10 * GB, 0)).toBe(10 * GB);
|
||||
});
|
||||
it('refuses an absurd margin', () => {
|
||||
expect(effectiveThreshold(10 * GB, 90)).toBe(effectiveThreshold(10 * GB, 50));
|
||||
});
|
||||
});
|
||||
|
||||
describe('shouldSend', () => {
|
||||
const base = {
|
||||
thresholdBytes: 10 * GB, marginPct: 5, cooldownMinutes: 60,
|
||||
lastSentAt: null as Date | null, enabled: true,
|
||||
};
|
||||
const NOW = new Date('2026-09-02T12:00:00Z');
|
||||
|
||||
it('stays quiet below the threshold', () => {
|
||||
expect(shouldSend({ ...base, periodBytes: 5 * GB }, NOW).reason).toBe('below-threshold');
|
||||
});
|
||||
|
||||
it('fires at the margin-adjusted threshold, not the raw one', () => {
|
||||
expect(shouldSend({ ...base, periodBytes: 9.6 * GB }, NOW).send).toBe(true);
|
||||
});
|
||||
|
||||
it('will not fire again inside the cooldown', () => {
|
||||
// Usage sits *at* the cap once reached; without this it would text on every poll.
|
||||
const justSent = new Date(NOW.getTime() - 10 * 60_000);
|
||||
const d = shouldSend({ ...base, periodBytes: 11 * GB, lastSentAt: justSent }, NOW);
|
||||
expect(d).toEqual({ send: false, reason: 'cooling-down' });
|
||||
});
|
||||
|
||||
it('fires again once the cooldown has passed', () => {
|
||||
const old = new Date(NOW.getTime() - 90 * 60_000);
|
||||
expect(shouldSend({ ...base, periodBytes: 11 * GB, lastSentAt: old }, NOW).send).toBe(true);
|
||||
});
|
||||
|
||||
it('does nothing when disabled, whatever the usage', () => {
|
||||
expect(shouldSend({ ...base, periodBytes: 99 * GB, enabled: false }, NOW).reason).toBe('disabled');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,118 @@
|
||||
/**
|
||||
* Daily data-cap tracking for cellular links, and when to trigger the SMS that
|
||||
* lifts an operator's throttle.
|
||||
*
|
||||
* Several European carriers sell "unlimited" mobile data that is throttled hard
|
||||
* once a daily allowance is used, and lift the throttle when the subscriber
|
||||
* texts a short code. The counter that matters lives on the carrier's side and
|
||||
* resets at local midnight; ours is a reconstruction, and the gap between the
|
||||
* two is what this module has to be honest about.
|
||||
*
|
||||
* **Our figure is always an undercount.** Interface byte counters do not survive
|
||||
* a reboot — confirmed on the hardware this was built for — so anything used
|
||||
* before the device was adopted, during a reboot, or between a missed poll and
|
||||
* the next one is invisible to us. Undercounting means triggering *late*, and
|
||||
* late means the user is already throttled. So thresholds are meant to be set
|
||||
* below the real allowance, and the default margin below does that for them.
|
||||
*
|
||||
* The saving grace is that the carrier resets daily too, so the error cannot
|
||||
* accumulate past one period.
|
||||
*/
|
||||
|
||||
/** Percentage of the configured threshold at which we actually fire. */
|
||||
export const DEFAULT_SAFETY_MARGIN_PCT = 5;
|
||||
|
||||
export interface CounterSample {
|
||||
rxBytes: number;
|
||||
txBytes: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Bytes used since the previous sample.
|
||||
*
|
||||
* A counter that has gone *backwards* means the device rebooted and zeroed it.
|
||||
* The correct reading then is the new counter itself — everything before the
|
||||
* reboot is unrecoverable, and treating the drop as negative usage would corrupt
|
||||
* the running total for the rest of the day.
|
||||
*/
|
||||
export function bytesSince(prev: CounterSample | null, cur: CounterSample): number {
|
||||
const safe = (n: number) => (Number.isFinite(n) && n >= 0 ? n : 0);
|
||||
const rx = safe(cur.rxBytes);
|
||||
const tx = safe(cur.txBytes);
|
||||
if (!prev) return 0; // first sight establishes a baseline only
|
||||
const dRx = rx < safe(prev.rxBytes) ? rx : rx - safe(prev.rxBytes);
|
||||
const dTx = tx < safe(prev.txBytes) ? tx : tx - safe(prev.txBytes);
|
||||
return dRx + dTx;
|
||||
}
|
||||
|
||||
/**
|
||||
* Which accounting period an instant falls in, as a local date string.
|
||||
*
|
||||
* Comparing period keys is how a reset is detected, and it sidesteps converting
|
||||
* a local wall-clock time back into UTC — which is where naive implementations
|
||||
* get daylight saving wrong. Before the reset hour, the instant still belongs to
|
||||
* the previous day's period.
|
||||
*/
|
||||
export function periodKey(now: Date, resetHour: number, resetMinute: number, timeZone: string): string {
|
||||
let y: number, m: number, d: number, hh: number, mm: number;
|
||||
try {
|
||||
const parts = Object.fromEntries(
|
||||
new Intl.DateTimeFormat('en-CA', {
|
||||
timeZone, hour12: false,
|
||||
year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit',
|
||||
}).formatToParts(now).map((p) => [p.type, p.value])
|
||||
);
|
||||
y = Number(parts.year); m = Number(parts.month); d = Number(parts.day);
|
||||
hh = Number(parts.hour) % 24; mm = Number(parts.minute);
|
||||
} catch {
|
||||
y = now.getUTCFullYear(); m = now.getUTCMonth() + 1; d = now.getUTCDate();
|
||||
hh = now.getUTCHours(); mm = now.getUTCMinutes();
|
||||
}
|
||||
|
||||
// Before the reset time, we are still in yesterday's period.
|
||||
if (hh * 60 + mm < resetHour * 60 + resetMinute) {
|
||||
const shifted = new Date(Date.UTC(y, m - 1, d));
|
||||
shifted.setUTCDate(shifted.getUTCDate() - 1);
|
||||
y = shifted.getUTCFullYear(); m = shifted.getUTCMonth() + 1; d = shifted.getUTCDate();
|
||||
}
|
||||
return `${y}-${String(m).padStart(2, '0')}-${String(d).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
/** Bytes at which we actually fire, given a configured allowance. */
|
||||
export function effectiveThreshold(thresholdBytes: number, marginPct = DEFAULT_SAFETY_MARGIN_PCT): number {
|
||||
const pct = Math.min(50, Math.max(0, marginPct));
|
||||
return Math.max(0, Math.floor(thresholdBytes * (1 - pct / 100)));
|
||||
}
|
||||
|
||||
export interface TriggerState {
|
||||
periodBytes: number;
|
||||
thresholdBytes: number;
|
||||
marginPct?: number;
|
||||
lastSentAt: Date | null;
|
||||
cooldownMinutes: number;
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
export interface TriggerDecision {
|
||||
send: boolean;
|
||||
reason: 'below-threshold' | 'cooling-down' | 'disabled' | 'threshold-reached';
|
||||
}
|
||||
|
||||
/**
|
||||
* Should an automatic SMS go out right now?
|
||||
*
|
||||
* The cooldown exists because usage sits *at* the cap once reached, not above
|
||||
* it — without one, a link parked on its limit would text the carrier on every
|
||||
* poll. Sending twice is a nuisance; sending forty times is an incident.
|
||||
*/
|
||||
export function shouldSend(state: TriggerState, now: Date): TriggerDecision {
|
||||
if (!state.enabled) return { send: false, reason: 'disabled' };
|
||||
if (state.periodBytes < effectiveThreshold(state.thresholdBytes, state.marginPct)) {
|
||||
return { send: false, reason: 'below-threshold' };
|
||||
}
|
||||
if (state.lastSentAt) {
|
||||
const elapsedMin = (now.getTime() - state.lastSentAt.getTime()) / 60_000;
|
||||
if (elapsedMin < state.cooldownMinutes) return { send: false, reason: 'cooling-down' };
|
||||
}
|
||||
return { send: true, reason: 'threshold-reached' };
|
||||
}
|
||||
Reference in New Issue
Block a user