feat: reference providers system with Wassup integration

- Pluggable multi-provider architecture (ReferenceEngine)
- Wassup provider implementation (HTTP GET + X_WASSUP_USER_IP)
- Auto-resolve references on Inform (BOOT/BOOTSTRAP events)
- References displayed in device Identity card
- REST API: GET/POST references per device
- Config via config/references.json
- Fix event extraction for single EventStruct and numeric EventCodes
- Mock Wassup provisioned by home-device-simulator on device creation
This commit is contained in:
Kevin L'Hereec committed 2026-06-02 15:52:16 +02:00
1 parent 4eb3f0795a
commit e857b4fbe7
16 files changed
+1154 -7

No files matched your search

+228
View File
@@ -0,0 +1,228 @@
# Feature : Références Client — Système de Providers Pluggable
## Contexte
Dans adam-acs, on veut un système de **références externes** (informations client) associées aux devices. Inspiré de Wassup (karma-com) mais conçu comme un **système de plugins** permettant de brancher N fournisseurs de données.
## Principe : N Providers
Chaque device peut être enrichi par **plusieurs** providers indépendants. Chaque provider est un service externe interrogé via HTTP qui retourne des paires clé-valeur.
```
┌─────────────────────────────────────┐
│ adam-acs │
│ │
│ DeviceRecord │
│ └─ references: [ │
Inform ──────► │ { provider: "wassup", ... } │
│ { provider: "crm", ... } │
│ { provider: "noc", ... } │
│ ] │
│ │
│ ReferenceEngine │
│ ├─ WassupProvider │───► Wassup (HTTP)
│ ├─ CRMProvider │───► CRM API
│ └─ NOCProvider │───► NOC API
└─────────────────────────────────────┘
```
## Architecture
### Interface Provider
Chaque provider implémente une interface commune :
```typescript
interface ReferenceProvider {
/** Identifiant unique du provider (ex: "wassup", "crm") */
name: string
/** Résout les références pour un device. Retourne null si non applicable. */
resolve(device: DeviceRecord, context: InformContext): Promise<ResolvedReference | null>
/** Indique si ce provider est activé */
enabled: boolean
}
interface ResolvedReference {
provider: string
resolvedAt: Date
resolvedBy: string // ex: "ip:10.0.0.1"
properties: Record<string, string>
status: 'ok' | 'unknown' | 'error'
error?: string
}
interface InformContext {
events: string[]
remoteIp: string
headers: Record<string, string>
}
```
### ReferenceEngine (orchestrateur)
```typescript
class ReferenceEngine {
private providers: ReferenceProvider[] = []
register(provider: ReferenceProvider): void
/** Résout tous les providers pour un device (en parallèle) */
async resolveAll(device: DeviceRecord, context: InformContext): Promise<ResolvedReference[]>
/** Résout un seul provider par nom */
async resolveOne(providerName: string, device: DeviceRecord, context: InformContext): Promise<ResolvedReference | null>
}
```
### Modèle de données (MongoDB)
```typescript
// Ajouté à DeviceRecord
interface DeviceRecord {
// ... champs existants ...
references?: DeviceReference[]
}
interface DeviceReference {
provider: string
resolvedAt: Date
resolvedBy: string
status: 'ok' | 'unknown' | 'error'
properties: Record<string, string>
error?: string
}
```
## Configuration : `config/references.json`
Fichier de config qui déclare les providers actifs et leur paramétrage :
```json
{
"providers": [
{
"name": "wassup",
"type": "wassup",
"enabled": true,
"url": "http://wassup-mock:9999",
"timeout": 3000,
"trigger": ["BOOT", "BOOTSTRAP"],
"resolveBy": "ip",
"requestedProperties": ["sai", "spr", "clo", "puid", "utz"]
},
{
"name": "crm",
"type": "http-json",
"enabled": false,
"url": "http://crm-api:8080/api/customers",
"timeout": 5000,
"trigger": ["BOOT"],
"resolveBy": "serialNumber",
"mapping": {
"requestParam": "serial",
"responseMapping": {
"customerId": "id",
"customerName": "name",
"offer": "subscription.offer"
}
}
}
]
}
```
### Types de providers supportés
| Type | Description | Protocole |
|------|-------------|-----------|
| `wassup` | Protocole Wassup (HTTP GET + headers + text/plain response) | Conforme au format Wassup Orange |
| `http-json` | API REST standard (GET avec query params, réponse JSON) | Générique |
| `http-properties` | HTTP GET avec réponse key=value (Java properties) | Générique |
| `static` | Valeurs statiques déclarées dans la config (pas d'appel externe) | Local |
### Champ `resolveBy` — Comment identifier le device auprès du provider
| Valeur | Source dans le device | Exemple |
|--------|----------------------|---------|
| `ip` | Header `x-forwarded-for` de l'Inform ou IP source | `10.0.0.1` |
| `serialNumber` | `device.deviceId.serialNumber` | `LB4-TEST-001` |
| `oui` | `device.deviceId.oui` | `F8B7FF` |
| `mac` | Paramètre MACAddress du device | `F8:B7:FF:00:11:22` |
| `parameter:<path>` | Valeur d'un paramètre du device | `Device.ManagementServer.Username` |
### Champ `trigger` — Quand déclencher la résolution
| Valeur | Événement |
|--------|-----------|
| `BOOT` | Event "1 BOOT" |
| `BOOTSTRAP` | Event "0 BOOTSTRAP" |
| `PERIODIC` | Event "2 PERIODIC" |
| `SCHEDULED` | Event "3 SCHEDULED" |
| `CONNECTION_REQUEST` | Event "6 CONNECTION REQUEST" |
| `MANUAL` | Uniquement via API (pas automatique) |
## Intégration dans le flux Inform
```typescript
// Dans cwmp_controller.ts → handleInform(), après persist device :
const informContext: InformContext = { events, remoteIp: request.ip(), headers: request.headers() }
const references = await referenceEngine.resolveAll(device, informContext)
if (references.length > 0) {
await DeviceModel.updateReferences(hashId, references)
}
```
## API REST
| Méthode | Route | Description |
|---------|-------|-------------|
| `GET` | `/api/v1/devices/:id/references` | Toutes les références du device |
| `GET` | `/api/v1/devices/:id/references/:provider` | Références d'un provider spécifique |
| `POST` | `/api/v1/devices/:id/references/resolve` | Déclencher la résolution (tous les providers) |
| `POST` | `/api/v1/devices/:id/references/resolve/:provider` | Résoudre un seul provider |
| `PUT` | `/api/v1/devices/:id/references/:provider` | Mettre à jour manuellement |
| `DELETE` | `/api/v1/devices/:id/references/:provider` | Supprimer les références d'un provider |
## UI (DeviceDetail.vue)
Nouvelle section collapsible "Références" dans la page device :
```
┌─────────────────────────────────────────────────┐
│ Références (2 providers) ▾ │
├─────────────────────────────────────────────────┤
│ 🟢 wassup — résolu le 02/06/2026 via ip:10.0.0.1│
│ SAI: 088913491 │
│ SPR: EN │
│ CLO: pppLB.user │
│ [Rafraîchir] │
│ │
│ ⚪ crm — non résolu (désactivé) │
└─────────────────────────────────────────────────┘
```
## Fichiers à créer/modifier dans adam-acs
| Fichier | Action |
|---------|--------|
| `config/references.json` | Créer — configuration des providers |
| `app/domain/device/device_record.ts` | Modifier — ajouter `references?: DeviceReference[]` |
| `app/services/references/reference_engine.ts` | Créer — orchestrateur |
| `app/services/references/providers/wassup_provider.ts` | Créer — provider Wassup |
| `app/services/references/providers/http_json_provider.ts` | Créer — provider HTTP JSON générique |
| `app/services/references/types.ts` | Créer — interfaces et types |
| `app/controllers/references_controller.ts` | Créer — API REST |
| `app/controllers/cwmp_controller.ts` | Modifier — appel ReferenceEngine dans handleInform |
| `inertia/pages/DeviceDetail.vue` | Modifier — section Références |
| `start/routes.ts` | Modifier — ajouter routes references |
## Mock Wassup (gogowassupgo)
Déjà implémenté et testé. Le mock tourne dans Docker (`wassup-mock:9999`) et est provisionné automatiquement par le simulateur lors de la création de devices.
- **Protocole** : identique à Wassup (HTTP GET + `X_WASSUP_USER_IP`)
- **Provisioning** : API `/admin/entries` (POST/GET/DELETE)
- **Stockage** : `data/entries.json`
- **Modes d'erreur** : `--error http-500`, `--error-status KO`, `--delay 2s`
+65
View File
@@ -0,0 +1,65 @@
# Tâches : Références Client — Providers Pluggable
## Tâche 1 : Types et interfaces
- [ ] Créer `app/services/references/types.ts`
- Interface `ReferenceProvider`
- Interface `ResolvedReference`
- Interface `InformContext`
- Interface `ProviderConfig` (issue de `config/references.json`)
- Type `ReferenceProviderType = 'wassup' | 'http-json' | 'http-properties' | 'static'`
## Tâche 2 : Modèle de données
- [ ] Modifier `app/domain/device/device_record.ts` — ajouter `references?: DeviceReference[]`
- [ ] Créer le type `DeviceReference` (provider, resolvedAt, resolvedBy, status, properties, error?)
- [ ] Modifier le modèle MongoDB `app/models/device.ts` pour persister/lire `references`
## Tâche 3 : Configuration
- [ ] Créer `config/references.json` avec un provider Wassup exemple (pointant vers `http://wassup-mock:9999`)
- [ ] Créer un loader de config `app/services/references/config_loader.ts` qui lit et valide le fichier
## Tâche 4 : ReferenceEngine (orchestrateur)
- [ ] Créer `app/services/references/reference_engine.ts`
- Méthode `register(provider)` pour enregistrer un provider
- Méthode `resolveAll(device, context)` — résout tous les providers actifs en parallèle
- Méthode `resolveOne(providerName, device, context)` — résout un seul
- Chargement automatique des providers depuis la config au démarrage
- Extraction du `resolveBy` (ip, serialNumber, mac, parameter:path)
## Tâche 5 : Provider Wassup
- [ ] Créer `app/services/references/providers/wassup_provider.ts`
- Implémente `ReferenceProvider`
- Construit la requête HTTP GET avec header `X_WASSUP_USER_IP`
- Parse la réponse text/plain (key=value)
- Gère les statuts UNKNOWN_USER et erreurs
- Timeout configurable
## Tâche 6 : Intégration dans le flux Inform
- [ ] Modifier `app/controllers/cwmp_controller.ts` → `handleInform()`
- Après persist device, appeler `referenceEngine.resolveAll(device, context)` si les events matchent un trigger
- Stocker les résultats dans le device via `DeviceModel.updateReferences(hashId, references)`
## Tâche 7 : API REST
- [ ] Créer `app/controllers/references_controller.ts`
- `GET /api/v1/devices/:id/references`
- `GET /api/v1/devices/:id/references/:provider`
- `POST /api/v1/devices/:id/references/resolve`
- `POST /api/v1/devices/:id/references/resolve/:provider`
- [ ] Ajouter les routes dans `start/routes.ts`
## Tâche 8 : UI — Section Références dans DeviceDetail
- [ ] Modifier `inertia/pages/DeviceDetail.vue`
- Ajouter une section collapsible "Références"
- Afficher chaque provider avec son statut (🟢 ok / 🔴 error / ⚪ non résolu)
- Afficher les propriétés clé-valeur
- Bouton "Rafraîchir" par provider
## Tâche 9 : Tests
- [ ] Test unitaire `reference_engine.spec.ts` — resolve all, resolve one, provider non activé
- [ ] Test unitaire `wassup_provider.spec.ts` — succès, unknown user, timeout, erreur HTTP
- [ ] Test intégration Docker — créer device dans simulateur → vérifier références provisionnées
## Ordre d'exécution recommandé
```
T1 (types) → T2 (modèle) → T3 (config) → T4 (engine) → T5 (wassup) → T6 (inform) → T7 (api) → T8 (ui) → T9 (tests)
```
+33 -7
View File
@@ -59,7 +59,8 @@ export default class CwmpController {
sessionId,
envelope?.header?.id,
remoteKey,
manager
manager,
request.header('x-forwarded-for') || ''
)
ctx.cwmpDeviceId = hashId
return this.sendSoap(response, xml)
@@ -102,7 +103,8 @@ export default class CwmpController {
session: string,
cwmpId: string | undefined,
remoteKey: string,
manager: StateMachineManager
manager: StateMachineManager,
xForwardedFor?: string
): Promise<{ xml: string; hashId: string }> {
logger.info(
{ informKeys: Object.keys(informData || {}), deviceId: informData?.DeviceId },
@@ -159,11 +161,14 @@ export default class CwmpController {
logger.info({ hashId }, 'Inform received')
// Extract events
const events: string[] = Array.isArray(informData?.Event?.EventStruct)
? informData.Event.EventStruct.map((e: any) => e?.EventCode).filter(
(c: any): c is string => !!c
)
: []
const rawEventStruct = informData?.Event?.EventStruct
const eventStructArray = Array.isArray(rawEventStruct)
? rawEventStruct
: rawEventStruct ? [rawEventStruct] : []
const events: string[] = eventStructArray
.map((e: any) => e?.EventCode)
.filter((c: any) => c != null)
.map((c: any) => String(c))
logger.info({ events }, 'CWMP Inform events extracted')
@@ -171,6 +176,27 @@ export default class CwmpController {
await manager.startStateMachine(device, session, { events })
manager.bindRemoteToDevice(remoteKey, device.deviceId)
// Resolve references from configured providers (async, non-blocking)
try {
logger.debug({ hashId, events, xForwardedFor }, '[References] Starting resolution')
const { getReferenceEngine } = await import('../services/references/reference_engine.js')
const engine = getReferenceEngine()
// Use x-forwarded-for header (set by simulator) or fall back to TCP remote IP
const cpeIp = (xForwardedFor || '').split(',')[0]?.trim() || remoteKey.split(':')[0]
const informContext = { events, remoteIp: cpeIp, headers: {} }
const references = await engine.resolveAll(
{ identifier: hashId, deviceId, properties: device.getProperties().map((p) => p.toSnapshot()), parameters: device.getParameters().map((p) => p.toSnapshot()) },
informContext
)
if (references.length > 0) {
await DeviceModel.updateOne({ identifier: hashId }, { $set: { references, updatedAt: new Date() } }).catch(() => {})
logger.debug({ hashId, providers: references.map((r) => r.provider) }, '[References] Resolved')
}
} catch (_e: any) {
// Non-critical — don't block Inform processing
logger.error({ error: _e?.message, stack: _e?.stack }, '[References] Error during resolution')
}
// After Inform for onboarding events, enqueue critical reads using the detected data model
const onlyConnReq = events.length > 0 && events.every((e) => e === '6 CONNECTION REQUEST')
if (!onlyConnReq) {
+87
View File
@@ -0,0 +1,87 @@
import type { HttpContext } from '@adonisjs/core/http'
import DeviceModel from '#models/device'
import { getReferenceEngine } from '../services/references/reference_engine.js'
export default class ReferencesController {
/**
* GET /api/v1/devices/:identifier/references
*/
async index({ params, response }: HttpContext) {
const device = await DeviceModel.findByIdentifier(params.identifier)
if (!device) {
return response.status(404).json({ message: 'Device not found' })
}
return response.json({ references: device.references || [] })
}
/**
* GET /api/v1/devices/:identifier/references/:provider
*/
async show({ params, response }: HttpContext) {
const device = await DeviceModel.findByIdentifier(params.identifier)
if (!device) {
return response.status(404).json({ message: 'Device not found' })
}
const ref = (device.references || []).find((r: any) => r.provider === params.provider)
if (!ref) {
return response.status(404).json({ message: `No reference from provider '${params.provider}'` })
}
return response.json(ref)
}
/**
* POST /api/v1/devices/:identifier/references/resolve
* Trigger resolution for all applicable providers.
*/
async resolveAll({ params, response }: HttpContext) {
const device = await DeviceModel.findByIdentifier(params.identifier)
if (!device) {
return response.status(404).json({ message: 'Device not found' })
}
const engine = getReferenceEngine()
const context = { events: ['MANUAL'], remoteIp: '', headers: {} }
// For manual resolution, use all providers regardless of trigger
const results = await engine.resolveAll(device, { ...context, events: ['0 BOOTSTRAP'] })
if (results.length > 0) {
// Merge with existing references (replace by provider name)
const existing = (device.references || []).filter(
(r: any) => !results.some((nr) => nr.provider === r.provider)
)
const merged = [...existing, ...results]
await DeviceModel.updateOne({ identifier: params.identifier }, { $set: { references: merged, updatedAt: new Date() } })
}
return response.json({ resolved: results.length, references: results })
}
/**
* POST /api/v1/devices/:identifier/references/resolve/:provider
* Trigger resolution for a single provider.
*/
async resolveOne({ params, response }: HttpContext) {
const device = await DeviceModel.findByIdentifier(params.identifier)
if (!device) {
return response.status(404).json({ message: 'Device not found' })
}
const engine = getReferenceEngine()
const context = { events: ['0 BOOTSTRAP'], remoteIp: '', headers: {} }
const result = await engine.resolveOne(params.provider, device, context)
if (!result) {
return response.status(404).json({ message: `Provider '${params.provider}' not found or cannot resolve` })
}
// Merge into device references
const existing = (device.references || []).filter((r: any) => r.provider !== params.provider)
await DeviceModel.updateOne(
{ identifier: params.identifier },
{ $set: { references: [...existing, result], updatedAt: new Date() } }
)
return response.json(result)
}
}
+2
View File
@@ -2,6 +2,7 @@ import { DeviceId } from './device_id.js'
import { PropertySnapshot, ParameterSnapshot } from './device_parameter.js'
import { DeviceComplianceInfo } from '../configuration/compliance.js'
import { DataModel, GpnStatus } from './device.js'
import type { DeviceReference } from '../../services/references/types.js'
export interface DeviceRecord {
identifier?: string
@@ -12,6 +13,7 @@ export interface DeviceRecord {
dataModel?: DataModel
compliance?: DeviceComplianceInfo
provisioningCode?: string
references?: DeviceReference[]
updatedAt?: Date
createdAt?: Date
}
+25
View File
@@ -0,0 +1,25 @@
import { readFileSync } from 'node:fs'
import { join } from 'node:path'
import type { ReferencesConfig } from './types.js'
const CONFIG_PATH = join(process.cwd(), 'config', 'references.json')
let cachedConfig: ReferencesConfig | null = null
export function loadReferencesConfig(): ReferencesConfig {
if (cachedConfig) return cachedConfig
try {
const raw = readFileSync(CONFIG_PATH, 'utf-8')
cachedConfig = JSON.parse(raw) as ReferencesConfig
return cachedConfig
} catch (_error: any) {
cachedConfig = { providers: [] }
return cachedConfig
}
}
export function reloadReferencesConfig(): ReferencesConfig {
cachedConfig = null
return loadReferencesConfig()
}
@@ -0,0 +1,158 @@
import type { DeviceRecord } from '../../../domain/device/device_record.js'
import type {
ReferenceProvider,
ResolvedReference,
InformContext,
ProviderConfig,
} from '../types.js'
import { ReferenceEngine } from '../reference_engine.js'
/**
* Wassup Provider — resolves customer identity via the Wassup protocol.
*
* Protocol:
* - HTTP GET to {url}/wassup?info=sai,spr,...
* - Header X_WASSUP_USER_IP with the CPE IP address
* - Response: text/plain with key=value lines (X_WASSUP_STATUS=OK, X_WASSUP_SAI=..., etc.)
*/
export class WassupProvider implements ReferenceProvider {
name: string
enabled: boolean
private url: string
private timeout: number
private service: string
private requestedProperties: string[]
private resolveBy: string
constructor(config: ProviderConfig) {
this.name = config.name
this.enabled = config.enabled
this.url = config.url || ''
this.timeout = config.timeout || 3000
this.resolveBy = config.resolveBy || 'ip'
this.service = config.options?.service || 'KRMSCO'
this.requestedProperties = config.options?.requestedProperties || ['sai', 'spr', 'clo', 'puid', 'utz']
}
async resolve(device: DeviceRecord, context: InformContext): Promise<ResolvedReference | null> {
const resolveValue = ReferenceEngine.extractResolveValue(device, this.resolveBy, context)
if (!resolveValue) {
console.debug({ provider: this.name, resolveBy: this.resolveBy }, '[Wassup] Cannot resolve: no value')
return null
}
const infoParam = this.requestedProperties.join(',')
const wassupUrl = `${this.url}/wassup?wassup=ident&prot=200&charset=utf8&serv=${this.service}&info=${infoParam}`
try {
const controller = new AbortController()
const timer = setTimeout(() => controller.abort(), this.timeout)
const response = await fetch(wassupUrl, {
method: 'GET',
headers: {
'X_WASSUP_USER_IP': resolveValue,
},
signal: controller.signal,
})
clearTimeout(timer)
if (!response.ok) {
return {
provider: this.name,
resolvedAt: new Date(),
resolvedBy: `${this.resolveBy}:${resolveValue}`,
status: 'error',
properties: {},
error: `HTTP ${response.status}`,
}
}
const body = await response.text()
const parsed = this.parseResponse(body)
const status = parsed['X_WASSUP_STATUS']
if (status === 'UNKNOWN_USER') {
return {
provider: this.name,
resolvedAt: new Date(),
resolvedBy: `${this.resolveBy}:${resolveValue}`,
status: 'unknown',
properties: {},
}
}
if (status !== 'OK') {
return {
provider: this.name,
resolvedAt: new Date(),
resolvedBy: `${this.resolveBy}:${resolveValue}`,
status: 'error',
properties: {},
error: parsed['X_WASSUP_ERROR'] || `Unexpected status: ${status}`,
}
}
// Map X_WASSUP_* keys to short names
const properties: Record<string, string> = {}
const keyMapping: Record<string, string> = {
'X_WASSUP_SAI': 'sai',
'X_WASSUP_SPR': 'spr',
'X_WASSUP_CLO': 'clo',
'X_WASSUP_PUID': 'puid',
'X_WASSUP_UTZ': 'utz',
'X_WASSUP_IUR': 'iur',
'X_WASSUP_IUS': 'ius',
}
for (const [wassupKey, shortKey] of Object.entries(keyMapping)) {
if (parsed[wassupKey] && parsed[wassupKey] !== '') {
properties[shortKey] = parsed[wassupKey]
}
}
console.info({ provider: this.name, resolveBy: `${this.resolveBy}:${resolveValue}`, keys: Object.keys(properties) }, '[Wassup] Resolved')
return {
provider: this.name,
resolvedAt: new Date(),
resolvedBy: `${this.resolveBy}:${resolveValue}`,
status: 'ok',
properties,
}
} catch (error: any) {
const isTimeout = error.name === 'AbortError'
console.error({ provider: this.name, error: error.message, isTimeout }, '[Wassup] Resolution failed')
return {
provider: this.name,
resolvedAt: new Date(),
resolvedBy: `${this.resolveBy}:${resolveValue}`,
status: 'error',
properties: {},
error: isTimeout ? 'Timeout' : error.message,
}
}
}
/**
* Parse a Wassup text/plain response into a key-value map.
* Format: "X_WASSUP_STATUS=OK\nX_WASSUP_SAI=111111111\n..."
*/
private parseResponse(body: string): Record<string, string> {
const result: Record<string, string> = {}
const lines = body.split('\n')
for (const line of lines) {
const idx = line.indexOf('=')
if (idx > 0) {
const key = line.slice(0, idx).trim()
const value = line.slice(idx + 1).trim()
result[key] = value
}
}
return result
}
}
+156
View File
@@ -0,0 +1,156 @@
import type { DeviceRecord } from '../../domain/device/device_record.js'
import type {
ReferenceProvider,
ResolvedReference,
InformContext,
ProviderConfig,
} from './types.js'
import { TRIGGER_EVENT_MAP } from './types.js'
import { loadReferencesConfig } from './config_loader.js'
import { WassupProvider } from './providers/wassup_provider.js'
export class ReferenceEngine {
private providers: ReferenceProvider[] = []
private configs: ProviderConfig[] = []
constructor() {
this.loadProviders()
}
private loadProviders(): void {
const config = loadReferencesConfig()
this.configs = config.providers
for (const providerConfig of config.providers) {
if (!providerConfig.enabled) continue
const provider = this.createProvider(providerConfig)
if (provider) {
this.providers.push(provider)
console.info({ name: providerConfig.name, type: providerConfig.type }, '[References] Provider registered')
}
}
}
private createProvider(config: ProviderConfig): ReferenceProvider | null {
switch (config.type) {
case 'wassup':
return new WassupProvider(config)
case 'http-json':
// TODO: implement
console.warn({ name: config.name }, '[References] http-json provider not yet implemented')
return null
case 'http-properties':
console.warn({ name: config.name }, '[References] http-properties provider not yet implemented')
return null
case 'static':
console.warn({ name: config.name }, '[References] static provider not yet implemented')
return null
default:
console.error({ name: config.name, type: config.type }, '[References] Unknown provider type')
return null
}
}
/**
* Resolve all providers for a device in parallel.
* Only triggers providers whose trigger events match the inform events.
*/
async resolveAll(device: DeviceRecord, context: InformContext): Promise<ResolvedReference[]> {
const applicable = this.getApplicableProviders(context.events)
if (applicable.length === 0) return []
const results = await Promise.allSettled(
applicable.map((p) => p.resolve(device, context))
)
const resolved: ResolvedReference[] = []
for (const result of results) {
if (result.status === 'fulfilled' && result.value) {
resolved.push(result.value)
} else if (result.status === 'rejected') {
console.error({ error: result.reason }, '[References] Provider resolution failed')
}
}
return resolved
}
/**
* Resolve a single provider by name.
*/
async resolveOne(providerName: string, device: DeviceRecord, context: InformContext): Promise<ResolvedReference | null> {
const provider = this.providers.find((p) => p.name === providerName)
if (!provider) {
console.warn({ providerName }, '[References] Provider not found')
return null
}
return provider.resolve(device, context)
}
/**
* Returns providers whose trigger events intersect with the inform events.
*/
private getApplicableProviders(informEvents: string[]): ReferenceProvider[] {
return this.providers.filter((provider) => {
const config = this.configs.find((c) => c.name === provider.name)
if (!config) return false
// Check if any trigger event matches the inform events
for (const trigger of config.trigger) {
const mappedEvents = TRIGGER_EVENT_MAP[trigger] || [trigger]
for (const mapped of mappedEvents) {
if (informEvents.some((e) => String(e).toUpperCase().includes(mapped.toUpperCase()))) {
return true
}
}
}
return false
})
}
/**
* Extracts the resolve value from a device based on the resolveBy config.
*/
static extractResolveValue(device: DeviceRecord, resolveBy: string, context: InformContext): string | null {
switch (resolveBy) {
case 'ip':
return context.remoteIp || null
case 'serialNumber':
return device.deviceId?.serialNumber || null
case 'oui':
return device.deviceId?.oui || null
case 'mac': {
const allParams = [...(device.properties || []), ...(device.parameters || [])]
const macParam = allParams.find((p) => p.name?.includes('MACAddress'))
return macParam?.value as string || null
}
default:
if (resolveBy.startsWith('parameter:')) {
const path = resolveBy.slice('parameter:'.length)
const allParams = [...(device.properties || []), ...(device.parameters || [])]
const param = allParams.find((p) => p.name === path)
return param?.value as string || null
}
return null
}
}
getProviderNames(): string[] {
return this.providers.map((p) => p.name)
}
getProviderConfigs(): ProviderConfig[] {
return this.configs
}
}
// Singleton
let instance: ReferenceEngine | null = null
export function getReferenceEngine(): ReferenceEngine {
if (!instance) {
instance = new ReferenceEngine()
}
return instance
}
+123
View File
@@ -0,0 +1,123 @@
/**
* Reference Providers — Types and Interfaces
*
* A pluggable system for resolving external customer/device information
* from N independent providers (Wassup, CRM, NOC, etc.)
*/
import type { DeviceRecord } from '../../domain/device/device_record.js'
// ─── Provider Interface ────────────────────────────────────────────────────────
export interface ReferenceProvider {
/** Unique identifier for this provider (e.g., "wassup", "crm") */
name: string
/** Whether this provider is currently enabled */
enabled: boolean
/**
* Resolve references for a device.
* Returns null if the provider cannot resolve (e.g., missing input data).
*/
resolve(device: DeviceRecord, context: InformContext): Promise<ResolvedReference | null>
}
// ─── Resolution Result ─────────────────────────────────────────────────────────
export interface ResolvedReference {
/** Provider name that produced this result */
provider: string
/** Timestamp of resolution */
resolvedAt: Date
/** Criterion used to resolve (e.g., "ip:10.0.0.1", "serial:LB4-001") */
resolvedBy: string
/** Resolution outcome */
status: ReferenceStatus
/** Key-value properties returned by the provider */
properties: Record<string, string>
/** Error message if status is 'error' */
error?: string
}
export type ReferenceStatus = 'ok' | 'unknown' | 'error'
// ─── Stored Reference (persisted in DeviceRecord) ──────────────────────────────
export interface DeviceReference {
provider: string
resolvedAt: Date
resolvedBy: string
status: ReferenceStatus
properties: Record<string, string>
error?: string
}
// ─── Inform Context (passed to providers during resolution) ────────────────────
export interface InformContext {
/** CWMP events from the Inform (e.g., ["1 BOOT", "0 BOOTSTRAP"]) */
events: string[]
/** Remote IP of the CPE (from TCP socket or x-forwarded-for) */
remoteIp: string
/** HTTP headers from the Inform request */
headers: Record<string, string>
}
// ─── Provider Configuration (from config/references.json) ──────────────────────
export type ReferenceProviderType = 'wassup' | 'http-json' | 'http-properties' | 'static'
export interface ProviderConfig {
/** Unique name for this provider instance */
name: string
/** Provider type (determines which implementation to use) */
type: ReferenceProviderType
/** Whether this provider is active */
enabled: boolean
/** Base URL of the external service */
url?: string
/** HTTP timeout in milliseconds */
timeout?: number
/** Events that trigger automatic resolution (e.g., ["BOOT", "BOOTSTRAP"]) */
trigger: string[]
/**
* How to identify the device for this provider.
* - "ip": use remoteIp from InformContext
* - "serialNumber": use device.deviceId.serialNumber
* - "oui": use device.deviceId.oui
* - "mac": look for a MACAddress parameter
* - "parameter:<path>": use value of a specific device parameter
*/
resolveBy: string
/** Provider-specific options */
options?: Record<string, any>
}
export interface ReferencesConfig {
providers: ProviderConfig[]
}
// ─── Trigger Event Mapping ─────────────────────────────────────────────────────
export const TRIGGER_EVENT_MAP: Record<string, string[]> = {
BOOT: ['1 BOOT', '1'],
BOOTSTRAP: ['0 BOOTSTRAP', '0'],
PERIODIC: ['2 PERIODIC', '2'],
SCHEDULED: ['3 SCHEDULED', '3'],
CONNECTION_REQUEST: ['6 CONNECTION REQUEST', '6'],
}
+17
View File
@@ -0,0 +1,17 @@
{
"providers": [
{
"name": "wassup",
"type": "wassup",
"enabled": true,
"url": "http://wassup-mock:9999",
"timeout": 3000,
"trigger": ["BOOT", "BOOTSTRAP"],
"resolveBy": "ip",
"options": {
"service": "KRMSCO",
"requestedProperties": ["sai", "spr", "clo", "puid", "utz"]
}
}
]
}
+6
View File
@@ -0,0 +1,6 @@
{
"10.0.0.1": {
"sai": "999",
"spr": "FR"
}
}
+17
View File
@@ -25,6 +25,9 @@ services:
PROXY: "false"
BSE_PROXY: "false"
AUTO_DELETE_ENABLED: "false"
# URL du mock Wassup pour provisionner les fiches client à la création d'un device
# Laisser vide pour désactiver
WASSUP_MOCK_URL: "http://wassup-mock:9999"
# Vide = mode permissif : envoie tous les paramètres dans l'Inform (utile pour le dev/test)
# Sinon, liste les catégories sémantiques du simulateur (ex: HardwareVersion,SoftwareVersion,...)
INFORM_ACCEPTED_PARAMS: ""
@@ -85,5 +88,19 @@ services:
depends_on:
- simulator
wassup-mock:
build:
context: ../grelieu/gogowassupgo
dockerfile: Dockerfile
restart: unless-stopped
ports:
- "9999:9999"
environment:
PORT: "9999"
DATA_FILE: "/app/data/entries.json"
volumes:
- wassup_data:/app/data
volumes:
simulator_logs:
wassup_data:
+54
View File
@@ -148,6 +148,27 @@ function getPropertyValue(name: string): string {
const prop = device.properties?.find((p: any) => p.name === name)
return prop?.value ?? ''
}
// References resolution
const resolvingProvider = ref<string | null>(null)
async function resolveProvider(providerName: string) {
resolvingProvider.value = providerName
try {
await fetch(`/api/v1/devices/${encodeURIComponent(device.identifier)}/references/resolve/${providerName}`, { method: 'POST' })
router.visit(`/devices/${encodeURIComponent(device.identifier)}`, { preserveScroll: true })
} catch { /* ignore */ }
finally { resolvingProvider.value = null }
}
async function resolveAllProviders() {
resolvingProvider.value = '__all__'
try {
await fetch(`/api/v1/devices/${encodeURIComponent(device.identifier)}/references/resolve`, { method: 'POST' })
router.visit(`/devices/${encodeURIComponent(device.identifier)}`, { preserveScroll: true })
} catch { /* ignore */ }
finally { resolvingProvider.value = null }
}
</script>
<template>
@@ -314,6 +335,39 @@ function getPropertyValue(name: string): string {
</div>
<!-- Zone 6 : Références client -->
<div v-if="device.references && device.references.length > 0" class="rounded-3 px-3 py-3" style="background:#f6f7f9">
<div class="d-flex align-items-center gap-2 mb-3">
<svg xmlns="http://www.w3.org/2000/svg" width="13" height="13" fill="currentColor" viewBox="0 0 16 16" class="text-muted flex-shrink-0">
<path d="M8 8a3 3 0 1 0 0-6 3 3 0 0 0 0 6m2-3a2 2 0 1 1-4 0 2 2 0 0 1 4 0m4 8c0 1-1 1-1 1H3s-1 0-1-1 1-4 6-4 6 4m-1-.004c-.001-.246-.154-.986-.832-1.664C11.516 10.68 10.289 10 8 10s-3.516.68-4.168 1.332c-.678.678-.83 1.418-.832 1.664z"/>
</svg>
<span class="text-muted fw-bold text-uppercase" style="font-size:0.62rem;letter-spacing:.08em">Références client</span>
<button class="btn btn-link btn-sm p-0 ms-auto" @click="resolveAllProviders" :disabled="resolvingProvider !== null">
<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" fill="currentColor" viewBox="0 0 16 16">
<path fill-rule="evenodd" d="M8 3a5 5 0 1 0 4.546 2.914.5.5 0 0 1 .908-.417A6 6 0 1 1 8 2z"/>
<path d="M8 4.466V.534a.25.25 0 0 1 .41-.192l2.36 1.966c.12.1.12.284 0 .384L8.41 4.658A.25.25 0 0 1 8 4.466"/>
</svg>
</button>
</div>
<div v-for="ref in device.references" :key="ref.provider" class="mb-2">
<div class="d-flex align-items-center gap-2 mb-2">
<span v-if="ref.status === 'ok'" style="color:#198754">●</span>
<span v-else-if="ref.status === 'error'" style="color:#dc3545">●</span>
<span v-else style="color:#6c757d">●</span>
<span class="fw-semibold small">{{ ref.provider }}</span>
<span class="text-muted small">{{ ref.resolvedBy }}</span>
<span class="text-muted small ms-auto">{{ ref.resolvedAt ? new Date(ref.resolvedAt).toLocaleString() : '' }}</span>
</div>
<div v-if="ref.error" class="text-danger small mb-1">{{ ref.error }}</div>
<div v-if="ref.status === 'ok' && ref.properties" class="row g-2">
<div v-for="(value, key) in ref.properties" :key="key" class="col-sm-6 col-lg-3">
<div class="text-muted fw-bold text-uppercase" style="font-size:0.58rem;letter-spacing:.06em">{{ key }}</div>
<div class="font-monospace small fw-medium">{{ value || '—' }}</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
+7
View File
@@ -29,6 +29,13 @@ router
router.post('/devices/:identifier/command', [DevicesController, 'executeCommand'])
router.post('/devices/:identifier/refresh', [DevicesController, 'refresh'])
// References
const ReferencesController = () => import('#controllers/references_controller')
router.get('/devices/:identifier/references', [ReferencesController, 'index'])
router.get('/devices/:identifier/references/:provider', [ReferencesController, 'show'])
router.post('/devices/:identifier/references/resolve', [ReferencesController, 'resolveAll'])
router.post('/devices/:identifier/references/resolve/:provider', [ReferencesController, 'resolveOne'])
// Commands
router.get('/commands', [CommandsController, 'index'])
router.get('/commands/:id', [CommandsController, 'show'])
@@ -0,0 +1,57 @@
import { describe, it, expect } from 'vitest'
import { ReferenceEngine } from '../../../../app/services/references/reference_engine.js'
import type { DeviceRecord } from '../../../../app/domain/device/device_record.js'
import type { InformContext } from '../../../../app/services/references/types.js'
import { DeviceId } from '../../../../app/domain/device/device_id.js'
function makeDevice(): DeviceRecord {
return {
identifier: 'test-device-001',
deviceId: new DeviceId('TestCorp', 'AABBCC', 'TestBox', 'SERIAL001'),
properties: [
{ name: 'Device.DeviceInfo.MACAddress', value: 'AA:BB:CC:11:22:33', type: 'xsd:string' as any },
],
parameters: [],
}
}
describe('ReferenceEngine.extractResolveValue', () => {
const context: InformContext = { events: [], remoteIp: '192.168.1.100', headers: {} }
it('extracts IP from context', () => {
const value = ReferenceEngine.extractResolveValue(makeDevice(), 'ip', context)
expect(value).toBe('192.168.1.100')
})
it('extracts serialNumber from device', () => {
const value = ReferenceEngine.extractResolveValue(makeDevice(), 'serialNumber', context)
expect(value).toBe('SERIAL001')
})
it('extracts OUI from device', () => {
const value = ReferenceEngine.extractResolveValue(makeDevice(), 'oui', context)
expect(value).toBe('AABBCC')
})
it('extracts MAC from device parameters', () => {
const value = ReferenceEngine.extractResolveValue(makeDevice(), 'mac', context)
expect(value).toBe('AA:BB:CC:11:22:33')
})
it('extracts parameter by path', () => {
const device = makeDevice()
device.properties.push({ name: 'Device.ManagementServer.Username', value: 'admin', type: 'xsd:string' as any })
const value = ReferenceEngine.extractResolveValue(device, 'parameter:Device.ManagementServer.Username', context)
expect(value).toBe('admin')
})
it('returns null for empty IP', () => {
const value = ReferenceEngine.extractResolveValue(makeDevice(), 'ip', { events: [], remoteIp: '', headers: {} })
expect(value).toBeNull()
})
it('returns null for unknown resolveBy', () => {
const value = ReferenceEngine.extractResolveValue(makeDevice(), 'unknown_key', context)
expect(value).toBeNull()
})
})
@@ -0,0 +1,119 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
import { WassupProvider } from '../../../../app/services/references/providers/wassup_provider.js'
import type { ProviderConfig } from '../../../../app/services/references/types.js'
import type { DeviceRecord } from '../../../../app/domain/device/device_record.js'
import { DeviceId } from '../../../../app/domain/device/device_id.js'
import { createServer, type Server } from 'node:http'
function makeDevice(): DeviceRecord {
return {
identifier: 'test-device-001',
deviceId: new DeviceId('TestCorp', 'AABBCC', 'TestBox', 'SERIAL001'),
properties: [],
parameters: [],
}
}
function makeConfig(overrides: Partial<ProviderConfig> = {}): ProviderConfig {
return {
name: 'wassup',
type: 'wassup',
enabled: true,
url: 'http://localhost:19876',
timeout: 2000,
trigger: ['BOOT'],
resolveBy: 'ip',
options: { service: 'KRMSCO', requestedProperties: ['sai', 'spr'] },
...overrides,
}
}
describe('WassupProvider', () => {
let server: Server
let responseBody: string
let responseStatus: number
beforeEach(() => {
responseBody = 'X_WASSUP_STATUS=OK\nX_WASSUP_SAI=123456789\nX_WASSUP_SPR=FR\n'
responseStatus = 200
server = createServer((_req, res) => {
res.writeHead(responseStatus, { 'Content-Type': 'text/plain' })
res.end(responseBody)
})
server.listen(19876)
})
afterEach(() => {
server.close()
})
it('resolves successfully with OK status', async () => {
const provider = new WassupProvider(makeConfig())
const result = await provider.resolve(makeDevice(), { events: [], remoteIp: '10.0.0.1', headers: {} })
expect(result).not.toBeNull()
expect(result!.status).toBe('ok')
expect(result!.provider).toBe('wassup')
expect(result!.resolvedBy).toBe('ip:10.0.0.1')
expect(result!.properties.sai).toBe('123456789')
expect(result!.properties.spr).toBe('FR')
})
it('returns unknown when user not found', async () => {
responseBody = 'X_WASSUP_STATUS=UNKNOWN_USER\nX_WASSUP_ERROR=ICS_RESULT_LDAP_ERROR\n'
const provider = new WassupProvider(makeConfig())
const result = await provider.resolve(makeDevice(), { events: [], remoteIp: '10.0.0.99', headers: {} })
expect(result).not.toBeNull()
expect(result!.status).toBe('unknown')
expect(result!.properties).toEqual({})
})
it('returns error on HTTP failure', async () => {
responseStatus = 500
responseBody = 'Internal Server Error'
const provider = new WassupProvider(makeConfig())
const result = await provider.resolve(makeDevice(), { events: [], remoteIp: '10.0.0.1', headers: {} })
expect(result).not.toBeNull()
expect(result!.status).toBe('error')
expect(result!.error).toContain('HTTP 500')
})
it('returns error on timeout', async () => {
server.close()
server = createServer((_req, res) => {
// Never respond — simulate timeout
setTimeout(() => {
res.writeHead(200)
res.end('X_WASSUP_STATUS=OK\n')
}, 5000)
})
server.listen(19876)
const provider = new WassupProvider(makeConfig({ timeout: 100 }))
const result = await provider.resolve(makeDevice(), { events: [], remoteIp: '10.0.0.1', headers: {} })
expect(result).not.toBeNull()
expect(result!.status).toBe('error')
expect(result!.error).toBe('Timeout')
})
it('returns null when no IP available', async () => {
const provider = new WassupProvider(makeConfig())
const result = await provider.resolve(makeDevice(), { events: [], remoteIp: '', headers: {} })
expect(result).toBeNull()
})
it('resolves by serialNumber when configured', async () => {
const provider = new WassupProvider(makeConfig({ resolveBy: 'serialNumber' }))
const result = await provider.resolve(makeDevice(), { events: [], remoteIp: '', headers: {} })
expect(result).not.toBeNull()
expect(result!.resolvedBy).toBe('serialNumber:SERIAL001')
})
})