diff --git a/docs/n8n-get-signal-airos.md b/docs/n8n-get-signal-airos.md new file mode 100644 index 0000000..3e42adb --- /dev/null +++ b/docs/n8n-get-signal-airos.md @@ -0,0 +1,123 @@ +# n8n webhook: `get_signal_airos` (wireless Do Stuff) + +This is the **one remaining hop** to light up the wireless (airOS / Cambium) "Do Stuff" +panel in OPS. Everything on the OPS side is built and deployed: + +- Hub reader/endpoint: `GET /collab/airos-signal?serial=[&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 + +**Request** — `GET /webhook/get_signal_airos`, JSON body: + +```json +{ "sn": "", "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": "" }` (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**: + +```js +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 + +```bash +curl -s -X GET https://n8napi.targo.ca/webhook/get_signal_airos \ + -H 'Content-Type: application/json' \ + -d '{"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.