gigafibre-fsm/docs/n8n-get-signal-airos.md
louispaulb 9ce66eec9e feat(ops): wireless Do Stuff LIVE via F bridge (ops_airos.php)
The hub can't reach the CPE network, so wireless signal now flows through a
new read-only F endpoint that CAN (F sits on that network + already has the
airOS code + device creds):

- services/legacy-bridge/ops_airos.php — token-gated (X-Ops-Token, same secret
  as ops_reassign.php), SSRF-safe (only curls a mgmt IP present in F's `device`
  table). Resolves device by serial/mac/ip, logs into the CPE (airOS >=8.5
  /api/auth or <8.5 /login.cgi), reads status.cgi, returns normalized signal /
  signal-AP / CCQ / TX-RX rate / airMAX capacity / model / uptime. v8 gets full
  data; v6 falls back to top-level wireless.signal; CCQ clamped to 0-100.
- hub: airosSignal now calls the F bridge FIRST (OPS_LEGACY_URL host +
  /app/data/ops_legacy.token), n8n webhook demoted to post-migration fallback.
  Shared normalizeAiros() for both paths.

Verified end-to-end (hub -> F -> CPE): NanoBeam 5AC signal -53 / airMAX
140/143 Mbps; v6 NanoStation signal -47; offline CPE -> clean {ok:false}.
Auth reject + SSRF guard verified. docs updated (F bridge primary).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 15:02:58 -04:00

6.1 KiB
Raw Blame History

Wireless Do Stuff — data source (F bridge now, n8n post-migration)

STATUS (2026-07-20): LIVE via the F bridge. Wireless signal now flows through services/legacy-bridge/ops_airos.php (deployed on facturation.targo.ca), which F can reach on the CPE network and which already has the airOS code + device creds. The hub (ticket-collab.airosSignal) calls it first (OPS_LEGACY_URL host + X-Ops-Token), and only falls back to the n8n webhook below if the F bridge is unreachable. The n8n webhook is the post-F-migration path — build it before F is sunset (~Sept), then the hub fallback picks it up automatically. Verified end-to-end: NanoBeam 5AC → signal 53, airMAX ↑140/↓143 Mbps; offline CPE → clean {ok:false}.

The rest of this doc specifies the n8n webhook (the future path). Everything on the OPS side is built and deployed:

  • Hub reader/endpoint: GET /collab/airos-signal?serial=<sn>[&ip=&port=&user=] (ticket-collab.airosSignal). Resolves the CPE mgmt IP + login from Service Equipment and calls https://n8napi.targo.ca/webhook/get_signal_airos.
  • UI: EquipmentDetail.vue shows "Do Stuff en direct (sans-fil)" for wireless equipment and renders the fields below. Until this webhook exists it degrades gracefully ("signal sans-fil en attente : webhook get_signal_airos à configurer").

Why n8n and not the hub directly? Verified empirically: the hub cannot route to the CPE management network (100.x CGNAT + 10.150/10.151.x) — every probe timed out. n8n sits where it can reach the field gear (same reason fiber TP-Link Do Stuff uses /webhook/dostuff). Keeping the fetch in n8n also keeps the AP password out of OPS/the hub.

This is a straight port of F's device_ajax/airos_ac_ajax.php (statuscgi branch).


Contract

RequestGET /webhook/get_signal_airos, JSON body:

{ "sn": "<serial>", "ip": "<mgmt ip>", "port": 2196, "user": "admin" }

ip/port/user are passed by the hub (resolved from Service Equipment.ip_address / login_user). The password must live in n8n (credential/env) — F's fallback is admin / N0HAk4u$.

Response — JSON object (or 1-element array). The hub reads these keys (snake_case as F emits them; camelCase also accepted):

key source in status.cgi
device_model host.devmodel
device_name host.hostname
version host.fwversion
ssid wireless.essid
frequency wireless.frequency
tx_power wireless.txpower
mac_ap wireless.apmac
signal wireless.sta[0].signal (dBm)
signal_ap wireless.sta[0].remote.signal (dBm)
ccq wireless.sta[0].ccq (or wireless.ccq)
tx_rate idx[wireless.tx_idx]
rx_rate idx[wireless.rx_idx]
airmax_capacity_uplink round(wireless.sta[0].airmax.uplink_capacity / 1024, 2) (Mbps)
airmax_capacity_downlink round(wireless.sta[0].airmax.downlink_capacity / 1024, 2) (Mbps)
connection_time secondsToTime(host.uptime)
if_speed_lan0 / if_speed_lan1 interfaces[ifname=eth0/eth1].status.speed + duplex

where idx = ['1x','2x','2x','4x','4x','6x','6x','8x','8x'] (airMAX MCS multiplier).

On failure return { "error": "<reason>" } (the hub surfaces it in the panel).


Workflow (4 nodes)

  1. Webhook — method GET, path get_signal_airos, "Respond: Using Respond to Webhook node".

  2. HTTP Request — airOS auth (airOS ≥ 8.5, fallback < 8.5)

    • POST https://{{$json.body.ip}}:{{$json.body.port}}/api/auth
    • body (form-urlencoded): username={{user}}, password={{$credentials}}
    • Options: ignore SSL issues = on, full response = on (need response headers to capture X-CSRF-ID and the session cookie).
    • If status ≠ 200 → fall back to POST /login.cgi (same fields).
  3. HTTP Request — status.cgi

    • GET https://{{ip}}:{{port}}/status.cgi
    • headers: Cookie from node 2, plus X-CSRF-ID: {{...}} when the ≥8.5 path was used.
    • ignore SSL issues = on. Response format = JSON.
  4. Function — normalize → then Respond to Webhook:

const s = items[0].json;                       // status.cgi
const idx = ['1x','2x','2x','4x','4x','6x','6x','8x','8x'];
const sec2t = (n) => { if(!n||n<=0) return ''; const d=Math.floor(n/86400),h=Math.floor(n%86400/3600),m=Math.floor(n%3600/60),ss=Math.floor(n%60); return `${d}d ${String(h).padStart(2,'0')}:${String(m).padStart(2,'0')}:${String(ss).padStart(2,'0')}`; };
const w = s.wireless||{}, sta = (w.sta&&w.sta[0])||{}, host = s.host||{};
const iface = (n) => (s.interfaces||[]).find(i=>i.ifname===n)||{};
const eth0 = iface('eth0').status||{}, eth1 = iface('eth1').status||{};
return [{ json: {
  device_model: host.devmodel, device_name: host.hostname, version: host.fwversion,
  ssid: w.essid, frequency: w.frequency, tx_power: w.txpower, mac_ap: w.apmac,
  signal: sta.signal, signal_ap: (sta.remote||{}).signal, ccq: sta.ccq ?? w.ccq,
  tx_rate: idx[w.tx_idx], rx_rate: idx[w.rx_idx],
  airmax_capacity_uplink: Math.round(((sta.airmax||{}).uplink_capacity||0)/1024*100)/100,
  airmax_capacity_downlink: Math.round(((sta.airmax||{}).downlink_capacity||0)/1024*100)/100,
  connection_time: sec2t(host.uptime),
  if_speed_lan0: eth0.plugged ? `${eth0.speed} Mbps` : 'unplugged',
  if_speed_lan1: eth1.plugged ? `${eth1.speed} Mbps` : 'unplugged',
}}];

(Optional) call GET /logout.cgi (with X-AIROS-LUA: 1 + X-CSRF-ID) after node 3 to release the session, as F does.


Cambium

Cambium CPEs use a different API (device/cambium.php in F). Handle as a branch in the same workflow keyed on the caller's device brand/category (add a brand/ category field to the hub call), or a separate get_signal_cambium webhook. Not required for the airOS/airMAX park (the majority).

Test

curl -s -X GET https://n8napi.targo.ca/webhook/get_signal_airos \
  -H 'Content-Type: application/json' \
  -d '{"ip":"<a-reachable-CPE-ip>","port":2196,"user":"admin"}'

Then in OPS: open a wireless device → Do Stuff en direct (sans-fil) → signal / CCQ / TX-RX / airMAX capacity render. No further OPS changes needed.