feat: immediate mode (I) commands via Connection Request

- Hide Refresh button when ConnectionRequestURL is unavailable
- Fix ImmediateCommandStateMachine session lifecycle (mark complete when no commands)
- Fix preemption: new CONNECTION REQUEST replaces stale immediate session
- Protect ensureSessionForRemote from rebinding to completed machines
- Normalize numeric event codes from simulators (6 -> 6 CONNECTION REQUEST)
- Extract updatePendingCommandsStatus for clarity
This commit is contained in:
Kevin L'Hereec committed 2026-06-02 14:00:31 +02:00
1 parent 713a043625
commit 4eb3f0795a
6 files changed
+71 -14

No files matched your search

+1 -1
View File
@@ -28,4 +28,4 @@ LOG_PORT=514
LOG_PROTOCOL=udp4
# Security
HASH_KEY=0123456789abcdef0123456789abcdef
HASH_KEY=0123456789abcdef0123456789abcdef
+3 -1
View File
@@ -38,10 +38,12 @@ export default class CwmpController {
try {
if (isEmpty) {
manager.ensureSessionForRemote(remoteKey, sessionId)
const rebound = manager.ensureSessionForRemote(remoteKey, sessionId)
const hashId = manager.getHashIdForRemote(remoteKey)
if (hashId) ctx.cwmpDeviceId = hashId
logger.debug({ remoteKey, sessionId, rebound, hashId, hasSession: manager.hasSession(sessionId) }, 'CWMP Empty POST received')
if (manager.hasSession(sessionId)) {
const result = await manager.continue(sessionId)
if (result && result !== '') {
+3 -1
View File
@@ -117,12 +117,14 @@ export class ExecutingCommandsState extends DeviceState {
this.commandsQueue = [...iWaiting, ...iPending, ...niPending]
this.started = true
logger.debug({ hashId, iWaiting: iWaiting.length, iPending: iPending.length, niPending: niPending.length, includeNI: this.includeNI }, '[ExecutingCommands] Loaded commands queue')
if (this.commandsQueue.length === 0) {
if (this.includeNI) {
return { nextState: new CompletedState(), isComplete: true, response: '' }
}
return { response: '' }
// ImmediateCommandStateMachine with no commands — mark complete
return { isComplete: true, response: '' }
}
return { response: await this.processNextCommand(context) }
+28 -8
View File
@@ -136,14 +136,21 @@ export class StateMachineManager extends EventEmitter {
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
// For a new CONNECTION REQUEST arriving while a previous immediate session
// is still technically "running", complete the old one and create a fresh machine
if (isImmediate && lastEvent === '6 CONNECTION REQUEST') {
this.completeMachine(existingMachine)
// Fall through to create a new machine below
} else {
// 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
}
return existingMachine
}
}
}
@@ -242,6 +249,11 @@ export class StateMachineManager extends EventEmitter {
const machine = this.machines.get(machineId)
if (!machine) return false
// Don't rebind to a completed/failed machine
if (machine.status !== MachineStatus.RUNNING) {
return false
}
if (existingSession !== newSession) {
this.sessionMachines.delete(existingSession)
this.sessionMachines.set(newSession, machineId)
@@ -312,6 +324,7 @@ export class StateMachineManager extends EventEmitter {
// Let the state machine continue
const response = await machineInstance.stateMachine.handleResponse(true, { isFirst: false })
logger.debug({ session, stateName: machineInstance.stateMachine.getCurrentStateName(), responseLength: response?.length || 0 }, '[Continue] State machine handleResponse result')
machineInstance.currentState = machineInstance.stateMachine.getCurrentStateName()
if (machineInstance.stateMachine.isCompleted()) {
@@ -406,7 +419,14 @@ export class StateMachineManager extends EventEmitter {
}
private normalizeEvent(evt: string): string {
return (evt || '').toString().trim().replace(/\s+/g, ' ').toUpperCase()
const normalized = (evt || '').toString().trim().replace(/\s+/g, ' ').toUpperCase()
// Handle simulator variants that send only the event code number
if (normalized === '6') return '6 CONNECTION REQUEST'
if (normalized === '0') return '0 BOOTSTRAP'
if (normalized === '1') return '1 BOOT'
if (normalized === '2') return '2 PERIODIC'
if (normalized === '3') return '3 SCHEDULED'
return normalized
}
cleanupCompletedMachines(olderThanHours: number = 1): void {
+7 -2
View File
@@ -120,8 +120,13 @@ export default class DeviceService {
}
const wakeSuccess = await this.sendWakeOnWlan(url, device)
await this.updatePendingCommandsStatus(identifier, wakeSuccess)
}
// Mark pending I commands as WAITING (or FAILED)
/**
* Update pending mode-I commands status based on Connection Request result
*/
private async updatePendingCommandsStatus(identifier: string, success: boolean): Promise<void> {
const collection = CommandModel.getCollection()
const pendingCmds = await collection.find({
deviceIdentifier: identifier,
@@ -132,7 +137,7 @@ export default class DeviceService {
for (const cmd of pendingCmds) {
await collection.updateOne(
{ _id: cmd._id },
{ $set: { status: wakeSuccess ? CommandStatus.WAITING : CommandStatus.PENDING, updatedAt: new Date() } }
{ $set: { status: success ? CommandStatus.WAITING : CommandStatus.PENDING, updatedAt: new Date() } }
)
}
}
+29 -1
View File
@@ -126,6 +126,18 @@ function submitGetModel() {
})
}
// Check if the device has a ConnectionRequestURL (needed for immediate mode / Refresh)
const hasConnectionRequestUrl = (() => {
const allParams = [...(device.properties || []), ...(device.parameters || [])]
const crUrl = allParams.find(
(p: any) =>
p.name &&
(p.name.endsWith('ManagementServer.ConnectionRequestURL') ||
p.name.endsWith('.ConnectionRequestURL'))
)
return crUrl && crUrl.value && crUrl.value.trim() !== ''
})()
function formatName(name: unknown): string {
if (name == null) return ''
const str = typeof name === 'string' ? name : String(name)
@@ -367,9 +379,25 @@ function getPropertyValue(name: string): string {
<div class="card-body p-4">
<h2 class="h6 fw-bold mb-3">Actions</h2>
<div class="d-flex gap-2 flex-wrap">
<button class="btn btn-outline-dark btn-sm" @click="refreshDevice" :disabled="isLoading">
<button
v-if="hasConnectionRequestUrl"
class="btn btn-outline-dark btn-sm"
@click="refreshDevice"
:disabled="isLoading"
>
{{ t('button.refresh') }}
</button>
<span
v-else
class="d-inline-block"
tabindex="0"
data-bs-toggle="tooltip"
:title="t('tooltip.refreshUnavailable', 'ConnectionRequestURL non disponible sur cet équipement')"
>
<button class="btn btn-outline-dark btn-sm" disabled>
{{ t('button.refresh') }}
</button>
</span>
<button class="btn btn-outline-dark btn-sm" @click="openGetModelPopup" :disabled="isLoading">
{{ t('button.getModel') }}
</button>