Backend: notifications now show a plain "-" for an absent name, vendor, or
device type (was empty string / "(unknown)" / "Unknown"), via a single
UNKNOWN_PLACEHOLDER constant.
Frontend:
- Empty/unknown values render as an em dash everywhere, centralised in a new
Placeholders.emptyValue constant (replaces inline '—' and '(unknown)').
- Route paths moved to a new Routes class, used by the router and every
navigation call site.
- Device event type modelled as a DeviceEventType enum mirroring the backend
(NewDevice/DeviceSeen) instead of bare string comparisons.
- Hardcoded EdgeInsets/SizedBox spacing replaced with existing Insets tokens.
Tests and formatting updated; all backend and frontend tests pass.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The Flutter web bundle was built with the default base href of "/" but is
served under "/web/", so index.html loaded while every asset reference
resolved to the site root and 404'd, leaving a blank page. Build the bundle
with --base-href=/web/ so asset URLs match the mount point.
Also correct the root guidance text (the API explorer is at /api/docs, not
/api) and add an /api -> /api/docs redirect for convenience.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Active scanners (ARP, SNMP) now accumulate every change across a whole
scan and emit one notification per type via events::notify: a single
device produces the usual single-device notification (carrying its MAC),
while two or more produce one consolidated summary with an empty
mac_address. Device events are still recorded per device.
Notification bodies no longer include MAC or IP addresses; the title
MAC fallback is masked to the last two octets. Summaries list up to
three devices then "…and N more devices".
Split sighting handling so record_sighting persists + records the event
and returns Vec<DeviceChange>; passive listeners (mDNS, SSDP, DHCP) use
record_and_notify since they see one device per event.
Also fixes NotificationType::from_str never mapping "DeviceChanged",
which made those notifications round-trip from the DB as Other.
Frontend: the card already hides the device link when mac_address is
null; added widget tests for the present/absent link cases.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Enabling WAL per pooled connection raced several concurrent journal-mode
switches when r2d2 eagerly opens the pool at startup, producing transient
"disk I/O error" and "database is locked" failures. WAL is a persistent
property of the database file, so enable it once in init_db (before the
scanners and web server start) and keep only the genuinely per-connection
pragmas (busy_timeout, synchronous, foreign_keys) in the pool initializer.
Also remove the -wal/-shm sidecars in run.sh: deleting oott.db but leaving
a stale write-ahead log behind makes SQLite open the fresh database against
an orphaned log and fail with "disk I/O error".
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Tune SQLite and remove blocking calls from async/scan paths:
- Enable WAL + synchronous=NORMAL + busy_timeout + foreign_keys on each
pooled connection, so the five scanners, web server, and retention no
longer contend on the default rollback journal / FULL fsync.
- Run DB work in axum handlers via spawn_blocking (db::run_blocking) so
synchronous rusqlite calls no longer block tokio worker threads.
- Deliver notifications on a dedicated task fed by a bounded channel; the
blocking Pushover HTTP call runs in spawn_blocking, so a slow or
unreachable Pushover can never stall device discovery.
- Make get_db_connection() return Result instead of panicking, so pool
exhaustion surfaces as a 500 rather than crashing the process.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add ActiveStatusCell/PassiveStatusCell wrappers in the scanners common
module so the five per-scanner status.rs files reduce to a single static;
replace parse_parameter_bool/int/string with one generic parse_parameter
over FromStr; extract the shared LIMIT/OFFSET paging clause into
db::apply_paging; and drop a no-op for-loop in the ARP sender.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The ARP and SNMP scanners reported every online sighting, so a device
seen on multiple IPs or via duplicate ARP replies was counted more than
once. Fold the dedup into ActiveStatus::record_scan, which now takes the
device slice and reports the number of distinct MAC addresses.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The list endpoints now return a total count alongside the page so the
front-end can show how many pages exist and offer a last-page jump.
Backend: add count(is_new) and count_devices(...) (sharing a WHERE-builder
with list_devices so page and count can't drift), wrap both list responses
in {items, total_count} structs, and register them with utoipa.
Front-end: parse the wrapper shape (dropping the fetch-one-extra trick),
add a Last-page button and a responsive "Page X of Y" / "X / Y" label to
the shared PaginationBar, and track the total in both lists. Notifications
re-sync the count on every fetch and decrement it locally on mark-read/
unread removals so the count stays accurate without a re-fetch.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The pushover config section is now only required when notifications.method
is "pushover". Validation at startup rejects the missing-section case so a
misconfiguration fails fast instead of erroring on every notification.
Updates the sample TOML, README and nix module for consistency.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Several settings were required at parse time even though they had a
sensible default, causing the backend to panic on startup when omitted:
- notifications.notify_when_not_seen_for now defaults to "1w"
- the whole [networking] section is now optional (no mandatory fields)
- scanner duration/timeout fields (ARP, mDNS, SSDP, SNMP) now fall back
to their defaults when the section is present but the field is omitted.
serde only applies a field default when marked #[serde(default)], so
the per-field attributes were added and the Default impls now share the
same default functions as the single source of truth.
Updates the sample TOML and README accordingly and adds tests covering
each defaulting case.
Fall back to "0.0.0.0" and 3000 when these fields are omitted from the
[web_server] section (api_key remains required). Update the sample TOML
and README to reflect the new defaults; the Nix module already used them.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Make the [log] section and its level field optional, falling back to
"warn" when omitted. Update the sample TOML, Nix module and README to
reflect the new default.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The version string was duplicated across six places. Collapse it to two
ecosystem sources of truth and derive the rest:
- backend/src/web_server.rs: omit the OpenAPI info.version so utoipa fills
it from CARGO_PKG_VERSION (backend/Cargo.toml); add a test pinning this.
- nix/package.nix: read the version from backend/Cargo.toml via fromTOML.
- nix/frontend.nix: read the version from frontend/pubspec.yaml by splitting
into lines (a whole-file regex triggers catastrophic backtracking in Nix's
regex engine).
- frontend/lib/about/about.dart: read the version at runtime via
package_info_plus instead of a hardcoded constant.
Also drop frontend/pubspec.lock.json: it is unreferenced (Nix's
autoPubspecLock generates its own JSON from pubspec.lock) and was going stale.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
When the same scanner sees the same device (same MAC and IPv4) again within
a configurable window (default 1 minute), only one device_events row is
recorded, keeping the events table from filling with near-identical rows.
Device last_seen updates and notifications are unaffected.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The five scanners (ARP, SNMP, mDNS, SSDP, DHCP) duplicated their
persist-and-notify pipeline, device enrichment, and status state
machines across same-family files. Extract the shared logic so a change
lands in one place instead of three to five.
- scanners/common/pipeline.rs: single record_sighting() persist+notify
path, replacing the per-scanner match blocks. ARP/SNMP now use the
same merge rules as the passive scanners (keep a stored hostname,
never overwrite a known IP with an empty one).
- scanners/common/enrichment.rs: build_device() for vendor/device-type
lookup with the privacy-MAC service fallback.
- scanners/common/{active,passive}_status.rs: the two status state
machines plus their tests, written once. Each scanner status.rs is now
a thin wrapper over its own static.
- utils/network::format_mac(): replaces three identical copies.
- web_server/scanner_status.rs: Active/Passive response types and two
handler helpers, replacing five near-identical structs+handlers. JSON
field names are unchanged so the frontend is unaffected; only OpenAPI
schema names change.
27 files changed, ~900 lines net removed. Build, clippy and all 113
tests (4 new) pass.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Replace the lifetime "devices seen since start" counter in the mDNS, SSDP
and DHCP scanners with a rolling count of distinct devices (deduped by
MAC) seen within the last hour.
Each scanner's status now tracks a MAC -> last-seen-time map; the snapshot
prunes entries older than 60 minutes and reports the remaining count. The
API field name (devices_seen) is unchanged, so only its meaning and the
frontend labels ("N devices in the last hour") are updated.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The ARP and SNMP scanner status endpoints and front-end cards now expose
the number of devices found by the most recent successful scan, following
the existing mDNS device-count pattern. The count persists across the
running/waiting transitions, and for SNMP a failed poll keeps the last
good count rather than overwriting it.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add an ArpScanner Default impl (30m/1m/10m, enabled) and mark the
arp_scanner field with serde default, so the [arp_scanner] section can
now be omitted entirely and fall back to code defaults — matching the
pattern used by the SNMP scanner. Previously these three durations were
mandatory and the backend would not start without them.
Reconcile the documentation to the canonical 30m/1m/10m: fix the README
NixOS example (was 15m/20m/30m) and options table (was 15m), and note
that the section is optional in both the README and sample TOML. The
NixOS module and sample TOML already used these values.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A typical router keeps active devices' ARP entries fresh continuously,
so a 10-minute poll interval stays well within common ARP cache TTLs
while keeping device-event churn modest (each poll records a DeviceSeen
event per device). A 5s per-request timeout adds margin for a busy
agent or large ARP table at no cost on the happy path.
Updates the default in settings.rs and the sample TOML, README and
NixOS module. TODO timing-review item narrowed to the ARP scanner.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Introduce a sixth scanner that periodically queries an SNMP agent
(typically the router/firewall) for its ARP/neighbour cache via
SNMPv2c and feeds discovered devices into the shared devices, events
and notifications pipeline. Unlike the ARP scanner it generates no
traffic on the local segment and can surface devices across all
subnets the agent routes.
Scope is intentionally minimal: SNMPv2c only, a single target, and the
ipNetToMediaTable (ARP) only. SNMPv3 and switch MAC/forwarding-table
polling are left as follow-ups in TODO.md.
- backend: csnmp dependency; SnmpScanner config (opt-in, off unless a
[snmp_scanner] section is present); DeviceEventScanner::Snmp;
scanners/snmp/{finder,scanner,status}; main.rs wiring; status API
endpoint wired into OpenAPI
- frontend: SNMP scanner status model, card, API method, status screen
and summary card rows, and device-event label
- docs/config: sample TOML, README options, NixOS module option, and
setup notes for enabling SNMP on pfSense/OPNsense
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- Build the Flutter web app at release time (nix/frontend.nix) and bundle
it next to the binary at $out/share/oott/web; resolve it at runtime
relative to the executable. Remove the prebuilt backend/web from git.
- Add web_server.{ip_address,port,api_key} options to the NixOS module so
the generated config deserializes (was missing, causing a startup panic).
- Docker image: set SSL_CERT_FILE for outbound TLS, drop the heavy
nixos/nix base image, trim contents to [oott cacert], and ensure /tmp
exists.
- Remove the unused "nix" flake input and commit flake.lock.
- Provide Swagger UI to utoipa-swagger-ui offline via a pinned fetchurl so
the package builds in the Nix sandbox; skip the redundant check phase
(tests run via backend/run_tests.sh).
- sample_oott.toml: set database.path to /db/oott.db for the Docker image.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Each scanner (ARP, mDNS, SSDP/UPnP, DHCP) can now be turned off via an
`enabled` flag in its config section, defaulting to true so existing
deployments are unchanged. A disabled scanner's entry function returns
early and never starts.
Documented in the README options table (also fixing the stale `timings.*`
key names to the actual `arp_scanner.*` keys), and added to the TOML
samples and the NixOS module.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Listen for DHCP DISCOVER/REQUEST broadcasts on UDP 67 to catch devices as
early as possible — a device must request an address before doing almost
anything else, often before it has an IP.
Follows the mDNS/SSDP scanner pattern (finder/scanner/status modules) and
feeds the shared devices/events/notifications pipeline. The client MAC is
taken directly from the packet's chaddr, so no ARP probe is needed; a
DISCOVER with no assigned IP reuses any previously recorded address rather
than clobbering it. Exposes GET /api/dhcp_scanner/status, wired into the
OpenAPI generation, and adds a Dhcp variant to DeviceEventScanner.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Introduce a new SSDP/UPnP network scanner alongside the existing ARP and
mDNS scanners:
- New scanner module under scanners/ssdp (finder, scanner, status)
- Wire the scanner into the main scan loop
- Add Ssdp variant to DeviceEventScanner
- Add SsdpScanner settings with a configurable probe timeout
- Expose /api/ssdp_scanner/status and wire it into OpenAPI generation
- Add a lint.sh helper and point CLAUDE.md at it
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Make each Pushover alert triageable from a lock-screen preview: titles now
carry the device's identity (hostname, vendor, or MAC suffix) and a concise
verb, and bodies are sectioned (Device / Status / Activity or Changes) with
the registration line and device type so a user can decide whether to act
without opening the app. Adds a security hint on new-unregistered devices
and on vendor-change-on-same-MAC (a MAC-spoofing tell); IP-only changes
stay quiet since DHCP rotation is normal. Rendering moves into pure helpers
covered by unit tests.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
So that consumers of the events API can tell whether a sighting came
from the ARP scanner or the mDNS listener. Adds a DeviceEventScanner
enum (Arp / Mdns) plumbed from each scanner's call site through to a
new scanner column on device_events, exposed via the existing events
endpoint and OpenAPI schema. Pre-existing rows are backfilled with
'ARP' since that was the only scanner before mDNS.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Add COLLATE NOCASE on every text content column (names, owners, vendors,
device types, MAC addresses, notification titles/bodies/types, event
types) so DESC-by-name lists no longer order "iPad" before "Lutron".
Normalize MAC addresses to lowercase at the DB layer so the now
case-insensitive primary key cannot accept duplicate-looking rows.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Extend PUT /api/devices/{mac} to accept an optional name and surface an
Edit action from both the device detail screen and the per-row overflow
menu, letting users modify owner, device type, vendor and name without
forgetting and re-registering.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
GET /api/devices accepts sort_by/sort_order (whitelisted columns; default
last_seen DESC, stable secondary sort on mac_address), device registration
captures an optional hostname, and the Flutter list switches between a
wide headered layout and a compact ListTile under 600px. Per-row overflow
menu retains View details / Register / Forget actions.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Apply clippy auto-fixes (filter().next() → find(), bool assert_eq →
assert!, len() >= 1 → !is_empty(), map_or(false, …) → is_some_and, etc.)
and refactor validate_device to take an expected Device instead of 8
positional args. Production code was already clean; all 60 tests pass.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The aliases re-encoded the module path into a name that no longer
matched the source identifier (e.g. `use super::finder as mdns`). Use
the actual module names at call sites; in the two web_server handlers
the local `status` fn collides with the module, so the single call site
uses the fully-qualified path instead.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Group each discovery protocol's primitive (finder), orchestration loop
(scanner), and status state under one module tree instead of splitting
them between device_finders/ and crate-root *_scanner / *_scanner_status
files. Also includes incidental rustfmt fixes to a few pre-existing
long lines.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Scanners now call seen() (sighting semantics: stamp last_seen, preserve
registration, conditional vendor/device_type/name writes). update() is
the user-editable mutation (owner, device_type, vendor) wired to
PUT /api/devices/{mac_address}.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Why: a device manually registered as "Laptop" was being overwritten to
"Phone" when a later scan deduced a vendor that maps to a different type.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Treat an empty->non-empty vendor transition as not a change, so first
deducing a vendor for a device that previously had none no longer raises
a "vendor changed" notification.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Add dedicated db::devices::register/unregister methods so registration
state (is_registered, owner) is owned by the API endpoints, and make
update sighting-only. This stops scanner re-sightings from wiping a
device's registration. Also preserve an existing mDNS hostname instead
of overwriting it on re-sighting.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Modern devices increasingly use randomized ("Private WiFi Address") MACs,
which are locally administered and have no real OUI, so the MAC-prefix
vendor lookup returns nothing. Such devices still advertise distinctive
mDNS service types, which we now use to deduce their vendor.
- Extend the mDNS parser to also capture PTR service types alongside
A-record hostnames.
- Add a service-type -> vendor mapping (data/mdns-service-vendor.json) and
a service_vendor_finder, using canonical vendor names so device-type
resolution still chains.
- Add utils::network::is_locally_administered to detect randomized MACs.
- In the mDNS scanner, fall back to service-based deduction only when the
OUI lookup is empty and the MAC is locally administered.
- Group the three finders under a new data module.
- Preserve a known vendor (and its derived device_type) when a later
sighting cannot deduce one, and suppress the spurious "vendor changed"
notification in that case.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Mirror the notifications list paging pattern (offset/limit, fetch one
extra row to detect the next page, First/Prev/Next controls) across the
devices DB query, REST endpoint, API client and UI. Also add a First
page button to the notifications pagination row.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Move select_interface and MAC-resolution logic (resolve_mac_address,
parse_proc_net_arp, probe_mac) out of the device_finders modules into a
single utils::network utility so future scanners can reuse them. Rename
resolve to resolve_mac_address and make parse_proc_net_arp private.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Introduce a second discovery module that passively listens for mDNS
multicast announcements (224.0.0.251:5353) and feeds discovered devices
into the existing devices/events/notifications pipeline, running in its
own task alongside the ARP scanner.
Since mDNS carries an IP and hostname but no MAC (the device key), the
module resolves IP->MAC via the OS ARP cache with a targeted ARP-probe
fallback. The advertised hostname is stored in a new optional `name`
column on devices (blank for ARP-only devices); the single `update`
writes `name` only when set so ARP rescans never clobber it. Device
names are included in event/notification messages.
Adds a GET /api/mdns_scanner/status endpoint (is_listening, devices_seen,
last_device_seen_seconds_ago) wired into OpenAPI, an optional
[mdns_scanner] config section, and the corresponding Nix module option.
Also aligns the Nix module's stale `timings` section with the current
`arp_scanner` schema.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
If no interface is set in the config, the ARP scanner now picks the
first non-loopback, up, IPv4-enabled interface automatically. The
setting is optional in the TOML config, sample config, and Nix module.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Adds a responsive Home screen combining the notification list with a
device summary panel and ARP scanner status, visible side-by-side on
wide screens and stacked on narrow ones.
Backend:
- New GET /api/devices/summary endpoint returning registered device
counts and "seen in last 24h / 7 days" breakdowns by registration
status
- Migration 07 adds indexes on (is_registered) and (last_seen,
is_registered) so summary queries use index range scans instead of
full table scans
Frontend:
- HomeScreen replaces NotificationList, embedding notifications,
device summary card, and ArpScannerCard in a LayoutBuilder-driven
two-column (≥700 px) or single-column layout
- ArpScannerCard extracted to a shared public widget reused by both
HomeScreen and StatusScreen
- Nav rail entry renamed to "Home" with home icon
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Renames scanner.rs to arp_scanner.rs to future-proof for additional scanner
types. Renames the [timings] config section to [arp_scanner] and simplifies
field names (arp_sender_timeout -> sender_timeout, arp_scan_duration ->
scan_duration). Introduces arp_scanner_status module to track whether the
scan is actively running or sleeping, and exposes this via a new
GET /api/arp_scanner/status endpoint returning is_running,
running_for_seconds, and next_run_in_seconds.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Loads vendor-device-type.json at compile time (same pattern as mac-vendors-export.json)
and sets device_type when a new device is detected by the ARP scanner.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Adds --llm flag to update_mac_vendors to classify unmatched vendors via
Claude Haiku 4.5, and --skip-download to reuse existing vendor DB.
Adds the anthropic Python package to the Nix dev shell. Updates CLAUDE.md
with the new script command.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Introduces backend/data/update_mac_vendors, an executable Python 3 script
that downloads the latest MAC vendor database from maclookup.app and
generates vendor-device-type.json — a mapping of vendor names to device
types (phone, laptop, tablet, server, tv, printer, network_appliance,
home_security, home_appliance, watch, pc, gaming_console) used for
auto-assigning device type on first detection.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>