diff --git a/apps/ops/src/components/shared/detail-sections/EquipmentDetail.vue b/apps/ops/src/components/shared/detail-sections/EquipmentDetail.vue
index ee31da0..12e8451 100644
--- a/apps/ops/src/components/shared/detail-sections/EquipmentDetail.vue
+++ b/apps/ops/src/components/shared/detail-sections/EquipmentDetail.vue
@@ -97,6 +97,38 @@
+
+
+
+
+ Interroge le CPE : signal, CCQ, débit, capacité airMAX — comme F
+
+
Interrogation du CPE…
+
+ {{ airos.ok ? (airos.model || 'CPE sans-fil') : 'Sans-fil' }}
+ Réinterroger
+
+
+
+
+
+ Signal {{ airos.signal }} dBm · AP {{ airos.signalAp }}
+ CCQ {{ airos.ccq }} %
+ TX/RX {{ airos.txRate || '—' }} / {{ airos.rxRate || '—' }}
+ airMAX ▼{{ airos.capDown != null ? airos.capDown : '—' }} ▲{{ airos.capUp != null ? airos.capUp : '—' }} Mbps
+ {{ airos.ssid }}
+ {{ airos.frequency }}
+ TX {{ airos.txPower }}
+ {{ airos.uptime }}
+ {{ airos.version }}
+
+
+ {{ airos.registered === false ? 'Signal sans-fil en attente : webhook n8n « get_signal_airos » à configurer.' : airos.error }}
+
+
+
Identité de l'appareil
@@ -567,6 +599,22 @@ const portCtx = ref(null)
// ── Do Stuff en direct (F) : /collab/dostuff interroge l'OLT via n8n (VoIP/IPTV/distance/WAN/profil/firmware).
// ~30 s, à la demande (charge l'OLT) — jamais auto à l'ouverture. Réservé tech-3 XGS-PON (sinon « OLT non reconnue »).
const liveF = ref(null); const liveFLoading = ref(false)
+// Sans-fil (airOS/Cambium) : pas de fibre (olt_ip), et marque/modèle/type = radio. Le CPE n'est ni dans GenieACS ni
+// dans le poller OLT → signal live via webhook n8n (comme la fibre). Heuristique volontairement stricte (marque radio).
+const isWireless = computed(() => {
+ const d = props.doc
+ if (d.olt_ip) return false
+ const s = ((d.brand || '') + ' ' + (d.model || '') + ' ' + (d.equipment_type || '')).toLowerCase()
+ return /ubiquit|airmax|litebeam|nanostation|nanobridge|rocket|powerbeam|bullet|cambium|airos|\bap\b|sans.?fil|wireless/.test(s)
+})
+const airos = ref(null); const airosLoading = ref(false)
+async function fetchAiros () {
+ airos.value = null; airosLoading.value = true
+ try {
+ const r = await fetch(`${HUB_URL}/collab/airos-signal?serial=${encodeURIComponent(props.doc.serial_number || '')}&ip=${encodeURIComponent(props.doc.ip_address || '')}`)
+ airos.value = await r.json()
+ } catch (e) { airos.value = { ok: false, error: e.message } } finally { airosLoading.value = false }
+}
async function fetchLiveF () {
const sn = props.doc.serial_number, olt = props.doc.olt_ip
if (!sn || !olt) return
@@ -603,6 +651,14 @@ function copyForTicket () {
const fw = (lf && lf.firmware) || dev.firmware
if (fw) L.push('Firmware : ' + fw)
if (dev.uptime) L.push('Uptime : ' + formatUptime(dev.uptime))
+ const a = airos.value
+ if (a && a.ok) {
+ if (a.model) L.push('CPE sans-fil : ' + a.model + (a.version ? ' (' + a.version + ')' : ''))
+ if (a.signal != null) L.push('Signal : ' + a.signal + ' dBm' + (a.signalAp != null ? ' (AP ' + a.signalAp + ')' : ''))
+ if (a.ccq != null) L.push('CCQ : ' + a.ccq + ' %')
+ if (a.txRate || a.rxRate) L.push('Débit TX/RX : ' + (a.txRate || '—') + ' / ' + (a.rxRate || '—'))
+ if (a.capDown != null || a.capUp != null) L.push('Capacité airMAX : DL ' + (a.capDown != null ? a.capDown : '—') + ' / UL ' + (a.capUp != null ? a.capUp : '—') + ' Mbps')
+ }
const txt = L.join('\n')
const done = () => $q.notify({ type: 'positive', message: 'Résumé copié — collable dans un ticket', timeout: 1500 })
if (navigator.clipboard && navigator.clipboard.writeText) navigator.clipboard.writeText(txt).then(done).catch(() => fallbackCopy(txt, done))
@@ -997,6 +1053,7 @@ onUnmounted(() => stopWanLive())
.dsf-tiles { display: flex; flex-wrap: wrap; gap: 5px; margin-top: 7px; }
.dsf-chip { display: inline-flex; align-items: center; gap: 3px; font-size: 0.74rem; color: #334155; background: #fff; border: 1px solid #e2e8f0; border-radius: 5px; padding: 2px 7px; }
.dsf-chip.dsf-warn { background: #fffbeb; border-color: #fde68a; color: #92400e; }
+.dsf-note { font-size: 0.74rem; color: #92400e; margin-top: 6px; display: flex; align-items: center; gap: 4px; }
.dsf-link { color: #1e40af; text-decoration: none; }
.dsf-link:hover { text-decoration: underline; }
.collapse-head { display: flex; align-items: center; cursor: pointer; font-size: 0.8rem; font-weight: 600; color: #475569; padding: 6px 2px; border-top: 1px solid #f1f5f9; user-select: none; }
diff --git a/services/targo-hub/lib/ticket-collab.js b/services/targo-hub/lib/ticket-collab.js
index 591c4fa..e662e41 100644
--- a/services/targo-hub/lib/ticket-collab.js
+++ b/services/targo-hub/lib/ticket-collab.js
@@ -682,6 +682,11 @@ async function handle (req, res, method, p, url) {
if (p === '/collab/dostuff' && method === 'GET') {
return json(res, 200, await dostuffStatus({ sn: url.searchParams.get('serial') || url.searchParams.get('sn') || '', olt: url.searchParams.get('olt') || '' }))
}
+ // GET /collab/airos-signal?serial=[&ip=&port=&user=] — signal LIVE d'un CPE SANS-FIL (airOS/Cambium) via webhook n8n
+ // (le hub ne joint PAS les CPE directement → pont n8n, comme la fibre TP-Link). 404 = webhook pas encore créé.
+ if (p === '/collab/airos-signal' && method === 'GET') {
+ return json(res, 200, await airosSignal({ sn: url.searchParams.get('serial') || url.searchParams.get('sn') || '', ip: url.searchParams.get('ip') || '', port: url.searchParams.get('port') || '', user: url.searchParams.get('user') || '' }))
+ }
// GET /collab/negative-billing[?refresh=1] — comptes dont le total récurrent mensuel ACTIF est < 0 (rabais > frais)
if (p === '/collab/negative-billing' && method === 'GET') {
return json(res, 200, await negativeBilling(url.searchParams.get('refresh') === '1'))
@@ -797,4 +802,49 @@ function dostuffStatus ({ sn, olt } = {}) {
})
}
-module.exports = { handle, addComment, activity, createTicket, serviceStatus, dostuffStatus }
+// ── airOS/Cambium (sans-fil fixe) : signal LIVE d'un CPE via un webhook n8n `get_signal_airos` (À CRÉER — voir la
+// fiche de mise en place n8n). Miroir de F `device_ajax/airos_ac_ajax.php` : n8n se logue sur le CPE (POST /api/auth
+// airOS ≥ 8.5, sinon /login.cgi), lit `status.cgi`, et renvoie signal/CCQ/débit/capacité airMAX. Le hub NE PEUT PAS
+// joindre les CPE directement (réseau terrain non routable — vérifié : timeouts) → d'où le pont n8n, exactement
+// comme la fibre TP-Link (/webhook/dostuff). 404 = webhook pas encore configuré → on dégrade proprement.
+async function airosSignal ({ sn, ip, port, user } = {}) {
+ // Résout l'IP de gestion + le login depuis Service Equipment si non fournis (source unique du parc).
+ if ((!ip || !user) && sn) {
+ try {
+ const rows = await erp.list('Service Equipment', { filters: [['serial_number', '=', String(sn)]], fields: ['ip_address', 'login_user'], limit: 1 })
+ const e = rows && rows[0]
+ if (e) { if (!ip) ip = e.ip_address || ''; if (!user) user = e.login_user || '' }
+ } catch (e) { /* résolution best-effort */ }
+ }
+ if (!ip) return { ok: false, error: 'IP de gestion introuvable pour cet appareil (Service Equipment.ip_address vide)' }
+ return new Promise((resolve) => {
+ let https; try { https = require('https') } catch (e) { return resolve({ ok: false, error: 'https indisponible' }) }
+ const body = JSON.stringify({ sn: String(sn || ''), ip: String(ip), port: Number(port) || 2196, user: String(user || 'admin') })
+ const req = https.request('https://n8napi.targo.ca/webhook/get_signal_airos', { method: 'GET', headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(body) }, timeout: 25000 }, (res) => {
+ let d = ''; res.on('data', c => { d += c }); res.on('end', () => {
+ if (res.statusCode === 404) return resolve({ ok: false, registered: false, error: 'Webhook n8n « get_signal_airos » non configuré' })
+ let arr; try { arr = JSON.parse(d) } catch (e) { return resolve({ ok: false, error: 'réponse n8n illisible' }) }
+ const r = Array.isArray(arr) ? arr[0] : arr
+ if (!r || r.ErrorCode || r.error) return resolve({ ok: false, error: (r && (r.ErrorCode || r.error)) || 'réponse vide (CPE injoignable ?)' })
+ const clean = (v) => String(v == null ? '' : v).replace(/^"+|"+$/g, '').trim()
+ const num = (v) => { const n = parseFloat(clean(v)); return isNaN(n) ? null : n }
+ resolve({
+ ok: true, source: 'airos',
+ model: clean(r.device_model || r.model) || null, name: clean(r.device_name || r.name) || null,
+ version: clean(r.version) || null, ssid: clean(r.ssid) || null,
+ frequency: clean(r.frequency) || null, txPower: clean(r.tx_power || r.txPower) || null, apMac: clean(r.mac_ap || r.apMac) || null,
+ signal: num(r.signal), signalAp: num(r.signal_ap || r.signalAp), ccq: num(r.ccq),
+ txRate: clean(r.tx_rate || r.txRate) || null, rxRate: clean(r.rx_rate || r.rxRate) || null,
+ capUp: num(r.airmax_capacity_uplink || r.capUp), capDown: num(r.airmax_capacity_downlink || r.capDown),
+ uptime: clean(r.connection_time || r.uptime) || null,
+ lan0: clean(r.if_speed_lan0 || r.lan0) || null, lan1: clean(r.if_speed_lan1 || r.lan1) || null,
+ })
+ })
+ })
+ req.on('error', e => resolve({ ok: false, error: e.message }))
+ req.on('timeout', () => { req.destroy(); resolve({ ok: false, error: 'délai dépassé (~25 s) — CPE lent ou hors ligne' }) })
+ req.write(body); req.end()
+ })
+}
+
+module.exports = { handle, addComment, activity, createTicket, serviceStatus, dostuffStatus, airosSignal }