Adds MAC address matching as the primary matching phase:
- Normalizes MACs (strips colons/dots/dashes) from SNMP interface data
- Matches against gaiia_inventory_items.mac_address (synced from Gaiia)
- Phase 1: MAC → Phase 2: IP → Phase 3: Site
Flash now shows breakdown: "Auto-matched 47 devices (12 by MAC, 34 by
IP, 1 by site). 17 remaining."
A bare <select phx-change="..."> sends %{"value" => ...}, not the name
attribute. Wrapping in <form phx-change="..."> fixes this so the handler
receives %{"manufacturer_id" => value}.
- Handle empty manufacturer_id selection (select placeholder)
- Log and flash when model fetch fails or returns empty
- Add require Logger to reconciliation live view
Replaces auto-matching with explicit two-step selection:
1. Select manufacturer from Gaiia's inventory model library (dropdown)
2. Select model for that manufacturer (dropdown, filtered live)
3. Click Create
Manufacturers and models are fetched from Gaiia on demand and cached
in socket assigns. Only one device form is open at a time.
ngettext is a macro that already calls dngettext internally. The
previous code was calling dngettext a second time on the result string,
passing nil as the count — causing a FunctionClauseError.
Adds `Towerops.Gaiia.InventoryCreator` which:
- Matches a device's SNMP-discovered manufacturer/model against Gaiia's
inventory model library to find the right modelId
- Calls `startAddInventoryItemsJob` to create the item, assigned to the
correct Gaiia network site with MAC address from SNMP interfaces
A "Create in Gaiia" button now appears next to each untracked device on
the reconciliation page.
Two improvements to Gaiia reconciliation:
1. Auto-match now uses two-phase matching:
- Phase 1: IP match (high confidence, same as before)
- Phase 2: Site match — if a device is at a Towerops site linked to a
Gaiia network site, match it to unmapped inventory assigned to that site
2. Gaiia sync now automatically maps network sites to Towerops sites by
name during sync. When a Gaiia network site name matches a Towerops
site name, `site_id` is set on the `gaiia_network_sites` row. This
enables the site-based matching in phase 2.
Flash message now shows breakdown: "Auto-matched 47 devices (34 by IP,
13 by site). 17 remaining."
Adds `Gaiia.auto_match_untracked/1` which finds unmapped Gaiia inventory
items whose IP matches an untracked Towerops device and links them.
Button on the reconciliation page's Untracked tab runs the match
inline and shows a flash with matched/remaining counts.
All 4 webhook controllers now verify the signature, insert an Oban job,
and return immediately. Previously, Stripe and PagerDuty did DB queries +
writes inline; Gaiia ran upserts; Agent Release broadcast to all connected
WebSockets — all in the request path.
New workers:
- StripeWebhookWorker — delegates to WebhookProcessor.process/1
- GaiiaWebhookWorker — delegates to Webhooks.process_event/3
- PagerdutyWebhookWorker — resolve/acknowledge alerts by dedup key
- AgentReleaseWebhookWorker — broadcast mass update to agents
Also fix: log error when resolve_alert fails in agent_channel (was silently
discarded, causing "Resolving stuck device_down alert" to repeat in logs).
Adds a parallel LLM path that reviews the whole org snapshot — Preseem
APs, SNMP signals, backhaul, agents, and the insight types the rules
already covered — and emits novel observations as ai_observation
insights (source=ai). Existing rule-based insights and per-insight
enrichment continue unchanged.
- lib/towerops/llm/network_snapshot.ex builds the bounded snapshot
- lib/towerops/llm/network_insight_prompt.ex builds the prompt and
parses the LLM's JSON observations array
- lib/towerops/workers/ai_network_insight_worker.ex runs nightly at
03:30 UTC (after the rule pass + per-insight enrichment) and on
the superuser regenerate button
- preseem/insight.ex extends @valid_types with "ai_observation" and
@valid_sources with "ai"
- insights_live/index.ex includes the new worker in the regen list
and adds an "AI" source badge style
Bumps the SnmpKit Manager timeout-options test threshold from 500ms
to 3000ms (was flaking on loaded dev machines), and scopes
list_unenriched_insights/1 assertion in insights_test:573 to the
test's org so cross-suite leaks don't fail the count.
- ToweropsWeb.AgentChannel now runs sensor readings through
Towerops.Snmp.SensorScale.normalize/2 in both resolve_sensor_value/2
and the GraphQL publisher. Without this, MikroTik mtxrHl* deci-degree
readings (e.g. Culleoka reporting 294 -> 29.4°C) were stored and
alerted on at 294°C. The DevicePollerWorker path already did this;
only the agent path was missing it.
- Demote "Received SNMP result from agent" and "Processing polling
result for X" from info to debug — they fire every poll cycle on
every device and were dominating prod logs.
- /insights now shows a "Regenerate insights" button for superusers
that enqueues all seven insight workers (Preseem baselines,
capacity, device-health, Gaiia, system, wireless, LLM enrichment).
Non-superusers neither see the button nor can trigger the event.
- Dialyzer is clean: added :tools to plt_add_apps, gave
Preseem.Insight a @type t, pinned a few unmatched returns, and
suppressed a known MapSet opaque false-positive in Towerops.Unused.
- e2e: NODE_OPTIONS=--disable-warning=DEP0205 silences the Node 25
deprecation noise from Playwright's internal ESM loader.
UI: the AI-generated footer dropped " · deepseek-v4-pro" — model
identity is irrelevant to operators reviewing the recommendation.
Parser: stop letting raw JSON leak into the summary text when the LLM
returns an almost-but-not-quite-valid response.
- Strip ```json fences (already done) PLUS try a balanced-brace
extraction so a "Here's the JSON: {…}" preamble doesn't break
Jason.decode
- When all decode paths fail, regex-extract `"summary"` and `"action"`
fields from the text rather than dumping the whole blob with braces
intact
- Last-ditch sanitiser strips remaining brace/quote noise so the
rendered summary never shows literal `"summary"` keys
Maintenance: `Towerops.Maintenance.RepairLlmSummaries.run()` clears
`llm_enriched_at` on already-stored rows whose summaries contain JSON
artifacts so the next worker cycle re-enriches them with the new
parser.
Test: insights_live_events_test now asserts the AI-generated label is
present *and* that the model name does not leak into the rendered HTML.
The CSS `capitalize` class only capitalises the first letter, so the
source filter pills and per-card badges showed "Ai" and "Snmp". Switch
to a `source_label/1` helper that returns "AI" / "SNMP" verbatim and
falls back to `String.capitalize/1` for the rest (Preseem, Gaiia,
System).
Adds an "ai" pill to the source-filter strip on /insights and a small
blue "AI" badge on every card whose `llm_enriched_at` is set. Selecting
the pill returns every LLM-enriched insight regardless of underlying
source (preseem / snmp / gaiia / system).
`source: "ai"` is a virtual filter — `list_insights/2` translates it to
`WHERE llm_enriched_at IS NOT NULL` rather than equality on the source
column, so existing real source semantics stay intact.
UI:
- Type-specific evidence cards (red/amber/orange/rose/purple/cyan/
indigo/blue) had `dark:text-{color}-300` labels and
`dark:bg-{color}-800 dark:text-{color}-100` badges that washed out on
the dark background. Bumped 31 label shades to -200 and switched the
three problem badges (Above manufacturer spec, Investigate upstream
first, own fleet, crit) to solid -600 with white text.
- upstream_degradation card got brighter device-link text and a
hover state in dark mode.
DeepSeek:
- Empty-string DEEPSEEK_MODEL / DEEPSEEK_BASE_URL env vars were not
treated as missing — `||` only short-circuits on nil/false — so a
blank secret would override the @default_model and the API would
reject `model: ""`. Both runtime.exs and the client itself now treat
blank values as missing.
Performance:
- HealthController: honor `:timeout` from `:redis` config (was hardcoded
2s) so the 503-when-unreachable test exits immediately
- DevicePollerBranches: drop `collect_events` window 1500ms → 100ms
(PubSub broadcasts are synchronous from `run_perform`)
- Towerops.Unused: cache xref analysis in `:persistent_term` keyed by
BEAM mtimes so repeated `mix unused` invocations skip re-analysis
- DiscoveryWorker: make wait_loop interval configurable via
`:discovery_wait_interval_ms` (25ms in tests vs 500ms in prod)
- ProfileWatcher: configurable `:profile_reloader` so tests can stub
the YAML reload instead of paying the full disk-load cost
- PingExecutor: tag the loopback test `:network` (it shells out to
`ping`); coverage already provided by 4 other `:network`-tagged tests
Flake fix:
- FrequencyChange tests used hardcoded `~U[2026-05-09 12:00:00Z]`,
which now falls outside the rule's 24h lookback window. Switched to
`DateTime.utc_now()` so scans stay inside the window regardless of
clock progression.
Fiber operators care a lot about optical Rx power because connectors
get dirty, splices degrade, and patch panels develop bend-radius
problems. The data is already in snmp_transceiver_readings — this
rule turns it into actionable insights.
- Towerops.Recommendations.Rules.OpticalRxLow — single query selects
the latest reading per transceiver (last hour), filters to those
with rx_power_dbm <= -28, joins back to the device. Critical at
<= -32 dBm. One insight per (device, port) via metadata.dedup_key.
- Insight.@valid_types extended with optical_rx_low.
- LLM prompt hint walks the operator through the standard ladder:
clean SC/LC connector with a one-click cleaner, inspect bend
radius, verify splices, then replace the transceiver.
- UI: cyan card with port, Rx power, Tx power, transceiver type, and
wavelength.
- Wired into RecommendationsRunWorker.
8 new tests.
When 50%+ of the access points at one site simultaneously have
Preseem QoE score <60, root cause is almost always a shared upstream
problem (backhaul, transit, DNS, DHCP) — not per-AP RF. This rule
emits one site-level insight that points the operator at the right
investigation layer instead of letting them chase per-AP RF
symptoms across many APs at the same site.
- Towerops.Recommendations.Rules.UpstreamDegradation — single query
pulls all matched Preseem APs grouped by site_id. Critical when
100% of APs at the site are degraded; warning otherwise. Lists
every degraded AP with its QoE score in metadata.
- Insight.@valid_types extended with upstream_degradation.
- LLM prompt hint tells the model to instruct the operator to
investigate upstream FIRST and to NOT chase per-AP RF symptoms.
- UI: indigo card with degraded/total AP counts, an "Investigate
upstream first" badge, and a clickable list of affected APs.
- Wired into RecommendationsRunWorker FIRST in the @rules list so
the upstream signal surfaces before per-AP RF rules.
Also fixes two flakes:
- device_live/show_test.exs is now async: false. It inserts to
InterfaceStat which is a TimescaleDB hypertable; concurrent inserts
deadlock on chunk-table locks under parallel SQL-sandbox tests
(ERROR 40P01 deadlock_detected in setup).
- Cleared an unused-variable warning in device_overheating_test.exs.
8 new tests for UpstreamDegradation.
Operators frequently misdiagnose thermal throttling as RF problems
and waste truck rolls. This rule reads the hottest temperature
sensor per device and, when the device is linked to a Preseem AP
with degraded QoE, makes the thermal-correlation explicit so the
fix is ventilation/shading rather than antenna alignment.
- Towerops.Recommendations.Rules.DeviceOverheating — single query
pulls the hottest temperature sensor per device above 65 °C; one
insight per device. Critical at 75 °C+. Includes the linked
Preseem qoe_score in metadata when present.
- Insight.@valid_types extended with device_overheating.
- LLM prompt hint tells the model to recommend ventilation/fan/
shading inspection BEFORE any RF action when QoE is also low.
- UI: red card with sensor name, temperature, linked QoE score,
and an "Above manufacturer spec" badge for >=75 °C.
Wired into RecommendationsRunWorker. 8 new tests.
Same-channel conflicts at one site are not all equal. A pair of APs
sharing a channel in a clean RF environment is a planning issue;
the same pair in a contaminated environment is an outage waiting to
happen. This commit makes the rule reason across sources before
deciding urgency.
- Towerops.Snmp.RfHealth — cross-source helper that combines:
* Most recent noise-floor / noise sensor on the AP (dBm)
* Preseem rf_score and qoe_score from the linked AccessPoint row
* Average CPE SNR and client count from wireless_clients
Returns a snapshot map and an interference_severity/1 classifier
(:critical | :warning | :ok). Critical when noise floor >= -80 dBm
OR Preseem rf_score < 4.0; warning when avg CPE SNR < 15 with
active clients.
- OwnFleetChannelConflict now enriches each conflicting AP with its
noise floor, rf_score, qoe_score, avg CPE SNR, CPE count, and a
per-AP rf_severity. Urgency promotes to critical when 3+ APs share
a channel OR any conflicting AP shows critical RF health.
- UI: amber card now renders an evidence table with width, noise,
rf_score, CPE SNR, and CPE count per AP, plus a "Critical RF
environment detected" badge when has_critical_rf is true.
Also strengthens the previous flake fix on agent_live_test.exs:567
by switching to Req.Test shared mode (the test is sync), so the
ReleaseChecker stub is visible to the LiveView process regardless of
when it spawns relative to setup. The earlier Req.Test.allow approach
relied on stub timing that didn't always hold under full-suite load.
11 new tests across RfHealth + the enriched rule. LLM enrichment
automatically picks up the richer metadata via the existing Phase 1
worker.
Adds a vendor-agnostic radio-state extractor that lets the frequency-
aware rules actually fire on production data without per-vendor
plumbing for each profile.
- Towerops.Snmp.RadioState — pure module that maps a frequency in MHz
to the corresponding 802.11 channel across 2.4 GHz, 5 GHz, and 6 GHz
bands; reads the freshest "frequency" sensor (already emitted by
Cambium/MikroTik/AirOS/Mimosa/Exalt/etc. profiles) and persists the
AP-level current_frequency_mhz, current_channel, last_radio_seen_at
fields onto snmp_devices.
- DevicePollerWorker now calls RadioState.update_from_sensors/1 after
each sensor poll so frequency-aware rules read a single canonical
source instead of joining sensors at query time.
- Towerops.Recommendations.Rules.OwnFleetChannelConflict — new rule
that needs no neighbor-scan collection: detects when 2+ of our own
APs at the same site share a channel (self-interference). One
insight per (site, channel) with a clickable list of conflicting
APs and their channel widths. Critical when 3+ APs share a channel.
- Insight.@valid_types extended with own_fleet_channel_conflict.
- UI: amber card showing channel, frequency, AP count, and the linked
list of conflicting devices.
Wired into the existing hourly RecommendationsRunWorker. LLM
enrichment automatically applies via the Phase 1 worker.
Two new multi-source rules using data we already collect, both wired
into the existing hourly RecommendationsRunWorker.
SectorOverload — fires when a Preseem-monitored AP has <25% free
airtime AND active subscribers. Critical urgency when free airtime
drops below 15%, OR when free airtime <25% AND QoE <50. Reuses the
airtime/subscriber/QoE data already pulled by PreseemSyncWorker so
no new collection is needed. UI: orange evidence card with free
airtime, subscriber count, QoE score, and AP model.
CpeRealign — fires when a wireless client has BOTH signal_strength
<=-78 dBm AND SNR <=18 dB seen in the last 2 hours. This is the
classic alignment / obstruction signature, distinct from the
existing single-metric wireless_signal_weak / wireless_snr_low
alerts produced by WirelessInsightWorker. One insight per (AP, CPE)
pair via a metadata.dedup_key. Critical when signal <=-88 OR SNR
<=10. UI: rose evidence card with signal, SNR, TX/RX rate, distance,
hostname.
Insights.insert_insight_if_new/1 dedup logic now prefers a
metadata.dedup_key over device_id when explicitly set, allowing
per-CPE insights without collapsing multiple CPEs on the same AP
into a single insight. Existing rules without dedup_key are
unaffected.
Insight.@valid_types extended with sector_overload and cpe_realign.
LLM enrichment automatically applies to both new types via the
existing Phase 1 worker — no extra wiring.
Also adds k8s/secrets.yaml to .gitignore so operators can drop a
local Secret manifest with real values, kubectl apply manually, and
never accidentally commit it. Documented in k8s/README.md.
When a wireless AP has a strong same-channel neighbor (RSSI >= -75 dBm)
and a measurably cleaner alternative channel exists in the same band,
the system now emits an ap_frequency_change insight with a specific
recommended channel and the top interfering BSSIDs as evidence.
- New schema wireless_neighbor_scans (append-only) storing observed
neighboring APs per device: bssid, ssid, channel, frequency_mhz,
channel_width_mhz, rssi_dbm, is_own_fleet, seen_at
- New columns on snmp_devices: current_channel, current_frequency_mhz,
current_channel_width_mhz, last_radio_seen_at — populated by vendor
SNMP profiles for APs that expose wireless OIDs
- Towerops.Recommendations.Rules.FrequencyChange — pure rule that scores
each candidate channel using sum-of-linear-power over the last 24h of
neighbor scans, recommends the cleanest if it beats the current by at
least 6 dB. Critical urgency when current score >= 100.
- Towerops.Workers.RecommendationsRunWorker — hourly Oban cron, iterates
organizations and runs all rules, uses insert_insight_if_new/1 for
idempotent upsert
- LiveView /insights renders a purple frequency-change card with current
vs recommended channel, dB-cleaner badge, and a top-offender list
(BSSID, SSID, RSSI, own-fleet flag)
- LLM enrichment automatically applies to the new insight type
Also fixes two pre-existing flaky tests that surfaced in CI:
- test/towerops_web/live/agent_live_test.exs:567 — now sets an explicit
Req.Test transport_error stub for ReleaseChecker and uses Req.Test.allow
so the LiveView process inherits it. Previously relied on absence of a
stub, which leaked across tests and produced different behavior under
full-suite parallel runs.
- test/towerops_web/telemetry_test.exs:140 — relaxes the "no log" check
to refute the specific messages the function under test would emit,
rather than asserting an empty capture_log/1 buffer (which catches
stray logs from concurrent tests, e.g. Postgrex sandbox disconnects).
Adds an optional plain-language summary and recommended action to every
active insight. A new Oban cron worker runs every 5 minutes, picks up
unenriched insights, and asks the configured LLM to restate the finding
and suggest one specific action grounded in the structured metadata.
- Migration: adds llm_summary, recommended_action, llm_model,
llm_enriched_at columns to preseem_insights
- Towerops.LLM context with swappable behaviour; real client uses Req
with the standard Req.Test plug for test isolation
- MockClient + InsightPrompt module (builds chat messages, parses JSON
with code-fence stripping and a raw-text fallback)
- Worker logs and skips on rate limits / missing key / parse errors so
insights still render without an AI summary when the LLM is unavailable
- Optional towerops-llm k8s secret with example template; deployment.yaml
references it as optional in both init and main containers
- UI renders summary + recommended action callout on /insights and on
the org-level insights page
- Add Codeberg Ansible collection link to API/GraphQL docs and Terraform guide
- Un-skip the previously-disabled DevicePollerWorker tests that still pass
- Expand MibController, CnMaestro.Sync, CheckWorker, ActivityFeed,
MobileController, JobCleanupTask tests
- New UploadMibs Mix task test covering arg validation and upload paths
- Set :mib_dir in test config so MibController upload/delete flows can
exercise the real on-disk paths against a writable tmp directory
The /docs/api and /docs/graphql pages were missing four resource
families that the API has shipped:
- Coverages (RF coverage prediction with async compute, recompute,
KMZ download)
- Maintenance Windows (one-off + recurring with RRULE)
- On-Call Schedules (top-level CRUD + layers, members, overrides,
on_call lookup)
- Escalation Policies (CRUD + rules + targets)
Both REST and GraphQL pages now have sidebar entries and example
requests for each.
Dialyzer:
* Add Lidar.Tile @type, narrow several @specs to match success-typing,
prefix discarded function returns with `_ =`, fix the broken
File.stream! call in the ms-buildings worker (it was causing dialyzer
to mark every private fun unreachable), broaden Antenna.spec key
types from `float()` to `number()` to match catalog int literals.
* Add :geo, :geo_postgis, :db_connection to plt_add_apps so the dep
PLT actually contains the modules we use.
* Result: 0 unsuppressed dialyzer warnings (was 50+ in project code).
Tree canopy:
* Replace the dead landfire.gov URL with the ETH Zurich Global
Canopy Height 10 m product. ETH publishes proper COGs with
overviews on libdrive.ethz.ch — confirmed working with
/vsicurl/ byte-range reads. The module now picks the 3°×3° tile
containing the bbox centroid, with both a `:metres` decoder
(ETH Byte raster, NoData=255) and a `:landfire_evh` decoder for
operators self-hosting LANDFIRE EVH COGs.
* Re-enable canopy by default now that there's a working source.
Tests (+105 tests, 0 failures):
* CoverageLive Index/Show/Form/Map — empty/loaded/auth states,
LiveView event handlers, formatters.
* Coverages.Building changeset + helpers.
* Coverages.TreeCanopy provider tile-URL builder and decoders.
* Workers.MsBuildingsImportWorker (full import flow incl. blank
lines, MultiPolygon, idempotency, hash fallback) — also fixed
the partial-index ON CONFLICT bug that prevented imports from
ever working: the unique index has WHERE ms_footprint_id IS
NOT NULL, so the worker now uses an :unsafe_fragment conflict
target that includes the predicate.
* GraphQLDocsController smoke test (was 0%).
Coverage rose from 73.19% → 74% with the major project modules I
touched all moved from 0% / <50% to >75%.
Rewire the single-coverage show page to mount MultiCoverageMap (the
same hook the org map uses) with one coverage in the array, and lift
the cnHeat-style overlay panels — LOS/NLOS toggle + height slider on
the left, opacity + RSSI filter on the bottom, distance probe on
click. Parameters move into a togglable right-side drawer so the map
dominates the viewport.
Show LiveView gains toggle_params / probe_point / close_probe /
set_probe_units handlers and delegates the formatters to
CoverageLive.Map. Site is now preloaded so coverages_payload/1 can
read site.name without an Ecto.AssociationNotLoaded crash that was
silently nuking the data-coverages JSON encode.
Also fixes a real bug in coverage_hooks.ts: pngForCoverage was
returning before the antenna-marker creation block, leaving the
marker code as unreachable dead code — towers never rendered. Moved
the marker creation into addCoverageLayer where it belongs and added
a no-PNG guard so a missing png_path logs instead of silently
failing the L.imageOverlay constructor.
Updates all docs, UI copy, release checker, proto go_package,
container image refs, and tests to point at
codeberg.org/towerops-agent/towerops-agent. Also deletes stray
empty package.json/package-lock.json (Phoenix uses esbuild — no
npm) and an unused test_encode_decode.exs scratch file.
Coverage rasters were being written to priv/static/coverage on the pod's
ephemeral filesystem — fine on a single-pod dev box, broken under k8s
where pods share NFS at /data and a restart wipes computed predictions.
* New :coverage_storage_dir config (defaults to priv/static/coverage for
dev/test, "/data/coverage" in production).
* Raster.output_dir/absolute_raster_path read this config and resolve
{otp_app, rel} or absolute paths.
* Endpoint now mounts a dedicated Plug.Static at /coverage from that
same config — replicas all serve the same NFS-backed file regardless
of which one ran the compute.
* Drop "coverage" from static_paths/0 since the dedicated plug owns
that prefix now.
cnHeat-2.0-style automation hooks. Coverage prediction is now a
first-class API resource that Terraform / Ansible / cron can drive.
REST API (under /api/v1, Bearer-token auth scoped to one org)
- GET /coverages list
- POST /coverages create + auto-queue compute
- GET /coverages/:id read (includes status / progress_pct
for polling-based providers)
- PATCH /coverages/:id update editable fields
- DELETE /coverages/:id delete + cleanup
- POST /coverages/:id/recompute requeue, returns 202
- GET /coverages/:id/kmz Google Earth bundle (KML + PNG)
Each response includes the full resource — Terraform read-after-write
reconciles cleanly; status polling hits a stable JSON shape.
Email-on-complete (Towerops.Coverages.Notifier)
- Worker fires deliver_compute_complete on both :ready and :failed
paths. Sends a short text email to every member of the owning
organisation with a link to the show page (or the failure
reason). Mirrors cnHeat 2.0's per-project email alerts.
- Failures are non-fatal: a stuck SMTP relay never blocks the
underlying coverage record from transitioning state.
KMZ export
- Builds a flat KMZ in-memory via :zip.create — doc.kml +
overlay.png, with the LatLonBox set from the coverage's bbox.
XML special chars are escaped. 409 if not yet ready.
Tests (11 new, all green)
- Bearer-token auth happy path + 401 missing
- index / show / create / update / delete + 404 cross-org
- create returns status:'queued' confirming the auto-enqueue
- recompute returns 202 with status:'queued'
Plus docs/terraform-coverages.md showing how to drive the new API
from a third-party restapi provider until the first-party
terraform-provider-towerops gets a coverage resource.
Closes the cnHeat-parity gap on modes and clutter:
Backend
- Towerops.Coverages.Buildings.for_bbox/1 — PostGIS ST_Intersects
query that returns each building polygon as a compact
%{height_m, coords} map ready for in-memory point-in-polygon
testing, so the per-pixel hot loop never round-trips to
Postgres.
- Towerops.Coverages.Profile.sample/5 — accepts an optional
:clutter list and adds the building rooftop height to terrain
whenever a sample point falls inside a polygon. Pure ray-cast
PIP for portability.
- Towerops.Workers.CoverageWorker — fetches buildings once per
job and computes BOTH LOS and NLOS variants per SM-height
tier (12 PNGs total). LOS passes clutter: [] (height-above-
clutter view), NLOS passes the prefetched list (height-above-
ground view with rooftop diffraction). Pixel-loop signature
bundled into a ctx map to keep the function arity within
Credo's max-8 limit.
- Towerops.Workers.MsBuildingsImportWorker — streams Microsoft
GlobalMLBuildingFootprints GeoJSONSeq exports line-by-line,
upserts into coverage_buildings in 500-row batches via
insert_all + ON CONFLICT (source, ms_footprint_id). Handles
Polygon and MultiPolygon geometries; stable-hashes the bbox
when an explicit feature id is missing so re-imports are
idempotent. Public import_file/2 lets ops drive a state at
a time from IEx.
- Schema: nlos_tiers JSONB column on coverages, status_changeset
whitelist, payload exposed via list_ready_for_organization.
Frontend
- LOS / NLOS toggle pills above the height slider (left panel).
Selecting NLOS swaps each L.imageOverlay's URL via setUrl to
the matching nlos_tiers PNG, drops the cached pixel data, and
re-decodes for the RSSI filter; falls back to the other
mode's tiers when one is empty so older coverages still
render. coverage_hooks chunk: 22 KB → 25 KB.
Vertical 'Height above clutter' slider on the left of the map now
matches cnHeat's experience: pick an SM install height, the heatmap
re-renders to show only the coverage achievable from that height.
Backend
- New JSONB height_tiers column on coverages keyed by string metres
→ relative PNG path.
- CoverageWorker runs compute_pixels once per tier in
[1.83, 3.05, 4.57, 6.10, 9.14, 12.19] m (~6/10/15/20/30/40 ft)
by varying receiver_height_m on a copied struct. Each tier
writes its own raster + PNG via Raster.write/4 with a per-height
filename suffix; the default 3.05 m tier also populates the
legacy raster_path / png_path so existing show pages keep
working.
Frontend
- Vertical range slider (writing-mode: vertical-lr) anchored
centre-left. Step picks one of HEIGHT_TIERS_FT; label updates
to '6 ft'..'40 ft'.
- pngForCoverage/2 picks the closest available tier from the
payload's height_tiers map, falling back to the original png
for coverages not yet recomputed.
- On slider change the JS swaps each L.imageOverlay's URL via
setUrl, drops the cached pixel data, and re-decodes so the RSSI
filter still applies. coverage_hooks chunk: 20 KB → 22 KB.
Adds the cnHeat-style horizontal 'Min RSSI' slider beside the opacity
control. Pixels weaker than the threshold turn fully transparent,
client-side, with no server round-trip — same compute output now
powers an interactive 'where can I get -75 dBm or better' exploration.
- Slider range -100..-40 dBm step 1; -100 disables filtering.
- addCoverageLayer fetches each PNG into a hidden canvas and caches
its ImageData. crossOrigin=anonymous keeps reads tainted-free.
- Each pixel is classified by nearest palette colour (-50/-65/-75/
-85/-95 dBm centres matching Raster.write/3) and dropped if its
dBm is below the threshold. Result re-encoded via toDataURL and
swapped onto the L.imageOverlay via setUrl.
- destroyed() revokes blob URLs and clears the filter cache to
avoid leaks during LiveView nav. Bundle: coverage_hooks chunk
16 KB → 20 KB; main bundle unchanged at 1.2 MB.
Switch esbuild to ESM with --splitting and load app.js as type=module.
Heavy hooks now ship in their own chunk and only download when their
LiveView mounts.
- assets/js/lib/leaflet.ts: extract ensureLeaflet (vendored Leaflet
loader) into a shared module with a typed window.L declaration.
- assets/js/hooks/coverage_hooks.ts: extract CoverageMap,
MultiCoverageMap, and CoverageLocationPicker (~510 lines) with
proper interface types for hook context and tightened typing on
payloads / event handlers (CoveragePayload is now a real type).
- app.ts: replace those three inline hook bodies with lazyHook
stubs that call import('./hooks/coverage_hooks') on first mount,
forwarding lifecycle hooks once the chunk resolves. Also adds a
scoped `declare const L: any` so the remaining Leaflet hooks
type-check.
- root.html.heex: switch the script tag to type=module so the
emitted ESM bundle can use dynamic import().
- esbuild config: add --splitting --format=esm.
Build output now produces:
app.js 1.2 MB
coverage_hooks-<hash>.js 16 KB (lazy)
chunk-<hash>.js 2.4 KB (shared, lazy)
- /coverage now renders a full-bleed Leaflet map of every ready
coverage in the org as one composite heatmap; the prior table view
moves to /coverage/list.
- Top-right tower toggle list with Show All / Hide All. Disabled
towers grey out and their overlay/marker drop from the map.
- Bottom-center opacity slider controls all overlays in unison; the
cnHeat dBm legend renders next to it.
- Top-left base-layer toggle (OSM streets / Esri satellite) and a
Nominatim address search bar that drops a marker.
- Click anywhere on the map → Distance Tool side panel with each
tower sorted by distance and the predicted RSSI at that point
(read from each coverage's GeoTIFF via gdallocationinfo). ft/m
unit toggle, NA for out-of-bbox, color-coded by signal quality.
- Backend additions: Coverages.list_ready_for_organization/1,
Coverages.query_point/3, Raster.query_rssi/3 (gdallocationinfo
wrapper with bbox guard, NoData sentinel handling).
- New JS hook MultiCoverageMap manages the layer set, toolbar,
search, opacity, click-to-probe, and live updates via PubSub
(coverage:enabled-changed and coverage:list-changed).
create flow only inserted the row and left status as :draft, so the
worker never ran. Chain queue_compute/1 after create_coverage/2 so
the coverage immediately moves to :queued and the show page renders
the compute progress.
- show.html.heex: `@coverage.device_id and ...` raised BadBooleanError
in production because device_id is a UUID string, not a boolean. Use
is_nil/1 checks instead.
- show.ex: handle_info/2 was reloading the coverage without preloading
:device, so the device row vanished from the UI on every status
broadcast. Preload :device alongside the reload.
- coverage_worker: thread the precomputed tx_height through the pixel
loop instead of re-deriving `coverage.height_agl_m + ...` per pixel
with inconsistent nil-safety vs. compute_pixels/6.
Adds a "Radio location" section to the coverage form with an embedded
Leaflet map. The marker starts at the parent site's coordinates and
the user can drag it to the actual radio location. A "Use site
location" button clears the override.
- New CoverageLocationPicker hook in assets/js/app.ts: creates the
map, draggable marker, and pushes "location_picked" {lat, lon} on
dragend. Re-creates the marker when the user picks a site that
initially had no coords.
- form.html.heex: location section with map div (phx-hook), readout
("lat, lon - override" if set), and reset button. Hidden inputs for
latitude_override / longitude_override so the form's params always
carry the chosen coordinates.
- form.ex: handle_event "location_picked" updates the override fields
in the changeset; "use_site_location" clears them. Helper functions
radio_marker_lat/lon walk the form values, falling back to the
selected site.
CI test gate fix:
- Worker test now requires GDAL CLI tools to write GeoTIFF + PNG.
test_helper.exs detects whether gdal_translate / gdaldem are on
PATH; when missing (CI's lean test image), :requires_gdal tagged
tests are excluded. Locally with brew install gdal they still run.
After picking a site in the coverage form, a new optional dropdown
lets the user attach the coverage to one of the devices at that site.
The dropdown reloads on every site change. If the site has no devices
yet, a hint replaces it.
Schema: nullable device_id FK on coverages (on_delete: nilify_all so
deleting a device leaves the coverage standing). Filtered by both
site_id and organization_id when loading the dropdown options.
Show page lists the attached device by name (or IP) when set.
Form simplification per user feedback. Form now shows only the
essentials (Name, Site, Antenna, Frequency, TX power, Range, Height,
Azimuth, Tilt, Foliage tuning) with imperial units throughout (ft, mi,
GHz). Defaults: TX power 18 dBm, height 100 ft, azimuth 0°, frequency
5.8 GHz, range 4 mi.
EIRP is calculated, not entered: shown as a live-updating badge in
the form header as `TX power + antenna.gain`.
Removed from the form (still on the schema with sensible defaults so
existing callers and the worker keep working): cell_size_m (auto-
computed from radius / 200, clamped 5–50 m), cable_loss_db, sm_gain_dbi,
tx_clearance_m, height_above_rooftop_m, receiver_height_m,
rx_threshold_dbm, latitude/longitude_override.
Schema:
- Six virtual imperial fields (height_agl_ft, height_above_rooftop_ft,
receiver_height_ft, tx_clearance_ft, radius_mi, frequency_ghz). The
changeset converts to SI before validation. Tests/workers/API keep
sending SI directly.
- ensure_cell_size_m/1: defaults the cell size when callers omit it.
Show + index pages now display GHz / ft / mi.
Propagation accuracy upgrade:
- Earth-curvature correction (4/3-Earth model, ITU-R P.453) applied
per profile sample. Important for paths over ~5 km.
- Replaced Bullington single-knife-edge with Deygout three-edge
multi-obstacle method (ITU-R P.526 §4.5): catches multi-ridge
terrain shadowing that a single dominant obstacle would miss.
Tests: 67 coverage tests pass (added Earth-curvature and Deygout
multi-obstacle reference checks). Full suite: 10817 tests.
The compute pipeline now actually runs: per-pixel path loss over LIDAR
terrain, antenna pattern lookup, real GeoTIFF + colored PNG output,
Leaflet imageOverlay rendering with opacity slider and dBm legend.
Propagation:
- Towerops.Coverages.Propagation: Friis FSPL + Bullington single-knife-edge
diffraction (ITU-R P.526). Pure Elixir, closed-form, no NIF dependency.
Clean signature so a future ITM/Longley-Rice backend can drop in.
- Towerops.Coverages.Profile: AAIGrid sampler with elevation_at, sample
along great circle, haversine distance.
- Towerops.Coverages.Raster: Float32 GeoTIFF via gdal_translate (with VRT
wrapper) and colored PNG via gdaldem color-relief. Outputs to
priv/static/coverage/<org>/<id>/, served by existing Plug.Static.
Worker:
- Towerops.Workers.CoverageWorker now does the full pipeline: bbox compute
-> Towerops.Lidar.get_elevation_grid -> per-pixel Task.async_stream
(parallel = schedulers) running antenna pattern lookup + propagation +
RSSI threshold check, write rasters. Configurable terrain source via
:coverage_terrain_module application env so tests can stub. Friendly
failure messages for :no_tile, :grid_too_large, :nodata,
:unknown_antenna, :missing_location.
Bundled antenna catalog (~95 entries):
- AntennaCatalog with compact specs for every antenna in the request list:
Cambium, ALPHA, Antel, ITELITE, KP Performance, L-com, MARS, MikroTik,
Mimosa, MTI, RADWIN, RF Elements, Ruckus, Tarana, Simulate, Ubiquiti.
- Antenna.from_spec/1 synthesizes 360+360 attenuation patterns from
gain+beamwidth (cosine-squared main lobe, smooth transition,
front-to-back floor with mild ripple). Real .ant files in priv/antennas/
override the catalog by slug. Test fixtures moved to test/support.
Schema (migration add_radio_fields_to_coverages):
- tx_power_dbm replaces eirp_dbm; EIRP is now derived as
tx_power + antenna.gain - cable_loss in Coverage.eirp_dbm/2 and shown
live in the form header.
- cable_loss_db, sm_gain_dbi, latitude_override, longitude_override,
height_above_rooftop_m, tx_clearance_m, foliage_tuning (0-100 slider).
- Coverage.location/1 returns {lat, lon} from override or parent site.
UI:
- form.html.heex: cnHeat-style grouped form (Identity / Location /
Mounting / RF / Coverage extent), foliage tuning range slider,
computed EIRP badge in the header.
- show.html.heex: Leaflet map area with L.imageOverlay heatmap,
opacity slider, dBm color legend, antenna marker with directional
wedge for azimuth.
- assets/js/app.ts: CoverageMap hook registered.
Tests: 24 new (propagation reference values, profile sampling,
worker end-to-end with stubbed terrain that writes real GeoTIFF + PNG,
org-mismatch job-cancel guard). Full suite: 10815 tests.