Pushing a vX.Y.Z tag kicks off the Codemagic iOS build automatically;
surface that in the release output so the operator knows it's running.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The app's only encryption is HTTPS/TLS (accessed via iOS) plus trivial
local XOR obfuscation, so set ITSAppUsesNonExemptEncryption to false.
This is the correct export-compliance classification and stops App Store
Connect from prompting on every upload.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Codemagic build VMs are ephemeral, so fetch-signing-files without a
persistent private key creates a distribution certificate that can't be
reused on later builds ("Cannot save Signing Certificates without
certificate private key"). Pass a CERTIFICATE_PRIVATE_KEY secret so the
certificate is created/reused with a key Codemagic owns.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
get-latest-testflight-build-number errors when the app has no builds
yet, which broke the first CI run. Fall back to 0 so the initial build
number is 1.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Set up automated cloud iOS builds and TestFlight distribution without a
local Mac:
- Add codemagic.yaml: iOS->TestFlight workflow with automatic signing,
build-number auto-increment, triggered by the vX.Y.Z release tags.
- Set the iOS bundle identifier to net.oott-security.app (was the
placeholder com.example.frontend).
- Set the iOS display name to OOTT.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The events.rs file mixed three domains: device-event recording, change
detection, and the entire notification pipeline (rendering + delivery +
sending). This made it long, gave functions side effects beyond their
stated goal (classify_* silently recorded events), and intertwined the
events and notifications logic.
Split along domain boundaries:
- model::device_events now owns DeviceChange, the shared contract.
- events records device events only (record_new_device/record_known_device);
events/detection.rs holds pure change detection.
- new notifications module owns rendering, delivery, and sending
(notifications.rs + delivery.rs + render.rs); pushover/error moved here.
Data now flows one way: events produces DeviceChange, notifications
consumes it, both depend only on model. Scanners/pipeline/main orchestrate.
classify_* renamed to record_* so the write is the stated goal; send and
send_notification collapsed into persist_and_deliver.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Record what happened on each known-device sighting instead of only
"seen": a baseline DeviceSeen (history heartbeat, no notification) plus
DeviceChanged and DeviceBackOnline events, each deduplicated
independently so a recent routine sighting no longer suppresses a
genuine change or return notification. The frontend chart now trusts the
event type for its marker and tooltip rather than comparing each event's
snapshot against the device's current state.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Gate notification persistence and delivery on the device-event
deduplication window, not just the device_events table: record_event now
reports whether it recorded, and classify_new_device/classify_existing_device
suppress their changes when the sighting is a duplicate.
Treat an empty->value IP fill as not a change via a new ip_changed helper
(mirroring vendor_changed), so a device gaining its first address raises no
"changed" notification.
Key deduplication on (mac, scanner, event_type) instead of (mac, ip,
scanner), so repeated sightings of the same kind collapse regardless of the
reported address.
Also delete stale oott.db-wal/-shm in run_tests.sh to avoid init_db panics.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The API docs nav entry uses the origin-relative path "/api/docs", which
Uri.parse left scheme- and host-less, so canLaunchUrl/launchUrl could not
open it (e.g. in Docker). Resolve external links against the current page
so they carry a scheme and host; absolute URLs pass through unchanged.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
New devices are never registered, so the status line only ever read
"Not registered" and added no information. Remove it from the single
new-device notification body (the consolidated summary never had one).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add an "API Docs" navigation entry that opens /api/docs in a new tab.
It is shown only in the wide-mode navigation rail (not the compact
bottom bar) and sits just before About.
Generalises the navigation destination model so an entry can be an
in-app route or an external link, and can be restricted to wide layouts.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The root URI had no content of its own and only showed a pointer
message; send visitors straight to the front-end instead.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
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>
Trim the storage considerations down to assumptions, per-scenario
estimates, and the levers to control DB size. Add an authentication
and access-control section noting OOTT has no built-in user management
and warning not to gate /api behind an external auth layer.
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>
Let users collapse the wide-mode NavigationRail to an icons-only compact
view to reclaim horizontal space. A bottom-pinned double-chevron toggle
sits centred when compact and slides to the rail's right side when
extended, animating in sync with the rail. The choice is persisted via
the nav_rail_extended preference (defaults to extended).
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>
Collapse the five scanner status models into two shared shapes,
ActiveScannerStatus and PassiveScannerStatus, mirroring the backend's
active/passive vocabulary. Replace the five near-identical per-scanner
detail card files with a single scanner_status_cards.dart (two shared
resolvers plus a config list), and rebuild the combined home card to
iterate a list of scanners with two shape resolvers instead of five
copy-pasted resolve methods.
Extract two reusable mixins:
- PeriodicRebuild: the shared once-a-second "rebuild to refresh elapsed
text" timer used by the scanner cards and the stale indicator.
- PaginatedListState: the shared pagination state, page-size/page-count
getters, cancel-token-aware fetch orchestration, and disposal used by
the device and notification lists.
No behaviour change; ~900 lines removed. Tests and analyzer pass.
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>
Wide layout makes the leading device-type icon column header tappable to
sort; narrow layout gains a "Device Type" option in the sort sheet. The
backend already whitelisted device_type as a sort column.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The device detail chart's time-range selector (Today / Last week / ...)
rendered as a SegmentedButton that overflowed on narrow phone layouts.
Reuse the responsive FilterSelector widget so the same control is used
for both the list filters and the chart: segmented pills on wide layouts,
a compact dropdown combo box on phones.
To keep the two controls consistent, FilterSelector now renders a
SegmentedButton (instead of ChoiceChips) on wide layouts.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Replace the notifications list's wholesale redraw with a SliverAnimatedList
driven by a GlobalKey, keeping `_items` in lockstep with the animated state.
- Background refreshes (poll, pull-to-refresh, resume, route pop, mark-all)
reconcile against the fetched page: departed rows slide out, newly fetched
rows slide in at the top with a theme-coloured arrival highlight, and
surviving rows stay put (with in-place read-state recolouring under "All").
- Filter/page changes and the initial load reset the list (fresh key) so the
new dataset appears instantly without per-row animation.
- Read/unread removals are owned by the list: buttons play a slide/fade exit,
while swipes let Dismissible animate and then reconcile, avoiding double
animation and the disposed-widget race.
Add the arrival highlight overlay to NotificationCard and cover the new
behaviour with widget tests (swipe-out, flash-in, external removal, in-place
"All" mark, filter reset).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Changing pages in the notifications or devices list gave no cue that
the next page was loading. Render an indeterminate progress bar at the
shell level, pinned flush against the bottom of the page body (above
the nav bar on phones, the screen bottom on wide layouts), driven by a
shared paginationLoading notifier the lists set while fetching. The
pagination bar keeps disabling its buttons during the fetch.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Wrap compact (phone) device rows in a Card instead of a bare
Material+Divider so they get the same rounded, spaced look as the
notifications list. Drop the row divider on narrow layouts and reduce
the phone page size to 5 to account for the taller card rows.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
On narrow layouts the devices list's filter chips (Not registered /
Registered / All) competed for horizontal space with the Sort and Filter
icon buttons and overlapped. Introduce a reusable FilterSelector that
keeps the chips on wide layouts but collapses to a compact dropdown
button on phones, and use it for both the devices and notifications
lists for consistency.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
On phone-width layouts (< Breakpoints.medium) the notification and device
lists now request fewer items per page so the list and its pagination bar
fit on screen together on common current phones. Notifications use 4 items
and devices 6 on phones; wider layouts keep 5 and 10 respectively. The
initial fetch is deferred to didChangeDependencies so the page size can read
the screen width from MediaQuery.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The devices list already supported pull-to-refresh; mirror that on the
notifications list by wrapping its CustomScrollView in a RefreshIndicator
with AlwaysScrollableScrollPhysics. Keep the existing list visible during
a refresh (_isLoading = _items.isEmpty) instead of flashing the skeleton,
matching the devices list behaviour.
Add widget tests covering pull-to-refresh for both lists.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
run_android_emulator.sh already boots the emulator and launches the app; it
delegated the boot step to run_android.sh. Inline that logic as a
boot_emulator() helper and drop the now-redundant script.
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>
Replace the _goToPage wrapper with an optional scrollToTop flag on
_fetchPage, so there is a single fetch entry point. The pagination bar
passes scrollToTop: true; all other callers keep the current behaviour.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The notifications and devices lists kept their scroll offset when paging,
so a new page would open partway down. Attach a ScrollController to each
CustomScrollView and animate back to the top whenever the page changes
via the pagination bar.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
SliverList recycles State objects by index, so when an expanded
notification was marked as read and dropped from the filtered list, the
next notification inherited the expanded state. Give each card a
ValueKey(id) so its expand/collapse state is matched by notification,
not by list position.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Tapping a notification's body already toggled expand/collapse, but the
action area below it wasn't tappable. Wrap the whole card in an InkWell
so tapping anywhere (body or action row) toggles, while the buttons
still handle their own taps.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Replace the vanilla Flutter splash with the OOTT wordmark (Barlow
Condensed Bold) on the Gruvbox-dark background, matching the in-app
AppBar badge. Generated via flutter_native_splash, covering legacy
Android, the Android 12+ SplashScreen API, and iOS (light + dark).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Three issues prevented `run_android_emulator.sh` from launching the app:
- gradlew's shebang was a hardcoded Nix-store bash path (copied verbatim
from the nixpkgs Flutter SDK template) that breaks once that store path
is garbage-collected, surfacing as a misleading "ProcessException: No
such file or directory". The dev-shell now normalizes the gitignored
android/gradlew shebang to the portable "#!/usr/bin/env sh" on entry.
- `flutter run -d android` never matched: -d resolves a device by id/name,
not platform. Resolve the concrete id (e.g. emulator-5554) from
`flutter devices --machine` instead.
- Install raced the boot ("device is still booting"); wait for
sys.boot_completed=1 before launching.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Rename android-emulator.sh to run_android.sh and run.sh to run_web.sh,
add run_android_emulator.sh to launch the app on the emulator, and
update CLAUDE.md commands accordingly.
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.
Frontend usability and aesthetics pass across web and mobile:
- Add shared layout tokens (theme/dimens.dart: Insets + Breakpoints) and
replace magic-number spacing and per-file breakpoint consts.
- Route both themes through buildAppTheme (theme/theme_builder.dart): explicit
useMaterial3 and a shared branded textTheme (Barlow Condensed for
display/headline/title styles); logo now reads its style from the theme.
- Move screen titles into the shared AppBar (route-derived) and drop the
redundant in-body headers; upgrade Settings buttons to M3 FilledButton.
- Add reusable EmptyState and skeleton loaders; Devices empty state links to
scanner status, notifications get filter-aware messages.
- Add a first-run welcome intro and Save-disabled helper text in Settings.
- Make device Filter/Sort adaptive: bottom sheet on phones, dialog on wide.
- Collapse the device-list filter chips and Sort/Filter buttons into one row;
rename the home Scanners card title and align its style.
Tests: add EmptyState/skeleton widget tests and Settings first-run cases;
update tests for the FilledButton swap and relocated titles. 89 passing,
dart analyze clean.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Introduce a headless `flutter test` suite (83 tests) covering models,
utilities, every API endpoint, and the five screens, with shared helpers
and fixtures so new tests stay terse.
API endpoint methods are statically-dispatched extensions on the
BackendAPI singleton, so they cannot be mocked via `implements`. Mock at
the Dio HTTP-adapter layer (http_mock_adapter) instead, enabled by two
small @visibleForTesting seams: BackendAPI.dioForTesting swaps the
singleton's Dio, and BackendReachability.forceOnlineForTesting() forces a
deterministic online state so polling widgets load.
Add frontend/run_tests.sh and document it in CLAUDE.md.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The google_apis android-36 image always exposes a phantom AT hardware
keyboard that cannot be removed via hw.keyboard=no, the lid switch, or
any emulator flag. With hw.keyboard=no the host keyboard is not forwarded
into the guest, and Gboard hides the on-screen keyboard for non-password
fields while it detects that phantom keyboard, leaving plain text fields
(e.g. Settings > Base URL) untypable.
Enforce hw.keyboard=yes in the AVD's config.ini on every dev-shell entry
so the host keyboard is forwarded to all fields. Applied idempotently so
it also repairs a pre-existing AVD; the changed hardware config makes the
emulator cold-boot once for the setting to take effect.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The Android SDK lives in the read-only Nix store, so the Gradle build
cannot auto-install missing components. Pin everything the Flutter
Android build needs in the dev shell's composeAndroidPackages:
platforms 33-36 (app targets 36; plugins pin 34 and 35), build-tools
35.0.0 (AGP 8.11.1), NDK 28.2.13676358 (flutter.ndkVersion), and
cmake 3.22.1 (plugin native builds).
Also redirect the Android Gradle Plugin to the Nix-patched aapt2 from
the SDK via android.aapt2FromMavenOverride, written to the user-global
~/.gradle/gradle.properties on shell entry (the project's tracked
gradle.properties must not contain a machine-specific store path).
AGP's Maven-downloaded aapt2 cannot run on NixOS.
Ignore the generated frontend/android/build/ directory.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Pin the Android SDK composition (platform 36 + google_apis x86_64 system
image + emulator) via composeAndroidPackages so the emulator image is
always available and reproducible across machines. Auto-create the
"oott_api36" AVD idempotently on dev-shell entry, and add
frontend/android-emulator.sh to launch it.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Recompute the storage estimates around the deduplication window as the
governing cap across all four default-enabled scanners (ARP, mDNS, SSDP,
DHCP), using the documented defaults rather than the old single-ARP
720-scans/day worst case. Present typical (not worst-case) figures, add a
per-scanner event breakdown, rework the tuning levers around retention and
disabling scanners, and note that the SNMP scanner is disabled by default
and excluded from the figures.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
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>
Replace the README config table's "Sample value" column with
"Default value" reflecting the real defaults from settings.rs:
mark options with no built-in default as Required and list the
code defaults for the scanners, retention and deduplication.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Reorganize into What is OOTT?, Getting started (Docker + mobile apps),
Deeper dive (Nix flake, config options, HTTPS), and Things to keep in
mind (storage, privileges/ports). Inline a minimal oott.toml and the
compose file in the Docker quickstart, and document the missing
web_server.* options.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Adds a repo-root release.sh that prompts for the version, bumps it in
Cargo.toml/pubspec.yaml and the About release date, refreshes lockfiles,
runs tests, commits/pushes, tags, creates the GitHub release, and builds
the Docker image, pausing for confirmation between sections.
Release notes live in RELEASE_NOTES.md (newest first, one section per
version); the script extracts the section for the version being cut.
Seeds the file with the v0.1.0 notes and adds gh to the Nix dev shell.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The status screen was a non-scrolling Column, so it overflowed
whenever the offline banner consumed vertical space or the viewport
was too short to fit all scanner cards. Wrap it in a
SingleChildScrollView, matching the other screens.
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>
Break the 372-line frontend API client into focused files under
lib/utils/api/: error mapping (api_error.dart), Dio setup behind a
buildDio factory (dio_config.dart), and device/scanner/notification
endpoints as extensions in part files. Collapse the five near-identical
scanner-status methods via a generic _getModel helper and de-duplicate
the pagination logic via a shared _paginate helper.
The BackendAPI singleton and dioErrorToUserMessage remain importable
from oott_api.dart unchanged, so no call sites are affected.
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>
Add NSAllowsLocalNetworking ATS exception so the iOS app can reach a
backend over HTTP on the local network, and document the supported
connection paths (direct private-IP HTTP vs. reverse proxy with HTTPS)
in the README.
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>
Add a DHCP scanner card to the Status screen and a DHCP row to the
consolidated Status card on the Home screen, mirroring the existing
mDNS/SSDP implementations. Also label DHCP-discovered device events as
"DHCP" instead of the raw "Dhcp".
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>
Surface the SSDP/UPnP scanner alongside ARP and mDNS: a dedicated card on
the Status screen and a row in the consolidated Status card on Home. Mirrors
the existing mDNS implementation and reuses the generic ScannerStatusCard and
PolledValue infrastructure. Also maps the 'Ssdp' device-event scanner to the
"SSDP/UPnP" label.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add a Network ports section noting that the mDNS (UDP 5353) and SSDP
(UDP 1900) ports are fixed by their protocols and must be available on
the host, alongside the configurable web server port.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Expose the ssdp_scanner.probe_timeout option (default 2s, mirroring the
mDNS scanner) in the sample config and the NixOS service module.
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>
Tapping a notification card now toggles an expanded view showing the
full body text and M3 action buttons (Mark as read/unread, View device),
replacing the former popup menu and direct navigation on tap.
Co-Authored-By: Claude Sonnet 4.6 <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>
Surface the originating scanner ("ARP" or "mDNS") in the event timeline
tooltip so users can tell which scanner reported each sighting.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>