diff --git a/.github/workflows/windows-printer-tests.yml b/.github/workflows/windows-printer-tests.yml
new file mode 100644
index 000000000..94b4e8019
--- /dev/null
+++ b/.github/workflows/windows-printer-tests.yml
@@ -0,0 +1,29 @@
+name: Windows printer recovery tests
+on:
+ pull_request:
+ paths:
+ - 'src/windows-printer-*.js'
+ - 'src/windows-printer-*.ps1'
+ - 'src/windows-print-queue.js'
+ - 'src/hardware-manager.html'
+ - 'tests/windows-printer-discovery.test.js'
+ - 'tests/windows-print-queue.test.js'
+ - 'tests/kot-multi-printer-recovery.test.js'
+ - '.github/workflows/windows-printer-tests.yml'
+ workflow_dispatch:
+permissions:
+ contents: read
+jobs:
+ printer-recovery:
+ name: Windows USB discovery and queue recovery
+ runs-on: windows-latest
+ timeout-minutes: 15
+ steps:
+ - uses: actions/checkout@v7
+ - uses: actions/setup-node@v7
+ with:
+ node-version: 22
+ cache: npm
+ - run: npm ci --ignore-scripts
+ # Synthetic device records and transport only; no printing or power changes.
+ - run: node --test tests/windows-printer-discovery.test.js tests/windows-print-queue.test.js tests/kot-multi-printer-recovery.test.js
diff --git a/docs/WINDOWS-PRINTER-RECOVERY.md b/docs/WINDOWS-PRINTER-RECOVERY.md
index 50d8e56e1..6598cbba8 100644
--- a/docs/WINDOWS-PRINTER-RECOVERY.md
+++ b/docs/WINDOWS-PRINTER-RECOVERY.md
@@ -27,7 +27,7 @@ pooling is refused because it can route a ticket to another device.
USB presence is checked with Windows PnP, mapping USBPRINT's device-specific
`PortName` to the configured queue. A persistent SWD PrintQueue instance, or
another printer with the same driver, does not prove physical presence.
-Unknown mappings stop safely and ask for an explicit physical instance ID.
+Only currently present matches are eligible. Unknown mappings and enumeration failures wait safely for another check; ambiguous or stale bindings remain distinct and never choose another device automatically.
The helper attempts to clear `WorkOffline` only when the matching device is
present. It uses the current user's permissions. If Windows refuses this
@@ -86,7 +86,8 @@ Idle keep-alive is enabled by default for configured USB ESC/POS queues,
and can be disabled in Hardware Manager. After at least 30 seconds quiet,
the same RAW writer sends only `ESC @` (1B 40), without feed, text, or cut.
It requires verified USB presence, matching port/device, an empty Windows
-queue, and both printer statuses explicitly Idle. Busy or unknown status
+queue, and primary printer status explicitly Idle. Extended Idle (3) or Unknown (2)
+is accepted for drivers such as POS-80C; unknown primary or busy status
is not permission to reset a printer's buffer. Initialization disabled also
disables keep-alive. This is not a bidirectional status acknowledgement.
@@ -123,3 +124,43 @@ arrive once; the queue's “Sent to printer” remains a Windows observation.
Windows references: [Win32_Printer properties](https://learn.microsoft.com/en-us/windows/win32/cimwin32prov/win32-printer)
and [Get-PnpDevice](https://learn.microsoft.com/en-us/powershell/module/pnpdevice/get-pnpdevice).
+
+## Discovery correction and old job recovery
+
+The health probe reads the configured queue and matches its port to currently present physical USBPRINT devices. Historical registry entries are not evidence of presence. A single present match is usable; multiple present matches, enumeration failures and missing mappings remain distinct. A saved physical instance ID never silently changes to another device. Expected ports remain frozen per job.
+
+The queue retries temporary discovery failures with its existing 2/5/10-second delays, then waits for recovery. Startup, background polling and repeated enqueue can migrate only the exact old USB identification failure when the durable record explicitly says it was never submitted and has no accepted, observed or spooler-ID evidence. The original ID and bytes are retained. Submitted or uncertain jobs are never replayed because an empty spooler does not prove non-delivery.
+
+## What the shopkeeper sees
+
+Open **Hardware Manager → Receipt Printer → Windows printer health & recovery**. Check the printer queue, expected port, physical identity, current discovery result and job status. A stale binding needs the physical printer and port checked before saving a new binding; existing pending jobs retain their original destination.
+
+The same section audits the current Windows power plan, read-only, at most once per minute. Enabled or unavailable settings display instructions:
+
+- For a dedicated counter, keep mains power connected. In **Control Panel → Power Options → Change plan settings**, set **Put the computer to sleep** to **Never** while serving.
+- Under **Change advanced power settings → Sleep**, check **Hibernate after**. Battery settings have a battery-life tradeoff.
+- For USB disconnections after idle, temporarily test **USB settings → USB selective suspend → Disabled**. Restore the setting if it does not improve the fault. Microsoft normally recommends selective suspend remain enabled; this is an isolation step, not a universal requirement.
+- If necessary, inspect the printer's USB hub in **Device Manager → Properties → Power Management**. Where available, temporarily clear **Allow the computer to turn off this device to save power**, and restore it if ineffective. The app cannot verify every hub/firmware setting or organizational policy.
+
+No power plan, driver, port, Windows security setting or spooler service is changed by this audit. Screen lock is different from system sleep. The app's existing power blocker does not prove USB hardware stays connected.
+
+## Remove the diagnostic helper before testing
+
+The separate `posnic-printer-keepalive.ps1` in the diagnostic handoff is not part of the product. Stop that helper and remove its shortcut from the Windows Startup folder (`shell:startup`) on the affected computer. Do not stop all PowerShell processes or delete unrelated shortcuts. Hardware Manager warns when the helper is detected, and optional app idle commands are suppressed while it is running or the audit is unavailable. This patch does not remotely remove diagnostic scripts.
+
+Idle ESC/POS initialization is serialized with the app's print jobs, requires a quiet interval and an empty Windows queue, and accepts the observed POS-80C combination of primary Idle (3) and extended Unknown (2). Unknown primary status still blocks it. ESC @ resets printer state; it is not a documented universal wake command and an empty spooler does not establish that every external writer or physical buffer is idle. Disable idle initialization if unsupported. No paper feed/cut is used for initialization.
+
+## Verification on the affected counter
+
+Do not replay diagnostic job metadata or actual orders. Keep a record of the selected reception/kitchen queues and their existing ports. After removing the separate helper, use a clearly labelled test ticket:
+
+1. Print once to each destination and verify the physical destination and formatting/cut.
+2. Leave the counter idle for its usual failure interval. Record discovery status and power settings, then print a fresh test ticket.
+3. Disconnect/reconnect the kitchen USB connection with one new test job pending. Verify one physical copy after reconnect and no reception reroute.
+4. Repeat the same idle interval with the kitchen printer on a short direct USB cable, then compare with the extender. Record USB arrival/removal times, power and cable/extender model. A repeated physical disappearance despite the software fixes needs this controlled hardware test.
+
+“Sent to printer” means Windows accepted/released the job, not proof of paper output. If the outcome is uncertain, inspect the paper and queue before explicitly requesting a duplicate.
+
+Automated tests use synthetic jobs and mock transports; Windows-only tests execute the actual PowerShell resolver with synthetic device records. They cannot establish that an extender or printer remains physically connected indefinitely.
+
+References: [Microsoft powercfg options](https://learn.microsoft.com/en-us/windows-hardware/design/device-experiences/powercfg-command-line-options), [USB selective suspend](https://learn.microsoft.com/en-us/windows-hardware/drivers/usbcon/usb-selective-suspend).
diff --git a/src/hardware-ipc.js b/src/hardware-ipc.js
index c872e75d7..c1b351ca8 100644
--- a/src/hardware-ipc.js
+++ b/src/hardware-ipc.js
@@ -175,9 +175,14 @@ function setupHardwareIPC(hardwareManager, kotManager, billManager) {
});
// Printer Handlers
- ipcMain.handle('printer:recovery-status', () => {
+ ipcMain.handle('printer:recovery-status', async () => {
const queue = hardwareManager.getWindowsPrintQueue();
- return queue ? { supported: true, bindings: queue.bindings,
+ let systemSettings;
+ if (queue) {
+ try { systemSettings = await queue.transport.systemSettings(); }
+ catch (_) { systemSettings = { guidance: ['Windows power settings could not be checked. Review Sleep, Hibernate and USB selective suspend manually in Power Options.'] }; }
+ }
+ return queue ? { supported: true, systemSettings, bindings: queue.bindings,
health: [...queue.health.values()].map(item => item.value), keepAlive: queue.keepAlive, jobs: queue.list() } : { supported: false };
});
ipcMain.handle('printer:recovery-configure', async (_event, printer, binding) => {
diff --git a/src/hardware-manager.html b/src/hardware-manager.html
index c85dd594e..24c24a337 100644
--- a/src/hardware-manager.html
+++ b/src/hardware-manager.html
@@ -394,11 +394,16 @@
🔧 Hardware Manager
-
-
Sends a paperless initialization after 30 seconds idle. No paper feed or cut. Requires ESC/POS initialization above. Pauses while printing or disconnected.
+
+
Sends ESC @ after 30 seconds idle, without feed or cut. This resets printer state and is not a guaranteed wake command. Enable only if your printer supports it. Pauses during app printing or disconnection. Do not run the separate diagnostic keep-alive helper alongside it.
Changing these settings affects new jobs only. Pending jobs retain their original printer and port. No ports are swapped automatically.
+
Windows settings for reliable printing
+
+
Recommended for a dedicated counter: keep it plugged in and prevent sleep/hibernate while serving. Battery settings affect battery life. These checks do not change Windows settings.
+
If a USB printer still disappears: in Device Manager, inspect its USB hub → Properties → Power Management, where available, and temporarily test with “Allow the computer to turn off this device to save power” cleared. Restore it if there is no improvement. Posnic cannot verify every hub or printer firmware setting.
+
For an extender connection, test the same printer with a short direct USB cable across the usual idle period. Check extender power and cables. Initialization cannot restore a device that Windows cannot see.
@@ -2141,10 +2146,16 @@
Hear the whole thing
if (!status.supported) return;
document.getElementById('windowsRecovery').style.display = 'block';
wrBindings = status.bindings || {};
+ const power = document.getElementById('wrPower');
+ power.replaceChildren();
+ const messages = status.systemSettings?.guidance || ['Power settings have not been checked.'];
+ (messages.length ? messages : ['Checked Sleep, Hibernate and USB selective suspend: all disabled in the current power plan.']).forEach(message => {
+ const row = document.createElement('p'); row.textContent = message; power.appendChild(row);
+ });
document.getElementById('wrKeepAliveStatus').textContent = Object.values(status.keepAlive || {}).map(p =>
p.printer + ' | ' + p.status + ' | ' + new Date(p.at).toLocaleTimeString()).join('\n');
document.getElementById('wrHealth').textContent = (status.health || []).map(p =>
- p.printer + ' | ' + p.port + ' | USB present: ' + p.present + ' | WorkOffline: ' + p.workOffline + ' | Windows status: ' + p.printerStatus).join('\n');
+ p.printer + ' | ' + p.port + ' | USB: ' + (p.discovery || p.present) + ' | WorkOffline: ' + p.workOffline + ' | Windows status: ' + p.printerStatus).join('\n');
const list = document.getElementById('wrJobs');
list.replaceChildren();
(status.jobs || []).slice().reverse().forEach(job => {
diff --git a/src/windows-print-queue.js b/src/windows-print-queue.js
index d79a8f9e8..cb0d51ecc 100644
--- a/src/windows-print-queue.js
+++ b/src/windows-print-queue.js
@@ -12,6 +12,10 @@ function readiness(health, binding = {}) {
if (String(health.port).includes(',')) return 'Printer pooling is not supported; choose a single port';
if (binding.port && binding.port.toLowerCase() !== String(health.port).toLowerCase()) return 'Printer port changed; check configuration';
if (binding.pnpId && String(health.pnpId).toLowerCase() !== binding.pnpId.toLowerCase()) return 'Printer device changed; check configuration';
+ if (health.discovery === 'port-mismatch') return 'Configured USB device belongs to a different port; check configuration';
+ if (health.discovery === 'stale-binding') return 'Saved USB device is absent; a different device is on this port. Check the physical printer and binding';
+ if (health.discovery === 'ambiguous') return 'Multiple connected USB printers match this port; check printer configuration';
+ if (health.discovery === 'error') return 'USB discovery failed; waiting to check again';
if (health.usb && health.present === false) return 'Printer disconnected';
if (health.usb && health.present !== true) return 'USB device could not be identified; configure its PnP instance ID';
if (health.workOffline || health.printerStatus === 7 || health.extendedStatus === 7) return 'Printer offline';
@@ -43,10 +47,23 @@ class WindowsPrintQueue {
for (const name of fs.readdirSync(dir).filter(name => /^[a-f0-9]{64}\.json$/.test(name))) {
const job = this.read(name);
if (!job || name !== job.id + '.json') throw new Error('Unreadable printer recovery state: ' + name);
+ this.migrateIdentityFailure(job);
this.jobs.set(job.id, job);
}
}
+ migrateIdentityFailure(job) {
+ // Only the exact old pre-submission failure is safe to recover. A spooler
+ // job may have vanished before polling, so absence is NOT replay evidence.
+ if (job.state !== 'failed' || job.submitted !== false || job.accepted || job.observed || job.spoolerId ||
+ job.reason !== 'USB device could not be identified; configure its PnP instance ID' ||
+ !fs.existsSync(path.join(this.dir, job.id + '.bin'))) return;
+ job.reconnect = true; job.retries = 0; job.nextAt = 0;
+ job.state = 'waiting'; job.reason = 'Checking USB printer identity again';
+ this.write(job.id + '.json', job);
+ this.log({ jobId: job.id, printer: job.printer, event: 'recover-legacy-identity-failure' });
+ }
+
read(file, fallback) {
try { return JSON.parse(fs.readFileSync(path.join(this.dir, file), 'utf8')); }
catch (error) { if (error.code === 'ENOENT' && fallback !== undefined) return fallback; throw error; }
@@ -119,7 +136,8 @@ class WindowsPrintQueue {
const key = printer.toLowerCase();
const serial = JSON.stringify(health);
if (this.health.get(key)?.serial !== serial) this.log({ printer, port: health.port,
- pnpId: health.pnpId, present: health.present, workOffline: health.workOffline,
+ pnpId: health.pnpId, present: health.present, discovery: health.discovery, candidateCount: health.candidateCount,
+ discoveryError: health.discoveryError, workOffline: health.workOffline,
printerStatus: health.printerStatus, extendedStatus: health.extendedStatus,
jobs: health.jobs.map(item => ({ id: item.id, status: item.status })) });
this.health.set(key, { serial, value: health });
@@ -134,6 +152,8 @@ class WindowsPrintQueue {
const existing = this.jobs.get(id);
if (existing) {
if (existing.printer.toLowerCase() !== printer.toLowerCase()) return Promise.resolve({ success: false, status: 'Failed', error: 'Job ID belongs to another printer' });
+ this.migrateIdentityFailure(existing);
+ this.start();
return this.run(printer).then(() => this.result(existing));
}
const frozen = { ...(this.bindings[printer.toLowerCase()] || {}), ...binding };
@@ -161,12 +181,13 @@ class WindowsPrintQueue {
async step(job) {
if (job.state === 'sent') return;
+ if (!job.submitted && job.state !== 'failed' && job.nextAt > this.now()) return;
if (job.state === 'failed' && !job.reconnect && !job.submitted) return;
let health;
try { health = await this.snapshot(job.printer, job.binding); }
catch (error) {
if (job.submitted) return this.transition(job, 'queued', 'Cannot read Windows queue: ' + error.message);
- return this.defer(job, 'Cannot read Windows queue: ' + error.message, false);
+ return this.defer(job, 'Cannot read Windows queue: ' + error.message);
}
let reason = readiness(health, job.binding);
if (!job.binding.port && health.port) {
@@ -178,6 +199,14 @@ class WindowsPrintQueue {
this.write('bindings.json', this.bindings);
}
}
+ if (!reason && health.present === true && !job.binding.pnpId && health.pnpId) {
+ job.binding.pnpId = health.pnpId;
+ this.write(job.id + '.json', job);
+ const saved = this.bindings[job.printer.toLowerCase()];
+ if (saved && !saved.pnpId && saved.port === job.binding.port) {
+ saved.pnpId = health.pnpId; this.write('bindings.json', this.bindings);
+ }
+ }
if (health.present === true && health.workOffline && !/changed/.test(reason) && job.nextAt <= this.now()) {
try {
await this.transport.recover(job.printer, job.binding);
@@ -230,7 +259,7 @@ class WindowsPrintQueue {
return this.transition(job, 'waiting', 'Waiting for printer initialization');
}
}
- if (reason) return this.defer(job, reason, /offline|disconnected|Printer error|failed job/i.test(reason));
+ if (reason) return this.defer(job, reason, /offline|disconnected|Printer error|failed job|USB discovery failed|could not be identified|Multiple connected USB|Saved USB device is absent|Printer status unknown/i.test(reason));
// Persist BEFORE crossing the spooler boundary. A crash in the following
// call is ambiguous, not permission to submit a second copy.
job.submitted = true;
@@ -307,7 +336,13 @@ class WindowsPrintQueue {
// spooler and explicitly Idle status, never between queued receipt jobs.
if (binding.keepAlive === false || binding.initialize === false || !this.transport.initialize ||
!health.usb || health.present !== true || reason || health.jobs.length ||
- health.printerStatus !== 3 || health.extendedStatus !== 3) return;
+ health.printerStatus !== 3 || ![2, 3].includes(health.extendedStatus)) return;
+ // Never add an app idle writer alongside the diagnostic helper. Failure
+ // to audit suppresses optional idle traffic, not actual receipt delivery.
+ if (this.transport.systemSettings) {
+ try { if ((await this.transport.systemSettings()).externalKeepAlive !== false) return; }
+ catch (_) { return; }
+ }
const last = Math.max(prior?.at || 0, this.activity.get(key) || 0);
if (this.now() - last < 30000) return;
const pulse = this.keepAlive[key] = { printer, at: this.now(), pending: true,
@@ -339,6 +374,7 @@ class WindowsPrintQueue {
const key = String(printer).toLowerCase();
if (key && key !== 'default' && !this.bindings[key]) this.bindings[key] = {};
}
+ for (const job of this.jobs.values()) this.migrateIdentityFailure(job);
const names = new Set([...this.jobs.values()].filter(job => job.state !== 'sent' &&
(job.state !== 'failed' || job.reconnect || job.submitted)).map(job => job.printer));
for (const name of names) await this.run(name);
diff --git a/src/windows-printer-health.ps1 b/src/windows-printer-health.ps1
index bc67df171..93f4ffbe3 100644
--- a/src/windows-printer-health.ps1
+++ b/src/windows-printer-health.ps1
@@ -1,41 +1,85 @@
$ErrorActionPreference = 'Stop'
[Console]::OutputEncoding = New-Object System.Text.UTF8Encoding($false)
[Console]::InputEncoding = New-Object System.Text.UTF8Encoding($false)
+# Pure resolver: no fallback to a different physical device for a saved ID.
+function Resolve-UsbIdentity($port, $savedId, $records, $presentIds) {
+ $matches = @($records | Where-Object { $_.port -eq $port -and $presentIds -contains $_.id } | ForEach-Object { $_.id } | Sort-Object -Unique)
+ $result = @{ pnpId = $savedId; present = $null; discovery = 'unknown'; candidateCount = $matches.Count }
+ if ($savedId) {
+ $saved = @($records | Where-Object { $_.id -eq $savedId })
+ if (@($saved | Where-Object { $_.port -and $_.port -ne $port }).Count) { $result.discovery = 'port-mismatch' }
+ elseif ($presentIds -contains $savedId) {
+ if ($matches -contains $savedId) { $result.present = $true; $result.discovery = 'resolved' }
+ } elseif ($matches.Count) { $result.discovery = 'stale-binding' }
+ else { $result.present = $false; $result.discovery = 'absent' }
+ } elseif ($matches.Count -eq 1) {
+ $result.pnpId = $matches[0]; $result.present = $true; $result.discovery = 'resolved'
+ } elseif ($matches.Count -gt 1) { $result.discovery = 'ambiguous' }
+ elseif (@($records | Where-Object { $_.port -eq $port }).Count -or $presentIds.Count -eq 0) { $result.present = $false; $result.discovery = 'absent' }
+ return $result
+}
+# Read-only power audit; never changes the power plan or requests elevation.
+function Read-PowerSetting($subgroup, $setting) {
+ try {
+ $output = (& powercfg.exe /query SCHEME_CURRENT $subgroup $setting 2>&1) -join "`n"
+ if ($LASTEXITCODE -ne 0) { throw 'Power setting unavailable' }
+ $values = @([regex]::Matches($output, '0x[0-9a-fA-F]{8}'))
+ if ($values.Count -lt 2) { throw 'Power setting response incomplete' }
+ # The final two hexadecimal values are AC/DC indices; preceding
+ # values are bounds or enumerated choices. No localized label parsing.
+ return @{ ac = [Convert]::ToInt64($values[-2].Value.Substring(2), 16); dc = [Convert]::ToInt64($values[-1].Value.Substring(2), 16) }
+ } catch { return @{ ac = $null; dc = $null } }
+}
try {
$request = [Console]::In.ReadToEnd() | ConvertFrom-Json
+ if ($request.action -eq 'power') {
+ $external = $null
+ try {
+ $external = @(Get-CimInstance Win32_Process -ErrorAction Stop | Where-Object {
+ $_.ProcessId -ne $PID -and $_.CommandLine -notmatch 'Read-PowerSetting' -and $_.Name -match '^(powershell|pwsh)(.exe)?$' -and $_.CommandLine -match 'posnic-printer-keepalive\.ps1'
+ }).Count -gt 0
+ } catch { }
+ @{
+ sleep = Read-PowerSetting '238c9fa8-0aad-41ed-83f4-97be242c8f20' '29f6c1db-86da-48c5-9fdb-f2b67b1f44da'
+ hibernate = Read-PowerSetting '238c9fa8-0aad-41ed-83f4-97be242c8f20' '9d7815a6-7ee4-497e-8888-515a05f02364'
+ usbSuspend = Read-PowerSetting '2a737441-1930-4402-8d77-b2bebba308a3' '48e6b7a6-50f5-4782-a5d4-53bb8f07e226'
+ externalKeepAlive = $external
+ } | ConvertTo-Json -Compress -Depth 5
+ exit 0
+ }
# Compare literal names, never interpolate a queue name into a WQL query.
$printer = Get-CimInstance Win32_Printer | Where-Object { $_.Name -eq $request.printer } | Select-Object -First 1
if (-not $printer) { throw 'Configured printer queue not found' }
$port = [string]$printer.PortName
$usb = $port -match '^USB\d+$'
- $deviceId = [string]$request.pnpId
- if ($usb -and -not $deviceId) {
- # A PrintQueue/SWD instance is NOT evidence of a connected USB device.
- # USBPRINT's per-device PortName associates the physical instance with
- # this queue, even when two printers use the same POS-80C driver.
- $deviceMatches = @()
- $root = 'HKLM:\SYSTEM\CurrentControlSet\Enum\USBPRINT'
- if (Test-Path $root) {
- foreach ($model in Get-ChildItem -LiteralPath $root) {
- foreach ($instance in Get-ChildItem -LiteralPath $model.PSPath) {
- $parameters = Get-ItemProperty -LiteralPath ($instance.PSPath + '\Device Parameters') -ErrorAction SilentlyContinue
- if ($parameters.PortName -eq $port) {
- $deviceMatches += 'USBPRINT\' + $model.PSChildName + '\' + $instance.PSChildName
+ $identity = @{ pnpId = [string]$request.pnpId; present = $null; discovery = 'not-usb'; candidateCount = 0 }
+ if ($usb) {
+ try {
+ # Query present devices once. Historical registry records alone do
+ # not prove presence, and a PrintQueue/SWD entry is not USB hardware.
+ $presentIds = @(Get-PnpDevice -PresentOnly -ErrorAction Stop | Where-Object { $_.InstanceId -match '^USB(PRINT)?\\' } | ForEach-Object { [string]$_.InstanceId })
+ $records = @()
+ $root = 'HKLM:\SYSTEM\CurrentControlSet\Enum\USBPRINT'
+ if (Test-Path -LiteralPath $root) {
+ foreach ($model in Get-ChildItem -LiteralPath $root -ErrorAction Stop) {
+ foreach ($instance in Get-ChildItem -LiteralPath $model.PSPath -ErrorAction Stop) {
+ $parametersPath = $instance.PSPath + '\Device Parameters'
+ if (Test-Path -LiteralPath $parametersPath) {
+ $parameters = Get-ItemProperty -LiteralPath $parametersPath -ErrorAction Stop
+ $records += @{ id = 'USBPRINT\' + $model.PSChildName + '\' + $instance.PSChildName; port = [string]$parameters.PortName }
+ }
}
}
}
- }
- if ($deviceMatches.Count -eq 1) { $deviceId = $deviceMatches[0] }
- elseif ([string]$printer.PNPDeviceID -match '^USB(PRINT)?\\') { $deviceId = [string]$printer.PNPDeviceID }
- }
- $present = $null
- if ($deviceId) {
- $present = @((Get-PnpDevice -PresentOnly) | Where-Object { $_.InstanceId -eq $deviceId }).Count -gt 0
- if ($usb -and $deviceId -match '^USBPRINT\\') {
- $mappedPort = (Get-ItemProperty -LiteralPath ('HKLM:\SYSTEM\CurrentControlSet\Enum\' + $deviceId + '\Device Parameters') -ErrorAction SilentlyContinue).PortName
- if ($mappedPort -and $mappedPort -ne $port) { throw 'Configured USB device belongs to a different port; check configuration' }
+ $identity = Resolve-UsbIdentity $port ([string]$request.pnpId) $records $presentIds
+ } catch {
+ $identity.discovery = 'error'
+ $identity.present = $null
+ $identity.discoveryError = $_.Exception.Message
}
}
+ $deviceId = $identity.pnpId
+ $present = $identity.present
if ($request.recover -and $printer.WorkOffline) {
if ($present -ne $true) { throw 'Cannot recover without a present physical USB device' }
if ($request.expectedPort -and $request.expectedPort -ne $port) { throw 'Printer port changed; check configuration' }
@@ -51,6 +95,7 @@ try {
@{ printer = [string]$printer.Name; port = $port; usb = $usb; pnpId = $deviceId; present = $present;
workOffline = [bool]$printer.WorkOffline; printerStatus = [int]$printer.PrinterStatus;
extendedStatus = [int]$printer.ExtendedPrinterStatus; detectedError = [int]$printer.DetectedErrorState;
+ discovery = $identity.discovery; candidateCount = $identity.candidateCount; discoveryError = $identity.discoveryError;
jobs = $jobs } | ConvertTo-Json -Compress -Depth 5
} catch {
@{ error = $_.Exception.Message } | ConvertTo-Json -Compress
diff --git a/src/windows-printer-transport.js b/src/windows-printer-transport.js
index a3e44c954..84e2be432 100644
--- a/src/windows-printer-transport.js
+++ b/src/windows-printer-transport.js
@@ -6,7 +6,7 @@ const path = require('node:path');
const raw = require('./raw-print-service');
const script = fs.readFileSync(path.join(__dirname, 'windows-printer-health.ps1'), 'utf8');
-function inspect(printer, binding = {}, recover = false) {
+function inspect(printer, binding = {}, recover = false, action) {
return new Promise((resolve, reject) => {
const child = execFile('powershell.exe', ['-NoProfile', '-NonInteractive', '-Command', script],
{ windowsHide: true, timeout: 12000, maxBuffer: 2 * 1024 * 1024, encoding: 'utf8' }, (error, stdout) => {
@@ -18,12 +18,41 @@ function inspect(printer, binding = {}, recover = false) {
} catch (failure) { reject(failure); }
});
child.stdin.on('error', () => {}); // execFile reports helper failures.
- child.stdin.end(JSON.stringify({ printer, expectedPort: binding.port, pnpId: binding.pnpId, recover }));
+ child.stdin.end(JSON.stringify({ printer, expectedPort: binding.port, pnpId: binding.pnpId, recover, action }));
});
}
+function powerGuidance(settings) {
+ const messages = [];
+ for (const [key, name, change] of [
+ ['sleep', 'Sleep', 'Power Options → Change plan settings → Put the computer to sleep: Never'],
+ ['hibernate', 'Hibernate', 'Power Options → Advanced settings → Sleep → Hibernate after: Never'],
+ ['usbSuspend', 'USB selective suspend', 'Power Options → Advanced settings → USB settings → USB selective suspend: Disabled'],
+ ]) {
+ for (const [source, label] of [['ac', 'Plugged in'], ['dc', 'On battery']]) {
+ const value = settings[key]?.[source];
+ if (value === null || value === undefined) messages.push(`${name} (${label}): could not verify. Check ${change}.`);
+ else if (value !== 0) messages.push(key === 'usbSuspend'
+ ? `${name} (${label}) is enabled. If this printer disconnects after idle, temporarily test ${change} (${label}); restore it if there is no improvement.`
+ : `${name} (${label}) is enabled. For a dedicated printing counter, set ${change} (${label}).`);
+ }
+ }
+ if (settings.externalKeepAlive) messages.push('A separate Posnic printer keep-alive helper is running. Stop that helper and remove its Startup shortcut before enabling app initialization or idle commands; two writers can interrupt a ticket.');
+ if (settings.externalKeepAlive === null) messages.push('Could not verify external keep-alive helpers. Do not run the diagnostic helper alongside app initialization or idle commands.');
+ return messages;
+}
+let powerCache, powerPending;
+async function systemSettings() {
+ if (powerCache && Date.now() - powerCache.checkedAt < 60000) return powerCache;
+ if (!powerPending) powerPending = inspect('', {}, false, 'power').then(settings => {
+ powerCache = { ...settings, guidance: powerGuidance(settings), checkedAt: Date.now() };
+ return powerCache;
+ }).finally(() => { powerPending = null; });
+ return powerPending;
+}
+
module.exports = {
- inspect,
+ inspect, systemSettings, powerGuidance,
recover: (printer, binding) => inspect(printer, binding, true),
initialize: ({ printer, file, document }) => {
fs.writeFileSync(file, Buffer.from([0x1b, 0x40]), { mode: 0o600 });
diff --git a/tests/windows-print-queue.test.js b/tests/windows-print-queue.test.js
index 3c6e0af41..ff13ff652 100644
--- a/tests/windows-print-queue.test.js
+++ b/tests/windows-print-queue.test.js
@@ -152,7 +152,8 @@ test('unknown USB presence and permission denied fail visibly without elevation'
assert.match((await r.send()).error, /could not be identified/);
r.health.present = true; r.health.workOffline = true;
r.transport.recover = async () => { throw Error('Access denied'); };
- assert.match((await r.send('other')).error, /Access denied/);
+ await r.tick(2000);
+ assert.match((await r.send()).error, /Access denied/);
assert.equal(r.calls.length, 0);
});
@@ -324,3 +325,76 @@ test('real initialization adapter sends exactly ESC @ with no printable bytes, f
await adapter.initialize({ printer: 'Kitchen_Printer', document: 'Paperless-test', file: path.join(r.dir, 'idle.bin') });
assert.deepEqual([...data], [0x1b, 0x40]);
});
+
+function legacyFailure(r, overrides = {}) {
+ const id = idFor('legacy');
+ const job = { id, printer: 'Kitchen_Printer', document: 'Posnic-' + id, binding: { port: 'USB003' },
+ state: 'failed', reason: 'USB device could not be identified; configure its PnP instance ID',
+ submitted: false, retries: 0, nextAt: 0, ...overrides };
+ fs.writeFileSync(path.join(r.dir, id + '.bin'), Buffer.from('test only'));
+ r.queue.write(id + '.json', job); return job;
+}
+test('legacy identity failure is scheduled after restart, without needing fresh enqueue', async t => {
+ const r = rig(t); legacyFailure(r); r.queue.stop();
+ const next = r.make(); await r.tick(0, next);
+ assert.equal(r.calls.length, 1); assert.equal(next.list()[0].status, 'Sent to printer');
+ await r.tick(30000, next); assert.equal(r.calls.length, 1);
+});
+test('legacy identity failure migrates during tick and fresh repeated enqueue', async t => {
+ for (const trigger of ['tick', 'enqueue']) {
+ const r = rig(t); const job = legacyFailure(r); r.queue.jobs.set(job.id, job);
+ if (trigger === 'tick') await r.tick(); else await r.send('legacy');
+ assert.equal(r.calls.length, 1); assert.equal(job.state, 'sent');
+ await r.send('legacy'); assert.equal(r.calls.length, 1);
+ }
+});
+test('migration never replays submitted, accepted, observed, spooler-ID or unrelated failures', async t => {
+ for (const fields of [{submitted:true}, {accepted:true}, {observed:true}, {spoolerId:93}, {reason:'Other error'}]) {
+ const r = rig(t); legacyFailure(r, fields); r.queue.stop(); const next = r.make();
+ await r.tick(0, next); assert.equal(r.calls.length, 0);
+ }
+});
+test('discovery errors retry on schedule and return after exhaustion without duplicate submissions', async t => {
+ const r = rig(t); const inspect = r.transport.inspect;
+ r.transport.inspect = async () => { throw Error('PnP enumeration failed'); };
+ await r.send(); const job = r.queue.jobs.get(idFor('kot-1'));
+ await r.tick(1000); assert.equal(job.retries, 1);
+ await r.tick(1000); await r.tick(5000); await r.tick(10000);
+ assert.equal(job.state, 'failed'); assert.equal(job.reconnect, true); assert.equal(r.calls.length, 0);
+ r.transport.inspect = inspect; await r.tick(); await r.send(); assert.equal(r.calls.length, 1);
+});
+test('ambiguous identity is not disconnected; reconnect resolves it and pins the destination', async t => {
+ const r = rig(t); r.health.discovery = 'ambiguous'; r.health.present = null; r.health.pnpId = '';
+ assert.match((await r.send()).error, /Multiple connected/); assert.equal(r.calls.length, 0);
+ r.health.discovery = 'resolved'; r.health.present = true; r.health.pnpId = 'USBPRINT\\Kitchen\\serial';
+ await r.tick(); assert.equal(r.calls.length, 1);
+ assert.equal(r.queue.jobs.get(idFor('kot-1')).binding.pnpId, r.health.pnpId);
+});
+test('driver extended status Unknown with primary Idle permits serialized quiet-period initialization', async t => {
+ const r = rig(t); r.health.extendedStatus = 2; r.queue.configure('Kitchen_Printer', {});
+ await r.tick(); assert.equal(r.initialized.length, 0);
+ await r.tick(30000); assert.equal(r.initialized.length, 1);
+});
+test('stale saved identity never switches to a replacement; original reconnect resumes once', async t => {
+ const r = rig(t); r.queue.configure('Kitchen_Printer', {port:'USB003',pnpId:r.health.pnpId});
+ r.health.discovery = 'stale-binding'; r.health.present = null;
+ await r.send(); assert.equal(r.calls.length, 0);
+ r.health.discovery = 'resolved'; r.health.present = true;
+ await r.tick(); assert.equal(r.calls.length, 1);
+});
+
+test('external helper or unavailable audit suppresses optional idle writes', async t => {
+ const r = rig(t); r.queue.configure('Kitchen_Printer', {});
+ r.transport.systemSettings = async () => ({externalKeepAlive:true});
+ await r.tick(); await r.tick(30000); assert.equal(r.initialized.length, 0);
+ r.transport.systemSettings = async () => {throw Error('Denied');};
+ await r.tick(30000); assert.equal(r.initialized.length, 0);
+ r.transport.systemSettings = async () => ({externalKeepAlive:false});
+ await r.tick(30000); assert.equal(r.initialized.length, 1);
+});
+test('missing legacy payload is not automatically recreated or replayed', async t => {
+ const r = rig(t); const job = legacyFailure(r);
+ fs.unlinkSync(path.join(r.dir, job.id + '.bin')); r.queue.stop();
+ const next = r.make(); await r.tick(0,next); assert.equal(r.calls.length,0);
+ assert.equal(next.jobs.get(job.id).state,'failed');
+});
diff --git a/tests/windows-printer-discovery.test.js b/tests/windows-printer-discovery.test.js
new file mode 100644
index 000000000..0ad7497d7
--- /dev/null
+++ b/tests/windows-printer-discovery.test.js
@@ -0,0 +1,80 @@
+'use strict';
+const test = require('node:test');
+const assert = require('node:assert/strict');
+const fs = require('node:fs');
+const path = require('node:path');
+const { spawnSync } = require('node:child_process');
+const { powerGuidance } = require('../src/windows-printer-transport');
+const script = fs.readFileSync(path.join(__dirname, '../src/windows-printer-health.ps1'), 'utf8').replace(/\r\n/g, '\n');
+const windows = process.platform === 'win32';
+function ps(code, input) {
+ const result = spawnSync('powershell.exe', ['-NoProfile', '-NonInteractive', '-Command', code], {
+ input: JSON.stringify(input), encoding:'utf8', windowsHide:true, timeout:15000,
+ });
+ assert.equal(result.status, 0, result.stderr || result.error?.message || result.stdout);
+ return JSON.parse(result.stdout.replace(/^\uFEFF/, '').trim());
+}
+const resolver = script.slice(script.indexOf('function Resolve-UsbIdentity'), script.indexOf('# Read-only power audit'));
+function resolve(input) {
+ return ps(resolver + '\n$c = [Console]::In.ReadToEnd() | ConvertFrom-Json; Resolve-UsbIdentity $c.port $c.savedId $c.records $c.presentIds | ConvertTo-Json -Compress', input);
+}
+const a='USBPRINT\\POS80\\A', b='USBPRINT\\POS80\\B', old='USBPRINT\\UnknownPrinter\\OLD';
+const records=[{id:a,port:'USB003'},{id:old,port:'USB003'},{id:b,port:'USB004'}];
+for (const [name, input, expected] of [
+ ['ignores phantom history and selects only present match',{port:'USB003',records,presentIds:[a,b]}, {discovery:'resolved',present:true,pnpId:a}],
+ ['absent is distinct from ambiguity',{port:'USB003',records,presentIds:[b]}, {discovery:'absent',present:false}],
+ ['two present matches never guess',{port:'USB003',records,presentIds:[a,old,b]}, {discovery:'ambiguous',present:null}],
+ ['explicit binding can disambiguate active matches',{port:'USB003',savedId:a,records,presentIds:[a,old,b]}, {discovery:'resolved',present:true,pnpId:a}],
+ ['stale binding never silently adopts another device',{port:'USB003',savedId:old,records,presentIds:[a,b]}, {discovery:'stale-binding',present:null,pnpId:old}],
+ ['saved device on another port is rejected',{port:'USB004',savedId:a,records,presentIds:[a,b]}, {discovery:'port-mismatch',present:null}],
+ ['unknown port for present saved device is unresolved',{port:'USB003',savedId:a,records:[],presentIds:[a]}, {discovery:'unknown',present:null}],
+ ['unmapped present devices are unknown, not proof of disconnection',{port:'USB003',records:[],presentIds:[a]}, {discovery:'unknown',present:null}],
+ ['case insensitive IDs retain proper queue mapping',{port:'usb004',records,presentIds:[b.toLowerCase()]}, {discovery:'resolved',present:true,pnpId:b}],
+]) {
+ test(name, {skip:!windows}, () => {
+ const got=resolve(input); for(const [key,value] of Object.entries(expected))assert.equal(got[key],value,key);
+ });
+}
+test('PnP enumeration failure returns unknown presence with error, not disconnected', {skip:!windows}, () => {
+ const mocks = `function Get-CimInstance { [pscustomobject]@{Name='Kitchen';PortName='USB003';WorkOffline=$false;PrinterStatus=3;ExtendedPrinterStatus=2;DetectedErrorState=0} }
+function Get-PnpDevice { throw 'Enumeration denied' }
+function Get-PrintJob { @() }
+`;
+ const got=ps(mocks+script,{printer:'Kitchen',recover:false});
+ assert.equal(got.discovery,'error'); assert.equal(got.present,null);
+ assert.match(got.discoveryError,/Enumeration denied/); assert.deepEqual(got.jobs,[]);
+});
+test('power guidance covers AC/DC and preserves unknowns without claiming settings changed', () => {
+ assert.deepEqual(powerGuidance({sleep:{ac:0,dc:0},hibernate:{ac:0,dc:0},usbSuspend:{ac:0,dc:0},externalKeepAlive:false}),[]);
+ const bad=powerGuidance({sleep:{ac:60,dc:0},hibernate:{ac:0,dc:120},usbSuspend:{ac:1,dc:null},externalKeepAlive:true}).join('\n');
+ assert.match(bad,/Sleep \(Plugged in\).*Never/);
+ assert.match(bad,/Hibernate \(On battery\).*Never/);
+ assert.match(bad,/USB selective suspend \(Plugged in\).*temporarily test.*Disabled/);
+ assert.match(bad,/On battery\): could not verify/);
+ assert.match(bad,/separate Posnic printer keep-alive/);
+});
+test('power parser accepts localized labels and reports command failure as unknown', {skip:!windows}, () => {
+ const fn=script.slice(script.indexOf('function Read-PowerSetting'),script.indexOf('try {\n $request'));
+ const got=ps(`function powercfg.exe { $global:LASTEXITCODE=0; 'Minimum: 0x00000000'; 'Maximum: 0xffffffff'; 'Netzbetrieb: 0x00000000'; 'Batterie: 0x00000001' };
+`+fn+"\nRead-PowerSetting 'a' 'b' | ConvertTo-Json -Compress",{});
+ assert.deepEqual(got,{ac:0,dc:1});
+ const failed=ps(`function powercfg.exe { $global:LASTEXITCODE=1; 'unsupported' };
+`+fn+"\nRead-PowerSetting 'a' 'b' | ConvertTo-Json -Compress",{});
+ assert.deepEqual(failed,{ac:null,dc:null});
+});
+
+test('Hardware Manager renders power guidance and discovery states as text', async () => {
+ const { JSDOM } = require('jsdom');
+ const html = fs.readFileSync(path.join(__dirname, '../src/hardware-manager.html'), 'utf8');
+ const dom = new JSDOM('', {runScripts:'outside-only'});
+ try {
+ const w = dom.window;
+ w.electronAPI = {printer:{recoveryStatus:async()=>({supported:true, systemSettings:{guidance:['Sleep: Never ']}, health:[{printer:'Kitchen',port:'USB003',discovery:'ambiguous',workOffline:false,printerStatus:3}], jobs:[]})}};
+ w.eval(html.slice(html.indexOf('let wrBindings ='),html.indexOf('async function wrSave()')));
+ await w.wrRefresh();
+ assert.equal(w.document.getElementById('wrPower').textContent,'Sleep: Never ');
+ assert.equal(w.document.querySelector('test'),null);
+ assert.match(w.document.getElementById('wrHealth').textContent,/ambiguous/);
+ assert.equal(w.document.getElementById('windowsRecovery').style.display,'block');
+ } finally {dom.window.close();}
+});