@esp-vision/device-core 0.4.0 → 0.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,6 +1,13 @@
1
1
  # Changelog
2
2
 
3
- ## 0.4.0 (unreleased)
3
+ ## 0.4.1 (unreleased)
4
+
5
+ ### Fixed
6
+
7
+ - Fail camera mode checks on allocation, timeout and unexpected errors instead of silently classifying those modes as unsupported; inspect optional storage state only when advertised.
8
+ - Keep hardware-check fixtures in bounded RAM-only Python calls, independent of filesystem availability. Add expected board/firmware and required-check validation, report storage mount state, and reject explicit file acceptance on unmounted Flash without formatting.
9
+
10
+ ## 0.4.0
4
11
 
5
12
  ### Added
6
13
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@esp-vision/device-core",
3
- "version": "0.4.0",
3
+ "version": "0.4.1",
4
4
  "description": "Shared EV-MUX, framed REPL, device session, and action contracts for ESP Vision clients.",
5
5
  "type": "module",
6
6
  "exports": {
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "$schema": "https://json-schema.org/draft/2020-12/schema",
3
3
  "title": "ESP Vision Device Actions",
4
- "deviceCoreVersion": "0.4.0",
4
+ "deviceCoreVersion": "0.4.1",
5
5
  "schemaVersion": 1,
6
6
  "actions": [
7
7
  {
@@ -2,6 +2,7 @@
2
2
  // Application-level HIL checks. All device I/O goes through createDevice's API.
3
3
  import { readFile, mkdir, writeFile } from 'node:fs/promises'
4
4
  import { dirname } from 'node:path'
5
+ import { EV_REPL_INPUT_MAX_BYTES, evReplExecInputBytes } from '../src/evrepl.js'
5
6
 
6
7
  export const DEVICE_CHECKS = Object.freeze(['identity', 'memory', 'camera', 'framesizes', 'pixformats', 'orientation', 'ipa_stats', 'snapshot_rate', 'preview', 'image_ops', 'display', 'display_stream'])
7
8
  const MARKER = '__EVDEVICECHECK__'
@@ -39,11 +40,14 @@ function pause(milliseconds, signal) {
39
40
  }
40
41
 
41
42
  export async function runDeviceChecks(device, options, { signal, nonce, report = value => console.log(JSON.stringify(value)) } = {}) {
42
- const results = [], fixturePath = `/evhil_${nonce}.py`, namespace = `_evhil_${nonce}`
43
- let ownsFixture = false, loaded = false, namespaceStarted = false, loadError = null
43
+ const results = [], namespace = `_evhil_${nonce}`
44
+ let loaded = false, namespaceStarted = false, loadError = null, inputLimit = EV_REPL_INPUT_MAX_BYTES
44
45
  const parameters = { frames: options.frames, framesize: options.framesize, pixformat: options.pixformat, displayHold: options.displayHold, screenCapture: Boolean(options.screenOutput) }
45
46
  const timeoutFor = name => 1000 * (name === 'display' ? 30 + options.displayHold * 3 : name === 'ipa_stats' ? options.frameTimeout + 40 : options.frameTimeout + Math.max(30, options.frames * (name === 'display_stream' ? 2 : 1)))
46
47
  const execute = async (source, timeout = 10000, operationSignal = signal) => {
48
+ // Larger python.execute() requests use temporary files in Device Core.
49
+ // Keep fixtures in RAM by respecting the actual encoded REPL-line budget.
50
+ if (evReplExecInputBytes(source) > inputLimit) throw failure('REPL_INPUT_LIMIT', 'Hardware fixture command exceeds the device REPL input limit')
47
51
  const result = await device.python.execute({ source, timeout, maxOutputChars: 16384, signal: operationSignal })
48
52
  if (result.ok === false || result.outputTruncated) throw failure('PYTHON_CHECK', 'Device Python check did not complete')
49
53
  return result.output
@@ -52,16 +56,25 @@ export async function runDeviceChecks(device, options, { signal, nonce, report =
52
56
  if (loaded) return
53
57
  if (loadError) throw loadError
54
58
  try {
55
- const entries = await device.files.list({ path: '/', signal })
56
- if (entries.some(entry => [fixturePath, `${fixturePath}.tmp`].includes(entry.path))) throw failure('TEST_PATH_EXISTS', 'Fixture path already exists')
59
+ const info = await device.inspect({ fields: ['capabilities'], signal })
60
+ const advertised = info.capabilities?.limits?.replInputMax
61
+ if (Number.isSafeInteger(advertised) && advertised > 0) inputLimit = Math.min(inputLimit, advertised)
57
62
  const source = await readFile(new URL('./fixtures/device-checks.py', import.meta.url), 'utf8')
58
- ownsFixture = true
59
- await device.files.write({ path: fixturePath, data: source, overwrite: false, signal })
60
63
  namespaceStarted = true
61
- const loader = `with open(${JSON.stringify(fixturePath)}) as _fixture:\n exec(_fixture.read(),globals())`
62
- await execute(`${namespace}={};exec(${JSON.stringify(loader)},${namespace})`)
64
+ await execute(`${namespace}={'_source':[]}`)
65
+ const append = chunk => `${namespace}['_source'].append(${JSON.stringify(chunk)})`
66
+ let chunk = ''
67
+ for (const character of source) {
68
+ if (evReplExecInputBytes(append(chunk + character)) > inputLimit) {
69
+ if (!chunk) throw failure('REPL_INPUT_LIMIT', 'Device REPL input limit cannot load hardware fixtures')
70
+ await execute(append(chunk))
71
+ chunk = ''
72
+ }
73
+ chunk += character
74
+ }
75
+ if (chunk) await execute(append(chunk))
76
+ await execute(`exec(''.join(${namespace}['_source']),${namespace});del ${namespace}['_source']`)
63
77
  loaded = true
64
- await device.files.remove({ path: fixturePath, signal })
65
78
  } catch (error) { loadError = error; throw error }
66
79
  }
67
80
  const invoke = async name => {
@@ -83,8 +96,12 @@ export async function runDeviceChecks(device, options, { signal, nonce, report =
83
96
  let data
84
97
  if (name === 'identity') {
85
98
  const info = await device.inspect({ fields: ['identity', 'capabilities', 'runtime'], signal })
99
+ if (info.capabilities?.features?.includes('storage.writer.lease')) {
100
+ info.storage = (await device.inspect({ fields: ['storage'], signal })).storage
101
+ }
86
102
  if (!info.identity?.board || !info.identity.firmwareVersion || info.capabilities?.protocol?.evMuxVersion !== 4 || info.runtime?.vmReady !== true) throw failure('IDENTITY_INVALID', 'Expected a ready EV-MUX v4 firmware identity')
87
- data = { identity: info.identity, features: info.capabilities.features, vmReady: info.runtime.vmReady }
103
+ data = { identity: info.identity, features: info.capabilities.features, vmReady: info.runtime.vmReady,
104
+ storage: info.storage && Object.fromEntries(Object.entries(info.storage).map(([volume, state]) => [volume, { present: state.present, mounted: state.mounted, writable: state.writable, reason: state.reason }])) }
88
105
  } else if (name === 'camera') {
89
106
  const image = await device.capture({ source: 'camera', format: 'jpeg', quality: options.quality, timeout: options.frameTimeout * 1000, signal })
90
107
  data = await saveJpeg(image, options.output)
@@ -116,14 +133,8 @@ export async function runDeviceChecks(device, options, { signal, nonce, report =
116
133
  result = { name, status: 'PASS', data }
117
134
  } catch (error) {
118
135
  cancelled(signal)
119
- const skip = unsupported(error) && !(name === 'display' && options.screenOutput)
136
+ const skip = unsupported(error) && !options.requireAll && !(name === 'display' && options.screenOutput)
120
137
  result = { name, status: skip ? 'SKIP' : 'FAIL', error: { code: error.code ?? 'DEVICE_CHECK_FAILED', message: String(error.message ?? 'Device check failed').slice(0, 240) } }
121
- if (error.code === 'WRITE_FAILED') {
122
- try {
123
- const status = await device.inspect({ fields: ['storage'], timeout: 3000, signal })
124
- result.storage = Object.fromEntries(Object.entries(status.storage).map(([volume, state]) => [volume, { present: state.present, mounted: state.mounted, status: state.status, writable: state.writable, reason: state.reason }]))
125
- } catch { /* Preserve the upload failure. */ }
126
- }
127
138
  } finally {
128
139
  if (name === 'display' && loaded) {
129
140
  try { await execute(`${namespace}['close_display']()`, 5000, AbortSignal.timeout(6000)) }
@@ -140,13 +151,7 @@ export async function runDeviceChecks(device, options, { signal, nonce, report =
140
151
  try { await execute(`if ${JSON.stringify(namespace)} in globals():\n ${namespace}.get('close_display',lambda:None)()\n del ${namespace}`, 5000, cleanupSignal) }
141
152
  catch (error) { cleanupError = error }
142
153
  }
143
- if (ownsFixture) {
144
- try {
145
- const entries = await device.files.list({ path: '/', signal: cleanupSignal })
146
- for (const entry of entries) if ([fixturePath, `${fixturePath}.tmp`].includes(entry.path)) await device.files.remove({ path: entry.path, signal: cleanupSignal })
147
- } catch (error) { cleanupError ??= error }
148
- }
149
- if (cleanupError) record({ name: 'fixture-cleanup', status: 'FAIL', error: { code: 'FIXTURE_CLEANUP_FAILED', message: 'Could not clean up the hardware fixture', path: fixturePath } })
154
+ if (cleanupError) record({ name: 'fixture-cleanup', status: 'FAIL', error: { code: 'FIXTURE_CLEANUP_FAILED', message: 'Could not release the hardware fixture namespace' } })
150
155
  }
151
156
  const summary = { passed: results.filter(result => result.status === 'PASS').length, failed: results.filter(result => result.status === 'FAIL').length, skipped: results.filter(result => result.status === 'SKIP').length }
152
157
  report({ check: 'summary', ...summary })
@@ -6,6 +6,11 @@ All transport, ownership and cleanup are supplied by the public JS SDK.
6
6
  """
7
7
 
8
8
 
9
+ def is_unsupported(exc):
10
+ message = str(exc).lower()
11
+ return any(token in message for token in ('esp_err_not_supported', 'esp_err_not_found', 'no module named', 'enodev'))
12
+
13
+
9
14
  def framesizes(opts):
10
15
  import sensor
11
16
  sensor.reset()
@@ -18,8 +23,10 @@ def framesizes(opts):
18
23
  img = sensor.snapshot()
19
24
  results.append(dict(name=name, supported=True, width=img.width(), height=img.height(),
20
25
  reportedWidth=sensor.width(), reportedHeight=sensor.height()))
21
- except Exception:
22
- results.append(dict(name=name, supported=False))
26
+ except Exception as exc:
27
+ if not is_unsupported(exc):
28
+ raise
29
+ results.append(dict(name=name, supported=False, reason=str(exc)))
23
30
  working = [item for item in results if item['supported']]
24
31
  assert working, 'no framesize produced a frame'
25
32
  for item in working:
@@ -39,8 +46,10 @@ def pixformats(opts):
39
46
  img = sensor.snapshot()
40
47
  results.append(dict(name=name, supported=True, width=img.width(), height=img.height(),
41
48
  actualBytes=img.size(), expectedBytes=img.width() * img.height() * depth))
42
- except Exception:
43
- results.append(dict(name=name, supported=False))
49
+ except Exception as exc:
50
+ if not is_unsupported(exc):
51
+ raise
52
+ results.append(dict(name=name, supported=False, reason=str(exc)))
44
53
  working = [item for item in results if item['supported']]
45
54
  assert working, 'no pixel format produced a frame'
46
55
  for item in working:
@@ -311,7 +320,6 @@ def run_check(name, opts):
311
320
  result = dict(name=name, status='PASS', data=data)
312
321
  except Exception as exc:
313
322
  message = str(exc)
314
- unsupported = ('ESP_ERR_NOT_SUPPORTED', 'ESP_ERR_NOT_FOUND', 'no module named', 'ENODEV')
315
- status = 'SKIP' if any(token.lower() in message.lower() for token in unsupported) else 'FAIL'
323
+ status = 'SKIP' if is_unsupported(exc) else 'FAIL'
316
324
  result = dict(name=name, status=status, error=dict(type=type(exc).__name__, message=message))
317
325
  print('__EVDEVICECHECK__' + json.dumps(result))
@@ -7,7 +7,7 @@ export { tcpTransport, serialTransport } from '../src/node_transport.js'
7
7
  import { createDevice } from '../src/index.js'
8
8
  import { DEVICE_CHECKS, runDeviceChecks, saveJpeg } from './device-checks.mjs'
9
9
 
10
- export const usage = 'Usage: npm run test:hardware -- (--tcp HOST[:PORT] | --serial PATH) [--role usj|usb|uart] [--python PYTHON] [--write] [--capture] [--checks all|CSV] [--frames N] [--framesize QVGA] [--pixformat RGB565] [--quality 60] [--frame-timeout SECONDS] [--display-hold SECONDS] [--output CAMERA.jpg] [--screen-output LCD.jpg] [--timeout MILLISECONDS]'
10
+ export const usage = 'Usage: npm run test:hardware -- (--tcp HOST[:PORT] | --serial PATH) [--role usj|usb|uart] [--python PYTHON] [--expect-board BOARD] [--expect-firmware-version VERSION] [--write] [--capture] [--checks all|CSV] [--require-all] [--frames N] [--framesize QVGA] [--pixformat RGB565] [--quality 60] [--frame-timeout SECONDS] [--display-hold SECONDS] [--output CAMERA.jpg] [--screen-output LCD.jpg] [--timeout MILLISECONDS]'
11
11
  const fail = (code, message) => Object.assign(new Error(message), { code })
12
12
 
13
13
  export function parseArgs(args) {
@@ -16,8 +16,8 @@ export function parseArgs(args) {
16
16
  for (let index = 0; index < args.length; index++) {
17
17
  const option = args[index]
18
18
  if (option === '--help') return { help: true }
19
- if (option === '--write' || option === '--capture') { options[option.slice(2)] = true; continue }
20
- if (!['--tcp', '--serial', '--role', '--python', '--timeout', '--checks', '--screen-output', '--output', '--frames', '--display-hold', '--framesize', '--pixformat', '--quality', '--frame-timeout'].includes(option)) throw fail('ARGUMENT', 'Unknown argument')
19
+ if (['--write', '--capture', '--require-all'].includes(option)) { options[option === '--require-all' ? 'requireAll' : option.slice(2)] = true; continue }
20
+ if (!['--tcp', '--serial', '--role', '--python', '--timeout', '--checks', '--screen-output', '--output', '--frames', '--display-hold', '--framesize', '--pixformat', '--quality', '--frame-timeout', '--expect-board', '--expect-firmware-version'].includes(option)) throw fail('ARGUMENT', 'Unknown argument')
21
21
  const value = args[++index]
22
22
  if (!value || value.startsWith('--')) throw fail('ARGUMENT', 'Missing argument value')
23
23
  const key = option.slice(2).replace(/-([a-z])/g, (_match, letter) => letter.toUpperCase())
@@ -75,13 +75,20 @@ export async function checkHardware(options, { device = createDevice({ transport
75
75
  await device.connect({ role: options.role, timeout: Math.min(options.timeout, 30000), signal: controller.signal })
76
76
  cancelled()
77
77
  const status = await device.inspect({ fields: ['identity', 'capabilities', 'runtime'], signal: controller.signal })
78
+ if (options.expectBoard && status.identity?.board !== options.expectBoard) throw fail('BOARD_MISMATCH', 'Connected board does not match the requested board')
79
+ if (options.expectFirmwareVersion && status.identity?.firmwareVersion !== options.expectFirmwareVersion) throw fail('FIRMWARE_MISMATCH', 'Running firmware does not match the selected build artifact')
78
80
  // Only selected public identity fields are logged; never network credentials or Python output.
79
- report({ check: 'inspect', ok: true, identity: { deviceId: status.identity?.deviceId ?? null, board: status.identity?.board ?? null, hostname: status.identity?.hostname ?? null } })
81
+ report({ check: 'inspect', ok: true, identity: { deviceId: status.identity?.deviceId ?? null, board: status.identity?.board ?? null, hostname: status.identity?.hostname ?? null, firmwareVersion: status.identity?.firmwareVersion ?? null } })
80
82
  const marker = `EV_HARDWARE_${nonce}`
81
83
  const execution = await device.python.execute({ source: `print('${marker}')`, timeout: 10000, maxOutputChars: 1024, signal: controller.signal })
82
84
  if (!execution.ok || !execution.output.split(/\r?\n/).some(line => line.trim() === marker)) throw fail('PYTHON_CHECK', 'Python marker was not received')
83
85
  report({ check: 'python', ok: true })
84
86
  if (options.write) {
87
+ const storage = await device.inspect({ fields: ['storage'], signal: controller.signal })
88
+ const flash = storage.storage?.flash
89
+ const ready = flash?.present === true && flash?.mounted === true && flash?.writable === true
90
+ report({ check: 'storage', ok: ready, flash: { present: flash?.present ?? null, mounted: flash?.mounted ?? null, writable: flash?.writable ?? null, reason: flash?.reason ?? null } })
91
+ if (!ready) throw fail('STORAGE_UNAVAILABLE', 'File acceptance requires a mounted writable Flash filesystem; no formatting was attempted')
85
92
  const entries = await device.files.list({ path: '/', signal: controller.signal })
86
93
  if (entries.some(entry => entry.path === path || entry.path === `${path}.tmp`)) throw fail('TEST_PATH_EXISTS', 'Test path already exists; refusing to modify it')
87
94
  ownedPath = path
@@ -3,6 +3,7 @@ import test from 'node:test'
3
3
  import assert from 'node:assert/strict'
4
4
  import { readFile, mkdtemp, rm } from 'node:fs/promises'
5
5
  import { join } from 'node:path'
6
+ import { EV_REPL_INPUT_MAX_BYTES, evReplExecInputBytes } from '../src/evrepl.js'
6
7
  import { tmpdir } from 'node:os'
7
8
  import { parseArgs } from './hardware-check.mjs'
8
9
  import { DEVICE_CHECKS, parseCheckResult, runDeviceChecks, saveJpeg } from './device-checks.mjs'
@@ -11,11 +12,11 @@ function devicePeer({ results = {}, board = 'TEST', screenFails = false } = {})
11
12
  const files = new Map(), calls = [], listeners = new Map()
12
13
  return {
13
14
  calls, files, listeners,
14
- async inspect({ fields }) { calls.push(['inspect', fields]); return { identity: { board, firmwareVersion: 'test' }, capabilities: { protocol: { evMuxVersion: 4 }, features: [] }, runtime: { vmReady: true } } },
15
+ async inspect({ fields }) { calls.push(['inspect', fields]); return { identity: { board, firmwareVersion: 'test' }, capabilities: { protocol: { evMuxVersion: 4 }, features: ['storage.writer.lease'], limits: { replInputMax: EV_REPL_INPUT_MAX_BYTES } }, runtime: { vmReady: true }, storage: { flash: { present: true, mounted: false, writable: false } } } },
15
16
  files: Object.assign(files, {
16
- async list() { return [...files.keys()].map(path => ({ path })) },
17
- async write({ path, data, overwrite }) { calls.push(['write', path]); assert.equal(overwrite, false); files.set(path, data) },
18
- async remove({ path }) { calls.push(['remove', path]); files.delete(path) },
17
+ async list() { throw new Error('Runtime checks must not access storage') },
18
+ async write() { throw Object.assign(new Error('Flash unmounted'), { code: 'FILE_OPEN_FAILED' }) },
19
+ async remove() { throw new Error('Runtime checks must not access storage') },
19
20
  }),
20
21
  python: { async execute({ source }) {
21
22
  calls.push(['execute', source])
@@ -48,10 +49,15 @@ test('device check reports reject stale, duplicated and malformed markers', () =
48
49
  for (const output of ['', valid + '\n' + valid, '__EVDEVICECHECK__bad', valid.replace('memory', 'camera'), valid.replace('PASS', 'UNKNOWN')]) assert.throws(() => parseCheckResult(output, 'memory'), { code: 'CHECK_REPORT_INVALID' })
49
50
  })
50
51
 
51
- test('functional checks stage their shared fixture once and consume preview via public events', async () => {
52
+ test('functional checks load fixtures in RAM despite unmounted storage and consume public preview events', async () => {
52
53
  const device = devicePeer(), reports = []
53
54
  await runDeviceChecks(device, parseArgs(['--tcp', 'test', '--checks', 'memory,preview,orientation']), { nonce: 'abc123', report: value => reports.push(value) })
54
- assert.equal(device.calls.filter(call => call[0] === 'write').length, 1)
55
+ const commands = device.calls.filter(call => call[0] === 'execute').map(call => call[1])
56
+ assert.ok(commands.every(source => evReplExecInputBytes(source) <= EV_REPL_INPUT_MAX_BYTES))
57
+ assert.equal(commands.filter(source => source.startsWith("exec(''.join(")).length, 1)
58
+ const loader = commands.filter(source => source.includes("['_source']") || source.includes("={'_source':"))
59
+ const uploaded = loader.filter(source => source.includes("['_source'].append(")).map(source => JSON.parse(source.slice(source.indexOf('.append(') + 8, -1))).join('')
60
+ assert.equal(uploaded, await readFile(new URL('./fixtures/device-checks.py', import.meta.url), 'utf8'))
55
61
  assert.equal(device.files.size, 0)
56
62
  assert.equal(device.listeners.size, 0)
57
63
  assert.equal(reports.find(result => result.check === 'preview').data.received, 1)
@@ -96,3 +102,36 @@ test('truncated JPEG and missing dimensions are refused before host artifact is
96
102
  await assert.rejects(saveJpeg({ data: new Uint8Array([255, 216]), mimeType: 'image/jpeg', metadata: { width: 1, height: 1 } }), { code: 'CAPTURE_INVALID' })
97
103
  await assert.rejects(saveJpeg({ data: new Uint8Array([255, 216, 255, 217]), mimeType: 'image/jpeg' }), { code: 'CAPTURE_INVALID' })
98
104
  })
105
+
106
+
107
+ test('strict acceptance fails unsupported selected checks without preventing independent identity evidence', async () => {
108
+ const device = devicePeer({ results: { memory: { name: 'memory', status: 'SKIP', error: { message: 'ESP_ERR_NOT_SUPPORTED' } } } }), reports = []
109
+ await assert.rejects(runDeviceChecks(device, parseArgs(['--tcp', 'test', '--checks', 'memory,identity', '--require-all']), { nonce: 'abc123', report: value => reports.push(value) }), { code: 'DEVICE_CHECKS_FAILED' })
110
+ assert.equal(reports.find(result => result.check === 'memory').status, 'FAIL')
111
+ assert.equal(reports.find(result => result.check === 'identity').status, 'PASS')
112
+ assert.equal(reports.find(result => result.check === 'identity').data.storage.flash.mounted, false)
113
+ })
114
+
115
+ test('failed partial RAM loading still releases its namespace without filesystem cleanup', async () => {
116
+ const device = devicePeer(), reports = [], execute = device.python.execute
117
+ let failed = false
118
+ device.python.execute = async request => {
119
+ if (!failed && request.source.includes("['_source'].append")) { failed = true; throw new Error('fixture load failed') }
120
+ return execute(request)
121
+ }
122
+ await assert.rejects(runDeviceChecks(device, parseArgs(['--tcp', 'test', '--checks', 'memory']), { nonce: 'abc123', report: value => reports.push(value) }), { code: 'DEVICE_CHECKS_FAILED' })
123
+ assert.ok(device.calls.some(call => call[0] === 'execute' && call[1].includes('del _evhil_abc123')))
124
+ assert.equal(reports.at(-1).failed, 1)
125
+ })
126
+
127
+ test('identity and RAM checks do not require optional storage inspection support', async () => {
128
+ const device = devicePeer(), inspect = device.inspect, reports = []
129
+ device.inspect = async request => {
130
+ assert.ok(!request.fields.includes('storage'))
131
+ const info = await inspect(request)
132
+ info.capabilities.features = []
133
+ return info
134
+ }
135
+ await runDeviceChecks(device, parseArgs(['--tcp', 'test', '--checks', 'identity,memory']), { nonce: 'abc123', report: value => reports.push(value) })
136
+ assert.deepEqual(reports.at(-1), { check: 'summary', passed: 2, failed: 0, skipped: 0 })
137
+ })
@@ -54,7 +54,7 @@ function fakeDevice({ mismatch = false, collision = false, writeFails = false }
54
54
  return {
55
55
  calls, files,
56
56
  async connect() { calls.push('connect') },
57
- async inspect() { return { identity: { board: 'TEST' }, network: { password: 'NEVER_PRINT' } } },
57
+ async inspect() { return { identity: { board: 'TEST', firmwareVersion: 'test' }, storage: { flash: { present: true, mounted: true, writable: true } }, network: { password: 'NEVER_PRINT' } } },
58
58
  python: { async execute() { return { ok: true, output: 'EV_HARDWARE_abc123\n' } } },
59
59
  files: Object.assign(files, {
60
60
  async list() { return [...files.keys()].map(path => ({ path })) },
@@ -101,3 +101,22 @@ test('missing optional Python executable fails cleanly without opening a port',
101
101
  await assert.rejects(transport.connect({ signal: AbortSignal.timeout(1000) }), { code: 'SERIAL_ERROR' })
102
102
  } finally { unsubscribe(); await transport.disconnect() }
103
103
  })
104
+
105
+
106
+ test('expected board and firmware must match before any Python or file operations', async () => {
107
+ for (const args of [['--expect-board', 'OTHER'], ['--expect-firmware-version', 'stale']]) {
108
+ const device = fakeDevice()
109
+ device.python.execute = async () => assert.fail('Wrong device must not execute Python')
110
+ await assert.rejects(checkHardware(parseArgs(['--tcp', 'localhost', ...args]), { device, report() {} }), { code: args[0] === '--expect-board' ? 'BOARD_MISMATCH' : 'FIRMWARE_MISMATCH' })
111
+ assert.deepEqual(device.calls, ['connect', 'disconnect'])
112
+ }
113
+ })
114
+
115
+ test('explicit file acceptance reports unmounted storage and fails without writing or formatting', async () => {
116
+ const device = fakeDevice(), inspect = device.inspect, reports = []
117
+ device.inspect = async request => ({ ...await inspect(request), storage: { flash: { present: true, mounted: false, writable: true, reason: 'HOST_WRITER_ACTIVE' } } })
118
+ await assert.rejects(checkHardware(parseArgs(['--tcp', 'localhost', '--write']), { device, nonce: 'abc123', report: value => reports.push(value) }), { code: 'STORAGE_UNAVAILABLE' })
119
+ assert.deepEqual(device.calls, ['connect', 'disconnect'])
120
+ assert.equal(reports.find(result => result.check === 'storage').flash.mounted, false)
121
+ assert.equal(reports.find(result => result.check === 'storage').ok, false)
122
+ })