chore: initial commit — AdonisJS v7 TR-069 ACS

- Docker infrastructure (Dockerfile, docker-compose.yml, genieacs-sim)
- AdonisJS v7 app with Inertia.js + Vue 3
- MongoDB integration
- CWMP/TR-069 state machine engine
- Configuration engine with compliance tracking
- Branding: ADAM ACS (favicon, navbar, locales)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
Kevin L'HereecandCopilot committed 2026-05-29 16:46:37 +02:00
commit d1c65dbf1e
164 files changed
+29127

No files matched your search

+15
View File
@@ -0,0 +1,15 @@
/**
* This file is automatically generated.
* DO NOT EDIT manually
*/
/// <reference path="./manifest.d.ts" />
import type { InferData, InferVariants } from '@adonisjs/core/types/transformers'
import type UserTransformer from '#transformers/user_transformer'
export namespace Data {
export type User = InferData<UserTransformer>
export namespace User {
export type Variants = InferVariants<UserTransformer>
}
}
+9
View File
@@ -0,0 +1,9 @@
/**
* This file is automatically generated.
* DO NOT EDIT manually
*/
/// <reference path="../../adonisrc.ts" />
/// <reference path="../../config/auth.ts" />
/// <reference path="../../config/hash.ts" />
/// <reference path="../../config/logger.ts" />
+47
View File
@@ -0,0 +1,47 @@
/* eslint-disable prettier/prettier */
import type { AdonisEndpoint } from '@tuyau/core/types'
import type { Registry } from './schema.d.ts'
import type { ApiDefinition } from './tree.d.ts'
const placeholder: any = {}
const routes = {
'auth.new_account.store': {
methods: ["POST"],
pattern: '/api/v1/auth/signup',
tokens: [{"old":"/api/v1/auth/signup","type":0,"val":"api","end":""},{"old":"/api/v1/auth/signup","type":0,"val":"v1","end":""},{"old":"/api/v1/auth/signup","type":0,"val":"auth","end":""},{"old":"/api/v1/auth/signup","type":0,"val":"signup","end":""}],
types: placeholder as Registry['auth.new_account.store']['types'],
},
'auth.access_tokens.store': {
methods: ["POST"],
pattern: '/api/v1/auth/login',
tokens: [{"old":"/api/v1/auth/login","type":0,"val":"api","end":""},{"old":"/api/v1/auth/login","type":0,"val":"v1","end":""},{"old":"/api/v1/auth/login","type":0,"val":"auth","end":""},{"old":"/api/v1/auth/login","type":0,"val":"login","end":""}],
types: placeholder as Registry['auth.access_tokens.store']['types'],
},
'profile.profile.show': {
methods: ["GET","HEAD"],
pattern: '/api/v1/account/profile',
tokens: [{"old":"/api/v1/account/profile","type":0,"val":"api","end":""},{"old":"/api/v1/account/profile","type":0,"val":"v1","end":""},{"old":"/api/v1/account/profile","type":0,"val":"account","end":""},{"old":"/api/v1/account/profile","type":0,"val":"profile","end":""}],
types: placeholder as Registry['profile.profile.show']['types'],
},
'profile.access_tokens.destroy': {
methods: ["POST"],
pattern: '/api/v1/account/logout',
tokens: [{"old":"/api/v1/account/logout","type":0,"val":"api","end":""},{"old":"/api/v1/account/logout","type":0,"val":"v1","end":""},{"old":"/api/v1/account/logout","type":0,"val":"account","end":""},{"old":"/api/v1/account/logout","type":0,"val":"logout","end":""}],
types: placeholder as Registry['profile.access_tokens.destroy']['types'],
},
} as const satisfies Record<string, AdonisEndpoint>
export { routes }
export const registry = {
routes,
$tree: {} as ApiDefinition,
}
declare module '@tuyau/core/types' {
export interface UserRegistry {
routes: typeof routes
$tree: ApiDefinition
}
}
+58
View File
@@ -0,0 +1,58 @@
/* eslint-disable prettier/prettier */
/// <reference path="../manifest.d.ts" />
import type { ExtractBody, ExtractErrorResponse, ExtractQuery, ExtractQueryForGet, ExtractResponse } from '@tuyau/core/types'
import type { InferInput, SimpleError } from '@vinejs/vine/types'
export type ParamValue = string | number | bigint | boolean
export interface Registry {
'auth.new_account.store': {
methods: ["POST"]
pattern: '/api/v1/auth/signup'
types: {
body: ExtractBody<InferInput<(typeof import('#validators/user').signupValidator)>>
paramsTuple: []
params: {}
query: ExtractQuery<InferInput<(typeof import('#validators/user').signupValidator)>>
response: ExtractResponse<Awaited<ReturnType<import('#controllers/new_account_controller').default['store']>>>
errorResponse: ExtractErrorResponse<Awaited<ReturnType<import('#controllers/new_account_controller').default['store']>>> | { status: 422; response: { errors: SimpleError[] } }
}
}
'auth.access_tokens.store': {
methods: ["POST"]
pattern: '/api/v1/auth/login'
types: {
body: ExtractBody<InferInput<(typeof import('#validators/user').loginValidator)>>
paramsTuple: []
params: {}
query: ExtractQuery<InferInput<(typeof import('#validators/user').loginValidator)>>
response: ExtractResponse<Awaited<ReturnType<import('#controllers/access_tokens_controller').default['store']>>>
errorResponse: ExtractErrorResponse<Awaited<ReturnType<import('#controllers/access_tokens_controller').default['store']>>> | { status: 422; response: { errors: SimpleError[] } }
}
}
'profile.profile.show': {
methods: ["GET","HEAD"]
pattern: '/api/v1/account/profile'
types: {
body: {}
paramsTuple: []
params: {}
query: {}
response: ExtractResponse<Awaited<ReturnType<import('#controllers/profile_controller').default['show']>>>
errorResponse: ExtractErrorResponse<Awaited<ReturnType<import('#controllers/profile_controller').default['show']>>>
}
}
'profile.access_tokens.destroy': {
methods: ["POST"]
pattern: '/api/v1/account/logout'
types: {
body: {}
paramsTuple: []
params: {}
query: {}
response: ExtractResponse<Awaited<ReturnType<import('#controllers/access_tokens_controller').default['destroy']>>>
errorResponse: ExtractErrorResponse<Awaited<ReturnType<import('#controllers/access_tokens_controller').default['destroy']>>>
}
}
}
+21
View File
@@ -0,0 +1,21 @@
/* eslint-disable prettier/prettier */
import type { routes } from './index.ts'
export interface ApiDefinition {
auth: {
newAccount: {
store: typeof routes['auth.new_account.store']
}
accessTokens: {
store: typeof routes['auth.access_tokens.store']
}
}
profile: {
profile: {
show: typeof routes['profile.profile.show']
}
accessTokens: {
destroy: typeof routes['profile.access_tokens.destroy']
}
}
}
+14
View File
@@ -0,0 +1,14 @@
/**
* This file is automatically generated.
* DO NOT EDIT manually
*/
export const controllers = {
Commands: () => import('#controllers/commands_controller'),
ConfigurationsV1: () => import('#controllers/configurations_v1_controller'),
Cwmp: () => import('#controllers/cwmp_controller'),
Devices: () => import('#controllers/devices_controller'),
FirmwareCatalog: () => import('#controllers/firmware_catalog_controller'),
Health: () => import('#controllers/health_controller'),
Pages: () => import('#controllers/pages_controller'),
}
+6
View File
@@ -0,0 +1,6 @@
/**
* This file is automatically generated.
* DO NOT EDIT manually
*/
export const events = {}
+6
View File
@@ -0,0 +1,6 @@
/**
* This file is automatically generated.
* DO NOT EDIT manually
*/
export const listeners = {}
+26
View File
@@ -0,0 +1,26 @@
import '@adonisjs/core/types/http'
type ParamValue = string | number | bigint | boolean
export type ScannedRoutes = {
ALL: {
'auth.new_account.store': { paramsTuple?: []; params?: {} }
'auth.access_tokens.store': { paramsTuple?: []; params?: {} }
'profile.profile.show': { paramsTuple?: []; params?: {} }
'profile.access_tokens.destroy': { paramsTuple?: []; params?: {} }
}
GET: {
'profile.profile.show': { paramsTuple?: []; params?: {} }
}
HEAD: {
'profile.profile.show': { paramsTuple?: []; params?: {} }
}
POST: {
'auth.new_account.store': { paramsTuple?: []; params?: {} }
'auth.access_tokens.store': { paramsTuple?: []; params?: {} }
'profile.access_tokens.destroy': { paramsTuple?: []; params?: {} }
}
}
declare module '@adonisjs/core/types/http' {
export interface RoutesList extends ScannedRoutes {}
}
+5
View File
@@ -0,0 +1,5 @@
node_modules
build
.git
.idea
.env
+22
View File
@@ -0,0 +1,22 @@
# http://editorconfig.org
[*]
indent_style = space
indent_size = 2
end_of_line = lf
charset = utf-8
trim_trailing_whitespace = true
insert_final_newline = true
[*.json]
insert_final_newline = unset
[**.min.js]
indent_style = unset
insert_final_newline = unset
[MakeFile]
indent_style = space
[*.md]
trim_trailing_whitespace = false
+31
View File
@@ -0,0 +1,31 @@
# Node
TZ=UTC
PORT=8080
HOST=0.0.0.0
NODE_ENV=development
# App
LOG_LEVEL=info
APP_KEY=your-app-key-here-min-16-chars!!
APP_URL=http://${HOST}:${PORT}
# Session
SESSION_DRIVER=cookie
# MongoDB
MONGODB_URI=mongodb://adam:adam@localhost:27017/adam-acs?authSource=admin
MONGODB_DB_NAME=adam-acs
# Observability
OTEL_EXPORTER_URI=http://localhost:4318
OTEL_SAMPLER_MODE=parentbased_traceidratio
OTEL_SAMPLER_RATIO=0.1
OTEL_SERVICE_NAME=adam-acs
# Remote logging
LOG_HOST=localhost
LOG_PORT=514
LOG_PROTOCOL=udp4
# Security
HASH_KEY=0123456789abcdef0123456789abcdef
+1
View File
@@ -0,0 +1 @@
SESSION_DRIVER=memory
+26
View File
@@ -0,0 +1,26 @@
# Dependencies and AdonisJS build
node_modules
build
tmp/*
!tmp/.gitkeep
# Secrets
.env
.env.local
.env.production.local
.env.development.local
# Frontend assets compiled code
public/assets
# Build tools specific
npm-debug.log
yarn-error.log
# Editors specific
.fleet
.idea
.vscode
# Platform specific
.DS_Store
+3
View File
@@ -0,0 +1,3 @@
.adonisjs
node_modules
build
+31
View File
@@ -0,0 +1,31 @@
# Build stage
FROM node:24-alpine AS builder
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm install
COPY . .
RUN node ace build
# Production stage
FROM node:24-alpine AS production
WORKDIR /app
ENV NODE_ENV=production
ENV PORT=8080
ENV HOST=0.0.0.0
COPY --from=builder /app/build ./
RUN npm install --omit=dev
RUN addgroup -g 1001 -S appgroup && \
adduser -u 1001 -S appuser -G appgroup
USER appuser
EXPOSE 8080
CMD ["node", "bin/server.js"]
+10
View File
@@ -0,0 +1,10 @@
FROM node:20-alpine
RUN apk add --no-cache build-base git && \
rm -rf /var/cache/apk/*
WORKDIR /app
RUN git clone https://github.com/genieacs/genieacs-sim.git . && \
npm install
ENTRYPOINT ["./genieacs-sim"]
+27
View File
@@ -0,0 +1,27 @@
/*
|--------------------------------------------------------------------------
| JavaScript entrypoint for running ace commands
|--------------------------------------------------------------------------
|
| DO NOT MODIFY THIS FILE AS IT WILL BE OVERRIDDEN DURING THE BUILD
| PROCESS.
|
| See docs.adonisjs.com/guides/typescript-build-process#creating-production-build
|
| Since, we cannot run TypeScript source code using "node" binary, we need
| a JavaScript entrypoint to run ace commands.
|
| This file registers the "ts-node/esm" hook with the Node.js module system
| and then imports the "bin/console.ts" file.
|
*/
/**
* Register hook to process TypeScript files using @poppinss/ts-exec
*/
import '@poppinss/ts-exec'
/**
* Import ace console entrypoint
*/
await import('./bin/console.js')
+129
View File
@@ -0,0 +1,129 @@
import { indexEntities } from '@adonisjs/core'
import { defineConfig } from '@adonisjs/core/app'
import { generateRegistry } from '@tuyau/core/hooks'
export default defineConfig({
/*
|--------------------------------------------------------------------------
| Experimental flags
|--------------------------------------------------------------------------
|
| The following features will be enabled by default in the next major release
| of AdonisJS. You can opt into them today to avoid any breaking changes
| during upgrade.
|
*/
experimental: {},
/*
|--------------------------------------------------------------------------
| Commands
|--------------------------------------------------------------------------
|
| List of ace commands to register from packages. The application commands
| will be scanned automatically from the "./commands" directory.
|
*/
commands: [
() => import('@adonisjs/core/commands'),
() => import('@adonisjs/session/commands'),
],
/*
|--------------------------------------------------------------------------
| Service providers
|--------------------------------------------------------------------------
|
| List of service providers to import and register when booting the
| application
|
*/
providers: [
() => import('@adonisjs/core/providers/app_provider'),
() => import('@adonisjs/core/providers/hash_provider'),
{
file: () => import('@adonisjs/core/providers/repl_provider'),
environment: ['repl', 'test'],
},
() => import('@adonisjs/core/providers/vinejs_provider'),
() => import('@adonisjs/core/providers/edge_provider'),
() => import('@adonisjs/session/session_provider'),
() => import('@adonisjs/shield/shield_provider'),
() => import('@adonisjs/static/static_provider'),
() => import('@adonisjs/cors/cors_provider'),
() => import('@adonisjs/vite/vite_provider'),
() => import('@adonisjs/inertia/inertia_provider'),
() => import('#providers/mongodb_provider'),
],
/*
|--------------------------------------------------------------------------
| Preloads
|--------------------------------------------------------------------------
|
| List of modules to import before starting the application.
|
*/
preloads: [
() => import('#start/routes'),
() => import('#start/kernel'),
],
/*
|--------------------------------------------------------------------------
| Tests
|--------------------------------------------------------------------------
|
| List of test suites to organize tests by their type. Feel free to remove
| and add additional suites.
|
*/
tests: {
suites: [
{
files: ['tests/unit/**/*.spec.{ts,js}'],
name: 'unit',
timeout: 2000,
},
{
files: ['tests/functional/**/*.spec.{ts,js}'],
name: 'functional',
timeout: 30000,
},
{
files: ['tests/integration/**/*.spec.{ts,js}'],
name: 'integration',
timeout: 60000,
},
{
files: ['tests/property/**/*.spec.{ts,js}'],
name: 'property',
timeout: 30000,
},
],
forceExit: false,
},
/*
|--------------------------------------------------------------------------
| Metafiles
|--------------------------------------------------------------------------
|
| A collection of files you want to copy to the build folder when creating
| the production build.
|
*/
metaFiles: [
{ pattern: 'resources/views/**/*.edge', reloadServer: false },
{ pattern: 'public/**', reloadServer: false },
{ pattern: 'resources/xsd/**', reloadServer: false },
],
hooks: {
init: [
indexEntities({ transformers: { enabled: true } }),
generateRegistry(),
],
buildStarting: [() => import('@adonisjs/vite/build_hook')],
},
})
+31
View File
@@ -0,0 +1,31 @@
import type { HttpContext } from '@adonisjs/core/http'
import CommandService from '#services/command_service'
import { createCommandValidator } from '#validators/command'
export default class CommandsController {
private commandService = new CommandService()
async index({ response }: HttpContext) {
const commands = await this.commandService.getCommands()
return response.json(commands)
}
async show({ params, response }: HttpContext) {
const commands = await this.commandService.getCommandsByDevice(params.id)
return response.json(commands)
}
async store({ request, response }: HttpContext) {
const payload = await request.validateUsing(createCommandValidator)
const command = await this.commandService.createCommand({
deviceIdentifier: payload.deviceIdentifier,
action: payload.action as any,
parameters: payload.parameters as Record<string, any>,
mode: payload.mode as any,
priority: payload.priority,
})
return response.status(201).json(command)
}
}
@@ -0,0 +1,279 @@
import type { HttpContext } from '@adonisjs/core/http'
import ConfigurationService, {
type ConfigurationFilters,
type CreateConfigurationDto,
type UpdateConfigurationDto,
} from '#services/configuration_service'
import ConfigurationEngine from '#services/configuration_engine'
import logger from '@adonisjs/core/services/logger'
/**
* Configurations API Controller
*
* Handles REST endpoints for Configuration management:
* - GET /api/v1/configurations (list with pagination and filters)
* - GET /api/v1/configurations/:id (get single with compliance stats)
* - POST /api/v1/configurations (create with validation and overlap detection)
* - PUT /api/v1/configurations/:id (update with validation and overlap detection)
* - DELETE /api/v1/configurations/:id (delete)
* - PATCH /api/v1/configurations/:id/toggle (toggle enabled status)
* - PATCH /api/v1/configurations/:id/tags (add/remove tags)
* - GET /api/v1/configurations/:id/history (version history)
* - POST /api/v1/configurations/:id/dry-run (dry-run evaluation)
*/
export default class ConfigurationsController {
private configService: ConfigurationService
private engine: ConfigurationEngine
constructor() {
this.configService = new ConfigurationService()
this.engine = new ConfigurationEngine()
}
/**
* GET /api/v1/configurations
* List configurations with pagination and filters
*/
async index({ request, response }: HttpContext) {
try {
const filters: ConfigurationFilters = {
page: Number(request.input('page', 1)),
perPage: Number(request.input('perPage', 20)),
tag: request.input('tag'),
enabled: request.input('enabled') !== undefined ? request.input('enabled') === 'true' : undefined,
productClass: request.input('productClass'),
}
const result = await this.configService.findAll(filters)
return response.ok(result)
} catch (error: any) {
logger.error({ error: error.message }, '[ConfigAPI] List configurations failed')
return response.status(500).json({ message: 'Failed to list configurations' })
}
}
/**
* GET /api/v1/configurations/:id
* Get single configuration with compliance statistics
*/
async show({ params, response }: HttpContext) {
try {
const config = await this.configService.findById(params.id)
if (!config) {
return response.status(404).json({ message: 'Configuration not found' })
}
// TODO: Add compliance statistics from device compliance info
return response.ok(config)
} catch (error: any) {
logger.error({ error: error.message, id: params.id }, '[ConfigAPI] Get configuration failed')
return response.status(500).json({ message: 'Failed to retrieve configuration' })
}
}
/**
* POST /api/v1/configurations
* Create new configuration with validation and overlap detection
*/
async store({ request, response }: HttpContext) {
try {
const data = request.only([
'identifier',
'name',
'version',
'priority',
'policy',
'enabled',
'tags',
'criteria',
'inventory',
'desiredParameters',
'firmwareTarget',
'schedule',
]) as CreateConfigurationDto
// Check for overlaps
const overlapResult = await this.configService.detectOverlap(data.criteria || {}, data.priority)
if (overlapResult && overlapResult.overlappingConfigurations && overlapResult.overlappingConfigurations.length > 0 && !request.input('ignoreOverlap')) {
return response.status(409).json({
message: 'Configuration overlaps with existing ones',
overlaps: overlapResult.overlappingConfigurations,
})
}
// Create configuration
const config = await this.configService.create(data)
return response.status(201).json(config)
} catch (error: any) {
logger.error({ error: error.message }, '[ConfigAPI] Create configuration failed')
if (error.field) {
return response.status(422).json({ message: error.message, field: error.field })
}
return response.status(500).json({ message: 'Failed to create configuration' })
}
}
/**
* PUT /api/v1/configurations/:id
* Update configuration with validation and overlap detection
*/
async update({ params, request, response }: HttpContext) {
try {
const data = request.only([
'name',
'version',
'priority',
'policy',
'enabled',
'tags',
'criteria',
'inventory',
'desiredParameters',
'firmwareTarget',
'schedule',
]) as UpdateConfigurationDto
// Check for overlaps if priority changed
if (data.priority) {
const overlapResult = await this.configService.detectOverlap(
data.criteria || {},
data.priority,
params.id
)
if (overlapResult && overlapResult.overlappingConfigurations && overlapResult.overlappingConfigurations.length > 0 && !request.input('ignoreOverlap')) {
return response.status(409).json({
message: 'Configuration overlaps with existing ones',
overlaps: overlapResult.overlappingConfigurations,
})
}
}
// Update configuration
const config = await this.configService.update(params.id, data)
if (!config) {
return response.status(404).json({ message: 'Configuration not found' })
}
return response.ok(config)
} catch (error: any) {
logger.error({ error: error.message, id: params.id }, '[ConfigAPI] Update configuration failed')
if (error.field) {
return response.status(422).json({ message: error.message, field: error.field })
}
return response.status(500).json({ message: 'Failed to update configuration' })
}
}
/**
* DELETE /api/v1/configurations/:id
* Delete configuration
*/
async destroy({ params, response }: HttpContext) {
try {
await this.configService.delete(params.id)
return response.status(204).send(null)
} catch (error: any) {
logger.error({ error: error.message, id: params.id }, '[ConfigAPI] Delete configuration failed')
return response.status(500).json({ message: 'Failed to delete configuration' })
}
}
/**
* PATCH /api/v1/configurations/:id/toggle
* Toggle enabled status
*/
async toggleEnabled({ params, request, response }: HttpContext) {
try {
const enabled = request.input('enabled') as boolean
const config = await this.configService.toggleEnabled(params.id, enabled)
if (!config) {
return response.status(404).json({ message: 'Configuration not found' })
}
return response.ok(config)
} catch (error: any) {
logger.error({ error: error.message, id: params.id }, '[ConfigAPI] Toggle configuration failed')
return response.status(500).json({ message: 'Failed to toggle configuration' })
}
}
/**
* PATCH /api/v1/configurations/:id/tags
* Add or remove tags
*/
async manageTags({ params, request, response }: HttpContext) {
try {
const action = request.input('action') as 'add' | 'remove'
const tag = request.input('tag') as string
if (!action || !['add', 'remove'].includes(action)) {
return response.status(422).json({ message: 'Invalid action: must be "add" or "remove"' })
}
if (!tag) {
return response.status(422).json({ message: 'Tag is required' })
}
const config = await this.configService.findById(params.id)
if (!config) {
return response.status(404).json({ message: 'Configuration not found' })
}
if (action === 'add') {
if (!config.tags.includes(tag)) {
config.tags.push(tag)
}
} else {
const index = config.tags.indexOf(tag)
if (index > -1) {
config.tags.splice(index, 1)
}
}
const updated = await this.configService.update(params.id, { tags: config.tags })
return response.ok(updated)
} catch (error: any) {
logger.error({ error: error.message, id: params.id }, '[ConfigAPI] Manage tags failed')
return response.status(500).json({ message: 'Failed to manage tags' })
}
}
/**
* GET /api/v1/configurations/:id/history
* Get configuration version history
*/
async history({ params, request, response }: HttpContext) {
try {
const page = Number(request.input('page', 1))
const limit = Number(request.input('limit', 10))
const result = await this.configService.getHistory(params.id, page, limit)
return response.ok(result)
} catch (error: any) {
logger.error({ error: error.message, id: params.id }, '[ConfigAPI] Get history failed')
return response.status(500).json({ message: 'Failed to retrieve configuration history' })
}
}
/**
* POST /api/v1/configurations/:id/dry-run
* Perform dry-run evaluation for a device
*/
async dryRun({ params, request, response }: HttpContext) {
try {
const deviceIdentifier = request.input('deviceIdentifier') as string
if (!deviceIdentifier) {
return response.status(422).json({ message: 'deviceIdentifier is required' })
}
const result = await this.engine.dryRun(params.id, deviceIdentifier)
return response.ok(result)
} catch (error: any) {
logger.error({ error: error.message, id: params.id }, '[ConfigAPI] Dry-run failed')
if (error.message.includes('not found')) {
return response.status(404).json({ message: error.message })
}
return response.status(500).json({ message: 'Dry-run failed' })
}
}
}
+203
View File
@@ -0,0 +1,203 @@
import type { HttpContext } from '@adonisjs/core/http'
import { buildSoapEnvelope } from '../protocol/soap.js'
import { CWMPMessageBuilder } from '../protocol/common/cwmp_message_builder.js'
import { StateMachineManager } from '../services/cwmp/state_machine_manager.js'
import { Device } from '../domain/device/device.js'
import { DeviceId } from '../domain/device/device_id.js'
import { DeviceParameter, DeviceProperty } from '../domain/device/device_parameter.js'
import DeviceModel from '../models/device.js'
import { getDeviceHashIdentifier } from '../utils/device_utils.js'
import logger from '@adonisjs/core/services/logger'
export default class CwmpController {
private static managerInitialized = false
private getManager(): StateMachineManager {
const manager = StateMachineManager.getInstance()
if (!CwmpController.managerInitialized) {
manager.startCleanupTimer(60000)
CwmpController.managerInitialized = true
}
return manager
}
/**
* POST /cwmp10
*
* Main CWMP/TR-069 endpoint. The cwmp_middleware has already:
* - Parsed the raw XML into a SOAP envelope
* - Extracted the CWMP message type and data
* - Validated content-type
* - Attached everything to request.cwmp
*/
async handle({ request, response }: HttpContext) {
const manager = this.getManager()
const { sessionId, remoteKey, envelope, message, isEmpty } = request.cwmp
try {
// Empty body = CPE polling, continue the state machine
if (isEmpty) {
manager.ensureSessionForRemote(remoteKey, sessionId)
if (manager.hasSession(sessionId)) {
const result = await manager.continue(sessionId)
if (result && result !== '') {
return this.sendSoap(response, result)
}
}
return response.status(204).send('')
}
// Handle Inform
if (message?.type === 'Inform') {
const result = await this.handleInform(message.data, sessionId, envelope?.header?.id, remoteKey, manager)
return this.sendSoap(response, result)
}
// Handle TransferComplete
if (message?.type === 'TransferComplete') {
const resp = buildSoapEnvelope(undefined, '<cwmp:TransferCompleteResponse></cwmp:TransferCompleteResponse>')
return this.sendSoap(response, resp)
}
// Other CWMP responses - route to state machine
manager.ensureSessionForRemote(remoteKey, sessionId)
if (message && manager.hasSession(sessionId)) {
const { success, payload } = this.getMessageInfo(message)
const result = await manager.handleCWMPResponse(sessionId, success, payload)
if (result && result !== '') {
return this.sendSoap(response, result)
}
}
return response.status(204).send('')
} catch (error: any) {
// Return SOAP Fault per TR-069 spec (always HTTP 200)
logger.error({ error: error?.message, stack: error?.stack }, 'CWMP error handling request')
const faultXml = this.buildSoapFault(error?.message || 'Internal error')
return this.sendSoap(response, faultXml)
}
}
// ─── Inform Handling ───────────────────────────────────────────────────────
private async handleInform(
informData: any,
session: string,
cwmpId: string | undefined,
remoteKey: string,
manager: StateMachineManager
): Promise<string> {
logger.info({ informKeys: Object.keys(informData || {}), deviceId: informData?.DeviceId }, 'CWMP handleInform called')
const deviceIdStruct = informData?.DeviceId || {}
const deviceId = DeviceId.fromInform(deviceIdStruct)
// Load or create device
let device = await this.loadDevice(deviceId)
if (!device) {
device = new Device(deviceId)
}
device.updateLastSeen()
// Process inform parameter values
const paramValues = informData?.ParameterList?.ParameterValueStruct || []
if (paramValues.length > 0) {
device.updateFromValues(paramValues)
}
device.setTmpParamValues(paramValues)
// Persist device
const hashId = getDeviceHashIdentifier({
manufacturer: deviceId.manufacturer,
oui: deviceId.oui,
productClass: deviceId.productClass,
serialNumber: deviceId.serialNumber,
})
await DeviceModel.createOrUpdate({
identifier: hashId,
deviceId,
properties: device.getProperties().map((p) => p.toSnapshot()),
parameters: device.getParameters().map((p) => p.toSnapshot()),
updatedAt: new Date(),
})
// 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)
: []
logger.info({ events }, 'CWMP Inform events extracted')
// Start state machine
await manager.startStateMachine(device, session, { events })
manager.bindRemoteToDevice(remoteKey, device.deviceId)
// After Inform for onboarding events, enqueue critical reads
const onlyConnReq = events.length > 0 && events.every((e) => e === '6 CONNECTION REQUEST')
if (!onlyConnReq) {
try {
const criticalPaths = [
'InternetGatewayDevice.DeviceInfo.SpecVersion',
'InternetGatewayDevice.DeviceInfo.HardwareVersion',
'InternetGatewayDevice.DeviceInfo.SoftwareVersion',
'InternetGatewayDevice.ManagementServer.ConnectionRequestURL',
]
await manager.enqueueImmediate(device, 'get_parameter_values', criticalPaths)
} catch (_e) {
// Non-critical
}
}
return CWMPMessageBuilder.informResponse(cwmpId)
}
// ─── Helpers ───────────────────────────────────────────────────────────────
private sendSoap(response: any, xml: string) {
response.header('Content-Type', 'text/xml; charset=utf-8')
response.header('SOAPAction', '""')
return response.send(xml)
}
private getMessageInfo(message: { type: string; data: any }): { success: boolean; payload: any } {
const failTypes = ['Fault']
return {
success: !failTypes.includes(message.type),
payload: message.data,
}
}
private buildSoapFault(reason: string): string {
const body = `<soap-env:Fault>
<faultcode>Server</faultcode>
<faultstring>${reason}</faultstring>
<detail>
<cwmp:Fault>
<FaultCode>8002</FaultCode>
<FaultString>${reason}</FaultString>
</cwmp:Fault>
</detail>
</soap-env:Fault>`
return buildSoapEnvelope(undefined, body)
}
private async loadDevice(deviceId: DeviceId): Promise<Device | null> {
try {
const record = await DeviceModel.findByDeviceId(deviceId)
if (!record) return null
const device = new Device(
new DeviceId(record.deviceId.manufacturer, record.deviceId.oui, record.deviceId.productClass, record.deviceId.serialNumber),
record.createdAt
)
record.properties?.forEach((p: any) => device.setProperty(new DeviceProperty(p.name, p.value, p.type)))
record.parameters?.forEach((p: any) => device.setParameter(new DeviceParameter(p.name, p.value, p.type)))
return device
} catch (_error) {
return null
}
}
}
+94
View File
@@ -0,0 +1,94 @@
import type { HttpContext } from '@adonisjs/core/http'
import DeviceService from '#services/device_service'
import { PaginationData } from '#services/device_service'
import { CommandType } from '../domain/commands/command.js'
import DeviceModel from '#models/device'
export default class DevicesController {
private deviceService = new DeviceService()
async criteriaValues({ response }: HttpContext) {
const values = await DeviceModel.distinctCriteriaValues()
return response.json(values)
}
async index({ request, response }: HttpContext) {
const offset = Number(request.qs().offset) || 0
const limit = Number(request.qs().limit) || 100
const { data, nbDocuments, pager } = await this.deviceService.getDevices(offset, limit)
response.header('x-total-count', nbDocuments.toString())
response.header('link', this.generateLinkHeader(request, pager))
return response.json(data)
}
async show({ params, response }: HttpContext) {
const device = await this.deviceService.getDeviceByIdentifier(params.identifier)
if (!device) {
return response.status(404).json({ message: 'Device not found' })
}
return response.json(device)
}
async destroy({ params, response }: HttpContext) {
const deleted = await this.deviceService.deleteByIdentifier(params.identifier)
if (!deleted) {
return response.status(404).json({ message: 'Device not found' })
}
return response.json({ message: 'Device deleted successfully' })
}
async executeCommand({ params, request, response }: HttpContext) {
try {
const body = request.body() as {
action: CommandType
parameters?: Record<string, any>
mode?: string
priority?: number
}
await this.deviceService.executeCommand(params.identifier, body)
return response.status(201).json({ message: 'Command created' })
} catch (error: any) {
if (error.message?.includes('not found')) {
return response.status(404).json({ message: error.message })
}
if (error.message?.includes('Duplicate')) {
return response.status(409).json({ message: error.message })
}
return response.status(400).json({ message: error.message })
}
}
async refresh({ params, response }: HttpContext) {
try {
await this.deviceService.refreshDevice(params.identifier)
return response.status(200).json({ message: 'Refresh command sent' })
} catch (error: any) {
if (error.message?.includes('not found')) {
return response.status(404).json({ message: error.message })
}
return response.status(500).json({ message: error.message })
}
}
private generateLinkHeader(request: any, pager: PaginationData): string {
const baseUrl = `${request.protocol()}://${request.host()}${request.url().split('?')[0]}`
const parts: string[] = []
if (pager.first) {
parts.push(`<${baseUrl}?offset=${pager.first.offset}&limit=${pager.first.limit}>; rel="first"`)
}
if (pager.previous) {
parts.push(`<${baseUrl}?offset=${pager.previous.offset}&limit=${pager.previous.limit}>; rel="prev"`)
}
if (pager.next) {
parts.push(`<${baseUrl}?offset=${pager.next.offset}&limit=${pager.next.limit}>; rel="next"`)
}
if (pager.last) {
parts.push(`<${baseUrl}?offset=${pager.last.offset}&limit=${pager.last.limit}>; rel="last"`)
}
return parts.join(',')
}
}
@@ -0,0 +1,192 @@
import type { HttpContext } from '@adonisjs/core/http'
import FirmwareCatalogService, {
type CreateFirmwareEntryDto,
type CreateMigrationRouteDto,
} from '#services/firmware_catalog_service'
import logger from '@adonisjs/core/services/logger'
/**
* Firmware Catalog API Controller
*
* Handles REST endpoints for Firmware Catalog management:
* - GET /api/v1/firmware-catalog (list entries grouped by productClass)
* - POST /api/v1/firmware-catalog/entries (create firmware entry)
* - DELETE /api/v1/firmware-catalog/entries/:id (delete firmware entry)
* - POST /api/v1/firmware-catalog/routes (create migration route)
* - PUT /api/v1/firmware-catalog/routes/:id (update migration route)
* - DELETE /api/v1/firmware-catalog/routes/:id (delete migration route)
*/
export default class FirmwareCatalogController {
private catalogService: FirmwareCatalogService
constructor() {
this.catalogService = new FirmwareCatalogService()
}
/**
* GET /api/v1/firmware-catalog/entries
* List firmware entries with optional search
*/
async listEntries({ request, response }: HttpContext) {
try {
const productClass = request.input('productClass')
const entries = await this.catalogService.findEntries(productClass || undefined)
return response.ok({ data: entries, total: entries.length })
} catch (error: any) {
logger.error({ error: error.message }, '[FirmwareAPI] List entries failed')
return response.status(500).json({ message: 'Failed to list firmware entries' })
}
}
/**
* GET /api/v1/firmware-catalog/entries/:id
* Get a single firmware entry by identifier
*/
async showEntry({ params, response }: HttpContext) {
try {
const entries = await this.catalogService.findEntries()
const entry = entries.find((e) => e.identifier === params.id)
if (!entry) return response.status(404).json({ message: 'Firmware entry not found' })
return response.ok({ data: entry })
} catch (error: any) {
logger.error({ error: error.message }, '[FirmwareAPI] Show entry failed')
return response.status(500).json({ message: 'Failed to load firmware entry' })
}
}
/**
* GET /api/v1/firmware-catalog/routes
* List all migration routes
*/
async listRoutes({ response }: HttpContext) {
try {
const routes = await this.catalogService.findAllRoutes()
return response.ok({ data: routes, total: routes.length })
} catch (error: any) {
logger.error({ error: error.message }, '[FirmwareAPI] List routes failed')
return response.status(500).json({ message: 'Failed to list migration routes' })
}
}
/**
* PUT /api/v1/firmware-catalog/entries/:id
* Update a firmware entry
*/
async updateEntry({ params, request, response }: HttpContext) {
try {
const data = request.only([
'productClass', 'version', 'downloadUrl', 'checksum', 'checksumType', 'fileSize',
])
const entry = await this.catalogService.updateEntry(params.id, data)
return response.ok(entry)
} catch (error: any) {
logger.error({ error: error.message }, '[FirmwareAPI] Update entry failed')
return response.status(400).json({ message: error.message })
}
}
/**
* GET /api/v1/firmware-catalog
* List all firmware entries, optionally filtered by productClass
*/
async index({ request, response }: HttpContext) {
try {
const productClass = request.input('productClass')
const entries = await this.catalogService.findEntries(productClass)
return response.ok({ entries })
} catch (error: any) {
logger.error({ error: error.message }, '[FirmwareAPI] List entries failed')
return response.status(500).json({ message: 'Failed to list firmware entries' })
}
}
/**
* POST /api/v1/firmware-catalog/entries
* Create new firmware entry
*/
async createEntry({ request, response }: HttpContext) {
try {
const data = request.only([
'identifier',
'productClass',
'version',
'downloadUrl',
'checksum',
'checksumType',
'fileSize',
]) as CreateFirmwareEntryDto
const entry = await this.catalogService.createEntry(data)
return response.status(201).json(entry)
} catch (error: any) {
logger.error({ error: error.message }, '[FirmwareAPI] Create entry failed')
return response.status(400).json({ message: error.message })
}
}
/**
* DELETE /api/v1/firmware-catalog/entries/:id
* Delete firmware entry
*/
async deleteEntry({ params, response }: HttpContext) {
try {
await this.catalogService.deleteEntry(params.id)
return response.status(204).send(null)
} catch (error: any) {
logger.error({ error: error.message, id: params.id }, '[FirmwareAPI] Delete entry failed')
return response.status(500).json({ message: 'Failed to delete firmware entry' })
}
}
/**
* POST /api/v1/firmware-catalog/routes
* Create new migration route
*/
async createRoute({ request, response }: HttpContext) {
try {
const data = request.only([
'identifier',
'productClass',
'sourceVersionPattern',
'targetVersion',
'downloadUrl',
'priority',
]) as CreateMigrationRouteDto
const route = await this.catalogService.createMigrationRoute(data)
return response.status(201).json(route)
} catch (error: any) {
logger.error({ error: error.message }, '[FirmwareAPI] Create route failed')
return response.status(400).json({ message: error.message })
}
}
/**
* PUT /api/v1/firmware-catalog/routes/:id
* Update migration route
*/
async updateRoute({ params: _params, request: _request, response }: HttpContext) {
try {
// TODO: Implement update method in FirmwareCatalogService
// For now, return not implemented
return response.status(501).json({ message: 'Not implemented' })
} catch (error: any) {
logger.error({ error: error.message, id: _params.id }, '[FirmwareAPI] Update route failed')
return response.status(500).json({ message: 'Failed to update migration route' })
}
}
/**
* DELETE /api/v1/firmware-catalog/routes/:id
* Delete migration route
*/
async deleteRoute({ params, response }: HttpContext) {
try {
await this.catalogService.deleteMigrationRoute(params.id)
return response.status(204).send(null)
} catch (error: any) {
logger.error({ error: error.message, id: params.id }, '[FirmwareAPI] Delete route failed')
return response.status(500).json({ message: 'Failed to delete migration route' })
}
}
}
+18
View File
@@ -0,0 +1,18 @@
import type { HttpContext } from '@adonisjs/core/http'
import { getMongoDb } from '#providers/mongodb_provider'
export default class HealthController {
async checklife({ response }: HttpContext) {
return response.json({ status: 'OK' })
}
async checkservice({ response }: HttpContext) {
try {
const db = getMongoDb()
await db.command({ ping: 1 })
return response.json({ status: 'OK', version: '1.0.0' })
} catch {
return response.status(503).json({ status: 'KO', version: '1.0.0' })
}
}
}
+132
View File
@@ -0,0 +1,132 @@
import type { HttpContext } from '@adonisjs/core/http'
import DeviceService from '#services/device_service'
import CommandService from '#services/command_service'
export default class PagesController {
private deviceService = new DeviceService()
private commandService = new CommandService()
async home({ inertia }: HttpContext) {
const { data } = await this.deviceService.getDevices(0, 200)
return (inertia as any).render('Home', { devices: data, message: '' })
}
async deviceDetail({ params, inertia, response }: HttpContext) {
const device = await this.deviceService.getDeviceByIdentifier(params.identifier)
if (!device) {
return response.status(404).json({ message: 'Device not found' })
}
// Get pending commands (max 5) + last processed command
const allCommands = await this.commandService.getCommandsByDevice(params.identifier)
const pending = allCommands
.filter((c: any) => ['PENDING', 'WAITING', 'SENT'].includes(c.status))
.slice(0, 5)
const lastProcessed = allCommands
.filter((c: any) => ['COMPLETED', 'FAILED'].includes(c.status))
.sort((a: any, b: any) => new Date(b.updatedAt || b.createdAt).getTime() - new Date(a.updatedAt || a.createdAt).getTime())
[0]
const commands = lastProcessed ? [...pending, lastProcessed] : pending
return (inertia as any).render('DeviceDetail', { device, commands })
}
async commands({ inertia, request }: HttpContext) {
const deviceFilter = request.qs().device || ''
const statusFilter = request.qs().status || ''
const actionFilter = request.qs().action || ''
const modeFilter = request.qs().mode || ''
let commands: any[] = await this.commandService.getCommands()
if (deviceFilter) {
commands = commands.filter((c: any) => c.deviceIdentifier === deviceFilter)
}
if (statusFilter) {
commands = commands.filter((c: any) => c.status === statusFilter)
}
if (actionFilter) {
commands = commands.filter((c: any) => c.action === actionFilter)
}
if (modeFilter) {
commands = commands.filter((c: any) => c.mode === modeFilter)
}
return (inertia as any).render('Commands', {
commands,
filters: { device: deviceFilter, status: statusFilter, action: actionFilter, mode: modeFilter },
})
}
async commandsFleet({ inertia, session }: HttpContext) {
const devices = (session as any).get('fleetDevices', [])
const commands = await this.commandService.getCommands()
return (inertia as any).render('CommandsFleet', { devices, commands })
}
async storeFleetDevices({ request, response, session }: HttpContext) {
const { devices } = request.body()
;(session as any).put('fleetDevices', devices || [])
return response.redirect().toPath('/commands/fleet')
}
async refreshDevice({ params, response }: HttpContext) {
// Create a REFRESH command and send Connection Request to wake the CPE
await this.deviceService.refreshDevice(params.identifier)
return response.redirect().back()
}
async getModel({ params, request }: HttpContext) {
const { modelPath } = request.body()
await this.deviceService.executeCommand(params.identifier, {
action: 'GET_PARAMETER_NAMES' as any,
parameters: { parameterPath: modelPath, nextLevel: true },
mode: 'I',
})
}
async storeFleetCommand({ request, response, session }: HttpContext) {
const { action, parameters, mode, priority, devices } = request.body()
const deviceList = devices || session.get('fleetDevices', [])
for (const deviceIdentifier of deviceList) {
await this.commandService.createCommand({
deviceIdentifier,
action,
parameters: parameters || {},
mode,
priority,
})
}
return response.redirect().toPath('/commands/fleet')
}
async configurationsList({ inertia }: HttpContext) {
return (inertia as any).render('configurations/ConfigurationsList')
}
async configurationCreate({ inertia }: HttpContext) {
return (inertia as any).render('configurations/ConfigurationCreate')
}
async configurationEdit({ params, inertia }: HttpContext) {
return (inertia as any).render('configurations/ConfigurationEdit', { id: params.id })
}
async configurationDetail({ params, inertia }: HttpContext) {
return (inertia as any).render('configurations/ConfigurationDetail', { id: params.id })
}
async firmwareCatalog({ inertia }: HttpContext) {
return (inertia as any).render('firmware_catalog/FirmwareCatalog')
}
async firmwareCatalogCreate({ inertia }: HttpContext) {
return (inertia as any).render('firmware_catalog/FirmwareCatalogCreate')
}
async firmwareCatalogEdit({ params, inertia }: HttpContext) {
return (inertia as any).render('firmware_catalog/FirmwareCatalogEdit', { id: params.id })
}
}
+145
View File
@@ -0,0 +1,145 @@
import { DeviceId } from '../device/device_id.js'
import { ModeType } from '../shared/mode.js'
import { ObjectId } from 'mongodb'
export type CommandId = string
export enum CommandType {
SET_PARAMETER_VALUES = 'SET_PARAMETER_VALUES',
GET_PARAMETER_VALUES = 'GET_PARAMETER_VALUES',
GET_PARAMETER_NAMES = 'GET_PARAMETER_NAMES',
SET_PARAMETER_ATTRIBUTES = 'SET_PARAMETER_ATTRIBUTES',
GET_PARAMETER_ATTRIBUTES = 'GET_PARAMETER_ATTRIBUTES',
DOWNLOAD = 'DOWNLOAD',
UPLOAD = 'UPLOAD',
REBOOT = 'REBOOT',
FACTORY_RESET = 'FACTORY_RESET',
SCHEDULE_INFORM = 'SCHEDULE_INFORM',
REFRESH = 'REFRESH',
}
export enum CommandStatus {
PENDING = 'PENDING',
SENT = 'SENT',
WAITING = 'WAITING',
FAILED = 'FAILED',
CANCELLED = 'CANCELLED',
COMPLETED = 'COMPLETED',
}
export enum Priority {
LOW = 0,
NORMAL = 1,
HIGH = 2,
CRITICAL = 3,
}
export type CommandParameters = Record<string, any>
export interface CommandResult {
success: boolean
data?: any
message?: string
timestamp: Date
}
export interface CommandRecord {
_id?: ObjectId
deviceIdentifier: string
mode: ModeType
action: CommandType
parameters: Record<string, any>
status: CommandStatus
createdAt: Date
updatedAt: Date
executedAt?: Date
completedAt?: Date
priority: number
}
export class Command {
private _status: CommandStatus
private _executedAt: Date | null = null
private _result: CommandResult | null = null
private _error: string | null = null
constructor(
public readonly id: CommandId,
public readonly deviceId: DeviceId,
public readonly type: CommandType,
public readonly parameters: CommandParameters = {},
public readonly priority: Priority = Priority.NORMAL,
public readonly createdAt: Date = new Date(),
status: CommandStatus = CommandStatus.PENDING
) {
this._status = status
}
public get status(): CommandStatus {
return this._status
}
public get executedAt(): Date | null {
return this._executedAt
}
public get result(): CommandResult | null {
return this._result
}
public get error(): string | null {
return this._error
}
public markAsSent(): void {
if (this._status !== CommandStatus.PENDING) {
throw new Error(`Cannot mark command as sent. Current status: ${this._status}`)
}
this._status = CommandStatus.SENT
this._executedAt = new Date()
}
public markAsWaiting(): void {
if (this._status !== CommandStatus.SENT) {
throw new Error(`Cannot mark command as waiting. Current status: ${this._status}`)
}
this._status = CommandStatus.WAITING
}
public complete(result: CommandResult): void {
if (![CommandStatus.SENT, CommandStatus.WAITING].includes(this._status)) {
throw new Error(`Cannot complete command. Current status: ${this._status}`)
}
this._status = CommandStatus.COMPLETED
this._result = result
this._executedAt = new Date()
}
public fail(error: string): void {
if (this._status === CommandStatus.COMPLETED) {
throw new Error('Cannot fail a successful command')
}
this._status = CommandStatus.FAILED
this._error = error
this._executedAt = new Date()
}
public cancel(): void {
if ([CommandStatus.COMPLETED, CommandStatus.FAILED].includes(this._status)) {
throw new Error(`Cannot cancel command with status: ${this._status}`)
}
this._status = CommandStatus.CANCELLED
}
public canExecute(): boolean {
return this._status === CommandStatus.PENDING
}
public isCompleted(): boolean {
return [CommandStatus.COMPLETED, CommandStatus.FAILED, CommandStatus.CANCELLED].includes(this._status)
}
public hasExpired(timeoutMinutes: number = 30): boolean {
return (Date.now() - this.createdAt.getTime()) / (1000 * 60) > timeoutMinutes
}
}
+12
View File
@@ -0,0 +1,12 @@
export enum ComplianceStatus {
COMPLIANT = 'compliant',
IN_PROGRESS = 'in_progress',
FAILED = 'failed',
}
export interface DeviceComplianceInfo {
status: ComplianceStatus
configurationIdentifier: string
lastEvaluatedAt: Date
lastError?: string
}
+58
View File
@@ -0,0 +1,58 @@
export enum PolicyType {
ONCE = 'once',
MAINTAIN = 'maintain',
}
export interface ParameterCriteria {
name: string // TR-069 path, e.g. "InternetGatewayDevice.Time.Enable"
value: string | number | boolean // expected value (compared as string)
}
export interface ConfigurationCriteria {
manufacturer?: string // exact or glob pattern
oui?: string // exact or glob pattern
productClass?: string // exact or glob pattern
serialNumber?: string // exact or glob pattern
parameterCriteria?: ParameterCriteria[] // match on known device parameter values
}
export interface InventoryPath {
path: string // TR-069 path, e.g. "InternetGatewayDevice.DeviceInfo." or full param name
}
export interface DesiredParameter {
name: string // Full TR-069 path
value: string | number | boolean // Target value
type: ParameterValueType // 'string' | 'int' | 'unsignedInt' | 'boolean' | 'dateTime'
}
export type ParameterValueType = 'string' | 'int' | 'unsignedInt' | 'boolean' | 'dateTime'
export interface ScheduleWindow {
daysOfWeek: number[] // 0=Sunday, 1=Monday, ..., 6=Saturday (at least one required)
startHour: number // 0-23
endHour: number // 0-23, must differ from startHour; if endHour < startHour, spans midnight
timezone: string // IANA timezone, e.g. "Europe/Paris"
}
export interface FirmwareTarget {
productClass: string
targetVersion: string
}
export interface Configuration {
identifier: string
name: string
version: string // semver, e.g. "1.0.0"
priority: number // positive integer, higher wins
policy: PolicyType
enabled: boolean
tags: string[]
criteria: ConfigurationCriteria
inventory: InventoryPath[]
desiredParameters: DesiredParameter[]
firmwareTarget?: FirmwareTarget
schedule?: ScheduleWindow
createdAt: Date
updatedAt: Date
}
@@ -0,0 +1,8 @@
import { Configuration } from './configuration.js'
export interface ConfigurationVersion {
configurationIdentifier: string
versionNumber: number
snapshot: Configuration // Full Configuration state at that point
modifiedAt: Date
}
+249
View File
@@ -0,0 +1,249 @@
import { DeviceId } from './device_id.js'
import { DeviceParameter, DeviceProperty, ParameterSnapshot, PropertySnapshot } from './device_parameter.js'
import { ParameterType } from '../shared/types.js'
import { createHash } from 'node:crypto'
export enum OnboardingStatus {
PENDING = 'PENDING',
DISCOVERING = 'DISCOVERING',
PROVISIONING = 'PROVISIONING',
COMPLETED = 'COMPLETED',
FAILED = 'FAILED',
}
export class Device {
public tmpParamValues: any[] = []
public tmpPC: any = null
private _parameters: Map<string, DeviceParameter> = new Map()
private _properties: Map<string, DeviceProperty> = new Map()
private _session: any = null
private _lastSeen: Date = new Date()
private _onboardingStatus: OnboardingStatus = OnboardingStatus.PENDING
constructor(
public readonly deviceId: DeviceId,
public readonly createdAt: Date = new Date()
) {}
public get session(): any {
return this._session
}
public get lastSeen(): Date {
return this._lastSeen
}
public get onboardingStatus(): OnboardingStatus {
return this._onboardingStatus
}
public get identifier(): string {
const raw = `${this.deviceId.manufacturer}-${this.deviceId.oui}-${this.deviceId.productClass}-${this.deviceId.serialNumber}`
return createHash('sha256').update(raw).digest('hex').substring(0, 24)
}
public attachSession(session: any): void {
this._session = session
this._lastSeen = new Date()
}
public detachSession(): void {
if (this._session) {
this._session = null
}
}
public isOnline(): boolean {
return this._session !== null
}
public updateOnboardingStatus(status: OnboardingStatus): void {
this._onboardingStatus = status
this._lastSeen = new Date()
}
public setParameter(parameter: DeviceParameter): void {
this._parameters.set(parameter.name, parameter)
}
public getParameter(name: string): DeviceParameter | null {
return this._parameters.get(name) || null
}
public getParameters(): DeviceParameter[] {
return Array.from(this._parameters.values())
}
public updateParameter(name: string, value: any): void {
const parameter = this._parameters.get(name)
if (parameter) {
parameter.updateValue(value)
}
}
public setProperty(property: DeviceProperty): void {
this._properties.set(property.name, property)
}
public getProperty(name: string): DeviceProperty | null {
return this._properties.get(name) || null
}
public getProperties(): DeviceProperty[] {
return Array.from(this._properties.values())
}
public updateProperty(name: string, value: any): void {
const property = this._properties.get(name)
if (property) {
property.updateValue(value)
}
}
public getConnectionRequestURL(): URL | null {
for (const property of this._properties.values()) {
const url = property.getConnectionRequestURL()
if (url) return url
}
return null
}
public canExecuteCommands(): boolean {
return this.isOnline() && this._onboardingStatus === OnboardingStatus.COMPLETED
}
public setTmpParamValues(values: any[]): void {
this.tmpParamValues = values
}
public getTmpParamValues(): any[] {
return this.tmpParamValues
}
public setTmpPC(value: any): void {
this.tmpPC = value
}
public getTmpPC(): any {
return this.tmpPC
}
public clearTmpValues(): void {
this.tmpParamValues = []
this.tmpPC = null
}
public organizeParameters(response: any, timestamp: Date): void {
const infoList = response?.ParameterList?.ParameterInfoStruct || []
for (const info of infoList) {
const name = info?.Name
if (!name) continue
// Skip object paths (ending with dot) - only store leaf parameters
if (name.endsWith('.')) continue
const isWritable = !!info?.Writable
if (this._parameters.has(name) || this._properties.has(name)) {
if (isWritable && this._properties.has(name)) {
const prop = this._properties.get(name)!
this._properties.delete(name)
this.setParameter(new DeviceParameter(name, prop.value, prop.type))
} else if (!isWritable && this._parameters.has(name)) {
const param = this._parameters.get(name)!
this._parameters.delete(name)
this.setProperty(new DeviceProperty(name, param.value, param.type))
}
continue
}
if (isWritable) {
this.setParameter(new DeviceParameter(name, null, ParameterType.UNKNOWN))
} else {
this.setProperty(new DeviceProperty(name, null, ParameterType.UNKNOWN))
}
}
this._lastSeen = timestamp
}
public updateFromValues(paramValues: any[]): void {
for (const pv of paramValues || []) {
const name = pv?.Name
const valueObj = pv?.Value
const hasTextNode = valueObj && Object.prototype.hasOwnProperty.call(valueObj as any, '_')
const hasTypeAttr = valueObj && (valueObj['$xsi:type'] || valueObj?.$?.Type)
const val = hasTextNode ? valueObj._ : (hasTypeAttr ? '' : undefined)
const typeStr = valueObj?.['$xsi:type'] || valueObj?.$?.Type
const mappedType = this.mapType(typeStr)
if (!name) continue
if (val === undefined && !hasTypeAttr) continue
if (this._parameters.has(name)) {
const existing = this._parameters.get(name)!
if (mappedType && existing.type === ParameterType.UNKNOWN) {
const snap = existing.toSnapshot()
this._parameters.set(
name,
new DeviceParameter(name, snap.value, mappedType, snap.createdAt, snap.updatedAt, snap.notification, snap.accessList)
)
}
this.updateParameter(name, val)
} else if (this._properties.has(name)) {
const existing = this._properties.get(name)!
if (mappedType && existing.type === ParameterType.UNKNOWN) {
const snap = existing.toSnapshot()
this._properties.set(
name,
new DeviceProperty(name, snap.value, mappedType, snap.createdAt, snap.updatedAt, snap.notification, snap.accessList)
)
}
this.updateProperty(name, val)
} else {
this.setProperty(new DeviceProperty(name, val ?? null, mappedType || ParameterType.UNKNOWN))
}
}
this._lastSeen = new Date()
}
private mapType(typeStr: string | undefined): ParameterType | undefined {
switch (typeStr) {
case 'xsd:string':
return ParameterType.STRING
case 'xsd:int':
return ParameterType.INT
case 'xsd:unsignedInt':
return ParameterType.UNSIGNED_INT
case 'xsd:boolean':
return ParameterType.BOOLEAN
default:
return undefined
}
}
public toSnapshot(): DeviceSnapshot {
return {
deviceId: this.deviceId,
identifier: this.identifier,
createdAt: this.createdAt,
lastSeen: this._lastSeen,
onboardingStatus: this._onboardingStatus,
isOnline: this.isOnline(),
parameters: this.getParameters().map((p) => p.toSnapshot()),
properties: this.getProperties().map((p) => p.toSnapshot()),
}
}
public updateLastSeen(): void {
this._lastSeen = new Date()
}
}
export interface DeviceSnapshot {
deviceId: DeviceId
identifier: string
createdAt: Date
lastSeen: Date
onboardingStatus: OnboardingStatus
isOnline: boolean
parameters: ParameterSnapshot[]
properties: PropertySnapshot[]
}
+45
View File
@@ -0,0 +1,45 @@
export class DeviceId {
constructor(
public readonly manufacturer: string,
public readonly oui: string,
public readonly productClass: string,
public readonly serialNumber: string
) {
this.validate()
}
private validate(): void {
if (!this.manufacturer?.trim()) {
throw new Error('Manufacturer is required')
}
if (!this.serialNumber?.trim()) {
throw new Error('Serial number is required')
}
}
public toIdentifier(): string {
return `${this.manufacturer}-${this.oui}-${this.productClass}-${this.serialNumber}`
}
public equals(other: DeviceId): boolean {
return (
this.manufacturer === other.manufacturer &&
this.oui === other.oui &&
this.productClass === other.productClass &&
this.serialNumber === other.serialNumber
)
}
public static fromInform(informData: any): DeviceId {
return new DeviceId(
String(informData?.Manufacturer ?? ''),
String(informData?.OUI ?? ''),
String(informData?.ProductClass ?? ''),
String(informData?.SerialNumber ?? '')
)
}
public toString(): string {
return this.toIdentifier()
}
}
+110
View File
@@ -0,0 +1,110 @@
import { ParameterType } from '../shared/types.js'
export interface ParameterSnapshot {
name: string
value: any
type: ParameterType
createdAt?: Date
updatedAt?: Date
notification?: number
accessList?: string[]
}
export interface PropertySnapshot {
name: string
value: any
type: ParameterType
createdAt?: Date
updatedAt?: Date
notification?: number
accessList?: string[]
}
export class DeviceParameter {
constructor(
public readonly name: string,
private _value: any,
public type: ParameterType = ParameterType.UNKNOWN,
public createdAt: Date = new Date(),
public updatedAt: Date = new Date(),
public notification?: number,
public accessList?: string[]
) {}
get value(): any {
return this._value
}
updateValue(value: any): void {
this._value = value
this.updatedAt = new Date()
}
updateAttributes(notification?: number, accessList?: string[]): void {
if (notification !== undefined) this.notification = notification
if (accessList !== undefined) this.accessList = accessList
this.updatedAt = new Date()
}
toSnapshot(): ParameterSnapshot {
return {
name: this.name,
value: this._value,
type: this.type,
createdAt: this.createdAt,
updatedAt: this.updatedAt,
notification: this.notification,
accessList: this.accessList,
}
}
}
export class DeviceProperty {
constructor(
public readonly name: string,
private _value: any,
public type: ParameterType = ParameterType.UNKNOWN,
public createdAt: Date = new Date(),
public updatedAt: Date = new Date(),
public notification?: number,
public accessList?: string[]
) {}
get value(): any {
return this._value
}
updateValue(value: any): void {
this._value = value
this.updatedAt = new Date()
}
updateAttributes(notification?: number, accessList?: string[]): void {
if (notification !== undefined) this.notification = notification
if (accessList !== undefined) this.accessList = accessList
this.updatedAt = new Date()
}
getConnectionRequestURL(): URL | null {
if (this.name.includes('ConnectionRequestURL') && this._value) {
try {
return new URL(this._value)
} catch {
return null
}
}
return null
}
toSnapshot(): PropertySnapshot {
return {
name: this.name,
value: this._value,
type: this.type,
createdAt: this.createdAt,
updatedAt: this.updatedAt,
notification: this.notification,
accessList: this.accessList,
}
}
}
+14
View File
@@ -0,0 +1,14 @@
import { DeviceId } from './device_id.js'
import { PropertySnapshot, ParameterSnapshot } from './device_parameter.js'
import { DeviceComplianceInfo } from '../configuration/compliance.js'
export interface DeviceRecord {
identifier?: string
deviceId: DeviceId
properties: PropertySnapshot[]
parameters: ParameterSnapshot[]
compliance?: DeviceComplianceInfo
provisioningCode?: string
updatedAt?: Date
createdAt?: Date
}
+25
View File
@@ -0,0 +1,25 @@
export interface FirmwareEntry {
identifier: string
productClass: string
version: string
downloadUrl: string
checksum: string
checksumType: 'md5' | 'sha256'
fileSize: number
createdAt: Date
}
export interface MigrationRoute {
identifier: string
productClass: string
sourceVersionPattern: string
targetVersion: string
downloadUrl: string
priority: number
createdAt: Date
}
export interface FirmwareCatalog {
entries: FirmwareEntry[]
migrationRoutes: MigrationRoute[]
}
+7
View File
@@ -0,0 +1,7 @@
export interface MigrationStep {
fromVersion: string
toVersion: string
downloadUrl: string
fileSize: number
checksum: string
}
+16
View File
@@ -0,0 +1,16 @@
export enum ModeType {
NI = 'NI',
I = 'I',
}
export function isModeType(value: any): value is ModeType {
return value === ModeType.NI || value === ModeType.I
}
export function parseMode(value?: string): ModeType | undefined {
if (!value) return undefined
const upper = value.toUpperCase()
if (upper === ModeType.NI) return ModeType.NI
if (upper === ModeType.I) return ModeType.I
return undefined
}
+7
View File
@@ -0,0 +1,7 @@
export enum ParameterType {
STRING = 'xsd:string',
UNSIGNED_INT = 'xsd:unsignedInt',
INT = 'xsd:int',
BOOLEAN = 'xsd:boolean',
UNKNOWN = 'unknown',
}
+14
View File
@@ -0,0 +1,14 @@
import app from '@adonisjs/core/services/app'
import { HttpContext, ExceptionHandler } from '@adonisjs/core/http'
export default class HttpExceptionHandler extends ExceptionHandler {
protected debug = !app.inProduction
async handle(error: unknown, ctx: HttpContext) {
return super.handle(error, ctx)
}
async report(error: unknown, ctx: HttpContext) {
return super.report(error, ctx)
}
}
+21
View File
@@ -0,0 +1,21 @@
import { Exception } from '@adonisjs/core/exceptions'
export class NotFoundException extends Exception {
static status = 404
static code = 'E_NOT_FOUND'
}
export class BadRequestException extends Exception {
static status = 400
static code = 'E_BAD_REQUEST'
}
export class UnprocessableEntityException extends Exception {
static status = 422
static code = 'E_UNPROCESSABLE'
}
export class DatabaseException extends Exception {
static status = 503
static code = 'E_DATABASE_ERROR'
}
@@ -0,0 +1,19 @@
import { Logger } from '@adonisjs/core/logger'
import { HttpContext } from '@adonisjs/core/http'
import { type NextFn } from '@adonisjs/core/types/http'
/**
* The container bindings middleware binds classes to their request
* specific value using the container resolver.
*
* - We bind "HttpContext" class to the "ctx" object
* - And bind "Logger" class to the "ctx.logger" object
*/
export default class ContainerBindingsMiddleware {
handle(ctx: HttpContext, next: NextFn) {
ctx.containerResolver.bindValue(HttpContext, ctx)
ctx.containerResolver.bindValue(Logger, ctx.logger)
return next()
}
}
+62
View File
@@ -0,0 +1,62 @@
import type { HttpContext } from '@adonisjs/core/http'
import type { NextFn } from '@adonisjs/core/types/http'
// @ts-ignore - xml-formatter types conflict with ESM interop
import formatXml from 'xml-formatter'
/**
* CWMP Logger Middleware
*
* Middleware dédié à l'observation des messages TR-069 XML.
* Trace les échanges CPE↔ACS (requêtes entrantes et réponses sortantes)
* avec le XML pretty-printed directement sur stdout pour lisibilité humaine.
*
* Doit être placé avant le cwmp_middleware dans la chaîne.
*/
export default class CwmpLoggerMiddleware {
private prettyXml(xml: string): string {
try {
return (formatXml as any)(xml, { indentation: ' ', collapseContent: true, lineSeparator: '\n' })
} catch {
return xml
}
}
private log(direction: string, sessionId: string, xml: string) {
const timestamp = new Date().toISOString()
const separator = '─'.repeat(80)
process.stdout.write(
`\n${separator}\n` +
`[TR-069] ${direction} | ${timestamp} | ${sessionId}\n` +
`${separator}\n` +
`${xml}\n` +
`${separator}\n\n`
)
}
async handle({ request, response }: HttpContext, next: NextFn) {
const rawBody = request.raw() || ''
const socket = request.request?.socket
const remoteIp = request.ip()
const remotePort = socket?.remotePort || 0
const localAddress = socket?.localAddress || 'unknown'
const localPort = socket?.localPort || 0
const sessionId = `${remoteIp}:${remotePort}->${localAddress}:${localPort}`
// Log incoming TR-069 message
if (rawBody && rawBody.trim() !== '') {
this.log('CPE→ACS', sessionId, this.prettyXml(rawBody))
}
// Intercept response to log outgoing XML
const originalSend = response.send.bind(response)
response.send = (body: any) => {
if (typeof body === 'string' && body.includes('soap-env:Envelope')) {
this.log('ACS→CPE', sessionId, this.prettyXml(body))
}
return originalSend(body)
}
return next()
}
}
+88
View File
@@ -0,0 +1,88 @@
import type { HttpContext } from '@adonisjs/core/http'
import type { NextFn } from '@adonisjs/core/types/http'
import { parseSoapXml, extractCwmpMessage } from '../protocol/soap.js'
/**
* CWMP Middleware
*
* Handles the specifics of TR-069/CWMP requests:
* - Parses raw XML body into a SOAP envelope
* - Extracts the CWMP message from the envelope
* - Attaches parsed data to the HTTP context for the controller
* - Validates content-type (must be text/xml or empty for polling)
*/
export default class CwmpMiddleware {
async handle({ request, response, logger }: HttpContext, next: NextFn) {
const contentType = request.header('content-type') || ''
const rawBody = request.raw() || ''
// Derive session identifiers from the TCP socket
const socket = request.request?.socket
const remoteIp = request.ip()
const remotePort = socket?.remotePort || 0
const localAddress = socket?.localAddress || 'unknown'
const localPort = socket?.localPort || 0
const sessionId = `${remoteIp}:${remotePort}->${localAddress}:${localPort}`
const remoteKey = `${remoteIp}:${remotePort}`
// Attach CWMP context to the request
request.cwmp = {
sessionId,
remoteKey,
rawBody,
envelope: null,
message: null,
isEmpty: !rawBody || rawBody.trim() === '',
}
// Empty body = CPE polling for next instruction (valid in TR-069)
if (request.cwmp.isEmpty) {
return next()
}
// Validate content type for non-empty requests
const isXml = contentType.includes('text/xml') ||
contentType.includes('application/xml') ||
contentType.includes('application/soap+xml') ||
contentType === '' // Some CPEs don't set content-type
if (!isXml && rawBody.trim() !== '') {
logger.warn({ sessionId, contentType }, 'CWMP invalid content-type')
response.status(415).send('Unsupported Media Type: expected text/xml')
return
}
// Parse SOAP XML
try {
const envelope = parseSoapXml(rawBody)
request.cwmp.envelope = envelope
// Extract CWMP message from envelope body
const message = extractCwmpMessage(envelope.body)
request.cwmp.message = message
} catch (error: any) {
logger.error({ sessionId, error: error.message }, 'CWMP XML parse error')
response.status(400).send('Malformed SOAP/XML')
return
}
return next()
}
}
/**
* Extend the AdonisJS Request interface to include CWMP context.
*/
declare module '@adonisjs/core/http' {
interface HttpRequest {
cwmp: {
sessionId: string
remoteKey: string
rawBody: string
envelope: any | null
message: { type: string; data: any } | null
isEmpty: boolean
}
}
}
+17
View File
@@ -0,0 +1,17 @@
import BaseInertiaMiddleware from '@adonisjs/inertia/inertia_middleware'
import type { HttpContext } from '@adonisjs/core/http'
import type { NextFn } from '@adonisjs/core/types/http'
export default class InertiaMiddleware extends BaseInertiaMiddleware {
async share(ctx: HttpContext) {
return {
errors: ctx.session?.flashMessages.get('errors'),
}
}
async handle(ctx: HttpContext, next: NextFn) {
await this.init(ctx)
await next()
this.dispose(ctx)
}
}
+39
View File
@@ -0,0 +1,39 @@
import { BaseModel } from '#database/base_model'
import { CommandRecord, CommandStatus } from '../domain/commands/command.js'
import { ModeType } from '../domain/shared/mode.js'
import { ObjectId } from 'mongodb'
export default class CommandModel extends BaseModel {
static collectionName = 'commands'
static async findByDevice(deviceIdentifier: string): Promise<CommandRecord[]> {
const collection = this.getCollection()
return collection.find({ deviceIdentifier }).sort({ createdAt: -1 }).toArray() as unknown as Promise<CommandRecord[]>
}
static async findPending(deviceIdentifier: string, mode?: ModeType): Promise<CommandRecord[]> {
const filter: any = { deviceIdentifier, status: CommandStatus.PENDING }
if (mode) filter.mode = mode
const collection = this.getCollection()
return collection.find(filter).sort({ priority: 1 }).toArray() as unknown as Promise<CommandRecord[]>
}
static async createCommand(command: Omit<CommandRecord, '_id'>): Promise<CommandRecord> {
const collection = this.getCollection()
const doc = { ...command, createdAt: new Date(), updatedAt: new Date() }
const result = await collection.insertOne(doc)
return { ...doc, _id: result.insertedId } as CommandRecord
}
static async updateCommand(id: ObjectId, update: Partial<CommandRecord>): Promise<void> {
const collection = this.getCollection()
await collection.updateOne({ _id: id }, { $set: { ...update, updatedAt: new Date() } })
}
static async deleteById(id: string | ObjectId): Promise<boolean> {
const collection = this.getCollection()
const objectId = typeof id === 'string' ? new ObjectId(id) : id
const result = await collection.deleteOne({ _id: objectId })
return result.deletedCount === 1
}
}
+37
View File
@@ -0,0 +1,37 @@
import { BaseModel } from '#database/base_model'
import { Configuration } from '../domain/configuration/configuration.js'
import { Filter, Sort } from 'mongodb'
export default class ConfigurationModel extends BaseModel {
static collectionName = 'configurations'
static async findByIdentifier(identifier: string): Promise<Configuration | null> {
return this.findOne({ identifier }) as Promise<Configuration | null>
}
static async findAllEnabled(sort: Sort = { priority: -1 }): Promise<Configuration[]> {
const collection = this.getCollection()
return collection.find({ enabled: true }).sort(sort).toArray() as unknown as Promise<Configuration[]>
}
static async paginateConfigurations(
page: number,
perPage: number,
filter: Filter<any> = {},
sort: Sort = { priority: -1 }
): Promise<{ data: Configuration[]; total: number; page: number; perPage: number; lastPage: number }> {
return this.paginate(page, perPage, filter, sort)
}
static async createIfNotExists(data: Partial<Configuration>): Promise<Configuration> {
const existing = await this.findOne({ identifier: data.identifier })
if (existing) {
throw new Error(`Configuration with identifier '${data.identifier}' already exists`)
}
return this.create(data) as Promise<Configuration>
}
static async deleteByIdentifier(identifier: string): Promise<boolean> {
return this.deleteOne({ identifier })
}
}
+40
View File
@@ -0,0 +1,40 @@
import { BaseModel } from '#database/base_model'
import { ConfigurationVersion } from '../domain/configuration/configuration_version.js'
import { Sort } from 'mongodb'
export default class ConfigurationVersionModel extends BaseModel {
static collectionName = 'configuration_versions'
static async findByConfigurationIdentifier(
configurationIdentifier: string,
sort: Sort = { versionNumber: -1 }
): Promise<ConfigurationVersion[]> {
const collection = this.getCollection()
return collection
.find({ configurationIdentifier })
.sort(sort)
.toArray() as unknown as Promise<ConfigurationVersion[]>
}
static async paginateVersions(
configurationIdentifier: string,
page: number,
perPage: number
): Promise<{ data: ConfigurationVersion[]; total: number; page: number; perPage: number; lastPage: number }> {
return this.paginate(page, perPage, { configurationIdentifier }, { versionNumber: -1 })
}
static async getLatestVersionNumber(configurationIdentifier: string): Promise<number> {
const collection = this.getCollection()
const latest = await collection
.find({ configurationIdentifier })
.sort({ versionNumber: -1 })
.limit(1)
.toArray()
return latest.length > 0 ? (latest[0] as any).versionNumber : 0
}
static async deleteByConfigurationIdentifier(configurationIdentifier: string): Promise<number> {
return this.deleteMany({ configurationIdentifier })
}
}
+131
View File
@@ -0,0 +1,131 @@
import { BaseModel } from '#database/base_model'
import { DeviceRecord } from '../domain/device/device_record.js'
import { DeviceId } from '../domain/device/device_id.js'
import { DeviceComplianceInfo } from '../domain/configuration/compliance.js'
import { Filter, Sort } from 'mongodb'
export default class DeviceModel extends BaseModel {
static collectionName = 'devices'
static async findByIdentifier(identifier: string): Promise<DeviceRecord | null> {
return this.findOne({ identifier })
}
static async findByDeviceId(deviceId: DeviceId): Promise<DeviceRecord | null> {
return this.findOne({
'deviceId.manufacturer': deviceId.manufacturer,
'deviceId.oui': deviceId.oui,
'deviceId.productClass': deviceId.productClass,
'deviceId.serialNumber': deviceId.serialNumber,
})
}
static async paginateDevices(
offset: number,
limit: number,
filter: Filter<any> = {}
): Promise<{ data: DeviceRecord[]; total: number }> {
const collection = this.getCollection()
const total = await collection.countDocuments(filter)
const data = (await collection
.find(filter, { projection: { _id: 0 } })
.skip(offset)
.limit(limit)
.sort({ updatedAt: -1 } as Sort)
.toArray()) as unknown as DeviceRecord[]
return { data, total }
}
static async createOrUpdate(device: Partial<DeviceRecord>): Promise<DeviceRecord> {
const collection = this.getCollection()
let filter: Filter<any> = {}
if (device.deviceId) {
filter = { deviceId: device.deviceId }
} else if (device.identifier) {
filter = { identifier: device.identifier }
} else {
throw new Error('Either identifier or deviceId must be provided')
}
const result = await collection.findOneAndUpdate(
filter,
{
$set: { ...device, updatedAt: new Date() },
$setOnInsert: { createdAt: new Date() },
},
{ upsert: true, returnDocument: 'after', projection: { _id: 0 } }
)
return result as unknown as DeviceRecord
}
static async updateCompliance(
identifier: string,
compliance: DeviceComplianceInfo
): Promise<void> {
await this.updateOne({ identifier }, { compliance })
}
static async updateProvisioningCode(
identifier: string,
provisioningCode: string
): Promise<void> {
await this.updateOne({ identifier }, { provisioningCode })
}
static async findByComplianceStatus(status: string): Promise<DeviceRecord[]> {
const collection = this.getCollection()
return collection
.find({ 'compliance.status': status })
.toArray() as unknown as Promise<DeviceRecord[]>
}
static async findByConfigurationIdentifier(configurationIdentifier: string): Promise<DeviceRecord[]> {
const collection = this.getCollection()
return collection
.find({ 'compliance.configurationIdentifier': configurationIdentifier })
.toArray() as unknown as Promise<DeviceRecord[]>
}
static async distinctCriteriaValues(): Promise<{
manufacturer: string[]
oui: string[]
productClass: string[]
serialNumber: string[]
}> {
const collection = this.getCollection()
const [manufacturer, oui, productClass, serialNumber] = await Promise.all([
collection.distinct('deviceId.manufacturer'),
collection.distinct('deviceId.oui'),
collection.distinct('deviceId.productClass'),
collection.distinct('deviceId.serialNumber'),
])
return {
manufacturer: (manufacturer as string[]).filter(Boolean).sort(),
oui: (oui as string[]).filter(Boolean).sort(),
productClass: (productClass as string[]).filter(Boolean).sort(),
serialNumber: (serialNumber as string[]).filter(Boolean).sort(),
}
}
static async countByComplianceStatus(
configurationIdentifier: string
): Promise<{ compliant: number; in_progress: number; failed: number }> {
const collection = this.getCollection()
const results = await collection
.aggregate([
{ $match: { 'compliance.configurationIdentifier': configurationIdentifier } },
{ $group: { _id: '$compliance.status', count: { $sum: 1 } } },
])
.toArray()
const counts = { compliant: 0, in_progress: 0, failed: 0 }
for (const r of results) {
if (r._id === 'compliant') counts.compliant = r.count
else if (r._id === 'in_progress') counts.in_progress = r.count
else if (r._id === 'failed') counts.failed = r.count
}
return counts
}
}
+34
View File
@@ -0,0 +1,34 @@
import { BaseModel } from '#database/base_model'
import { FirmwareEntry } from '../domain/firmware/firmware_catalog.js'
export default class FirmwareEntryModel extends BaseModel {
static collectionName = 'firmware_entries'
static async findByIdentifier(identifier: string): Promise<FirmwareEntry | null> {
return this.findOne({ identifier }) as Promise<FirmwareEntry | null>
}
static async findByProductClass(productClass: string): Promise<FirmwareEntry[]> {
const collection = this.getCollection()
return collection.find({ productClass }).toArray() as unknown as Promise<FirmwareEntry[]>
}
static async findByProductClassAndVersion(
productClass: string,
version: string
): Promise<FirmwareEntry | null> {
return this.findOne({ productClass, version }) as Promise<FirmwareEntry | null>
}
static async createIfNotExists(data: Partial<FirmwareEntry>): Promise<FirmwareEntry> {
const existing = await this.findOne({ identifier: data.identifier })
if (existing) {
throw new Error(`Firmware entry with identifier '${data.identifier}' already exists`)
}
return this.create(data) as Promise<FirmwareEntry>
}
static async deleteByIdentifier(identifier: string): Promise<boolean> {
return this.deleteOne({ identifier })
}
}
+31
View File
@@ -0,0 +1,31 @@
import { BaseModel } from '#database/base_model'
import { MigrationRoute } from '../domain/firmware/firmware_catalog.js'
import { Sort } from 'mongodb'
export default class MigrationRouteModel extends BaseModel {
static collectionName = 'migration_routes'
static async findByIdentifier(identifier: string): Promise<MigrationRoute | null> {
return this.findOne({ identifier }) as Promise<MigrationRoute | null>
}
static async findByProductClass(
productClass: string,
sort: Sort = { priority: -1 }
): Promise<MigrationRoute[]> {
const collection = this.getCollection()
return collection.find({ productClass }).sort(sort).toArray() as unknown as Promise<MigrationRoute[]>
}
static async createIfNotExists(data: Partial<MigrationRoute>): Promise<MigrationRoute> {
const existing = await this.findOne({ identifier: data.identifier })
if (existing) {
throw new Error(`Migration route with identifier '${data.identifier}' already exists`)
}
return this.create(data) as Promise<MigrationRoute>
}
static async deleteByIdentifier(identifier: string): Promise<boolean> {
return this.deleteOne({ identifier })
}
}
+150
View File
@@ -0,0 +1,150 @@
import { buildSoapEnvelope } from '../soap.js'
export class CWMPMessageBuilder {
static informResponse(cwmpId?: string): string {
const body = `<cwmp:InformResponse>
<MaxEnvelopes>1</MaxEnvelopes>
</cwmp:InformResponse>`
return buildSoapEnvelope(cwmpId, body)
}
static getParameterNames(cwmpId: string | undefined, parameterPath: string, nextLevel: boolean = true): string {
const body = `<cwmp:GetParameterNames>
<ParameterPath>${parameterPath}</ParameterPath>
<NextLevel>${nextLevel ? '1' : '0'}</NextLevel>
</cwmp:GetParameterNames>`
return buildSoapEnvelope(cwmpId, body)
}
static getParameterValues(cwmpId: string | undefined, parameterNames: string[]): string {
const names = parameterNames.map((n) => `<string>${n}</string>`).join('\n ')
const body = `<cwmp:GetParameterValues>
<ParameterNames soap-enc:arrayType="xsd:string[${parameterNames.length}]">
${names}
</ParameterNames>
</cwmp:GetParameterValues>`
return buildSoapEnvelope(cwmpId, body)
}
static setParameterValues(cwmpId: string | undefined, parameters: { name: string; value: string; type: string }[]): string {
const params = parameters
.map(
(p) => `<ParameterValueStruct>
<Name>${p.name}</Name>
<Value xsi:type="${p.type}">${p.value}</Value>
</ParameterValueStruct>`
)
.join('\n ')
const body = `<cwmp:SetParameterValues>
<ParameterList soap-enc:arrayType="cwmp:ParameterValueStruct[${parameters.length}]">
${params}
</ParameterList>
<ParameterKey>papaye-v2</ParameterKey>
</cwmp:SetParameterValues>`
return buildSoapEnvelope(cwmpId, body)
}
static download(
cwmpId: string | undefined,
params: {
commandKey?: string
fileType?: string
url: string
username?: string
password?: string
fileSize?: number
targetFileName?: string
delaySeconds?: number
successUrl?: string
failureUrl?: string
}
): string {
const body = `<cwmp:Download>
<CommandKey>${params.commandKey || ''}</CommandKey>
<FileType>${params.fileType || '1 Firmware Upgrade Image'}</FileType>
<URL>${params.url}</URL>
<Username>${params.username || ''}</Username>
<Password>${params.password || ''}</Password>
<FileSize>${params.fileSize || 0}</FileSize>
<TargetFileName>${params.targetFileName || ''}</TargetFileName>
<DelaySeconds>${params.delaySeconds || 0}</DelaySeconds>
<SuccessURL>${params.successUrl || ''}</SuccessURL>
<FailureURL>${params.failureUrl || ''}</FailureURL>
</cwmp:Download>`
return buildSoapEnvelope(cwmpId, body)
}
static upload(
cwmpId: string | undefined,
params: { commandKey?: string; fileType?: string; url: string; username?: string; password?: string; delaySeconds?: number }
): string {
const body = `<cwmp:Upload>
<CommandKey>${params.commandKey || ''}</CommandKey>
<FileType>${params.fileType || '3 Vendor Configuration File'}</FileType>
<URL>${params.url}</URL>
<Username>${params.username || ''}</Username>
<Password>${params.password || ''}</Password>
<DelaySeconds>${params.delaySeconds || 0}</DelaySeconds>
</cwmp:Upload>`
return buildSoapEnvelope(cwmpId, body)
}
static reboot(cwmpId?: string, commandKey?: string): string {
const body = `<cwmp:Reboot>
<CommandKey>${commandKey || ''}</CommandKey>
</cwmp:Reboot>`
return buildSoapEnvelope(cwmpId, body)
}
static factoryReset(cwmpId?: string): string {
const body = `<cwmp:FactoryReset></cwmp:FactoryReset>`
return buildSoapEnvelope(cwmpId, body)
}
static scheduleInform(cwmpId: string | undefined, delaySeconds: number, commandKey?: string): string {
const body = `<cwmp:ScheduleInform>
<DelaySeconds>${delaySeconds}</DelaySeconds>
<CommandKey>${commandKey || ''}</CommandKey>
</cwmp:ScheduleInform>`
return buildSoapEnvelope(cwmpId, body)
}
static getParameterAttributes(cwmpId: string | undefined, parameterNames: string[]): string {
const names = parameterNames.map((n) => `<string>${n}</string>`).join('\n ')
const body = `<cwmp:GetParameterAttributes>
<ParameterNames soap-enc:arrayType="xsd:string[${parameterNames.length}]">
${names}
</ParameterNames>
</cwmp:GetParameterAttributes>`
return buildSoapEnvelope(cwmpId, body)
}
static setParameterAttributes(
cwmpId: string | undefined,
attributes: { name: string; notificationChange: boolean; notification: number; accessListChange: boolean; accessList: string[] }[]
): string {
const attrs = attributes
.map(
(a) => `<SetParameterAttributesStruct>
<Name>${a.name}</Name>
<NotificationChange>${a.notificationChange ? '1' : '0'}</NotificationChange>
<Notification>${a.notification}</Notification>
<AccessListChange>${a.accessListChange ? '1' : '0'}</AccessListChange>
<AccessList soap-enc:arrayType="xsd:string[${a.accessList.length}]">
${a.accessList.map((s) => `<string>${s}</string>`).join('')}
</AccessList>
</SetParameterAttributesStruct>`
)
.join('')
const body = `<cwmp:SetParameterAttributes>
<ParameterList soap-enc:arrayType="cwmp:SetParameterAttributesStruct[${attributes.length}]">
${attrs}
</ParameterList>
</cwmp:SetParameterAttributes>`
return buildSoapEnvelope(cwmpId, body)
}
static emptyResponse(): string {
return ''
}
}
+97
View File
@@ -0,0 +1,97 @@
import { XMLParser } from 'fast-xml-parser'
export const NAMESPACES = {
'soap-env': 'http://schemas.xmlsoap.org/soap/envelope/',
'soap-enc': 'http://schemas.xmlsoap.org/soap/encoding/',
xsd: 'http://www.w3.org/2001/XMLSchema',
xsi: 'http://www.w3.org/2001/XMLSchema-instance',
cwmp: 'urn:dslforum-org:cwmp-1-0',
}
export const CWMP_VERSIONS: Record<string, string> = {
'urn:dslforum-org:cwmp-1-0': '1.0',
'urn:dslforum-org:cwmp-1-1': '1.1',
'urn:dslforum-org:cwmp-1-2': '1.2',
'urn:dslforum-org:cwmp-1-3': '1.3',
'urn:dslforum-org:cwmp-1-4': '1.4',
}
const xmlParser = new XMLParser({
ignoreAttributes: false,
attributeNamePrefix: '$',
textNodeName: '_',
numberParseOptions: { leadingZeros: false, hex: false },
isArray: (name) => {
const arrayTags = ['ParameterValueStruct', 'ParameterInfoStruct', 'EventStruct', 'string']
return arrayTags.includes(name)
},
})
export interface SoapEnvelope {
header: SoapHeader
body: any
cwmpVersion: string
}
export interface SoapHeader {
id?: string
holdRequests?: boolean
noMoreRequests?: boolean
}
export interface CwmpMessage {
type: string
data: any
}
export function parseSoapXml(xml: string): SoapEnvelope {
const parsed = xmlParser.parse(xml)
const envelope = parsed['soap-env:Envelope'] || parsed['SOAP-ENV:Envelope'] || parsed['soapenv:Envelope'] || {}
const header = envelope['soap-env:Header'] || envelope['SOAP-ENV:Header'] || envelope['soapenv:Header'] || {}
const body = envelope['soap-env:Body'] || envelope['SOAP-ENV:Body'] || envelope['soapenv:Body'] || {}
// Detect CWMP version from namespace
let cwmpVersion = '1.0'
for (const [, value] of Object.entries(envelope)) {
if (typeof value === 'string' && value.startsWith('urn:dslforum-org:cwmp-')) {
cwmpVersion = CWMP_VERSIONS[value] || '1.0'
break
}
}
const soapHeader: SoapHeader = {}
const idNode = header['cwmp:ID'] || header['cwmp:Id']
if (idNode) {
soapHeader.id = typeof idNode === 'string' ? idNode : idNode._ || idNode
}
return { header: soapHeader, body, cwmpVersion }
}
export function extractCwmpMessage(body: any): CwmpMessage | null {
for (const key of Object.keys(body)) {
if (key.startsWith('cwmp:')) {
const type = key.replace('cwmp:', '')
return { type, data: body[key] }
}
}
return null
}
export function buildSoapEnvelope(cwmpId: string | undefined, bodyContent: string): string {
const id = cwmpId || Math.random().toString(36).substring(2, 10)
return `<?xml version="1.0" encoding="UTF-8"?>
<soap-env:Envelope
xmlns:soap-env="http://schemas.xmlsoap.org/soap/envelope/"
xmlns:soap-enc="http://schemas.xmlsoap.org/soap/encoding/"
xmlns:xsd="http://www.w3.org/2001/XMLSchema"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xmlns:cwmp="urn:dslforum-org:cwmp-1-0">
<soap-env:Header>
<cwmp:ID soap-env:mustUnderstand="1">${id}</cwmp:ID>
</soap-env:Header>
<soap-env:Body>
${bodyContent}
</soap-env:Body>
</soap-env:Envelope>`
}
+58
View File
@@ -0,0 +1,58 @@
import CommandModel from '#models/command'
import { CommandRecord, CommandStatus, CommandType } from '../domain/commands/command.js'
import { ModeType } from '../domain/shared/mode.js'
import { Priority } from '../domain/commands/command.js'
export default class CommandService {
async getCommands() {
const collection = CommandModel.getCollection()
return collection.find({}).sort({ createdAt: -1 }).toArray()
}
async getCommandsByDevice(deviceIdentifier: string) {
return CommandModel.findByDevice(deviceIdentifier)
}
async createCommand(data: {
deviceIdentifier: string
action: CommandType
parameters: Record<string, any>
mode?: ModeType
priority?: number
}): Promise<CommandRecord> {
return CommandModel.createCommand({
deviceIdentifier: data.deviceIdentifier,
action: data.action,
parameters: data.parameters,
mode: data.mode || ModeType.NI,
status: CommandStatus.PENDING,
priority: data.priority ?? Priority.NORMAL,
createdAt: new Date(),
updatedAt: new Date(),
})
}
async getICommandsForDeviceWithStatus(deviceIdentifier: string, status: CommandStatus): Promise<CommandRecord[]> {
const collection = CommandModel.getCollection()
return collection
.find({ deviceIdentifier, mode: ModeType.I, status })
.sort({ priority: 1 })
.toArray() as unknown as Promise<CommandRecord[]>
}
async getNICommandsForDeviceWithStatus(deviceIdentifier: string, status: CommandStatus): Promise<CommandRecord[]> {
const collection = CommandModel.getCollection()
return collection
.find({ deviceIdentifier, mode: ModeType.NI, status })
.sort({ priority: 1 })
.toArray() as unknown as Promise<CommandRecord[]>
}
async updateCommandStatus(id: any, update: Partial<CommandRecord>): Promise<void> {
await CommandModel.updateCommand(id, update)
}
async deleteCommand(id: string): Promise<boolean> {
return CommandModel.deleteById(id)
}
}
+408
View File
@@ -0,0 +1,408 @@
import ConfigurationModel from '#models/configuration'
import DeviceModel from '#models/device'
import FirmwareCatalogService from './firmware_catalog_service.js'
import type { Configuration, ParameterCriteria, ParameterValueType } from '../domain/configuration/configuration.js'
import type { Device } from '../domain/device/device.js'
import type { MigrationStep } from '../domain/firmware/migration_step.js'
import { isWithinSchedule } from '../utils/schedule_window.js'
import { matchGlob } from '../utils/glob_match.js'
import { computeContentHash, parseProvisioningCode } from '../utils/provisioning_code.js'
/**
* Result of a single device configuration evaluation.
* Contains all the actions to be executed by the state machine.
*/
export interface EngineResult {
configuration: Configuration | null
inventoryPaths: string[]
parameterDiffs: ParameterDiff[]
firmwareMigrationPath: MigrationStep[]
isAlreadyCompliant: boolean
skipFirmware: boolean // true if outside schedule window
}
/**
* A single parameter difference (desired vs current).
*/
export interface ParameterDiff {
name: string
currentValue: string | number | boolean | null
targetValue: string | number | boolean
type: ParameterValueType
}
/**
* Result of a dry-run evaluation.
*/
export interface DryRunResult {
configurationId: string
deviceIdentifier: string
evaluation: EngineResult
timestamp: Date
}
/**
* ConfigurationEngine evaluates device compliance against configurations.
*
* Responsibilities:
* - Find matching enabled configurations for a device
* - Select the highest priority configuration
* - Compute parameter differences (desired vs current)
* - Handle policy "once" via ProvisioningCode comparison
* - Resolve firmware migration paths
* - Check schedule windows for firmware operations
*/
export default class ConfigurationEngine {
private firmwareService: FirmwareCatalogService
constructor() {
this.firmwareService = new FirmwareCatalogService()
}
/**
* Evaluate device compliance against all matching configurations.
*
* Steps:
* 1. Load all enabled configurations
* 2. Filter by criteria (glob matching against DeviceId)
* 3. Select highest priority configuration
* 4. Check if already compliant via ProvisioningCode (policy "once")
* 5. Compute parameter differences
* 6. Resolve firmware migration path
* 7. Check schedule window for firmware operations
*/
async evaluate(device: Device): Promise<EngineResult> {
// 1. Load all enabled configurations
const allConfigs = await ConfigurationModel.find({ enabled: true })
// 2. Filter by criteria (glob matching against DeviceId)
const matchingConfigs = allConfigs.filter((config) => this.matchesCriteria(device, config))
// 3. Select highest priority configuration
let selectedConfig: Configuration | null = null
if (matchingConfigs.length > 0) {
selectedConfig = matchingConfigs.reduce((prev, curr) =>
curr.priority > prev.priority ? curr : prev
)
}
// If no matching configuration, device is already compliant (nothing to do)
if (!selectedConfig) {
return {
configuration: null,
inventoryPaths: [],
parameterDiffs: [],
firmwareMigrationPath: [],
isAlreadyCompliant: true,
skipFirmware: false,
}
}
// 4. Check if already compliant via ProvisioningCode (policy "once")
if (!this.shouldApplyOnce(device, selectedConfig)) {
return {
configuration: selectedConfig,
inventoryPaths: [],
parameterDiffs: [],
firmwareMigrationPath: [],
isAlreadyCompliant: true,
skipFirmware: false,
}
}
// 5. Compute parameter differences
const parameterDiffs = await this.computeParameterDiffs(device, selectedConfig)
// 6. Resolve firmware migration path
let firmwareMigrationPath: MigrationStep[] = []
let skipFirmware = false
if (selectedConfig.firmwareTarget) {
// Check schedule window for firmware operations
if (!isWithinSchedule(selectedConfig.schedule)) {
skipFirmware = true
} else {
// Get current device version (from properties or parameter)
const currentVersion = this.getDeviceVersion(device, selectedConfig.firmwareTarget.productClass)
if (currentVersion && currentVersion !== selectedConfig.firmwareTarget.targetVersion) {
firmwareMigrationPath = await this.firmwareService.resolveUpgradePath(
selectedConfig.firmwareTarget.productClass,
currentVersion,
selectedConfig.firmwareTarget.targetVersion
)
}
}
}
// Check if compliant (no diffs and no firmware needed)
const isCompliant =
parameterDiffs.length === 0 &&
firmwareMigrationPath.length === 0
return {
configuration: selectedConfig,
inventoryPaths: selectedConfig.inventory.map((inv) => inv.path),
parameterDiffs,
firmwareMigrationPath,
isAlreadyCompliant: isCompliant,
skipFirmware,
}
}
/**
* Dry-run evaluation for a specific configuration on a device.
* Similar to evaluate() but can be called explicitly by the user.
*/
async dryRun(configId: string, deviceIdentifier: string): Promise<DryRunResult> {
const configuration = await ConfigurationModel.findOne({ identifier: configId })
if (!configuration) {
throw new Error(`Configuration ${configId} not found`)
}
const device = await DeviceModel.findOne({
'deviceId.id': deviceIdentifier,
})
if (!device) {
throw new Error(`Device ${deviceIdentifier} not found`)
}
// Check if configuration matches device criteria
if (!this.matchesCriteria(device, configuration)) {
throw new Error(`Configuration ${configId} does not match device ${deviceIdentifier}`)
}
const evaluation = await this.evaluateSpecific(device, configuration)
return {
configurationId: configId,
deviceIdentifier,
evaluation,
timestamp: new Date(),
}
}
/**
* Evaluate a specific configuration against a device.
* Used by dry-run and potentially other flows.
*/
private async evaluateSpecific(device: Device, config: Configuration): Promise<EngineResult> {
// Check policy "once"
if (!this.shouldApplyOnce(device, config)) {
return {
configuration: config,
inventoryPaths: [],
parameterDiffs: [],
firmwareMigrationPath: [],
isAlreadyCompliant: true,
skipFirmware: false,
}
}
// Compute parameter differences
const parameterDiffs = await this.computeParameterDiffs(device, config)
// Resolve firmware migration path
let firmwareMigrationPath: MigrationStep[] = []
let skipFirmware = false
if (config.firmwareTarget) {
if (!isWithinSchedule(config.schedule)) {
skipFirmware = true
} else {
const currentVersion = this.getDeviceVersion(device, config.firmwareTarget.productClass)
if (currentVersion && currentVersion !== config.firmwareTarget.targetVersion) {
firmwareMigrationPath = await this.firmwareService.resolveUpgradePath(
config.firmwareTarget.productClass,
currentVersion,
config.firmwareTarget.targetVersion
)
}
}
}
const isCompliant = parameterDiffs.length === 0 && firmwareMigrationPath.length === 0
return {
configuration: config,
inventoryPaths: config.inventory.map((inv) => inv.path),
parameterDiffs,
firmwareMigrationPath,
isAlreadyCompliant: isCompliant,
skipFirmware,
}
}
/**
* Check if a device matches all criteria of a configuration.
* Uses glob matching for each criterion.
*/
private matchesCriteria(device: Device, config: Configuration): boolean {
const criteria = config.criteria
// manufacturer
if (criteria.manufacturer) {
const deviceValue = device.deviceId.manufacturer || ''
if (!matchGlob(criteria.manufacturer, deviceValue)) {
return false
}
}
// oui
if (criteria.oui) {
const deviceValue = device.deviceId.oui || ''
if (!matchGlob(criteria.oui, deviceValue)) {
return false
}
}
// productClass
if (criteria.productClass) {
const deviceValue = device.deviceId.productClass || ''
if (!matchGlob(criteria.productClass, deviceValue)) {
return false
}
}
// serialNumber
if (criteria.serialNumber) {
const deviceValue = device.deviceId.serialNumber || ''
if (!matchGlob(criteria.serialNumber, deviceValue)) {
return false
}
}
// parameterCriteria: all conditions must match a known device parameter value
if (Array.isArray(criteria.parameterCriteria) && criteria.parameterCriteria.length > 0) {
const allParams = [...(device as any).getParameters?.() ?? [], ...(device as any).getProperties?.() ?? []]
for (const pc of criteria.parameterCriteria as ParameterCriteria[]) {
const known = allParams.find((p: any) => p.name === pc.name)
if (known === undefined || known.value === null || known.value === undefined) {
return false
}
if (String(known.value) !== String(pc.value)) {
return false
}
}
}
return true
}
/**
* Determine if policy "once" should allow application of the configuration.
*
* - For policy "maintain": always apply
* - For policy "once": apply if device doesn't have the provisioning code
* (indicating content has changed or never applied)
*/
private shouldApplyOnce(device: Device, config: Configuration): boolean {
if (config.policy === 'maintain') {
return true
}
if (config.policy !== 'once') {
return true
}
// For "once" policy, check if device has matching provisioning code
const deviceProvCode = (device as any).provisioningCode
if (!deviceProvCode) {
return true // No provisioning code yet, apply it
}
// Parse the provisioning code
const parsed = parseProvisioningCode(deviceProvCode)
if (!parsed) {
return true // Invalid format, re-apply
}
// Check if identifiers match and content hasn't changed
if (parsed.identifier !== config.identifier) {
return true // Different configuration, apply
}
const currentHash = computeContentHash(config)
if (parsed.hash !== currentHash) {
return true // Content changed, re-apply
}
// Same configuration, same content hash => already applied
return false
}
/**
* Compute parameter differences between desired and current state.
*/
private async computeParameterDiffs(
device: Device,
config: Configuration
): Promise<ParameterDiff[]> {
const diffs: ParameterDiff[] = []
for (const desired of config.desiredParameters) {
// Get current value from device
const currentValue = this.getDeviceParameterValue(device, desired.name)
// Compare values
if (currentValue !== desired.value) {
diffs.push({
name: desired.name,
currentValue,
targetValue: desired.value,
type: desired.type,
})
}
}
return diffs
}
/**
* Get the current value of a parameter from the device.
* Returns null if not found.
*/
private getDeviceParameterValue(device: Device, paramName: string): string | number | boolean | null {
const param = (device as any).getParameter?.(paramName)
if (!param) {
return null
}
const value = param.value
if (value === undefined || value === null) {
return null
}
// Type coercion based on stored value type
if (typeof value === 'boolean') {
return value
}
if (typeof value === 'number') {
return value
}
return String(value)
}
/**
* Get the device's current firmware version.
* Looks in device properties or parameters (e.g., "Device.DeviceInfo.SoftwareVersion")
*/
private getDeviceVersion(device: Device, productClass: string): string | null {
// Try common firmware version parameters
const versionParams = [
'Device.DeviceInfo.SoftwareVersion',
'InternetGatewayDevice.DeviceInfo.SoftwareVersion',
`Device.${productClass}.FirmwareVersion`,
]
for (const paramName of versionParams) {
const value = this.getDeviceParameterValue(device, paramName)
if (value && typeof value === 'string') {
return value
}
}
return null
}
}
+541
View File
@@ -0,0 +1,541 @@
import ConfigurationModel from '#models/configuration'
import ConfigurationVersionModel from '#models/configuration_version'
import {
type Configuration,
type ConfigurationCriteria,
type DesiredParameter,
type InventoryPath,
PolicyType,
} from '../domain/configuration/configuration.js'
import type { ConfigurationVersion } from '../domain/configuration/configuration_version.js'
import { isParameterCoveredByInventory, suggestInventoryPath } from '../utils/inventory_coverage.js'
import { matchGlob } from '../utils/glob_match.js'
/**
* Allowed criteria fields — DeviceId attributes (immutable) + parameterCriteria (evaluated dynamically).
*/
const ALLOWED_CRITERIA_FIELDS: ReadonlySet<string> = new Set([
'manufacturer',
'oui',
'productClass',
'serialNumber',
'parameterCriteria',
])
const IDENTIFIER_REGEX = /^[A-Za-z0-9][A-Za-z0-9_-]*$/
const SEMVER_REGEX = /^\d+\.\d+\.\d+$/
export interface ConfigurationFilters {
page?: number
perPage?: number
tag?: string
enabled?: boolean
productClass?: string
}
export interface CreateConfigurationDto {
identifier: string
name: string
version: string
priority: number
policy: PolicyType | string
enabled?: boolean
tags?: string[]
criteria: ConfigurationCriteria
inventory: InventoryPath[]
desiredParameters: DesiredParameter[]
firmwareTarget?: { productClass: string; targetVersion: string }
schedule?: {
daysOfWeek: number[]
startHour: number
endHour: number
timezone: string
}
}
export type UpdateConfigurationDto = Partial<Omit<CreateConfigurationDto, 'identifier'>>
export interface OverlapWarning {
type: 'overlap_different_priority'
overlappingConfigurations: Array<{ identifier: string; priority: number }>
}
export type OverlapResult = OverlapWarning | null
export class ValidationError extends Error {
public readonly field: string
public readonly statusCode: number
constructor(message: string, field: string = '', statusCode: number = 422) {
super(message)
this.name = 'ValidationError'
this.field = field
this.statusCode = statusCode
}
}
export default class ConfigurationService {
/**
* Create a new Configuration after validation.
*/
async create(data: CreateConfigurationDto): Promise<Configuration & { warning?: OverlapWarning }> {
this.validateIdentifier(data.identifier)
this.validateVersion(data.version)
this.validatePriority(data.priority)
this.validatePolicy(data.policy)
this.validateCriteria(data.criteria)
this.validateParameterCriteriaDeadlock(data.criteria, data.desiredParameters)
this.validateInventoryCoverage(data.desiredParameters, data.inventory)
// Detect overlap — throws on same-priority conflict, returns warning for different-priority
const overlapResult = await this.detectOverlap(data.criteria, data.priority)
const configuration: Partial<Configuration> = {
identifier: data.identifier,
name: data.name,
version: data.version,
priority: data.priority,
policy: data.policy as PolicyType,
enabled: data.enabled ?? true,
tags: data.tags ?? [],
criteria: data.criteria,
inventory: data.inventory,
desiredParameters: data.desiredParameters,
firmwareTarget: data.firmwareTarget,
schedule: data.schedule,
}
const created = await ConfigurationModel.createIfNotExists(configuration)
if (overlapResult) {
return { ...created, warning: overlapResult }
}
return created
}
/**
* Return a paginated list of Configurations with optional filters.
*/
async findAll(filters: ConfigurationFilters = {}) {
const page = filters.page ?? 1
const perPage = filters.perPage ?? 25
const mongoFilter: Record<string, any> = {}
if (filters.tag) {
mongoFilter.tags = filters.tag
}
if (filters.enabled !== undefined) {
mongoFilter.enabled = filters.enabled
}
if (filters.productClass) {
mongoFilter['criteria.productClass'] = filters.productClass
}
return ConfigurationModel.paginateConfigurations(page, perPage, mongoFilter)
}
/**
* Find a Configuration by its unique identifier.
*/
async findById(identifier: string): Promise<Configuration | null> {
return ConfigurationModel.findByIdentifier(identifier)
}
/**
* Update an existing Configuration after validation.
* Creates a version snapshot of the current state before applying changes.
*/
async update(identifier: string, data: UpdateConfigurationDto): Promise<Configuration & { warning?: OverlapWarning }> {
const existing = await ConfigurationModel.findByIdentifier(identifier)
if (!existing) {
throw new ValidationError(`Configuration '${identifier}' not found`, 'identifier', 404)
}
if (data.version !== undefined) {
this.validateVersion(data.version)
}
if (data.priority !== undefined) {
this.validatePriority(data.priority)
}
if (data.policy !== undefined) {
this.validatePolicy(data.policy)
}
if (data.criteria !== undefined) {
this.validateCriteria(data.criteria)
}
// Validate inventory coverage with merged data
const finalDesiredParameters = data.desiredParameters ?? existing.desiredParameters
const finalInventory = data.inventory ?? existing.inventory
const finalCriteria2 = data.criteria ?? existing.criteria
this.validateParameterCriteriaDeadlock(finalCriteria2, finalDesiredParameters)
this.validateInventoryCoverage(finalDesiredParameters, finalInventory)
// Detect overlap if criteria or priority changed
let overlapResult: OverlapResult = null
const finalCriteria = data.criteria ?? existing.criteria
const finalPriority = data.priority ?? existing.priority
if (data.criteria !== undefined || data.priority !== undefined) {
overlapResult = await this.detectOverlap(finalCriteria, finalPriority, identifier)
}
// Create a version snapshot BEFORE applying changes
const latestVersionNumber = await ConfigurationVersionModel.getLatestVersionNumber(identifier)
const versionSnapshot: Omit<ConfigurationVersion, '_id'> = {
configurationIdentifier: identifier,
versionNumber: latestVersionNumber + 1,
snapshot: existing,
modifiedAt: new Date(),
}
await ConfigurationVersionModel.create(versionSnapshot)
// Build update payload (only provided fields)
const updatePayload: Record<string, any> = {}
if (data.name !== undefined) updatePayload.name = data.name
if (data.version !== undefined) updatePayload.version = data.version
if (data.priority !== undefined) updatePayload.priority = data.priority
if (data.policy !== undefined) updatePayload.policy = data.policy
if (data.enabled !== undefined) updatePayload.enabled = data.enabled
if (data.tags !== undefined) updatePayload.tags = data.tags
if (data.criteria !== undefined) updatePayload.criteria = data.criteria
if (data.inventory !== undefined) updatePayload.inventory = data.inventory
if (data.desiredParameters !== undefined) updatePayload.desiredParameters = data.desiredParameters
if (data.firmwareTarget !== undefined) updatePayload.firmwareTarget = data.firmwareTarget
if (data.schedule !== undefined) updatePayload.schedule = data.schedule
await ConfigurationModel.updateOne({ identifier }, updatePayload)
const updated = await ConfigurationModel.findByIdentifier(identifier)
if (overlapResult) {
return { ...updated!, warning: overlapResult }
}
return updated!
}
/**
* Get the version history of a Configuration with pagination.
* Returns versions in reverse chronological order.
*/
async getHistory(
identifier: string,
page: number = 1,
limit: number = 25
): Promise<{ data: ConfigurationVersion[]; total: number; page: number; perPage: number; lastPage: number }> {
const existing = await ConfigurationModel.findByIdentifier(identifier)
if (!existing) {
throw new ValidationError(`Configuration '${identifier}' not found`, 'identifier', 404)
}
return ConfigurationVersionModel.paginateVersions(identifier, page, limit)
}
/**
* Toggle the enabled flag of a Configuration without modifying other fields.
*/
async toggleEnabled(identifier: string, enabled: boolean): Promise<Configuration> {
const existing = await ConfigurationModel.findByIdentifier(identifier)
if (!existing) {
throw new ValidationError(`Configuration '${identifier}' not found`, 'identifier', 404)
}
await ConfigurationModel.updateOne({ identifier }, { enabled })
const updated = await ConfigurationModel.findByIdentifier(identifier)
return updated!
}
/**
* Add and/or remove tags from a Configuration without modifying other fields.
*/
async updateTags(
identifier: string,
add?: string[],
remove?: string[]
): Promise<Configuration> {
const existing = await ConfigurationModel.findByIdentifier(identifier)
if (!existing) {
throw new ValidationError(`Configuration '${identifier}' not found`, 'identifier', 404)
}
let tags = [...existing.tags]
// Add tags (avoid duplicates)
if (add && add.length > 0) {
for (const tag of add) {
if (!tags.includes(tag)) {
tags.push(tag)
}
}
}
// Remove tags
if (remove && remove.length > 0) {
tags = tags.filter((tag) => !remove.includes(tag))
}
await ConfigurationModel.updateOne({ identifier }, { tags })
const updated = await ConfigurationModel.findByIdentifier(identifier)
return updated!
}
/**
* Delete a Configuration and all its associated versions.
*/
async delete(identifier: string): Promise<boolean> {
const deleted = await ConfigurationModel.deleteByIdentifier(identifier)
if (deleted) {
await ConfigurationVersionModel.deleteByConfigurationIdentifier(identifier)
}
return deleted
}
// ─── Overlap Detection ─────────────────────────────────────────────────────
/**
* Detect overlap between a Configuration's criteria and existing enabled Configurations.
*
* Two configurations overlap if their criteria could match the same device.
* - If both define the same field, check if patterns could match overlapping values
* - If one doesn't define a field, it matches all values → potential overlap
*
* @param criteria - The criteria to check for overlap
* @param priority - The priority of the configuration being created/updated
* @param excludeId - Optional identifier to exclude (for updates)
* @returns OverlapWarning if different-priority overlap found, null if no overlap
* @throws ValidationError with 409 if same-priority overlap found
*/
async detectOverlap(
criteria: ConfigurationCriteria,
priority: number,
excludeId?: string
): Promise<OverlapResult> {
const allEnabled = await ConfigurationModel.findAllEnabled()
const samePriorityConflicts: Array<{ identifier: string; priority: number }> = []
const differentPriorityOverlaps: Array<{ identifier: string; priority: number }> = []
for (const existing of allEnabled) {
// Skip the configuration being updated
if (excludeId && existing.identifier === excludeId) {
continue
}
if (this.criteriaOverlap(criteria, existing.criteria)) {
if (existing.priority === priority) {
samePriorityConflicts.push({
identifier: existing.identifier,
priority: existing.priority,
})
} else {
differentPriorityOverlaps.push({
identifier: existing.identifier,
priority: existing.priority,
})
}
}
}
// Same-priority overlap → reject with 409
if (samePriorityConflicts.length > 0) {
const identifiers = samePriorityConflicts.map((c) => c.identifier).join(', ')
throw new ValidationError(
`Priority conflict: configurations with the same priority ${priority} have overlapping criteria: ${identifiers}`,
'priority',
409
)
}
// Different-priority overlap → return warning
if (differentPriorityOverlaps.length > 0) {
return {
type: 'overlap_different_priority',
overlappingConfigurations: differentPriorityOverlaps,
}
}
return null
}
/**
* Check if two sets of criteria could potentially match the same device.
*
* Conservative approach:
* - If one criteria doesn't define a field, it matches ALL values → potential overlap
* - If both define the same field, check if patterns could overlap using glob matching
*/
private criteriaOverlap(
criteriaA: ConfigurationCriteria,
criteriaB: ConfigurationCriteria
): boolean {
const fields: Array<keyof ConfigurationCriteria> = [
'manufacturer',
'oui',
'productClass',
'serialNumber',
]
for (const field of fields) {
const patternA = criteriaA[field] as string | undefined
const patternB = criteriaB[field] as string | undefined
// If either doesn't define the field, it matches all → potential overlap on this field
if (!patternA || !patternB) {
continue
}
// Both define the field — check if patterns could match overlapping values
if (!this.patternsCouldOverlap(patternA, patternB)) {
return false
}
}
// All fields are potentially overlapping
return true
}
/**
* Determine if two glob patterns could match overlapping sets of values.
*
* Conservative approach: if one pattern matches the other (or vice versa),
* they could overlap. Also, if both are identical, they definitely overlap.
* For complex cases, we assume potential overlap (conservative).
*/
private patternsCouldOverlap(patternA: string, patternB: string): boolean {
// Identical patterns always overlap
if (patternA === patternB) {
return true
}
// Check if one pattern could match a value that the other also matches.
// Simple heuristic: try matching each pattern against the other as a literal value.
// If patternA matches patternB (as a value) or vice versa, they could overlap.
if (matchGlob(patternA, patternB) || matchGlob(patternB, patternA)) {
return true
}
// If either contains wildcards, be conservative and check for prefix overlap
const hasWildcardA = patternA.includes('*') || patternA.includes('?')
const hasWildcardB = patternB.includes('*') || patternB.includes('?')
if (hasWildcardA || hasWildcardB) {
// Extract the fixed prefix before the first wildcard
const prefixA = patternA.split(/[*?]/)[0]
const prefixB = patternB.split(/[*?]/)[0]
// If one prefix starts with the other, patterns could overlap
if (prefixA.startsWith(prefixB) || prefixB.startsWith(prefixA)) {
return true
}
}
// No overlap detected
return false
}
// ─── Validation Methods ──────────────────────────────────────────────────────
private validateIdentifier(identifier: string): void {
if (!identifier || !IDENTIFIER_REGEX.test(identifier)) {
throw new ValidationError(
`Invalid identifier '${identifier}'. Must start with alphanumeric and contain only alphanumeric, hyphens, or underscores.`,
'identifier'
)
}
}
private validateVersion(version: string): void {
if (!version || !SEMVER_REGEX.test(version)) {
throw new ValidationError(
`Invalid version '${version}'. Must be a valid semver format (e.g. "1.0.0").`,
'version'
)
}
}
private validatePriority(priority: number): void {
if (!Number.isInteger(priority) || priority <= 0) {
throw new ValidationError(
`Invalid priority '${priority}'. Must be a positive integer greater than 0.`,
'priority'
)
}
}
private validatePolicy(policy: string): void {
if (policy !== PolicyType.ONCE && policy !== PolicyType.MAINTAIN) {
throw new ValidationError(
`Invalid policy '${policy}'. Must be "once" or "maintain".`,
'policy'
)
}
}
private validateCriteria(criteria: ConfigurationCriteria): void {
const fields = Object.keys(criteria)
for (const field of fields) {
if (!ALLOWED_CRITERIA_FIELDS.has(field)) {
throw new ValidationError(
`Invalid criteria field '${field}'. Only DeviceId fields are allowed: ${[...ALLOWED_CRITERIA_FIELDS].join(', ')}.`,
'criteria'
)
}
}
}
/**
* Warn if a parameterCriteria path is also in desiredParameters — this creates a risk of
* the config never matching (deadlock) if the device doesn't already have that parameter value.
* We throw a ValidationError to inform the caller of this dangerous overlap.
*/
private validateParameterCriteriaDeadlock(
criteria: ConfigurationCriteria,
desiredParameters: DesiredParameter[]
): void {
const pcNames = (criteria.parameterCriteria || []).map((p) => p.name)
const dpNames = new Set((desiredParameters || []).map((p) => p.name))
const conflicts = pcNames.filter((name) => dpNames.has(name))
if (conflicts.length > 0) {
throw new ValidationError(
`Deadlock risk: parameter(s) [${conflicts.join(', ')}] appear in both criteria.parameterCriteria and desiredParameters. ` +
`A device that does not yet have these values will never match, and the config will never apply.`,
'criteria.parameterCriteria'
)
}
}
private validateInventoryCoverage(
desiredParameters: DesiredParameter[],
inventory: InventoryPath[]
): void {
if (!desiredParameters || desiredParameters.length === 0) {
return
}
const uncoveredParams: Array<{ name: string; suggestedPath: string }> = []
for (const param of desiredParameters) {
if (!isParameterCoveredByInventory(param.name, inventory)) {
uncoveredParams.push({
name: param.name,
suggestedPath: suggestInventoryPath(param.name),
})
}
}
if (uncoveredParams.length > 0) {
const details = uncoveredParams
.map((p) => `"${p.name}" (suggest adding "${p.suggestedPath}" to inventory)`)
.join(', ')
throw new ValidationError(
`Desired parameters not covered by inventory: ${details}`,
'desiredParameters'
)
}
}
}
@@ -0,0 +1,62 @@
import { Device } from '../../domain/device/device.js'
import { DeviceActionExecutor } from './device_action_executor.js'
import logger from '@adonisjs/core/services/logger'
/**
* Action types
*/
export enum ActionType {
SET_PARAMETER_VALUES = 'SET_PARAMETER_VALUES',
SET_PARAMETER_ATTRIBUTES = 'SET_PARAMETER_ATTRIBUTES',
SCHEDULE_INFORM = 'SCHEDULE_INFORM',
DOWNLOAD = 'DOWNLOAD',
UPLOAD = 'UPLOAD',
FACTORY_RESET = 'FACTORY_RESET',
REBOOT = 'REBOOT',
}
/**
* Dispatches an action to the corresponding DeviceActionExecutor method
*/
export async function executeAction(
deviceAction: DeviceActionExecutor,
device: Device,
actionType: ActionType,
parameters: any[],
callback: ((result: unknown) => Promise<string>) = () => Promise.resolve('')
): Promise<string> {
switch (actionType) {
case ActionType.SET_PARAMETER_VALUES:
return deviceAction.applySetParameterValues(device, parameters as any[], callback as (r: boolean) => Promise<string>)
case ActionType.SET_PARAMETER_ATTRIBUTES:
return deviceAction.applySetParameterAttributes(device, parameters as any[], callback as (r: boolean) => Promise<string>)
case ActionType.SCHEDULE_INFORM:
return deviceAction.applyScheduleInform(device, parameters[0] as any, callback as (r: boolean) => Promise<string>)
case ActionType.DOWNLOAD: {
const p = parameters[0]
return deviceAction.applyDownload(device, p, callback as (r: boolean) => Promise<string>)
}
case ActionType.UPLOAD: {
const p = parameters[0]
return deviceAction.applyUpload(device, p, callback as (r: boolean) => Promise<string>)
}
case ActionType.FACTORY_RESET: {
const p = parameters?.[0]
return deviceAction.applyFactoryReset(device, p, callback as (r: boolean) => Promise<string>)
}
case ActionType.REBOOT: {
const p = parameters[0]
return deviceAction.applyReboot(device, p, callback as (r: boolean) => Promise<string>)
}
default:
logger.error(`Unknown action type: ${actionType}`)
return ''
}
}
@@ -0,0 +1,269 @@
import ConfigurationEngine from '../configuration_engine.js'
import type { EngineResult } from '../configuration_engine.js'
import type { DeviceStateResult, DeviceContext } from './main_state_machine.js'
import DeviceModel from '#models/device'
import logger from '@adonisjs/core/services/logger'
import { ComplianceStatus } from '../../domain/configuration/compliance.js'
import { buildProvisioningCode } from '../../utils/provisioning_code.js'
/**
* ConfigurationEvaluationState
*
* Stateful state that drives the full configuration evaluation flow:
* 1. On first handle(): calls ConfigurationEngine.evaluate(), sets compliance to "in_progress"
* 2. Processes inventory reads one path at a time (GetParameterNames RPC)
* 3. Processes all parameter diffs in one SetParameterValues RPC
* 4. If firmware needed, transitions to FirmwareUpgradeState
* 5. Otherwise, transitions to ComplianceUpdateState
*/
export class ConfigurationEvaluationState {
name = 'configuration-evaluation'
private engine: ConfigurationEngine
private engineResult: EngineResult | null = null
private inventoryQueue: string[] = []
private pendingValuesQueue: string[] = []
private paramDiffs: Array<{ name: string; value: string | number | boolean; type?: string }> = []
private initialized = false
constructor() {
this.engine = new ConfigurationEngine()
}
async handle(context: DeviceContext): Promise<DeviceStateResult> {
try {
if (!this.initialized) {
this.initialized = true
this.engineResult = await this.engine.evaluate(context.device)
logger.debug(
{
identifier: context.device.identifier,
configId: this.engineResult.configuration?.identifier,
inventoryPaths: this.engineResult.inventoryPaths.length,
paramDiffs: this.engineResult.parameterDiffs.length,
firmwareSteps: this.engineResult.firmwareMigrationPath.length,
isAlreadyCompliant: this.engineResult.isAlreadyCompliant,
},
'[ConfigEval] Evaluation complete'
)
await DeviceModel.updateOne(
{ identifier: context.device.identifier },
{
'compliance.status': ComplianceStatus.IN_PROGRESS,
'compliance.lastEvaluatedAt': new Date(),
'compliance.configurationIdentifier': this.engineResult.configuration?.identifier || null,
}
)
// Always populate inventory queue (discovery happens regardless of compliance)
this.inventoryQueue = [...this.engineResult.inventoryPaths]
if (!this.engineResult.isAlreadyCompliant) {
this.paramDiffs = this.engineResult.parameterDiffs.map((d) => ({
name: d.name,
value: d.targetValue,
type: d.type,
}))
}
}
return this.processNextAction(context)
} catch (error: any) {
logger.error({ identifier: context.device.identifier, error: error.message }, '[ConfigEval] Evaluation failed')
await DeviceModel.updateOne(
{ identifier: context.device.identifier },
{
'compliance.status': ComplianceStatus.FAILED,
'compliance.lastError': error.message,
'compliance.lastEvaluatedAt': new Date(),
}
).catch(() => {})
return { isComplete: true }
}
}
private async processNextAction(context: DeviceContext): Promise<DeviceStateResult> {
// 1. Inventory phase 1: GetParameterNames per subtree path
if (this.inventoryQueue.length > 0) {
const path = this.inventoryQueue.shift()!
logger.debug({ identifier: context.device.identifier, path }, '[ConfigEval] Inventory GetParameterNames')
const response = await context.services.deviceAction.getParameterNames(
context.device,
path,
async (result) => {
// Collect leaf names into pendingValuesQueue for phase 2
const infoList = result.data?.ParameterList?.ParameterInfoStruct || []
const leafNames: string[] = infoList
.map((info: any) => info?.Name)
.filter((name: any): name is string => !!name && !name.endsWith('.'))
if (leafNames.length > 0) {
this.pendingValuesQueue.push(...leafNames)
}
return ''
}
)
return { response }
}
// 2. Inventory phase 2: GetParameterValues for discovered leaf names
if (this.pendingValuesQueue.length > 0) {
const leaves = this.pendingValuesQueue.splice(0)
logger.debug({ identifier: context.device.identifier, count: leaves.length }, '[ConfigEval] Inventory GetParameterValues')
const response = await context.services.deviceAction.getParameterValues(
context.device,
leaves,
() => Promise.resolve('')
)
return { response }
}
// 3. All parameter diffs in one SetParameterValues RPC
if (this.paramDiffs.length > 0) {
const diffs = this.paramDiffs.splice(0)
logger.debug({ identifier: context.device.identifier, count: diffs.length }, '[ConfigEval] Applying parameter diffs')
const params = diffs.map((d) => ({ parameterName: d.name, value: d.value, type: d.type }))
const response = await context.services.deviceAction.applySetParameterValues(
context.device,
params,
() => Promise.resolve('')
)
return { response }
}
// 4. Firmware upgrade (if needed and within schedule)
if (this.engineResult!.firmwareMigrationPath.length > 0 && !this.engineResult!.skipFirmware) {
logger.debug(
{ identifier: context.device.identifier, steps: this.engineResult!.firmwareMigrationPath.length },
'[ConfigEval] Transitioning to firmware upgrade'
)
return { nextState: new FirmwareUpgradeState(this.engineResult!.firmwareMigrationPath, 0, this.engineResult!) }
}
// 5. All done
return { nextState: new ComplianceUpdateState(this.engineResult!) }
}
}
/**
* FirmwareUpgradeState
*
* Sends a Download RPC for the current migration step.
* After the RPC, the session ends (device reboots and re-Informs for next step).
*/
export class FirmwareUpgradeState {
name = 'firmware-upgrade'
private migrationSteps: any[]
private currentStepIndex: number
private engineResult: EngineResult
constructor(migrationSteps: any[], currentStepIndex: number = 0, engineResult: EngineResult) {
this.migrationSteps = migrationSteps
this.currentStepIndex = currentStepIndex
this.engineResult = engineResult
}
async handle(context: DeviceContext): Promise<DeviceStateResult> {
const step = this.migrationSteps[this.currentStepIndex]
if (!step) {
return { nextState: new ComplianceUpdateState(this.engineResult) }
}
logger.debug(
{ identifier: context.device.identifier, step: this.currentStepIndex + 1, total: this.migrationSteps.length },
'[FirmwareUpgrade] Sending Download RPC'
)
try {
const response = await context.services.deviceAction.applyDownload(
context.device,
{
commandKey: `fw-upgrade-step${this.currentStepIndex}-${Date.now()}`,
fileType: '1 Firmware Upgrade Image',
url: step.downloadUrl,
fileSize: step.fileSize || 0,
targetFileName: '',
},
async () => ''
)
return { response }
} catch (error: any) {
logger.error({ identifier: context.device.identifier, error: error.message }, '[FirmwareUpgrade] Download RPC failed')
await DeviceModel.updateOne(
{ identifier: context.device.identifier },
{
'compliance.status': ComplianceStatus.FAILED,
'compliance.lastError': `Firmware upgrade failed: ${error.message}`,
'compliance.lastEvaluatedAt': new Date(),
}
).catch(() => {})
return { isComplete: true }
}
}
}
/**
* ComplianceUpdateState
*
* Finalizes device compliance:
* - Updates compliance status to "conforme" in DB
* - For policy "once": writes ProvisioningCode to device
* - Transitions to ExecutingCommandsState to process pending user commands
*/
export class ComplianceUpdateState {
name = 'compliance-update'
private engineResult: EngineResult
private started = false
constructor(engineResult: EngineResult) {
this.engineResult = engineResult
}
async handle(context: DeviceContext): Promise<DeviceStateResult> {
const { ExecutingCommandsState } = await import('./main_state_machine.js')
if (!this.started) {
this.started = true
const updateData: Record<string, any> = {
'compliance.status': ComplianceStatus.COMPLIANT,
'compliance.lastEvaluatedAt': new Date(),
'compliance.lastError': null,
}
if (this.engineResult.configuration) {
updateData['compliance.configurationIdentifier'] = this.engineResult.configuration.identifier
}
// Always build and write provisioning code to device (functional for "once", informative for "maintain")
if (this.engineResult.configuration) {
const provCode = buildProvisioningCode(this.engineResult.configuration)
updateData.provisioningCode = provCode
await DeviceModel.updateOne({ identifier: context.device.identifier }, updateData).catch(() => {})
context.device.tmpPC = provCode
logger.debug({ identifier: context.device.identifier, provCode, policy: this.engineResult.configuration.policy }, '[ComplianceUpdate] Writing provisioning code')
const response = await context.services.deviceAction.insertProvisioningCode(
context.device,
() => Promise.resolve('')
)
return { response }
}
await DeviceModel.updateOne({ identifier: context.device.identifier }, updateData).catch(() => {})
logger.debug({ identifier: context.device.identifier }, '[ComplianceUpdate] Compliance finalized (no configuration)')
return { nextState: new ExecutingCommandsState(true) }
}
// Second call (after provCode write response): execute user commands
return { nextState: new ExecutingCommandsState(true) }
}
}
+377
View File
@@ -0,0 +1,377 @@
import { Device } from '../../domain/device/device.js'
import { ParameterType } from '../../domain/shared/types.js'
import { CWMPTaskQueue, ResultStatus, TaskResult } from './task_queue.js'
import DeviceModel from '../../models/device.js'
import logger from '@adonisjs/core/services/logger'
// Parameter types (simplified from legacy provisioning domain)
interface SetParameterValue {
parameterName: string
value: string | number | boolean
type?: string
}
interface SetParameterAttribute {
name: string
notificationChange: boolean
notification: number
accessListChange: boolean
accessList: string[]
}
interface SetScheduleInform {
delayInSeconds: number
commandKey?: string
}
interface DownloadParameter {
commandKey: string
fileType: string
url: string
username?: string
password?: string
fileSize?: number
targetFileName?: string
delaySeconds?: number
successUrl?: string
failureUrl?: string
}
interface UploadParameter {
commandKey: string
fileType: string
url: string
username?: string
password?: string
delayInSeconds?: number
}
interface FactoryResetParameter {
commandKey?: string
}
interface RebootParameter {
commandKey?: string
}
/**
* Executes CWMP actions on devices via the TaskQueue
* Each action wraps a callback that persists state on success
*/
export class DeviceActionExecutor {
private taskQueue: CWMPTaskQueue
constructor(taskQueue: CWMPTaskQueue) {
this.taskQueue = taskQueue
}
private async persistDevice(device: Device): Promise<void> {
try {
const snapshot = device.toSnapshot()
await DeviceModel.createOrUpdate({
identifier: snapshot.identifier,
deviceId: snapshot.deviceId,
properties: snapshot.properties,
parameters: snapshot.parameters,
updatedAt: new Date(),
})
} catch (e) {
logger.error(`Failed to persist device: ${e}`)
}
}
async getParameterValues(
device: Device,
parameterPaths: string[],
callback: (result: TaskResult) => Promise<string>
): Promise<string> {
const wrappedCallback = async (result: TaskResult) => {
if (result.status === ResultStatus.Success) {
try {
const resp: any = result.data
const list = resp?.ParameterList?.ParameterValueStruct
if (Array.isArray(list) && list.length > 0) {
device.updateFromValues(list)
await this.persistDevice(device)
}
} catch (e) {
logger.error(`Error updating device from parameter values: ${e}`)
}
}
return await callback(result)
}
return this.taskQueue.addTask(device.deviceId, 'get_parameter_values', parameterPaths, wrappedCallback)
}
async getParameterAttributes(
device: Device,
parameterList: string[],
callback: (result: TaskResult) => Promise<string>
): Promise<string> {
const wrappedCallback = async (result: TaskResult) => {
if (result.status === ResultStatus.Success) {
try {
const resp: any = result.data
const list = resp?.ParameterList?.ParameterAttributeStruct
if (Array.isArray(list) && list.length > 0) {
for (const attr of list) {
const name: string | undefined = attr?.Name
if (!name) continue
const notification: number | undefined = typeof attr?.Notification === 'number' ? attr.Notification : undefined
const accessList: string[] | undefined = attr?.AccessList?.string
const param = device.getParameter(name)
if (param) {
param.updateAttributes?.(notification, accessList)
continue
}
const prop = device.getProperty(name)
if (prop) {
prop.updateAttributes?.(notification, accessList)
}
}
await this.persistDevice(device)
}
} catch (e) {
logger.error(`Error updating device from parameter attributes: ${e}`)
}
}
return await callback(result)
}
return this.taskQueue.addTask(device.deviceId, 'get_parameter_attributes', parameterList, wrappedCallback)
}
async getParameterNames(
device: Device,
parameterName: string,
callback: (result: TaskResult) => Promise<string>
): Promise<string> {
const wrappedCallback = async (result: TaskResult) => {
if (result.status === ResultStatus.Success) {
try {
device.organizeParameters(result.data as any, new Date())
await this.persistDevice(device)
} catch (e) {
logger.error(`Error organizing parameters: ${e}`)
}
}
return await callback(result)
}
return this.taskQueue.addTask(device.deviceId, 'get_parameter_names', parameterName, wrappedCallback)
}
async applySetParameterValues(
device: Device,
parameters: SetParameterValue[],
callback: (result: boolean) => Promise<string>
): Promise<string> {
const existingParameters = device.getParameters()
const inferType = (val: any): string => {
const t = typeof val
switch (t) {
case 'boolean': return 'xsd:boolean'
case 'number': return Number.isInteger(val) ? 'xsd:int' : 'xsd:string'
default: return 'xsd:string'
}
}
const cwmpTypeMap: Record<string, string> = {
string: 'xsd:string',
unsignedInt: 'xsd:unsignedInt',
int: 'xsd:int',
boolean: 'xsd:boolean',
dateTime: 'xsd:dateTime',
}
const pvStructs = parameters.map((p) => {
const existing = existingParameters.find((ep) => ep.name === p.parameterName)
// Priority: 1) explicit type from config, 2) known device type, 3) inferred from JS value
let xsdType: string
if (p.type && cwmpTypeMap[p.type]) {
xsdType = cwmpTypeMap[p.type]
} else if (existing?.type && existing.type !== ParameterType.UNKNOWN) {
xsdType = existing.type // already in xsd: format (e.g. 'xsd:string')
} else {
xsdType = inferType(p.value)
}
return {
Name: p.parameterName,
Value: { _: p.value, $: { Type: xsdType } },
}
})
const parameterKey = `SetPValues_${device.deviceId.serialNumber}_${Date.now()}`
return this.taskQueue.addTask(
device.deviceId,
'set_parameter_values',
{ parameterValueList: pvStructs, parameterKey },
async (result: TaskResult) => {
if (result.status === ResultStatus.Success) {
device.updateFromValues(pvStructs as any)
await this.persistDevice(device)
return await callback(true)
}
return await callback(false)
}
)
}
async applySetParameterAttributes(
device: Device,
parameters: SetParameterAttribute[],
callback: (result: boolean) => Promise<string>
): Promise<string> {
const attrs = parameters.map((p) => ({
name: p.name,
notificationChange: p.notificationChange ?? false,
notification: p.notification ?? 0,
accessListChange: p.accessListChange ?? false,
accessList: (p.accessList as any)?.string || [],
}))
return this.taskQueue.addTask(
device.deviceId,
'set_parameter_attributes',
attrs,
async (result: TaskResult) => {
if (result.status === ResultStatus.Success) {
return await callback(true)
}
return await callback(false)
}
)
}
async applyScheduleInform(
device: Device,
parameter: SetScheduleInform,
callback: (result: boolean) => Promise<string>
): Promise<string> {
const commandKey = parameter.commandKey || `ScheduleInform_${device.deviceId.serialNumber}_${Date.now()}`
return this.taskQueue.addTask(
device.deviceId,
'schedule_inform',
{ delayInSeconds: parameter.delayInSeconds, commandKey },
async (result: TaskResult) => {
return await callback(result.status === ResultStatus.Success)
}
)
}
async applyFactoryReset(
_device: Device,
_parameter: FactoryResetParameter,
callback: (result: boolean) => Promise<string>
): Promise<string> {
return this.taskQueue.addTask(
_device.deviceId,
'factory_reset',
{},
async (result: TaskResult) => {
return await callback(result.status === ResultStatus.Success)
}
)
}
async applyReboot(
device: Device,
parameter: RebootParameter,
callback: (result: boolean) => Promise<string>
): Promise<string> {
return this.taskQueue.addTask(
device.deviceId,
'reboot',
{ commandKey: parameter.commandKey || '' },
async (result: TaskResult) => {
return await callback(result.status === ResultStatus.Success)
}
)
}
async applyDownload(
device: Device,
parameter: DownloadParameter,
callback: (result: boolean) => Promise<string>
): Promise<string> {
return this.taskQueue.addTask(
device.deviceId,
'download',
{
commandKey: parameter.commandKey,
fileType: parameter.fileType,
url: parameter.url,
username: parameter.username,
password: parameter.password,
fileSize: parameter.fileSize,
targetFileName: parameter.targetFileName,
delaySeconds: parameter.delaySeconds,
successUrl: parameter.successUrl,
failureUrl: parameter.failureUrl,
},
async (result: TaskResult) => {
return await callback(result.status === ResultStatus.Success)
}
)
}
async applyUpload(
device: Device,
parameter: UploadParameter,
callback: (result: boolean) => Promise<string>
): Promise<string> {
return this.taskQueue.addTask(
device.deviceId,
'upload',
{
commandKey: parameter.commandKey,
fileType: parameter.fileType,
url: parameter.url,
username: parameter.username,
password: parameter.password,
delaySeconds: parameter.delayInSeconds,
},
async (result: TaskResult) => {
return await callback(result.status === ResultStatus.Success)
}
)
}
async insertProvisioningCode(
device: Device,
callback: (result: boolean) => Promise<string>
): Promise<string> {
const allParams = device.getParameters()
const allProps = device.getProperties()
const knownPC = [...allParams, ...allProps].find((p: any) => /(^|\.)ProvisioningCode$/.test(p?.name))
const hasDeviceTree = [...allParams, ...allProps].some((p: any) => typeof p?.name === 'string' && p.name.startsWith('Device.'))
const hasIgdTree = [...allParams, ...allProps].some((p: any) => typeof p?.name === 'string' && p.name.startsWith('InternetGatewayDevice.'))
const inferredPath = hasDeviceTree ? 'Device.DeviceInfo.ProvisioningCode' : 'InternetGatewayDevice.DeviceInfo.ProvisioningCode'
const provisioningPath = knownPC?.name || (hasIgdTree && !hasDeviceTree ? 'InternetGatewayDevice.DeviceInfo.ProvisioningCode' : inferredPath)
const parameterKey = `SetPValues_${device.deviceId.serialNumber}_${Date.now()}`
const pvStructs = [{ Name: provisioningPath, Value: { _: device.tmpPC, $: { Type: 'xsd:string' } } }]
return this.taskQueue.addTask(
device.deviceId,
'set_parameter_values',
{ parameterValueList: pvStructs, parameterKey },
async (result: TaskResult) => {
if (result.status === ResultStatus.Success) {
device.updateFromValues(pvStructs as any)
await this.persistDevice(device)
device.tmpPC = ''
return await callback(true)
}
return await callback(false)
}
)
}
}
@@ -0,0 +1,64 @@
import { Device } from '../../domain/device/device.js'
import { CWMPTaskQueue } from './task_queue.js'
import { DeviceActionExecutor } from './device_action_executor.js'
import { ExecutingCommandsState, DeviceContext, DeviceStateResult } from './main_state_machine.js'
import logger from '@adonisjs/core/services/logger'
/**
* State machine for "6 CONNECTION REQUEST" events.
* Only executes pending Immediate (I) commands - does NOT include NI commands.
*/
export class ImmediateCommandStateMachine {
private currentState: ExecutingCommandsState
private context: DeviceContext
constructor(device: Device, session: string, taskQueue: CWMPTaskQueue) {
// includeNI = false: only execute Immediate commands
this.currentState = new ExecutingCommandsState(false)
this.context = {
device,
session,
taskQueue,
services: {
deviceAction: new DeviceActionExecutor(taskQueue),
},
}
}
async start(): Promise<string> {
logger.info(`Starting immediate-command state machine for device ${this.context.device.deviceId?.serialNumber}`)
return this.execute()
}
async handleResponse(success: boolean, message: any): Promise<string> {
this.context.message = { success, data: message }
return this.execute()
}
getCurrentStateName(): string {
return this.currentState.name
}
isCompleted(): boolean {
return this.currentState.name === 'completed'
}
private async execute(): Promise<string> {
try {
const result: DeviceStateResult = await this.currentState.handle(this.context)
if (result.isComplete) {
return ''
}
if (result.response && result.response !== '') {
return result.response
}
return ''
} catch (error) {
logger.error(`Immediate-command state machine error: ${error}`)
return ''
}
}
}
+366
View File
@@ -0,0 +1,366 @@
import { Device } from '../../domain/device/device.js'
import { CWMPTaskQueue } from './task_queue.js'
import { DeviceActionExecutor } from './device_action_executor.js'
import { getDeviceHashIdentifier } from '../../utils/device_utils.js'
import { CommandStatus, CommandType } from '../../domain/commands/command.js'
import DeviceModel from '../../models/device.js'
import CommandModel from '../../models/command.js'
import { ModeType } from '../../domain/shared/mode.js'
import logger from '@adonisjs/core/services/logger'
import { ConfigurationEvaluationState } from './configuration_evaluation_state.js'
// ─── Interfaces ────────────────────────────────────────────────────────────────
export interface DeviceContext {
device: Device
session: string
message?: { success: boolean; data: any }
taskQueue: CWMPTaskQueue
services: {
deviceAction: DeviceActionExecutor
}
}
export interface DeviceStateResult {
nextState?: DeviceState
response?: string
isComplete?: boolean
}
// ─── Abstract State ────────────────────────────────────────────────────────────
abstract class DeviceState {
abstract name: string
abstract handle(context: DeviceContext): Promise<DeviceStateResult>
}
// ─── States ────────────────────────────────────────────────────────────────────
class IdleState extends DeviceState {
name = 'idle'
async handle(context: DeviceContext): Promise<DeviceStateResult> {
const parameterQueue = context.device.tmpParamValues.map((p: any) => p.Name).filter((n: any): n is string => !!n)
if (parameterQueue.length === 0) {
return { nextState: new ConfigurationEvaluationState(), response: '' }
}
return { nextState: new DiscoveringParametersState(parameterQueue), response: '' }
}
}
class DiscoveringParametersState extends DeviceState {
name = 'discovering-parameters'
private parameterQueue: string[]
private started = false
constructor(parameterQueue: string[]) {
super()
this.parameterQueue = parameterQueue
}
async handle(context: DeviceContext): Promise<DeviceStateResult> {
if (!this.started) {
this.started = true
if (this.parameterQueue.length === 0) {
return { nextState: new ConfigurationEvaluationState(), response: '' }
}
return { response: await this.processNext(context) }
}
// Process response and continue
if (this.parameterQueue.length > 0) {
return { response: await this.processNext(context) }
}
return { nextState: new ConfigurationEvaluationState(), response: '' }
}
private async processNext(context: DeviceContext): Promise<string> {
if (this.parameterQueue.length === 0) return ''
const current = this.parameterQueue.shift()!
return context.services.deviceAction.getParameterNames(context.device, current, () => Promise.resolve(''))
}
}
export class ExecutingCommandsState extends DeviceState {
name = 'executing-commands'
private commandsQueue: any[] = []
private started = false
private currentCommandRecord: any | null = null
private includeNI: boolean
constructor(includeNI: boolean = true) {
super()
this.includeNI = includeNI
}
async handle(context: DeviceContext): Promise<DeviceStateResult> {
if (!this.started) {
const hashId = getDeviceHashIdentifier({
manufacturer: context.device.deviceId.manufacturer,
oui: context.device.deviceId.oui,
productClass: context.device.deviceId.productClass,
serialNumber: context.device.deviceId.serialNumber,
})
const collection = CommandModel.getCollection()
const iWaiting = await collection.find({ deviceIdentifier: hashId, mode: ModeType.I, status: CommandStatus.WAITING }).sort({ priority: 1 }).toArray()
const iPending = await collection.find({ deviceIdentifier: hashId, mode: ModeType.I, status: CommandStatus.PENDING }).sort({ priority: 1 }).toArray()
const niPending = this.includeNI
? await collection.find({ deviceIdentifier: hashId, mode: ModeType.NI, status: CommandStatus.PENDING }).sort({ priority: 1 }).toArray()
: []
this.commandsQueue = [...iWaiting, ...iPending, ...niPending]
this.started = true
if (this.commandsQueue.length === 0) {
if (this.includeNI) {
return { nextState: new CompletedState(), isComplete: true, response: '' }
}
return { response: '' }
}
return { response: await this.processNextCommand(context) }
}
// Handle response for last command
if (this.currentCommandRecord) {
const now = new Date()
if (context.message?.success) {
this.currentCommandRecord.status = CommandStatus.COMPLETED
this.currentCommandRecord.completedAt = now
this.currentCommandRecord.executedAt = now
// Optimistic update for SPV
if (this.currentCommandRecord.action === CommandType.SET_PARAMETER_VALUES) {
const params = Array.isArray(this.currentCommandRecord.parameters) ? this.currentCommandRecord.parameters : []
const pvStructs = params.map((p: any) => ({
Name: p?.name,
Value: { _: p?.value, $: { Type: 'xsd:string' } },
}))
if (pvStructs.length > 0) {
context.device.updateFromValues(pvStructs)
}
}
} else {
this.currentCommandRecord.status = CommandStatus.FAILED
this.currentCommandRecord.executedAt = now
}
try {
await CommandModel.updateCommand(this.currentCommandRecord._id, {
status: this.currentCommandRecord.status,
executedAt: now,
completedAt: this.currentCommandRecord.completedAt,
updatedAt: now,
})
} catch (err) {
logger.error(`Failed to update command: ${err}`)
}
this.currentCommandRecord = null
}
if (this.commandsQueue.length > 0) {
return { response: await this.processNextCommand(context) }
}
// All commands done
if (this.includeNI) {
return { nextState: new CompletedState(), isComplete: true, response: '' }
}
return { response: '' }
}
private async processNextCommand(context: DeviceContext): Promise<string> {
if (this.commandsQueue.length === 0) return ''
const commandRecord = this.commandsQueue.shift()!
this.currentCommandRecord = commandRecord
// Mark as SENT
const now = new Date()
commandRecord.status = CommandStatus.SENT
commandRecord.executedAt = now
try {
await CommandModel.updateCommand(commandRecord._id, { status: CommandStatus.SENT, executedAt: now, updatedAt: now })
} catch (err) {
logger.error(`Failed to mark command as SENT: ${err}`)
}
// Clear pending tasks for this device before issuing command
try {
context.taskQueue.clearPendingTasksForDevice(context.device.deviceId)
} catch {}
const action = commandRecord.action as CommandType
const params = commandRecord.parameters
// Map CommandType to CWMP request
switch (action) {
case CommandType.GET_PARAMETER_VALUES: {
const names: string[] = Array.isArray(params) ? params : params?.names || params?.paths || params?.parameterNames || []
return context.services.deviceAction.getParameterValues(context.device, names, () => Promise.resolve(''))
}
case CommandType.GET_PARAMETER_NAMES: {
const name: string = typeof params === 'string' ? params : (params?.name || params?.path || params?.parameterPath || '')
return context.services.deviceAction.getParameterNames(context.device, name, () => Promise.resolve(''))
}
case CommandType.GET_PARAMETER_ATTRIBUTES: {
const names: string[] = Array.isArray(params) ? params : params?.names || params?.paths || params?.parameterNames || []
return context.services.deviceAction.getParameterAttributes(context.device, names, () => Promise.resolve(''))
}
case CommandType.SET_PARAMETER_VALUES: {
const rawParams = Array.isArray(params)
? params
: params?.parameterValues || params?.parameters || []
const spvParams = rawParams.map((p: any) => ({
parameterName: p.parameterName ?? p.name,
value: p.value,
type: p.type,
}))
return context.services.deviceAction.applySetParameterValues(context.device, spvParams, () => Promise.resolve(''))
}
case CommandType.SET_PARAMETER_ATTRIBUTES: {
const spaParams = Array.isArray(params) ? params : params?.attributes || params?.parameterAttributes || []
return context.services.deviceAction.applySetParameterAttributes(context.device, spaParams, () => Promise.resolve(''))
}
case CommandType.DOWNLOAD: {
return context.services.deviceAction.applyDownload(context.device, params || {}, () => Promise.resolve(''))
}
case CommandType.UPLOAD: {
return context.services.deviceAction.applyUpload(context.device, params || {}, () => Promise.resolve(''))
}
case CommandType.REBOOT: {
return context.services.deviceAction.applyReboot(context.device, params || {}, () => Promise.resolve(''))
}
case CommandType.FACTORY_RESET: {
return context.services.deviceAction.applyFactoryReset(context.device, {}, () => Promise.resolve(''))
}
case CommandType.SCHEDULE_INFORM: {
return context.services.deviceAction.applyScheduleInform(
context.device,
{ delayInSeconds: params?.delayInSeconds || 300, commandKey: params?.commandKey },
() => Promise.resolve('')
)
}
case CommandType.REFRESH: {
// Refresh = GetParameterValues on all known leaf parameters that have non-empty values
const allNames = [
...context.device.getParameters().filter((p) => p.value != null && p.value !== '').map((p) => p.name),
...context.device.getProperties().filter((p) => p.value != null && p.value !== '').map((p) => p.name),
].filter((name) => !!name && !name.endsWith('.'))
if (allNames.length > 0) {
return context.services.deviceAction.getParameterValues(context.device, allNames, () => Promise.resolve(''))
}
return ''
}
default:
logger.error(`Unknown command type: ${action}`)
// Mark as failed
commandRecord.status = CommandStatus.FAILED
await CommandModel.updateCommand(commandRecord._id, { status: CommandStatus.FAILED, updatedAt: new Date() })
this.currentCommandRecord = null
if (this.commandsQueue.length > 0) {
return this.processNextCommand(context)
}
return ''
}
}
}
class CompletedState extends DeviceState {
name = 'completed'
async handle(context: DeviceContext): Promise<DeviceStateResult> {
// Persist device on completion
try {
const snapshot = context.device.toSnapshot()
await DeviceModel.createOrUpdate({
identifier: snapshot.identifier,
deviceId: snapshot.deviceId,
properties: snapshot.properties,
parameters: snapshot.parameters,
updatedAt: new Date(),
})
} catch (e) {
logger.error(`Failed to persist device on completion: ${e}`)
}
return { isComplete: true, response: '' }
}
}
// ─── Main State Machine ────────────────────────────────────────────────────────
export class MainStateMachine {
private currentState: DeviceState
private context: DeviceContext
constructor(device: Device, session: string, taskQueue: CWMPTaskQueue) {
this.currentState = new IdleState()
this.context = {
device,
session,
taskQueue,
services: {
deviceAction: new DeviceActionExecutor(taskQueue),
},
}
}
async start(): Promise<string> {
return this.executeCurrentState()
}
async handleResponse(success: boolean, message: any): Promise<string> {
this.context.message = { success, data: message }
return this.executeCurrentState()
}
isCompleted(): boolean {
return this.currentState.name === 'completed'
}
getCurrentStateName(): string {
return this.currentState.name
}
private async executeCurrentState(): Promise<string> {
let safetyGuard = 0
while (safetyGuard++ < 15) {
try {
logger.debug({ state: this.currentState.name, iteration: safetyGuard }, '[StateMachine] Executing state')
const result = await this.currentState.handle(this.context)
if (result.nextState) {
logger.debug({ from: this.currentState.name, to: result.nextState.name }, '[StateMachine] State transition')
this.currentState = result.nextState
}
if (result.isComplete) {
logger.info({ finalState: this.currentState.name }, '[StateMachine] Completed')
return ''
}
if (result.response && result.response !== '') {
logger.debug({ state: this.currentState.name, responseLength: result.response.length }, '[StateMachine] Sending response to CPE')
return result.response
}
if (!result.nextState) {
return ''
}
} catch (error) {
logger.error(`Error in state ${this.currentState.name}: ${error}`)
// On error, try to complete gracefully
this.currentState = new CompletedState()
await this.currentState.handle(this.context)
return ''
}
}
logger.warn('Main state machine safety guard reached')
return ''
}
}
+383
View File
@@ -0,0 +1,383 @@
import { EventEmitter } from 'node:events'
import { Device } from '../../domain/device/device.js'
import { DeviceId } from '../../domain/device/device_id.js'
import { CWMPTaskQueue, ResultStatus } from './task_queue.js'
import { MainStateMachine } from './main_state_machine.js'
import { ImmediateCommandStateMachine } from './immediate_command_state_machine.js'
import { getDeviceHashIdentifier } from '../../utils/device_utils.js'
import { CommandStatus } from '../../domain/commands/command.js'
import { ModeType } from '../../domain/shared/mode.js'
import CommandModel from '../../models/command.js'
import logger from '@adonisjs/core/services/logger'
export enum MachineStatus {
CREATED = 'created',
RUNNING = 'running',
COMPLETED = 'completed',
FAILED = 'failed',
}
export interface StateMachineInstance {
id: string
deviceId: DeviceId
session: string
stateMachine: MainStateMachine | ImmediateCommandStateMachine
status: MachineStatus
createdAt: Date
completedAt?: Date
currentState: string
lastTaskId?: string
events?: string[]
}
/**
* Central state machine manager with TCP-socket-based session tracking,
* device preemption, and CWMP task queue integration.
*/
export class StateMachineManager extends EventEmitter {
private taskQueue: CWMPTaskQueue
private machines: Map<string, StateMachineInstance> = new Map()
private deviceSessions: Map<string, string> = new Map() // deviceKey -> sessionId
private sessionMachines: Map<string, string> = new Map() // sessionId -> machineId
private remoteToDevice: Map<string, string> = new Map() // remoteKey -> deviceKey
private cleanupInterval: ReturnType<typeof setInterval> | null = null
private static instance: StateMachineManager | null = null
constructor() {
super()
this.taskQueue = new CWMPTaskQueue()
this.setupTaskQueueListeners()
}
static getInstance(): StateMachineManager {
if (!StateMachineManager.instance) {
StateMachineManager.instance = new StateMachineManager()
}
return StateMachineManager.instance
}
getTaskQueue(): CWMPTaskQueue {
return this.taskQueue
}
startCleanupTimer(intervalMs: number = 60000): void {
if (this.cleanupInterval) return
this.cleanupInterval = setInterval(() => this.cleanupCompletedMachines(), intervalMs)
}
stopCleanupTimer(): void {
if (this.cleanupInterval) {
clearInterval(this.cleanupInterval)
this.cleanupInterval = null
}
}
/**
* Start a state machine for a device session
*/
async startStateMachine(
device: Device,
session: string,
opts?: { events?: string[] }
): Promise<StateMachineInstance> {
const deviceKey = JSON.stringify(device.deviceId)
const evts = opts?.events || []
const lastEvent = this.normalizeEvent(evts.length > 0 ? evts[evts.length - 1] : '')
// Check for NI commands that force MainStateMachine
let forceMainForNI = false
try {
const hashId = getDeviceHashIdentifier({
manufacturer: device.deviceId.manufacturer,
oui: device.deviceId.oui,
productClass: device.deviceId.productClass,
serialNumber: device.deviceId.serialNumber,
})
const collection = CommandModel.getCollection()
const niCount = await collection.countDocuments({
deviceIdentifier: hashId,
mode: ModeType.NI,
status: { $in: [CommandStatus.PENDING, CommandStatus.WAITING] },
})
forceMainForNI = niCount > 0
} catch (e) {
logger.warn(`Failed to check NI commands: ${e}`)
}
// Handle preemption of existing machine
const existingSessionId = this.deviceSessions.get(deviceKey)
if (existingSessionId) {
const existingMachineId = this.sessionMachines.get(existingSessionId)
if (existingMachineId) {
const existingMachine = this.machines.get(existingMachineId)
if (existingMachine && existingMachine.status === MachineStatus.RUNNING) {
const isImmediate = existingMachine.stateMachine instanceof ImmediateCommandStateMachine
const onboardingEvent = ['0 BOOTSTRAP', '1 BOOT', '2 PERIODIC', '3 SCHEDULED'].includes(lastEvent)
const shouldPreempt = isImmediate && (forceMainForNI || onboardingEvent)
if (shouldPreempt) {
// Rebind session if changed
if (existingSessionId !== session) {
this.sessionMachines.delete(existingSessionId)
this.sessionMachines.set(session, existingMachineId)
this.deviceSessions.set(deviceKey, session)
existingMachine.session = session
}
// Replace with MainStateMachine
const newMachine = new MainStateMachine(device, session, this.taskQueue)
;(existingMachine as any).stateMachine = newMachine
existingMachine.events = evts
await newMachine.start()
existingMachine.currentState = newMachine.getCurrentStateName()
if (newMachine.isCompleted()) this.completeMachine(existingMachine)
return existingMachine
}
// Rebind session to existing machine
if (existingSessionId !== session) {
this.sessionMachines.delete(existingSessionId)
this.sessionMachines.set(session, existingMachineId)
this.deviceSessions.set(deviceKey, session)
existingMachine.session = session
}
return existingMachine
}
}
}
// Create new machine
const machineId = `${deviceKey}_${session}_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`
let stateMachine: MainStateMachine | ImmediateCommandStateMachine
if (forceMainForNI) {
stateMachine = new MainStateMachine(device, session, this.taskQueue)
} else if (lastEvent === '6 CONNECTION REQUEST') {
stateMachine = new ImmediateCommandStateMachine(device, session, this.taskQueue)
} else if (['0 BOOTSTRAP', '1 BOOT', '2 PERIODIC', '3 SCHEDULED'].includes(lastEvent) || !lastEvent) {
stateMachine = new MainStateMachine(device, session, this.taskQueue)
} else {
// Unknown event - use MainStateMachine as default
stateMachine = new MainStateMachine(device, session, this.taskQueue)
}
const machineInstance: StateMachineInstance = {
id: machineId,
deviceId: device.deviceId,
session,
stateMachine,
status: MachineStatus.CREATED,
createdAt: new Date(),
currentState: stateMachine.getCurrentStateName(),
events: evts,
}
this.machines.set(machineId, machineInstance)
this.deviceSessions.set(deviceKey, session)
this.sessionMachines.set(session, machineId)
// Start the machine
try {
machineInstance.status = MachineStatus.RUNNING
await stateMachine.start()
machineInstance.currentState = stateMachine.getCurrentStateName()
if (stateMachine.isCompleted()) {
this.completeMachine(machineInstance)
}
} catch (error) {
machineInstance.status = MachineStatus.FAILED
machineInstance.completedAt = new Date()
logger.error(`Failed to start state machine: ${error}`)
}
return machineInstance
}
/**
* Bind a remote endpoint to a device for session rebinding
*/
bindRemoteToDevice(remoteKey: string, deviceId: DeviceId): void {
this.remoteToDevice.set(remoteKey, JSON.stringify(deviceId))
}
/**
* Ensure session is bound when TCP connection changes
*/
ensureSessionForRemote(remoteKey: string, newSession: string): boolean {
const deviceKey = this.remoteToDevice.get(remoteKey)
if (!deviceKey) return false
const existingSession = this.deviceSessions.get(deviceKey)
if (!existingSession) return false
const machineId = this.sessionMachines.get(existingSession)
if (!machineId) return false
const machine = this.machines.get(machineId)
if (!machine) return false
if (existingSession !== newSession) {
this.sessionMachines.delete(existingSession)
this.sessionMachines.set(newSession, machineId)
this.deviceSessions.set(deviceKey, newSession)
machine.session = newSession
}
return true
}
/**
* Continue when CPE sends empty body
*/
async continue(session: string): Promise<string> {
const machineId = this.sessionMachines.get(session)
if (!machineId) return ''
const machineInstance = this.machines.get(machineId)
if (!machineInstance || machineInstance.status !== MachineStatus.RUNNING) return ''
try {
// Serve queued tasks first
const pendingTask = this.taskQueue.getTaskForDevice(machineInstance.deviceId)
if (pendingTask) {
const builder = CWMPTaskQueue.task_map[pendingTask.task]
if (typeof builder === 'function') {
const envelope = builder(pendingTask.props)
if (pendingTask.id) machineInstance.lastTaskId = pendingTask.id
return envelope
}
}
// If a task is in-flight, wait
if (machineInstance.lastTaskId) return ''
// Let the state machine continue
const response = await machineInstance.stateMachine.handleResponse(true, { isFirst: false })
machineInstance.currentState = machineInstance.stateMachine.getCurrentStateName()
if (machineInstance.stateMachine.isCompleted()) {
this.completeMachine(machineInstance)
return ''
}
return response
} catch (error) {
logger.error(`Error continuing session ${session}: ${error}`)
this.failMachine(machineInstance, String(error))
return ''
}
}
/**
* Handle a CWMP response message
*/
async handleCWMPResponse(session: string, success: boolean, message: any): Promise<string> {
const machineId = this.sessionMachines.get(session)
if (!machineId) return ''
const machineInstance = this.machines.get(machineId)
if (!machineInstance || machineInstance.status !== MachineStatus.RUNNING) return ''
try {
const responseTaskId = machineInstance.lastTaskId
const response = await machineInstance.stateMachine.handleResponse(success, message)
// Execute task callback
if (responseTaskId) {
if (success) {
this.taskQueue.completeTask(responseTaskId, message)
} else {
this.taskQueue.failTask(responseTaskId, JSON.stringify(message))
}
try {
await this.taskQueue.executeTaskCallback(
responseTaskId,
success ? ResultStatus.Success : ResultStatus.Error,
message
)
} catch (cbErr) {
logger.error(`Task callback error: ${cbErr}`)
}
if (machineInstance.lastTaskId === responseTaskId) {
machineInstance.lastTaskId = undefined
}
}
machineInstance.currentState = machineInstance.stateMachine.getCurrentStateName()
if (machineInstance.stateMachine.isCompleted()) {
this.completeMachine(machineInstance)
return ''
}
return response
} catch (error) {
logger.error(`Error handling CWMP response: ${error}`)
this.failMachine(machineInstance, String(error))
return ''
}
}
hasSession(session: string): boolean {
return this.sessionMachines.has(session)
}
/**
* Enqueue an immediate task for a device (e.g., after Inform)
*/
async enqueueImmediate(device: Device, type: string, payload: any): Promise<void> {
this.taskQueue.addCWMPTask(device.deviceId, type, payload)
}
private completeMachine(machine: StateMachineInstance): void {
machine.status = MachineStatus.COMPLETED
machine.completedAt = new Date()
const deviceKey = JSON.stringify(machine.deviceId)
this.deviceSessions.delete(deviceKey)
this.sessionMachines.delete(machine.session)
}
private failMachine(machine: StateMachineInstance, error: string): void {
machine.status = MachineStatus.FAILED
machine.completedAt = new Date()
const deviceKey = JSON.stringify(machine.deviceId)
this.deviceSessions.delete(deviceKey)
this.sessionMachines.delete(machine.session)
logger.error(`State machine failed: ${error}`)
}
private normalizeEvent(evt: string): string {
return (evt || '').toString().trim().replace(/\s+/g, ' ').toUpperCase()
}
cleanupCompletedMachines(olderThanHours: number = 1): void {
const cutoff = new Date(Date.now() - olderThanHours * 60 * 60 * 1000)
for (const [id, machine] of this.machines.entries()) {
if ((machine.status === MachineStatus.COMPLETED || machine.status === MachineStatus.FAILED) &&
machine.completedAt && machine.completedAt < cutoff) {
this.machines.delete(id)
}
}
}
async shutdown(): Promise<void> {
this.stopCleanupTimer()
this.cleanupCompletedMachines(0)
}
private setupTaskQueueListeners(): void {
this.taskQueue.on('taskSent', (task: any) => {
// Find the machine for this device and set lastTaskId
const deviceKey = JSON.stringify(task.deviceId)
const sessionId = this.deviceSessions.get(deviceKey)
if (sessionId) {
const machineId = this.sessionMachines.get(sessionId)
if (machineId) {
const machine = this.machines.get(machineId)
if (machine) machine.lastTaskId = task.id
}
}
})
}
}
+250
View File
@@ -0,0 +1,250 @@
import { DeviceId } from '../../domain/device/device_id.js'
import { EventEmitter } from 'node:events'
import { CWMPMessageBuilder } from '../../protocol/common/cwmp_message_builder.js'
import logger from '@adonisjs/core/services/logger'
export class TaskResult {
status!: ResultStatus
data: any
timestamp!: Date
}
export enum ResultStatus {
Success = 'success',
Error = 'error',
}
export interface CWMPTask {
id: string
deviceId: DeviceId
type: string
payload: any
status: TaskStatus
createdAt: Date
completedAt?: Date
result?: any
error?: string
}
export enum TaskStatus {
PENDING = 'pending',
SENT = 'sent',
COMPLETED = 'completed',
FAILED = 'failed',
}
/**
* CWMP Task Queue with event-based lifecycle
* Maps tasks to CWMP message builders and manages callbacks
*/
export class CWMPTaskQueue extends EventEmitter {
static task_map: Record<string, (props: any) => string> = {
get_parameter_names: (parameterPath: string) => {
return CWMPMessageBuilder.getParameterNames(undefined, parameterPath, true)
},
get_parameter_attributes: (parameterNames: string[]) => {
return CWMPMessageBuilder.getParameterAttributes(undefined, parameterNames)
},
get_parameter_values: (parameterNames: string[]) => {
return CWMPMessageBuilder.getParameterValues(undefined, parameterNames)
},
set_parameter_values: (props: { parameterValueList: any[]; parameterKey?: string }) => {
const params = (props.parameterValueList || []).map((p: any) => ({
name: p.Name,
value: String(p.Value?._ ?? ''),
type: p.Value?.$?.Type || 'xsd:string',
}))
return CWMPMessageBuilder.setParameterValues(undefined, params)
},
set_parameter_attributes: (attrs: any[]) => {
const mapped = attrs.map((a: any) => ({
name: a.name || a.Name,
notificationChange: !!(a.notificationChange ?? a.NotificationChange),
notification: a.notification ?? a.Notification ?? 0,
accessListChange: !!(a.accessListChange ?? a.AccessListChange),
accessList: a.accessList ?? a.AccessList ?? [],
}))
return CWMPMessageBuilder.setParameterAttributes(undefined, mapped)
},
schedule_inform: (props: { delayInSeconds: number; commandKey?: string }) => {
return CWMPMessageBuilder.scheduleInform(undefined, props.delayInSeconds || 300, props.commandKey)
},
download: (props: any) => {
return CWMPMessageBuilder.download(undefined, props)
},
upload: (props: any) => {
return CWMPMessageBuilder.upload(undefined, props)
},
factory_reset: () => {
return CWMPMessageBuilder.factoryReset(undefined)
},
reboot: (props: any) => {
return CWMPMessageBuilder.reboot(undefined, props?.commandKey)
},
}
private tasks: Map<string, CWMPTask> = new Map()
private deviceQueues: Map<string, string[]> = new Map()
private taskCallbacks: Map<string, (result: TaskResult) => Promise<string>> = new Map()
/**
* Adds a task with callback (legacy-compatible API used by DeviceActionExecutor)
* Returns the CWMP XML envelope to send to the device
*/
addTask(
deviceId: DeviceId,
taskType: string,
props: any,
callback: (result: TaskResult) => Promise<string>
): string {
const cwmpTask = this.addCWMPTask(deviceId, taskType, props)
if (callback) {
this.taskCallbacks.set(cwmpTask.id, callback)
}
// Mark as SENT immediately so the manager correlates the response
if (cwmpTask.status === TaskStatus.PENDING) {
cwmpTask.status = TaskStatus.SENT
this.emit('taskSent', cwmpTask)
}
// Generate CWMP envelope
const builder = CWMPTaskQueue.task_map[taskType]
if (typeof builder !== 'function') {
logger.warn(`No CWMP builder found for task '${taskType}'`)
return ''
}
return builder(props)
}
/**
* Registers a callback for a task already in the queue (e.g. added via addCWMPTask)
*/
registerCallback(taskId: string, callback: (result: TaskResult) => Promise<string>): void {
this.taskCallbacks.set(taskId, callback)
}
/**
* Adds a CWMP task (internal)
*/
addCWMPTask(deviceId: DeviceId, type: string, payload: any): CWMPTask {
const taskId = this.generateTaskId(deviceId, type)
const task: CWMPTask = {
id: taskId,
deviceId,
type,
payload,
status: TaskStatus.PENDING,
createdAt: new Date(),
}
this.tasks.set(taskId, task)
const deviceKey = JSON.stringify(deviceId)
if (!this.deviceQueues.has(deviceKey)) {
this.deviceQueues.set(deviceKey, [])
}
this.deviceQueues.get(deviceKey)!.push(taskId)
this.emit('taskAdded', task)
return task
}
/**
* Retrieve and mark a pending task as SENT for a device
*/
getTaskForDevice(deviceId: DeviceId): { id: string; task: string; props: any } | undefined {
const deviceKey = JSON.stringify(deviceId)
const queue = this.deviceQueues.get(deviceKey)
if (!queue || queue.length === 0) return undefined
const taskId = queue[0]
const cwmpTask = this.tasks.get(taskId)
if (!cwmpTask) return undefined
// Dequeue
queue.shift()
if (cwmpTask.status === TaskStatus.PENDING) {
cwmpTask.status = TaskStatus.SENT
this.emit('taskSent', cwmpTask)
}
return { id: taskId, task: cwmpTask.type, props: cwmpTask.payload }
}
completeTask(taskId: string, result: any): void {
const task = this.tasks.get(taskId)
if (!task) return
task.status = TaskStatus.COMPLETED
task.result = result
task.completedAt = new Date()
this.removeTaskFromQueue(task.deviceId, taskId)
this.emit('taskCompleted', task)
}
failTask(taskId: string, error: string): void {
const task = this.tasks.get(taskId)
if (!task) return
task.status = TaskStatus.FAILED
task.error = error
task.completedAt = new Date()
this.removeTaskFromQueue(task.deviceId, taskId)
this.emit('taskFailed', task)
}
async executeTaskCallback(taskId: string, status: ResultStatus, data: any): Promise<string> {
const callback = this.taskCallbacks.get(taskId)
if (callback) {
const result = new TaskResult()
result.status = status
result.data = data
result.timestamp = new Date()
try {
const response = await callback(result)
this.taskCallbacks.delete(taskId)
return response
} catch (error) {
logger.error(`Error executing task callback: ${error}`)
this.taskCallbacks.delete(taskId)
return ''
}
}
return ''
}
clearPendingTasksForDevice(deviceId: DeviceId): void {
const deviceKey = JSON.stringify(deviceId)
const queue = this.deviceQueues.get(deviceKey) || []
const keep: string[] = []
for (const taskId of queue) {
const t = this.tasks.get(taskId)
if (t && t.status === TaskStatus.SENT) {
keep.push(taskId)
continue
}
this.tasks.delete(taskId)
this.taskCallbacks.delete(taskId)
}
this.deviceQueues.set(deviceKey, keep)
}
private generateTaskId(deviceId: DeviceId, type: string): string {
return `${JSON.stringify(deviceId)}_${type}_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`
}
private removeTaskFromQueue(deviceId: DeviceId, taskId: string): void {
const deviceKey = JSON.stringify(deviceId)
const queue = this.deviceQueues.get(deviceKey)
if (queue) {
const index = queue.indexOf(taskId)
if (index > -1) queue.splice(index, 1)
}
}
}
+197
View File
@@ -0,0 +1,197 @@
import DeviceModel from '#models/device'
import CommandModel from '#models/command'
import { DeviceRecord } from '../domain/device/device_record.js'
import { CommandStatus, CommandType, Priority } from '../domain/commands/command.js'
import { ModeType } from '../domain/shared/mode.js'
import logger from '@adonisjs/core/services/logger'
export interface PaginationData {
first?: { offset: number; limit: number }
previous?: { offset: number; limit: number }
next?: { offset: number; limit: number }
last?: { offset: number; limit: number }
}
export default class DeviceService {
async getDevices(offset: number = 0, limit: number = 100) {
const { data, total } = await DeviceModel.paginateDevices(offset, limit)
const pager = this.calculatePagination(offset, limit, total)
return { data, nbDocuments: total, pager }
}
async getDeviceByIdentifier(identifier: string): Promise<DeviceRecord | null> {
return DeviceModel.findByIdentifier(identifier)
}
async deleteByIdentifier(identifier: string): Promise<boolean> {
return DeviceModel.deleteOne({ identifier })
}
/**
* Execute a command on a device. Mode I triggers a Connection Request.
*/
async executeCommand(identifier: string, commandPayload: {
action: CommandType
parameters?: Record<string, any>
mode?: string
priority?: number
}): Promise<void> {
const device = await DeviceModel.findByIdentifier(identifier)
if (!device) {
throw new Error(`Device not found: ${identifier}`)
}
const mode = (commandPayload.mode || 'NI').toUpperCase() === 'I' ? ModeType.I : ModeType.NI
const priority = commandPayload.priority ?? Priority.NORMAL
// Check for duplicate pending command with same mode + priority + action
const collection = CommandModel.getCollection()
const existingDup = await collection.findOne({
deviceIdentifier: identifier,
mode,
status: CommandStatus.PENDING,
action: commandPayload.action,
priority,
})
if (existingDup) {
throw new Error(`Duplicate pending command: ${commandPayload.action} in mode ${mode}`)
}
// Create the command
await CommandModel.createCommand({
deviceIdentifier: identifier,
action: commandPayload.action,
parameters: commandPayload.parameters || {},
mode,
status: CommandStatus.PENDING,
priority,
createdAt: new Date(),
updatedAt: new Date(),
})
// For mode I, trigger Connection Request to wake the device
if (mode === ModeType.I) {
await this.applyImmediateCommandForDevice(device, identifier)
}
}
/**
* Refresh a device by sending a Connection Request for REFRESH command
*/
async refreshDevice(identifier: string): Promise<void> {
const device = await DeviceModel.findByIdentifier(identifier)
if (!device) {
throw new Error(`Device not found: ${identifier}`)
}
await CommandModel.createCommand({
deviceIdentifier: identifier,
action: CommandType.REFRESH,
parameters: {},
mode: ModeType.I,
status: CommandStatus.PENDING,
priority: Priority.NORMAL,
createdAt: new Date(),
updatedAt: new Date(),
})
await this.applyImmediateCommandForDevice(device, identifier)
}
/**
* Send Connection Request to CPE to wake it up for immediate command execution
*/
private async applyImmediateCommandForDevice(device: DeviceRecord, identifier: string): Promise<void> {
const connectionRequestParam = (device.properties || []).find((p: any) =>
p.name?.includes('ConnectionRequestURL')
)
if (!connectionRequestParam || !connectionRequestParam.value) {
logger.error({ identifier, availableProps: (device.properties || []).map((p: any) => p.name) }, 'ConnectionRequestURL not found in device properties')
return
}
let url: URL
try {
url = new URL(connectionRequestParam.value as string)
} catch {
logger.error(`Invalid ConnectionRequestURL: ${connectionRequestParam.value}`)
return
}
const wakeSuccess = await this.sendWakeOnWlan(url, device)
// Mark pending I commands as WAITING (or FAILED)
const collection = CommandModel.getCollection()
const pendingCmds = await collection.find({
deviceIdentifier: identifier,
mode: ModeType.I,
status: CommandStatus.PENDING,
}).toArray()
for (const cmd of pendingCmds) {
await collection.updateOne(
{ _id: cmd._id },
{ $set: { status: wakeSuccess ? CommandStatus.WAITING : CommandStatus.PENDING, updatedAt: new Date() } }
)
}
}
private async sendWakeOnWlan(connectionRequestURL: URL, device: DeviceRecord): Promise<boolean> {
try {
// Use ConnectionRequest credentials from device properties/parameters
// Fallback chain: ConnectionRequestUsername > ManagementServer.Username > OUI-ProductClass-Serial
const allParams = [...(device.properties || []), ...(device.parameters || [])]
const connReqUsername = allParams.find((p: any) => p.name?.includes('ConnectionRequestUsername'))
const connReqPassword = allParams.find((p: any) => p.name?.includes('ConnectionRequestPassword'))
const mgmtUsername = allParams.find((p: any) => p.name?.endsWith('ManagementServer.Username'))
const mgmtPassword = allParams.find((p: any) => p.name?.endsWith('ManagementServer.Password'))
// genieacs-sim uses ManagementServer.Username/Password for CR auth (non-standard)
// Real CPEs use ConnectionRequestUsername/Password
const username = mgmtUsername?.value || connReqUsername?.value || `${device.deviceId.oui}-${device.deviceId.productClass}-${device.deviceId.serialNumber}`
const password = mgmtPassword?.value ?? connReqPassword?.value ?? ''
logger.info({ url: connectionRequestURL.toString(), username }, 'Sending Connection Request')
const basicAuth = Buffer.from(`${username}:${password}`).toString('base64')
const response = await fetch(connectionRequestURL.toString(), {
method: 'GET',
headers: {
Authorization: `Basic ${basicAuth}`,
},
})
if (!response.ok) {
logger.error({ status: response.status, statusText: response.statusText, url: connectionRequestURL.toString() }, 'Connection Request failed with non-OK status')
return false
}
logger.info({ status: response.status, url: connectionRequestURL.toString() }, 'Connection Request successful')
return true
} catch (error: any) {
logger.error({ error: error?.message, code: error?.cause?.code, url: connectionRequestURL.toString() }, 'Connection Request network error')
return false
}
}
calculatePagination(offset: number, limit: number, total: number): PaginationData {
const pagination: PaginationData = { first: { offset: 0, limit } }
if (total >= limit) {
pagination.last = { offset: total - limit, limit }
} else {
pagination.last = { offset: 0, limit }
}
if (offset > 0) {
pagination.previous = { offset: Math.max(0, offset - limit), limit }
}
if (offset + limit < total) {
pagination.next = { offset: offset + limit, limit }
}
return pagination
}
}
+225
View File
@@ -0,0 +1,225 @@
import FirmwareEntryModel from '#models/firmware_entry'
import MigrationRouteModel from '#models/migration_route'
import { FirmwareEntry, MigrationRoute } from '../domain/firmware/firmware_catalog.js'
import { MigrationStep } from '../domain/firmware/migration_step.js'
import { matchGlob } from '../utils/glob_match.js'
import { BadRequestException } from '../exceptions/index.js'
export interface CreateFirmwareEntryDto {
identifier: string
productClass: string
version: string
downloadUrl: string
checksum: string
checksumType: 'md5' | 'sha256'
fileSize: number
}
export interface CreateMigrationRouteDto {
identifier: string
productClass: string
sourceVersionPattern: string
targetVersion: string
downloadUrl: string
priority: number
}
export default class FirmwareCatalogService {
/**
* Validate that a string is a valid URL.
*/
private validateUrl(url: string, fieldName: string): void {
try {
new URL(url)
} catch {
throw new BadRequestException(`${fieldName} must be a valid URL`)
}
}
/**
* Validate that a number is a positive integer (> 0).
*/
private validatePositiveInteger(value: number, fieldName: string): void {
if (!Number.isInteger(value) || value <= 0) {
throw new BadRequestException(`${fieldName} must be a positive integer`)
}
}
/**
* Validate that a string is non-empty.
*/
private validateNonEmptyString(value: string, fieldName: string): void {
if (!value || value.trim().length === 0) {
throw new BadRequestException(`${fieldName} must be a non-empty string`)
}
}
/**
* Validate checksumType is 'md5' or 'sha256'.
*/
private validateChecksumType(value: string, fieldName: string): void {
if (value !== 'md5' && value !== 'sha256') {
throw new BadRequestException(`${fieldName} must be 'md5' or 'sha256'`)
}
}
/**
* Create a new firmware entry after validation.
*/
async createEntry(data: CreateFirmwareEntryDto): Promise<FirmwareEntry> {
this.validateNonEmptyString(data.identifier, 'identifier')
this.validateUrl(data.downloadUrl, 'downloadUrl')
this.validatePositiveInteger(data.fileSize, 'fileSize')
this.validateChecksumType(data.checksumType, 'checksumType')
return FirmwareEntryModel.createIfNotExists({
identifier: data.identifier,
productClass: data.productClass,
version: data.version,
downloadUrl: data.downloadUrl,
checksum: data.checksum,
checksumType: data.checksumType,
fileSize: data.fileSize,
})
}
/**
* Find firmware entries, optionally filtered by productClass.
*/
async findEntries(productClass?: string): Promise<FirmwareEntry[]> {
if (productClass) {
return FirmwareEntryModel.findByProductClass(productClass)
}
return FirmwareEntryModel.find({}) as unknown as Promise<FirmwareEntry[]>
}
/**
* Delete a firmware entry by identifier.
*/
async deleteEntry(identifier: string): Promise<void> {
const deleted = await FirmwareEntryModel.deleteByIdentifier(identifier)
if (!deleted) {
throw new BadRequestException(`Firmware entry '${identifier}' not found`)
}
}
/**
* Create a new migration route after validation.
*/
async createMigrationRoute(data: CreateMigrationRouteDto): Promise<MigrationRoute> {
this.validateNonEmptyString(data.identifier, 'identifier')
this.validateUrl(data.downloadUrl, 'downloadUrl')
this.validatePositiveInteger(data.priority, 'priority')
return MigrationRouteModel.createIfNotExists({
identifier: data.identifier,
productClass: data.productClass,
sourceVersionPattern: data.sourceVersionPattern,
targetVersion: data.targetVersion,
downloadUrl: data.downloadUrl,
priority: data.priority,
})
}
async findAllRoutes(): Promise<MigrationRoute[]> {
return MigrationRouteModel.find({}) as unknown as Promise<MigrationRoute[]>
}
async updateEntry(identifier: string, data: Partial<CreateFirmwareEntryDto>): Promise<FirmwareEntry> {
const collection = FirmwareEntryModel.getCollection()
const result = await collection.findOneAndUpdate(
{ identifier },
{ $set: data },
{ returnDocument: 'after' }
)
if (!result) throw new BadRequestException(`Firmware entry '${identifier}' not found`)
return result as unknown as FirmwareEntry
}
/**
* Find migration routes for a productClass, sorted by priority descending.
*/
async findMigrationRoutes(productClass: string): Promise<MigrationRoute[]> {
return MigrationRouteModel.findByProductClass(productClass, { priority: -1 })
}
/**
* Delete a migration route by identifier.
*/
async deleteMigrationRoute(identifier: string): Promise<void> {
const deleted = await MigrationRouteModel.deleteByIdentifier(identifier)
if (!deleted) {
throw new BadRequestException(`Migration route '${identifier}' not found`)
}
}
/**
* Resolve the firmware upgrade path from currentVersion to targetVersion.
* Uses a greedy best-first algorithm based on route priority with cycle detection.
*
* @returns MigrationStep[] ordered from first to last, or [] if no path exists
*/
async resolveUpgradePath(
productClass: string,
currentVersion: string,
targetVersion: string
): Promise<MigrationStep[]> {
// Trivial case: already at target version
if (currentVersion === targetVersion) return []
// Load all routes for this productClass
const routes = await MigrationRouteModel.findByProductClass(productClass)
const path: MigrationStep[] = []
const visited = new Set<string>()
let version = currentVersion
visited.add(version)
while (version !== targetVersion) {
// Find all applicable routes from the current version
const applicableRoutes = routes
.filter((r) => matchGlob(r.sourceVersionPattern, version))
.sort((a, b) => b.priority - a.priority) // Highest priority first
// Take the best route (highest priority)
const bestRoute = applicableRoutes[0]
if (!bestRoute) {
// No route found → no path exists
return []
}
// Cycle detection
if (visited.has(bestRoute.targetVersion)) {
return []
}
// Add step to path
path.push({
fromVersion: version,
toVersion: bestRoute.targetVersion,
downloadUrl: bestRoute.downloadUrl,
fileSize: 0,
checksum: '',
})
// Advance
visited.add(bestRoute.targetVersion)
version = bestRoute.targetVersion
}
// Enrich each step with fileSize and checksum from firmware_entries
for (const step of path) {
const entry = await FirmwareEntryModel.findByProductClassAndVersion(
productClass,
step.toVersion
)
if (entry) {
step.fileSize = entry.fileSize
step.checksum = entry.checksum
}
}
return path
}
}
+297
View File
@@ -0,0 +1,297 @@
import ConfigurationService from './configuration_service.js'
import FirmwareCatalogService from './firmware_catalog_service.js'
import type { CreateConfigurationDto } from './configuration_service.js'
import type { CreateFirmwareEntryDto } from './firmware_catalog_service.js'
import { Db } from 'mongodb'
interface MigrationReport {
discoveryMigrated: number
discoverySkipped: number
provisioningMigrated: number
provisioningSkipped: number
firmwareEntriesCreated: number
errors: string[]
}
interface RollbackReport {
discoveryRolledBack: number
provisioningRolledBack: number
errors: string[]
}
export default class MigrationService {
constructor(
private db: Db,
private configService = new ConfigurationService(),
private firmwareCatalogService = new FirmwareCatalogService(),
) {}
/**
* Migrate all legacy Discovery and Provisioning configs to unified Configuration format
*/
async migrateAll(): Promise<MigrationReport> {
const report: MigrationReport = {
discoveryMigrated: 0,
discoverySkipped: 0,
provisioningMigrated: 0,
provisioningSkipped: 0,
firmwareEntriesCreated: 0,
errors: [],
}
try {
// Migrate Discovery configs (inventory + maintain policy)
const discoveryCollection = this.db.collection('discovery_configs')
const discoveryConfigs = await discoveryCollection.find({}).toArray()
for (const legacy of discoveryConfigs) {
try {
const migrated = await this.migrateDiscoveryConfig(legacy)
if (migrated) {
report.discoveryMigrated++
} else {
report.discoverySkipped++
}
} catch (error) {
report.discoverySkipped++
report.errors.push(`Discovery ${legacy.identifier}: ${(error as Error).message}`)
}
}
// Migrate Provisioning configs (desired parameters + once/maintain + firmware)
const provisioningCollection = this.db.collection('provisioning_configs')
const provisioningConfigs = await provisioningCollection.find({}).toArray()
for (const legacy of provisioningConfigs) {
try {
const migrated = await this.migrateProvisioningConfig(legacy)
if (migrated) {
report.provisioningMigrated++
} else {
report.provisioningSkipped++
}
// Extract firmware Downloads into FirmwareCatalog
if (legacy.downloadActions && legacy.downloadActions.length > 0) {
for (const action of legacy.downloadActions) {
try {
await this.extractFirmwareEntry(action, legacy)
report.firmwareEntriesCreated++
} catch (error) {
report.errors.push(`Firmware extraction for ${legacy.identifier}: ${(error as Error).message}`)
}
}
}
} catch (error) {
report.provisioningSkipped++
report.errors.push(`Provisioning ${legacy.identifier}: ${(error as Error).message}`)
}
}
} catch (error) {
report.errors.push(`Migration error: ${(error as Error).message}`)
}
return report
}
/**
* Migrate a single Discovery config to Configuration
* Discovery = inventory paths + "maintain" policy
*/
private async migrateDiscoveryConfig(legacy: any): Promise<boolean> {
const criteria = this.buildCriteriaFromLegacy(legacy)
const dto: CreateConfigurationDto = {
identifier: `legacy-disc-${legacy.identifier}`,
name: `[Migrated] ${legacy.name || legacy.identifier}`,
version: '1.0.0',
priority: 100,
policy: 'maintain',
enabled: true,
tags: ['legacy', 'discovery'],
criteria,
inventory: (legacy.modelPaths || []).map((p: string) => ({ path: p })),
desiredParameters: [],
}
// Check if already exists (skip if yes)
const existing = await this.configService.findById(dto.identifier)
if (existing) {
return false
}
await this.configService.create(dto)
return true
}
/**
* Migrate a single Provisioning config to Configuration
* Provisioning = desired parameters + (once OR maintain) policy + firmware migration
*/
private async migrateProvisioningConfig(legacy: any): Promise<boolean> {
const criteria = this.buildCriteriaFromLegacy(legacy)
const policy = legacy.downloadActions?.length > 0 ? 'once' : 'maintain'
const desiredParameters = this.buildDesiredParametersFromLegacy(legacy)
// Build inventory paths from desired parameters (auto-suggest)
const inventoryPaths = new Set<string>()
for (const param of desiredParameters) {
const parts = param.name.split('.')
parts.pop()
inventoryPaths.add(parts.join('.') + '.')
}
const dto: CreateConfigurationDto = {
identifier: `legacy-prov-${legacy.identifier}`,
name: `[Migrated] ${legacy.name || legacy.identifier}`,
version: '1.0.0',
priority: 100,
policy,
enabled: true,
tags: ['legacy', 'provisioning'],
criteria,
inventory: [...inventoryPaths].map((p) => ({ path: p })),
desiredParameters,
}
const existing = await this.configService.findById(dto.identifier)
if (existing) {
return false
}
await this.configService.create(dto)
return true
}
/**
* Build criteria object from legacy config device filters
*/
private buildCriteriaFromLegacy(legacy: any): Record<string, string> {
const criteria: Record<string, string> = {}
if (legacy.manufacturer) criteria.manufacturer = legacy.manufacturer
if (legacy.oui) criteria.oui = legacy.oui
if (legacy.productClass) criteria.productClass = legacy.productClass
if (legacy.serialNumber) criteria.serialNumber = legacy.serialNumber
return criteria
}
/**
* Build desired parameters from legacy Set Parameter actions
*/
private buildDesiredParametersFromLegacy(legacy: any): Array<{ name: string; value: string | number | boolean; type: 'string' | 'int' | 'unsignedInt' | 'boolean' | 'dateTime' }> {
const params: Array<{ name: string; value: string | number | boolean; type: 'string' | 'int' | 'unsignedInt' | 'boolean' | 'dateTime' }> = []
if (!legacy.actions) return params
for (const action of legacy.actions) {
if (action.type === 'SET_PARAMETER' && action.parameter && action.value !== undefined) {
params.push({
name: action.parameter,
value: action.value,
type: typeof action.value === 'boolean' ? 'boolean' : typeof action.value === 'number' ? 'int' : 'string',
})
}
}
return params
}
/**
* Extract firmware Download action into FirmwareCatalog entry
*/
private async extractFirmwareEntry(action: any, legacy: any): Promise<void> {
const entry: CreateFirmwareEntryDto = {
identifier: `legacy-fw-${legacy.productClass || 'device'}-${action.version || '1.0.0'}`,
productClass: legacy.productClass || 'Device',
version: action.version || '1.0.0',
downloadUrl: action.url || action.firmwareUrl || '',
checksum: action.checksum || '',
checksumType: 'md5',
fileSize: action.fileSize || 0,
}
try {
await this.firmwareCatalogService.createEntry(entry)
} catch {
// Entry may already exist — skip silently
}
}
/**
* Rollback migration: reconstruct legacy collections from prefixed configurations
*/
async rollback(): Promise<RollbackReport> {
const report: RollbackReport = {
discoveryRolledBack: 0,
provisioningRolledBack: 0,
errors: [],
}
try {
const result = await this.configService.findAll({ perPage: 1000 })
const allConfigs = result.data
const discoveryCollection = this.db.collection('discovery_configs')
const provisioningCollection = this.db.collection('provisioning_configs')
for (const config of allConfigs) {
try {
if (config.identifier.startsWith('legacy-disc-')) {
const originalId = config.identifier.replace('legacy-disc-', '')
const legacy = this.buildLegacyDiscoveryFromConfig(config, originalId)
await discoveryCollection.insertOne(legacy)
report.discoveryRolledBack++
} else if (config.identifier.startsWith('legacy-prov-')) {
const originalId = config.identifier.replace('legacy-prov-', '')
const legacy = this.buildLegacyProvisioningFromConfig(config, originalId)
await provisioningCollection.insertOne(legacy)
report.provisioningRolledBack++
}
} catch (error) {
report.errors.push(`Rollback ${config.identifier}: ${(error as Error).message}`)
}
}
} catch (error) {
report.errors.push(`Rollback error: ${(error as Error).message}`)
}
return report
}
private buildLegacyDiscoveryFromConfig(config: any, originalId: string): any {
return {
identifier: originalId,
name: config.name.replace('[Migrated] ', ''),
manufacturer: config.criteria?.manufacturer,
oui: config.criteria?.oui,
productClass: config.criteria?.productClass,
serialNumber: config.criteria?.serialNumber,
modelPaths: (config.inventory || []).map((inv: any) => inv.path),
createdAt: config.createdAt,
updatedAt: config.updatedAt,
}
}
private buildLegacyProvisioningFromConfig(config: any, originalId: string): any {
const actions = config.desiredParameters?.map((param: any) => ({
type: 'SET_PARAMETER',
parameter: param.name,
value: param.value,
})) || []
return {
identifier: originalId,
name: config.name.replace('[Migrated] ', ''),
manufacturer: config.criteria?.manufacturer,
oui: config.criteria?.oui,
productClass: config.criteria?.productClass,
serialNumber: config.criteria?.serialNumber,
actions,
downloadActions: [],
createdAt: config.createdAt,
updatedAt: config.updatedAt,
}
}
}
+26
View File
@@ -0,0 +1,26 @@
import env from '#start/env'
let initialized = false
export function initTelemetry() {
if (initialized) return
initialized = true
const exporterUri = env.get('OTEL_EXPORTER_URI')
if (!exporterUri) return
// OpenTelemetry setup is optional - only initialize when available
import('@opentelemetry/sdk-node')
.then(({ NodeSDK }) => {
return import('@opentelemetry/auto-instrumentations-node').then(({ getNodeAutoInstrumentations }) => {
const sdk = new NodeSDK({
serviceName: env.get('OTEL_SERVICE_NAME', 'papaye-v2'),
instrumentations: [getNodeAutoInstrumentations()],
})
sdk.start()
})
})
.catch(() => {
// OpenTelemetry not available, skip
})
}
+1
View File
@@ -0,0 +1 @@
import '@adonisjs/inertia/types'
+15
View File
@@ -0,0 +1,15 @@
import { createHash } from 'node:crypto'
/**
* Generates a deterministic hex identifier for a device (URL-safe, 24 chars)
*/
export function getDeviceHashIdentifier(device: {
oui: string
productClass: string
serialNumber: string
manufacturer?: string
}): string {
const manufacturer = device.manufacturer ?? ''
const canonical = `${manufacturer}-${device.oui}-${device.productClass}-${device.serialNumber}`
return createHash('sha256').update(canonical).digest('hex').substring(0, 24)
}
+18
View File
@@ -0,0 +1,18 @@
/**
* Simple glob matching for Configuration criteria.
* Supports:
* - `*` matches any sequence of characters (including empty)
* - `?` matches exactly one character
* Matching is case-sensitive.
*/
export function matchGlob(pattern: string, value: string): boolean {
const regex = new RegExp(
'^' +
pattern
.replace(/[.+^${}()|[\]\\]/g, '\\$&')
.replace(/\*/g, '.*')
.replace(/\?/g, '.') +
'$'
)
return regex.test(value)
}
+32
View File
@@ -0,0 +1,32 @@
import type { InventoryPath } from '../domain/configuration/configuration.js'
/**
* Checks whether a desired parameter is covered by at least one inventory path.
* An inventory path ending with "." covers all parameters starting with that prefix (subtree match).
* An inventory path without trailing "." requires an exact match.
*/
export function isParameterCoveredByInventory(
paramName: string,
inventoryPaths: InventoryPath[]
): boolean {
return inventoryPaths.some((inv) => {
const path = inv.path
if (!path.endsWith('.')) {
return paramName === path
}
// Subtree match: param starts with the inventory path
return paramName.startsWith(path)
})
}
/**
* Suggests the inventory path to add to cover a given parameter.
* Removes the leaf name and adds a trailing dot.
* Example: "InternetGatewayDevice.ManagementServer.PeriodicInformInterval"
* → "InternetGatewayDevice.ManagementServer."
*/
export function suggestInventoryPath(paramName: string): string {
const parts = paramName.split('.')
parts.pop() // Remove leaf name
return parts.join('.') + '.'
}
+38
View File
@@ -0,0 +1,38 @@
import { createHash } from 'node:crypto'
import type { Configuration } from '../domain/configuration/configuration.js'
/**
* Computes a 12-character hex content hash of a Configuration's desired state.
* The hash is based on the sorted desiredParameters and firmwareTarget.
*/
export function computeContentHash(config: Configuration): string {
const payload = JSON.stringify({
desiredParameters: config.desiredParameters
.slice()
.sort((a, b) => a.name.localeCompare(b.name)),
firmwareTarget: config.firmwareTarget || null,
})
return createHash('sha256').update(payload).digest('hex').substring(0, 12)
}
/**
* Builds the ProvisioningCode string for a Configuration.
* Format: `<identifier>(v<version>)-<hash12>`
* Example: `BM632w-standard(v1.0.1)-a1b2c3d4e5f6`
*/
export function buildProvisioningCode(config: Configuration): string {
const hash = computeContentHash(config)
return `${config.identifier}(v${config.version})-${hash}`
}
/**
* Parses an existing ProvisioningCode string.
* Returns null if the format is invalid.
*/
export function parseProvisioningCode(
code: string
): { identifier: string; version: string; hash: string } | null {
const match = code.match(/^(.+)\(v(.+)\)-([a-f0-9]{12})$/)
if (!match) return null
return { identifier: match[1], version: match[2], hash: match[3] }
}
+87
View File
@@ -0,0 +1,87 @@
import type { ScheduleWindow } from '../domain/configuration/configuration.js'
/**
* Checks if the current time (or provided time) falls within the schedule window.
* Returns true if no schedule is defined (no restriction).
*
* Handles:
* - IANA timezone conversion (uses the timezone to get local hour and day)
* - Midnight-spanning windows (startHour > endHour means spans midnight)
* - Day-of-week filtering (daysOfWeek array, 0=Sunday)
*/
export function isWithinSchedule(schedule: ScheduleWindow | undefined, now?: Date): boolean {
if (!schedule) {
return true
}
const currentTime = now ?? new Date()
// Get the local hour and day in the schedule's timezone
const localParts = getLocalTimeParts(currentTime, schedule.timezone)
const localHour = localParts.hour
const localDay = localParts.dayOfWeek // 0=Sunday
// Check day-of-week filter
if (schedule.daysOfWeek.length > 0 && !schedule.daysOfWeek.includes(localDay)) {
return false
}
// Check time window
const { startHour, endHour } = schedule
if (startHour <= endHour) {
// Normal window (e.g., 2-6 means 02:00 to 06:00)
return localHour >= startHour && localHour < endHour
} else {
// Midnight-spanning window (e.g., 22-6 means 22:00 to 06:00 next day)
return localHour >= startHour || localHour < endHour
}
}
/**
* Gets the local hour and day-of-week for a given Date in a specific IANA timezone.
*/
function getLocalTimeParts(
date: Date,
timezone: string
): { hour: number; dayOfWeek: number } {
const formatter = new Intl.DateTimeFormat('en-US', {
timeZone: timezone,
hour: 'numeric',
weekday: 'short',
hour12: false,
})
const parts = formatter.formatToParts(date)
let hour = 0
let dayOfWeek = 0
for (const part of parts) {
if (part.type === 'hour') {
// Intl may return "24" for midnight in some locales, normalize to 0
hour = Number.parseInt(part.value, 10) % 24
}
if (part.type === 'weekday') {
dayOfWeek = weekdayToNumber(part.value)
}
}
return { hour, dayOfWeek }
}
/**
* Converts a short weekday name (from Intl) to a number (0=Sunday).
*/
function weekdayToNumber(weekday: string): number {
const map: Record<string, number> = {
Sun: 0,
Mon: 1,
Tue: 2,
Wed: 3,
Thu: 4,
Fri: 5,
Sat: 6,
}
return map[weekday] ?? 0
}
+23
View File
@@ -0,0 +1,23 @@
import vine from '@vinejs/vine'
export const createCommandValidator = vine.compile(
vine.object({
deviceIdentifier: vine.string().trim(),
action: vine.enum([
'SET_PARAMETER_VALUES',
'GET_PARAMETER_VALUES',
'GET_PARAMETER_NAMES',
'SET_PARAMETER_ATTRIBUTES',
'GET_PARAMETER_ATTRIBUTES',
'DOWNLOAD',
'UPLOAD',
'REBOOT',
'FACTORY_RESET',
'SCHEDULE_INFORM',
'REFRESH',
]),
parameters: vine.object({}).allowUnknownProperties(),
mode: vine.enum(['NI', 'I']).optional(),
priority: vine.number().min(0).max(3).optional(),
})
)
+69
View File
@@ -0,0 +1,69 @@
import vine from '@vinejs/vine'
const parameterCriteriaItemSchema = vine.object({
name: vine.string().trim().minLength(1),
value: vine.any(),
})
const criteriaSchema = vine.object({
manufacturer: vine.string().optional(),
oui: vine.string().optional(),
productClass: vine.string().optional(),
serialNumber: vine.string().optional(),
parameterCriteria: vine.array(parameterCriteriaItemSchema).optional(),
})
const inventoryPathSchema = vine.object({
path: vine.string().trim().minLength(1),
})
const desiredParameterSchema = vine.object({
name: vine.string().trim().minLength(1),
value: vine.any(),
type: vine.enum(['string', 'int', 'unsignedInt', 'boolean', 'dateTime']),
})
const firmwareTargetSchema = vine.object({
productClass: vine.string().trim().minLength(1),
targetVersion: vine.string().trim().minLength(1),
})
const scheduleWindowSchema = vine.object({
daysOfWeek: vine.array(vine.number().min(0).max(6)).minLength(1),
startHour: vine.number().min(0).max(23),
endHour: vine.number().min(0).max(23),
timezone: vine.string().trim().minLength(1),
})
export const createConfigurationValidator = vine.compile(
vine.object({
identifier: vine.string().trim().regex(/^[a-z0-9]+(-[a-z0-9]+)*$/),
name: vine.string().trim().minLength(1),
version: vine.string().trim().regex(/^\d+\.\d+\.\d+$/),
priority: vine.number().positive(),
policy: vine.enum(['once', 'maintain']),
enabled: vine.boolean().optional(),
tags: vine.array(vine.string().trim()).optional(),
criteria: criteriaSchema.optional(),
inventory: vine.array(inventoryPathSchema).optional(),
desiredParameters: vine.array(desiredParameterSchema).optional(),
firmwareTarget: firmwareTargetSchema.optional(),
schedule: scheduleWindowSchema.optional(),
})
)
export const updateConfigurationValidator = vine.compile(
vine.object({
name: vine.string().trim().minLength(1).optional(),
version: vine.string().trim().regex(/^\d+\.\d+\.\d+$/).optional(),
priority: vine.number().positive().optional(),
policy: vine.enum(['once', 'maintain']).optional(),
enabled: vine.boolean().optional(),
tags: vine.array(vine.string().trim()).optional(),
criteria: criteriaSchema.optional(),
inventory: vine.array(inventoryPathSchema).optional(),
desiredParameters: vine.array(desiredParameterSchema).optional(),
firmwareTarget: firmwareTargetSchema.optional(),
schedule: scheduleWindowSchema.optional(),
})
)
+12
View File
@@ -0,0 +1,12 @@
import vine from '@vinejs/vine'
export const createDeviceValidator = vine.compile(
vine.object({
deviceId: vine.object({
manufacturer: vine.string().trim().minLength(1),
oui: vine.string().trim(),
productClass: vine.string().trim(),
serialNumber: vine.string().trim().minLength(1),
}),
})
)
+45
View File
@@ -0,0 +1,45 @@
import vine from '@vinejs/vine'
export const createFirmwareEntryValidator = vine.compile(
vine.object({
identifier: vine.string().trim().regex(/^[a-z0-9]+(-[a-z0-9]+)*$/),
productClass: vine.string().trim().minLength(1),
version: vine.string().trim().minLength(1),
downloadUrl: vine.string().trim().url(),
checksum: vine.string().trim().minLength(1),
checksumType: vine.enum(['md5', 'sha256']),
fileSize: vine.number().min(0),
})
)
export const updateFirmwareEntryValidator = vine.compile(
vine.object({
productClass: vine.string().trim().minLength(1).optional(),
version: vine.string().trim().minLength(1).optional(),
downloadUrl: vine.string().trim().url().optional(),
checksum: vine.string().trim().minLength(1).optional(),
checksumType: vine.enum(['md5', 'sha256']).optional(),
fileSize: vine.number().min(0).optional(),
})
)
export const createMigrationRouteValidator = vine.compile(
vine.object({
identifier: vine.string().trim().regex(/^[a-z0-9]+(-[a-z0-9]+)*$/),
productClass: vine.string().trim().minLength(1),
sourceVersionPattern: vine.string().trim().minLength(1),
targetVersion: vine.string().trim().minLength(1),
downloadUrl: vine.string().trim().url(),
priority: vine.number().min(0),
})
)
export const updateMigrationRouteValidator = vine.compile(
vine.object({
productClass: vine.string().trim().minLength(1).optional(),
sourceVersionPattern: vine.string().trim().minLength(1).optional(),
targetVersion: vine.string().trim().minLength(1).optional(),
downloadUrl: vine.string().trim().url().optional(),
priority: vine.number().min(0).optional(),
})
)
+47
View File
@@ -0,0 +1,47 @@
/*
|--------------------------------------------------------------------------
| Ace entry point
|--------------------------------------------------------------------------
|
| The "console.ts" file is the entrypoint for booting the AdonisJS
| command-line framework and executing commands.
|
| Commands do not boot the application, unless the currently running command
| has "options.startApp" flag set to true.
|
*/
await import('reflect-metadata')
const { Ignitor, prettyPrintError } = await import('@adonisjs/core')
/**
* URL to the application root. AdonisJS need it to resolve
* paths to file and directories for scaffolding commands
*/
const APP_ROOT = new URL('../', import.meta.url)
/**
* The importer is used to import files in context of the
* application.
*/
const IMPORTER = (filePath: string) => {
if (filePath.startsWith('./') || filePath.startsWith('../')) {
return import(new URL(filePath, APP_ROOT).href)
}
return import(filePath)
}
new Ignitor(APP_ROOT, { importer: IMPORTER })
.tap((app) => {
app.booting(async () => {
await import('#start/env')
})
app.listen('SIGTERM', () => app.terminate())
app.listenIf(app.managedByPm2, 'SIGINT', () => app.terminate())
})
.ace()
.handle(process.argv.splice(2))
.catch((error) => {
process.exitCode = 1
prettyPrintError(error)
})
+45
View File
@@ -0,0 +1,45 @@
/*
|--------------------------------------------------------------------------
| HTTP server entrypoint
|--------------------------------------------------------------------------
|
| The "server.ts" file is the entrypoint for starting the AdonisJS HTTP
| server. Either you can run this file directly or use the "serve"
| command to run this file and monitor file changes
|
*/
await import('reflect-metadata')
const { Ignitor, prettyPrintError } = await import('@adonisjs/core')
/**
* URL to the application root. AdonisJS need it to resolve
* paths to file and directories for scaffolding commands
*/
const APP_ROOT = new URL('../', import.meta.url)
/**
* The importer is used to import files in context of the
* application.
*/
const IMPORTER = (filePath: string) => {
if (filePath.startsWith('./') || filePath.startsWith('../')) {
return import(new URL(filePath, APP_ROOT).href)
}
return import(filePath)
}
new Ignitor(APP_ROOT, { importer: IMPORTER })
.tap((app) => {
app.booting(async () => {
await import('#start/env')
})
app.listen('SIGTERM', () => app.terminate())
app.listenIf(app.managedByPm2, 'SIGINT', () => app.terminate())
})
.httpServer()
.start()
.catch((error) => {
process.exitCode = 1
prettyPrintError(error)
})
+62
View File
@@ -0,0 +1,62 @@
/*
|--------------------------------------------------------------------------
| Test runner entrypoint
|--------------------------------------------------------------------------
|
| The "test.ts" file is the entrypoint for running tests using Japa.
|
| Either you can run this file directly or use the "test"
| command to run this file and monitor file changes.
|
*/
process.env.NODE_ENV = 'test'
import 'reflect-metadata'
import { Ignitor, prettyPrintError } from '@adonisjs/core'
import { configure, processCLIArgs, run } from '@japa/runner'
/**
* URL to the application root. AdonisJS need it to resolve
* paths to file and directories for scaffolding commands
*/
const APP_ROOT = new URL('../', import.meta.url)
/**
* The importer is used to import files in context of the
* application.
*/
const IMPORTER = (filePath: string) => {
if (filePath.startsWith('./') || filePath.startsWith('../')) {
return import(new URL(filePath, APP_ROOT).href)
}
return import(filePath)
}
new Ignitor(APP_ROOT, { importer: IMPORTER })
.tap((app) => {
app.booting(async () => {
await import('#start/env')
})
app.listen('SIGTERM', () => app.terminate())
app.listenIf(app.managedByPm2, 'SIGINT', () => app.terminate())
})
.testRunner()
.configure(async (app) => {
const { runnerHooks, ...config } = await import('../tests/bootstrap.js')
processCLIArgs(process.argv.splice(2))
configure({
...app.rcFile.tests,
...config,
...{
setup: runnerHooks.setup,
teardown: runnerHooks.teardown.concat([() => app.terminate()]),
},
})
})
.run(() => run())
.catch((error) => {
process.exitCode = 1
prettyPrintError(error)
})
+84
View File
@@ -0,0 +1,84 @@
import env from '#start/env'
import app from '@adonisjs/core/services/app'
import { defineConfig } from '@adonisjs/core/http'
/**
* The app URL can be used in various places where you want to create absolute
* URLs to your application. For example, when sending emails, images should
* use absolute URLs.
*/
export const appUrl = env.get('APP_URL')
/**
* The configuration settings used by the HTTP server
*/
export const http = defineConfig({
/**
* Generate a unique request id for each incoming request.
* Useful to correlate logs and debug a request flow.
*/
generateRequestId: true,
/**
* Allow HTTP method spoofing via the "_method" form/query parameter.
* This lets HTML forms target PUT/PATCH/DELETE routes while still
* submitting with POST.
*/
allowMethodSpoofing: false,
/**
* Enabling async local storage will let you access HTTP context
* from anywhere inside your application.
*/
useAsyncLocalStorage: false,
/**
* Redirect configuration controls the behavior of
* response.redirect().back() and query string forwarding.
*/
redirect: {
/**
* When enabled, all redirects automatically carry over the current
* request's query string parameters to the redirect destination.
* Use withQs(false) to opt out for a specific redirect.
*/
forwardQueryString: true,
},
/**
* Manage cookies configuration. The settings for the session id cookie are
* defined inside the "config/session.ts" file.
*/
cookie: {
/**
* Restrict the cookie to a specific domain.
* Keep empty to use the current host.
*/
domain: '',
/**
* Restrict the cookie to a URL path. '/' means all routes.
*/
path: '/',
/**
* Default lifetime for cookies managed by the HTTP layer.
*/
maxAge: '2h',
/**
* Prevent JavaScript access to the cookie in the browser.
*/
httpOnly: true,
/**
* Send cookies only over HTTPS in production.
*/
secure: app.inProduction,
/**
* Cross-site policy for cookie sending.
*/
sameSite: 'lax',
},
})
+78
View File
@@ -0,0 +1,78 @@
import { defineConfig } from '@adonisjs/core/bodyparser'
const bodyParserConfig = defineConfig({
/**
* Parse request bodies for these HTTP methods.
* Keep this aligned with methods that receive payloads in your routes.
*/
allowedMethods: ['POST', 'PUT', 'PATCH', 'DELETE'],
/**
* Config for the "application/x-www-form-urlencoded"
* content-type parser.
*/
form: {
/**
* Normalize empty string values to null.
*/
convertEmptyStringsToNull: true,
/**
* Content types handled by the form parser.
*/
types: ['application/x-www-form-urlencoded'],
},
/**
* Config for the JSON parser.
*/
json: {
/**
* Normalize empty string values to null.
*/
convertEmptyStringsToNull: true,
/**
* Content types handled by the JSON parser.
*/
types: [
'application/json',
'application/json-patch+json',
'application/vnd.api+json',
'application/csp-report',
],
},
/**
* Config for the "multipart/form-data" content-type parser.
* File uploads are handled by the multipart parser.
*/
multipart: {
/**
* Automatically process uploaded files into the system tmp directory.
*/
autoProcess: true,
/**
* Normalize empty string values to null.
*/
convertEmptyStringsToNull: true,
/**
* Routes where multipart processing is handled manually.
*/
processManually: [],
/**
* Maximum accepted payload size for multipart requests.
*/
limit: '20mb',
/**
* Content types handled by the multipart parser.
*/
types: ['multipart/form-data'],
},
})
export default bodyParserConfig
+19
View File
@@ -0,0 +1,19 @@
import { defineConfig } from '@adonisjs/cors'
/**
* Configuration options to tweak the CORS policy. The following
* options are documented on the official documentation website.
*
* https://docs.adonisjs.com/guides/security/cors
*/
const corsConfig = defineConfig({
enabled: true,
origin: [],
methods: ['GET', 'HEAD', 'POST', 'PUT', 'DELETE'],
headers: true,
exposeHeaders: [],
credentials: true,
maxAge: 90,
})
export default corsConfig
+8
View File
@@ -0,0 +1,8 @@
import env from '#start/env'
const databaseConfig = {
uri: env.get('MONGODB_URI'),
dbName: env.get('MONGODB_DB_NAME', 'adam-acs'),
}
export default databaseConfig
+34
View File
@@ -0,0 +1,34 @@
import env from '#start/env'
import { defineConfig, drivers } from '@adonisjs/core/encryption'
const encryptionConfig = defineConfig({
/**
* Default encryption driver used by the application.
*/
default: 'gcm',
list: {
gcm: drivers.aes256gcm({
/**
* Keys used for encryption/decryption.
* First key encrypts, all keys are tried for decryption.
*/
keys: [env.get('APP_KEY')],
/**
* Stable identifier for this driver.
*/
id: 'gcm',
}),
},
})
export default encryptionConfig
/**
* Inferring types for the list of encryptors you have configured
* in your application.
*/
declare module '@adonisjs/core/types' {
export interface EncryptorsList extends InferEncryptors<typeof encryptionConfig> {}
}
+75
View File
@@ -0,0 +1,75 @@
import { defineConfig, drivers } from '@adonisjs/core/hash'
/**
* Hashing configuration.
*
* This starter uses Node.js scrypt under the hood.
* Node.js reference: https://nodejs.org/api/crypto.html#cryptoscryptpassword-salt-keylen-options-callback
*/
const hashConfig = defineConfig({
/**
* Default hasher used by the application.
*/
default: 'scrypt',
list: {
/**
* Scrypt is memory-hard, which makes brute-force attacks more expensive.
*/
scrypt: drivers.scrypt({
/**
* Work factor (Node alias: N / cost).
* Higher values increase security and CPU+memory usage.
*
* Tuning guideline:
* - Start with 16384.
* - Increase gradually (for example 32768) and benchmark login/signup latency.
* - Keep values practical for your slowest production machine.
*
* Node constraint: value must be a power of two greater than 1.
*/
cost: 16384,
/**
* Block size (Node alias: r / blockSize).
* Increases memory and CPU linearly.
*
* Tuning guideline:
* - Keep 8 unless you have a measured reason to change it.
* - Raise only with benchmark data, because memory usage grows quickly.
*/
blockSize: 8,
/**
* Parallelization (Node alias: p / parallelization).
* Controls how many independent computations are performed.
*
* Tuning guideline:
* - Keep 1 for most applications.
* - Increase only after load testing if your infrastructure benefits from it.
*/
parallelization: 1,
/**
* Maximum memory limit in bytes (Node alias: maxmem / maxMemory).
* Hashing throws if the estimated memory usage is above this limit.
* Node documents the check as approximately: 128 * N * r > maxmem.
*
* Tuning guideline:
* - Keep this aligned with your cost/blockSize choices.
* - Increase carefully on memory-constrained environments.
*/
maxMemory: 33554432,
}),
},
})
export default hashConfig
/**
* Inferring types for the list of hashers you have configured
* in your application.
*/
declare module '@adonisjs/core/types' {
export interface HashersList extends InferHashers<typeof hashConfig> {}
}
+8
View File
@@ -0,0 +1,8 @@
import { defineConfig } from '@adonisjs/inertia'
export default defineConfig({
rootView: 'inertia_layout',
ssr: {
enabled: false,
},
})
+35
View File
@@ -0,0 +1,35 @@
import env from '#start/env'
import app from '@adonisjs/core/services/app'
import { defineConfig, targets } from '@adonisjs/core/logger'
const loggerConfig = defineConfig({
default: 'app',
/**
* The loggers object can be used to define multiple loggers.
* By default, we configure only one logger (named "app").
*/
loggers: {
app: {
enabled: true,
name: env.get('APP_NAME'),
level: env.get('LOG_LEVEL'),
transport: {
targets: targets()
.pushIf(!app.inProduction, targets.pretty())
.pushIf(app.inProduction, targets.file({ destination: 1 }))
.toArray(),
},
},
},
})
export default loggerConfig
/**
* Inferring types for the list of loggers you have configured
* in your application.
*/
declare module '@adonisjs/core/types' {
export interface LoggersList extends InferLoggers<typeof loggerConfig> {}
}
+48
View File
@@ -0,0 +1,48 @@
import env from '#start/env'
import app from '@adonisjs/core/services/app'
import { defineConfig, stores } from '@adonisjs/session'
const sessionConfig = defineConfig({
enabled: true,
cookieName: 'adonis-session',
/**
* When set to true, the session id cookie will be deleted
* once the user closes the browser.
*/
clearWithBrowser: false,
/**
* Define how long to keep the session data alive without
* any activity.
*/
age: '2h',
/**
* Configuration for session cookie and the
* cookie store
*/
cookie: {
path: '/',
httpOnly: true,
secure: app.inProduction,
sameSite: 'lax',
},
/**
* The store to use. Make sure to validate the environment
* variable in order to infer the store name without any
* errors.
*/
store: env.get('SESSION_DRIVER'),
/**
* List of configured stores. Refer documentation to see
* list of available stores and their config.
*/
stores: {
cookie: stores.cookie(),
},
})
export default sessionConfig
+54
View File
@@ -0,0 +1,54 @@
import { defineConfig } from '@adonisjs/shield'
const shieldConfig = defineConfig({
/**
* Configure CSP policies for your app. Refer documentation
* to learn more
*/
csp: {
enabled: false,
directives: {},
reportOnly: false,
},
/**
* Configure CSRF protection options. Refer documentation
* to learn more
*/
csrf: {
enabled: true,
exceptRoutes: (ctx) => {
const url = ctx.request.url()
return url === '/cwmp10' || url.startsWith('/api/v1/')
},
enableXsrfCookie: true,
methods: ['POST', 'PUT', 'PATCH', 'DELETE'],
},
/**
* Control how your website should be embedded inside
* iFrames
*/
xFrame: {
enabled: true,
action: 'DENY',
},
/**
* Force browser to always use HTTPS
*/
hsts: {
enabled: true,
maxAge: '180 days',
},
/**
* Disable browsers from sniffing the content type of a
* response and always rely on the "content-type" header.
*/
contentTypeSniffing: {
enabled: true,
},
})
export default shieldConfig
+17
View File
@@ -0,0 +1,17 @@
import { defineConfig } from '@adonisjs/static'
/**
* Configuration options to tweak the static files middleware.
* The complete set of options are documented on the
* official documentation website.
*
* https://docs.adonisjs.com/guides/static-assets
*/
const staticServerConfig = defineConfig({
enabled: true,
etag: true,
lastModified: true,
dotFiles: 'ignore',
})
export default staticServerConfig
+28
View File
@@ -0,0 +1,28 @@
import { defineConfig } from '@adonisjs/vite'
const viteBackendConfig = defineConfig({
/**
* The output of vite will be written inside this
* directory. The path should be relative from
* the application root.
*/
buildDirectory: 'public/assets',
/**
* The path to the manifest file generated by the
* "vite build" command.
*/
manifestFile: 'public/assets/.vite/manifest.json',
/**
* Feel free to change the value of the "assetsUrl" to
* point to a CDN in production.
*/
assetsUrl: '/assets',
scriptAttributes: {
defer: true,
},
})
export default viteBackendConfig
+136
View File
@@ -0,0 +1,136 @@
import { Db, Collection, ObjectId, Filter, UpdateFilter, Sort } from 'mongodb'
import { getMongoDb } from '#providers/mongodb_provider'
export abstract class BaseModel {
static collectionName: string
_id?: ObjectId
createdAt?: Date
updatedAt?: Date
static getDb(): Db {
return getMongoDb()
}
static getCollection(this: { collectionName: string; getDb(): Db }): Collection {
return this.getDb().collection(this.collectionName)
}
static async find(this: { collectionName: string; getDb(): Db }, filter: Filter<any> = {}): Promise<any[]> {
const collection = this.getDb().collection(this.collectionName)
return collection.find(filter).toArray()
}
static async findById(this: { collectionName: string; getDb(): Db }, id: string | ObjectId): Promise<any | null> {
const collection = this.getDb().collection(this.collectionName)
const objectId = typeof id === 'string' ? new ObjectId(id) : id
return collection.findOne({ _id: objectId })
}
static async findOne(this: { collectionName: string; getDb(): Db }, filter: Filter<any>): Promise<any | null> {
const collection = this.getDb().collection(this.collectionName)
return collection.findOne(filter)
}
static async create(this: { collectionName: string; getDb(): Db }, data: Partial<any>): Promise<any> {
const collection = this.getDb().collection(this.collectionName)
const now = new Date()
const doc = { ...data, createdAt: now, updatedAt: now }
const result = await collection.insertOne(doc)
return { ...doc, _id: result.insertedId }
}
static async createMany(this: { collectionName: string; getDb(): Db }, data: Partial<any>[]): Promise<any[]> {
const collection = this.getDb().collection(this.collectionName)
const now = new Date()
const docs = data.map((d) => ({ ...d, createdAt: now, updatedAt: now }))
const result = await collection.insertMany(docs)
return docs.map((doc, i) => ({ ...doc, _id: result.insertedIds[i] }))
}
static async updateOne(
this: { collectionName: string; getDb(): Db },
filter: Filter<any>,
update: UpdateFilter<any> | Partial<any>
): Promise<any> {
const collection = this.getDb().collection(this.collectionName)
const updateDoc = '$set' in update ? update : { $set: { ...update, updatedAt: new Date() } }
return collection.updateOne(filter, updateDoc)
}
static async updateMany(
this: { collectionName: string; getDb(): Db },
filter: Filter<any>,
update: UpdateFilter<any> | Partial<any>
): Promise<any> {
const collection = this.getDb().collection(this.collectionName)
const updateDoc = '$set' in update ? update : { $set: { ...update, updatedAt: new Date() } }
return collection.updateMany(filter, updateDoc)
}
static async deleteOne(this: { collectionName: string; getDb(): Db }, filter: Filter<any>): Promise<boolean> {
const collection = this.getDb().collection(this.collectionName)
const result = await collection.deleteOne(filter)
return result.deletedCount === 1
}
static async deleteMany(this: { collectionName: string; getDb(): Db }, filter: Filter<any>): Promise<number> {
const collection = this.getDb().collection(this.collectionName)
const result = await collection.deleteMany(filter)
return result.deletedCount
}
static async paginate(
this: { collectionName: string; getDb(): Db },
page: number,
perPage: number,
filter: Filter<any> = {},
sort: Sort = {}
): Promise<{ data: any[]; total: number; page: number; perPage: number; lastPage: number }> {
const collection = this.getDb().collection(this.collectionName)
const total = await collection.countDocuments(filter)
const lastPage = Math.max(1, Math.ceil(total / perPage))
const skip = (page - 1) * perPage
const data = await collection.find(filter).sort(sort).skip(skip).limit(perPage).toArray()
return { data, total, page, perPage, lastPage }
}
static query(this: { collectionName: string; getDb(): Db }) {
const collection = this.getDb().collection(this.collectionName)
return collection
}
async save(): Promise<void> {
const constructor = this.constructor as typeof BaseModel
const collection = constructor.getDb().collection(constructor.collectionName)
this.updatedAt = new Date()
if (!this.createdAt) {
this.createdAt = new Date()
}
const { _id, ...data } = this as any
if (_id) {
await collection.updateOne({ _id }, { $set: data })
} else {
const result = await collection.insertOne(data)
this._id = result.insertedId
}
}
async delete(): Promise<boolean> {
const constructor = this.constructor as typeof BaseModel
const collection = constructor.getDb().collection(constructor.collectionName)
if (!this._id) return false
const result = await collection.deleteOne({ _id: this._id })
return result.deletedCount === 1
}
async refresh(): Promise<void> {
const constructor = this.constructor as typeof BaseModel
const collection = constructor.getDb().collection(constructor.collectionName)
if (!this._id) return
const doc = await collection.findOne({ _id: this._id })
if (doc) {
Object.assign(this, doc)
}
}
}
@@ -0,0 +1,21 @@
import { BaseSchema } from '@adonisjs/lucid/schema'
export default class extends BaseSchema {
protected tableName = 'users'
async up() {
this.schema.createTable(this.tableName, (table) => {
table.increments('id').notNullable()
table.string('full_name').nullable()
table.string('email', 254).notNullable().unique()
table.string('password').notNullable()
table.timestamp('created_at').notNullable()
table.timestamp('updated_at').nullable()
})
}
async down() {
this.schema.dropTable(this.tableName)
}
}
@@ -0,0 +1,31 @@
import { BaseSchema } from '@adonisjs/lucid/schema'
export default class extends BaseSchema {
protected tableName = 'auth_access_tokens'
async up() {
this.schema.createTable(this.tableName, (table) => {
table.increments('id')
table
.integer('tokenable_id')
.notNullable()
.unsigned()
.references('id')
.inTable('users')
.onDelete('CASCADE')
table.string('type').notNullable()
table.string('name').nullable()
table.string('hash').notNullable()
table.text('abilities').notNullable()
table.timestamp('created_at')
table.timestamp('updated_at')
table.timestamp('last_used_at').nullable()
table.timestamp('expires_at').nullable()
})
}
async down() {
this.schema.dropTable(this.tableName)
}
}
@@ -0,0 +1,27 @@
import { MongoMigration } from '../mongo_migrator.js'
export default class CreateDevicesCollection extends MongoMigration {
async up(): Promise<void> {
const collection = this.db.collection('devices')
await collection.createIndex(
{
'deviceId.manufacturer': 1,
'deviceId.oui': 1,
'deviceId.productClass': 1,
'deviceId.serialNumber': 1,
},
{ unique: true, name: 'idx_deviceId_compound' }
)
await collection.createIndex({ identifier: 1 }, { unique: true, name: 'idx_identifier' })
await collection.createIndex({ 'deviceId.manufacturer': 1 }, { name: 'idx_manufacturer' })
await collection.createIndex({ 'deviceId.oui': 1 }, { name: 'idx_oui' })
await collection.createIndex({ 'deviceId.productClass': 1 }, { name: 'idx_productClass' })
await collection.createIndex({ 'deviceId.serialNumber': 1 }, { name: 'idx_serialNumber' })
}
async down(): Promise<void> {
await this.db.dropCollection('devices').catch(() => {})
}
}
@@ -0,0 +1,20 @@
import { MongoMigration } from '../mongo_migrator.js'
export default class CreateCommandsCollection extends MongoMigration {
async up(): Promise<void> {
const collection = this.db.collection('commands')
await collection.createIndex(
{ deviceIdentifier: 1 },
{ background: true, name: 'idx_deviceIdentifier' }
)
await collection.createIndex(
{ status: 1, priority: -1 },
{ background: true, name: 'idx_status_priority' }
)
}
async down(): Promise<void> {
await this.db.dropCollection('commands').catch(() => {})
}
}
@@ -0,0 +1,12 @@
import { MongoMigration } from '../mongo_migrator.js'
export default class CreateDiscoveryConfigsCollection extends MongoMigration {
async up(): Promise<void> {
const collection = this.db.collection('discovery_configs')
await collection.createIndex({ identifier: 1 }, { unique: true, name: 'idx_identifier' })
}
async down(): Promise<void> {
await this.db.dropCollection('discovery_configs').catch(() => {})
}
}
@@ -0,0 +1,12 @@
import { MongoMigration } from '../mongo_migrator.js'
export default class CreateProvisioningConfigsCollection extends MongoMigration {
async up(): Promise<void> {
const collection = this.db.collection('provisioning_configs')
await collection.createIndex({ identifier: 1 }, { unique: true, name: 'idx_identifier' })
}
async down(): Promise<void> {
await this.db.dropCollection('provisioning_configs').catch(() => {})
}
}
@@ -0,0 +1,19 @@
import { MongoMigration } from '../mongo_migrator.js'
export default class CreateConfigurationsCollection extends MongoMigration {
async up(): Promise<void> {
const collection = this.db.collection('configurations')
await collection.createIndex({ identifier: 1 }, { unique: true, name: 'idx_identifier' })
await collection.createIndex({ enabled: 1, priority: -1 }, { name: 'idx_enabled_priority' })
await collection.createIndex({ tags: 1 }, { name: 'idx_tags' })
await collection.createIndex({ 'criteria.productClass': 1 }, { name: 'idx_criteria_product_class' })
await collection.createIndex(
{ 'criteria.manufacturer': 1, 'criteria.productClass': 1 },
{ name: 'idx_criteria_manufacturer_product_class' }
)
}
async down(): Promise<void> {
await this.db.dropCollection('configurations').catch(() => {})
}
}
@@ -0,0 +1,19 @@
import { MongoMigration } from '../mongo_migrator.js'
export default class CreateConfigurationVersionsCollection extends MongoMigration {
async up(): Promise<void> {
const collection = this.db.collection('configuration_versions')
await collection.createIndex(
{ configurationIdentifier: 1, versionNumber: -1 },
{ name: 'idx_configuration_identifier_version_number' }
)
await collection.createIndex(
{ configurationIdentifier: 1 },
{ name: 'idx_configuration_identifier' }
)
}
async down(): Promise<void> {
await this.db.dropCollection('configuration_versions').catch(() => {})
}
}
Loaded 100 of 164 files, more files were not shown because too many files have changed in this diff. Show more