diff --git a/apps/ops/src/components/shared/detail-sections/CommunicationDetail.vue b/apps/ops/src/components/shared/detail-sections/CommunicationDetail.vue
new file mode 100644
index 0000000..d2d4142
--- /dev/null
+++ b/apps/ops/src/components/shared/detail-sections/CommunicationDetail.vue
@@ -0,0 +1,65 @@
+
+
+
+
+
+ {{ typeLabel }}
+ {{ formatDateTime(doc.communication_date) }}
+
+
+
{{ doc.subject }}
+
+
+
De{{ doc.sender }}
+
À{{ doc.recipients }}
+
CC{{ doc.cc }}
+
+
+
+
+
+
+
+
Aucun contenu.
+
+
+
+
+
+
diff --git a/apps/ops/src/components/shared/detail-sections/DispatchJobDetail.vue b/apps/ops/src/components/shared/detail-sections/DispatchJobDetail.vue
new file mode 100644
index 0000000..b8aab43
--- /dev/null
+++ b/apps/ops/src/components/shared/detail-sections/DispatchJobDetail.vue
@@ -0,0 +1,160 @@
+
+
+
+
+
+
+ {{ doc.customer_name || doc.customer }}
+
+
+
+
+
+ {{ prioLabel }}
+ {{ scheduledLabel }}
+ {{ doc.job_type }}
+
+
{{ doc.address }}
+
Ticket F #{{ doc.legacy_ticket_id }}
+
+
+
+
+
+
+
+
+
+
+
{ doc.duration_h = p.duration_h }" />
+
+ { doc.required_skill = (arr && arr[0]) || '' }" @create="onCreateTag" />
+
+
+ $emit('dispatch-updated', doc.name || docName)" />
+
+
+
+
+ { threadRef && threadRef.reload(); $emit('reply-sent', doc.name || docName) }" />
+
+
+
+
+
+
+
diff --git a/apps/ops/src/components/shared/detail-sections/EquipmentDetail.vue b/apps/ops/src/components/shared/detail-sections/EquipmentDetail.vue
index 239e1c1..7221c9c 100644
--- a/apps/ops/src/components/shared/detail-sections/EquipmentDetail.vue
+++ b/apps/ops/src/components/shared/detail-sections/EquipmentDetail.vue
@@ -448,6 +448,8 @@
Fin garantie{{ doc.warranty_end }}
Adresse{{ doc.service_location }}
Abonnement{{ doc.subscription }}
+
+
@@ -507,6 +509,7 @@
import { ref, computed, onMounted, onUnmounted, watch } from 'vue'
import { useQuasar } from 'quasar'
import InlineField from 'src/components/shared/InlineField.vue'
+import OnuActionsPanel from './modules/OnuActionsPanel.vue'
import { useDeviceStatus } from 'src/composables/useDeviceStatus'
import { useModemDiagnostic } from 'src/composables/useModemDiagnostic'
import { deleteDoc } from 'src/api/erp'
@@ -826,6 +829,13 @@ function toggleWanLive () { wanLive.value ? stopWanLive() : startWanLive() }
// Liste d'appareils au clic sur l'icône : on déplie → la version EN CACHE s'affiche vite (le hub sert le cache par défaut) + anneau de chargement pendant le rafraîchissement.
function openHosts () { showHosts.value = true; if (!hosts.value && device.value) loadHosts() }
+// Une action ONU a modifié le réseau → refléter le nouveau statut (optimiste) + re-sonder l'appareil.
+function onOnuChanged ({ action } = {}) {
+ const map = { suspend: 'Suspendu', unsuspend: 'Actif', activate: 'Actif', remove: 'Retiré' }
+ if (map[action]) props.doc.status = map[action]
+ setTimeout(doRefresh, 1500)
+}
+
async function doRefresh () {
if (!props.doc.serial_number) return
refreshing.value = true
diff --git a/apps/ops/src/components/shared/detail-sections/modules/OnuActionsPanel.vue b/apps/ops/src/components/shared/detail-sections/modules/OnuActionsPanel.vue
new file mode 100644
index 0000000..3a78b42
--- /dev/null
+++ b/apps/ops/src/components/shared/detail-sections/modules/OnuActionsPanel.vue
@@ -0,0 +1,144 @@
+
+
+
+
Actions ONU (fibre)
+
Chaque action montre un aperçu avant d’exécuter. Modifie le réseau du client.
+
+
+
+
+
+
+
+ {{ planData ? planData.label : 'Action ONU' }}
+
+ tech-{{ planData.tech }}
+
+
+
+
+
+
+
+
+
+
+
+
Série{{ planData.target.serial || '—' }}
+
OLT{{ planData.target.olt || '—' }} · {{ planData.target.slot }}/{{ planData.target.port }}/{{ planData.target.ontid }}
+
Client{{ planData.customer }}
+
Appel{{ planData.verb }} dostuff
+
+
+
+
+
+
+ {{ w }}
+
+
+ {{ planError || 'Aperçu indisponible.' }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/apps/ops/src/config/nav.js b/apps/ops/src/config/nav.js
index a0a350c..aa29bc7 100644
--- a/apps/ops/src/config/nav.js
+++ b/apps/ops/src/config/nav.js
@@ -23,6 +23,7 @@ export const navItems = [
{ path: '/copilote', icon: 'Sparkles', label: 'Copilote', requires: 'view_all_jobs', section: 'terrain' },
{ path: '/historique', icon: 'History', label: 'Historique', requires: 'view_all_jobs', section: 'terrain' },
{ path: '/taches-pilote', icon: 'Workflow', label: 'Orchestration (tâches)', requires: 'view_all_jobs', section: 'terrain' },
+ { path: '/network', icon: 'Network', label: 'Réseau (GPON)', requires: 'view_settings', section: 'terrain' },
// ── Administration ──
{ path: '/equipe', icon: 'UsersRound', label: 'Équipe', requires: 'manage_users', section: 'admin' },
{ path: '/campaigns', icon: 'Gift', label: 'Campagnes', requires: 'manage_users', section: 'admin' },
@@ -31,6 +32,8 @@ export const navItems = [
{ path: '/conformite-adresses', icon: 'MapPinned', label: 'Conformité adresses', requires: 'view_settings', section: 'admin' },
{ path: '/sync-legacy', icon: 'RefreshCw', label: 'Sync F↔ERPNext', requires: 'view_settings', section: 'admin' },
{ path: '/email-queue', icon: 'Mail', label: 'File courriels', requires: 'view_settings', section: 'admin' },
+ { path: '/telephony', icon: 'Phone', label: 'Téléphonie (SIP)', requires: 'view_settings', section: 'admin' },
+ { path: '/agent-flows', icon: 'Bot', label: 'Flux agent IA', requires: 'view_settings', section: 'admin' },
{ path: '/factures-fournisseurs', icon: 'ReceiptText', label: 'Factures fournisseurs', requires: 'view_settings', section: 'admin' },
{ path: '/sous-traitants', icon: 'HardHat', label: 'Sous-traitants', requires: 'view_settings', section: 'admin' },
{ path: '/facturation/approbations', icon: 'ClipboardCheck', label: 'Approb. facturation', requires: 'view_all_jobs', section: 'admin' },
diff --git a/apps/ops/src/layouts/MainLayout.vue b/apps/ops/src/layouts/MainLayout.vue
index 3e74c52..d3b2902 100644
--- a/apps/ops/src/layouts/MainLayout.vue
+++ b/apps/ops/src/layouts/MainLayout.vue
@@ -199,6 +199,7 @@ import {
LayoutDashboard, Users, Truck, Ticket, UsersRound, BarChart3,
Gift, Settings, LogOut, PanelLeftOpen, PanelLeftClose, Mail,
CalendarRange, CalendarClock, Sparkles, MapPinned, Workflow, RefreshCw, ReceiptText, MessagesSquare, History, Award, Star, ClipboardCheck, HardHat, PartyPopper,
+ Network, Phone, Bot,
} from 'lucide-vue-next'
// Composants lourds montés conditionnellement (v-if) → chargés à la demande (defineAsyncComponent) au lieu d'alourdir
// le chunk MainLayout livré sur CHAQUE page. Aucun accès par ref → sûr. NotificationBell reste synchrone (barre du haut, toujours visible).
@@ -217,7 +218,7 @@ const OutboxPanel = defineAsyncComponent(() => import('src/components/shared/Out
const CommandPalette = defineAsyncComponent(() => import('src/components/shared/CommandPalette.vue'))
import { useCommandPalette } from 'src/composables/useCommandPalette'
-const icons = { LayoutDashboard, Users, Truck, Ticket, UsersRound, BarChart3, Gift, Settings, LogOut, PanelLeftOpen, PanelLeftClose, Mail, CalendarRange, CalendarClock, Sparkles, MapPinned, Workflow, RefreshCw, ReceiptText, MessagesSquare, History, Award, Star, ClipboardCheck, HardHat, PartyPopper }
+const icons = { LayoutDashboard, Users, Truck, Ticket, UsersRound, BarChart3, Gift, Settings, LogOut, PanelLeftOpen, PanelLeftClose, Mail, CalendarRange, CalendarClock, Sparkles, MapPinned, Workflow, RefreshCw, ReceiptText, MessagesSquare, History, Award, Star, ClipboardCheck, HardHat, PartyPopper, Network, Phone, Bot }
const { panelOpen, newDialogOpen, newDialogChannel, activeCount: convCount, openComposeDraft } = useConversations()
const { requestCreate } = useCreateSignal()
diff --git a/apps/ops/src/pages/RapportsPage.vue b/apps/ops/src/pages/RapportsPage.vue
index cf5809a..1e6ec4e 100644
--- a/apps/ops/src/pages/RapportsPage.vue
+++ b/apps/ops/src/pages/RapportsPage.vue
@@ -15,23 +15,6 @@
-
-
-
- {{ opsReports.length }} rapports — bientôt disponibles
-
-
-
-
-
-
{{ report.title }}
-
-
-
{{ report.description }}
-
-
-
-
@@ -39,7 +22,6 @@
import { onMounted, onUnmounted } from 'vue'
import { useRouter } from 'vue-router'
import PageHeader from 'src/components/shared/PageHeader.vue'
-import DisclosureSection from 'src/components/shared/DisclosureSection.vue'
import { useCommandPalette } from 'src/composables/useCommandPalette'
const router = useRouter()
@@ -103,44 +85,6 @@ const financeReports = [
},
]
-const opsReports = [
- {
- title: 'Clients par territoire',
- description: 'Répartition des clients actifs par zone géographique.',
- icon: 'map',
- color: 'teal-6',
- action: () => {},
- },
- {
- title: 'Tickets par priorité',
- description: 'Analyse des tickets ouverts et temps de résolution moyen.',
- icon: 'bug_report',
- color: 'orange-6',
- action: () => {},
- },
- {
- title: 'Équipements déployés',
- description: 'Inventaire des ONT, routeurs et modems par statut.',
- icon: 'router',
- color: 'blue-6',
- action: () => {},
- },
- {
- title: 'Abonnements actifs',
- description: 'Liste des abonnements avec plan, montant et ancienneté.',
- icon: 'subscriptions',
- color: 'purple-6',
- action: () => {},
- },
- {
- title: 'Dispatch performance',
- description: 'Taux de complétion des tâches et temps moyen par technicien.',
- icon: 'speed',
- color: 'red-6',
- action: () => {},
- },
-]
-
// Chaque rapport devient joignable par la palette (⌘K) — l'index visuel reste épuré,
// mais rien n'est « caché » : taper le nom d'un rapport l'ouvre directement.
const { registerActions, unregisterActions } = useCommandPalette()
diff --git a/apps/ops/src/pages/SupplierInvoicesPage.vue b/apps/ops/src/pages/SupplierInvoicesPage.vue
index ac95e3b..9d5a902 100644
--- a/apps/ops/src/pages/SupplierInvoicesPage.vue
+++ b/apps/ops/src/pages/SupplierInvoicesPage.vue
@@ -8,6 +8,8 @@
+
+
diff --git a/services/targo-hub/lib/olt-ops.js b/services/targo-hub/lib/olt-ops.js
new file mode 100644
index 0000000..3603f6b
--- /dev/null
+++ b/services/targo-hub/lib/olt-ops.js
@@ -0,0 +1,171 @@
+'use strict'
+// ══════════════════════════════════════════════════════════════════════════════
+// olt-ops.js — ÉCRITURES cycle-de-vie ONU (activate / replace / speed / suspend /
+// remove) pour la fibre TP-Link tech-3 (XGS-PON), en enveloppant les MÊMES verbes
+// n8n que F (`/webhook/dostuff`, le VERBE HTTP = l'action). C'est le port G2 du
+// plan « Do Stuff » : OPS lisait déjà tout (olt-snmp) mais n'écrivait RIEN à l'OLT.
+//
+// SÉCURITÉ (leçons PPA + F sans idempotence) :
+// • plan() = LECTURE SEULE — résout le contexte (OLT/slot/port/profil depuis
+// Service Equipment), retourne le payload EXACT + effets + avertissements. 0 réseau.
+// • run() = fire le verbe n8n PUIS miroir ERPNext. Exige confirm:true + une
+// idempotency-key ; re-jouer la MÊME clé ne re-tire PAS (cache /app/data/olt_ops.json).
+// • tech-2 (Raisecom, RCMG) = SNMP direct côté F → n8n renvoie « OLT non reconnue » ;
+// plan() le signale (écriture SNMP native = G3, pas encore branchée) et refuse run().
+// ══════════════════════════════════════════════════════════════════════════════
+const fs = require('fs')
+const erp = require('./erp')
+const { log } = require('./helpers')
+
+const N8N_DOSTUFF = 'https://n8napi.targo.ca/webhook/dostuff'
+const STORE = '/app/data/olt_ops.json'
+const IDEMP_WINDOW_MS = 10 * 60 * 1000 // rejouer la même clé dans 10 min = même résultat (pas de 2e tir)
+
+// action → { verb HTTP n8n, champs requis, description FR }
+const ACTIONS = {
+ activate: { verb: 'POST', label: 'Activer / provisionner l’ONU', needs: ['sn', 'olt', 'profileid'] },
+ replace: { verb: 'PUT', label: 'Remplacer l’ONU (échange de série)', needs: ['old_sn', 'new_sn', 'olt'] },
+ speed: { verb: 'PATCH', label: 'Changer le forfait / la vitesse', needs: ['sn', 'olt', 'profileid'], extra: { trigger: 'speedchange' } },
+ suspend: { verb: 'PATCH', label: 'Suspendre l’internet (VLAN)', needs: ['sn', 'olt'], extra: { profileid: '', trigger: 'suspend' } },
+ unsuspend: { verb: 'PATCH', label: 'Rétablir l’internet (VLAN)', needs: ['sn', 'olt'], extra: { profileid: '', trigger: 'unsuspend' } },
+ remove: { verb: 'DELETE', label: 'Retirer / désactiver l’ONU', needs: ['sn', 'olt'] },
+}
+
+function readStore () { try { return JSON.parse(fs.readFileSync(STORE, 'utf8')) } catch (e) { return {} } }
+function writeStore (o) { try { fs.writeFileSync(STORE, JSON.stringify(o)) } catch (e) { log('olt-ops store write: ' + e.message) } }
+
+// tech-3 (TP-Link XGS-PON) = n8n ; tech-2 (Raisecom) = SNMP direct côté F (pas n8n).
+function techOf (serial, equip) {
+ const s = String(serial || (equip && equip.serial_number) || '').toUpperCase()
+ if (equip && equip.fibre_tech) return Number(equip.fibre_tech)
+ if (s.startsWith('TPLG')) return 3
+ if (s.startsWith('RCMG')) return 2
+ return null // inconnu
+}
+
+// Résout le contexte réseau d'un ONU depuis Service Equipment (miroir de la table `fibre` de F).
+async function resolveEquip (serial) {
+ // ⚠️ Ne PAS demander fibre_line_profile/fibre_service_profile ici : ce ne sont PAS des DocFields v16
+ // (erp.list les strippe → bruit + latence). Le profil (activate/speed) est fourni par l'appelant
+ // (l'UI le collecte) ; suspend/unsuspend/remove n'en ont pas besoin.
+ const rows = await erp.list('Service Equipment', {
+ filters: [['serial_number', '=', String(serial)]],
+ fields: ['name', 'serial_number', 'mac_address', 'olt_ip', 'olt_slot', 'olt_port', 'olt_ontid', 'customer', 'service_location', 'status', 'equipment_type'],
+ limit: 1,
+ }).catch(() => [])
+ return (rows && rows[0]) || null
+}
+
+// Appel n8n dostuff avec un VERBE arbitraire + body JSON (le module https, comme dostuffStatus).
+function dostuff (verb, payload, timeoutMs = 60000) {
+ return new Promise((resolve) => {
+ let https; try { https = require('https') } catch (e) { return resolve({ ok: false, error: 'https indisponible' }) }
+ const body = JSON.stringify(payload)
+ const req = https.request(N8N_DOSTUFF, { method: verb, headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(body) }, timeout: timeoutMs }, (res) => {
+ let d = ''; res.on('data', c => { d += c }); res.on('end', () => {
+ let parsed; try { parsed = JSON.parse(d) } catch (e) { parsed = d }
+ const r = Array.isArray(parsed) ? parsed[0] : parsed
+ const recognized = !(r && r.ErrorCode)
+ resolve({ ok: recognized && res.statusCode < 400, status: res.statusCode, recognized, error: (r && r.ErrorCode) || null, data: r })
+ })
+ })
+ req.on('error', e => resolve({ ok: false, error: e.message }))
+ req.on('timeout', () => { req.destroy(); resolve({ ok: false, error: 'délai n8n dépassé (~' + Math.round(timeoutMs / 1000) + ' s)' }) })
+ req.write(body); req.end()
+ })
+}
+
+// Construit le payload n8n EXACT pour une action, à partir du contexte résolu + overrides.
+function buildPayload (action, equip, opts = {}) {
+ const spec = ACTIONS[action]
+ const olt = opts.olt || (equip && equip.olt_ip) || ''
+ const sn = opts.sn || (equip && equip.serial_number) || ''
+ const profileid = opts.profileid != null ? opts.profileid : (equip && equip.fibre_line_profile) || ''
+ const base = { user: opts.user || 'ops', tel: opts.tel || '' }
+ let p
+ if (action === 'replace') p = { old_sn: opts.old_sn || sn, new_sn: opts.new_sn || '', olt, ...base }
+ else p = { sn, olt, ...base }
+ if (spec.needs.includes('profileid') || action === 'speed') p.profileid = profileid
+ if (action === 'activate') { p.profileid = profileid; p.clientid = opts.clientid || (equip && equip.customer) || '' }
+ if (spec.extra) Object.assign(p, spec.extra)
+ return p
+}
+
+// PLAN — LECTURE SEULE. Résout le contexte, retourne le payload + effets + avertissements. Aucun appel réseau.
+async function plan ({ serial, action, ...opts } = {}) {
+ const spec = ACTIONS[action]
+ if (!spec) return { ok: false, error: 'action inconnue: ' + action + ' (activate|replace|speed|suspend|unsuspend|remove)' }
+ const equip = await resolveEquip(opts.old_sn || serial)
+ const tech = techOf(opts.old_sn || serial, equip)
+ const payload = buildPayload(action, equip, { ...opts, sn: serial, old_sn: opts.old_sn || serial })
+ const warnings = []
+ if (!equip) warnings.push('Aucun Service Equipment pour ce numéro de série — l’OLT/profil doivent être fournis manuellement.')
+ if (tech === 2) warnings.push('ONU Raisecom (tech-2) : les écritures passent par SNMP direct (côté F), PAS par n8n. Écriture native SNMP non encore branchée (G3) → utiliser F pour l’instant.')
+ if (tech == null) warnings.push('Technologie ONU inconnue (préfixe série non reconnu) — vérifier avant d’exécuter.')
+ if (!payload.olt) warnings.push('IP OLT manquante — requise.')
+ const missing = spec.needs.filter(k => !payload[k] && payload[k] !== 0)
+ if (missing.length) warnings.push('Champs manquants : ' + missing.join(', '))
+ const effects = []
+ if (action === 'activate') effects.push('Provisionne l’ONU sur l’OLT + met Service Equipment « Actif ».')
+ if (action === 'replace') effects.push('Ré-authentifie la nouvelle série sur l’OLT + met à jour le numéro de série de Service Equipment.')
+ if (action === 'speed') effects.push('Change le profil de ligne (l’ONU redémarre).')
+ if (action === 'suspend') effects.push('Bascule le VLAN internet → coupe l’accès (facturation inchangée).')
+ if (action === 'unsuspend') effects.push('Rétablit le VLAN internet.')
+ if (action === 'remove') effects.push('Retire l’ONU de l’OLT + délie Service Equipment.')
+ return {
+ ok: warnings.length === 0 || (tech === 3 && !missing.length && !!payload.olt),
+ action, label: spec.label, verb: spec.verb, tech,
+ target: { serial: serial || payload.sn, olt: payload.olt, slot: equip && equip.olt_slot, port: equip && equip.olt_port, ontid: equip && equip.olt_ontid, profile: payload.profileid },
+ equipment: equip ? equip.name : null, customer: equip && equip.customer, service_location: equip && equip.service_location,
+ payload, effects, warnings,
+ can_run: tech === 3 && !missing.length && !!payload.olt, // n8n = tech-3 seulement
+ }
+}
+
+// RUN — fire le verbe n8n PUIS miroir ERPNext. Idempotent par clé. confirm:true obligatoire.
+async function run ({ serial, action, confirm, idempotencyKey, actor, ...opts } = {}) {
+ const spec = ACTIONS[action]
+ if (!spec) return { ok: false, error: 'action inconnue' }
+ if (!confirm) return { ok: false, error: 'confirm:true requis (action réseau)' }
+ const pl = await plan({ serial, action, ...opts })
+ if (!pl.can_run) return { ok: false, error: 'plan non exécutable', plan: pl }
+ // Idempotence : même clé rejouée dans la fenêtre → renvoyer le résultat mémorisé (NE PAS re-tirer).
+ const key = String(idempotencyKey || '')
+ if (!key) return { ok: false, error: 'idempotencyKey requis' }
+ const store = readStore()
+ const prev = store[key]
+ if (prev && (Date.now() - prev.at) < IDEMP_WINDOW_MS) return { ok: true, idempotent: true, ...prev.result }
+ // FIRE
+ const net = await dostuff(spec.verb, pl.payload)
+ const result = { action, serial: pl.target.serial, olt: pl.payload.olt, verb: spec.verb, net_ok: !!net.ok, net_error: net.error || null, net_status: net.status, mirrored: [] }
+ if (net.ok && pl.equipment) {
+ // Miroir ERPNext (fill-only, best-effort) — reflète l'effet sur Service Equipment.
+ try {
+ if (action === 'activate' || action === 'unsuspend') { await erp.update('Service Equipment', pl.equipment, { status: 'Actif' }); result.mirrored.push('status=Actif') }
+ else if (action === 'suspend') { await erp.update('Service Equipment', pl.equipment, { status: 'Suspendu' }); result.mirrored.push('status=Suspendu') }
+ else if (action === 'remove') { await erp.update('Service Equipment', pl.equipment, { status: 'Retiré' }); result.mirrored.push('status=Retiré') }
+ else if (action === 'replace' && opts.new_sn) { await erp.update('Service Equipment', pl.equipment, { serial_number: opts.new_sn }); result.mirrored.push('serial=' + opts.new_sn) }
+ } catch (e) { result.mirror_error = e.message }
+ }
+ store[key] = { at: Date.now(), action, actor: actor || '', result }
+ writeStore(store)
+ log('[olt-ops] ' + action + ' ' + pl.target.serial + ' → net_ok=' + result.net_ok + (result.net_error ? ' (' + result.net_error + ')' : '') + ' actor=' + (actor || '?'))
+ return { ok: !!net.ok, ...result }
+}
+
+// G1 reste : clients WiFi connectés d'un ONU (n8n get_wifi_info) — LECTURE.
+function wifiClients ({ sn } = {}) {
+ return new Promise((resolve) => {
+ if (!sn) return resolve({ ok: false, error: 'sn requis' })
+ let https; try { https = require('https') } catch (e) { return resolve({ ok: false, error: 'https indisponible' }) }
+ const body = JSON.stringify({ sn: String(sn) })
+ const req = https.request('https://n8napi.targo.ca/webhook/get_wifi_info', { method: 'GET', headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(body) }, timeout: 20000 }, (res) => {
+ let d = ''; res.on('data', c => { d += c }); res.on('end', () => { let j; try { j = JSON.parse(d) } catch (e) { j = null } resolve({ ok: !!j, clients: Array.isArray(j) ? j : (j && j.clients) || [], raw: j }) })
+ })
+ req.on('error', e => resolve({ ok: false, error: e.message }))
+ req.on('timeout', () => { req.destroy(); resolve({ ok: false, error: 'délai dépassé' }) })
+ req.write(body); req.end()
+ })
+}
+
+module.exports = { plan, run, wifiClients, ACTIONS }