Alert on genuinely new (never-seen-before) devices
New seen_macs table: permanent, insert-only, MAC-keyed record of the first time each device was ever seen -- deliberately decoupled from devices.first_seen (IP-keyed, would false-positive on every DHCP lease change). Bootstrap-safe: first call seeds the baseline from whatever's currently on the network without alerting on all 71+ existing devices at once. Verified locally: bootstrap call reports nothing new, repeat calls with the same MACs report nothing new, one genuinely new MAC gets reported exactly once. Pushes a Home Assistant persistent_notification when a new MAC appears (gated behind HOME_ASSISTANT_TOKEN + homeAssistant.url in hosts.yaml -- missing config just means no push, detection still runs). Also surfaced directly in the dashboard as a blue "new" badge for anything first seen in the last 24h, independent of HA config. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -53,6 +53,16 @@ export function openDb(path: string): Database.Database {
|
||||
label TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
-- Insert-only, permanent record of the first time each MAC was ever seen.
|
||||
-- Deliberately separate from the devices table (keyed by IP, which churns
|
||||
-- on DHCP renewal) -- keying "have we ever seen this MAC" by IP would
|
||||
-- generate a false new-device alert every time an existing device's
|
||||
-- lease just happened to change.
|
||||
CREATE TABLE IF NOT EXISTS seen_macs (
|
||||
mac TEXT PRIMARY KEY,
|
||||
first_seen TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
`);
|
||||
// Migration for the devices table pre-dating mdns_hostname (CREATE TABLE IF
|
||||
// NOT EXISTS above doesn't touch already-existing tables). SQLite's ALTER
|
||||
@@ -185,6 +195,7 @@ export interface DeviceRow {
|
||||
manual_label: string | null;
|
||||
first_seen: string;
|
||||
last_seen: string;
|
||||
first_ever_seen: string | null;
|
||||
}
|
||||
|
||||
// Devices not seen in the last 24h (unplugged, moved, DHCP lease expired) are
|
||||
@@ -192,13 +203,17 @@ export interface DeviceRow {
|
||||
// there's no persistent per-device up/down state to track like hosts have.
|
||||
// Manual labels are joined by MAC (survives IP changes) and take priority —
|
||||
// see effectiveName() in routes/devices.ts for the full precedence order.
|
||||
// first_ever_seen comes from seen_macs (permanent, MAC-keyed), not
|
||||
// devices.first_seen (IP-keyed, resets on DHCP lease change) — see
|
||||
// recordSeenMacs()'s comment for why that distinction matters.
|
||||
export function getRecentDevices(db: Database.Database, sinceHours = 24): DeviceRow[] {
|
||||
return db
|
||||
.prepare(
|
||||
`SELECT d.ip, d.mac, d.known_name, d.mdns_hostname, l.label AS manual_label,
|
||||
d.first_seen, d.last_seen
|
||||
d.first_seen, d.last_seen, s.first_seen AS first_ever_seen
|
||||
FROM devices d
|
||||
LEFT JOIN device_labels l ON l.mac = d.mac
|
||||
LEFT JOIN seen_macs s ON s.mac = d.mac
|
||||
WHERE d.last_seen > datetime('now', @cutoff)
|
||||
ORDER BY (l.label IS NULL AND d.known_name IS NULL), d.ip`
|
||||
)
|
||||
@@ -222,3 +237,24 @@ export function setDeviceLabel(db: Database.Database, mac: string, label: string
|
||||
export function clearDeviceLabel(db: Database.Database, mac: string): void {
|
||||
db.prepare(`DELETE FROM device_labels WHERE mac = @mac`).run({ mac });
|
||||
}
|
||||
|
||||
// Records every MAC seen this discovery cycle, returning only the ones never
|
||||
// recorded before. On the very first run (empty table), the whole current
|
||||
// device population would otherwise look "new" and trigger an alert storm --
|
||||
// that first call just seeds the baseline and reports nothing as new.
|
||||
export function recordSeenMacs(db: Database.Database, macs: string[]): string[] {
|
||||
const isBootstrap = (db.prepare(`SELECT COUNT(*) AS n FROM seen_macs`).get() as { n: number }).n === 0;
|
||||
|
||||
const alreadySeen = new Set(
|
||||
(db.prepare(`SELECT mac FROM seen_macs`).all() as { mac: string }[]).map((r) => r.mac)
|
||||
);
|
||||
const newMacs = macs.filter((mac) => !alreadySeen.has(mac));
|
||||
|
||||
const insert = db.prepare(`INSERT OR IGNORE INTO seen_macs (mac) VALUES (@mac)`);
|
||||
const tx = db.transaction((items: string[]) => {
|
||||
for (const mac of items) insert.run({ mac });
|
||||
});
|
||||
tx(macs);
|
||||
|
||||
return isBootstrap ? [] : newMacs;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user