From f958cdfaab402ad75e9e3f14efca800e32aba98f Mon Sep 17 00:00:00 2001
From: Kaiser <104786001+KaiserIIII@users.noreply.github.com>
Date: Wed, 7 Oct 2026 12:35:15 +0800
Subject: [PATCH 01/15] docs: define field acceptance upgrade plan
---
.../2026-10-07-field-acceptance-workbench.md | 87 ++++++++++++
.../2026-10-07-field-acceptance-design.md | 129 ++++++++++++++++++
2 files changed, 216 insertions(+)
create mode 100644 docs/superpowers/plans/2026-10-07-field-acceptance-workbench.md
create mode 100644 docs/superpowers/specs/2026-10-07-field-acceptance-design.md
diff --git a/docs/superpowers/plans/2026-10-07-field-acceptance-workbench.md b/docs/superpowers/plans/2026-10-07-field-acceptance-workbench.md
new file mode 100644
index 0000000..343e16c
--- /dev/null
+++ b/docs/superpowers/plans/2026-10-07-field-acceptance-workbench.md
@@ -0,0 +1,87 @@
+# Field Acceptance Workbench Implementation Plan
+
+> **For agentic workers:** Use superpowers:subagent-driven-development for task implementation and review. Steps use checkbox syntax.
+
+**Goal:** Deliver a configuration-driven, evidence-preserving industrial acceptance workflow with a hardware-independent demo and reviewable GitHub changes.
+
+**Architecture:** The existing protocol core stays behind a detector adapter. An inspection service serializes work, persists task snapshots to SQLite, and supplies the HTTP API, comparison and reports. The browser consumes only these APIs.
+
+**Tech Stack:** Python 3.10+, standard-library HTTP/SQLite/threads, existing psutil and pyserial, local HTML/CSS/JavaScript, pytest and coverage for development.
+
+## Global Constraints
+
+- Work in E:/学习/实习/工控验收升级 on codex/field-acceptance-workbench; preserve the original project directory.
+- No real industrial device operations during development. Fake adapters and loopback fixtures only.
+- No third-party Skill installation, no remote transmission of private code/configuration, preserve MIT license.
+- Explicit --demo uses a synthetic catalog and never calls GenericDetector or hardware enumeration.
+- Default bind 127.0.0.1; non-loopback bind requires QLDC_ACCESS_TOKEN.
+- A single process/worker owns one SQLite task DB. Restart marks unfinished tasks interrupted, without replay.
+- Verdicts PASS, FAIL, REVIEW, NOT_RUN; incomplete/failed tasks never count as fully passed.
+- Modes/config hashes/scope must be checked before comparison claims; demo excluded from live statistics.
+- Cancellation is cooperative at device boundaries and reports unfinished devices NOT_RUN.
+- Existing config formats and legacy scan/retest endpoints remain available in live mode under a shared lock.
+
+### Task 1: Acceptance domain, persistence, adapters and HTTP
+
+**Files:** Create inspection/{__init__,domain,store,adapters,service,comparison,reports,http}.py and tests/test_workbench.py, tests/test_http.py; modify web_app.py into a compatibility entry point by preserving the old server in legacy_web.py; create demo catalog in code.
+
+**Interfaces:** InspectionService(db_path, demo=False, config_path=None, adapter=None); catalog() -> dict; create(payload, snapshot=None) -> job dict; get(job_id) -> dict; list_jobs(limit=20, offset=0, status='', query='') -> dict; cancel(job_id) -> dict; retest(job_id,payload) -> dict; insights() -> dict; close() requests cancellation and waits boundedly. Service exposes hardware_lock for legacy calls. Store.get/list/update persist JSON job records and event timestamps. compare_jobs(baseline,current) -> dict; export_job(job,format) -> (bytes,content_type). make_server(host,port,service,token='') -> ThreadingHTTPServer.
+
+**Job JSON:** id, status, mode ('demo'/'live'), scenario ('healthy'/'faults'/'timeout'/'crc'/'missing'), metadata {station_id,batch,operator,notes}, config_hash, config_snapshot, device_ids, parent_job_id, created_at/started_at/finished_at ISO UTC, duration_ms, progress {completed,total}, summary {expected,passed,failed,review,not_run,verdict}, results [], events []. Result fields device_id,name,connection_type,interface,protocol,scope,verdict,fault_code,summary,suggestion,request,response,duration_ms,simulated. catalog returns mode,config_hash,devices [{device_id,name,connection_type,interface,protocol,scope}],scenarios. Every expected device has an initial NOT_RUN result.
+
+- [ ] Write failing service tests for missing devices, restart interruption, cancellation, contention, snapshot preservation, retest selection, scope and mode mismatch, escaped reports.
+
+```python
+def test_missing_device_cannot_pass(tmp_path):
+ service = InspectionService(tmp_path / 'jobs.db', demo=True)
+ job = service.create({'scenario': 'missing', 'station_id': 'DEMO-01'})
+ finished = wait_finished(service, job['id'])
+ assert finished['summary']['failed'] >= 1
+ assert len(finished['results']) == finished['summary']['expected']
+ assert finished['summary']['verdict'] != 'PASS'
+```
+
+- [ ] Run pytest tests/test_workbench.py; confirm missing implementation fails. Implement domain verdicts, bounded validation, atomic SQLite store, then one-worker service. Adapter.run(config, device_ids, scenario, cancelled, on_result) produces normalized device results in order, including parent carrier checks for selected children. Live adapter performs per-top-level-group detection with StandardDeviceConfig(data=snapshot), strips unselected results, reports progress at group boundaries, never filters missing devices. Write-labelled operations are rejected in acceptance regardless of stored allow_write_tests; do not claim arbitrary custom bytes are safe.
+- [ ] Implement reports and comparison. retest only FAIL/REVIEW/NOT_RUN devices using original full config snapshot; payload can change demo scenario and operator notes but cannot widen scope. Full acceptance comparison requires same selected scope; failed-only retest may compare its shared devices with explicit partial scope and cannot claim whole-unit recovery.
+- [ ] Write failing real HTTP tests before http.py. Use ephemeral loopback HTTP server, JSON via urllib, test create/poll/list/cancel/retest/catalog/validate/insights/compare/export and malformed input, missing IDs, foreign origin, missing token, 64 KiB cap, static traversal, legacy isolation in demo, 409 conflict.
+
+```python
+status, body = request('POST', '/api/jobs', {'scenario': 'faults'})
+assert status == 202
+assert body['job']['status'] in ('queued', 'running')
+```
+
+- [ ] Implement routes with wrappers {ok:true,job:...}/{ok:true,jobs:[],total:...}; compare {ok:true,comparison:...}, insights {ok:true,...}, catalog {ok:true,...}. Errors {ok:false,error:...}, use 400/401/403/404/409/413; do not expose exception traces. UI assets /assets/app.js and /assets/style.css served from fixed allowlist. Legacy UI /legacy; old APIs only live, share service lock. New root web/index.html is supplied in Task 3.
+- [ ] Verify service and API tests, run existing self_test.py. Review task for state races, progress, persistent data and hardware access isolation; commit intended files.
+
+### Task 2: Protocol framing regressions
+
+**Files:** Modify core/protocol_engine.py and focused TCP receive paths in core/generic_detector.py; create core/transport.py and tests/test_protocol_regressions.py.
+
+**Interfaces:** receive_response(sock, timeout, max_bytes=4096, complete=None) -> bytes uses a monotonic deadline, accumulates fragments up to a bound, handles EOF, preserves partial response at timeout. A predicate identifies complete Modbus MBAP frames or a satisfied explicit custom response rule. Without a predicate, collect until EOF/deadline rather than assuming recv is one application frame.
+
+- [ ] Write failing tests for split custom TCP and Modbus TCP responses, byte-count mismatches, truncation, wrong transaction/unit/function, CRC error and write function refusal.
+
+```python
+def test_declared_modbus_byte_count_cannot_be_ignored():
+ packet = bytes.fromhex('00 01 00 00 00 05 01 03 04 00 2A')
+ assert not validate_modbus_tcp_response(packet, 1, 1, 3)[0]
+```
+
+- [ ] Run tests and capture observed failures. Replace one-recv assumptions in configured active TCP paths with bounded assembly; preserve actual partial bytes in evidence. Validate Modbus function 1/2 versus 3/4 response byte-count consistency, maximum ADU size, exception size and request count expectations where known.
+- [ ] Run new protocol tests and original loopback self-test, review changes against Modbus official specification; commit.
+
+### Task 3: Local browser workbench, delivery and publication
+
+**Files:** Replace web/index.html; create web/assets/{app.js,style.css}; preserve old page as web/legacy.html. Update README.md, README_zh.md, docs/{ARCHITECTURE,API,DEMO,VALIDATION,DEPENDENCIES}.md, CHANGELOG.md, requirements.txt/requirements-dev.txt, .github/workflows/tests.yml, tests/browser_workflow.cjs, .gitignore.
+
+- [ ] Write browser acceptance script that runs demo on localhost, creates faults task, waits completed, opens evidence, downloads JSON, creates healthy failed-only retest, compares recovery, refreshes history, and checks narrow screen overflow. Run against missing UI, capture failure.
+- [ ] Implement local assets with sidebar, station overview, new-task form, expected catalog, task progress and status, sortable/filterable history, evidence detail drawer, exports, failed retest and two-job comparison. API auth token stored only in session memory/storage, never URL. Text is assigned safely via textContent or escaped templating. Show SIMULATED and partial scope/comparability prominently. Do not fabricate dashboard values or measurement data.
+- [ ] Verify using local server, browser console/network, desktop and 390 px screenshot. Confirm cancellation exits spinner and all completed task rows survive refresh.
+- [ ] Lock reviewed dependency sources/commits; fixed CI Action commits and minimum test environment. No CDN or added runtime framework. Run pytest, coverage (report workbench separately from legacy detector), self-test, config validation, Python compile, JS syntax, fresh demo startup.
+- [ ] Request independent branch review, fix actionable findings and rerun affected tests. Write truthful validation including unverified Linux/Kylin/physical-device paths.
+- [ ] Use GitHub yeet workflow: inspect full diff, commit, push branch, connector create draft PR and attach PR artifact. Verify remote commit/PR.
+
+## Progress
+
+All tasks pending. The controller maintains the SDD progress ledger and records verified commands and commits. No user checkpoint is needed between authorized implementation tasks.
diff --git a/docs/superpowers/specs/2026-10-07-field-acceptance-design.md b/docs/superpowers/specs/2026-10-07-field-acceptance-design.md
new file mode 100644
index 0000000..d18db44
--- /dev/null
+++ b/docs/superpowers/specs/2026-10-07-field-acceptance-design.md
@@ -0,0 +1,129 @@
+# 工控设备现场验收工作台升级设计
+
+日期:2026-10-07(Asia/Hong_Kong)
+
+状态:用户已确认按推荐方案实现并更新 GitHub;进入实施。
+
+目标仓库:KaiserIIII/QLDeviceCheck_Generic_WebUI_Linux。用户已明确仅升级此项目。
+
+## 产品定位
+
+面向设备厂商、工控系统集成商与交付工程师的 Linux 本地现场验收工作台。把设备配置转为验收清单,在执行过程中记录证据,交付后可以复测和比较。离线演示让面试、售前和培训无需连接现场设备。
+
+目标差异化组合:配置驱动的应检设备覆盖 + 串口/网络/PCI 共用验收视图 + 通信路径与业务功能分级 + 故障证据 + 同配置复测对比 + 本机部署。这是产品方向判断,不宣称市场独占、已获客户或已经改善效率。
+
+## 路线选择
+
+| 路线 | 内容 | 取舍 |
+|---|---|---|
+| A:现场验收闭环(推荐) | 异步任务、应检清单、证据、历史记录、基线对比、演示、工程验证 | 在现有协议能力上形成可演示和可交付的业务流程 |
+| B:界面与报告增强 | 美化界面,增加导出、文档和截图 | 较快,不能解决漏检和结果追溯问题 |
+| C:集中设备管理平台 | 多站点、账号、资产、远程代理、工单、调度 | 运维和安全范围明显扩大,超出本轮验收工具定位 |
+
+采用 A。第一版针对单个工位,不做多站点云管理。
+
+## 已核实的起点
+
+- 远程 main:7accc80c7e815b5a5623713b95b6532cf2221472。
+- 当前 core/generic_detector.py 约 2700 行;负责接口枚举、设备搜索、协议收发和结果汇总。
+- web_app.py 同时负责 HTTP、全局扫描状态、串行互斥、检测业务、报告生成和清理。
+- 已有串口 Modbus RTU、Modbus TCP、通用 TCP/UDP、PCI 被动检查、单接口扫描与单设备复测。
+- 测试全部先筛选已发现设备再复测;未响应的应检设备可能未进入最终报告。
+- TCP 收发中存在一次 recv 后即判断的路径;分段响应需补充处理和回归测试。
+- 当前报告压缩后不保留完整报文证据;无持久任务索引、基线对比或硬件独立演示入口。
+- 本地目录不是 Git 仓库,web_app.py 与远程版本不同。实现从远程指定基线创建独立工作副本,保留原目录。
+- 使用现有虚拟环境运行 23 项离线自检通过,配置校验通过。验证只使用纯计算与本机回环模拟器。
+
+## 本轮功能
+
+### 1. 应检设备与验收判定
+
+- 验收以配置中的设备清单为范围;展示应检、完成、通过、失败、待确认及未执行数量。
+- 未发现或未响应设备也生成记录,不因扫描过滤而消失。
+- 结果明确区分 PASS、FAIL、REVIEW、NOT_RUN;存在失败、待确认或未执行时,整体不得显示全部通过。
+- 继电器通过透明 IO 路径响应,仅证明通信路径;PCI 被动检查只证明实际采集的被动状态,不宣称业务功能或电气动作正常。
+- 保留现有全量扫描、单接口扫描、单设备复测能力和配置格式。
+
+### 2. 异步任务与追溯
+
+- 创建任务填写工位/设备编号、批次、操作者和备注,均有长度约束;支持验收和指定设备复测。
+- 后台执行,浏览器轮询状态。一个工位同一时间只允许一个硬件任务,冲突返回 409。
+- 任务状态 queued、running、cancelling、completed、cancelled、failed、interrupted。取消在设备边界生效;不伪装成即时终止串口或网络操作。
+- SQLite 保存任务、配置快照与 SHA-256、设备结果、原始证据、事件和实际耗时。服务重启时未完成任务标为 interrupted,不自动连接硬件重跑。
+- 任务创建时固定配置快照;运行中配置变化不改变正在执行的任务。
+- 每个结果有接口、协议、通信范围、请求/响应、失败分类及基于规则的排查建议。不能把规则建议表述为已确定根因。
+- 新任务数据库与已有 report 目录分离,不删除历史文件。
+
+### 3. 历史、复测和导出
+
+- 历史任务支持状态和文本筛选、分页、查看详情与按失败设备创建复测。
+- 比较两次任务:新增、移除、恢复、退化、仍失败、其他变化、未变化。
+- 模式和配置摘要一致时才能显示可比结论;不一致时明确提示,设备差异仅供查看。
+- HTML、JSON、CSV 导出包含任务元数据、判定、证据和配置摘要;CSV 防止公式注入,HTML 转义不可信内容。
+- 演示报告固定标记 SIMULATED,不混入真实设备通过率;统计仅来自已保存任务。
+
+### 4. 硬件独立演示
+
+- 独立 --demo 启动入口,使用内置合成设备清单与确定性 Fake 适配器,不初始化现场扫描或真实连接。
+- 场景覆盖正常、响应超时、CRC 错误与设备缺失;可以演示故障验收、失败复测、正常恢复及前后对比。
+- UI 始终显示演示模式和模拟证据。演示场景不等于真实硬件兼容性验证。
+- 真实模式继续默认绑定 127.0.0.1。公开到局域网需要配置访问令牌,并在 UI 输入会话凭证;凭证不进入 URL、报告和日志。
+
+### 5. 架构和界面
+
+```text
+浏览器:工位总览 / 新建验收 / 设备清单 / 任务详情与证据 / 历史对比
+ -> HTTP 输入校验与访问边界
+ -> InspectionService(验收范围、任务状态、取消、复测)
+ -> DetectorAdapter / DemoAdapter(统一结果契约)
+ -> 已有 GenericDetector + protocol_engine
+
+InspectionService -> SQLiteStore(任务、事件、配置快照)
+已保存任务 -> ComparisonService / ReportExporter
+```
+
+- 新业务模块按任务、持久化、适配、判定、比较和导出拆分;HTTP 不直接操作串口和 socket。
+- 复用现有配置与底层协议,不为了展示架构更换成微服务。
+- 单页前端拆分本地 CSS/JS,脱离 CDN,保留窄屏操作;包含进度、状态标签、故障证据侧栏与恢复对比。
+- HTTP 限制请求大小、验证枚举与资源 ID,错误不返回 traceback;检查非同源浏览器请求。
+- 自定义指令仍需对设备语义进行人工审查;单凭 operation=read 标签无法保证物理动作安全。
+
+## 接口契约
+
+| 接口 | 行为 |
+|---|---|
+| GET /api/health | 版本、模式、任务状态和持久化就绪信息 |
+| GET /api/catalog | 当前模式下设备清单与配置摘要 |
+| POST /api/config/validate | 校验上传 JSON,不写入现场配置 |
+| POST /api/jobs | 创建验收/复测任务,202 返回任务 ID |
+| GET /api/jobs | 分页和筛选任务 |
+| GET /api/jobs/{id} | 状态、事件、结果和证据 |
+| POST /api/jobs/{id}/cancel | 请求取消,最终状态在设备边界更新 |
+| POST /api/jobs/{id}/retest | 从已保存失败项创建复测,保持原配置快照 |
+| GET /api/compare?baseline=...¤t=... | 可比性和设备差异 |
+| GET /api/jobs/{id}/export?format=html/json/csv | 任务报告 |
+| GET /api/insights | 按真实/演示模式分开的实际运行统计 |
+
+原有 /api/scan、/api/scan-interface、/api/run、/api/test-device 的兼容入口保留,统一互斥并明确旧格式与新任务记录的边界;演示模式下不得通过兼容入口访问真实硬件。
+
+## 验证与交付
+
+1. 保留既有离线自检;新增 pytest 覆盖任务状态、持久化恢复、取消、复测、预期设备缺失、比较与导出。
+2. Fake 和回环协议测试覆盖超时、断线、分段响应、错误 CRC、事务号/功能码/长度不匹配和写操作禁止。
+3. 从真实 HTTP 入口验证所有新增接口、错误输入、访问边界与互斥;不调用现场设备。
+4. 浏览器跑通“故障演示验收 -> 查看证据 -> 导出 -> 恢复复测 -> 比较”,检查桌面与窄屏并保存截图。
+5. 报告实际覆盖率与未覆盖风险。Windows 无法证明银河麒麟、Linux 串口权限、驱动和真实设备兼容性;这些明确列为现场验收项目。
+6. 依赖不递归安装 Skill;必要依赖记录来源、许可证、审核 commit 和安装时间,版本固定。CI Action 固定审核 commit。
+7. 中英文 README、架构说明、部署和演示手册、API、变更记录与实际测试结果随代码交付。沿用远程已有 MIT 许可证。
+8. 验证后推送 codex/field-acceptance-workbench 分支,创建附测试结果的 GitHub PR,更新仓库供评审;不把未验证功能描述为已生产就绪。
+
+## 范围边界
+
+不包含工厂 MES 集成、多站点云服务、计费、完整用户体系、PLC 电气动作和自动写设备。保留扩展接口,后续由实际客户需求驱动。
+
+## 市场参考
+
+- NI TestStand:https://www.ni.com/en/shop/electronic-test-instrumentation/application-software-for-electronic-test-and-instrumentation-category/what-is-teststand.html 。官方列出测试执行、操作界面、报告和数据库记录能力。
+- OpenHTF:https://github.com/google/openhtf 。官方仓库介绍 test run、recipe、phase、measurement、attachment 和 plug 等概念。
+
+上述参考用于确认测试管理能力属于市场已有基础能力。选择配置驱动、Linux 本地工位和现场复测证据作为聚焦方向,属于设计推断;本轮不安装这两个系统。
From 6d9f7d91bf09e37651a80646115df6a579c4bd56 Mon Sep 17 00:00:00 2001
From: Kaiser <104786001+KaiserIIII@users.noreply.github.com>
Date: Wed, 7 Oct 2026 12:55:03 +0800
Subject: [PATCH 02/15] feat: add persistent configuration-driven acceptance
tasks
---
inspection/__init__.py | 1 +
inspection/adapters.py | 67 ++++
inspection/comparison.py | 30 ++
inspection/domain.py | 94 ++++++
inspection/http.py | 216 ++++++++++++
inspection/reports.py | 28 ++
inspection/service.py | 191 +++++++++++
inspection/store.py | 55 ++++
legacy_web.py | 679 ++++++++++++++++++++++++++++++++++++++
tests/test_http.py | 136 ++++++++
tests/test_workbench.py | 314 ++++++++++++++++++
web/legacy.html | 624 +++++++++++++++++++++++++++++++++++
web_app.py | 696 ++-------------------------------------
13 files changed, 2468 insertions(+), 663 deletions(-)
create mode 100644 inspection/__init__.py
create mode 100644 inspection/adapters.py
create mode 100644 inspection/comparison.py
create mode 100644 inspection/domain.py
create mode 100644 inspection/http.py
create mode 100644 inspection/reports.py
create mode 100644 inspection/service.py
create mode 100644 inspection/store.py
create mode 100644 legacy_web.py
create mode 100644 tests/test_http.py
create mode 100644 tests/test_workbench.py
create mode 100644 web/legacy.html
diff --git a/inspection/__init__.py b/inspection/__init__.py
new file mode 100644
index 0000000..4b7812d
--- /dev/null
+++ b/inspection/__init__.py
@@ -0,0 +1 @@
+"""Local field acceptance workbench; hardware access belongs to adapters."""
diff --git a/inspection/adapters.py b/inspection/adapters.py
new file mode 100644
index 0000000..cc5565c
--- /dev/null
+++ b/inspection/adapters.py
@@ -0,0 +1,67 @@
+import copy
+import time
+
+from .domain import catalog_devices, ensure_read_only, initial_result
+
+
+def demo_config():
+ return {'schema_version': 2, 'workstation_id': 'DEMO-01', 'description': 'SIMULATED acceptance catalog',
+ 'settings': {'allow_write_tests': False}, 'devices': [
+ {'device_id': 'DEMO_RELAY', 'device_name': '透明 IO 继电器通信路径', 'device_type': 'relay', 'connection_type': 'serial', 'interface': 'SIM-COM1', 'serial_config': {'baudrate': 9600}, 'protocol': {'type': 'modbus_rtu', 'function_code': 3, 'address': 0, 'count': 2}, 'test_scope': 'communication_path'},
+ {'device_id': 'DEMO_PLC', 'device_name': 'PLC 通信寄存器', 'device_type': 'plc', 'connection_type': 'network', 'interface': 'SIM-ETH0', 'network_config': {'ip': '127.0.0.1', 'port': 502}, 'protocol': {'type': 'modbus_tcp', 'function_code': 3, 'address': 0, 'count': 2}},
+ {'device_id': 'DEMO_PCI', 'device_name': 'PCI 通信卡被动状态', 'device_type': 'pci', 'connection_type': 'pci', 'pci_slot': 'SIM-PCI0', 'pci_match': {'vendor': '1234'}}]}
+
+
+class DemoAdapter:
+ def run(self, config, device_ids, scenario, cancelled, on_result):
+ for index, device in enumerate(d for d in catalog_devices(config) if d['device_id'] in device_ids):
+ if cancelled():
+ return
+ time.sleep(.03)
+ if cancelled():
+ return
+ fault = scenario if scenario in ('timeout', 'crc', 'missing') and index == 0 else 'timeout' if scenario == 'faults' and index == 1 else ''
+ result = initial_result(device, True)
+ result.update(verdict='FAIL' if fault else 'PASS', fault_code=fault.upper(),
+ summary='SIMULATED ' + (fault or ('PCI passive state observed' if device['scope'] == 'pci_passive' else 'Communication path responded')),
+ suggestion='Check configured carrier, cabling and protocol parameters; verify onsite' if fault else 'Business and electrical function require separate verification',
+ request='SIMULATED READ 01 03 00 00 00 02', response='' if fault in ('timeout', 'missing') else 'SIMULATED CRC ERROR' if fault else 'SIMULATED 01 03 04 00 00 00 00', duration_ms=30)
+ on_result(result)
+
+
+def normalize_result(device, raw):
+ result = initial_result(device, False)
+ if raw is None:
+ result.update(verdict='FAIL', fault_code='MISSING', summary='Expected device did not produce a result', suggestion='Check carrier availability and configuration')
+ return result
+ evidence = str(raw.get('evidence') or raw.get('summary') or '')
+ status = raw.get('status')
+ verdict = 'PASS' if status in ('正常', '在线', '已发现') else 'REVIEW' if status == '需确认' else 'FAIL'
+ lower = evidence.lower()
+ fault = '' if verdict == 'PASS' else 'TIMEOUT' if 'timeout' in lower or 'timed out' in lower or '超时' in evidence or '无响应' in evidence else 'CRC' if 'crc' in lower else 'CARRIER' if '中间模块' in evidence else 'RESPONSE_MISMATCH' if raw.get('response') else 'UNAVAILABLE'
+ result.update(verdict=verdict, fault_code=fault, interface=raw.get('interface') or device['interface'], summary=evidence,
+ suggestion='Check cabling, carrier and configured protocol; this is a rule-based suggestion' if fault else 'This result covers communication or passive state only; verify business function separately',
+ request=str(raw.get('request') or ''), response=str(raw.get('response') or ''), duration_ms=max(0, float(raw.get('duration_ms') or 0)))
+ return result
+
+
+class LiveAdapter:
+ def run(self, config, device_ids, scenario, cancelled, on_result):
+ from core.config_manager import StandardDeviceConfig
+ from core.generic_detector import GenericDetector
+ ensure_read_only(config)
+ devices = {d['device_id']: d for d in catalog_devices(config)}
+ for parent in config['devices']:
+ group_ids = [parent['device_id']] + [d['device_id'] for d in parent.get('child_devices', [])]
+ chosen = [d for d in group_ids if d in device_ids]
+ if not chosen:
+ continue
+ if cancelled():
+ return
+ # selected() retains the parent carrier for selected children.
+ selected = StandardDeviceConfig(data=config).selected(chosen)
+ selected.data.setdefault('settings', {})['allow_write_tests'] = False
+ raw = GenericDetector(config=selected, serial_probe=True, subnet_probe=False).run().to_dict()
+ index = {r.get('device_id'): r for r in raw.get('devices', [])}
+ for device_id in chosen:
+ on_result(normalize_result(devices[device_id], index.get(device_id)))
diff --git a/inspection/comparison.py b/inspection/comparison.py
new file mode 100644
index 0000000..fc0ada7
--- /dev/null
+++ b/inspection/comparison.py
@@ -0,0 +1,30 @@
+from .domain import catalog_devices
+
+
+def compare_jobs(baseline, current):
+ left = {r['device_id']: r for r in baseline['results']}
+ right = {r['device_id']: r for r in current['results']}
+ same_scope = set(baseline['device_ids']) == set(current['device_ids'])
+ related_subset = current.get('parent_job_id') == baseline['id'] and set(current['device_ids']).issubset(baseline['device_ids'])
+ configured = {device['device_id'] for device in catalog_devices(current['config_snapshot'])}
+ partial = set(current['device_ids']) != configured
+ reasons = []
+ if baseline['mode'] != current['mode']:
+ reasons.append('Mode mismatch')
+ if baseline['config_hash'] != current['config_hash']:
+ reasons.append('Configuration mismatch')
+ if not same_scope and not related_subset:
+ reasons.append('Selected scope mismatch')
+ if any(left[i]['scope'] != right[i]['scope'] for i in left.keys() & right.keys()):
+ reasons.append('Result scope mismatch')
+ if baseline['status'] not in ('completed', 'cancelled', 'failed', 'interrupted') or current['status'] not in ('completed', 'cancelled', 'failed', 'interrupted'):
+ reasons.append('Jobs are not finished')
+ output = {'baseline_id': baseline['id'], 'current_id': current['id'], 'comparable': not reasons, 'reasons': reasons, 'partial_scope': partial,
+ 'scope': 'shared_devices' if partial else 'full_selection', 'whole_unit_recovered': False,
+ 'added': sorted(right.keys() - left.keys()), 'removed': sorted(left.keys() - right.keys()), 'recovered': [], 'regressed': [], 'still_failed': [], 'changed': [], 'unchanged': []}
+ for device_id in sorted(left.keys() & right.keys()):
+ a, b = left[device_id]['verdict'], right[device_id]['verdict']
+ key = 'recovered' if a != 'PASS' and b == 'PASS' else 'regressed' if a == 'PASS' and b != 'PASS' else 'still_failed' if a == b == 'FAIL' else 'unchanged' if a == b else 'changed'
+ output[key].append(device_id)
+ output['whole_unit_recovered'] = not reasons and not partial and bool(output['recovered']) and current['summary']['verdict'] == 'PASS'
+ return output
diff --git a/inspection/domain.py b/inspection/domain.py
new file mode 100644
index 0000000..a113649
--- /dev/null
+++ b/inspection/domain.py
@@ -0,0 +1,94 @@
+import copy
+import hashlib
+import json
+from datetime import datetime, timezone
+
+STATUSES = {'queued', 'running', 'cancelling', 'completed', 'cancelled', 'failed', 'interrupted'}
+TERMINAL = {'completed', 'cancelled', 'failed', 'interrupted'}
+SCENARIOS = ['healthy', 'faults', 'timeout', 'crc', 'missing']
+VERDICTS = {'PASS', 'FAIL', 'REVIEW', 'NOT_RUN'}
+
+
+def utc_now():
+ return datetime.now(timezone.utc).isoformat(timespec='milliseconds').replace('+00:00', 'Z')
+
+
+def config_hash(snapshot):
+ return hashlib.sha256(json.dumps(snapshot, sort_keys=True, ensure_ascii=False, separators=(',', ':')).encode()).hexdigest()
+
+
+def catalog_devices(snapshot):
+ # Configuration validation is pure computation; detector imports stay in LiveAdapter.
+ from core.config_manager import StandardDeviceConfig
+ result = []
+ for device in StandardDeviceConfig(data=snapshot).all_configured_devices():
+ scope = 'pci_passive' if device['connection_type'] == 'pci' else 'communication_path'
+ result.append({'device_id': device['device_id'], 'name': device['device_name'],
+ 'connection_type': device['connection_type'],
+ 'interface': device.get('interface') or device.get('pci_slot') or '',
+ 'protocol': (device.get('protocol') or {}).get('type', ''), 'scope': scope})
+ return result
+
+
+def ensure_read_only(snapshot):
+ def visit(value):
+ if isinstance(value, dict):
+ if str(value.get('operation', '')).lower() == 'write':
+ raise ValueError('Acceptance rejects write-labelled operations')
+ if 'function_code' in value:
+ code = value['function_code']
+ try:
+ number = int(code, 0) if isinstance(code, str) else int(code)
+ except (TypeError, ValueError):
+ raise ValueError('Invalid Modbus function code') from None
+ if number not in (1, 2, 3, 4):
+ raise ValueError('Acceptance rejects Modbus write operations')
+ for child in value.values():
+ visit(child)
+ elif isinstance(value, list):
+ for child in value:
+ visit(child)
+ visit(snapshot)
+
+
+def validate_payload(payload, devices, mode):
+ if not isinstance(payload, dict):
+ raise ValueError('Expected JSON object')
+ allowed = {'scenario', 'station_id', 'batch', 'operator', 'notes', 'device_ids', 'metadata'}
+ if set(payload) - allowed:
+ raise ValueError('Unknown job field')
+ scenario = payload.get('scenario', 'healthy')
+ if scenario not in SCENARIOS or (mode == 'live' and scenario != 'healthy'):
+ raise ValueError('Invalid scenario for this mode')
+ metadata = payload.get('metadata', {})
+ if not isinstance(metadata, dict) or set(metadata) - {'station_id', 'batch', 'operator', 'notes'}:
+ raise ValueError('Invalid metadata')
+ metadata = {key: payload.get(key, metadata.get(key, '')) for key in ('station_id', 'batch', 'operator', 'notes')}
+ for key, value in metadata.items():
+ if not isinstance(value, str) or len(value) > (2000 if key == 'notes' else 128):
+ raise ValueError('Invalid metadata length or type')
+ ids = payload.get('device_ids', [d['device_id'] for d in devices])
+ known = {d['device_id'] for d in devices}
+ if not isinstance(ids, list) or not ids or len(ids) > 1000 or any(not isinstance(i, str) or i not in known for i in ids) or len(set(ids)) != len(ids):
+ raise ValueError('Invalid device selection')
+ selected = [d['device_id'] for d in devices if d['device_id'] in ids]
+ return scenario, metadata, selected
+
+
+def initial_result(device, simulated):
+ return dict(copy.deepcopy(device), verdict='NOT_RUN', fault_code='NOT_RUN', summary='Not executed',
+ suggestion='Run acceptance for this device', request='', response='', duration_ms=0, simulated=simulated)
+
+
+def summarize(results):
+ counts = {v: sum(r['verdict'] == v for r in results) for v in VERDICTS}
+ verdict = 'FAIL' if counts['FAIL'] else 'REVIEW' if counts['REVIEW'] else 'NOT_RUN' if counts['NOT_RUN'] or not results else 'PASS'
+ return {'expected': len(results), 'passed': counts['PASS'], 'failed': counts['FAIL'], 'review': counts['REVIEW'], 'not_run': counts['NOT_RUN'], 'verdict': verdict}
+
+
+def refresh(job):
+ job['summary'] = summarize(job['results'])
+ if job['status'] in ('failed', 'cancelled', 'interrupted') and job['summary']['verdict'] == 'PASS':
+ job['summary']['verdict'] = 'REVIEW'
+ job['progress'] = {'completed': sum(r['verdict'] != 'NOT_RUN' for r in job['results']), 'total': len(job['results'])}
+ return job
diff --git a/inspection/http.py b/inspection/http.py
new file mode 100644
index 0000000..da5011e
--- /dev/null
+++ b/inspection/http.py
@@ -0,0 +1,216 @@
+import hmac
+import ipaddress
+import json
+import re
+from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
+from pathlib import Path
+from urllib.parse import parse_qs, unquote, urlsplit
+
+from .comparison import compare_jobs
+from .domain import catalog_devices, config_hash, ensure_read_only
+from .reports import export_job
+
+BASE_DIR = Path(__file__).resolve().parent.parent
+JOB_PATH = re.compile(r'^/api/jobs/([a-f0-9]{32})(?:/(cancel|retest|export))?$')
+MAX_BODY = 65536
+
+
+class HttpError(Exception):
+ def __init__(self, status, message):
+ self.status = status
+ super().__init__(message)
+
+
+def loopback(host):
+ try:
+ return ipaddress.ip_address(host).is_loopback
+ except ValueError:
+ return host.lower() == 'localhost'
+
+
+def make_server(host, port, service, token=''):
+ if not loopback(host) and not token:
+ raise ValueError('Nonloopback bind requires QLDC_ACCESS_TOKEN')
+ class Server(ThreadingHTTPServer):
+ daemon_threads = True
+ server = Server((host, port), Handler)
+ server.service = service
+ server.access_token = token
+ server.bind_host = host
+ return server
+
+
+class Handler(BaseHTTPRequestHandler):
+ server_version = 'QLDeviceCheckWorkbench/3.0'
+
+ def setup(self):
+ super().setup()
+ self.connection.settimeout(5)
+
+ def do_GET(self):
+ self._dispatch('GET')
+
+ def do_POST(self):
+ self._dispatch('POST')
+
+ def _dispatch(self, method):
+ try:
+ if method == 'POST':
+ self._body()
+ parsed = urlsplit(self.path)
+ path = unquote(parsed.path)
+ self._access(path)
+ query = parse_qs(parsed.query)
+ if method == 'GET' and path in ('/', '/index.html', '/legacy', '/assets/app.js', '/assets/style.css'):
+ relative = {'/': 'web/index.html', '/index.html': 'web/index.html', '/legacy': 'web/legacy.html', '/assets/app.js': 'web/assets/app.js', '/assets/style.css': 'web/assets/style.css'}[path]
+ target = BASE_DIR / relative
+ if not target.is_file():
+ raise HttpError(404, 'Asset not found')
+ mime = 'text/javascript' if path.endswith('.js') else 'text/css' if path.endswith('.css') else 'text/html'
+ return self._send(target.read_bytes(), mime + '; charset=utf-8')
+ if method == 'GET' and path == '/favicon.ico':
+ return self._send(b'', 'image/x-icon', 204)
+ svc = self.server.service
+ if method == 'GET' and path == '/api/health':
+ return self._json({'ok': True, 'version': '3.0', 'mode': svc.mode, 'active_job_id': svc.active_id, 'persistence_ready': not svc.closed})
+ if method == 'GET' and path == '/api/catalog':
+ return self._json(dict(ok=True, **svc.catalog()))
+ if method == 'POST' and path == '/api/config/validate':
+ body = self._body()
+ snapshot = body.get('config', body.get('snapshot', body))
+ if not isinstance(snapshot, dict):
+ raise ValueError('Expected configuration object')
+ devices = catalog_devices(snapshot)
+ ensure_read_only(snapshot)
+ return self._json({'ok': True, 'valid': True, 'config_hash': config_hash(snapshot), 'devices': devices, 'device_count': len(devices), 'warnings': ['Custom bytes require manual review; a read label does not establish physical safety']})
+ if path == '/api/jobs':
+ if method == 'POST':
+ return self._json({'ok': True, 'job': svc.create(self._body())}, 202)
+ return self._json(dict(ok=True, **svc.list_jobs(limit=int(query.get('limit', ['20'])[0]), offset=int(query.get('offset', ['0'])[0]), status=query.get('status', [''])[0], query=query.get('query', query.get('q', ['']))[0])))
+ if method == 'GET' and path == '/api/insights':
+ return self._json(dict(ok=True, **svc.insights()))
+ if method == 'GET' and path == '/api/compare':
+ baseline, current = query.get('baseline', [''])[0], query.get('current', [''])[0]
+ if not re.fullmatch('[a-f0-9]{32}', baseline) or not re.fullmatch('[a-f0-9]{32}', current):
+ raise ValueError('Invalid comparison job ID')
+ return self._json({'ok': True, 'comparison': compare_jobs(svc.get(baseline), svc.get(current))})
+ match = JOB_PATH.fullmatch(path)
+ if match:
+ job_id, action = match.groups()
+ if method == 'GET' and action is None:
+ return self._json({'ok': True, 'job': svc.get(job_id)})
+ if method == 'GET' and action == 'export':
+ format = query.get('format', ['html'])[0]
+ content, mime = export_job(svc.get(job_id), format)
+ return self._send(content, mime, headers={'Content-Disposition': 'attachment; filename="acceptance-' + job_id + '.' + format + '"'})
+ if method == 'POST' and action in ('cancel', 'retest'):
+ body = self._body()
+ job = svc.cancel(job_id) if action == 'cancel' else svc.retest(job_id, body)
+ return self._json({'ok': True, 'job': job}, 202 if action == 'retest' else 200)
+ if path in ('/api/scan', '/api/scan-interface', '/api/run', '/api/test-device', '/api/standards'):
+ if svc.demo:
+ raise HttpError(403, 'Legacy hardware APIs are disabled in demo mode')
+ if path == '/api/standards' and method == 'GET':
+ from core.config_manager import StandardDeviceConfig
+ return self._json({'ok': True, 'devices': StandardDeviceConfig(svc.config_path).devices() if svc.config_path else StandardDeviceConfig().devices(), 'legacy': True})
+ if method == 'POST':
+ body = self._body()
+ if not svc.hardware_lock.acquire(blocking=False):
+ raise RuntimeError('A station operation is already running')
+ try:
+ import legacy_web
+ legacy_web.RUN_LOCK = svc.hardware_lock
+ operations = {'/api/scan': legacy_web.scan_catalog, '/api/scan-interface': legacy_web.scan_interface, '/api/run': legacy_web.test_all_discovered, '/api/test-device': legacy_web.test_single_device}
+ return self._json(dict(operations[path](body), legacy=True))
+ finally:
+ svc.hardware_lock.release()
+ if method == 'GET' and path.startswith('/report/'):
+ if svc.demo:
+ raise HttpError(403, 'Legacy reports are disabled in demo mode')
+ root = (BASE_DIR / 'report').resolve()
+ target = (root / path[len('/report/'):]).resolve()
+ if not target.is_relative_to(root) or target.suffix not in ('.html', '.json', '.txt') or not target.is_file():
+ raise HttpError(404, 'Report not found')
+ mime = {'.html': 'text/html', '.json': 'application/json', '.txt': 'text/plain'}[target.suffix]
+ return self._send(target.read_bytes(), mime + '; charset=utf-8')
+ raise HttpError(404, 'Route not found')
+ except HttpError as exc:
+ self._json({'ok': False, 'error': str(exc)}, exc.status)
+ except KeyError:
+ self._json({'ok': False, 'error': 'Job not found'}, 404)
+ except (ValueError, TypeError):
+ self._json({'ok': False, 'error': 'Invalid request; check input fields and configuration'}, 400)
+ except RuntimeError:
+ self._json({'ok': False, 'error': 'Station operation conflict or service unavailable'}, 409)
+ except Exception:
+ self._json({'ok': False, 'error': 'Operation failed'}, 500)
+
+ def _access(self, path):
+ host = self.headers.get('Host', '')
+ try:
+ parsed = urlsplit('http://' + host)
+ hostname, port = parsed.hostname or '', parsed.port or 80
+ except ValueError:
+ raise HttpError(403, 'Invalid host') from None
+ if port != self.server.server_port or (loopback(self.server.bind_host) and not loopback(hostname)):
+ raise HttpError(403, 'Untrusted host')
+ origin = self.headers.get('Origin')
+ if origin and origin != 'http://' + host:
+ raise HttpError(403, 'Foreign origin is not allowed')
+ if self.headers.get('Sec-Fetch-Site') == 'cross-site':
+ raise HttpError(403, 'Cross-site requests are not allowed')
+ token = self.server.access_token
+ if token and (path.startswith('/api/') or path.startswith('/report/')):
+ if not hmac.compare_digest(self.headers.get('Authorization', ''), 'Bearer ' + token):
+ raise HttpError(401, 'Access token required')
+
+ def _body(self):
+ if hasattr(self, '_parsed_body'):
+ return self._parsed_body
+ if self.headers.get('Transfer-Encoding'):
+ raise HttpError(400, 'Transfer encoding is not supported')
+ try:
+ length = int(self.headers.get('Content-Length', '0'))
+ except ValueError:
+ raise HttpError(400, 'Invalid content length') from None
+ if length < 0:
+ raise HttpError(400, 'Invalid content length')
+ if length > MAX_BODY:
+ # Bounded discard avoids a TCP reset hiding the 413 response on Windows.
+ # No oversized body is decoded, allocated in full, or dispatched.
+ self.connection.settimeout(.2)
+ try:
+ self.rfile.read(min(length, MAX_BODY + 1))
+ except OSError:
+ pass
+ raise HttpError(413, 'Request exceeds 64 KiB')
+ raw = self.rfile.read(length)
+ if len(raw) != length:
+ raise HttpError(400, 'Incomplete request body')
+ try:
+ body = json.loads(raw.decode('utf-8')) if raw else {}
+ except (UnicodeError, ValueError):
+ raise HttpError(400, 'Malformed JSON') from None
+ if not isinstance(body, dict):
+ raise HttpError(400, 'Expected JSON object')
+ self._parsed_body = body
+ return body
+
+ def _json(self, data, status=200):
+ self._send(json.dumps(data, ensure_ascii=False).encode(), 'application/json; charset=utf-8', status)
+
+ def _send(self, content, content_type, status=200, headers=None):
+ self.send_response(status)
+ self.send_header('Content-Type', content_type)
+ self.send_header('Content-Length', str(len(content)))
+ self.send_header('Cache-Control', 'no-store')
+ self.send_header('X-Content-Type-Options', 'nosniff')
+ self.send_header('Referrer-Policy', 'no-referrer')
+ for key, value in (headers or {}).items():
+ self.send_header(key, value)
+ self.end_headers()
+ self.wfile.write(content)
+
+ def log_message(self, fmt, *args):
+ # Request URLs can contain operator data; never log them or credentials.
+ pass
diff --git a/inspection/reports.py b/inspection/reports.py
new file mode 100644
index 0000000..6ba9b5c
--- /dev/null
+++ b/inspection/reports.py
@@ -0,0 +1,28 @@
+import csv
+import html
+import io
+import json
+
+
+def export_job(job, format):
+ marker = 'SIMULATED' if job['mode'] == 'demo' else 'LIVE'
+ if format == 'json':
+ return json.dumps(dict(job, report_marker=marker), ensure_ascii=False, indent=2).encode(), 'application/json; charset=utf-8'
+ if format == 'html':
+ esc = lambda value: html.escape(str(value), quote=True)
+ # Complete evidence is retained, including the original configuration snapshot.
+ content = '
Acceptance report ' + marker + ' Acceptance report Scope: communication path / PCI passive state. Business and electrical function require separate verification.
' + esc(json.dumps(job, ensure_ascii=False, indent=2)) + ' '
+ return content.encode(), 'text/html; charset=utf-8'
+ if format == 'csv':
+ stream = io.StringIO(newline='')
+ writer = csv.writer(stream)
+ columns = ['report_marker', 'job_id', 'status', 'mode', 'scenario', 'station_id', 'batch', 'operator', 'notes', 'config_hash', 'config_snapshot', 'created_at', 'finished_at', 'device_id', 'name', 'connection_type', 'interface', 'protocol', 'scope', 'verdict', 'fault_code', 'summary', 'suggestion', 'request', 'response', 'duration_ms', 'simulated']
+ writer.writerow(columns)
+ def safe(value):
+ text = str(value if value is not None else '')
+ return "'" + text if text.lstrip().startswith(('=', '+', '-', '@')) or text.startswith(('\t', '\r', '\n')) else text
+ for result in job['results']:
+ row = dict(job, **job['metadata'], **result, report_marker=marker, job_id=job['id'], config_snapshot=json.dumps(job['config_snapshot'], ensure_ascii=False))
+ writer.writerow([safe(row.get(key, '')) for key in columns])
+ return stream.getvalue().encode('utf-8-sig'), 'text/csv; charset=utf-8'
+ raise ValueError('Unsupported export format')
diff --git a/inspection/service.py b/inspection/service.py
new file mode 100644
index 0000000..bb48df1
--- /dev/null
+++ b/inspection/service.py
@@ -0,0 +1,191 @@
+import copy
+import threading
+import time
+import uuid
+
+from .adapters import DemoAdapter, LiveAdapter, demo_config
+from .domain import SCENARIOS, TERMINAL, catalog_devices, config_hash, ensure_read_only, initial_result, refresh, utc_now, validate_payload
+from .store import SQLiteStore
+
+
+class InspectionService:
+ def __init__(self, db_path, demo=False, config_path=None, adapter=None):
+ self.demo = demo
+ self.mode = 'demo' if demo else 'live'
+ self.config_path = config_path
+ self.adapter = adapter if adapter is not None else DemoAdapter() if demo else LiveAdapter()
+ self.hardware_lock = threading.Lock()
+ self.lock = threading.RLock()
+ self.store = SQLiteStore(db_path)
+ self.closed = False
+ self.worker = None
+ self.active_id = None
+ self.active_started = None
+ self.cancel_event = threading.Event()
+ for job in self.store.all():
+ if job['status'] not in TERMINAL:
+ job['status'] = 'interrupted'
+ job['finished_at'] = utc_now()
+ job['events'].append({'at': utc_now(), 'type': 'interrupted', 'message': 'Service restarted; no automatic hardware retry'})
+ self.store.update(refresh(job))
+
+ def _snapshot(self):
+ if self.demo:
+ return demo_config()
+ from core.config_manager import StandardDeviceConfig
+ return StandardDeviceConfig(self.config_path).data if self.config_path else StandardDeviceConfig().data
+
+ def catalog(self):
+ snapshot = self._snapshot()
+ return {'mode': self.mode, 'config_hash': config_hash(snapshot), 'config_snapshot': snapshot,
+ 'devices': catalog_devices(snapshot), 'scenarios': SCENARIOS if self.demo else ['healthy']}
+
+ def create(self, payload, snapshot=None):
+ snapshot = copy.deepcopy(snapshot if snapshot is not None else self._snapshot())
+ devices = catalog_devices(snapshot)
+ ensure_read_only(snapshot)
+ scenario, metadata, selected = validate_payload(payload, devices, self.mode)
+ with self.lock:
+ if self.closed:
+ raise RuntimeError('Service is closed')
+ if not self.hardware_lock.acquire(blocking=False):
+ raise RuntimeError('A station operation is already running')
+ try:
+ job = refresh({'id': uuid.uuid4().hex, 'status': 'queued', 'mode': self.mode, 'scenario': scenario,
+ 'metadata': metadata, 'config_hash': config_hash(snapshot), 'config_snapshot': snapshot,
+ 'device_ids': selected, 'parent_job_id': None, 'created_at': utc_now(), 'started_at': None,
+ 'finished_at': None, 'duration_ms': 0, 'results': [initial_result(d, self.demo) for d in devices if d['device_id'] in selected],
+ 'events': [{'at': utc_now(), 'type': 'queued', 'message': 'Acceptance queued'}]})
+ self.store.insert(job)
+ self.active_id = job['id']
+ self.cancel_event = threading.Event()
+ self.worker = threading.Thread(target=self._run, args=(job['id'],), daemon=True, name='acceptance-worker')
+ self.worker.start()
+ return copy.deepcopy(job)
+ except Exception:
+ self.hardware_lock.release()
+ raise
+
+ def _run(self, job_id):
+ started = time.monotonic()
+ try:
+ with self.lock:
+ self.active_started = started
+ job = self.store.get(job_id)
+ job['status'] = 'cancelling' if self.cancel_event.is_set() else 'running'
+ job['started_at'] = utc_now()
+ job['events'].append({'at': utc_now(), 'type': 'running', 'message': 'Acceptance started'})
+ self.store.update(job)
+ def on_result(result):
+ with self.lock:
+ if self.closed:
+ return
+ job = self.store.get(job_id)
+ matches = [i for i, r in enumerate(job['results']) if r['device_id'] == result.get('device_id')]
+ if not matches or result.get('verdict') not in ('PASS', 'FAIL', 'REVIEW', 'NOT_RUN'):
+ raise ValueError('Invalid adapter result')
+ expected = job['results'][matches[0]]
+ value = dict(expected, **copy.deepcopy(result))
+ value['scope'] = expected['scope']
+ value['simulated'] = self.demo
+ job['results'][matches[0]] = value
+ job['events'].append({'at': utc_now(), 'type': 'result', 'device_id': value['device_id'], 'message': value['verdict']})
+ self.store.update(refresh(job))
+ if not self.cancel_event.is_set():
+ self.adapter.run(copy.deepcopy(job['config_snapshot']), list(job['device_ids']), job['scenario'], self.cancel_event.is_set, on_result)
+ with self.lock:
+ if not self.closed:
+ job = self.store.get(job_id)
+ status = 'cancelled' if self.cancel_event.is_set() else 'completed'
+ if status == 'completed':
+ for result in job['results']:
+ if result['verdict'] == 'NOT_RUN':
+ result.update(verdict='FAIL', fault_code='MISSING', summary='Expected device produced no result', suggestion='Check carrier and device configuration')
+ self._finish(job, status, started)
+ except Exception:
+ with self.lock:
+ if not self.closed:
+ self._finish(self.store.get(job_id), 'failed', started)
+ finally:
+ with self.lock:
+ self.active_id = None
+ self.active_started = None
+ self.hardware_lock.release()
+
+ def _finish(self, job, status, started):
+ job.update(status=status, finished_at=utc_now(), duration_ms=round((time.monotonic() - started) * 1000, 2))
+ job['events'].append({'at': utc_now(), 'type': status, 'message': 'Acceptance ' + status})
+ self.store.update(refresh(job))
+
+ def get(self, job_id):
+ return self.store.get(job_id)
+
+ def list_jobs(self, limit=20, offset=0, status='', query=''):
+ return self.store.list(limit, offset, status, query)
+
+ def cancel(self, job_id):
+ with self.lock:
+ job = self.get(job_id)
+ if job['status'] in TERMINAL:
+ return job
+ if self.active_id != job_id:
+ raise RuntimeError('Job is not active')
+ self.cancel_event.set()
+ job['status'] = 'cancelling'
+ job['events'].append({'at': utc_now(), 'type': 'cancelling', 'message': 'Cancellation requested; effective at a device/group boundary'})
+ self.store.update(job)
+ return job
+
+ def retest(self, job_id, payload):
+ with self.lock:
+ original = self.get(job_id)
+ if original['status'] not in TERMINAL:
+ raise RuntimeError('Retest requires a finished job')
+ if original['mode'] != self.mode:
+ raise ValueError('Retest mode differs from service mode')
+ if not isinstance(payload, dict) or set(payload) - {'scenario', 'operator', 'notes', 'device_ids'}:
+ raise ValueError('Retest can only change scenario, operator or notes')
+ selected = [r['device_id'] for r in original['results'] if r['verdict'] in ('FAIL', 'REVIEW', 'NOT_RUN')]
+ if 'device_ids' in payload:
+ ids = payload['device_ids']
+ if not isinstance(ids, list) or not ids or any(not isinstance(i, str) for i in ids) or len(set(ids)) != len(ids) or any(i not in selected for i in ids):
+ raise ValueError('Retest cannot widen scope')
+ selected = [i for i in selected if i in ids]
+ if not selected:
+ raise ValueError('No failed or pending devices to retest')
+ data = dict(original['metadata'], scenario=payload.get('scenario', original['scenario']), device_ids=selected)
+ data.update({k: v for k, v in payload.items() if k in ('operator', 'notes')})
+ job = self.create(data, snapshot=original['config_snapshot'])
+ saved = self.store.get(job['id'])
+ saved['parent_job_id'] = original['id']
+ self.store.update(saved)
+ job['parent_job_id'] = original['id']
+ return job
+
+ def insights(self):
+ jobs = self.store.all()
+ output = {}
+ for mode in ('demo', 'live'):
+ records = [job for job in jobs if job['mode'] == mode]
+ finished = [job for job in records if job['status'] == 'completed']
+ output[mode] = {'total': len(records), 'completed': len(finished), 'passed': sum(job['summary']['verdict'] == 'PASS' for job in finished),
+ 'failed': sum(job['summary']['verdict'] == 'FAIL' for job in finished), 'devices_passed': sum(job['summary']['passed'] for job in finished),
+ 'devices_expected': sum(job['summary']['expected'] for job in finished)}
+ return output
+
+ def close(self):
+ with self.lock:
+ if self.closed:
+ return
+ self.cancel_event.set()
+ worker = self.worker
+ if worker:
+ worker.join(timeout=2)
+ with self.lock:
+ if self.closed:
+ return
+ if self.active_id:
+ job = self.store.get(self.active_id)
+ self._finish(job, 'interrupted', self.active_started or time.monotonic())
+ self.closed = True
+ self.store.close()
diff --git a/inspection/store.py b/inspection/store.py
new file mode 100644
index 0000000..fa39cdc
--- /dev/null
+++ b/inspection/store.py
@@ -0,0 +1,55 @@
+import json
+import sqlite3
+import threading
+from pathlib import Path
+
+from .domain import STATUSES
+
+
+class SQLiteStore:
+ """One process owns this store. Each complete JSON record is committed atomically."""
+ def __init__(self, path):
+ Path(path).parent.mkdir(parents=True, exist_ok=True)
+ self.lock = threading.RLock()
+ self.connection = sqlite3.connect(str(path), check_same_thread=False, timeout=5)
+ self.connection.execute('PRAGMA journal_mode=WAL')
+ self.connection.execute('CREATE TABLE IF NOT EXISTS jobs (id TEXT PRIMARY KEY, created_at TEXT NOT NULL, status TEXT NOT NULL, record TEXT NOT NULL)')
+ self.connection.commit()
+
+ def insert(self, job):
+ with self.lock, self.connection:
+ self.connection.execute('INSERT INTO jobs VALUES (?, ?, ?, ?)', (job['id'], job['created_at'], job['status'], json.dumps(job, ensure_ascii=False)))
+
+ def get(self, job_id):
+ with self.lock:
+ row = self.connection.execute('SELECT record FROM jobs WHERE id=?', (job_id,)).fetchone()
+ if row is None:
+ raise KeyError('Job not found')
+ return json.loads(row[0])
+
+ def update(self, job):
+ with self.lock, self.connection:
+ cursor = self.connection.execute('UPDATE jobs SET status=?, record=? WHERE id=?', (job['status'], json.dumps(job, ensure_ascii=False), job['id']))
+ if not cursor.rowcount:
+ raise KeyError('Job not found')
+
+ def list(self, limit=20, offset=0, status='', query=''):
+ if isinstance(limit, bool) or not isinstance(limit, int) or not 1 <= limit <= 100 or not isinstance(offset, int) or not 0 <= offset <= 1000000:
+ raise ValueError('Invalid pagination')
+ if status and status not in STATUSES:
+ raise ValueError('Invalid status')
+ if not isinstance(query, str) or len(query) > 128:
+ raise ValueError('Invalid query')
+ with self.lock:
+ rows = self.connection.execute('SELECT record FROM jobs ORDER BY created_at DESC, rowid DESC').fetchall()
+ jobs = [json.loads(row[0]) for row in rows]
+ jobs = [job for job in jobs if (not status or job['status'] == status) and (not query or query.casefold() in json.dumps(job['metadata'], ensure_ascii=False).casefold() or query.casefold() in job['id'].casefold())]
+ return {'jobs': jobs[offset:offset + limit], 'total': len(jobs), 'limit': limit, 'offset': offset}
+
+ def all(self):
+ with self.lock:
+ return [json.loads(row[0]) for row in self.connection.execute('SELECT record FROM jobs').fetchall()]
+
+ def close(self):
+ with self.lock:
+ self.connection.close()
diff --git a/legacy_web.py b/legacy_web.py
new file mode 100644
index 0000000..a78ffb1
--- /dev/null
+++ b/legacy_web.py
@@ -0,0 +1,679 @@
+
+#!/usr/bin/env python3
+"""
+Industrial PC generic test system - local Web UI.
+
+The page runs real local detection and matches devices against a configurable
+standardized device list. Results are shown directly in the Web UI.
+"""
+
+import argparse
+import copy
+import html as html_lib
+import json
+import os
+import shutil
+import sys
+import threading
+import time
+import traceback
+from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
+from urllib.parse import unquote, urlparse
+
+BASE_DIR = os.path.dirname(os.path.abspath(__file__))
+sys.path.insert(0, BASE_DIR)
+
+from core.config_manager import StandardDeviceConfig
+from core.generic_detector import DEFAULT_TCP_PORTS, GenericDetector
+
+REPORT_DIR = os.path.join(BASE_DIR, "report")
+WEB_INDEX_PATH = os.path.join(BASE_DIR, "web", "index.html")
+REPORT_KEEP_LIMIT = 20
+RUN_LOCK = threading.Lock()
+SCAN_STATE = {"devices": {}, "updated_at": ""}
+CONNECTED_STATUSES = {"在线", "已发现", "正常"}
+INTERFACE_GROUP_KEYS = {
+ "serial": "serial_ports",
+ "network": "network_interfaces",
+ "pci": "pci_devices",
+}
+INTERFACE_CATEGORIES = {"serial": "串口", "network": "网口", "pci": "PCI"}
+
+def detector_from_payload(payload: dict, config: StandardDeviceConfig = None) -> GenericDetector:
+ raw_ports = payload.get("tcp_ports", ",".join(str(port) for port in DEFAULT_TCP_PORTS))
+ return GenericDetector(
+ serial_probe=bool(payload.get("serial_probe", True)),
+ subnet_probe=bool(payload.get("subnet_probe", True)),
+ tcp_ports=parse_ports(raw_ports),
+ config=config,
+ )
+
+
+def parse_ports(value):
+ raw_parts = value if isinstance(value, list) else str(value).replace(",", ",").replace(";", ",").split(",")
+ ports = []
+ for part in raw_parts:
+ try:
+ port = int(str(part).strip())
+ except ValueError:
+ continue
+ if 1 <= port <= 65535 and port not in ports:
+ ports.append(port)
+ return ports or DEFAULT_TCP_PORTS
+
+
+def connected_devices(data: dict):
+ return [item for item in data.get("devices", []) or [] if item.get("status") in CONNECTED_STATUSES]
+
+
+def cache_scan_devices(devices):
+ SCAN_STATE["devices"] = {
+ str(item.get("device_id")): copy.deepcopy(item)
+ for item in devices
+ if item.get("device_id")
+ }
+ SCAN_STATE["updated_at"] = time.strftime("%Y-%m-%d %H:%M:%S")
+
+
+def same_interface(connection_type: str, left: str, right: str) -> bool:
+ left = str(left or "").strip()
+ right = str(right or "").strip()
+ if connection_type == "pci":
+ return left.replace("0000:", "") == right.replace("0000:", "")
+ return left == right
+
+
+def cache_interface_devices(connection_type: str, interface: str, devices):
+ retained = {
+ device_id: item
+ for device_id, item in SCAN_STATE.get("devices", {}).items()
+ if not (
+ str(item.get("connection_type") or "") == connection_type
+ and same_interface(connection_type, item.get("interface", ""), interface)
+ )
+ }
+ for item in devices:
+ device_id = str(item.get("device_id") or "")
+ if device_id:
+ retained[device_id] = copy.deepcopy(item)
+ SCAN_STATE["devices"] = retained
+ SCAN_STATE["updated_at"] = time.strftime("%Y-%m-%d %H:%M:%S")
+
+
+def selected_catalog(device_ids):
+ interfaces = {
+ device_id: str(item.get("interface") or "")
+ for device_id, item in SCAN_STATE.get("devices", {}).items()
+ if item.get("interface")
+ }
+ return StandardDeviceConfig().selected(device_ids, interfaces)
+
+
+def update_interface_counts(data: dict):
+ counts = {
+ connection_type: len(data.get(key, []) or [])
+ for connection_type, key in INTERFACE_GROUP_KEYS.items()
+ }
+ data.setdefault("summary", {}).update({
+ "interface_total": sum(counts.values()),
+ "serial_count": counts["serial"],
+ "network_count": counts["network"],
+ "pci_count": counts["pci"],
+ })
+
+
+def prepare_ui_result(data: dict, mode: str, connected_only: bool = False) -> dict:
+ result = copy.deepcopy(data)
+ catalog_count = len(StandardDeviceConfig().all_configured_devices())
+ devices = result.get("devices", []) or []
+ if connected_only:
+ devices = [item for item in devices if item.get("status") in CONNECTED_STATUSES]
+ result["devices"] = devices
+ result["mode"] = mode
+
+ passed = sum(1 for item in devices if item.get("status") in CONNECTED_STATUSES)
+ warning = sum(1 for item in devices if item.get("status") == "需确认")
+ failed = sum(1 for item in devices if item.get("status") == "异常")
+ summary = result.setdefault("summary", {})
+ summary.update({
+ "total": len(devices),
+ "catalog_count": catalog_count,
+ "device_count": len(devices),
+ "connected_count": passed,
+ "passed": passed,
+ "warning": warning,
+ "failed": failed,
+ "fault_count": failed,
+ "matched_count": passed,
+ })
+ _rebuild_interface_devices(result, devices)
+ update_interface_counts(result)
+ return result
+
+
+def _rebuild_interface_devices(data: dict, devices):
+ groups = {
+ connection_type: data.get(key, []) or []
+ for connection_type, key in INTERFACE_GROUP_KEYS.items()
+ }
+ index = {}
+ for connection_type, items in groups.items():
+ for item in items:
+ details = item.setdefault("details", {})
+ details["configured_devices"] = []
+ index[(connection_type, str(item.get("name") or ""))] = item
+
+ for device in devices:
+ connection_type = str(device.get("connection_type") or "")
+ interface = str(device.get("interface") or "")
+ item = index.get((connection_type, interface))
+ if not item:
+ continue
+ name = str(device.get("name") or device.get("device_id") or "")
+ if name:
+ item["details"]["configured_devices"].append(name)
+
+ for items in groups.values():
+ for item in items:
+ names = item.get("details", {}).get("configured_devices", []) or []
+ if names:
+ item["summary"] = f"已发现 {len(names)} 个已知设备"
+ elif item.get("status") == "正常":
+ item["summary"] = "接口可用,未发现已知设备"
+
+ data["pci_devices"] = [
+ item for item in groups["pci"]
+ if not str(item.get("name") or "").startswith("匹配规则:")
+ or (item.get("details", {}).get("configured_devices", []) or [])
+ ]
+
+
+def preserve_scan_interfaces(result: dict, scan_raw: dict):
+ """Keep every first-pass interface after testing discovered devices."""
+ for connection_type, key in INTERFACE_GROUP_KEYS.items():
+ scanned = copy.deepcopy(scan_raw.get(key, []) or [])
+ tested = copy.deepcopy(result.get(key, []) or [])
+ merged = []
+ used = set()
+ for scanned_item in scanned:
+ match_index = next((
+ index for index, tested_item in enumerate(tested)
+ if same_interface(
+ connection_type,
+ scanned_item.get("name", ""),
+ tested_item.get("name", ""),
+ )
+ ), None)
+ if match_index is None:
+ merged.append(scanned_item)
+ continue
+ used.add(match_index)
+ tested_item = tested[match_index]
+ combined = copy.deepcopy(scanned_item)
+ combined.update(tested_item)
+ details = copy.deepcopy(scanned_item.get("details", {}) or {})
+ details.update(copy.deepcopy(tested_item.get("details", {}) or {}))
+ combined["details"] = details
+ merged.append(combined)
+ merged.extend(item for index, item in enumerate(tested) if index not in used)
+ result[key] = merged
+
+ _rebuild_interface_devices(result, result.get("devices", []) or [])
+ update_interface_counts(result)
+ return result
+
+
+def scan_catalog(payload: dict) -> dict:
+ raw = detector_from_payload(payload).run().to_dict()
+ discovered = connected_devices(raw)
+ cache_scan_devices(discovered)
+ return prepare_ui_result(raw, "scan", connected_only=True)
+
+
+def scan_interface(payload: dict) -> dict:
+ connection_type = str(payload.get("interface_type") or "").strip().lower()
+ interface = str(payload.get("interface") or "").strip()
+ if connection_type not in ("serial", "network", "pci"):
+ raise ValueError("interface_type必须是serial、network或pci")
+ if not interface:
+ raise ValueError("缺少接口名称")
+
+ config = StandardDeviceConfig().for_interface(connection_type, interface)
+ if config is None:
+ now = time.strftime("%Y-%m-%d %H:%M:%S")
+ raw = {
+ "started_at": now,
+ "finished_at": now,
+ "duration_ms": 0,
+ "summary": {},
+ "serial_ports": [],
+ "network_interfaces": [],
+ "pci_devices": [],
+ "devices": [],
+ "connection_tests": [],
+ "logs": [],
+ }
+ key = INTERFACE_GROUP_KEYS[connection_type]
+ category = INTERFACE_CATEGORIES[connection_type]
+ raw[key] = [{
+ "name": interface,
+ "category": category,
+ "status": "正常",
+ "summary": "配置库中没有此类已知设备",
+ "details": {"configured_devices": []},
+ }]
+ else:
+ raw = detector_from_payload(payload, config=config).run().to_dict()
+
+ devices = [
+ item for item in connected_devices(raw)
+ if str(item.get("connection_type") or "") == connection_type
+ and same_interface(connection_type, item.get("interface", ""), interface)
+ ]
+ raw["devices"] = devices
+ target_key = INTERFACE_GROUP_KEYS[connection_type]
+ for key in INTERFACE_GROUP_KEYS.values():
+ items = raw.get(key, []) or []
+ raw[key] = [
+ item for item in items
+ if key == target_key and same_interface(connection_type, item.get("name", ""), interface)
+ ]
+ cache_interface_devices(connection_type, interface, devices)
+ result = prepare_ui_result(raw, "scan", connected_only=True)
+ result["interface_type"] = connection_type
+ result["interface_name"] = interface
+ return result
+
+
+def test_all_discovered(payload: dict) -> dict:
+ scan_raw = detector_from_payload(payload).run().to_dict()
+ discovered = connected_devices(scan_raw)
+ cache_scan_devices(discovered)
+ device_ids = [str(item.get("device_id")) for item in discovered if item.get("device_id")]
+ if not device_ids:
+ result = prepare_ui_result(scan_raw, "test_all", connected_only=True)
+ else:
+ config = selected_catalog(device_ids)
+ tested = detector_from_payload(payload, config=config).run().to_dict()
+ result = prepare_ui_result(tested, "test_all")
+ preserve_scan_interfaces(result, scan_raw)
+ result["summary"]["scan_connected_count"] = len(discovered)
+ keep_limit = StandardDeviceConfig().settings().get("max_report_count", REPORT_KEEP_LIMIT)
+ save_report(result, keep_limit)
+ return result
+
+
+def test_single_device(payload: dict) -> dict:
+ device_id = str(payload.get("device_id") or "").strip()
+ if not device_id:
+ raise ValueError("缺少device_id")
+ if device_id not in SCAN_STATE.get("devices", {}):
+ raise ValueError("设备不在当前扫描结果中,请先扫描设备")
+ config = selected_catalog([device_id])
+ tested = detector_from_payload(payload, config=config).run().to_dict()
+ result = prepare_ui_result(tested, "single")
+ target = next((item for item in result.get("devices", []) if str(item.get("device_id")) == device_id), None)
+ if target is None:
+ raise ValueError("未生成该设备的测试结果")
+ return {"ok": True, "mode": "single", "device": target, "finished_at": result.get("finished_at", "")}
+
+
+def save_report(data: dict, keep_limit: int = REPORT_KEEP_LIMIT) -> dict:
+ keep_limit = max(1, min(1000, int(keep_limit or REPORT_KEEP_LIMIT)))
+ os.makedirs(REPORT_DIR, exist_ok=True)
+ stamp = time.strftime("%Y%m%d_%H%M%S")
+ suffix = int((time.time() * 1000) % 1000)
+ folder = f"{stamp}_{suffix:03d}"
+ folder_path = os.path.join(REPORT_DIR, folder)
+ os.makedirs(folder_path, exist_ok=True)
+ html_name = "report.html"
+ txt_name = "report.txt"
+ json_name = "report.json"
+ report_info = {
+ "folder": folder,
+ "html": html_name,
+ "txt": txt_name,
+ "json": json_name,
+ "url": f"/report/{folder}/{html_name}",
+ "html_url": f"/report/{folder}/{html_name}",
+ "txt_url": f"/report/{folder}/{txt_name}",
+ "json_url": f"/report/{folder}/{json_name}",
+ "keep_limit": keep_limit,
+ }
+ logs = data.setdefault("logs", [])
+ logs.append(f"[{time.strftime('%Y-%m-%d %H:%M:%S')}] 生成测试报告:report/{folder}/report.html、report.txt、report.json,report目录最多保留 {keep_limit} 个报告文件夹")
+ data["report"] = report_info
+ data.setdefault("summary", {})["report_folder"] = folder
+ data["summary"]["report_keep_limit"] = keep_limit
+ with open(os.path.join(folder_path, html_name), "w", encoding="utf-8") as f:
+ f.write(render_compact_report_html(data))
+ with open(os.path.join(folder_path, txt_name), "w", encoding="utf-8") as f:
+ f.write(render_compact_report_txt(data))
+ with open(os.path.join(folder_path, json_name), "w", encoding="utf-8") as f:
+ json.dump(compact_report_data(data), f, ensure_ascii=False, indent=2)
+ prune_reports(keep_limit)
+ return report_info
+
+
+def prune_reports(keep_limit: int = REPORT_KEEP_LIMIT):
+ if not os.path.isdir(REPORT_DIR):
+ return
+ reports = []
+ for name in os.listdir(REPORT_DIR):
+ path = os.path.join(REPORT_DIR, name)
+ if name.startswith("report_") and name.endswith(".html") and os.path.isfile(path):
+ try:
+ os.remove(path)
+ except OSError:
+ pass
+ continue
+ if os.path.isdir(path) and os.path.isfile(os.path.join(path, "report.json")):
+ reports.append((os.path.getmtime(path), path))
+ reports.sort(reverse=True)
+ for _, path in reports[max(1, int(keep_limit or REPORT_KEEP_LIMIT)):]:
+ try:
+ shutil.rmtree(path)
+ except OSError:
+ pass
+
+
+def compact_result_text(device: dict) -> str:
+ if device.get("status") in CONNECTED_STATUSES:
+ return "连通正常"
+ evidence = str(device.get("evidence") or "")
+ if "timed out" in evidence.lower() or "超时" in evidence or "未收到响应" in evidence:
+ return "无响应"
+ if "不存在" in evidence:
+ return "接口不存在"
+ return "测试失败"
+
+
+def compact_connection_text(device: dict) -> str:
+ connection = {"serial": "串口", "network": "网口", "pci": "PCI"}.get(
+ str(device.get("connection_type") or ""), str(device.get("connection_type") or "-")
+ )
+ protocol = str(device.get("protocol") or "").lower()
+ connection = {
+ "modbus_rtu": "Modbus RTU",
+ "modbus_tcp": "Modbus TCP",
+ "tcp": "TCP",
+ "udp": "UDP",
+ }.get(protocol, connection)
+ via = device.get("via_device", {}) or {}
+ via_name = str(via.get("device_name") or "")
+ return f"{connection} / 经{via_name}" if via_name else connection
+
+
+def compact_report_data(data: dict) -> dict:
+ devices = data.get("devices", []) or []
+ summary = data.get("summary", {}) or {}
+ interfaces = []
+ for category, key in INTERFACE_GROUP_KEYS.items():
+ for item in data.get(key, []) or []:
+ interfaces.append({
+ "type": category,
+ "interface": item.get("name", ""),
+ "status": item.get("status", ""),
+ "devices": (item.get("details", {}) or {}).get("configured_devices", []) or [],
+ })
+
+ compact_devices = []
+ for device in devices:
+ passed = device.get("status") in CONNECTED_STATUSES
+ via = device.get("via_device", {}) or {}
+ compact_devices.append({
+ "device_id": device.get("device_id", ""),
+ "device_name": device.get("name", ""),
+ "device_type": device.get("type", ""),
+ "interface_type": device.get("connection_type", ""),
+ "interface": device.get("interface", ""),
+ "communication": compact_connection_text(device),
+ "via_device": via.get("device_name", ""),
+ "result": "PASS" if passed else "FAIL",
+ "test_report": compact_result_text(device),
+ })
+
+ return {
+ "report_version": 1,
+ "hostname": data.get("hostname", ""),
+ "started_at": data.get("started_at", ""),
+ "finished_at": data.get("finished_at", ""),
+ "duration_ms": round(float(data.get("duration_ms") or 0), 1),
+ "summary": {
+ "found": len(devices),
+ "passed": summary.get("passed", 0),
+ "failed": summary.get("failed", 0),
+ },
+ "interfaces": interfaces,
+ "devices": compact_devices,
+ "report": data.get("report", {}) or {},
+ }
+
+
+def render_compact_report_txt(data: dict) -> str:
+ summary = data.get("summary", {}) or {}
+ devices = data.get("devices", []) or []
+ lines = [
+ "设备连通性测试报告",
+ "=" * 28,
+ f"主机: {data.get('hostname', '')}",
+ f"测试时间: {data.get('finished_at') or data.get('started_at', '')}",
+ f"发现设备: {summary.get('device_count', len(devices))}",
+ f"通过: {summary.get('passed', 0)}",
+ f"失败: {summary.get('failed', 0)}",
+ "",
+ "测试结果",
+ "-" * 28,
+ ]
+ if not devices:
+ lines.append("未发现配置库中的已知设备")
+ for index, device in enumerate(devices, start=1):
+ result = "通过" if device.get("status") in CONNECTED_STATUSES else "失败"
+ lines.append(
+ f"[{index}] {device.get('name', '')} | {device.get('interface', '-') or '-'} | "
+ f"{compact_connection_text(device)} | {result} | {compact_result_text(device)}"
+ )
+ return "\n".join(lines) + "\n"
+
+
+def render_compact_report_html(data: dict) -> str:
+ summary = data.get("summary", {}) or {}
+ devices = data.get("devices", []) or []
+
+ def esc(value) -> str:
+ return html_lib.escape(str(value if value is not None else ""), quote=True)
+
+ rows = []
+ for device in devices:
+ passed = device.get("status") in CONNECTED_STATUSES
+ result = "通过" if passed else "失败"
+ cls = "pass" if passed else "fail"
+ rows.append(
+ ""
+ f"{esc(device.get('name', ''))} "
+ f"{esc(device.get('interface', '') or '-')} "
+ f"{esc(compact_connection_text(device))} "
+ f"{result} "
+ f"{esc(compact_result_text(device))} "
+ " "
+ )
+ if not rows:
+ rows.append('未发现配置库中的已知设备 ')
+
+ return f"""
+
+
+
+
+ 设备连通性测试报告
+
+
+
+
+ 设备连通性测试报告
+
+
主机
{esc(data.get('hostname', '-'))}
+
测试时间
{esc(data.get('finished_at') or data.get('started_at', '-'))}
+
通过
{esc(summary.get('passed', 0))}
+
失败
{esc(summary.get('failed', 0))}
+
+
+
+ 设备 接口 连接方式 结果 测试报告
+ {''.join(rows)}
+
+
+
+
+"""
+
+
+class Handler(BaseHTTPRequestHandler):
+ server_version = "QLDeviceCheckGeneric/2.0"
+
+ def do_GET(self):
+ path = urlparse(self.path).path
+ if path in ("/", "/index.html"):
+ with open(WEB_INDEX_PATH, "r", encoding="utf-8") as f:
+ self._send_html(f.read())
+ return
+ if path == "/api/health":
+ self._send_json({"ok": True})
+ return
+ if path == "/api/standards":
+ self._send_json({"ok": True, "devices": StandardDeviceConfig().devices()})
+ return
+ if path.startswith("/report/"):
+ self._send_report(path)
+ return
+ if path == "/favicon.ico":
+ self._send_empty()
+ return
+ self.send_error(404, "Not Found")
+
+ def do_POST(self):
+ path = urlparse(self.path).path
+ handlers = {
+ "/api/scan": scan_catalog,
+ "/api/scan-interface": scan_interface,
+ "/api/run": test_all_discovered,
+ "/api/test-device": test_single_device,
+ }
+ operation = handlers.get(path)
+ if operation is None:
+ self.send_error(404, "Not Found")
+ return
+ if not RUN_LOCK.acquire(blocking=False):
+ self._send_json({"ok": False, "error": "检测任务正在运行,请稍候"}, status=409)
+ return
+ try:
+ payload = self._read_json()
+ self._send_json(operation(payload))
+ except Exception as exc:
+ self._send_json({
+ "ok": False,
+ "error": str(exc),
+ "traceback": traceback.format_exc().splitlines(),
+ }, status=500)
+ finally:
+ RUN_LOCK.release()
+
+ def _read_json(self):
+ length = int(self.headers.get("Content-Length", "0"))
+ if length <= 0:
+ return {}
+ raw = self.rfile.read(length).decode("utf-8")
+ if not raw.strip():
+ return {}
+ return json.loads(raw)
+
+ def _send_html(self, html: str):
+ encoded = html.encode("utf-8")
+ self.send_response(200)
+ self.send_header("Content-Type", "text/html; charset=utf-8")
+ self.send_header("Content-Length", str(len(encoded)))
+ self.end_headers()
+ self.wfile.write(encoded)
+
+ def _send_report(self, path: str):
+ relative = unquote(path[len("/report/"):]).replace("\\", "/").strip("/")
+ if not relative or ".." in relative.split("/"):
+ self.send_error(404, "Not Found")
+ return
+ if not relative.endswith((".html", ".txt", ".json")):
+ self.send_error(404, "Not Found")
+ return
+ full_path = os.path.abspath(os.path.join(REPORT_DIR, *relative.split("/")))
+ report_root = os.path.abspath(REPORT_DIR)
+ if not full_path.startswith(report_root + os.sep) or not os.path.isfile(full_path):
+ self.send_error(404, "Not Found")
+ return
+ content_types = {
+ ".html": "text/html; charset=utf-8",
+ ".txt": "text/plain; charset=utf-8",
+ ".json": "application/json; charset=utf-8",
+ }
+ ext = os.path.splitext(full_path)[1]
+ with open(full_path, "rb") as f:
+ encoded = f.read()
+ self.send_response(200)
+ self.send_header("Content-Type", content_types.get(ext, "application/octet-stream"))
+ self.send_header("Content-Length", str(len(encoded)))
+ self.end_headers()
+ self.wfile.write(encoded)
+
+ def _send_json(self, data: dict, status: int = 200):
+ encoded = json.dumps(data, ensure_ascii=False).encode("utf-8")
+ self.send_response(status)
+ self.send_header("Content-Type", "application/json; charset=utf-8")
+ self.send_header("Content-Length", str(len(encoded)))
+ self.end_headers()
+ self.wfile.write(encoded)
+
+ def _send_empty(self, status: int = 204):
+ self.send_response(status)
+ self.send_header("Content-Length", "0")
+ self.end_headers()
+
+ def log_message(self, fmt, *args):
+ print("[%s] %s" % (self.log_date_time_string(), fmt % args))
+
+
+def main():
+ parser = argparse.ArgumentParser(description="工控机通用测试系统 Web UI")
+ parser.add_argument("--host", default="127.0.0.1")
+ parser.add_argument("--port", default=8080, type=int)
+ args = parser.parse_args()
+
+ server = ThreadingHTTPServer((args.host, args.port), Handler)
+ print("工控机通用测试系统已启动")
+ print(f"访问地址: http://{args.host}:{args.port}")
+ print("按 Ctrl+C 退出")
+ try:
+ server.serve_forever()
+ except KeyboardInterrupt:
+ print("\n正在退出...")
+ finally:
+ server.server_close()
+
+
+if __name__ == "__main__":
+ main()
diff --git a/tests/test_http.py b/tests/test_http.py
new file mode 100644
index 0000000..6a1da7c
--- /dev/null
+++ b/tests/test_http.py
@@ -0,0 +1,136 @@
+import json
+import threading
+import urllib.error
+import urllib.request
+
+import pytest
+
+from inspection.http import make_server
+from inspection.service import InspectionService
+from test_workbench import BlockingAdapter, wait_finished
+
+
+@pytest.fixture
+def api(tmp_path):
+ svc = InspectionService(tmp_path / 'jobs.db', demo=True)
+ server = make_server('127.0.0.1', 0, svc)
+ thread = threading.Thread(target=server.serve_forever, daemon=True)
+ thread.start()
+ yield svc, server, requester(server)
+ server.shutdown()
+ server.server_close()
+ svc.close()
+ thread.join(2)
+
+
+def requester(server):
+ def request(method, path, payload=None, headers=None, raw=None):
+ data = raw if raw is not None else json.dumps(payload).encode() if payload is not None else None
+ request = urllib.request.Request('http://127.0.0.1:%d%s' % (server.server_port, path), data=data, method=method,
+ headers={'Content-Type': 'application/json', **(headers or {})})
+ try:
+ response = urllib.request.urlopen(request, timeout=3)
+ except urllib.error.HTTPError as exc:
+ response = exc
+ with response:
+ body = response.read()
+ return response.status, json.loads(body) if 'application/json' in response.headers.get('Content-Type', '') else body
+ return request
+
+
+def test_real_http_job_workflow(api):
+ svc, server, request = api
+ status, catalog = request('GET', '/api/catalog')
+ assert status == 200 and catalog['ok'] and catalog['mode'] == 'demo'
+ assert request('GET', '/api/health')[1]['persistence_ready']
+ assert request('POST', '/api/config/validate', {'config': catalog['config_snapshot']})[1]['valid']
+ status, created = request('POST', '/api/jobs', {'scenario': 'faults'})
+ assert status == 202 and created['job']['status'] in ('queued', 'running')
+ job = wait_finished(svc, created['job']['id'])
+ assert request('GET', '/api/jobs/' + job['id'])[1]['job'] == job
+ assert request('GET', '/api/jobs?limit=1&status=completed')[1]['total'] == 1
+ assert request('GET', '/api/insights')[1]['demo']['completed'] == 1
+ assert request('POST', '/api/jobs/' + job['id'] + '/cancel', {})[1]['job']['status'] == 'completed'
+ status, retest = request('POST', '/api/jobs/' + job['id'] + '/retest', {'scenario': 'healthy'})
+ assert status == 202
+ done = wait_finished(svc, retest['job']['id'])
+ status, comparison = request('GET', '/api/compare?baseline=' + job['id'] + '¤t=' + done['id'])
+ assert status == 200 and comparison['comparison']['partial_scope']
+ for format in ('html', 'json', 'csv'):
+ status, data = request('GET', '/api/jobs/' + job['id'] + '/export?format=' + format)
+ assert status == 200
+ assert data['id'] == job['id'] if format == 'json' else b'SIMULATED' in data
+
+
+def test_input_boundaries(api):
+ svc, server, request = api
+ for raw in (b'{', b'[]', b'null', b'"string"'):
+ status, body = request('POST', '/api/jobs', raw=raw)
+ assert status == 400 and not body['ok'] and 'traceback' not in body
+ assert request('POST', '/api/jobs', raw=b' ' * 65537)[0] == 413
+ assert request('GET', '/api/jobs/' + 'a' * 32)[0] == 404
+ assert request('GET', '/api/jobs?limit=0')[0] == 400
+ assert request('POST', '/api/config/validate', {'config': {}})[0] == 400
+ assert request('GET', '/assets/../config/device_list.json')[0] == 404
+ assert request('GET', '/assets/%2e%2e/config/device_list.json')[0] == 404
+ assert request('GET', '/legacy')[0] == 200
+ for path in ('/api/scan', '/api/run', '/api/scan-interface', '/api/test-device'):
+ assert request('POST', path, {})[0] == 403
+
+
+def test_origin_and_token(api):
+ svc, server, request = api
+ assert request('POST', '/api/jobs', {}, {'Origin': 'https://foreign.invalid'})[0] == 403
+ server.access_token = 'secret'
+ assert request('GET', '/api/catalog')[0] == 401
+ assert request('POST', '/api/jobs', {}, {'Authorization': 'Bearer wrong'})[0] == 401
+ assert request('GET', '/api/catalog', headers={'Authorization': 'Bearer secret'})[0] == 200
+ assert request('GET', '/api/catalog?token=secret')[0] == 401
+ with pytest.raises(ValueError):
+ make_server('0.0.0.0', 0, svc)
+
+
+def test_real_http_conflict_cancel_and_no_traceback(api):
+ svc, server, request = api
+ blocker = BlockingAdapter()
+ svc.adapter = blocker
+ status, job = request('POST', '/api/jobs', {})
+ assert status == 202 and blocker.started.wait(1)
+ assert request('POST', '/api/jobs', {})[0] == 409
+ assert request('POST', '/api/jobs/' + job['job']['id'] + '/retest', {})[0] == 409
+ assert request('POST', '/api/jobs/' + job['job']['id'] + '/cancel', {})[1]['job']['status'] == 'cancelling'
+ assert wait_finished(svc, job['job']['id'])['status'] == 'cancelled'
+
+
+def test_demo_never_imports_detector(tmp_path):
+ import subprocess
+ import sys
+ code = "import sys; from inspection.service import InspectionService; from inspection.http import make_server; s=InspectionService(sys.argv[1],demo=True); h=make_server('127.0.0.1',0,s); s.create({}); s.close(); h.server_close(); assert 'core.generic_detector' not in sys.modules; assert 'legacy_web' not in sys.modules"
+ subprocess.run([sys.executable, '-c', code, str(tmp_path / 'isolated.db')], check=True, timeout=5)
+
+
+def test_http_error_never_leaks_traceback(api, monkeypatch):
+ svc, server, request = api
+ def fail():
+ raise OSError('private path and password')
+ monkeypatch.setattr(svc, 'catalog', fail)
+ status, body = request('GET', '/api/catalog')
+ assert status == 500
+ assert body == {'ok': False, 'error': 'Operation failed'}
+ assert request('GET', '/api/health', headers={'Host': 'foreign.invalid:' + str(server.server_port)})[0] == 403
+
+
+def test_legacy_live_calls_share_acceptance_lock_and_are_explicit(api, monkeypatch):
+ import legacy_web
+ svc, server, request = api
+ monkeypatch.setattr(legacy_web, 'scan_catalog', lambda payload: {'ok': True, 'devices': [], 'mode': 'scan'})
+ svc.demo = False
+ svc.mode = 'live'
+ svc.hardware_lock.acquire()
+ try:
+ assert request('POST', '/api/scan', {})[0] == 409
+ finally:
+ svc.hardware_lock.release()
+ status, body = request('POST', '/api/scan', {})
+ assert status == 200 and body['legacy']
+ assert legacy_web.RUN_LOCK is svc.hardware_lock
diff --git a/tests/test_workbench.py b/tests/test_workbench.py
new file mode 100644
index 0000000..eae57b0
--- /dev/null
+++ b/tests/test_workbench.py
@@ -0,0 +1,314 @@
+import copy
+import json
+import threading
+import time
+
+import pytest
+
+from inspection.service import InspectionService
+from inspection.store import SQLiteStore
+from inspection.comparison import compare_jobs
+from inspection.reports import export_job
+
+
+TERMINAL = {'completed', 'cancelled', 'failed', 'interrupted'}
+
+
+def wait_finished(service, job_id):
+ deadline = time.monotonic() + 4
+ while time.monotonic() < deadline:
+ job = service.get(job_id)
+ if job['status'] in TERMINAL:
+ return job
+ time.sleep(.01)
+ raise AssertionError('job did not finish')
+
+
+@pytest.fixture
+def service(tmp_path):
+ value = InspectionService(tmp_path / 'jobs.db', demo=True)
+ yield value
+ value.close()
+
+
+def test_missing_device_cannot_pass(service):
+ job = service.create({'scenario': 'missing', 'station_id': 'DEMO-01'})
+ assert all(r['verdict'] == 'NOT_RUN' for r in job['results'])
+ done = wait_finished(service, job['id'])
+ assert done['summary']['failed'] >= 1
+ assert len(done['results']) == done['summary']['expected']
+ assert done['summary']['verdict'] != 'PASS'
+ assert all(r['simulated'] for r in done['results'])
+
+
+def test_snapshot_and_retest_scope(service):
+ job = wait_finished(service, service.create({'scenario': 'faults'})['id'])
+ broken = [r['device_id'] for r in job['results'] if r['verdict'] != 'PASS']
+ assert broken and len(broken) < len(job['results'])
+ with pytest.raises(ValueError):
+ service.retest(job['id'], {'device_ids': job['device_ids']})
+ child = wait_finished(service, service.retest(job['id'], {'scenario': 'healthy', 'notes': 'fixed'})['id'])
+ assert child['device_ids'] == broken
+ assert child['config_snapshot'] == job['config_snapshot']
+ assert child['config_hash'] == job['config_hash']
+ assert child['parent_job_id'] == job['id']
+ assert child['summary']['verdict'] == 'PASS'
+ comparison = compare_jobs(job, child)
+ assert comparison['comparable'] and comparison['partial_scope']
+ assert not comparison['whole_unit_recovered']
+ assert len(comparison['recovered']) == len(broken)
+ other = copy.deepcopy(child)
+ other['mode'] = 'live'
+ assert not compare_jobs(job, other)['comparable']
+ other = copy.deepcopy(job)
+ other['device_ids'] = job['device_ids'][:1]
+ assert not compare_jobs(job, other)['comparable']
+
+
+class BlockingAdapter:
+ def __init__(self):
+ self.started = threading.Event()
+ self.release = threading.Event()
+ self.snapshot = None
+
+ def run(self, config, device_ids, scenario, cancelled, on_result):
+ self.snapshot = copy.deepcopy(config)
+ self.started.set()
+ while not self.release.wait(.01) and not cancelled():
+ pass
+
+
+def test_contention_cancel_and_shared_lock(tmp_path):
+ adapter = BlockingAdapter()
+ svc = InspectionService(tmp_path / 'jobs.db', demo=True, adapter=adapter)
+ try:
+ svc.hardware_lock.acquire()
+ with pytest.raises(RuntimeError):
+ svc.create({})
+ svc.hardware_lock.release()
+ job = svc.create({})
+ assert adapter.started.wait(1)
+ job['config_snapshot']['devices'][0]['device_name'] = 'changed outside'
+ assert adapter.snapshot['devices'][0]['device_name'] != 'changed outside'
+ with pytest.raises(RuntimeError):
+ svc.create({})
+ svc.cancel(job['id'])
+ done = wait_finished(svc, job['id'])
+ assert done['status'] == 'cancelled'
+ assert done['summary']['not_run'] == done['summary']['expected']
+ assert done['summary']['verdict'] != 'PASS'
+ assert done['finished_at']
+ assert done['events'][-1]['type'] == 'cancelled'
+ finally:
+ adapter.release.set()
+ svc.close()
+
+
+def test_restart_marks_pending_interrupted(tmp_path, service):
+ job = wait_finished(service, service.create({})['id'])
+ job['status'] = 'running'
+ db = tmp_path / 'other.db'
+ store = SQLiteStore(db)
+ store.insert(job)
+ store.close()
+ recovered = InspectionService(db, demo=True)
+ try:
+ result = recovered.get(job['id'])
+ assert result['status'] == 'interrupted'
+ assert result['events'][-1]['type'] == 'interrupted'
+ finally:
+ recovered.close()
+
+
+def test_reports_preserve_evidence_and_escape(service):
+ job = wait_finished(service, service.create({'notes': '', 'operator': '=CMD()'})['id'])
+ html, mime = export_job(job, 'html')
+ assert '' not in html.decode()
+ assert '<script>' in html.decode()
+ assert 'SIMULATED' in html.decode()
+ csv, mime = export_job(job, 'csv')
+ assert "'=CMD()" in csv.decode('utf-8-sig')
+ raw, mime = export_job(job, 'json')
+ assert json.loads(raw)['config_snapshot'] == job['config_snapshot']
+ assert json.loads(raw)['results'][0]['response']
+
+
+def test_validation_history_insights(service):
+ for payload in ({'scenario': 'bad'}, {'device_ids': []}, {'device_ids': ['unknown']}, {'notes': 'x' * 2001}):
+ with pytest.raises(ValueError):
+ service.create(payload)
+ job = wait_finished(service, service.create({'batch': 'batch-find'})['id'])
+ assert service.list_jobs(query='batch-find')['total'] == 1
+ assert service.list_jobs(status='completed', limit=1)['jobs'][0]['id'] == job['id']
+ assert service.insights()['demo']['completed'] == 1
+ assert service.insights()['live']['total'] == 0
+
+
+def test_live_adapter_group_selection_and_missing(monkeypatch):
+ from inspection.adapters import LiveAdapter, demo_config
+ import core.generic_detector
+ seen = []
+ class FakeDetector:
+ def __init__(self, **kwargs):
+ seen.append(kwargs['config'].data)
+ def run(self):
+ return self
+ def to_dict(self):
+ return {'devices': []}
+ monkeypatch.setattr(core.generic_detector, 'GenericDetector', FakeDetector)
+ config = demo_config()
+ config['devices'][1]['child_devices'] = [copy.deepcopy(config['devices'][0])]
+ config['devices'][1]['child_devices'][0]['device_id'] = 'child'
+ results = []
+ LiveAdapter().run(config, ['child'], 'healthy', lambda: False, results.append)
+ assert len(seen) == 1 and seen[0]['devices'][0]['device_id'] == config['devices'][1]['device_id']
+ assert [r['device_id'] for r in results] == ['child']
+ assert results[0]['verdict'] == 'FAIL'
+
+
+def test_acceptance_rejects_write_before_adapter(service):
+ config = service.catalog()['config_snapshot']
+ config['settings']['allow_write_tests'] = True
+ config['devices'][0]['protocol']['operation'] = 'write'
+ with pytest.raises(ValueError, match='write'):
+ service.create({}, snapshot=config)
+
+
+def test_shutdown_is_bounded_and_does_not_record_late_pass(tmp_path):
+ class StubbornAdapter(BlockingAdapter):
+ def run(self, config, device_ids, scenario, cancelled, on_result):
+ from inspection.adapters import DemoAdapter
+ self.started.set()
+ self.release.wait(5)
+ DemoAdapter().run(config, device_ids, scenario, lambda: False, on_result)
+ adapter = StubbornAdapter()
+ path = tmp_path / 'shutdown.db'
+ svc = InspectionService(path, demo=True, adapter=adapter)
+ job = svc.create({})
+ assert adapter.started.wait(1)
+ start = time.monotonic()
+ svc.close()
+ assert time.monotonic() - start < 2.6
+ adapter.release.set()
+ svc.worker.join(1)
+ reopened = InspectionService(path, demo=True)
+ try:
+ saved = reopened.get(job['id'])
+ assert saved['status'] == 'interrupted'
+ assert saved['summary']['not_run'] == saved['summary']['expected']
+ assert saved['duration_ms'] >= 1900
+ finally:
+ reopened.close()
+
+
+def test_simultaneous_creation_has_one_owner(tmp_path):
+ adapter = BlockingAdapter()
+ svc = InspectionService(tmp_path / 'race.db', demo=True, adapter=adapter)
+ barrier = threading.Barrier(6)
+ outcomes = []
+ def run():
+ barrier.wait()
+ try:
+ outcomes.append(svc.create({})['id'])
+ except RuntimeError:
+ outcomes.append('conflict')
+ threads = [threading.Thread(target=run) for _ in range(6)]
+ try:
+ for thread in threads:
+ thread.start()
+ for thread in threads:
+ thread.join(2)
+ assert len(outcomes) == 6 and outcomes.count('conflict') == 5
+ assert svc.list_jobs()['total'] == 1
+ finally:
+ svc.close()
+
+
+def test_adapter_failure_cannot_leave_pass_verdict(tmp_path):
+ class FailsAfterResults:
+ def run(self, config, device_ids, scenario, cancelled, on_result):
+ from inspection.adapters import DemoAdapter
+ DemoAdapter().run(config, device_ids, scenario, cancelled, on_result)
+ raise RuntimeError('private traceback content')
+ svc = InspectionService(tmp_path / 'failed.db', demo=True, adapter=FailsAfterResults())
+ try:
+ done = wait_finished(svc, svc.create({})['id'])
+ assert done['status'] == 'failed'
+ assert done['summary']['verdict'] != 'PASS'
+ assert 'private traceback' not in json.dumps(done)
+ finally:
+ svc.close()
+
+
+def test_duplicate_retest_input_is_validation_error(service):
+ job = wait_finished(service, service.create({'scenario': 'faults'})['id'])
+ with pytest.raises(ValueError):
+ service.retest(job['id'], {'device_ids': [{'bad': 'object'}]})
+
+
+def test_failed_only_retest_never_claims_whole_unit_recovery(service):
+ original = wait_finished(service, service.create({'scenario': 'missing', 'device_ids': ['DEMO_RELAY']})['id'])
+ child = wait_finished(service, service.retest(original['id'], {'scenario': 'healthy'})['id'])
+ comparison = compare_jobs(original, child)
+ assert comparison['comparable'] and comparison['partial_scope']
+ assert not comparison['whole_unit_recovered']
+
+
+def test_real_config_snapshot_survives_file_change(tmp_path):
+ from inspection.adapters import demo_config, DemoAdapter
+ path = tmp_path / 'config.json'
+ snapshot = demo_config()
+ path.write_text(json.dumps(snapshot), encoding='utf-8')
+ blocker = BlockingAdapter()
+ svc = InspectionService(tmp_path / 'live.db', config_path=str(path), adapter=blocker)
+ try:
+ job = svc.create({})
+ assert blocker.started.wait(1)
+ snapshot['devices'][0]['device_name'] = 'new name'
+ path.write_text(json.dumps(snapshot), encoding='utf-8')
+ assert svc.catalog()['config_hash'] != job['config_hash']
+ assert svc.get(job['id'])['config_snapshot']['devices'][0]['device_name'] != 'new name'
+ svc.cancel(job['id'])
+ original = wait_finished(svc, job['id'])
+ svc.adapter = DemoAdapter()
+ child = wait_finished(svc, svc.retest(original['id'], {})['id'])
+ assert child['config_snapshot'] == original['config_snapshot']
+ assert child['config_hash'] == original['config_hash']
+ assert all(not r['simulated'] for r in child['results'])
+ finally:
+ svc.close()
+
+
+def test_live_missing_and_progress_at_group_boundaries(monkeypatch):
+ from inspection.adapters import LiveAdapter, demo_config
+ import core.generic_detector
+ seen = []
+ results = []
+ stop = threading.Event()
+ class FakeDetector:
+ def __init__(self, **kwargs):
+ self.config = kwargs['config']
+ seen.append(self.config.data)
+ def run(self):
+ return self
+ def to_dict(self):
+ raw = self.config.raw_devices()[0]
+ return {'devices': [{'device_id': raw['device_id'], 'status': '正常', 'evidence': 'passive evidence', 'request': 'read', 'response': 'response'}]}
+ monkeypatch.setattr(core.generic_detector, 'GenericDetector', FakeDetector)
+ def record(result):
+ results.append(result)
+ stop.set()
+ snapshot = demo_config()
+ LiveAdapter().run(snapshot, [d['device_id'] for d in snapshot['devices']], 'healthy', stop.is_set, record)
+ assert len(seen) == len(results) == 1
+ assert results[0]['verdict'] == 'PASS' and not results[0]['simulated']
+
+
+def test_comparison_rejects_configuration_or_evidence_scope_mismatch(service):
+ job = wait_finished(service, service.create({})['id'])
+ other = copy.deepcopy(job)
+ other['config_hash'] = 'different'
+ assert not compare_jobs(job, other)['comparable']
+ other = copy.deepcopy(job)
+ other['results'][0]['scope'] = 'business_function'
+ assert not compare_jobs(job, other)['comparable']
diff --git a/web/legacy.html b/web/legacy.html
new file mode 100644
index 0000000..977b98d
--- /dev/null
+++ b/web/legacy.html
@@ -0,0 +1,624 @@
+
+
+
+
+
+ 工控机设备检测
+
+
+
+
+
+
工控机设备检测
+
+ 扫描设备
+ 测试全部
+
+
+
+
+ 等待扫描
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/web_app.py b/web_app.py
index a78ffb1..15ac685 100644
--- a/web_app.py
+++ b/web_app.py
@@ -1,679 +1,49 @@
-
#!/usr/bin/env python3
-"""
-Industrial PC generic test system - local Web UI.
-
-The page runs real local detection and matches devices against a configurable
-standardized device list. Results are shown directly in the Web UI.
-"""
-
+"""Compatibility entry point for the local field acceptance workbench."""
import argparse
-import copy
-import html as html_lib
-import json
import os
-import shutil
-import sys
-import threading
-import time
-import traceback
-from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
-from urllib.parse import unquote, urlparse
-
-BASE_DIR = os.path.dirname(os.path.abspath(__file__))
-sys.path.insert(0, BASE_DIR)
-
-from core.config_manager import StandardDeviceConfig
-from core.generic_detector import DEFAULT_TCP_PORTS, GenericDetector
-
-REPORT_DIR = os.path.join(BASE_DIR, "report")
-WEB_INDEX_PATH = os.path.join(BASE_DIR, "web", "index.html")
-REPORT_KEEP_LIMIT = 20
-RUN_LOCK = threading.Lock()
-SCAN_STATE = {"devices": {}, "updated_at": ""}
-CONNECTED_STATUSES = {"在线", "已发现", "正常"}
-INTERFACE_GROUP_KEYS = {
- "serial": "serial_ports",
- "network": "network_interfaces",
- "pci": "pci_devices",
-}
-INTERFACE_CATEGORIES = {"serial": "串口", "network": "网口", "pci": "PCI"}
-
-def detector_from_payload(payload: dict, config: StandardDeviceConfig = None) -> GenericDetector:
- raw_ports = payload.get("tcp_ports", ",".join(str(port) for port in DEFAULT_TCP_PORTS))
- return GenericDetector(
- serial_probe=bool(payload.get("serial_probe", True)),
- subnet_probe=bool(payload.get("subnet_probe", True)),
- tcp_ports=parse_ports(raw_ports),
- config=config,
- )
-
-
-def parse_ports(value):
- raw_parts = value if isinstance(value, list) else str(value).replace(",", ",").replace(";", ",").split(",")
- ports = []
- for part in raw_parts:
- try:
- port = int(str(part).strip())
- except ValueError:
- continue
- if 1 <= port <= 65535 and port not in ports:
- ports.append(port)
- return ports or DEFAULT_TCP_PORTS
-
-
-def connected_devices(data: dict):
- return [item for item in data.get("devices", []) or [] if item.get("status") in CONNECTED_STATUSES]
-
-
-def cache_scan_devices(devices):
- SCAN_STATE["devices"] = {
- str(item.get("device_id")): copy.deepcopy(item)
- for item in devices
- if item.get("device_id")
- }
- SCAN_STATE["updated_at"] = time.strftime("%Y-%m-%d %H:%M:%S")
-
-
-def same_interface(connection_type: str, left: str, right: str) -> bool:
- left = str(left or "").strip()
- right = str(right or "").strip()
- if connection_type == "pci":
- return left.replace("0000:", "") == right.replace("0000:", "")
- return left == right
-
-
-def cache_interface_devices(connection_type: str, interface: str, devices):
- retained = {
- device_id: item
- for device_id, item in SCAN_STATE.get("devices", {}).items()
- if not (
- str(item.get("connection_type") or "") == connection_type
- and same_interface(connection_type, item.get("interface", ""), interface)
- )
- }
- for item in devices:
- device_id = str(item.get("device_id") or "")
- if device_id:
- retained[device_id] = copy.deepcopy(item)
- SCAN_STATE["devices"] = retained
- SCAN_STATE["updated_at"] = time.strftime("%Y-%m-%d %H:%M:%S")
-
-
-def selected_catalog(device_ids):
- interfaces = {
- device_id: str(item.get("interface") or "")
- for device_id, item in SCAN_STATE.get("devices", {}).items()
- if item.get("interface")
- }
- return StandardDeviceConfig().selected(device_ids, interfaces)
-
-
-def update_interface_counts(data: dict):
- counts = {
- connection_type: len(data.get(key, []) or [])
- for connection_type, key in INTERFACE_GROUP_KEYS.items()
- }
- data.setdefault("summary", {}).update({
- "interface_total": sum(counts.values()),
- "serial_count": counts["serial"],
- "network_count": counts["network"],
- "pci_count": counts["pci"],
- })
-
-
-def prepare_ui_result(data: dict, mode: str, connected_only: bool = False) -> dict:
- result = copy.deepcopy(data)
- catalog_count = len(StandardDeviceConfig().all_configured_devices())
- devices = result.get("devices", []) or []
- if connected_only:
- devices = [item for item in devices if item.get("status") in CONNECTED_STATUSES]
- result["devices"] = devices
- result["mode"] = mode
-
- passed = sum(1 for item in devices if item.get("status") in CONNECTED_STATUSES)
- warning = sum(1 for item in devices if item.get("status") == "需确认")
- failed = sum(1 for item in devices if item.get("status") == "异常")
- summary = result.setdefault("summary", {})
- summary.update({
- "total": len(devices),
- "catalog_count": catalog_count,
- "device_count": len(devices),
- "connected_count": passed,
- "passed": passed,
- "warning": warning,
- "failed": failed,
- "fault_count": failed,
- "matched_count": passed,
- })
- _rebuild_interface_devices(result, devices)
- update_interface_counts(result)
- return result
-
-
-def _rebuild_interface_devices(data: dict, devices):
- groups = {
- connection_type: data.get(key, []) or []
- for connection_type, key in INTERFACE_GROUP_KEYS.items()
- }
- index = {}
- for connection_type, items in groups.items():
- for item in items:
- details = item.setdefault("details", {})
- details["configured_devices"] = []
- index[(connection_type, str(item.get("name") or ""))] = item
-
- for device in devices:
- connection_type = str(device.get("connection_type") or "")
- interface = str(device.get("interface") or "")
- item = index.get((connection_type, interface))
- if not item:
- continue
- name = str(device.get("name") or device.get("device_id") or "")
- if name:
- item["details"]["configured_devices"].append(name)
-
- for items in groups.values():
- for item in items:
- names = item.get("details", {}).get("configured_devices", []) or []
- if names:
- item["summary"] = f"已发现 {len(names)} 个已知设备"
- elif item.get("status") == "正常":
- item["summary"] = "接口可用,未发现已知设备"
-
- data["pci_devices"] = [
- item for item in groups["pci"]
- if not str(item.get("name") or "").startswith("匹配规则:")
- or (item.get("details", {}).get("configured_devices", []) or [])
- ]
-
-
-def preserve_scan_interfaces(result: dict, scan_raw: dict):
- """Keep every first-pass interface after testing discovered devices."""
- for connection_type, key in INTERFACE_GROUP_KEYS.items():
- scanned = copy.deepcopy(scan_raw.get(key, []) or [])
- tested = copy.deepcopy(result.get(key, []) or [])
- merged = []
- used = set()
- for scanned_item in scanned:
- match_index = next((
- index for index, tested_item in enumerate(tested)
- if same_interface(
- connection_type,
- scanned_item.get("name", ""),
- tested_item.get("name", ""),
- )
- ), None)
- if match_index is None:
- merged.append(scanned_item)
- continue
- used.add(match_index)
- tested_item = tested[match_index]
- combined = copy.deepcopy(scanned_item)
- combined.update(tested_item)
- details = copy.deepcopy(scanned_item.get("details", {}) or {})
- details.update(copy.deepcopy(tested_item.get("details", {}) or {}))
- combined["details"] = details
- merged.append(combined)
- merged.extend(item for index, item in enumerate(tested) if index not in used)
- result[key] = merged
-
- _rebuild_interface_devices(result, result.get("devices", []) or [])
- update_interface_counts(result)
- return result
-
-
-def scan_catalog(payload: dict) -> dict:
- raw = detector_from_payload(payload).run().to_dict()
- discovered = connected_devices(raw)
- cache_scan_devices(discovered)
- return prepare_ui_result(raw, "scan", connected_only=True)
-
+from pathlib import Path
-def scan_interface(payload: dict) -> dict:
- connection_type = str(payload.get("interface_type") or "").strip().lower()
- interface = str(payload.get("interface") or "").strip()
- if connection_type not in ("serial", "network", "pci"):
- raise ValueError("interface_type必须是serial、network或pci")
- if not interface:
- raise ValueError("缺少接口名称")
+from inspection.http import make_server
+from inspection.service import InspectionService
- config = StandardDeviceConfig().for_interface(connection_type, interface)
- if config is None:
- now = time.strftime("%Y-%m-%d %H:%M:%S")
- raw = {
- "started_at": now,
- "finished_at": now,
- "duration_ms": 0,
- "summary": {},
- "serial_ports": [],
- "network_interfaces": [],
- "pci_devices": [],
- "devices": [],
- "connection_tests": [],
- "logs": [],
- }
- key = INTERFACE_GROUP_KEYS[connection_type]
- category = INTERFACE_CATEGORIES[connection_type]
- raw[key] = [{
- "name": interface,
- "category": category,
- "status": "正常",
- "summary": "配置库中没有此类已知设备",
- "details": {"configured_devices": []},
- }]
- else:
- raw = detector_from_payload(payload, config=config).run().to_dict()
- devices = [
- item for item in connected_devices(raw)
- if str(item.get("connection_type") or "") == connection_type
- and same_interface(connection_type, item.get("interface", ""), interface)
- ]
- raw["devices"] = devices
- target_key = INTERFACE_GROUP_KEYS[connection_type]
- for key in INTERFACE_GROUP_KEYS.values():
- items = raw.get(key, []) or []
- raw[key] = [
- item for item in items
- if key == target_key and same_interface(connection_type, item.get("name", ""), interface)
- ]
- cache_interface_devices(connection_type, interface, devices)
- result = prepare_ui_result(raw, "scan", connected_only=True)
- result["interface_type"] = connection_type
- result["interface_name"] = interface
- return result
-
-
-def test_all_discovered(payload: dict) -> dict:
- scan_raw = detector_from_payload(payload).run().to_dict()
- discovered = connected_devices(scan_raw)
- cache_scan_devices(discovered)
- device_ids = [str(item.get("device_id")) for item in discovered if item.get("device_id")]
- if not device_ids:
- result = prepare_ui_result(scan_raw, "test_all", connected_only=True)
- else:
- config = selected_catalog(device_ids)
- tested = detector_from_payload(payload, config=config).run().to_dict()
- result = prepare_ui_result(tested, "test_all")
- preserve_scan_interfaces(result, scan_raw)
- result["summary"]["scan_connected_count"] = len(discovered)
- keep_limit = StandardDeviceConfig().settings().get("max_report_count", REPORT_KEEP_LIMIT)
- save_report(result, keep_limit)
- return result
-
-
-def test_single_device(payload: dict) -> dict:
- device_id = str(payload.get("device_id") or "").strip()
- if not device_id:
- raise ValueError("缺少device_id")
- if device_id not in SCAN_STATE.get("devices", {}):
- raise ValueError("设备不在当前扫描结果中,请先扫描设备")
- config = selected_catalog([device_id])
- tested = detector_from_payload(payload, config=config).run().to_dict()
- result = prepare_ui_result(tested, "single")
- target = next((item for item in result.get("devices", []) if str(item.get("device_id")) == device_id), None)
- if target is None:
- raise ValueError("未生成该设备的测试结果")
- return {"ok": True, "mode": "single", "device": target, "finished_at": result.get("finished_at", "")}
-
-
-def save_report(data: dict, keep_limit: int = REPORT_KEEP_LIMIT) -> dict:
- keep_limit = max(1, min(1000, int(keep_limit or REPORT_KEEP_LIMIT)))
- os.makedirs(REPORT_DIR, exist_ok=True)
- stamp = time.strftime("%Y%m%d_%H%M%S")
- suffix = int((time.time() * 1000) % 1000)
- folder = f"{stamp}_{suffix:03d}"
- folder_path = os.path.join(REPORT_DIR, folder)
- os.makedirs(folder_path, exist_ok=True)
- html_name = "report.html"
- txt_name = "report.txt"
- json_name = "report.json"
- report_info = {
- "folder": folder,
- "html": html_name,
- "txt": txt_name,
- "json": json_name,
- "url": f"/report/{folder}/{html_name}",
- "html_url": f"/report/{folder}/{html_name}",
- "txt_url": f"/report/{folder}/{txt_name}",
- "json_url": f"/report/{folder}/{json_name}",
- "keep_limit": keep_limit,
- }
- logs = data.setdefault("logs", [])
- logs.append(f"[{time.strftime('%Y-%m-%d %H:%M:%S')}] 生成测试报告:report/{folder}/report.html、report.txt、report.json,report目录最多保留 {keep_limit} 个报告文件夹")
- data["report"] = report_info
- data.setdefault("summary", {})["report_folder"] = folder
- data["summary"]["report_keep_limit"] = keep_limit
- with open(os.path.join(folder_path, html_name), "w", encoding="utf-8") as f:
- f.write(render_compact_report_html(data))
- with open(os.path.join(folder_path, txt_name), "w", encoding="utf-8") as f:
- f.write(render_compact_report_txt(data))
- with open(os.path.join(folder_path, json_name), "w", encoding="utf-8") as f:
- json.dump(compact_report_data(data), f, ensure_ascii=False, indent=2)
- prune_reports(keep_limit)
- return report_info
-
-
-def prune_reports(keep_limit: int = REPORT_KEEP_LIMIT):
- if not os.path.isdir(REPORT_DIR):
- return
- reports = []
- for name in os.listdir(REPORT_DIR):
- path = os.path.join(REPORT_DIR, name)
- if name.startswith("report_") and name.endswith(".html") and os.path.isfile(path):
- try:
- os.remove(path)
- except OSError:
- pass
- continue
- if os.path.isdir(path) and os.path.isfile(os.path.join(path, "report.json")):
- reports.append((os.path.getmtime(path), path))
- reports.sort(reverse=True)
- for _, path in reports[max(1, int(keep_limit or REPORT_KEEP_LIMIT)):]:
- try:
- shutil.rmtree(path)
- except OSError:
- pass
-
-
-def compact_result_text(device: dict) -> str:
- if device.get("status") in CONNECTED_STATUSES:
- return "连通正常"
- evidence = str(device.get("evidence") or "")
- if "timed out" in evidence.lower() or "超时" in evidence or "未收到响应" in evidence:
- return "无响应"
- if "不存在" in evidence:
- return "接口不存在"
- return "测试失败"
-
-
-def compact_connection_text(device: dict) -> str:
- connection = {"serial": "串口", "network": "网口", "pci": "PCI"}.get(
- str(device.get("connection_type") or ""), str(device.get("connection_type") or "-")
- )
- protocol = str(device.get("protocol") or "").lower()
- connection = {
- "modbus_rtu": "Modbus RTU",
- "modbus_tcp": "Modbus TCP",
- "tcp": "TCP",
- "udp": "UDP",
- }.get(protocol, connection)
- via = device.get("via_device", {}) or {}
- via_name = str(via.get("device_name") or "")
- return f"{connection} / 经{via_name}" if via_name else connection
-
-
-def compact_report_data(data: dict) -> dict:
- devices = data.get("devices", []) or []
- summary = data.get("summary", {}) or {}
- interfaces = []
- for category, key in INTERFACE_GROUP_KEYS.items():
- for item in data.get(key, []) or []:
- interfaces.append({
- "type": category,
- "interface": item.get("name", ""),
- "status": item.get("status", ""),
- "devices": (item.get("details", {}) or {}).get("configured_devices", []) or [],
- })
-
- compact_devices = []
- for device in devices:
- passed = device.get("status") in CONNECTED_STATUSES
- via = device.get("via_device", {}) or {}
- compact_devices.append({
- "device_id": device.get("device_id", ""),
- "device_name": device.get("name", ""),
- "device_type": device.get("type", ""),
- "interface_type": device.get("connection_type", ""),
- "interface": device.get("interface", ""),
- "communication": compact_connection_text(device),
- "via_device": via.get("device_name", ""),
- "result": "PASS" if passed else "FAIL",
- "test_report": compact_result_text(device),
- })
-
- return {
- "report_version": 1,
- "hostname": data.get("hostname", ""),
- "started_at": data.get("started_at", ""),
- "finished_at": data.get("finished_at", ""),
- "duration_ms": round(float(data.get("duration_ms") or 0), 1),
- "summary": {
- "found": len(devices),
- "passed": summary.get("passed", 0),
- "failed": summary.get("failed", 0),
- },
- "interfaces": interfaces,
- "devices": compact_devices,
- "report": data.get("report", {}) or {},
- }
-
-
-def render_compact_report_txt(data: dict) -> str:
- summary = data.get("summary", {}) or {}
- devices = data.get("devices", []) or []
- lines = [
- "设备连通性测试报告",
- "=" * 28,
- f"主机: {data.get('hostname', '')}",
- f"测试时间: {data.get('finished_at') or data.get('started_at', '')}",
- f"发现设备: {summary.get('device_count', len(devices))}",
- f"通过: {summary.get('passed', 0)}",
- f"失败: {summary.get('failed', 0)}",
- "",
- "测试结果",
- "-" * 28,
- ]
- if not devices:
- lines.append("未发现配置库中的已知设备")
- for index, device in enumerate(devices, start=1):
- result = "通过" if device.get("status") in CONNECTED_STATUSES else "失败"
- lines.append(
- f"[{index}] {device.get('name', '')} | {device.get('interface', '-') or '-'} | "
- f"{compact_connection_text(device)} | {result} | {compact_result_text(device)}"
- )
- return "\n".join(lines) + "\n"
-
-
-def render_compact_report_html(data: dict) -> str:
- summary = data.get("summary", {}) or {}
- devices = data.get("devices", []) or []
-
- def esc(value) -> str:
- return html_lib.escape(str(value if value is not None else ""), quote=True)
-
- rows = []
- for device in devices:
- passed = device.get("status") in CONNECTED_STATUSES
- result = "通过" if passed else "失败"
- cls = "pass" if passed else "fail"
- rows.append(
- ""
- f"{esc(device.get('name', ''))} "
- f"{esc(device.get('interface', '') or '-')} "
- f"{esc(compact_connection_text(device))} "
- f"{result} "
- f"{esc(compact_result_text(device))} "
- " "
- )
- if not rows:
- rows.append('未发现配置库中的已知设备 ')
-
- return f"""
-
-
-
-
- 设备连通性测试报告
-
-
-
-
- 设备连通性测试报告
-
-
主机
{esc(data.get('hostname', '-'))}
-
测试时间
{esc(data.get('finished_at') or data.get('started_at', '-'))}
-
通过
{esc(summary.get('passed', 0))}
-
失败
{esc(summary.get('failed', 0))}
-
-
-
- 设备 接口 连接方式 结果 测试报告
- {''.join(rows)}
-
-
-
-
-"""
-
-
-class Handler(BaseHTTPRequestHandler):
- server_version = "QLDeviceCheckGeneric/2.0"
-
- def do_GET(self):
- path = urlparse(self.path).path
- if path in ("/", "/index.html"):
- with open(WEB_INDEX_PATH, "r", encoding="utf-8") as f:
- self._send_html(f.read())
- return
- if path == "/api/health":
- self._send_json({"ok": True})
- return
- if path == "/api/standards":
- self._send_json({"ok": True, "devices": StandardDeviceConfig().devices()})
- return
- if path.startswith("/report/"):
- self._send_report(path)
- return
- if path == "/favicon.ico":
- self._send_empty()
- return
- self.send_error(404, "Not Found")
-
- def do_POST(self):
- path = urlparse(self.path).path
- handlers = {
- "/api/scan": scan_catalog,
- "/api/scan-interface": scan_interface,
- "/api/run": test_all_discovered,
- "/api/test-device": test_single_device,
- }
- operation = handlers.get(path)
- if operation is None:
- self.send_error(404, "Not Found")
- return
- if not RUN_LOCK.acquire(blocking=False):
- self._send_json({"ok": False, "error": "检测任务正在运行,请稍候"}, status=409)
- return
- try:
- payload = self._read_json()
- self._send_json(operation(payload))
- except Exception as exc:
- self._send_json({
- "ok": False,
- "error": str(exc),
- "traceback": traceback.format_exc().splitlines(),
- }, status=500)
- finally:
- RUN_LOCK.release()
-
- def _read_json(self):
- length = int(self.headers.get("Content-Length", "0"))
- if length <= 0:
- return {}
- raw = self.rfile.read(length).decode("utf-8")
- if not raw.strip():
- return {}
- return json.loads(raw)
-
- def _send_html(self, html: str):
- encoded = html.encode("utf-8")
- self.send_response(200)
- self.send_header("Content-Type", "text/html; charset=utf-8")
- self.send_header("Content-Length", str(len(encoded)))
- self.end_headers()
- self.wfile.write(encoded)
-
- def _send_report(self, path: str):
- relative = unquote(path[len("/report/"):]).replace("\\", "/").strip("/")
- if not relative or ".." in relative.split("/"):
- self.send_error(404, "Not Found")
- return
- if not relative.endswith((".html", ".txt", ".json")):
- self.send_error(404, "Not Found")
- return
- full_path = os.path.abspath(os.path.join(REPORT_DIR, *relative.split("/")))
- report_root = os.path.abspath(REPORT_DIR)
- if not full_path.startswith(report_root + os.sep) or not os.path.isfile(full_path):
- self.send_error(404, "Not Found")
- return
- content_types = {
- ".html": "text/html; charset=utf-8",
- ".txt": "text/plain; charset=utf-8",
- ".json": "application/json; charset=utf-8",
- }
- ext = os.path.splitext(full_path)[1]
- with open(full_path, "rb") as f:
- encoded = f.read()
- self.send_response(200)
- self.send_header("Content-Type", content_types.get(ext, "application/octet-stream"))
- self.send_header("Content-Length", str(len(encoded)))
- self.end_headers()
- self.wfile.write(encoded)
-
- def _send_json(self, data: dict, status: int = 200):
- encoded = json.dumps(data, ensure_ascii=False).encode("utf-8")
- self.send_response(status)
- self.send_header("Content-Type", "application/json; charset=utf-8")
- self.send_header("Content-Length", str(len(encoded)))
- self.end_headers()
- self.wfile.write(encoded)
-
- def _send_empty(self, status: int = 204):
- self.send_response(status)
- self.send_header("Content-Length", "0")
- self.end_headers()
-
- def log_message(self, fmt, *args):
- print("[%s] %s" % (self.log_date_time_string(), fmt % args))
+def __getattr__(name):
+ # Preserve explicitly requested Python helpers without importing hardware in demo.
+ if name == 'preserve_scan_interfaces':
+ from legacy_web import preserve_scan_interfaces
+ return preserve_scan_interfaces
+ raise AttributeError(name)
def main():
- parser = argparse.ArgumentParser(description="工控机通用测试系统 Web UI")
- parser.add_argument("--host", default="127.0.0.1")
- parser.add_argument("--port", default=8080, type=int)
+ parser = argparse.ArgumentParser(description='Field acceptance workbench')
+ parser.add_argument('--demo', action='store_true', help='Synthetic devices; never access hardware')
+ parser.add_argument('--data-dir', default=str(Path(__file__).resolve().parent / 'data'))
+ parser.add_argument('--host', default='127.0.0.1')
+ parser.add_argument('--port', default=8080, type=int)
+ parser.add_argument('--config', default=None)
args = parser.parse_args()
-
- server = ThreadingHTTPServer((args.host, args.port), Handler)
- print("工控机通用测试系统已启动")
- print(f"访问地址: http://{args.host}:{args.port}")
- print("按 Ctrl+C 退出")
+ service = None
+ server = None
try:
+ service = InspectionService(Path(args.data_dir) / 'jobs.db', demo=args.demo, config_path=args.config)
+ server = make_server(args.host, args.port, service, token=os.environ.get('QLDC_ACCESS_TOKEN', ''))
+ print('SIMULATED demo mode' if args.demo else 'LIVE field mode')
+ print(f'Workbench: http://{args.host}:{args.port}')
server.serve_forever()
except KeyboardInterrupt:
- print("\n正在退出...")
+ print('Shutting down...')
+ except Exception:
+ print('Startup or operation failed. Check configuration, port and QLDC_ACCESS_TOKEN.')
+ return 1
finally:
- server.server_close()
+ if server:
+ server.server_close()
+ if service:
+ service.close()
+ return 0
-if __name__ == "__main__":
- main()
+if __name__ == '__main__':
+ raise SystemExit(main())
From bbe0d7baed732a84e3280f136a198bbced2e648a Mon Sep 17 00:00:00 2001
From: Kaiser <104786001+KaiserIIII@users.noreply.github.com>
Date: Wed, 7 Oct 2026 12:55:04 +0800
Subject: [PATCH 03/15] fix: assemble TCP frames and validate Modbus read
lengths
---
core/generic_detector.py | 12 ++-
core/protocol_engine.py | 27 ++++++-
core/transport.py | 34 ++++++++
tests/test_protocol_regressions.py | 126 +++++++++++++++++++++++++++++
4 files changed, 193 insertions(+), 6 deletions(-)
create mode 100644 core/transport.py
create mode 100644 tests/test_protocol_regressions.py
diff --git a/core/generic_detector.py b/core/generic_detector.py
index 56dbabd..33cdade 100644
--- a/core/generic_detector.py
+++ b/core/generic_detector.py
@@ -18,6 +18,7 @@
import serial.tools.list_ports
from .config_manager import StandardDeviceConfig
+from .transport import modbus_tcp_complete, receive_response
from .protocol_engine import (
ProtocolConfigError,
build_modbus_rtu_read,
@@ -786,6 +787,7 @@ def _test_modbus_rtu_configured(self, port: str, serial_cfg: dict, protocol: dic
slave_id=frame[0],
function_code=frame[1],
accept_exception=bool(probe.get("accept_exception", protocol.get("accept_exception", False))),
+ expected_count=int.from_bytes(frame[4:6], "big"),
)
rule = response_rule(protocol, probe)
if response_ok and rule:
@@ -1136,7 +1138,11 @@ def _test_tcp_protocol_configured(self, ip: str, port: int, iface: str,
if payload:
sock.sendall(payload)
should_read = bool(rule) or bool(probe.get("read_response", bool(payload)))
- response = sock.recv(max(1, min(65536, int(probe.get("read_size", 4096) or 4096)))) if should_read else b""
+ response = receive_response(
+ sock, timeout,
+ max_bytes=max(1, min(65536, int(probe.get("read_size", 4096) or 4096))),
+ complete=(lambda data: match_response(data, rule)[0]) if rule else None,
+ ) if should_read else b""
if should_read:
ok, note = match_response(response, rule)
else:
@@ -1206,13 +1212,15 @@ def _test_modbus_tcp_configured(self, ip: str, port: int, iface: str,
self._bind_network_socket(sock, iface)
sock.connect((ip, port))
sock.sendall(frame)
- response = sock.recv(260)
+ response = receive_response(sock, float(reg.get("timeout", protocol.get("timeout", 3)) or 3),
+ max_bytes=260, complete=modbus_tcp_complete)
response_ok, response_note, valid_frame = validate_modbus_tcp_response(
response,
transaction_id=index,
unit_id=unit_id,
function_code=function_code,
accept_exception=bool(reg.get("accept_exception", protocol.get("accept_exception", False))),
+ expected_count=count,
)
rule = response_rule(protocol, reg)
if response_ok and rule:
diff --git a/core/protocol_engine.py b/core/protocol_engine.py
index 304e40a..9b1d227 100644
--- a/core/protocol_engine.py
+++ b/core/protocol_engine.py
@@ -199,7 +199,7 @@ def build_modbus_rtu_read(slave_id: int, function_code: int, address: int, count
def find_modbus_rtu_response(data: bytes, slave_id: int, function_code: int,
- accept_exception: bool = False) -> Tuple[bool, str, bytes]:
+ accept_exception: bool = False, expected_count: Optional[int] = None) -> Tuple[bool, str, bytes]:
if not data:
return False, "未收到响应", b""
for start in range(max(0, len(data) - 4)):
@@ -210,7 +210,10 @@ def find_modbus_rtu_response(data: bytes, slave_id: int, function_code: int,
if is_exception:
frame_length = 5
elif actual_function == function_code:
- frame_length = 5 + data[start + 2]
+ byte_count = data[start + 2]
+ if not 1 <= byte_count <= 250 or (function_code in (3, 4) and byte_count % 2):
+ continue
+ frame_length = 5 + byte_count
else:
continue
end = start + frame_length
@@ -224,6 +227,10 @@ def find_modbus_rtu_response(data: bytes, slave_id: int, function_code: int,
if accept_exception:
return True, f"设备返回Modbus异常码0x{code:02X},按配置允许异常响应", frame
return False, f"设备返回Modbus异常码0x{code:02X}", frame
+ if expected_count is not None:
+ expected_bytes = expected_count * 2 if function_code in (3, 4) else (expected_count + 7) // 8
+ if frame[2] != expected_bytes:
+ return False, "Modbus RTU响应数量与请求不匹配", frame
return True, "返回有效Modbus RTU响应", frame
return False, "收到数据,但站号、功能码或CRC不匹配", b""
@@ -249,24 +256,36 @@ def build_modbus_tcp_read(transaction_id: int, unit_id: int, function_code: int,
def validate_modbus_tcp_response(data: bytes, transaction_id: int, unit_id: int,
- function_code: int, accept_exception: bool = False) -> Tuple[bool, str, bytes]:
+ function_code: int, accept_exception: bool = False,
+ expected_count: Optional[int] = None) -> Tuple[bool, str, bytes]:
if len(data) < 9:
return False, "Modbus TCP响应长度不足", b""
if int.from_bytes(data[0:2], "big") != transaction_id or data[2:4] != b"\x00\x00":
return False, "Modbus TCP事务号或协议标识不匹配", b""
declared_length = int.from_bytes(data[4:6], "big")
frame_length = 6 + declared_length
- if declared_length < 3 or len(data) < frame_length:
+ if declared_length < 3 or declared_length > 254 or len(data) < frame_length:
return False, "Modbus TCP长度字段不正确", b""
frame = data[:frame_length]
if frame[6] != (unit_id & 0xFF):
return False, "Modbus TCP单元标识不匹配", frame
actual_function = frame[7]
if actual_function == (function_code | 0x80):
+ if declared_length != 3:
+ return False, "Modbus TCP异常响应长度不正确", frame
code = frame[8]
if accept_exception:
return True, f"设备返回Modbus异常码0x{code:02X},按配置允许异常响应", frame
return False, f"设备返回Modbus异常码0x{code:02X}", frame
if actual_function != function_code:
return False, "Modbus TCP功能码不匹配", frame
+ byte_count = frame[8]
+ if not 1 <= byte_count <= 250 or declared_length != 3 + byte_count:
+ return False, "Modbus TCP数据字节数与长度不匹配", frame
+ if function_code in (3, 4) and byte_count % 2:
+ return False, "Modbus TCP寄存器数据必须为完整的双字节", frame
+ if expected_count is not None:
+ expected_bytes = expected_count * 2 if function_code in (3, 4) else (expected_count + 7) // 8
+ if byte_count != expected_bytes:
+ return False, "Modbus TCP响应数量与请求不匹配", frame
return True, "返回有效Modbus TCP响应", frame
diff --git a/core/transport.py b/core/transport.py
new file mode 100644
index 0000000..f7109a3
--- /dev/null
+++ b/core/transport.py
@@ -0,0 +1,34 @@
+"""Bounded stream assembly; TCP recv boundaries are not application frames."""
+import socket
+import time
+
+
+def modbus_tcp_complete(data):
+ if len(data) < 6:
+ return False
+ length = int.from_bytes(data[4:6], 'big')
+ return length < 3 or length > 254 or len(data) >= 6 + length
+
+
+def receive_response(sock, timeout, max_bytes=4096, complete=None):
+ deadline = time.monotonic() + timeout
+ data = bytearray()
+ while len(data) < max_bytes:
+ remaining = deadline - time.monotonic()
+ if remaining <= 0:
+ break
+ sock.settimeout(remaining)
+ try:
+ fragment = sock.recv(max_bytes - len(data))
+ except socket.timeout:
+ break
+ except ConnectionError:
+ if not data:
+ raise
+ break
+ if not fragment:
+ break
+ data.extend(fragment)
+ if complete and complete(bytes(data)):
+ break
+ return bytes(data)
diff --git a/tests/test_protocol_regressions.py b/tests/test_protocol_regressions.py
new file mode 100644
index 0000000..f62f585
--- /dev/null
+++ b/tests/test_protocol_regressions.py
@@ -0,0 +1,126 @@
+import socket
+import threading
+import time
+
+import pytest
+
+from core.protocol_engine import (ProtocolConfigError, append_checksum,
+ build_modbus_rtu_read, build_modbus_tcp_read, find_modbus_rtu_response,
+ validate_modbus_tcp_response)
+
+
+def test_tcp_declared_byte_count_is_checked():
+ packet = bytes.fromhex('00 01 00 00 00 05 01 03 04 00 2A')
+ assert not validate_modbus_tcp_response(packet, 1, 1, 3)[0]
+
+
+@pytest.mark.parametrize('payload', ['010303000000', '010300', '01030200'])
+def test_rtu_malformed_register_length_is_rejected(payload):
+ packet = append_checksum(bytes.fromhex(payload), 'crc16_modbus')
+ assert not find_modbus_rtu_response(packet, 1, 3)[0]
+
+
+@pytest.mark.parametrize('function', [5, 6, 15, 16, 23])
+def test_automatic_write_requests_are_refused(function):
+ with pytest.raises(ProtocolConfigError):
+ build_modbus_rtu_read(1, function, 0, 1)
+ with pytest.raises(ProtocolConfigError):
+ build_modbus_tcp_read(1, 1, function, 0, 1)
+
+
+def test_crc_corruption_and_truncation_are_rejected():
+ packet = bytes.fromhex('01 03 02 00 2A 39 9B')
+ assert not find_modbus_rtu_response(packet[:-1], 1, 3)[0]
+ assert not find_modbus_rtu_response(packet[:-1] + b'\x00', 1, 3)[0]
+
+
+@pytest.mark.parametrize('packet', [
+ '00 02 00 00 00 05 01 03 02 00 2A',
+ '00 01 00 01 00 05 01 03 02 00 2A',
+ '00 01 00 00 00 05 02 03 02 00 2A',
+ '00 01 00 00 00 05 01 04 02 00 2A',
+ '00 01 00 00 00 05 01 03 02 00',
+ '00 01 00 00 00 04 01 83 02 00',
+])
+def test_invalid_tcp_frames(packet):
+ assert not validate_modbus_tcp_response(bytes.fromhex(packet), 1, 1, 3)[0]
+
+
+class FragmentSocket:
+ def __init__(self, fragments):
+ self.fragments = iter(fragments)
+ def settimeout(self, value):
+ pass
+ def recv(self, size):
+ value = next(self.fragments, b'')
+ if isinstance(value, Exception):
+ raise value
+ return value[:size]
+
+
+def test_split_tcp_and_eof():
+ from core.transport import receive_response
+ sock = FragmentSocket([b'PO', b'NG', b' DEVICE-01', b''])
+ assert receive_response(sock, 1) == b'PONG DEVICE-01'
+
+
+def test_timeout_preserves_partial_response():
+ from core.transport import receive_response
+ assert receive_response(FragmentSocket([b'partial', socket.timeout()]), .1) == b'partial'
+
+
+def test_complete_mbap_frame_does_not_wait_for_eof():
+ from core.transport import receive_response, modbus_tcp_complete
+ raw = bytes.fromhex('00 01 00 00 00 05 01 03 02 00 2A')
+ assert receive_response(FragmentSocket([raw[:4], raw[4:8], raw[8:], AssertionError('extra recv')]), 1, complete=modbus_tcp_complete) == raw
+
+
+def test_connection_reset_preserves_partial_bytes():
+ from core.transport import receive_response
+ assert receive_response(FragmentSocket([b'partial', ConnectionResetError()]), 1) == b'partial'
+
+
+def test_empty_connection_reset_remains_error():
+ from core.transport import receive_response
+ with pytest.raises(ConnectionResetError):
+ receive_response(FragmentSocket([ConnectionResetError()]), 1)
+
+
+def test_read_response_must_match_requested_count():
+ tcp = bytes.fromhex('00 01 00 00 00 05 01 03 02 00 2A')
+ rtu = append_checksum(bytes.fromhex('01 03 02 00 2A'), 'crc16_modbus')
+ assert not validate_modbus_tcp_response(tcp, 1, 1, 3, expected_count=2)[0]
+ assert not find_modbus_rtu_response(rtu, 1, 3, expected_count=2)[0]
+
+
+@pytest.mark.parametrize('modbus', [False, True])
+def test_configured_tcp_receives_fragmented_loopback_response(modbus):
+ from core.generic_detector import GenericDetector
+ server = socket.socket()
+ server.bind(('127.0.0.1', 0))
+ server.listen(1)
+ server.settimeout(2)
+ port = server.getsockname()[1]
+ raw = bytes.fromhex('00 01 00 00 00 05 01 03 02 00 2A') if modbus else b'PONG DEVICE-01'
+ def serve():
+ try:
+ conn, _ = server.accept()
+ with conn:
+ conn.recv(128)
+ for chunk in (raw[:2], raw[2:6], raw[6:]):
+ conn.sendall(chunk)
+ time.sleep(.015)
+ finally:
+ server.close()
+ thread = threading.Thread(target=serve, daemon=True)
+ thread.start()
+ detector = GenericDetector()
+ protocol = {'type': 'modbus_tcp', 'test_registers': [{'address': 0, 'count': 1}]} if modbus else {'type': 'fixture_tcp', 'probes': [{'request_text': 'PING', 'response_match': {'exact_text': 'PONG DEVICE-01'}}]}
+ parent = {'network_config': {'transport': 'tcp', 'device_role': 'server'}}
+ profile = detector._network_profile({'protocol': protocol}, parent)
+ try:
+ result = detector._test_modbus_tcp_configured('127.0.0.1', port, '', protocol, profile) if modbus else detector._test_tcp_protocol_configured('127.0.0.1', port, '', {'timeout': 1}, protocol, profile)
+ assert result['ok']
+ assert raw.hex(' ').upper() in result['response']
+ finally:
+ thread.join(2)
From 38a2a47eda61c0c526c69db680d2c61293dde39a Mon Sep 17 00:00:00 2001
From: Kaiser <104786001+KaiserIIII@users.noreply.github.com>
Date: Wed, 7 Oct 2026 13:05:34 +0800
Subject: [PATCH 04/15] feat: render printable acceptance reports with explicit
coverage
---
inspection/reports.py | 41 ++++++++++++++++++++++++++++++++++++++---
tests/test_workbench.py | 13 +++++++++++++
2 files changed, 51 insertions(+), 3 deletions(-)
diff --git a/inspection/reports.py b/inspection/reports.py
index 6ba9b5c..b076ebe 100644
--- a/inspection/reports.py
+++ b/inspection/reports.py
@@ -3,15 +3,50 @@
import io
import json
+from .domain import catalog_devices
+
+
+def html_report(job, marker):
+ """A portable, printable report; evidence and snapshot travel with the file."""
+ def esc(value):
+ return html.escape(str(value if value is not None else '—'), quote=True)
+ def code(value):
+ return '' + esc(value) + ' '
+ expected = len(catalog_devices(job['config_snapshot']))
+ selected = len(job['device_ids'])
+ scope = '仅选定设备' if selected != expected else '完整配置设备'
+ notice = ('SIMULATED · 合成演示,不能作为真实设备验收凭据。' if job['mode'] == 'demo'
+ else 'LIVE · 结论仅覆盖本次通信路径及 PCI 被动状态;电气性能、业务功能需另行验证。')
+ labels = {'PASS': '通过', 'FAIL': '未通过', 'REVIEW': '需复核', 'NOT_RUN': '未执行'}
+ metadata = [(label, job['metadata'].get(key, '')) for key, label in
+ [('station_id', '工位 / 设备编号'), ('batch', '批次'), ('operator', '操作者'), ('notes', '备注')]]
+ metadata += [('任务 ID', job['id']), ('复测源任务', job.get('parent_job_id')), ('任务状态', job['status']),
+ ('创建时间 UTC', job['created_at']), ('结束时间 UTC', job['finished_at']),
+ ('耗时 ms', job['duration_ms']), ('配置 SHA-256', job['config_hash'])]
+ info = ''.join('
' + esc(k) + ' ' + esc(v) + ' ' for k, v in metadata)
+ rows, evidence = [], []
+ for result in job['results']:
+ verdict = result['verdict']
+ scope_name = 'PCI 被动状态' if result['scope'] == 'pci_passive' else '通信路径'
+ rows.append('' + ''.join('' + esc(v) + ' ' for v in [result['device_id'], result['name'],
+ result['interface'], scope_name, labels[verdict] + ' / ' + verdict, result['fault_code'], result['duration_ms']]) + ' ')
+ evidence.append('' + esc(result['name']) + ' · ' + esc(result['device_id']) + ' ' +
+ esc(result['summary']) + '
排查建议:' + esc(result['suggestion']) +
+ '
请求 / Request ' + code(result['request']) + '响应 / Response ' + code(result['response']) + ' ')
+ summary = job['summary']
+ totals = '通过 {passed} · 未通过 {failed} · 需复核 {review} · 未执行 {not_run}'.format(**summary)
+ return '''
+工控设备验收报告 QLDEVICECHECK / FIELD ACCEPTANCE
工控设备验收报告 ''' + '' + esc(notice) + '
' + esc(labels[summary['verdict']]) + ' / ' + esc(summary['verdict']) + ' ' + esc(scope) + ':' + str(selected) + ' / ' + str(expected) + ' 台' + esc(totals) + '
部分设备复测通过不表示整机恢复。任务中止、取消或失败时,已收集的证据仍保留,结论需复核。
验收记录 ' + info + ' 设备结论 测试证据 ' + ''.join(evidence) + '配置快照与任务事件 SHA-256 用于识别配置版本;本报告未使用数字签名,不提供防篡改证明。
' + code(json.dumps({'config_snapshot': job['config_snapshot'], 'events': job['events']}, ensure_ascii=False, indent=2)) + ' '
+
def export_job(job, format):
marker = 'SIMULATED' if job['mode'] == 'demo' else 'LIVE'
if format == 'json':
return json.dumps(dict(job, report_marker=marker), ensure_ascii=False, indent=2).encode(), 'application/json; charset=utf-8'
if format == 'html':
- esc = lambda value: html.escape(str(value), quote=True)
- # Complete evidence is retained, including the original configuration snapshot.
- content = 'Acceptance report ' + marker + ' Acceptance report Scope: communication path / PCI passive state. Business and electrical function require separate verification.
' + esc(json.dumps(job, ensure_ascii=False, indent=2)) + ' '
+ content = html_report(job, marker)
return content.encode(), 'text/html; charset=utf-8'
if format == 'csv':
stream = io.StringIO(newline='')
diff --git a/tests/test_workbench.py b/tests/test_workbench.py
index eae57b0..efb9037 100644
--- a/tests/test_workbench.py
+++ b/tests/test_workbench.py
@@ -133,6 +133,19 @@ def test_reports_preserve_evidence_and_escape(service):
assert json.loads(raw)['results'][0]['response']
+def test_html_report_is_readable_and_declares_partial_scope(service):
+ job = wait_finished(service, service.create({'station_id': 'DEMO-UNIT-7', 'batch': 'B7', 'device_ids': ['DEMO_RELAY']})['id'])
+ content, mime = export_job(job, 'html')
+ report = content.decode('utf-8')
+ assert '
Date: Wed, 7 Oct 2026 13:15:19 +0800
Subject: [PATCH 05/15] build: package offline station installers and pinned
verification environment
---
.gitattributes | 12 ++
.github/workflows/tests.yml | 27 +++++
.gitignore | 7 ++
Start-Demo.cmd | 6 +
docs/DEPENDENCIES.md | 23 ++++
docs/RELEASE.md | 45 +++++++
docs/dependency-audit.json | 227 ++++++++++++++++++++++++++++++++++++
pytest.ini | 4 +
requirements-dev.txt | 8 ++
requirements-runtime.lock | 26 +++++
requirements.lock | 133 +++++++++++++++++++++
requirements.txt | 2 +-
run_demo.ps1 | 3 +
run_demo.sh | 4 +
run_web.ps1 | 7 ++
setup_linux.sh | 8 +-
setup_windows.ps1 | 22 ++++
tests/release_smoke.py | 98 ++++++++++++++++
tools/build_release.py | 67 +++++++++++
19 files changed, 726 insertions(+), 3 deletions(-)
create mode 100644 .gitattributes
create mode 100644 .github/workflows/tests.yml
create mode 100644 Start-Demo.cmd
create mode 100644 docs/DEPENDENCIES.md
create mode 100644 docs/RELEASE.md
create mode 100644 docs/dependency-audit.json
create mode 100644 pytest.ini
create mode 100644 requirements-dev.txt
create mode 100644 requirements-runtime.lock
create mode 100644 requirements.lock
create mode 100644 run_demo.ps1
create mode 100644 run_demo.sh
create mode 100644 run_web.ps1
create mode 100644 setup_windows.ps1
create mode 100644 tests/release_smoke.py
create mode 100644 tools/build_release.py
diff --git a/.gitattributes b/.gitattributes
new file mode 100644
index 0000000..5afc912
--- /dev/null
+++ b/.gitattributes
@@ -0,0 +1,12 @@
+* text=auto
+*.sh text eol=lf
+*.py text eol=lf
+*.js text eol=lf
+*.cjs text eol=lf
+*.html text eol=lf
+*.css text eol=lf
+*.md text eol=lf
+*.yml text eol=lf
+*.lock text eol=lf
+*.ps1 text eol=crlf
+*.cmd text eol=crlf
diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml
new file mode 100644
index 0000000..7e9a338
--- /dev/null
+++ b/.github/workflows/tests.yml
@@ -0,0 +1,27 @@
+name: Offline acceptance checks
+on:
+ push:
+ pull_request:
+permissions:
+ contents: read
+jobs:
+ offline:
+ strategy:
+ fail-fast: false
+ matrix:
+ os: [ubuntu-latest, windows-latest]
+ python: ['3.10', '3.13']
+ runs-on: ${{ matrix.os }}
+ env:
+ PYTHONIOENCODING: utf-8
+ steps:
+ - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
+ - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0
+ with:
+ python-version: ${{ matrix.python }}
+ - run: python -m pip install --require-hashes -r requirements.lock
+ - run: python validate_config.py
+ - run: python self_test.py
+ - run: python -m coverage run --source=inspection,core.protocol_engine,core.transport -m pytest
+ - run: python -m coverage report --show-missing
+ - run: python -m compileall -q inspection core web_app.py legacy_web.py
diff --git a/.gitignore b/.gitignore
index 85c67f4..6903657 100644
--- a/.gitignore
+++ b/.gitignore
@@ -11,6 +11,13 @@ __pycache__/
# Generated reports
report/
+data/
+output/
+.coverage
+coverage.xml
+htmlcov/
+.pytest_cache/
+.superpowers/
# IDE
.idea/
diff --git a/Start-Demo.cmd b/Start-Demo.cmd
new file mode 100644
index 0000000..229f9c3
--- /dev/null
+++ b/Start-Demo.cmd
@@ -0,0 +1,6 @@
+@echo off
+cd /d "%~dp0"
+echo QLDeviceCheck demo: http://127.0.0.1:8080
+echo Leave this window open while using the workbench. Ctrl+C stops the station.
+powershell.exe -NoProfile -ExecutionPolicy Bypass -File "%~dp0run_demo.ps1" %*
+if errorlevel 1 pause
diff --git a/docs/DEPENDENCIES.md b/docs/DEPENDENCIES.md
new file mode 100644
index 0000000..dfaefcb
--- /dev/null
+++ b/docs/DEPENDENCIES.md
@@ -0,0 +1,23 @@
+# Dependency provenance / 依赖来源
+
+No runtime framework was added. Runtime uses Python's standard library plus the original psutil and pyserial dependencies. Exact release versions, official source commits, licenses, installed wheel URLs, SHA-256 and installation time are recorded in [dependency-audit.json](dependency-audit.json).
+
+| Package | Version | License | Role |
+| --- | --- | --- | --- |
+| psutil | 7.2.2 | BSD-3-Clause | Interface/system inspection |
+| pyserial | 3.5 | BSD-3-Clause | Serial transport |
+| pytest | 8.4.2 | MIT | Test runner |
+| coverage | 7.10.6 | Apache-2.0 | Scoped coverage |
+| iniconfig | 2.1.0 | MIT | Pytest dependency |
+| packaging | 25.0 | Apache-2.0 / BSD-2-Clause | Pytest dependency |
+| pluggy | 1.6.0 | MIT | Pytest dependency |
+| Pygments | 2.19.2 | BSD-2-Clause | Pytest dependency |
+| colorama | 0.4.6 | BSD-3-Clause | Windows test output |
+
+`requirements-runtime.lock` contains the two runtime releases. `requirements.lock` contains the complete test environment. Both use release artifact hashes; run `python -m pip install --require-hashes -r `. The corresponding `.txt` files are readable version manifests. Source tag commits were checked against official repositories; installed artifacts are PyPI distributions, not source builds. The audit records their separate identities without claiming bit-for-bit reproducibility between wheel and source commit. Hash verification verifies the selected distribution, not absence of vulnerabilities.
+
+CI Actions are fixed to reviewed commits: `actions/checkout` v4.2.2 at `11bd71901bbe5b1630ceea73d27597364c9af683`; `actions/setup-python` v5.6.0 at `a26af69be951a213d495a4c3e4e4022e16d87065` (both MIT). Runner images and Python patch versions remain platform-managed and are not fully reproducible artifacts. No new third-party Skill was installed or cloned.
+
+The optional browser regression uses an existing local Playwright/Chrome installation. It is not part of the application runtime or hash-locked Python environment. See [validation](VALIDATION.md) for the versions actually exercised.
+
+Release bundles include only the reviewed runtime wheels, selected for Windows x64 or Linux x86_64. `tools/build_release.py` refuses artifacts outside the audited SHA-256 set and includes source provenance plus copies of wheel license files. Python itself remains a prerequisite. Bundle installation uses `--no-index --find-links wheels`, following [pip's official offline download/install workflow](https://pip.pypa.io/en/stable/cli/pip_download/).
diff --git a/docs/RELEASE.md b/docs/RELEASE.md
new file mode 100644
index 0000000..4c630a4
--- /dev/null
+++ b/docs/RELEASE.md
@@ -0,0 +1,45 @@
+# Release 安装与使用
+
+下载 [GitHub Release](https://github.com/KaiserIIII/QLDeviceCheck_Generic_WebUI_Linux/releases) 中对应平台的包:
+
+| 包 | 用途 |
+| --- | --- |
+| `QLDeviceCheck-v3.0.0-windows-x64.zip` | Windows x64,含离线 psutil/pyserial 运行依赖 |
+| `QLDeviceCheck-v3.0.0-linux-x86_64.zip` | Linux x86_64/glibc,含离线运行依赖 |
+| `SHA256SUMS.txt` | 下载文件校验值 |
+
+需要预先安装 **CPython 3.10+**,包括 venv/pip。包不包含 Python 解释器;ARM、其他架构及 Python 自由线程构建需要匹配的依赖制品。Linux 的 psutil wheel 支持 glibc 2.12+ 的常规 CPython 构建。项目方已确认项目测试可用;本次 Release 的检查结果见 [VALIDATION.md](VALIDATION.md)。
+
+## Windows
+
+1. 解压到可写目录。
+2. 双击 `Start-Demo.cmd`。首次启动自动创建 `.venv`,从随包 `wheels/` 离线安装固定依赖。
+3. 浏览器打开 `http://127.0.0.1:8080`,保留终端窗口;`Ctrl+C` 关闭服务。
+
+执行真实验收使用 PowerShell:
+
+```powershell
+powershell -NoProfile -ExecutionPolicy Bypass -File .\setup_windows.ps1
+powershell -NoProfile -ExecutionPolicy Bypass -File .\run_web.ps1 --config config\device_list.json --data-dir data\live
+```
+
+## Linux
+
+```bash
+unzip QLDeviceCheck-v3.0.0-linux-x86_64.zip
+cd QLDeviceCheck-v3.0.0
+bash setup_linux.sh
+bash run_demo.sh
+# 真实验收使用审核后的设备配置:
+bash run_web.sh --config config/device_list.json --data-dir data/live
+```
+
+安装器检测到 `wheels/` 时只使用本地依赖,校验锁定的 SHA-256。源码包不含 wheels 时使用 PyPI 安装同一版本。每个发行包包含 `RELEASE-MANIFEST.json`,记录对应的 Git commit、平台、依赖来源/许可证/哈希。
+
+## 数据与更新
+
+默认监听本机;网络使用和访问令牌见 [部署说明](DEPLOYMENT.md)。任务在选定的 `data` 目录保留。升级时先停止服务,备份数据和配置,将新版本解压到新目录,再用 `--data-dir` 指向原数据目录。不要复制旧虚拟环境到新平台,重新运行安装器。旧扫描报告位于 `report/`,需要单独保留。
+
+同一设备工位避免并发启动多个实例;同一数据库由独占锁保护。关闭服务后可打包备份,任务配置快照仍在记录中。Release 来源通过标签固定,PR 保留便于审阅,不自动合并主分支。
+
+离线 wheel 选择与哈希校验遵循 [pip 官方下载说明](https://pip.pypa.io/en/stable/cli/pip_download/)和[安全安装说明](https://pip.pypa.io/en/stable/topics/secure-installs/)。
diff --git a/docs/dependency-audit.json b/docs/dependency-audit.json
new file mode 100644
index 0000000..4c343ca
--- /dev/null
+++ b/docs/dependency-audit.json
@@ -0,0 +1,227 @@
+{
+ "installed_at_utc": "2026-10-07 04:40:22 UTC",
+ "dependencies": [
+ {
+ "name": "psutil",
+ "version": "7.2.2",
+ "repo": "giampaolo/psutil",
+ "commit": "714a292bd14cf0c28b9454d34f5afc26dbb9b0dc",
+ "license": "BSD-3-Clause",
+ "installed_artifact_url": "https://files.pythonhosted.org/packages/b4/90/e2159492b5426be0c1fef7acba807a03511f97c5f86b3caeda6ad92351a7/psutil-7.2.2-cp37-abi3-win_amd64.whl",
+ "installed_artifact_sha256": "eb7e81434c8d223ec4a219b5fc1c47d0417b12be7ea866e24fb5ad6e84b3d988",
+ "allowed_artifact_sha256": [
+ "0746f5f8d406af344fd547f1c8daa5f5c33dbc293bb8d6a16d80b4bb88f59372",
+ "076a2d2f923fd4821644f5ba89f059523da90dc9014e85f8e45a5774ca5bc6f9",
+ "11fe5a4f613759764e79c65cf11ebdf26e33d6dd34336f8a337aa2996d71c841",
+ "1a571f2330c966c62aeda00dd24620425d4b0cc86881c89861fbc04549e5dc63",
+ "1a7b04c10f32cc88ab39cbf606e117fd74721c831c98a27dc04578deb0c16979",
+ "1fa4ecf83bcdf6e6c8f4449aff98eefb5d0604bf88cb883d7da3d8d2d909546a",
+ "2edccc433cbfa046b980b0df0171cd25bcaeb3a68fe9022db0979e7aa74a826b",
+ "7b6d09433a10592ce39b13d7be5a54fbac1d1228ed29abc880fb23df7cb694c9",
+ "8c233660f575a5a89e6d4cb65d9f938126312bca76d8fe087b947b3a1aaac9ee",
+ "917e891983ca3c1887b4ef36447b1e0873e70c933afc831c6b6da078ba474312",
+ "ab486563df44c17f5173621c7b198955bd6b613fb87c71c161f827d3fb149a9b",
+ "ae0aefdd8796a7737eccea863f80f81e468a1e4cf14d926bd9b6f5f2d5f90ca9",
+ "b0726cecd84f9474419d67252add4ac0cd9811b04d61123054b9fb6f57df6e9e",
+ "b58fabe35e80b264a4e3bb23e6b96f9e45a3df7fb7eed419ac0e5947c61e47cc",
+ "c7663d4e37f13e884d13994247449e9f8f574bc4655d509c3b95e9ec9e2b9dc1",
+ "e452c464a02e7dc7822a05d25db4cde564444a67e58539a00f929c51eddda0cf",
+ "e78c8603dcd9a04c7364f1a3e670cea95d51ee865e4efb3556a3a63adef958ea",
+ "eb7e81434c8d223ec4a219b5fc1c47d0417b12be7ea866e24fb5ad6e84b3d988",
+ "ed0cace939114f62738d808fdcecd4c869222507e266e574799e9c0faa17d486",
+ "eed63d3b4d62449571547b60578c5b2c4bcccc5387148db46e0c2313dad0ee00",
+ "fd04ef36b4a6d599bbdb225dd1d3f51e00105f6d48a28f006da7f9822f2606d8"
+ ]
+ },
+ {
+ "name": "pyserial",
+ "version": "3.5",
+ "repo": "pyserial/pyserial",
+ "commit": "0e7634747568547b8a7f9fd0c48ed74f16af4b23",
+ "license": "BSD-3-Clause",
+ "installed_artifact_url": "https://files.pythonhosted.org/packages/07/bc/587a445451b253b285629263eb51c2d8e9bcea4fc97826266d186f96f558/pyserial-3.5-py2.py3-none-any.whl",
+ "installed_artifact_sha256": "c4451db6ba391ca6ca299fb3ec7bae67a5c55dde170964c7a14ceefec02f2cf0",
+ "allowed_artifact_sha256": [
+ "3c77e014170dfffbd816e6ffc205e9842efb10be9f58ec16d3e8675b4925cddb",
+ "c4451db6ba391ca6ca299fb3ec7bae67a5c55dde170964c7a14ceefec02f2cf0"
+ ]
+ },
+ {
+ "name": "pytest",
+ "version": "8.4.2",
+ "repo": "pytest-dev/pytest",
+ "commit": "24941e4bf50e0f03114dc5262fa4730d0e7dd6ba",
+ "license": "MIT",
+ "installed_artifact_url": "https://files.pythonhosted.org/packages/a8/a4/20da314d277121d6534b3a980b29035dcd51e6744bd79075a6ce8fa4eb8d/pytest-8.4.2-py3-none-any.whl",
+ "installed_artifact_sha256": "872f880de3fc3a5bdc88a11b39c9710c3497a547cfa9320bc3c5e62fbf272e79",
+ "allowed_artifact_sha256": [
+ "86c0d0b93306b961d58d62a4db4879f27fe25513d4b969df351abdddb3c30e01",
+ "872f880de3fc3a5bdc88a11b39c9710c3497a547cfa9320bc3c5e62fbf272e79"
+ ]
+ },
+ {
+ "name": "coverage",
+ "version": "7.10.6",
+ "repo": "nedbat/coveragepy",
+ "commit": "51066d16ebe7b6314f15f7c29dab94514c1aeca7",
+ "license": "Apache-2.0",
+ "installed_artifact_url": "https://files.pythonhosted.org/packages/a7/14/0b831122305abcc1060c008f6c97bbdc0a913ab47d65070a01dc50293c2b/coverage-7.10.6-cp313-cp313-win_amd64.whl",
+ "installed_artifact_sha256": "628055297f3e2aa181464c3808402887643405573eb3d9de060d81531fa79d32",
+ "allowed_artifact_sha256": [
+ "073711de3181b2e204e4870ac83a7c4853115b42e9cd4d145f2231e12d670930",
+ "081b98395ced0d9bcf60ada7661a0b75f36b78b9d7e39ea0790bb4ed8da14747",
+ "0de434f4fbbe5af4fa7989521c655c8c779afb61c53ab561b64dcee6149e4c65",
+ "0e93b1476b79eae849dc3872faeb0bf7948fd9ea34869590bc16a2a00b9c82a7",
+ "0f3f56e4cb573755e96a16501a98bf211f100463d70275759e73f3cbc00d4f27",
+ "0f7cb359a448e043c576f0da00aa8bfd796a01b06aa610ca453d4dde09cc1034",
+ "10356fdd33a7cc06e8051413140bbdc6f972137508a3572e3f59f805cd2832fd",
+ "137921f2bac5559334ba66122b753db6dc5d1cf01eb7b64eb412bb0d064ef35b",
+ "160c00a5e6b6bdf4e5984b0ef21fc860bc94416c41b7df4d63f536d17c38902e",
+ "2195f8e16ba1a44651ca684db2ea2b2d4b5345da12f07d9c22a395202a05b23c",
+ "282b1b20f45df57cc508c1e033403f02283adfb67d4c9c35a90281d81e5c52c5",
+ "28395ca3f71cd103b8c116333fa9db867f3a3e1ad6a084aa3725ae002b6583bc",
+ "2904271c80898663c810a6b067920a61dd8d38341244a3605bd31ab55250dad5",
+ "2b38261034fda87be356f2c3f42221fdb4171c3ce7658066ae449241485390d5",
+ "2e4c33e6378b9d52d3454bd08847a8651f4ed23ddbb4a0520227bd346382bbc6",
+ "388d80e56191bf846c485c14ae2bc8898aa3124d9d35903fef7d907780477634",
+ "3e23dd5408fe71a356b41baa82892772a4cefcf758f2ca3383d2aa39e1b7a003",
+ "3fb99d0786fe17b228eab663d16bee2288e8724d26a199c29325aac4b0319b9b",
+ "441c357d55f4936875636ef2cfb3bee36e466dcf50df9afbd398ce79dba1ebb7",
+ "4cec13817a651f8804a86e4f79d815b3b28472c910e099e4d5a0e8a3b6a1d4cb",
+ "5aea98383463d6e1fa4e95416d8de66f2d0cb588774ee20ae1b28df826bcb619",
+ "5b15a87265e96307482746d86995f4bff282f14b027db75469c446da6127433b",
+ "5b2dd6059938063a2c9fee1af729d4f2af28fd1a545e9b7652861f0d752ebcea",
+ "5e75e37f23eb144e78940b40395b42f2321951206a4f50e23cfd6e8a198d3ceb",
+ "6008a021907be8c4c02f37cdc3ffb258493bdebfeaf9a839f9e71dfdc47b018e",
+ "61c950fc33d29c91b9e18540e1aed7d9f6787cc870a3e4032493bbbe641d12fc",
+ "628055297f3e2aa181464c3808402887643405573eb3d9de060d81531fa79d32",
+ "675824a363cc05781b1527b39dc2587b8984965834a748177ee3c37b64ffeafb",
+ "689920ecfd60f992cafca4f5477d55720466ad2c7fa29bb56ac8d44a1ac2b47a",
+ "692d70ea725f471a547c305f0d0fc6a73480c62fb0da726370c088ab21aed282",
+ "6937347c5d7d069ee776b2bf4e1212f912a9f1f141a429c475e6089462fcecc5",
+ "6b3039e2ca459a70c79523d39347d83b73f2f06af5624905eba7ec34d64d80b5",
+ "6e31b8155150c57e5ac43ccd289d079eb3f825187d7c66e755a055d2c85794c6",
+ "70e7bfbd57126b5554aa482691145f798d7df77489a177a6bef80de78860a356",
+ "752a3005a1ded28f2f3a6e8787e24f28d6abe176ca64677bcd8d53d6fe2ec08a",
+ "7d79dabc0a56f5af990cc6da9ad1e40766e82773c075f09cc571e2076fef882e",
+ "7eb68d356ba0cc158ca535ce1381dbf2037fa8cb5b1ae5ddfc302e7317d04144",
+ "80b1695cf7c5ebe7b44bf2521221b9bb8cdf69b1f24231149a7e3eb1ae5fa2fb",
+ "851430a9a361c7a8484a36126d1d0ff8d529d97385eacc8dfdc9bfc8c2d2cbe4",
+ "856986eadf41f52b214176d894a7de05331117f6035a28ac0016c0f63d887629",
+ "86b9b59f2b16e981906e9d6383eb6446d5b46c278460ae2c36487667717eccf1",
+ "8953746d371e5695405806c46d705a3cd170b9cc2b9f93953ad838f6c1e58612",
+ "8cdbe264f11afd69841bd8c0d83ca10b5b32853263ee62e6ac6a0ab63895f972",
+ "8dd5af36092430c2b075cee966719898f2ae87b636cefb85a653f1d0ba5d5393",
+ "8e0c38dc289e0508ef68ec95834cb5d2e96fdbe792eaccaa1bccac3966bbadcc",
+ "90558c35af64971d65fbd935c32010f9a2f52776103a259f1dee865fe8259352",
+ "90cb5b1a4670662719591aa92d0095bb41714970c0b065b02a2610172dbf0af6",
+ "92be86fcb125e9bda0da7806afd29a3fd33fdf58fba5d60318399adf40bf37d0",
+ "92c4ecf6bf11b2e85fd4d8204814dc26e6a19f0c9d938c207c5cb0eadfcabbe3",
+ "95d91d7317cde40a1c249d6b7382750b7e6d86fad9d8eaf4fa3f8f44cf171e80",
+ "961834e2f2b863a0e14260a9a273aff07ff7818ab6e66d2addf5628590c628f9",
+ "9702b59d582ff1e184945d8b501ffdd08d2cee38d93a2206aa5f1365ce0b8d78",
+ "98cede73eb83c31e2118ae8d379c12e3e42736903a8afcca92a7218e1f2903b0",
+ "99c4283e2a0e147b9c9cc6bc9c96124de9419d6044837e9799763a0e29a7321a",
+ "99e1a305c7765631d74b98bf7dbf54eeea931f975e80f115437d23848ee8c27c",
+ "a517feaf3a0a3eca1ee985d8373135cfdedfbba3882a5eab4362bda7c7cf518d",
+ "a80f7aef9535442bdcf562e5a0d5a5538ce8abe6bb209cfbf170c462ac2c2a32",
+ "ac765b026c9f33044419cbba1da913cfb82cca1b60598ac1c7a5ed6aac4621a0",
+ "acf36b8268785aad739443fa2780c16260ee3fa09d12b3a70f772ef100939d80",
+ "adec1d980fa07e60b6ef865f9e5410ba760e4e1d26f60f7e5772c73b9a5b0713",
+ "b0353b0f0850d49ada66fdd7d0c7cdb0f86b900bb9e367024fd14a60cecc1e27",
+ "b37201ce4a458c7a758ecc4efa92fa8ed783c66e0fa3c42ae19fc454a0792153",
+ "bf9a19f5012dab774628491659646335b1928cfc931bf8d97b0d5918dd58033c",
+ "c61fc91ab80b23f5fddbee342d19662f3d3328173229caded831aa0bd7595460",
+ "c68018e4fc4e14b5668f1353b41ccf4bc83ba355f0e1b3836861c6f042d89ac1",
+ "c706db3cabb7ceef779de68270150665e710b46d56372455cd741184f3868d8f",
+ "c83f6afb480eae0313114297d29d7c295670a41c11b274e6bca0c64540c1ce7b",
+ "c8a3ec16e34ef980a46f60dc6ad86ec60f763c3f2fa0db6d261e6e754f72e945",
+ "c9a8b7a34a4de3ed987f636f71881cd3b8339f61118b1aa311fbda12741bff0b",
+ "cd4b2b0707fc55afa160cd5fc33b27ccbf75ca11d81f4ec9863d5793fc6df56a",
+ "d6b9ae13d5d3e8aeca9ca94198aa7b3ebbc5acfada557d724f2a1f03d2c0b0df",
+ "d8fd7879082953c156d5b13c74aa6cca37f6a6f4747b39538504c3f9c63d043d",
+ "d9369a23186d189b2fc95cc08b8160ba242057e887d766864f7adf3c46b2df21",
+ "db4a1d897bbbe7339946ffa2fe60c10cc81c43fab8b062d3fcb84188688174a4",
+ "df4ec1f8540b0bcbe26ca7dd0f541847cc8a108b35596f9f91f59f0c060bfdd2",
+ "e132b9152749bd33534e5bd8565c7576f135f157b4029b975e15ee184325f528",
+ "e3fb1fa01d3598002777dd259c0c2e6d9d5e10e7222976fc8e03992f972a2cba",
+ "e41be6f0f19da64af13403e52f2dec38bbc2937af54df8ecef10850ff8d35301",
+ "ec98435796d2624d6905820a42f82149ee9fc4f2d45c2c5bc5a44481cc50db62",
+ "efeda443000aa23f276f4df973cb82beca682fd800bb119d19e80504ffe53ec2",
+ "f2a6a8e06bbda06f78739f40bfb56c45d14eb8249d0f0ea6d4b3d48e1f7c695d",
+ "f32ff80e7ef6a5b5b606ea69a36e97b219cd9dc799bcf2963018a4d8f788cfbf",
+ "f35ed9d945bece26553d5b4c8630453169672bea0050a564456eb88bdffd927e",
+ "f644a3ae5933a552a29dbb9aa2f90c677a875f80ebea028e5a52a4f429044b90",
+ "f863c08f4ff6b64fa8045b1e3da480f5374779ef187f07b82e0538c68cb4ff8e",
+ "fc53ba868875bfbb66ee447d64d6413c2db91fddcfca57025a0e7ab5b07d5862",
+ "ff8a991f70f4c0cf53088abf1e3886edcc87d53004c7bb94e78650b4d3dac3b5",
+ "ffea0575345e9ee0144dfe5701aa17f3ba546f8c3bb48db62ae101afb740e7d6"
+ ]
+ },
+ {
+ "name": "iniconfig",
+ "version": "2.1.0",
+ "repo": "pytest-dev/iniconfig",
+ "commit": "34793a6417e35e511d0c17ffdd8a02ade0f9568e",
+ "license": "MIT",
+ "installed_artifact_url": "https://files.pythonhosted.org/packages/2c/e1/e6716421ea10d38022b952c159d5161ca1193197fb744506875fbb87ea7b/iniconfig-2.1.0-py3-none-any.whl",
+ "installed_artifact_sha256": "9deba5723312380e77435581c6bf4935c94cbfab9b1ed33ef8d238ea168eb760",
+ "allowed_artifact_sha256": [
+ "3abbd2e30b36733fee78f9c7f7308f2d0050e88f0087fd25c2645f63c773e1c7",
+ "9deba5723312380e77435581c6bf4935c94cbfab9b1ed33ef8d238ea168eb760"
+ ]
+ },
+ {
+ "name": "packaging",
+ "version": "25.0",
+ "repo": "pypa/packaging",
+ "commit": "f58537628042c7f29780b9d33f31597e7fc9d664",
+ "license": "Apache-2.0 OR BSD-2-Clause",
+ "installed_artifact_url": "https://files.pythonhosted.org/packages/20/12/38679034af332785aac8774540895e234f4d07f7545804097de4b666afd8/packaging-25.0-py3-none-any.whl",
+ "installed_artifact_sha256": "29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484",
+ "allowed_artifact_sha256": [
+ "29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484",
+ "d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f"
+ ]
+ },
+ {
+ "name": "pluggy",
+ "version": "1.6.0",
+ "repo": "pytest-dev/pluggy",
+ "commit": "fd08ab5f811a9b2fa9124ae8cbbd393221151e2c",
+ "license": "MIT",
+ "installed_artifact_url": "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl",
+ "installed_artifact_sha256": "e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746",
+ "allowed_artifact_sha256": [
+ "7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3",
+ "e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746"
+ ]
+ },
+ {
+ "name": "Pygments",
+ "version": "2.19.2",
+ "repo": "pygments/pygments",
+ "commit": "cfca62e6e95136e48a255e8cbffb0bbe1d98456c",
+ "license": "BSD-2-Clause",
+ "installed_artifact_url": "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl",
+ "installed_artifact_sha256": "86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b",
+ "allowed_artifact_sha256": [
+ "636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887",
+ "86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b"
+ ]
+ },
+ {
+ "name": "colorama",
+ "version": "0.4.6",
+ "repo": "tartley/colorama",
+ "commit": "3de9f013df4b470069d03d250224062e8cf15c49",
+ "license": "BSD-3-Clause",
+ "installed_artifact_url": "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl",
+ "installed_artifact_sha256": "4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6",
+ "allowed_artifact_sha256": [
+ "08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44",
+ "4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"
+ ]
+ }
+ ]
+}
diff --git a/pytest.ini b/pytest.ini
new file mode 100644
index 0000000..c34e94f
--- /dev/null
+++ b/pytest.ini
@@ -0,0 +1,4 @@
+[pytest]
+testpaths = tests
+pythonpath = .
+addopts = -ra
diff --git a/requirements-dev.txt b/requirements-dev.txt
new file mode 100644
index 0000000..43d5519
--- /dev/null
+++ b/requirements-dev.txt
@@ -0,0 +1,8 @@
+-r requirements.txt
+pytest==8.4.2
+coverage==7.10.6
+iniconfig==2.1.0
+packaging==25.0
+pluggy==1.6.0
+pygments==2.19.2
+colorama==0.4.6
diff --git a/requirements-runtime.lock b/requirements-runtime.lock
new file mode 100644
index 0000000..51dff3c
--- /dev/null
+++ b/requirements-runtime.lock
@@ -0,0 +1,26 @@
+# Reviewed runtime releases; provenance: docs/dependency-audit.json
+psutil==7.2.2 \
+ --hash=sha256:0746f5f8d406af344fd547f1c8daa5f5c33dbc293bb8d6a16d80b4bb88f59372 \
+ --hash=sha256:076a2d2f923fd4821644f5ba89f059523da90dc9014e85f8e45a5774ca5bc6f9 \
+ --hash=sha256:11fe5a4f613759764e79c65cf11ebdf26e33d6dd34336f8a337aa2996d71c841 \
+ --hash=sha256:1a571f2330c966c62aeda00dd24620425d4b0cc86881c89861fbc04549e5dc63 \
+ --hash=sha256:1a7b04c10f32cc88ab39cbf606e117fd74721c831c98a27dc04578deb0c16979 \
+ --hash=sha256:1fa4ecf83bcdf6e6c8f4449aff98eefb5d0604bf88cb883d7da3d8d2d909546a \
+ --hash=sha256:2edccc433cbfa046b980b0df0171cd25bcaeb3a68fe9022db0979e7aa74a826b \
+ --hash=sha256:7b6d09433a10592ce39b13d7be5a54fbac1d1228ed29abc880fb23df7cb694c9 \
+ --hash=sha256:8c233660f575a5a89e6d4cb65d9f938126312bca76d8fe087b947b3a1aaac9ee \
+ --hash=sha256:917e891983ca3c1887b4ef36447b1e0873e70c933afc831c6b6da078ba474312 \
+ --hash=sha256:ab486563df44c17f5173621c7b198955bd6b613fb87c71c161f827d3fb149a9b \
+ --hash=sha256:ae0aefdd8796a7737eccea863f80f81e468a1e4cf14d926bd9b6f5f2d5f90ca9 \
+ --hash=sha256:b0726cecd84f9474419d67252add4ac0cd9811b04d61123054b9fb6f57df6e9e \
+ --hash=sha256:b58fabe35e80b264a4e3bb23e6b96f9e45a3df7fb7eed419ac0e5947c61e47cc \
+ --hash=sha256:c7663d4e37f13e884d13994247449e9f8f574bc4655d509c3b95e9ec9e2b9dc1 \
+ --hash=sha256:e452c464a02e7dc7822a05d25db4cde564444a67e58539a00f929c51eddda0cf \
+ --hash=sha256:e78c8603dcd9a04c7364f1a3e670cea95d51ee865e4efb3556a3a63adef958ea \
+ --hash=sha256:eb7e81434c8d223ec4a219b5fc1c47d0417b12be7ea866e24fb5ad6e84b3d988 \
+ --hash=sha256:ed0cace939114f62738d808fdcecd4c869222507e266e574799e9c0faa17d486 \
+ --hash=sha256:eed63d3b4d62449571547b60578c5b2c4bcccc5387148db46e0c2313dad0ee00 \
+ --hash=sha256:fd04ef36b4a6d599bbdb225dd1d3f51e00105f6d48a28f006da7f9822f2606d8
+pyserial==3.5 \
+ --hash=sha256:3c77e014170dfffbd816e6ffc205e9842efb10be9f58ec16d3e8675b4925cddb \
+ --hash=sha256:c4451db6ba391ca6ca299fb3ec7bae67a5c55dde170964c7a14ceefec02f2cf0
diff --git a/requirements.lock b/requirements.lock
new file mode 100644
index 0000000..ceb62ab
--- /dev/null
+++ b/requirements.lock
@@ -0,0 +1,133 @@
+# Audited release hashes; source commits and licenses: docs/dependency-audit.json
+psutil==7.2.2 \
+ --hash=sha256:0746f5f8d406af344fd547f1c8daa5f5c33dbc293bb8d6a16d80b4bb88f59372 \
+ --hash=sha256:076a2d2f923fd4821644f5ba89f059523da90dc9014e85f8e45a5774ca5bc6f9 \
+ --hash=sha256:11fe5a4f613759764e79c65cf11ebdf26e33d6dd34336f8a337aa2996d71c841 \
+ --hash=sha256:1a571f2330c966c62aeda00dd24620425d4b0cc86881c89861fbc04549e5dc63 \
+ --hash=sha256:1a7b04c10f32cc88ab39cbf606e117fd74721c831c98a27dc04578deb0c16979 \
+ --hash=sha256:1fa4ecf83bcdf6e6c8f4449aff98eefb5d0604bf88cb883d7da3d8d2d909546a \
+ --hash=sha256:2edccc433cbfa046b980b0df0171cd25bcaeb3a68fe9022db0979e7aa74a826b \
+ --hash=sha256:7b6d09433a10592ce39b13d7be5a54fbac1d1228ed29abc880fb23df7cb694c9 \
+ --hash=sha256:8c233660f575a5a89e6d4cb65d9f938126312bca76d8fe087b947b3a1aaac9ee \
+ --hash=sha256:917e891983ca3c1887b4ef36447b1e0873e70c933afc831c6b6da078ba474312 \
+ --hash=sha256:ab486563df44c17f5173621c7b198955bd6b613fb87c71c161f827d3fb149a9b \
+ --hash=sha256:ae0aefdd8796a7737eccea863f80f81e468a1e4cf14d926bd9b6f5f2d5f90ca9 \
+ --hash=sha256:b0726cecd84f9474419d67252add4ac0cd9811b04d61123054b9fb6f57df6e9e \
+ --hash=sha256:b58fabe35e80b264a4e3bb23e6b96f9e45a3df7fb7eed419ac0e5947c61e47cc \
+ --hash=sha256:c7663d4e37f13e884d13994247449e9f8f574bc4655d509c3b95e9ec9e2b9dc1 \
+ --hash=sha256:e452c464a02e7dc7822a05d25db4cde564444a67e58539a00f929c51eddda0cf \
+ --hash=sha256:e78c8603dcd9a04c7364f1a3e670cea95d51ee865e4efb3556a3a63adef958ea \
+ --hash=sha256:eb7e81434c8d223ec4a219b5fc1c47d0417b12be7ea866e24fb5ad6e84b3d988 \
+ --hash=sha256:ed0cace939114f62738d808fdcecd4c869222507e266e574799e9c0faa17d486 \
+ --hash=sha256:eed63d3b4d62449571547b60578c5b2c4bcccc5387148db46e0c2313dad0ee00 \
+ --hash=sha256:fd04ef36b4a6d599bbdb225dd1d3f51e00105f6d48a28f006da7f9822f2606d8
+pyserial==3.5 \
+ --hash=sha256:3c77e014170dfffbd816e6ffc205e9842efb10be9f58ec16d3e8675b4925cddb \
+ --hash=sha256:c4451db6ba391ca6ca299fb3ec7bae67a5c55dde170964c7a14ceefec02f2cf0
+pytest==8.4.2 \
+ --hash=sha256:86c0d0b93306b961d58d62a4db4879f27fe25513d4b969df351abdddb3c30e01 \
+ --hash=sha256:872f880de3fc3a5bdc88a11b39c9710c3497a547cfa9320bc3c5e62fbf272e79
+coverage==7.10.6 \
+ --hash=sha256:073711de3181b2e204e4870ac83a7c4853115b42e9cd4d145f2231e12d670930 \
+ --hash=sha256:081b98395ced0d9bcf60ada7661a0b75f36b78b9d7e39ea0790bb4ed8da14747 \
+ --hash=sha256:0de434f4fbbe5af4fa7989521c655c8c779afb61c53ab561b64dcee6149e4c65 \
+ --hash=sha256:0e93b1476b79eae849dc3872faeb0bf7948fd9ea34869590bc16a2a00b9c82a7 \
+ --hash=sha256:0f3f56e4cb573755e96a16501a98bf211f100463d70275759e73f3cbc00d4f27 \
+ --hash=sha256:0f7cb359a448e043c576f0da00aa8bfd796a01b06aa610ca453d4dde09cc1034 \
+ --hash=sha256:10356fdd33a7cc06e8051413140bbdc6f972137508a3572e3f59f805cd2832fd \
+ --hash=sha256:137921f2bac5559334ba66122b753db6dc5d1cf01eb7b64eb412bb0d064ef35b \
+ --hash=sha256:160c00a5e6b6bdf4e5984b0ef21fc860bc94416c41b7df4d63f536d17c38902e \
+ --hash=sha256:2195f8e16ba1a44651ca684db2ea2b2d4b5345da12f07d9c22a395202a05b23c \
+ --hash=sha256:282b1b20f45df57cc508c1e033403f02283adfb67d4c9c35a90281d81e5c52c5 \
+ --hash=sha256:28395ca3f71cd103b8c116333fa9db867f3a3e1ad6a084aa3725ae002b6583bc \
+ --hash=sha256:2904271c80898663c810a6b067920a61dd8d38341244a3605bd31ab55250dad5 \
+ --hash=sha256:2b38261034fda87be356f2c3f42221fdb4171c3ce7658066ae449241485390d5 \
+ --hash=sha256:2e4c33e6378b9d52d3454bd08847a8651f4ed23ddbb4a0520227bd346382bbc6 \
+ --hash=sha256:388d80e56191bf846c485c14ae2bc8898aa3124d9d35903fef7d907780477634 \
+ --hash=sha256:3e23dd5408fe71a356b41baa82892772a4cefcf758f2ca3383d2aa39e1b7a003 \
+ --hash=sha256:3fb99d0786fe17b228eab663d16bee2288e8724d26a199c29325aac4b0319b9b \
+ --hash=sha256:441c357d55f4936875636ef2cfb3bee36e466dcf50df9afbd398ce79dba1ebb7 \
+ --hash=sha256:4cec13817a651f8804a86e4f79d815b3b28472c910e099e4d5a0e8a3b6a1d4cb \
+ --hash=sha256:5aea98383463d6e1fa4e95416d8de66f2d0cb588774ee20ae1b28df826bcb619 \
+ --hash=sha256:5b15a87265e96307482746d86995f4bff282f14b027db75469c446da6127433b \
+ --hash=sha256:5b2dd6059938063a2c9fee1af729d4f2af28fd1a545e9b7652861f0d752ebcea \
+ --hash=sha256:5e75e37f23eb144e78940b40395b42f2321951206a4f50e23cfd6e8a198d3ceb \
+ --hash=sha256:6008a021907be8c4c02f37cdc3ffb258493bdebfeaf9a839f9e71dfdc47b018e \
+ --hash=sha256:61c950fc33d29c91b9e18540e1aed7d9f6787cc870a3e4032493bbbe641d12fc \
+ --hash=sha256:628055297f3e2aa181464c3808402887643405573eb3d9de060d81531fa79d32 \
+ --hash=sha256:675824a363cc05781b1527b39dc2587b8984965834a748177ee3c37b64ffeafb \
+ --hash=sha256:689920ecfd60f992cafca4f5477d55720466ad2c7fa29bb56ac8d44a1ac2b47a \
+ --hash=sha256:692d70ea725f471a547c305f0d0fc6a73480c62fb0da726370c088ab21aed282 \
+ --hash=sha256:6937347c5d7d069ee776b2bf4e1212f912a9f1f141a429c475e6089462fcecc5 \
+ --hash=sha256:6b3039e2ca459a70c79523d39347d83b73f2f06af5624905eba7ec34d64d80b5 \
+ --hash=sha256:6e31b8155150c57e5ac43ccd289d079eb3f825187d7c66e755a055d2c85794c6 \
+ --hash=sha256:70e7bfbd57126b5554aa482691145f798d7df77489a177a6bef80de78860a356 \
+ --hash=sha256:752a3005a1ded28f2f3a6e8787e24f28d6abe176ca64677bcd8d53d6fe2ec08a \
+ --hash=sha256:7d79dabc0a56f5af990cc6da9ad1e40766e82773c075f09cc571e2076fef882e \
+ --hash=sha256:7eb68d356ba0cc158ca535ce1381dbf2037fa8cb5b1ae5ddfc302e7317d04144 \
+ --hash=sha256:80b1695cf7c5ebe7b44bf2521221b9bb8cdf69b1f24231149a7e3eb1ae5fa2fb \
+ --hash=sha256:851430a9a361c7a8484a36126d1d0ff8d529d97385eacc8dfdc9bfc8c2d2cbe4 \
+ --hash=sha256:856986eadf41f52b214176d894a7de05331117f6035a28ac0016c0f63d887629 \
+ --hash=sha256:86b9b59f2b16e981906e9d6383eb6446d5b46c278460ae2c36487667717eccf1 \
+ --hash=sha256:8953746d371e5695405806c46d705a3cd170b9cc2b9f93953ad838f6c1e58612 \
+ --hash=sha256:8cdbe264f11afd69841bd8c0d83ca10b5b32853263ee62e6ac6a0ab63895f972 \
+ --hash=sha256:8dd5af36092430c2b075cee966719898f2ae87b636cefb85a653f1d0ba5d5393 \
+ --hash=sha256:8e0c38dc289e0508ef68ec95834cb5d2e96fdbe792eaccaa1bccac3966bbadcc \
+ --hash=sha256:90558c35af64971d65fbd935c32010f9a2f52776103a259f1dee865fe8259352 \
+ --hash=sha256:90cb5b1a4670662719591aa92d0095bb41714970c0b065b02a2610172dbf0af6 \
+ --hash=sha256:92be86fcb125e9bda0da7806afd29a3fd33fdf58fba5d60318399adf40bf37d0 \
+ --hash=sha256:92c4ecf6bf11b2e85fd4d8204814dc26e6a19f0c9d938c207c5cb0eadfcabbe3 \
+ --hash=sha256:95d91d7317cde40a1c249d6b7382750b7e6d86fad9d8eaf4fa3f8f44cf171e80 \
+ --hash=sha256:961834e2f2b863a0e14260a9a273aff07ff7818ab6e66d2addf5628590c628f9 \
+ --hash=sha256:9702b59d582ff1e184945d8b501ffdd08d2cee38d93a2206aa5f1365ce0b8d78 \
+ --hash=sha256:98cede73eb83c31e2118ae8d379c12e3e42736903a8afcca92a7218e1f2903b0 \
+ --hash=sha256:99c4283e2a0e147b9c9cc6bc9c96124de9419d6044837e9799763a0e29a7321a \
+ --hash=sha256:99e1a305c7765631d74b98bf7dbf54eeea931f975e80f115437d23848ee8c27c \
+ --hash=sha256:a517feaf3a0a3eca1ee985d8373135cfdedfbba3882a5eab4362bda7c7cf518d \
+ --hash=sha256:a80f7aef9535442bdcf562e5a0d5a5538ce8abe6bb209cfbf170c462ac2c2a32 \
+ --hash=sha256:ac765b026c9f33044419cbba1da913cfb82cca1b60598ac1c7a5ed6aac4621a0 \
+ --hash=sha256:acf36b8268785aad739443fa2780c16260ee3fa09d12b3a70f772ef100939d80 \
+ --hash=sha256:adec1d980fa07e60b6ef865f9e5410ba760e4e1d26f60f7e5772c73b9a5b0713 \
+ --hash=sha256:b0353b0f0850d49ada66fdd7d0c7cdb0f86b900bb9e367024fd14a60cecc1e27 \
+ --hash=sha256:b37201ce4a458c7a758ecc4efa92fa8ed783c66e0fa3c42ae19fc454a0792153 \
+ --hash=sha256:bf9a19f5012dab774628491659646335b1928cfc931bf8d97b0d5918dd58033c \
+ --hash=sha256:c61fc91ab80b23f5fddbee342d19662f3d3328173229caded831aa0bd7595460 \
+ --hash=sha256:c68018e4fc4e14b5668f1353b41ccf4bc83ba355f0e1b3836861c6f042d89ac1 \
+ --hash=sha256:c706db3cabb7ceef779de68270150665e710b46d56372455cd741184f3868d8f \
+ --hash=sha256:c83f6afb480eae0313114297d29d7c295670a41c11b274e6bca0c64540c1ce7b \
+ --hash=sha256:c8a3ec16e34ef980a46f60dc6ad86ec60f763c3f2fa0db6d261e6e754f72e945 \
+ --hash=sha256:c9a8b7a34a4de3ed987f636f71881cd3b8339f61118b1aa311fbda12741bff0b \
+ --hash=sha256:cd4b2b0707fc55afa160cd5fc33b27ccbf75ca11d81f4ec9863d5793fc6df56a \
+ --hash=sha256:d6b9ae13d5d3e8aeca9ca94198aa7b3ebbc5acfada557d724f2a1f03d2c0b0df \
+ --hash=sha256:d8fd7879082953c156d5b13c74aa6cca37f6a6f4747b39538504c3f9c63d043d \
+ --hash=sha256:d9369a23186d189b2fc95cc08b8160ba242057e887d766864f7adf3c46b2df21 \
+ --hash=sha256:db4a1d897bbbe7339946ffa2fe60c10cc81c43fab8b062d3fcb84188688174a4 \
+ --hash=sha256:df4ec1f8540b0bcbe26ca7dd0f541847cc8a108b35596f9f91f59f0c060bfdd2 \
+ --hash=sha256:e132b9152749bd33534e5bd8565c7576f135f157b4029b975e15ee184325f528 \
+ --hash=sha256:e3fb1fa01d3598002777dd259c0c2e6d9d5e10e7222976fc8e03992f972a2cba \
+ --hash=sha256:e41be6f0f19da64af13403e52f2dec38bbc2937af54df8ecef10850ff8d35301 \
+ --hash=sha256:ec98435796d2624d6905820a42f82149ee9fc4f2d45c2c5bc5a44481cc50db62 \
+ --hash=sha256:efeda443000aa23f276f4df973cb82beca682fd800bb119d19e80504ffe53ec2 \
+ --hash=sha256:f2a6a8e06bbda06f78739f40bfb56c45d14eb8249d0f0ea6d4b3d48e1f7c695d \
+ --hash=sha256:f32ff80e7ef6a5b5b606ea69a36e97b219cd9dc799bcf2963018a4d8f788cfbf \
+ --hash=sha256:f35ed9d945bece26553d5b4c8630453169672bea0050a564456eb88bdffd927e \
+ --hash=sha256:f644a3ae5933a552a29dbb9aa2f90c677a875f80ebea028e5a52a4f429044b90 \
+ --hash=sha256:f863c08f4ff6b64fa8045b1e3da480f5374779ef187f07b82e0538c68cb4ff8e \
+ --hash=sha256:fc53ba868875bfbb66ee447d64d6413c2db91fddcfca57025a0e7ab5b07d5862 \
+ --hash=sha256:ff8a991f70f4c0cf53088abf1e3886edcc87d53004c7bb94e78650b4d3dac3b5 \
+ --hash=sha256:ffea0575345e9ee0144dfe5701aa17f3ba546f8c3bb48db62ae101afb740e7d6
+iniconfig==2.1.0 \
+ --hash=sha256:3abbd2e30b36733fee78f9c7f7308f2d0050e88f0087fd25c2645f63c773e1c7 \
+ --hash=sha256:9deba5723312380e77435581c6bf4935c94cbfab9b1ed33ef8d238ea168eb760
+packaging==25.0 \
+ --hash=sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484 \
+ --hash=sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f
+pluggy==1.6.0 \
+ --hash=sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3 \
+ --hash=sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746
+Pygments==2.19.2 \
+ --hash=sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887 \
+ --hash=sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b
+colorama==0.4.6 \
+ --hash=sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44 \
+ --hash=sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6
diff --git a/requirements.txt b/requirements.txt
index c3f7ee6..e4e07d1 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -1,2 +1,2 @@
-psutil>=5.9
+psutil==7.2.2
pyserial==3.5
diff --git a/run_demo.ps1 b/run_demo.ps1
new file mode 100644
index 0000000..951e49c
--- /dev/null
+++ b/run_demo.ps1
@@ -0,0 +1,3 @@
+param([Parameter(ValueFromRemainingArguments=$true)][string[]]$WorkbenchArgs)
+& "$PSScriptRoot\run_web.ps1" --demo --data-dir data/demo @WorkbenchArgs
+exit $LASTEXITCODE
diff --git a/run_demo.sh b/run_demo.sh
new file mode 100644
index 0000000..3f4d744
--- /dev/null
+++ b/run_demo.sh
@@ -0,0 +1,4 @@
+#!/usr/bin/env bash
+set -euo pipefail
+cd "$(dirname "$0")"
+bash ./run_web.sh --demo --data-dir data/demo "$@"
diff --git a/run_web.ps1 b/run_web.ps1
new file mode 100644
index 0000000..397ddf4
--- /dev/null
+++ b/run_web.ps1
@@ -0,0 +1,7 @@
+param([Parameter(ValueFromRemainingArguments=$true)][string[]]$WorkbenchArgs)
+$ErrorActionPreference = 'Stop'
+Set-Location -LiteralPath $PSScriptRoot
+if (-not (Test-Path -LiteralPath '.venv\Scripts\python.exe')) { & "$PSScriptRoot\setup_windows.ps1" }
+$taskPython = Join-Path $PSScriptRoot '.venv\Scripts\python.exe'
+& $taskPython -X utf8 web_app.py @WorkbenchArgs
+exit $LASTEXITCODE
diff --git a/setup_linux.sh b/setup_linux.sh
index 0b54b3a..7a3f07e 100644
--- a/setup_linux.sh
+++ b/setup_linux.sh
@@ -13,8 +13,12 @@ if [ ! -d ".venv" ]; then
fi
source .venv/bin/activate
-python -m pip install --upgrade pip
-python -m pip install -r requirements.txt
+python -c 'import sys; sys.exit(0 if sys.version_info >= (3, 10) else 1)' || { echo '需要 Python 3.10+'; exit 1; }
+install_args=(--require-hashes -r requirements-runtime.lock --disable-pip-version-check)
+if [ -d wheels ]; then
+ install_args+=(--no-index --find-links wheels)
+fi
+python -m pip install "${install_args[@]}"
echo "环境准备完成。"
echo "启动网页系统: bash run_web.sh"
diff --git a/setup_windows.ps1 b/setup_windows.ps1
new file mode 100644
index 0000000..3f17d0a
--- /dev/null
+++ b/setup_windows.ps1
@@ -0,0 +1,22 @@
+$ErrorActionPreference = 'Stop'
+Set-Location -LiteralPath $PSScriptRoot
+
+if (-not (Test-Path -LiteralPath '.venv\Scripts\python.exe')) {
+ if (Get-Command py -ErrorAction SilentlyContinue) {
+ & py -3 -m venv .venv
+ } elseif (Get-Command python -ErrorAction SilentlyContinue) {
+ & python -m venv .venv
+ } else {
+ throw 'Python 3.10+ is required. Install CPython, then run setup_windows.ps1 again.'
+ }
+ if ($LASTEXITCODE -ne 0) { throw 'Could not create the virtual environment.' }
+}
+$taskPython = Join-Path $PSScriptRoot '.venv\Scripts\python.exe'
+& $taskPython -c 'import sys; sys.exit(0 if sys.version_info >= (3, 10) else 1)'
+if ($LASTEXITCODE -ne 0) { throw 'This workbench requires Python 3.10+.' }
+$taskInstallArgs = @('-m', 'pip', 'install', '--require-hashes', '-r', 'requirements-runtime.lock', '--disable-pip-version-check')
+if (Test-Path -LiteralPath 'wheels') { $taskInstallArgs += @('--no-index', '--find-links', 'wheels') }
+& $taskPython @taskInstallArgs
+if ($LASTEXITCODE -ne 0) { throw 'Dependency installation failed. Check Python version and the release platform.' }
+Write-Host 'Ready. Start-Demo.cmd opens the hardware-free local station.'
+Write-Host 'For reviewed live configuration: .\run_web.ps1 --config config\device_list.json --data-dir data\live'
diff --git a/tests/release_smoke.py b/tests/release_smoke.py
new file mode 100644
index 0000000..d663e78
--- /dev/null
+++ b/tests/release_smoke.py
@@ -0,0 +1,98 @@
+"""Extract a Windows release, install offline, run and restart the real demo server."""
+import argparse
+import json
+import os
+import socket
+import subprocess
+import tempfile
+import time
+import urllib.request
+import zipfile
+from pathlib import Path
+
+
+def request(base, path, body=None):
+ req = urllib.request.Request(base + path, data=json.dumps(body).encode() if body is not None else None,
+ headers={'Content-Type': 'application/json'})
+ with urllib.request.urlopen(req, timeout=3) as response:
+ return json.load(response)
+
+
+def wait_for(callback, condition, timeout=10):
+ deadline = time.monotonic() + timeout
+ while time.monotonic() < deadline:
+ try:
+ value = callback()
+ if condition(value):
+ return value
+ except (OSError, ValueError):
+ pass
+ time.sleep(.05)
+ raise AssertionError('Release workflow timed out')
+
+
+def smoke(package):
+ if os.name != 'nt':
+ raise RuntimeError('This smoke test exercises the Windows release launcher')
+ env = dict(os.environ, PYTHONIOENCODING='utf-8', PIP_NO_INDEX='1')
+ env.pop('QLDC_ACCESS_TOKEN', None)
+ with tempfile.TemporaryDirectory(prefix='release-smoke-', dir='output') as scratch:
+ scratch = Path(scratch).resolve()
+ assert scratch.is_relative_to(Path('output').resolve())
+ with zipfile.ZipFile(package) as bundle:
+ for item in bundle.infolist():
+ if not (scratch / item.filename).resolve().is_relative_to(scratch):
+ raise ValueError('Unsafe archive path')
+ bundle.extractall(scratch)
+ root = next(scratch.iterdir())
+ install = subprocess.run(['powershell.exe', '-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', str(root / 'setup_windows.ps1')],
+ cwd=root, env=env, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, timeout=60)
+ if install.returncode:
+ raise AssertionError(install.stdout.decode('utf-8', errors='replace'))
+ launcher = subprocess.run(['powershell.exe', '-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', str(root / 'run_demo.ps1'), '--help'],
+ cwd=root, env=env, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, timeout=10)
+ assert launcher.returncode == 0 and b'--demo' in launcher.stdout and b'--data-dir' in launcher.stdout
+ with socket.socket() as reserve:
+ reserve.bind(('127.0.0.1', 0))
+ port = reserve.getsockname()[1]
+ base = 'http://127.0.0.1:' + str(port)
+ process = None
+ # Direct child ownership makes the forced-restart check deterministic.
+ command = [str(root / '.venv/Scripts/python.exe'), '-X', 'utf8', str(root / 'web_app.py'), '--demo', '--port', str(port), '--data-dir', str(root / 'data/demo')]
+ try:
+ for run in range(2):
+ process = subprocess.Popen(command, cwd=root, env=env, stdout=subprocess.PIPE, stderr=subprocess.STDOUT)
+ wait_for(lambda: request(base, '/api/health'), lambda d: d['mode'] == 'demo')
+ with urllib.request.urlopen(base, timeout=3) as page:
+ assert 'FIELD ACCEPTANCE'.encode() in page.read()
+ if run == 0:
+ original = request(base, '/api/jobs', {'station_id': 'RELEASE-SMOKE-01', 'batch': 'RELEASE', 'scenario': 'faults'})['job']
+ original = wait_for(lambda: request(base, '/api/jobs/' + original['id'])['job'], lambda j: j['status'] == 'completed')
+ assert original['summary']['failed'] > 0
+ child = request(base, '/api/jobs/' + original['id'] + '/retest', {'scenario': 'healthy'})['job']
+ child = wait_for(lambda: request(base, '/api/jobs/' + child['id'])['job'], lambda j: j['status'] == 'completed')
+ assert child['summary']['verdict'] == 'PASS'
+ comparison = request(base, '/api/compare?baseline=' + original['id'] + '¤t=' + child['id'])['comparison']
+ assert comparison['comparable'] and comparison['partial_scope'] and not comparison['whole_unit_recovered']
+ report = request(base, '/api/jobs/' + child['id'] + '/export?format=json')
+ assert report['report_marker'] == 'SIMULATED' and report['config_snapshot'] == original['config_snapshot']
+ else:
+ assert request(base, '/api/jobs')['total'] == 2
+ assert request(base, '/api/jobs/' + original['id'])['job']['summary']['failed'] > 0
+ process.terminate()
+ process.wait(5)
+ process = None
+ manifest = json.loads((root / 'RELEASE-MANIFEST.json').read_text('utf-8'))
+ return {'result': 'PASS', 'source_commit': manifest['source_commit'], 'package': package.name,
+ 'offline_install': True, 'task_report_retest_restart': True}
+ finally:
+ if process is not None:
+ process.terminate()
+ process.wait(5)
+
+
+if __name__ == '__main__':
+ parser = argparse.ArgumentParser(description=__doc__)
+ parser.add_argument('package', type=Path)
+ args = parser.parse_args()
+ print(json.dumps(smoke(args.package), ensure_ascii=False))
diff --git a/tools/build_release.py b/tools/build_release.py
new file mode 100644
index 0000000..758a0f0
--- /dev/null
+++ b/tools/build_release.py
@@ -0,0 +1,67 @@
+"""Package a committed snapshot and already-audited wheels; no network access."""
+import argparse
+import hashlib
+import io
+import json
+import re
+import subprocess
+import zipfile
+from datetime import datetime, timezone
+from pathlib import Path
+
+
+def build(ref, version, wheels_root, output):
+ if not re.fullmatch(r'v\d+\.\d+\.\d+', version):
+ raise ValueError('Use a semantic release version such as v3.0.0')
+ source_sha = subprocess.check_output(['git', 'rev-parse', ref], text=True).strip()
+ source = subprocess.check_output(['git', 'archive', '--format=zip', source_sha])
+ audit = json.loads(subprocess.check_output(['git', 'show', source_sha + ':docs/dependency-audit.json']))
+ runtime = {d['name'].lower(): d for d in audit['dependencies'] if d['name'].lower() in ('psutil', 'pyserial')}
+ output.mkdir(parents=True, exist_ok=True)
+ assets = []
+ for folder, platform in [('windows', 'windows-x64'), ('linux', 'linux-x86_64')]:
+ wheels = sorted((wheels_root / folder).glob('*.whl'))
+ if len(wheels) != len(runtime):
+ raise ValueError('Expected exactly the audited runtime wheels for ' + platform)
+ manifest = {'version': version, 'source_commit': source_sha, 'platform': platform,
+ 'built_at_utc': datetime.now(timezone.utc).isoformat(), 'python_requirement': 'CPython >=3.10',
+ 'dependencies': []}
+ used = set()
+ for wheel in wheels:
+ name = wheel.name.split('-')[0].lower()
+ dep = runtime[name]
+ digest = hashlib.sha256(wheel.read_bytes()).hexdigest()
+ if digest not in dep['allowed_artifact_sha256'] or name in used:
+ raise ValueError('Wheel is not a unique audited release artifact: ' + wheel.name)
+ used.add(name)
+ manifest['dependencies'].append({'file': wheel.name, 'sha256': digest, 'version': dep['version'],
+ 'source_repository': dep['repo'], 'source_commit': dep['commit'], 'license': dep['license']})
+ target = output / ('QLDeviceCheck-' + version + '-' + platform + '.zip')
+ prefix = 'QLDeviceCheck-' + version + '/'
+ with zipfile.ZipFile(io.BytesIO(source)) as base, zipfile.ZipFile(target, 'w', zipfile.ZIP_DEFLATED) as bundle:
+ for item in base.infolist():
+ if item.is_dir():
+ continue
+ bundle.writestr(prefix + item.filename, base.read(item))
+ for wheel in wheels:
+ bundle.write(wheel, prefix + 'wheels/' + wheel.name)
+ with zipfile.ZipFile(wheel) as distribution:
+ for item in distribution.namelist():
+ if not item.endswith('/') and ('license' in item.lower() or 'copying' in item.lower()):
+ bundle.writestr(prefix + 'third-party-licenses/' + wheel.name.split('-')[0] + '/' + Path(item).name,
+ distribution.read(item))
+ bundle.writestr(prefix + 'RELEASE-MANIFEST.json', json.dumps(manifest, ensure_ascii=False, indent=2))
+ assets.append(target)
+ checksum = output / 'SHA256SUMS.txt'
+ checksum.write_text(''.join(hashlib.sha256(p.read_bytes()).hexdigest() + ' ' + p.name + '\n' for p in assets), encoding='utf-8')
+ return {'source_commit': source_sha, 'assets': [str(p.resolve()) for p in assets + [checksum]]}
+
+
+if __name__ == '__main__':
+ parser = argparse.ArgumentParser(description=__doc__)
+ parser.add_argument('--ref', default='HEAD')
+ parser.add_argument('--version', default='v3.0.0')
+ parser.add_argument('--wheels', type=Path, default=Path('output/release-wheels'))
+ parser.add_argument('--output', type=Path, default=Path('output/release'))
+ args = parser.parse_args()
+ print(json.dumps(build(args.ref, args.version, args.wheels, args.output), ensure_ascii=False))
From 5018e4b7a6d4f0138fe6ec3c31401a0eadc42935 Mon Sep 17 00:00:00 2001
From: Kaiser <104786001+KaiserIIII@users.noreply.github.com>
Date: Wed, 7 Oct 2026 13:21:32 +0800
Subject: [PATCH 06/15] fix: enforce station ownership and retain comparable
protocol evidence
---
core/generic_detector.py | 27 +-
inspection/adapters.py | 14 +-
inspection/comparison.py | 8 +
inspection/domain.py | 11 +-
inspection/http.py | 20 +-
inspection/ownership.py | 36 +++
inspection/reports.py | 8 +-
inspection/service.py | 53 +++-
inspection/store.py | 9 +-
legacy_web.py | 92 ++++---
tests/test_backend_fixes.py | 379 +++++++++++++++++++++++++++++
tests/test_http.py | 6 +-
tests/test_protocol_regressions.py | 104 +++++++-
tests/test_workbench.py | 4 +-
web/legacy.html | 19 +-
15 files changed, 719 insertions(+), 71 deletions(-)
create mode 100644 inspection/ownership.py
create mode 100644 tests/test_backend_fixes.py
diff --git a/core/generic_detector.py b/core/generic_detector.py
index 33cdade..7a935ad 100644
--- a/core/generic_detector.py
+++ b/core/generic_detector.py
@@ -122,7 +122,10 @@ def _run_configured(self) -> GenericDetectionResult:
parent_results: Dict[str, dict] = {}
for config in raw_devices:
phase = "中间模块测试" if config.get("is_intermediate_module") else "直连设备测试"
+ check_start = len(self.connection_tests)
result = self._test_configured_device(config, phase)
+ for check in self.connection_tests[check_start:]:
+ check['device_id'] = str(config.get('device_id', ''))
devices.append(result)
parent_results[str(config.get("device_id", ""))] = result
@@ -134,7 +137,11 @@ def _run_configured(self) -> GenericDetectionResult:
continue
parent_result = parent_results.get(str(parent.get("device_id", "")), {})
for child in children:
+ check_start = len(self.connection_tests)
devices.append(self._test_configured_child_device(child, parent, parent_result))
+ for check in self.connection_tests[check_start:]:
+ check['device_id'] = str(child.get('device_id', ''))
+ check['parent_device_id'] = str(parent.get('device_id', ''))
self._attach_devices_to_interface_items(serial_ports, network_interfaces, pci_devices, devices)
self._log(self._configured_child_summary(devices))
self._log("开始第四阶段:通信协议交互验证...")
@@ -1359,10 +1366,12 @@ def _test_tcp_client_configured(self, iface: str, expected_ip: str, local_port:
conn.settimeout(timeout)
incoming = b""
if self._protocol_has_exchange(protocol):
- try:
- incoming = conn.recv(65536)
- except socket.timeout:
- incoming = b""
+ probes = protocol_probes(protocol)
+ has_rules = any(response_rule(protocol, probe) for probe in probes)
+ incoming = receive_response(
+ conn, timeout, max_bytes=65536,
+ complete=(lambda data: self._match_incoming_protocol_payload(protocol, data)[0]) if has_rules else None,
+ )
payload_ok, payload_note = self._match_incoming_protocol_payload(protocol, incoming)
matched = bool(source_ok and payload_ok)
response = f"accepted {source[0]}:{source[1]}"
@@ -1379,8 +1388,14 @@ def _test_tcp_client_configured(self, iface: str, expected_ip: str, local_port:
reply_mapping["request_hex"] = first_probe.get("reply_hex")
elif first_probe.get("reply_text") not in (None, ""):
reply_mapping["request_text"] = first_probe.get("reply_text")
- if reply_mapping:
- conn.sendall(request_payload(reply_mapping))
+ if reply_mapping and matched:
+ try:
+ conn.sendall(request_payload(reply_mapping))
+ except OSError as exc:
+ matched = False
+ status_text = '异常'
+ summary = f'TCP回复异常: {exc}'
+ response += f'; reply error: {exc}'
finally:
conn.close()
except socket.timeout:
diff --git a/inspection/adapters.py b/inspection/adapters.py
index cc5565c..0045f71 100644
--- a/inspection/adapters.py
+++ b/inspection/adapters.py
@@ -14,7 +14,9 @@ def demo_config():
class DemoAdapter:
def run(self, config, device_ids, scenario, cancelled, on_result):
- for index, device in enumerate(d for d in catalog_devices(config) if d['device_id'] in device_ids):
+ for index, device in enumerate(catalog_devices(config)):
+ if device['device_id'] not in device_ids:
+ continue
if cancelled():
return
time.sleep(.03)
@@ -63,5 +65,13 @@ def run(self, config, device_ids, scenario, cancelled, on_result):
selected.data.setdefault('settings', {})['allow_write_tests'] = False
raw = GenericDetector(config=selected, serial_probe=True, subnet_probe=False).run().to_dict()
index = {r.get('device_id'): r for r in raw.get('devices', [])}
+ checks = raw.get('connection_tests', []) or []
+ def evidence(device_id):
+ result = normalize_result(devices[device_id], index.get(device_id))
+ result['attempts'] = copy.deepcopy([check for check in checks if check.get('device_id') == device_id])
+ return result
for device_id in chosen:
- on_result(normalize_result(devices[device_id], index.get(device_id)))
+ result = evidence(device_id)
+ if device_id != parent['device_id']:
+ result['supporting_checks'] = [evidence(parent['device_id'])]
+ on_result(result)
diff --git a/inspection/comparison.py b/inspection/comparison.py
index fc0ada7..4659087 100644
--- a/inspection/comparison.py
+++ b/inspection/comparison.py
@@ -9,6 +9,14 @@ def compare_jobs(baseline, current):
configured = {device['device_id'] for device in catalog_devices(current['config_snapshot'])}
partial = set(current['device_ids']) != configured
reasons = []
+ a_identity, b_identity = baseline.get('metadata', {}), current.get('metadata', {})
+ a_station, b_station = a_identity.get('station_id', ''), b_identity.get('station_id', '')
+ if not a_station.strip() or not b_station.strip():
+ reasons.append('Missing station/unit identity')
+ elif a_station != b_station:
+ reasons.append('Station/unit identity mismatch')
+ if a_identity.get('batch', '') != b_identity.get('batch', ''):
+ reasons.append('Batch mismatch')
if baseline['mode'] != current['mode']:
reasons.append('Mode mismatch')
if baseline['config_hash'] != current['config_hash']:
diff --git a/inspection/domain.py b/inspection/domain.py
index a113649..5666b90 100644
--- a/inspection/domain.py
+++ b/inspection/domain.py
@@ -20,8 +20,14 @@ def config_hash(snapshot):
def catalog_devices(snapshot):
# Configuration validation is pure computation; detector imports stay in LiveAdapter.
from core.config_manager import StandardDeviceConfig
+ if not isinstance(snapshot, dict) or not isinstance(snapshot.get('settings', {}), dict):
+ raise ValueError('Invalid configuration object/settings')
+ try:
+ configured = StandardDeviceConfig(data=snapshot).all_configured_devices()
+ except (AttributeError, TypeError):
+ raise ValueError('Invalid nested configuration shape') from None
result = []
- for device in StandardDeviceConfig(data=snapshot).all_configured_devices():
+ for device in configured:
scope = 'pci_passive' if device['connection_type'] == 'pci' else 'communication_path'
result.append({'device_id': device['device_id'], 'name': device['device_name'],
'connection_type': device['connection_type'],
@@ -77,7 +83,8 @@ def validate_payload(payload, devices, mode):
def initial_result(device, simulated):
return dict(copy.deepcopy(device), verdict='NOT_RUN', fault_code='NOT_RUN', summary='Not executed',
- suggestion='Run acceptance for this device', request='', response='', duration_ms=0, simulated=simulated)
+ suggestion='Run acceptance for this device', request='', response='', duration_ms=0, simulated=simulated,
+ attempts=[], supporting_checks=[])
def summarize(results):
diff --git a/inspection/http.py b/inspection/http.py
index da5011e..64e9f9e 100644
--- a/inspection/http.py
+++ b/inspection/http.py
@@ -86,7 +86,7 @@ def _dispatch(self, method):
if path == '/api/jobs':
if method == 'POST':
return self._json({'ok': True, 'job': svc.create(self._body())}, 202)
- return self._json(dict(ok=True, **svc.list_jobs(limit=int(query.get('limit', ['20'])[0]), offset=int(query.get('offset', ['0'])[0]), status=query.get('status', [''])[0], query=query.get('query', query.get('q', ['']))[0])))
+ return self._json(dict(ok=True, **svc.list_jobs(limit=int(query.get('limit', ['20'])[0]), offset=int(query.get('offset', ['0'])[0]), status=query.get('status', [''])[0], query=query.get('query', query.get('q', ['']))[0], sort=query.get('sort', ['newest'])[0])))
if method == 'GET' and path == '/api/insights':
return self._json(dict(ok=True, **svc.insights()))
if method == 'GET' and path == '/api/compare':
@@ -112,18 +112,20 @@ def _dispatch(self, method):
raise HttpError(403, 'Legacy hardware APIs are disabled in demo mode')
if path == '/api/standards' and method == 'GET':
from core.config_manager import StandardDeviceConfig
- return self._json({'ok': True, 'devices': StandardDeviceConfig(svc.config_path).devices() if svc.config_path else StandardDeviceConfig().devices(), 'legacy': True})
+ snapshot = svc._snapshot()
+ catalog_devices(snapshot)
+ return self._json({'ok': True, 'devices': StandardDeviceConfig(data=snapshot).devices(), 'legacy': True})
if method == 'POST':
body = self._body()
- if not svc.hardware_lock.acquire(blocking=False):
- raise RuntimeError('A station operation is already running')
- try:
+ with svc.legacy_operation():
+ from core.config_manager import StandardDeviceConfig
+ snapshot = svc._snapshot()
+ catalog_devices(snapshot)
+ ensure_read_only(snapshot)
import legacy_web
- legacy_web.RUN_LOCK = svc.hardware_lock
operations = {'/api/scan': legacy_web.scan_catalog, '/api/scan-interface': legacy_web.scan_interface, '/api/run': legacy_web.test_all_discovered, '/api/test-device': legacy_web.test_single_device}
- return self._json(dict(operations[path](body), legacy=True))
- finally:
- svc.hardware_lock.release()
+ result = dict(operations[path](body, config=StandardDeviceConfig(data=snapshot), state=svc.legacy_state), legacy=True)
+ return self._json(result)
if method == 'GET' and path.startswith('/report/'):
if svc.demo:
raise HttpError(403, 'Legacy reports are disabled in demo mode')
diff --git a/inspection/ownership.py b/inspection/ownership.py
new file mode 100644
index 0000000..dc47d18
--- /dev/null
+++ b/inspection/ownership.py
@@ -0,0 +1,36 @@
+"""OS-held database ownership; a crashed process releases its lease automatically."""
+import os
+from pathlib import Path
+
+
+class DatabaseOwner:
+ def __init__(self, db_path):
+ path = Path(db_path).resolve()
+ path.parent.mkdir(parents=True, exist_ok=True)
+ self.file = open(str(path) + '.owner', 'a+b')
+ try:
+ if os.name == 'nt':
+ import msvcrt
+ if self.file.seek(0, 2) == 0:
+ self.file.write(b'\0')
+ self.file.flush()
+ self.file.seek(0)
+ msvcrt.locking(self.file.fileno(), msvcrt.LK_NBLCK, 1)
+ else:
+ import fcntl
+ fcntl.flock(self.file.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
+ except OSError:
+ self.file.close()
+ raise RuntimeError('Database is already owned by another service') from None
+
+ def close(self):
+ if self.file.closed:
+ return
+ if os.name == 'nt':
+ import msvcrt
+ self.file.seek(0)
+ msvcrt.locking(self.file.fileno(), msvcrt.LK_UNLCK, 1)
+ else:
+ import fcntl
+ fcntl.flock(self.file.fileno(), fcntl.LOCK_UN)
+ self.file.close()
diff --git a/inspection/reports.py b/inspection/reports.py
index b076ebe..6097b42 100644
--- a/inspection/reports.py
+++ b/inspection/reports.py
@@ -32,7 +32,9 @@ def code(value):
result['interface'], scope_name, labels[verdict] + ' / ' + verdict, result['fault_code'], result['duration_ms']]) + '')
evidence.append('' + esc(result['name']) + ' · ' + esc(result['device_id']) + ' ' +
esc(result['summary']) + '
排查建议:' + esc(result['suggestion']) +
- '
请求 / Request ' + code(result['request']) + '响应 / Response ' + code(result['response']) + ' ')
+ '请求 / Request ' + code(result['request']) + '响应 / Response ' + code(result['response']) +
+ '逐项探测 / Attempts ' + code(json.dumps(result.get('attempts', []), ensure_ascii=False, indent=2)) +
+ '载体支持证据 / Supporting checks ' + code(json.dumps(result.get('supporting_checks', []), ensure_ascii=False, indent=2)) + '')
summary = job['summary']
totals = '通过 {passed} · 未通过 {failed} · 需复核 {review} · 未执行 {not_run}'.format(**summary)
return '''
@@ -51,13 +53,15 @@ def export_job(job, format):
if format == 'csv':
stream = io.StringIO(newline='')
writer = csv.writer(stream)
- columns = ['report_marker', 'job_id', 'status', 'mode', 'scenario', 'station_id', 'batch', 'operator', 'notes', 'config_hash', 'config_snapshot', 'created_at', 'finished_at', 'device_id', 'name', 'connection_type', 'interface', 'protocol', 'scope', 'verdict', 'fault_code', 'summary', 'suggestion', 'request', 'response', 'duration_ms', 'simulated']
+ columns = ['report_marker', 'job_id', 'status', 'mode', 'scenario', 'station_id', 'batch', 'operator', 'notes', 'config_hash', 'config_snapshot', 'created_at', 'finished_at', 'device_id', 'name', 'connection_type', 'interface', 'protocol', 'scope', 'verdict', 'fault_code', 'summary', 'suggestion', 'request', 'response', 'duration_ms', 'simulated', 'attempts', 'supporting_checks']
writer.writerow(columns)
def safe(value):
text = str(value if value is not None else '')
return "'" + text if text.lstrip().startswith(('=', '+', '-', '@')) or text.startswith(('\t', '\r', '\n')) else text
for result in job['results']:
row = dict(job, **job['metadata'], **result, report_marker=marker, job_id=job['id'], config_snapshot=json.dumps(job['config_snapshot'], ensure_ascii=False))
+ for key in ('attempts', 'supporting_checks'):
+ row[key] = json.dumps(result.get(key, []), ensure_ascii=False)
writer.writerow([safe(row.get(key, '')) for key in columns])
return stream.getvalue().encode('utf-8-sig'), 'text/csv; charset=utf-8'
raise ValueError('Unsupported export format')
diff --git a/inspection/service.py b/inspection/service.py
index bb48df1..81116a5 100644
--- a/inspection/service.py
+++ b/inspection/service.py
@@ -2,10 +2,12 @@
import threading
import time
import uuid
+from contextlib import contextmanager
from .adapters import DemoAdapter, LiveAdapter, demo_config
from .domain import SCENARIOS, TERMINAL, catalog_devices, config_hash, ensure_read_only, initial_result, refresh, utc_now, validate_payload
from .store import SQLiteStore
+from .ownership import DatabaseOwner
class InspectionService:
@@ -16,24 +18,51 @@ def __init__(self, db_path, demo=False, config_path=None, adapter=None):
self.adapter = adapter if adapter is not None else DemoAdapter() if demo else LiveAdapter()
self.hardware_lock = threading.Lock()
self.lock = threading.RLock()
- self.store = SQLiteStore(db_path)
+ self.owner = DatabaseOwner(db_path)
+ try:
+ self.store = SQLiteStore(db_path)
+ except Exception:
+ self.owner.close()
+ raise
+ self.legacy_state = {'devices': {}, 'updated_at': '', 'config_hash': None}
self.closed = False
self.worker = None
self.active_id = None
self.active_started = None
self.cancel_event = threading.Event()
- for job in self.store.all():
- if job['status'] not in TERMINAL:
- job['status'] = 'interrupted'
- job['finished_at'] = utc_now()
- job['events'].append({'at': utc_now(), 'type': 'interrupted', 'message': 'Service restarted; no automatic hardware retry'})
- self.store.update(refresh(job))
+ try:
+ for job in self.store.all():
+ if job['status'] not in TERMINAL:
+ job['status'] = 'interrupted'
+ job['finished_at'] = utc_now()
+ job['events'].append({'at': utc_now(), 'type': 'interrupted', 'message': 'Service restarted; no automatic hardware retry'})
+ self.store.update(refresh(job))
+ except Exception:
+ self.store.close()
+ self.owner.close()
+ raise
def _snapshot(self):
if self.demo:
return demo_config()
from core.config_manager import StandardDeviceConfig
- return StandardDeviceConfig(self.config_path).data if self.config_path else StandardDeviceConfig().data
+ try:
+ return StandardDeviceConfig(self.config_path).data if self.config_path else StandardDeviceConfig().data
+ except (AttributeError, TypeError):
+ raise ValueError('Invalid nested configuration shape') from None
+
+ @contextmanager
+ def legacy_operation(self):
+ with self.lock:
+ if self.closed or not self.hardware_lock.acquire(blocking=False):
+ raise RuntimeError('A station operation is already running or service is closed')
+ try:
+ yield
+ finally:
+ with self.lock:
+ self.hardware_lock.release()
+ if self.closed:
+ self.owner.close()
def catalog(self):
snapshot = self._snapshot()
@@ -111,6 +140,8 @@ def on_result(result):
self.active_id = None
self.active_started = None
self.hardware_lock.release()
+ if self.closed:
+ self.owner.close()
def _finish(self, job, status, started):
job.update(status=status, finished_at=utc_now(), duration_ms=round((time.monotonic() - started) * 1000, 2))
@@ -120,8 +151,8 @@ def _finish(self, job, status, started):
def get(self, job_id):
return self.store.get(job_id)
- def list_jobs(self, limit=20, offset=0, status='', query=''):
- return self.store.list(limit, offset, status, query)
+ def list_jobs(self, limit=20, offset=0, status='', query='', sort='newest'):
+ return self.store.list(limit, offset, status, query, sort)
def cancel(self, job_id):
with self.lock:
@@ -189,3 +220,5 @@ def close(self):
self._finish(job, 'interrupted', self.active_started or time.monotonic())
self.closed = True
self.store.close()
+ if not self.hardware_lock.locked():
+ self.owner.close()
diff --git a/inspection/store.py b/inspection/store.py
index fa39cdc..47be18a 100644
--- a/inspection/store.py
+++ b/inspection/store.py
@@ -33,18 +33,21 @@ def update(self, job):
if not cursor.rowcount:
raise KeyError('Job not found')
- def list(self, limit=20, offset=0, status='', query=''):
+ def list(self, limit=20, offset=0, status='', query='', sort='newest'):
if isinstance(limit, bool) or not isinstance(limit, int) or not 1 <= limit <= 100 or not isinstance(offset, int) or not 0 <= offset <= 1000000:
raise ValueError('Invalid pagination')
if status and status not in STATUSES:
raise ValueError('Invalid status')
if not isinstance(query, str) or len(query) > 128:
raise ValueError('Invalid query')
+ if sort not in ('newest', 'oldest'):
+ raise ValueError('Invalid sort')
+ direction = 'DESC' if sort == 'newest' else 'ASC'
with self.lock:
- rows = self.connection.execute('SELECT record FROM jobs ORDER BY created_at DESC, rowid DESC').fetchall()
+ rows = self.connection.execute('SELECT record FROM jobs ORDER BY created_at ' + direction + ', rowid ' + direction).fetchall()
jobs = [json.loads(row[0]) for row in rows]
jobs = [job for job in jobs if (not status or job['status'] == status) and (not query or query.casefold() in json.dumps(job['metadata'], ensure_ascii=False).casefold() or query.casefold() in job['id'].casefold())]
- return {'jobs': jobs[offset:offset + limit], 'total': len(jobs), 'limit': limit, 'offset': offset}
+ return {'jobs': jobs[offset:offset + limit], 'total': len(jobs), 'limit': limit, 'offset': offset, 'sort': sort}
def all(self):
with self.lock:
diff --git a/legacy_web.py b/legacy_web.py
index a78ffb1..b60fd7a 100644
--- a/legacy_web.py
+++ b/legacy_web.py
@@ -25,6 +25,7 @@
from core.config_manager import StandardDeviceConfig
from core.generic_detector import DEFAULT_TCP_PORTS, GenericDetector
+from inspection.domain import config_hash
REPORT_DIR = os.path.join(BASE_DIR, "report")
WEB_INDEX_PATH = os.path.join(BASE_DIR, "web", "index.html")
@@ -66,13 +67,22 @@ def connected_devices(data: dict):
return [item for item in data.get("devices", []) or [] if item.get("status") in CONNECTED_STATUSES]
-def cache_scan_devices(devices):
- SCAN_STATE["devices"] = {
+def scan_state(config, state=None):
+ state = SCAN_STATE if state is None else state
+ fingerprint = config_hash(config.data)
+ if state.get('config_hash') != fingerprint:
+ state.update(devices={}, updated_at='', config_hash=fingerprint)
+ return state
+
+
+def cache_scan_devices(devices, state=None):
+ state = SCAN_STATE if state is None else state
+ state["devices"] = {
str(item.get("device_id")): copy.deepcopy(item)
for item in devices
if item.get("device_id")
}
- SCAN_STATE["updated_at"] = time.strftime("%Y-%m-%d %H:%M:%S")
+ state["updated_at"] = time.strftime("%Y-%m-%d %H:%M:%S")
def same_interface(connection_type: str, left: str, right: str) -> bool:
@@ -83,10 +93,11 @@ def same_interface(connection_type: str, left: str, right: str) -> bool:
return left == right
-def cache_interface_devices(connection_type: str, interface: str, devices):
+def cache_interface_devices(connection_type: str, interface: str, devices, state=None):
+ state = SCAN_STATE if state is None else state
retained = {
device_id: item
- for device_id, item in SCAN_STATE.get("devices", {}).items()
+ for device_id, item in state.get("devices", {}).items()
if not (
str(item.get("connection_type") or "") == connection_type
and same_interface(connection_type, item.get("interface", ""), interface)
@@ -96,17 +107,28 @@ def cache_interface_devices(connection_type: str, interface: str, devices):
device_id = str(item.get("device_id") or "")
if device_id:
retained[device_id] = copy.deepcopy(item)
- SCAN_STATE["devices"] = retained
- SCAN_STATE["updated_at"] = time.strftime("%Y-%m-%d %H:%M:%S")
+ state["devices"] = retained
+ state["updated_at"] = time.strftime("%Y-%m-%d %H:%M:%S")
-def selected_catalog(device_ids):
+def selected_catalog(device_ids, config=None, state=None):
+ config = config if config is not None else StandardDeviceConfig()
+ state = scan_state(config, state)
interfaces = {
device_id: str(item.get("interface") or "")
- for device_id, item in SCAN_STATE.get("devices", {}).items()
+ for device_id, item in state.get("devices", {}).items()
if item.get("interface")
}
- return StandardDeviceConfig().selected(device_ids, interfaces)
+ # A child using its parent's carrier must stay inherited. Pinning only the
+ # child's interface would turn it into an independent, incomplete carrier.
+ carrier_fields = ('interface', 'serial_config', 'network_config', 'pci_slot', 'pci_match', 'provided_interfaces')
+ for parent in config.raw_devices():
+ for child in parent.get('child_devices', []) or []:
+ if not any(child.get(key) for key in carrier_fields):
+ discovered = interfaces.pop(str(child.get('device_id')), '')
+ if discovered:
+ interfaces.setdefault(str(parent.get('device_id')), discovered)
+ return config.selected(device_ids, interfaces)
def update_interface_counts(data: dict):
@@ -122,9 +144,9 @@ def update_interface_counts(data: dict):
})
-def prepare_ui_result(data: dict, mode: str, connected_only: bool = False) -> dict:
+def prepare_ui_result(data: dict, mode: str, connected_only: bool = False, config=None) -> dict:
result = copy.deepcopy(data)
- catalog_count = len(StandardDeviceConfig().all_configured_devices())
+ catalog_count = len((config if config is not None else StandardDeviceConfig()).all_configured_devices())
devices = result.get("devices", []) or []
if connected_only:
devices = [item for item in devices if item.get("status") in CONNECTED_STATUSES]
@@ -223,14 +245,18 @@ def preserve_scan_interfaces(result: dict, scan_raw: dict):
return result
-def scan_catalog(payload: dict) -> dict:
- raw = detector_from_payload(payload).run().to_dict()
+def scan_catalog(payload: dict, config=None, state=None) -> dict:
+ config = config if config is not None else StandardDeviceConfig()
+ state = scan_state(config, state)
+ raw = detector_from_payload(payload, config=config).run().to_dict()
discovered = connected_devices(raw)
- cache_scan_devices(discovered)
- return prepare_ui_result(raw, "scan", connected_only=True)
+ cache_scan_devices(discovered, state)
+ return prepare_ui_result(raw, "scan", connected_only=True, config=config)
-def scan_interface(payload: dict) -> dict:
+def scan_interface(payload: dict, config=None, state=None) -> dict:
+ catalog = config if config is not None else StandardDeviceConfig()
+ state = scan_state(catalog, state)
connection_type = str(payload.get("interface_type") or "").strip().lower()
interface = str(payload.get("interface") or "").strip()
if connection_type not in ("serial", "network", "pci"):
@@ -238,7 +264,7 @@ def scan_interface(payload: dict) -> dict:
if not interface:
raise ValueError("缺少接口名称")
- config = StandardDeviceConfig().for_interface(connection_type, interface)
+ config = catalog.for_interface(connection_type, interface)
if config is None:
now = time.strftime("%Y-%m-%d %H:%M:%S")
raw = {
@@ -278,40 +304,44 @@ def scan_interface(payload: dict) -> dict:
item for item in items
if key == target_key and same_interface(connection_type, item.get("name", ""), interface)
]
- cache_interface_devices(connection_type, interface, devices)
- result = prepare_ui_result(raw, "scan", connected_only=True)
+ cache_interface_devices(connection_type, interface, devices, state)
+ result = prepare_ui_result(raw, "scan", connected_only=True, config=catalog)
result["interface_type"] = connection_type
result["interface_name"] = interface
return result
-def test_all_discovered(payload: dict) -> dict:
- scan_raw = detector_from_payload(payload).run().to_dict()
+def test_all_discovered(payload: dict, config=None, state=None) -> dict:
+ catalog = config if config is not None else StandardDeviceConfig()
+ state = scan_state(catalog, state)
+ scan_raw = detector_from_payload(payload, config=catalog).run().to_dict()
discovered = connected_devices(scan_raw)
- cache_scan_devices(discovered)
+ cache_scan_devices(discovered, state)
device_ids = [str(item.get("device_id")) for item in discovered if item.get("device_id")]
if not device_ids:
- result = prepare_ui_result(scan_raw, "test_all", connected_only=True)
+ result = prepare_ui_result(scan_raw, "test_all", connected_only=True, config=catalog)
else:
- config = selected_catalog(device_ids)
+ config = selected_catalog(device_ids, catalog, state)
tested = detector_from_payload(payload, config=config).run().to_dict()
- result = prepare_ui_result(tested, "test_all")
+ result = prepare_ui_result(tested, "test_all", config=catalog)
preserve_scan_interfaces(result, scan_raw)
result["summary"]["scan_connected_count"] = len(discovered)
- keep_limit = StandardDeviceConfig().settings().get("max_report_count", REPORT_KEEP_LIMIT)
+ keep_limit = catalog.settings().get("max_report_count", REPORT_KEEP_LIMIT)
save_report(result, keep_limit)
return result
-def test_single_device(payload: dict) -> dict:
+def test_single_device(payload: dict, config=None, state=None) -> dict:
+ catalog = config if config is not None else StandardDeviceConfig()
+ state = scan_state(catalog, state)
device_id = str(payload.get("device_id") or "").strip()
if not device_id:
raise ValueError("缺少device_id")
- if device_id not in SCAN_STATE.get("devices", {}):
+ if device_id not in state.get("devices", {}):
raise ValueError("设备不在当前扫描结果中,请先扫描设备")
- config = selected_catalog([device_id])
+ config = selected_catalog([device_id], catalog, state)
tested = detector_from_payload(payload, config=config).run().to_dict()
- result = prepare_ui_result(tested, "single")
+ result = prepare_ui_result(tested, "single", config=catalog)
target = next((item for item in result.get("devices", []) if str(item.get("device_id")) == device_id), None)
if target is None:
raise ValueError("未生成该设备的测试结果")
diff --git a/tests/test_backend_fixes.py b/tests/test_backend_fixes.py
new file mode 100644
index 0000000..072f176
--- /dev/null
+++ b/tests/test_backend_fixes.py
@@ -0,0 +1,379 @@
+"""Review regressions: synthetic configuration, Fake adapters, loopback only."""
+import copy
+import json
+import re
+import shutil
+import subprocess
+import sys
+import threading
+
+import pytest
+
+from inspection.adapters import DemoAdapter, LiveAdapter, demo_config
+from inspection.comparison import compare_jobs
+from inspection.http import make_server
+from inspection.reports import export_job
+from inspection.service import InspectionService
+from test_http import api, requester
+from test_workbench import BlockingAdapter, wait_finished
+
+
+def test_second_process_cannot_recover_or_probe_owned_database(tmp_path):
+ adapter = BlockingAdapter()
+ path = tmp_path / 'jobs.db'
+ svc = InspectionService(path, demo=True, adapter=adapter)
+ try:
+ job = svc.create({})
+ assert adapter.started.wait(1)
+ code = """
+import sys
+from inspection.service import InspectionService
+class Forbidden:
+ def run(self, *args):
+ raise AssertionError('second adapter called')
+try:
+ svc = InspectionService(sys.argv[1], demo=sys.argv[2] == 'demo', adapter=Forbidden())
+except RuntimeError:
+ sys.exit(23)
+svc.close()
+"""
+ for mode in ('demo', 'live'):
+ result = subprocess.run([sys.executable, '-c', code, str(path), mode], timeout=5)
+ assert result.returncode == 23
+ assert svc.get(job['id'])['status'] == 'running'
+ server = make_server('127.0.0.1', 0, svc)
+ try:
+ result = subprocess.run([sys.executable, 'web_app.py', '--demo', '--data-dir', str(tmp_path), '--port', str(server.server_port)], capture_output=True, timeout=5)
+ assert result.returncode == 1
+ assert svc.get(job['id'])['status'] == 'running'
+ finally:
+ server.server_close()
+ finally:
+ svc.close()
+
+
+def test_crashed_owner_releases_lease_and_recovers(tmp_path):
+ path = tmp_path / 'jobs.db'
+ code = """
+import os, sys, threading
+from inspection.service import InspectionService
+class Blocker:
+ def run(self, *args):
+ threading.Event().wait(10)
+svc=InspectionService(sys.argv[1], demo=True, adapter=Blocker())
+job=svc.create({})
+print(job['id'], flush=True)
+os._exit(0)
+"""
+ result = subprocess.run([sys.executable, '-c', code, str(path)], capture_output=True, text=True, timeout=5, check=True)
+ svc = InspectionService(path, demo=True)
+ try:
+ assert svc.get(result.stdout.strip())['status'] == 'interrupted'
+ finally:
+ svc.close()
+
+
+def test_stubborn_worker_keeps_database_owned_after_bounded_close(tmp_path):
+ class Stubborn(BlockingAdapter):
+ def run(self, *args):
+ self.started.set()
+ self.release.wait(8)
+ adapter = Stubborn()
+ path = tmp_path / 'jobs.db'
+ svc = InspectionService(path, demo=True, adapter=adapter)
+ svc.create({})
+ assert adapter.started.wait(1)
+ svc.close()
+ try:
+ with pytest.raises(RuntimeError):
+ InspectionService(path, demo=True)
+ finally:
+ adapter.release.set()
+ svc.worker.join(2)
+ other = InspectionService(path, demo=True)
+ other.close()
+
+
+@pytest.mark.parametrize('field,value,reason', [('station_id', 'UNIT-B', 'Station'), ('station_id', '', 'identity'), ('station_id', ' ', 'identity'), ('batch', 'LOT-B', 'Batch')])
+def test_recovery_requires_same_unambiguous_identity(tmp_path, field, value, reason):
+ svc = InspectionService(tmp_path / 'jobs.db', demo=True)
+ try:
+ baseline = wait_finished(svc, svc.create({'scenario': 'faults', 'station_id': 'UNIT-A', 'batch': 'LOT-A'})['id'])
+ payload = {'station_id': 'UNIT-A', 'batch': 'LOT-A', field: value}
+ current = wait_finished(svc, svc.create(payload)['id'])
+ diff = compare_jobs(baseline, current)
+ assert not diff['comparable'] and not diff['whole_unit_recovered']
+ assert any(reason.lower() in r.lower() for r in diff['reasons'])
+ assert diff['recovered'] == ['DEMO_PLC']
+ finally:
+ svc.close()
+
+
+def test_same_identity_full_rerun_and_empty_batch_can_recover(tmp_path):
+ svc = InspectionService(tmp_path / 'jobs.db', demo=True)
+ try:
+ baseline = wait_finished(svc, svc.create({'scenario': 'faults', 'station_id': 'UNIT-A'})['id'])
+ current = wait_finished(svc, svc.create({'station_id': 'UNIT-A'})['id'])
+ assert compare_jobs(baseline, current)['whole_unit_recovered']
+ missing = copy.deepcopy(current)
+ missing['metadata']['station_id'] = ''
+ baseline['metadata']['station_id'] = ''
+ assert not compare_jobs(baseline, missing)['comparable']
+ finally:
+ svc.close()
+
+
+@pytest.mark.parametrize('scenario', ['healthy', 'faults', 'timeout', 'crc', 'missing'])
+def test_demo_scenario_outcomes_do_not_change_with_selected_subset(scenario):
+ full = []
+ adapter = DemoAdapter()
+ config = demo_config()
+ adapter.run(config, ['DEMO_RELAY', 'DEMO_PLC', 'DEMO_PCI'], scenario, lambda: False, full.append)
+ for expected in full:
+ subset = []
+ adapter.run(config, [expected['device_id']], scenario, lambda: False, subset.append)
+ assert subset[0] == expected
+
+
+def test_same_scenario_retest_remains_failed(tmp_path):
+ svc = InspectionService(tmp_path / 'jobs.db', demo=True)
+ try:
+ first = wait_finished(svc, svc.create({'scenario': 'faults', 'station_id': 'UNIT-A'})['id'])
+ second = wait_finished(svc, svc.retest(first['id'], {})['id'])
+ assert second['results'][0]['verdict'] == 'FAIL'
+ third = wait_finished(svc, svc.retest(second['id'], {'scenario': 'healthy'})['id'])
+ assert third['results'][0]['verdict'] == 'PASS'
+ finally:
+ svc.close()
+
+
+@pytest.mark.parametrize('statuses', [('有响应', '有响应'), ('异常', '有响应'), ('有响应', '异常')])
+def test_all_probe_and_parent_evidence_survives_storage_and_exports(tmp_path, monkeypatch, statuses):
+ import core.generic_detector
+ config = demo_config()
+ parent = config['devices'][1]
+ child = copy.deepcopy(config['devices'][0])
+ child['device_id'] = 'CHILD'
+ parent['child_devices'] = [child]
+ probes = [{'device_id': 'CHILD', 'status': status, 'request': 'request%d ', request('GET', '/legacy')[1].decode()).group(1)
+ node = shutil.which('node')
+ if not node:
+ pytest.skip('Node required to execute legacy page JavaScript')
+ code = """
+const vm = require('node:vm');
+const script = JSON.parse(process.argv[1]);
+const base = process.argv[2];
+const values = new Map([['qldc.token', 'session-secret']]);
+const element = {addEventListener(){}, querySelectorAll(){return []}};
+const document = {getElementById(){return element}, addEventListener(){}};
+const context = {document, sessionStorage:{getItem:k=>values.get(k),setItem:(k,v)=>values.set(k,v)}, fetch:(url,opts)=>{if(url.includes('secret'))throw Error('credential in URL');return fetch(base+url,opts)}};
+vm.createContext(context);
+vm.runInContext(script, context);
+(async()=>{
+ for(const url of ['/api/scan','/api/test-device'])await vm.runInContext(`postJson('${url}', {device_id:'FAKE'})`,context);
+ values.set('qldc.token','wrong');
+ let rejected=false;
+ try{await vm.runInContext("postJson('/api/scan')",context)}catch{rejected=true}
+ if(!rejected)throw Error('wrong token accepted');
+})().catch(e=>{console.error(e.message);process.exitCode=1});
+"""
+ result = subprocess.run([node, '-e', code, json.dumps(script), 'http://127.0.0.1:%d' % server.server_port], capture_output=True, text=True, timeout=8)
+ assert result.returncode == 0, result.stderr
+
+
+def test_history_sort_has_stable_ties_and_pagination_over_http(api):
+ svc, server, request = api
+ jobs = [wait_finished(svc, svc.create({'batch': 'sort-fixture'})['id']) for _ in range(3)]
+ # Equal timestamps exercise rowid tie ordering after persisted reopen.
+ for job in jobs:
+ svc.store.connection.execute('UPDATE jobs SET created_at=? WHERE id=?', ('2026-10-07T00:00:00.000Z', job['id']))
+ svc.store.connection.commit()
+ from inspection.store import SQLiteStore
+ db_path = svc.store.connection.execute('PRAGMA database_list').fetchone()[2]
+ svc.store.close()
+ svc.store = SQLiteStore(db_path)
+ ids = [job['id'] for job in jobs]
+ for sort, expected in [('oldest', ids), ('newest', list(reversed(ids)))]:
+ got = [request('GET', '/api/jobs?sort=%s&limit=1&offset=%d&q=sort-fixture' % (sort, offset))[1]['jobs'][0]['id'] for offset in range(3)]
+ assert got == expected
+ assert svc.list_jobs(sort=sort)['jobs'][0]['id'] == expected[0]
+ assert request('GET', '/api/jobs')[1]['jobs'][0]['id'] == ids[-1]
+ assert request('GET', '/api/jobs?sort=arbitrary')[0] == 400
+ with pytest.raises(ValueError):
+ svc.list_jobs(sort='arbitrary')
+
+
+def test_core_tags_every_probe_at_exact_parent_child_boundaries(monkeypatch):
+ from core.config_manager import StandardDeviceConfig
+ from core.generic_detector import GenericDetector
+ config = demo_config()
+ config['devices'] = [config['devices'][1]]
+ config['devices'][0]['child_devices'] = [{'device_id': 'CHILD', 'device_name': 'child', 'device_type': 'plc', 'connection_type': 'network', 'protocol': {'type': 'modbus_tcp'}}]
+ detector = GenericDetector(config=StandardDeviceConfig(data=config))
+ monkeypatch.setattr(detector, '_configured_interface_items', lambda raw: ([], [], []))
+ def device(raw, phase):
+ detector.connection_tests.extend([{'request': 'first', 'status': '异常'}, {'request': 'second', 'status': '有响应'}])
+ return {'device_id': raw['device_id'], 'status': '正常'}
+ def child(raw, parent, result):
+ detector.connection_tests.append({'request': 'child', 'status': '有响应'})
+ return {'device_id': raw['device_id'], 'status': '正常'}
+ monkeypatch.setattr(detector, '_test_configured_device', device)
+ monkeypatch.setattr(detector, '_test_configured_child_device', child)
+ raw = detector.run().to_dict()
+ assert [probe['device_id'] for probe in raw['connection_tests']] == ['DEMO_PLC', 'DEMO_PLC', 'CHILD']
+ assert raw['connection_tests'][2]['parent_device_id'] == 'DEMO_PLC'
+
+
+def test_inflight_legacy_operation_keeps_lease_until_it_returns(tmp_path):
+ path = tmp_path / 'jobs.db'
+ svc = InspectionService(path, demo=True)
+ started, release = threading.Event(), threading.Event()
+ def operation():
+ with svc.legacy_operation():
+ started.set()
+ release.wait(3)
+ thread = threading.Thread(target=operation)
+ thread.start()
+ assert started.wait(1)
+ svc.close()
+ try:
+ with pytest.raises(RuntimeError):
+ InspectionService(path, demo=True)
+ finally:
+ release.set()
+ thread.join(1)
+ svc = InspectionService(path, demo=True)
+ svc.close()
+
+
+def test_legacy_operation_releases_lock_before_response_is_sent(api, monkeypatch):
+ import legacy_web
+ from inspection.http import Handler
+ svc, server, request = api
+ svc.demo = False
+ monkeypatch.setattr(svc, '_snapshot', demo_config)
+ monkeypatch.setattr(legacy_web, 'scan_catalog', lambda *args, **kwargs: {'ok': True, 'operation_done': True})
+ original_json = Handler._json
+ def checked_json(handler, data, status=200):
+ if data.get('operation_done'):
+ assert not svc.hardware_lock.locked(), 'response sent while operation lock held'
+ return original_json(handler, data, status)
+ monkeypatch.setattr(Handler, '_json', checked_json)
+ status, body = request('POST', '/api/scan', {})
+ assert status == 200 and body['operation_done']
diff --git a/tests/test_http.py b/tests/test_http.py
index 6a1da7c..52b9db6 100644
--- a/tests/test_http.py
+++ b/tests/test_http.py
@@ -123,7 +123,9 @@ def fail():
def test_legacy_live_calls_share_acceptance_lock_and_are_explicit(api, monkeypatch):
import legacy_web
svc, server, request = api
- monkeypatch.setattr(legacy_web, 'scan_catalog', lambda payload: {'ok': True, 'devices': [], 'mode': 'scan'})
+ monkeypatch.setattr(legacy_web, 'scan_catalog', lambda payload, **kwargs: {'ok': True, 'devices': [], 'mode': 'scan'})
+ from inspection.adapters import demo_config
+ monkeypatch.setattr(svc, '_snapshot', demo_config)
svc.demo = False
svc.mode = 'live'
svc.hardware_lock.acquire()
@@ -133,4 +135,4 @@ def test_legacy_live_calls_share_acceptance_lock_and_are_explicit(api, monkeypat
svc.hardware_lock.release()
status, body = request('POST', '/api/scan', {})
assert status == 200 and body['legacy']
- assert legacy_web.RUN_LOCK is svc.hardware_lock
+ assert not svc.hardware_lock.locked()
diff --git a/tests/test_protocol_regressions.py b/tests/test_protocol_regressions.py
index f62f585..93c6a12 100644
--- a/tests/test_protocol_regressions.py
+++ b/tests/test_protocol_regressions.py
@@ -114,7 +114,9 @@ def serve():
server.close()
thread = threading.Thread(target=serve, daemon=True)
thread.start()
- detector = GenericDetector()
+ from core.config_manager import StandardDeviceConfig
+ from inspection.adapters import demo_config
+ detector = GenericDetector(config=StandardDeviceConfig(data=demo_config()))
protocol = {'type': 'modbus_tcp', 'test_registers': [{'address': 0, 'count': 1}]} if modbus else {'type': 'fixture_tcp', 'probes': [{'request_text': 'PING', 'response_match': {'exact_text': 'PONG DEVICE-01'}}]}
parent = {'network_config': {'transport': 'tcp', 'device_role': 'server'}}
profile = detector._network_profile({'protocol': protocol}, parent)
@@ -124,3 +126,103 @@ def serve():
assert raw.hex(' ').upper() in result['response']
finally:
thread.join(2)
+
+
+def incoming_detector():
+ from core.config_manager import StandardDeviceConfig
+ from core.generic_detector import GenericDetector
+ from inspection.adapters import demo_config
+ detector = GenericDetector(config=StandardDeviceConfig(data=demo_config()))
+ detector._local_ipv4_for_iface = lambda iface: '127.0.0.1'
+ return detector
+
+
+def test_incoming_tcp_split_loopback_frame_and_matching_reply():
+ detector = incoming_detector()
+ port_socket = socket.socket()
+ port_socket.bind(('127.0.0.1', 0))
+ port = port_socket.getsockname()[1]
+ port_socket.close()
+ protocol = {'type': 'fixture_tcp', 'response_match': {'exact_text': 'PONG'}, 'reply_text': 'ACK'}
+ profile = detector._network_profile({'protocol': protocol}, {'network_config': {'transport': 'tcp', 'device_role': 'client'}})
+ replies = []
+ errors = []
+ def client():
+ try:
+ deadline = time.monotonic() + 2
+ while True:
+ try:
+ conn = socket.create_connection(('127.0.0.1', port), timeout=.2)
+ break
+ except OSError:
+ if time.monotonic() >= deadline:
+ raise
+ time.sleep(.01)
+ with conn:
+ conn.sendall(b'PO')
+ time.sleep(.035)
+ conn.sendall(b'NG')
+ replies.append(conn.recv(128))
+ except Exception as exc:
+ errors.append(exc)
+ thread = threading.Thread(target=client, daemon=True)
+ thread.start()
+ try:
+ result = detector._test_tcp_client_configured('', '127.0.0.1', port, .4, protocol, profile)
+ finally:
+ thread.join(2)
+ assert result['ok'], result
+ assert '50 4F 4E 47' in result['response']
+ assert replies == [b'ACK'] and not errors
+
+
+@pytest.mark.parametrize('fragments,rule,expected_ip,ok,reply,reply_error', [
+ ([b'PO', b'NG'], {'exact_text': 'PONG'}, '127.0.0.1', True, [b'ACK'], False),
+ ([b'PO', socket.timeout()], {'exact_text': 'PONG'}, '127.0.0.1', False, [], False),
+ ([b'PO', ConnectionResetError()], {'exact_text': 'PONG'}, '127.0.0.1', False, [], False),
+ ([b'PO', b'NG', b''], {}, '127.0.0.1', True, [b'ACK'], False),
+ ([b'PO', socket.timeout()], {}, '127.0.0.1', True, [b'ACK'], False),
+ ([b'PONG'], {'exact_text': 'PONG'}, '127.0.0.2', False, [], False),
+ ([b'PONG'], {'exact_text': 'PONG'}, '127.0.0.1', False, [b'ACK'], True),
+])
+def test_incoming_tcp_partial_evidence_deadline_source_and_reply(monkeypatch, fragments, rule, expected_ip, ok, reply, reply_error):
+ detector = incoming_detector()
+ class Connection(FragmentSocket):
+ def __init__(self):
+ super().__init__(fragments)
+ self.sent = []
+ self.timeouts = []
+ def settimeout(self, value):
+ self.timeouts.append(value)
+ def getsockname(self):
+ return ('127.0.0.1', 123)
+ def sendall(self, data):
+ self.sent.append(data)
+ if reply_error:
+ raise ConnectionResetError('reply reset')
+ def close(self):
+ pass
+ conn = Connection()
+ class Listener:
+ def settimeout(self, value):
+ pass
+ def setsockopt(self, *args):
+ pass
+ def bind(self, address):
+ assert address == ('127.0.0.1', 123)
+ def listen(self, count):
+ pass
+ def accept(self):
+ return conn, ('127.0.0.1', 42)
+ def close(self):
+ pass
+ monkeypatch.setattr(socket, 'socket', lambda *args: Listener())
+ protocol = {'type': 'fixture_tcp', 'request_text': 'read', 'response_match': rule, 'reply_text': 'ACK'}
+ profile = detector._network_profile({'protocol': protocol}, {'network_config': {'device_role': 'client'}})
+ result = detector._test_tcp_client_configured('', expected_ip, 123, .1, protocol, profile)
+ assert result['ok'] == ok
+ assert '50 4F' in result['response']
+ if len(fragments) > 1 and fragments[1] == b'NG':
+ assert '50 4F 4E 47' in result['response']
+ assert conn.sent == reply
+ assert all(0 < t <= .1 for t in conn.timeouts)
diff --git a/tests/test_workbench.py b/tests/test_workbench.py
index efb9037..b4b3218 100644
--- a/tests/test_workbench.py
+++ b/tests/test_workbench.py
@@ -42,7 +42,7 @@ def test_missing_device_cannot_pass(service):
def test_snapshot_and_retest_scope(service):
- job = wait_finished(service, service.create({'scenario': 'faults'})['id'])
+ job = wait_finished(service, service.create({'scenario': 'faults', 'station_id': 'UNIT-A'})['id'])
broken = [r['device_id'] for r in job['results'] if r['verdict'] != 'PASS']
assert broken and len(broken) < len(job['results'])
with pytest.raises(ValueError):
@@ -260,7 +260,7 @@ def test_duplicate_retest_input_is_validation_error(service):
def test_failed_only_retest_never_claims_whole_unit_recovery(service):
- original = wait_finished(service, service.create({'scenario': 'missing', 'device_ids': ['DEMO_RELAY']})['id'])
+ original = wait_finished(service, service.create({'scenario': 'missing', 'station_id': 'UNIT-A', 'device_ids': ['DEMO_RELAY']})['id'])
child = wait_finished(service, service.retest(original['id'], {'scenario': 'healthy'})['id'])
comparison = compare_jobs(original, child)
assert comparison['comparable'] and comparison['partial_scope']
diff --git a/web/legacy.html b/web/legacy.html
index 977b98d..756fc83 100644
--- a/web/legacy.html
+++ b/web/legacy.html
@@ -304,10 +304,12 @@
+
等待扫描
@@ -377,9 +379,12 @@
}
async function postJson(url, body = {}) {
+ const headers = { "Content-Type": "application/json" };
+ const token = sessionStorage.getItem('qldc.token') || '';
+ if (token) headers.Authorization = `Bearer ${token}`;
const response = await fetch(url, {
method: "POST",
- headers: { "Content-Type": "application/json" },
+ headers,
body: JSON.stringify(body),
});
const data = await response.json();
@@ -604,6 +609,18 @@
}
}
+ document.getElementById("credentialsBtn").addEventListener("click", () => {
+ document.getElementById("accessToken").value = sessionStorage.getItem('qldc.token') || '';
+ document.getElementById("credentials").showModal();
+ });
+ document.getElementById("credentialsCancel").addEventListener("click", () => document.getElementById("credentials").close());
+ document.getElementById("credentialsForm").addEventListener("submit", event => {
+ event.preventDefault();
+ sessionStorage.setItem('qldc.token', document.getElementById("accessToken").value);
+ document.getElementById("accessToken").value = '';
+ document.getElementById("credentials").close();
+ setStatus("访问令牌已保存,可继续扫描", "ok");
+ });
document.getElementById("scanBtn").addEventListener("click", () => {
runOperation("/api/scan", "正在扫描已知设备", "扫描完成");
});
From 8872651c64b87f532d8e543199fac3994788ef1b Mon Sep 17 00:00:00 2001
From: Kaiser <104786001+KaiserIIII@users.noreply.github.com>
Date: Wed, 7 Oct 2026 13:21:33 +0800
Subject: [PATCH 07/15] feat: add responsive acceptance dashboard and repair
evidence workflow
---
tests/browser_workflow.cjs | 111 +++++++
web/assets/app.js | 52 +++
web/assets/style.css | 3 +
web/index.html | 628 +------------------------------------
4 files changed, 172 insertions(+), 622 deletions(-)
create mode 100644 tests/browser_workflow.cjs
create mode 100644 web/assets/app.js
create mode 100644 web/assets/style.css
diff --git a/tests/browser_workflow.cjs b/tests/browser_workflow.cjs
new file mode 100644
index 0000000..be6224d
--- /dev/null
+++ b/tests/browser_workflow.cjs
@@ -0,0 +1,111 @@
+/* Hardware-free browser acceptance. Use an existing Playwright installation.
+ * NODE_PATH=/path/to/node_modules node tests/browser_workflow.cjs http://127.0.0.1:18318
+ * Start the server with --demo and a disposable --data-dir first.
+ */
+const assert = require('node:assert/strict');
+const fs = require('node:fs/promises');
+const path = require('node:path');
+const { chromium } = require('playwright');
+
+(async () => {
+ const url = process.argv[2] || 'http://127.0.0.1:18318';
+ const browser = await chromium.launch({ headless: true,
+ ...(process.env.PLAYWRIGHT_CHANNEL ? { channel: process.env.PLAYWRIGHT_CHANNEL } : {}) });
+ const page = await browser.newPage({ viewport: { width: 1440, height: 980 } });
+ const errors = [];
+ page.on('pageerror', e => errors.push(e.message));
+ try {
+ await page.goto(url);
+ if (process.env.QLDC_BROWSER_TOKEN) {
+ await page.getByRole('button', { name: '访问凭证', exact: true }).click();
+ await page.getByLabel('访问令牌').fill(process.env.QLDC_BROWSER_TOKEN);
+ await page.getByRole('button', { name: '保存并连接' }).click();
+ }
+ await page.getByRole('button', { name: '配置校验', exact: true }).click();
+ await page.getByRole('button', { name: '载入当前配置' }).click();
+ await page.getByRole('button', { name: '校验配置', exact: true }).click();
+ await page.locator('#validation').filter({ hasText: '"valid": true' }).waitFor();
+ await page.getByRole('button', { name: '设备清单', exact: true }).click();
+ await page.getByText('DEMO_PLC', { exact: true }).waitFor();
+ await page.getByRole('button', { name: '新建验收', exact: true }).click({ timeout: 3000 });
+ await page.getByLabel('工位 / 设备编号').fill('DEMO-FAT-01');
+ await page.getByLabel('批次编号').fill('DEMO-2026-10');
+ await page.getByLabel('操作者').fill('演示工程师');
+ await page.getByLabel('演示场景').selectOption('faults');
+ await page.getByRole('button', { name: '创建验收任务', exact: true }).click();
+ await page.getByTestId('task-state').filter({ hasText: '已完成' }).waitFor();
+ await page.getByRole('button', { name: '查看证据' }).first().click();
+ await page.getByRole('dialog', { name: '设备测试证据' }).waitFor();
+ assert.ok((await page.getByRole('dialog').innerText()).includes('SIMULATED'));
+ await page.getByRole('button', { name: '关闭证据' }).click();
+ const downloadPromise = page.waitForEvent('download');
+ await page.getByRole('button', { name: '导出 JSON' }).click();
+ const download = await downloadPromise;
+ const saved = await download.path();
+ const report = JSON.parse(await fs.readFile(saved, 'utf8'));
+ assert.equal(report.mode, 'demo');
+ assert.ok(report.summary.failed > 0);
+ const baselineId = report.id;
+ await page.getByLabel('复测场景').selectOption('healthy');
+ await page.getByRole('button', { name: '复测未通过设备', exact: true }).click();
+ await page.getByTestId('task-state').filter({ hasText: '已完成' }).waitFor();
+ await page.getByRole('button', { name: '与原任务比较', exact: true }).click();
+ await page.getByTestId('comparison').waitFor();
+ assert.ok((await page.getByTestId('comparison').innerText()).includes('恢复'));
+ assert.ok((await page.getByTestId('comparison').innerText()).includes('部分范围'));
+ await fs.mkdir('output/playwright', { recursive: true });
+ await page.evaluate(() => scrollTo(0, 0));
+ await page.screenshot({ path: 'output/playwright/desktop.png', fullPage: true });
+ const htmlDownload = page.waitForEvent('download');
+ await page.getByRole('button', { name: '导出 HTML' }).click();
+ const html = await htmlDownload;
+ const htmlPath = await html.path();
+ const htmlReport = await fs.readFile(htmlPath, 'utf8');
+ assert.ok(htmlReport.includes('仅选定设备'));
+ assert.ok(htmlReport.includes('SIMULATED'));
+ await fs.writeFile('output/playwright/report.html', htmlReport);
+ const reportPage = await browser.newPage({ viewport: { width: 1200, height: 900 } });
+ await reportPage.setContent(htmlReport);
+ await reportPage.getByRole('heading', { name: '工控设备验收报告', exact: true }).waitFor();
+ await reportPage.screenshot({ path: 'output/playwright/report.png', fullPage: true });
+ await reportPage.close();
+ await page.getByRole('button', { name: '工位总览', exact: true }).click();
+ await page.getByText('DEMO-FAT-01').first().waitFor();
+ await page.evaluate(() => scrollTo(0, 0));
+ await page.screenshot({ path: 'output/playwright/overview.png', fullPage: true });
+ await page.getByRole('button', { name: '任务历史', exact: true }).click();
+ await page.reload();
+ await page.getByRole('button', { name: '任务历史', exact: true }).click();
+ await page.getByText('DEMO-FAT-01').first().waitFor();
+ assert.ok((await page.locator('body').innerText()).includes('DEMO-2026-10'));
+ await page.getByLabel('时间排序').selectOption('oldest');
+ await page.getByRole('button', { name: '筛选', exact: true }).click();
+ await page.getByLabel('时间排序').filter({ has: page.locator('option[value="oldest"][selected]') }).waitFor();
+ const ordered = await page.evaluate(async () => {
+ const token = sessionStorage.getItem('qldc.token');
+ return (await fetch('/api/jobs?sort=oldest&limit=20', { headers: token ? { Authorization: `Bearer ${token}` } : {} })).json();
+ });
+ assert.equal(await page.locator('[data-action="open"]').first().getAttribute('data-id'), ordered.jobs[0].id);
+ await page.setViewportSize({ width: 390, height: 844 });
+ await page.screenshot({ path: 'output/playwright/mobile.png', fullPage: true });
+ assert.ok(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth + 1));
+ // A browser-only fixture verifies structured probe/carrier evidence and escaping.
+ await page.route('**/api/jobs/' + baselineId, async route => {
+ const response = await route.fetch();
+ const payload = await response.json();
+ payload.job.results[0].attempts = [{ request: '', response: 'UI-PROBE-RESPONSE' }];
+ payload.job.results[0].supporting_checks = [{ device_id: 'UI-CARRIER', attempts: [{ response: 'UI-CARRIER-RESPONSE' }] }];
+ await route.fulfill({ response, json: payload });
+ });
+ await page.locator(`[data-action="open"][data-id="${baselineId}"]`).click();
+ await page.getByRole('button', { name: '查看证据' }).first().click();
+ await page.getByText('全部探测记录 (1)', { exact: true }).click();
+ await page.getByText('载体检查证据 (1)', { exact: true }).click();
+ assert.ok((await page.getByRole('dialog', { name: '设备测试证据' }).innerText()).includes('UI-PROBE-RESPONSE'));
+ assert.ok((await page.getByRole('dialog', { name: '设备测试证据' }).innerText()).includes('UI-CARRIER-RESPONSE'));
+ assert.equal(await page.locator('#evidence-body script').count(), 0);
+ assert.equal(await page.evaluate(() => window.badInjected), undefined);
+ assert.deepEqual(errors, []);
+ console.log(JSON.stringify({ result: 'PASS', baselineId, consoleErrors: errors, screenshots: ['desktop.png', 'mobile.png'] }));
+ } finally { await browser.close(); }
+})().catch(err => { console.error(err); process.exitCode = 1; });
diff --git a/web/assets/app.js b/web/assets/app.js
new file mode 100644
index 0000000..1c2b706
--- /dev/null
+++ b/web/assets/app.js
@@ -0,0 +1,52 @@
+'use strict';
+const $=s=>document.querySelector(s);
+const esc=v=>String(v??'').replace(/[&<>"']/g,c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
+const statuses={queued:'等待执行',running:'执行中',cancelling:'正在取消',completed:'已完成',cancelled:'已取消',failed:'执行故障',interrupted:'已中断'};
+const verdicts={PASS:'通过',FAIL:'失败',REVIEW:'待确认',NOT_RUN:'未执行'};
+const scenarios={healthy:'全部正常',faults:'混合故障',timeout:'响应超时',crc:'CRC 错误',missing:'设备缺失'};
+const scopes={communication_path:'通信路径验证',pci_passive:'PCI 被动检查'};
+const faults={TIMEOUT:'响应超时',CRC:'CRC 校验失败',MISSING:'应检设备未响应',CARRIER:'载体通信失败',RESPONSE_MISMATCH:'响应不匹配',UNAVAILABLE:'接口或设备不可用',NOT_RUN:'尚未执行'};
+const S={catalog:null,job:null,view:'overview',offset:0,filter:'',sort:'newest',query:'',timer:null,token:sessionStorage.getItem('qldc.token')||''};
+const done=s=>['completed','cancelled','failed','interrupted'].includes(s);
+const date=v=>v?new Date(v).toLocaleString('zh-CN',{hour12:false}):'—';
+const id=v=>String(v||'').slice(0,8).toUpperCase();
+const pill=(v,label)=>`
${esc(label||verdicts[v]||statuses[v]||v)} `;
+function notice(text,error=false){$('#notice').hidden=!text;$('#notice').className=error?'error':'';$('#notice').textContent=text;}
+async function api(url,method='GET',body){const headers={};if(S.token)headers.Authorization=`Bearer ${S.token}`;if(body!==undefined)headers['Content-Type']='application/json';const r=await fetch(url,{method,headers,body:body===undefined?undefined:JSON.stringify(body),cache:'no-store'});const d=await r.json();if(!r.ok||d.ok===false)throw new Error(d.error||`请求失败 (${r.status})`);return d;}
+const heading=(title,description,actions='')=>`
FIELD ACCEPTANCE / 工位验收 ${esc(title)} ${esc(description)}
${actions}
`;
+const metric=(name,value,description,kind='')=>`
${esc(name)}
${esc(value)} ${esc(description)} `;
+const empty=(title,text)=>`
${esc(title)} ${esc(text)}
`;
+const demoNote=()=>S.catalog.mode==='demo'?'
SIMULATED · 当前为离线演示,使用合成设备与报文,不连接现场硬件。
':'';
+const device=d=>`
${d.connection_type==='serial'?'↔':d.connection_type==='pci'?'◫':'⌁'} ${esc(d.name)} ${esc(d.device_id)}
`;
+function jobTable(jobs){return `
工位 / 批次 任务 / 创建时间 执行状态 验收结果 操作 ${jobs.map(j=>`${esc(j.metadata.station_id||'未命名工位')} ${esc(j.metadata.batch||'无批次')} · ${esc(j.metadata.operator||'未记录操作者')} ${id(j.id)}${date(j.created_at)} ${pill(j.status)} ${pill(j.summary.verdict)}${j.summary.passed} / ${j.summary.expected} 通过 查看任务 → `).join('')}
`;}
+function setView(view){clearTimeout(S.timer);S.view=view;notice('');$('#page-name').textContent=({overview:'工位总览',new:'新建验收',catalog:'设备清单',history:'任务历史',task:'任务详情',config:'配置校验'})[view];document.querySelectorAll('nav [data-view]').forEach(b=>b.classList.toggle('active',b.dataset.view===view));}
+async function navigate(view){setView(view);$('#main').innerHTML=empty('正在读取工位数据','');try{if(view==='overview')await overview();if(view==='new')newJob();if(view==='catalog')catalog();if(view==='history')await history();if(view==='config')config();}catch(e){notice(e.message,true);$('#main').innerHTML=empty('暂时无法读取工位','检查服务和访问凭证后重试。');}}
+async function overview(){const [stats,list]=await Promise.all([api('/api/insights'),api('/api/jobs?limit=5')]);const counts={serial:0,network:0,pci:0};S.catalog.devices.forEach(d=>counts[d.connection_type]++);const m=stats[S.catalog.mode];
+$('#main').innerHTML=heading('让每一次验收,有据可查。','从应检清单到协议证据,再到交付后的复测对比。所有统计来自当前工位保存的任务记录。')+
+`
${S.catalog.mode==='demo'?'SIMULATED / OFFLINE DEMO':'LIVE / LOCAL STATION'} ${S.catalog.mode==='demo'?'先演示一轮故障,再验证恢复。':'新一轮验收,从完整设备清单开始。'} 配置中应存在的设备都会进入验收结果。未响应设备保留失败记录,复测后可检查恢复、退化与证据变化。
开始验收 ↗ `+
+`
${metric('应检设备',S.catalog.devices.length,'当前配置中的完整清单')}${metric('已完成任务',m.completed,'当前模式的已完成验收')}${metric('通过任务',m.passed,'所有选定设备均通过','good')}${metric('失败任务',m.failed,'至少一项设备验收失败','bad')} `+
+`
最近验收 全部任务 → ${list.jobs.length?jobTable(list.jobs):empty('等待第一份验收记录','创建任务后,这里会展示工位、批次和结果。')}
验收流程 EVIDENCE FIRST ${[['完整清单','未发现、超时或无响应设备也保留结果。'],['可追溯证据','接口、请求和响应与本次配置快照一起保存。'],['复测与比较','检查配置和范围,区分部分复测与整套验收。']].map(([t,p],i)=>`
`).join('')}
设备组成 按配置统计 ${[['串口 / RS-485',counts.serial],['网络 / TCP · UDP',counts.network],['PCI / 被动状态',counts.pci]].map(([n,v])=>`
${n} ${v}
`).join('')}
通信响应证明当前通信路径可用;PCI 被动状态不代表完整业务功能或电气动作正常。
查看应检清单
当前配置 SHA-256 ${esc(S.catalog.config_hash)}
任务创建时固定配置快照,历史结果不会随配置修改而改变。
`;}
+function newJob(){$('#main').innerHTML=heading('新建现场验收','记录工位与批次,按当前配置检查全部应检设备。')+demoNote()+`
本次应检 ${S.catalog.devices.map(d=>`
${esc(d.name)} ${esc(scopes[d.scope])}
${esc(d.connection_type.toUpperCase())} `).join('')}
未响应设备保留为失败项;取消后未执行的设备单独标记。
`;}
+function catalog(){$('#main').innerHTML=heading('应检设备清单','这份清单决定验收覆盖范围。设备无需先被扫描发现,才进入验收结果。','
新建验收任务 ')+demoNote()+`
${S.catalog.devices.length} 个应检设备 当前配置 设备 / 标识 接口类型 接口 / 槽位 协议 验收范围 ${S.catalog.devices.map(d=>`${device(d)} ${esc(d.connection_type.toUpperCase())} ${esc(d.interface||'按配置匹配')} ${esc(d.protocol||'被动检查')} ${esc(scopes[d.scope])} `).join('')}
配置摘要 ${esc(S.catalog.config_hash)}
${S.catalog.mode==='live'?'
':''}`;}
+async function history(){const d=await api(`/api/jobs?limit=20&offset=${S.offset}&sort=${S.sort}&status=${encodeURIComponent(S.filter)}&query=${encodeURIComponent(S.query)}`);$('#main').innerHTML=heading('任务历史','按工位、批次或操作者查找验收记录,选择两次任务查看恢复与退化。','
新建验收任务 ')+`
两次任务比较 ${[['baseline','基线任务'],['current','当前任务']].map(([key,label])=>`${label}选择任务 ${d.jobs.map(j=>`${esc(j.metadata.station_id||'未命名')} · ${id(j.id)} · ${verdicts[j.summary.verdict]} `).join('')} `).join('')}
比较任务
`;}
+function config(){$('#main').innerHTML=heading('校验设备配置','检查设备标识、接口参数和只读验收规则。校验不会修改工位当前配置。')+`
配置内容 VALIDATE ONLY `;}
+async function openJob(jobId){setView('task');S.job=null;$('#main').innerHTML=empty('正在读取任务','');S.job=(await api(`/api/jobs/${encodeURIComponent(jobId)}`)).job;task();poll(jobId);}
+function poll(jobId){clearTimeout(S.timer);if(!S.job||done(S.job.status)||S.view!=='task')return;S.timer=setTimeout(async()=>{try{const d=await api(`/api/jobs/${jobId}`);if(S.view!=='task'||S.job?.id!==jobId)return;S.job=d.job;task();poll(jobId);}catch(e){notice(e.message,true);}},650);}
+function task(){const j=S.job,s=j.summary,finished=done(j.status),pending=j.results.some(r=>r.verdict!=='PASS'),pct=j.progress.total?Math.round(j.progress.completed/j.progress.total*100):0;
+$('#main').innerHTML=heading(j.metadata.station_id||'验收任务',`任务 ${id(j.id)} · ${j.parent_job_id?'指定设备复测':'设备验收'}`,`
返回历史 ${finished?'':'
取消任务 '}`)+
+`
${j.mode==='demo'?'SIMULATED / 合成验收记录':'LIVE / 现场验收记录'} ${j.parent_job_id?'复测验收':'工位验收'} · ${esc(j.metadata.batch||'无批次')} 操作者 ${esc(j.metadata.operator||'未记录')} 创建 ${date(j.created_at)} 执行 ${(j.duration_ms/1000).toFixed(2)} s
${pill(j.status)} 设备进度 ${j.progress.completed} / ${j.progress.total} ${pct}%
${j.metadata.notes?`
${esc(j.metadata.notes)}
`:''}
本次配置摘要 ${esc(j.config_hash)}
`+
+(j.parent_job_id?'
本任务只复测原任务未通过设备。部分复测通过不代表整套设备验收通过。
':'')+
+`
${metric('应检设备',s.expected,'本次选定的验收范围')}${metric('通过',s.passed,'保留对应通信 / 被动证据','good')}${metric('失败 / 待确认',s.failed+s.review,'需要排查或现场确认','bad')}${metric('未执行',s.not_run,'等待执行或提前结束','warn')}
设备验收结果 ${pill(s.verdict,'本次范围 '+verdicts[s.verdict])}设备 接口 / 协议 验收结果 判定依据 耗时 操作 ${j.results.map((r,i)=>`${device(r)} ${esc(r.interface||r.connection_type)}${esc(r.protocol||'被动检查')} ${pill(r.verdict)} ${esc(r.verdict==='PASS'?scopes[r.scope]:faults[r.fault_code]||r.fault_code)}${esc(r.summary)} ${Number(r.duration_ms).toFixed(1)} ms 查看证据 `).join('')}
`+
+`
复测与交付 ${finished&&pending?`
${j.mode==='demo'?`复测场景${Object.entries(scenarios).map(([k,v])=>`${v} `).join('')} `:''}复测未通过设备
`:finished?'
已完成当前选定范围检查,证据与配置快照已保留。
':'
执行结束后可复测或导出。取消在当前设备组结束后生效。
'}${j.parent_job_id&&finished?'
与原任务比较
':''}
${['HTML','JSON','CSV'].map(f=>`导出 ${f} `).join('')}
报告保留验收范围、报文证据与判定。模拟报告始终标记 SIMULATED。
任务记录 ${j.events.map(e=>`
${date(e.at)} ${esc(e.message)}
`).join('')}
`;}
+function structuredEvidence(result) {
+ const attempts = result.attempts || [], supporting = result.supporting_checks || [];
+ return (attempts.length ? `
全部探测记录 (${attempts.length}) ${esc(JSON.stringify(attempts, null, 2))} ` : '') +
+ (supporting.length ? `
载体检查证据 (${supporting.length}) 载体检查支持本次设备判定,不扩大选定设备范围。
${esc(JSON.stringify(supporting, null, 2))} ` : '');
+}
+function evidence(index){const r=S.job.results[index];$('#evidence-body').innerHTML=`
${pill(r.verdict)}${r.simulated?'SIMULATED':''}
${esc(r.name)} ${[['设备标识',r.device_id],['验收范围',scopes[r.scope]],['接口',r.interface||r.connection_type],['协议',r.protocol||'PCI 被动检查'],['判定依据',r.summary],['耗时',`${r.duration_ms} ms`]].map(([k,v])=>`
${k} ${esc(v)} `).join('')} 请求 / 检查方式 ${esc(r.request||'无报文 / 被动检查')} 响应 / 采集结果 ${esc(r.response||'无响应记录')} ${structuredEvidence(r)}${r.verdict!=='PASS'?`
排查建议 ${esc(r.suggestion)}基于规则的建议,需要现场确认。
`:''}
此项结果只覆盖 ${esc(scopes[r.scope])},不覆盖电气动作或完整业务功能。
`;$('#evidence').showModal();}
+async function compare(baseline,current){const c=(await api(`/api/compare?baseline=${encodeURIComponent(baseline)}¤t=${encodeURIComponent(current)}`)).comparison;$('#comparison-host').innerHTML=`
复测结果比较 ${id(baseline)} → ${id(current)} ${!c.comparable?`
不满足可比条件:${esc(c.reasons.join(' · '))}。以下差异仅供查看。
`:c.partial_scope?'
部分范围比较:只检查共同设备,未宣称整套设备已恢复。
':'
模式、配置和选定设备范围一致,可比较验收状态。
'}
${[['恢复',c.recovered],['退化',c.regressed],['仍失败',c.still_failed],['未变化',c.unchanged]].map(([n,ids])=>`
${c.comparable?'':'观察到的'}${n} ${ids.length}
`).join('')}
${[['恢复',c.recovered],['退化',c.regressed],['新增',c.added],[c.partial_scope?'未纳入本次复测':'移除',c.removed],['其他变化',c.changed]].filter(([,v])=>v.length).map(([n,ids])=>`
${n} ${ids.map(v=>`${esc(v)}`).join('')}
`).join('')}
`;$('#comparison-host').scrollIntoView({behavior:'smooth'});}
+async function download(format){const headers=S.token?{Authorization:`Bearer ${S.token}`}:{},r=await fetch(`/api/jobs/${S.job.id}/export?format=${format}`,{headers});if(!r.ok)throw new Error('报告导出失败');const url=URL.createObjectURL(await r.blob()),a=document.createElement('a');a.href=url;a.download=`acceptance-${id(S.job.id)}.${format}`;document.body.append(a);a.click();a.remove();setTimeout(()=>URL.revokeObjectURL(url),3000);}
+document.addEventListener('click',async event=>{const b=event.target.closest('button');if(!b||b.disabled)return;try{if(b.dataset.view)return await navigate(b.dataset.view);switch(b.dataset.action){case 'open':await openJob(b.dataset.id);break;case 'evidence':evidence(Number(b.dataset.index));break;case 'close-evidence':$('#evidence').close();break;case 'credentials':$('#credentials').showModal();break;case 'close-credentials':$('#credentials').close();break;case 'filter':S.query=$('#query').value;S.filter=$('#status').value;S.sort=$('#sort').value;S.offset=0;await history();break;case 'prev':S.offset=Math.max(0,S.offset-20);await history();break;case 'next':S.offset+=20;await history();break;case 'cancel':await api(`/api/jobs/${S.job.id}/cancel`,'POST',{});await openJob(S.job.id);break;case 'retest':{b.disabled=true;const body={};if($('#retest-scenario'))body.scenario=$('#retest-scenario').value;const d=await api(`/api/jobs/${S.job.id}/retest`,'POST',body);await openJob(d.job.id);break;}case 'compare-parent':await compare(S.job.parent_job_id,S.job.id);break;case 'export':await download(b.dataset.format);break;case 'load-config':$('textarea[name="config"]').value=JSON.stringify(S.catalog.config_snapshot,null,2);break;}}catch(e){notice(e.message,true);if(b.isConnected)b.disabled=false;}});
+document.addEventListener('submit',async event=>{event.preventDefault();const form=event.target,b=form.querySelector('[type="submit"]');try{if(b)b.disabled=true;const v=Object.fromEntries(new FormData(form));if(form.id==='job-form')await openJob((await api('/api/jobs','POST',v)).job.id);if(form.id==='compare-form'){if(v.baseline===v.current)throw new Error('请选择两个不同任务');await compare(v.baseline,v.current);}if(form.id==='config-form'){const d=await api('/api/config/validate','POST',{config:JSON.parse(v.config)});$('#validation').hidden=false;$('#validation').textContent=JSON.stringify(d,null,2);}if(form.id==='credentials-form'){S.token=v.token;sessionStorage.setItem('qldc.token',S.token);$('#credentials').close();await boot();}}catch(e){notice(e.message,true);}finally{if(b?.isConnected)b.disabled=false;}});
+async function boot(){try{S.catalog=await api('/api/catalog');$('#station-mode').textContent=S.catalog.mode==='demo'?'离线演示工位':'本地现场工位';$('#mode-badge').textContent=S.catalog.mode==='demo'?'SIMULATED / DEMO':'LIVE / LOCAL';await navigate('overview');}catch(e){notice(e.message,true);$('#main').innerHTML=empty('连接工位后开始验收','服务不可用或需要访问凭证,请通过右上角输入令牌。');}}
+boot();
diff --git a/web/assets/style.css b/web/assets/style.css
new file mode 100644
index 0000000..12083a6
--- /dev/null
+++ b/web/assets/style.css
@@ -0,0 +1,3 @@
+:root{--nav:#142b38;--ink:#1a3442;--muted:#6b8190;--line:#dfe7eb;--accent:#087d79;--bg:#f3f6f8;--red:#b8413b;--mono:Consolas,monospace}*{box-sizing:border-box}body{margin:0;background:var(--bg);color:var(--ink);font:14px/1.6 "Segoe UI","Microsoft YaHei",sans-serif}button,input,textarea,select{font:inherit}button{cursor:pointer;border:0}button:disabled{opacity:.5;cursor:wait}a{color:var(--accent);text-decoration:none}h1,h2,h3,p{margin-top:0}h1{font-size:30px;line-height:1.3;letter-spacing:-.8px;margin-bottom:12px}h2{font-size:19px;margin-bottom:12px}h3{font-size:14px}small{font-size:11px}.muted{color:var(--muted)}.mono{font-family:var(--mono);font-size:11px;overflow-wrap:anywhere}.eyebrow{display:block;font:10px var(--mono);letter-spacing:2px;color:var(--accent);margin-bottom:10px}aside{width:234px;position:fixed;inset:0 auto 0 0;background:var(--nav);padding:28px 18px;display:flex;flex-direction:column;color:#a8c0cb}.brand{display:flex;gap:11px;align-items:center;color:#fff;font-weight:700;font-size:14px;letter-spacing:1px;margin:0 8px 46px}.brand b{position:relative;width:42px;height:42px;display:grid;place-items:center;border:1px solid #557582;border-radius:9px;background:#1b3b4b;font-size:19px;letter-spacing:-1px}.brand b span{color:#68dbb9;position:absolute;right:3px;top:-7px}.brand small{display:block;font-size:8px;letter-spacing:2px;color:#87a5b5}.nav-label{font-size:10px;letter-spacing:2px;padding:0 14px;margin-bottom:12px;color:#6c8a9b}nav button{display:block;width:100%;text-align:left;padding:13px 14px;border-radius:6px;background:none;color:#c2d2db;margin-bottom:5px;font-size:13px}nav button:hover{background:#234454}nav button.active{background:#244b58;color:#90dfc7}.station{margin-top:auto;border-top:1px solid #2e4754;padding:20px 10px 0;font-size:11px}.station i{display:inline-block;width:7px;height:7px;background:#5dd3b1;border-radius:50%;margin-right:8px}.station small{display:block;margin-top:7px;font-size:9px;color:#7894a3}.workspace{margin-left:234px}header{height:72px;padding:0 35px;background:#fff;border-bottom:1px solid var(--line);display:flex;align-items:center;justify-content:space-between;gap:12px;font-size:12px}header span{color:var(--muted)}header strong{font-weight:500}header code{font:9px var(--mono);letter-spacing:1px;background:#edf8f4;border:1px solid #bfded3;color:#397b65;padding:4px 8px;border-radius:4px}.actions{display:flex;gap:9px;flex-wrap:wrap;align-items:center}.text{color:var(--muted);background:none;font-size:11px;padding:5px}.primary,.secondary,.danger{display:inline-flex;align-items:center;justify-content:center;min-height:39px;padding:9px 16px;border-radius:5px;font-size:12px;font-weight:600;white-space:nowrap}.primary{background:var(--accent);color:white}.primary:hover{background:#05635f}.secondary{background:white;border:1px solid #ccd9df;color:#476572}.danger{background:#fff0ee;border:1px solid #efcbc5;color:var(--red)}.link{background:transparent;color:var(--accent);font-size:11px;padding:5px 0;white-space:nowrap}main{max-width:1500px;padding:34px 35px;min-height:calc(100vh - 132px);margin:auto}.heading{display:flex;justify-content:space-between;gap:22px;align-items:flex-start;margin-bottom:26px}.heading p{color:var(--muted);font-size:13px;max-width:700px;margin:0}.heading .actions{align-self:center}.hero{background:linear-gradient(110deg,#173a4a,#23525a);border-radius:9px;color:white;padding:30px;display:flex;gap:30px;justify-content:space-between;margin-bottom:25px}.hero h2{font-size:24px;margin-bottom:9px}.hero p{color:#b7d3d9;font-size:12px;margin:0;max-width:650px}.hero .eyebrow{color:#85e0c1}.hero .primary{align-self:center;background:#b5ead5;color:#1b5349}.tag{display:inline-block;font:9px var(--mono);letter-spacing:2px;border:1px solid #51737b;padding:4px 8px;border-radius:4px;color:#b9dfd8;margin-bottom:18px}.metrics{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:16px;margin-bottom:26px}.metric{border:1px solid var(--line);border-radius:8px;background:white;padding:20px;position:relative}.metric-label{font-size:12px;color:var(--muted)}.metric strong{display:block;font:33px/1.4 var(--mono);letter-spacing:-1px;margin:8px 0 4px}.metric small{font-size:10px;color:#8295a1}.metric:after{content:'';position:absolute;right:18px;top:23px;width:6px;height:6px;border-radius:50%;background:#8faeba}.metric.good:after{background:#38a979}.metric.bad:after{background:#ce625b}.metric.warn:after{background:#cea04a}.columns{display:grid;grid-template-columns:minmax(0,1.6fr) minmax(270px,1fr);gap:21px}.card{background:white;border:1px solid var(--line);border-radius:8px;margin-bottom:22px;min-width:0}.card-head{padding:18px 21px;border-bottom:1px solid var(--line);display:flex;justify-content:space-between;align-items:center;gap:12px}.card-head h2,.card-head h3{margin:0;font-size:15px}.card-head small{color:var(--muted)}.pad{padding:23px}.note{background:#eef6f6;border-left:3px solid var(--accent);padding:12px 15px;color:#477275;border-radius:0 4px 4px 0;font-size:12px;margin:18px 0;overflow-wrap:anywhere}.note.warn{background:#fff6e5;border-color:#cba040;color:#8a672c}.empty{padding:40px 24px;text-align:center;color:var(--muted);font-size:12px}.empty strong{display:block;font-size:15px;color:#486574;margin-bottom:7px}.empty button{margin-top:14px}.table-wrap{overflow:auto;max-width:100%}table{border-collapse:collapse;width:100%;text-align:left}th{font-size:10px;font-weight:600;color:#7b909b;background:#f8fafb;padding:12px 17px;white-space:nowrap;border-bottom:1px solid var(--line)}td{padding:15px 17px;font-size:12px;border-bottom:1px solid #edf1f3;vertical-align:middle}td small{display:block;color:var(--muted);font-size:10px;margin-top:3px}tbody tr:last-child td{border-bottom:0}.device{display:flex;align-items:center;gap:10px;min-width:190px}.symbol{flex-shrink:0;width:33px;height:33px;border-radius:7px;display:grid;place-items:center;background:#edf4f7;color:#7697a5;font-size:18px}.pill{display:inline-flex;align-items:center;gap:5px;font-size:10px;white-space:nowrap;padding:3px 9px;border-radius:4px;background:#edf2f5;color:#698190}.pill:before{content:'';width:4px;height:4px;border-radius:50%;background:currentColor}.pill.PASS,.pill.completed{background:#e7f4ed;color:#26744e}.pill.FAIL,.pill.failed{background:#fbece9;color:#b8413b}.pill.REVIEW,.pill.cancelling{background:#fff2dc;color:#986a19}.pill.running,.pill.queued{background:#e6f1f8;color:#387f9a}.pill.NOT_RUN,.pill.cancelled,.pill.interrupted{background:#edf1f4;color:#778b97}.row{display:flex;justify-content:space-between;align-items:center;padding:13px 0;border-bottom:1px solid #edf1f3;gap:14px}.row:last-child{border-bottom:0}.row strong{font-size:12px}.row span{font:20px var(--mono);color:#527c8b}.row small{display:block;color:var(--muted)}.step{display:flex;gap:14px;margin-bottom:18px}.step>span{width:27px;height:27px;flex-shrink:0;display:grid;place-items:center;border-radius:50%;background:#edf6f4;color:var(--accent);border:1px solid #d7e9e2;font:11px var(--mono)}.step strong{font-size:12px}.step p{font-size:11px;color:var(--muted);margin:4px 0 0}.form-grid{display:grid;grid-template-columns:1fr 1fr;gap:19px}label{display:block;font-size:12px;font-weight:600;color:#466573}input,select,textarea{width:100%;min-width:0;margin-top:7px;padding:10px 12px;border:1px solid #cad8de;border-radius:5px;color:var(--ink);background:white;font-weight:400}textarea{resize:vertical;min-height:105px}input:focus,textarea:focus,select:focus{outline:2px solid #6baba5;outline-offset:1px}.full{grid-column:span 2}.form-actions{margin-top:20px;justify-content:flex-end}.form-foot{padding:18px 23px;border-top:1px solid var(--line);display:flex;gap:16px;align-items:center;justify-content:space-between}.form-foot small{color:var(--muted)}.filters{display:flex;align-items:flex-end;gap:12px;padding:16px 21px;flex-wrap:wrap;border-bottom:1px solid var(--line)}.filters label{font-size:10px}.filters input{width:220px}.filters select{width:150px}.pager{padding:15px 21px;display:flex;align-items:center;justify-content:space-between;font-size:11px;color:var(--muted);gap:12px}.task-top{display:flex;justify-content:space-between;gap:16px}.task-top h2{margin-bottom:4px}.task-info{display:flex;gap:15px;flex-wrap:wrap;font-size:11px;color:var(--muted);margin-top:15px}.progress{background:#e6eef0;height:6px;border-radius:6px;overflow:hidden;margin:20px 0 8px}.progress>div{height:100%;background:var(--accent);transition:width .3s}.progress-label{display:flex;justify-content:space-between;font-size:11px;color:var(--muted)}.hash{background:#f9fbfc;border-top:1px solid var(--line);padding:12px 21px;font-size:10px;color:#7b909c;overflow-wrap:anywhere}.event{display:flex;gap:12px;font-size:11px;padding:9px 0;overflow-wrap:anywhere}.event time{font-family:var(--mono);color:#91a0aa;white-space:nowrap}.compare-grid{display:grid;grid-template-columns:repeat(4,1fr);gap:12px}.compare-tile{background:#f3f7f8;padding:17px;border-radius:6px}.compare-tile span{font-size:11px;color:var(--muted);display:block}.compare-tile strong{display:block;font:27px var(--mono);color:var(--accent);margin-top:7px}.compare-list{display:flex;gap:9px;flex-wrap:wrap;margin-top:15px;font-size:11px}.compare-list code{background:#edf4f5;padding:4px 7px;border-radius:4px;overflow-wrap:anywhere}.retake{display:flex;align-items:flex-end;gap:12px;flex-wrap:wrap}.retake label{width:180px;font-size:10px}#notice{margin:18px 35px 0;border:1px solid #ebd7b1;background:#fff6e7;color:#885f22;padding:12px 16px;border-radius:6px;overflow-wrap:anywhere}#notice.error{border-color:#eccbc6;background:#fff0ed;color:#a63b32}dialog{padding:0;width:min(680px,calc(100vw - 30px));border:1px solid var(--line);border-radius:10px;color:var(--ink);max-height:90vh;overflow:auto;box-shadow:0 20px 60px #142b3840}dialog::backdrop{background:#0f2c3e70;backdrop-filter:blur(2px)}dialog .card-head h2{font-size:19px}.evidence-grid{display:grid;grid-template-columns:1fr 1fr;gap:17px}.evidence-grid dt{font-size:10px;color:var(--muted)}.evidence-grid dd{font-size:12px;margin:4px 0 0;overflow-wrap:anywhere}pre{font:11px/1.8 var(--mono);background:#15313f;color:#c6e3e8;padding:16px;border-radius:6px;white-space:pre-wrap;overflow-wrap:anywhere;max-height:230px;overflow:auto}footer{padding:18px 35px;border-top:1px solid var(--line);font-size:10px;color:#8a9da8;display:flex;justify-content:space-between;gap:12px}
+@media(max-width:1100px){aside{width:210px}.workspace{margin-left:210px}main{padding:27px 23px}header{padding:0 23px}.columns{grid-template-columns:1fr}.metrics{gap:10px}.metric{padding:17px}.metric strong{font-size:29px}}
+@media(max-width:760px){aside{position:relative;width:100%;padding:15px 16px 9px}.brand{margin:0 0 14px;font-size:12px}.brand b{width:33px;height:33px;font-size:16px}.nav-label,.station{display:none}nav{display:flex;overflow-x:auto;gap:4px}nav button{width:auto;white-space:nowrap;flex-shrink:0;padding:8px 9px;font-size:11px;margin:0}.workspace{margin-left:0}header{height:58px;padding:0 17px;font-size:11px}header>div>span{display:none}header code{font-size:8px;letter-spacing:0}header .actions{gap:6px}.text{font-size:10px}main{padding:23px 16px}h1{font-size:25px}.heading{display:block;margin-bottom:22px}.heading p{font-size:12px}.heading .actions{margin-top:15px}.hero{padding:22px;display:block}.hero h2{font-size:21px}.hero .primary{margin-top:20px}.hero p{font-size:11px}.metrics{grid-template-columns:repeat(2,minmax(0,1fr));gap:10px;margin-bottom:20px}.metric{padding:16px}.metric strong{font-size:28px}.metric small{font-size:9px}.metric-label{font-size:11px}.columns{gap:0}.card-head{padding:15px 16px}.pad{padding:17px}.form-grid{grid-template-columns:1fr;gap:15px}.full{grid-column:span 1}.form-foot{padding:17px;display:block}.form-foot .primary{width:100%;margin-top:14px}.filters{padding:15px;gap:10px}.filters label{flex:1}.filters input,.filters select{width:100%}td,th{padding:12px}.task-top{display:block}.task-top>.pill,.task-top>[data-testid]{display:inline-block;margin-top:12px}.task-info{gap:8px}.hash{padding:12px 16px}.event{display:block}.event time{display:block;margin-bottom:3px}.evidence-grid{grid-template-columns:1fr}.compare-grid{grid-template-columns:repeat(2,minmax(0,1fr));gap:8px}.compare-tile{padding:13px}.retake label{flex:1;min-width:140px}.retake .primary{width:100%}footer{padding:17px;display:block}footer span{display:block;margin-top:4px}#notice{margin:14px 16px 0;font-size:12px}.pager{padding:15px}}
diff --git a/web/index.html b/web/index.html
index 977b98d..0716fa5 100644
--- a/web/index.html
+++ b/web/index.html
@@ -1,624 +1,8 @@
-
-
-
-
-
工控机设备检测
-
-
+
QL DeviceCheck · 现场验收工作台
-
-
-
工控机设备检测
-
- 扫描设备
- 测试全部
-
-
-
-
- 等待扫描
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
验收中心 / 工位总览
CONNECTING访问凭证
正在读取工位数据…
QL DeviceCheck 现场记录保存在当前工位 · v3.0
+
+
连接受保护的工位 令牌仅用于本次浏览器会话,不写入报告。
访问令牌 取消 保存并连接
+
From e0dc7fcd990d5420d28e11a1da4a3f5d1ec538de Mon Sep 17 00:00:00 2001
From: Kaiser <104786001+KaiserIIII@users.noreply.github.com>
Date: Wed, 7 Oct 2026 13:23:59 +0800
Subject: [PATCH 08/15] docs: document field acceptance workflows and verified
release delivery
---
CHANGELOG.md | 13 ++++
README.md | 72 +++++++++++-------
README_zh.md | 72 +++++++++++-------
docs/API.md | 45 +++++++++++
docs/ARCHITECTURE.md | 53 +++++++++++++
docs/DEMO.md | 26 +++++++
docs/DEPLOYMENT.md | 32 ++++++++
docs/VALIDATION.md | 48 ++++++++++++
docs/screenshots/mobile.png | Bin 0 -> 88823 bytes
docs/screenshots/overview.png | Bin 0 -> 196465 bytes
docs/screenshots/report.png | Bin 0 -> 129116 bytes
docs/screenshots/retest.png | Bin 0 -> 111033 bytes
.../2026-10-07-field-acceptance-workbench.md | 6 +-
13 files changed, 310 insertions(+), 57 deletions(-)
create mode 100644 CHANGELOG.md
create mode 100644 docs/API.md
create mode 100644 docs/ARCHITECTURE.md
create mode 100644 docs/DEMO.md
create mode 100644 docs/DEPLOYMENT.md
create mode 100644 docs/VALIDATION.md
create mode 100644 docs/screenshots/mobile.png
create mode 100644 docs/screenshots/overview.png
create mode 100644 docs/screenshots/report.png
create mode 100644 docs/screenshots/retest.png
diff --git a/CHANGELOG.md b/CHANGELOG.md
new file mode 100644
index 0000000..f642f8b
--- /dev/null
+++ b/CHANGELOG.md
@@ -0,0 +1,13 @@
+# Changelog
+
+## 3.0 — 2026-10-07
+
+- Add a configuration-driven acceptance workbench with station/batch/operator metadata, async progress and cooperative cancellation.
+- Persist task snapshots, results and events in SQLite; mark unfinished tasks interrupted on restart without hardware replay.
+- Keep expected missing devices as failures; separate execution status from inspection verdict.
+- Add failed-only retest lineage, scope-aware comparison, HTML/JSON/CSV evidence reports and separate demo/live metrics.
+- Add a hardware-independent synthetic demo, local responsive browser assets and a compatibility UI at `/legacy`.
+- Assemble fragmented configured active TCP responses; validate Modbus byte counts, quantities and frame identity.
+- Add API and protocol regressions, a demo browser workflow, source/artifact dependency provenance and pinned offline CI.
+
+The project owner confirmed the project was tested and is usable. This upgrade's automated checks and release-package validation are recorded in [validation](docs/VALIDATION.md).
diff --git a/README.md b/README.md
index 9fc38a9..e32698c 100644
--- a/README.md
+++ b/README.md
@@ -1,49 +1,65 @@
-# Industrial Device Check
+# QLDeviceCheck · Field Acceptance Workbench
-[简体中文](README_zh.md) · [Configuration guide](config/CONFIG_GUIDE.md) · [MIT License](LICENSE)
+[简体中文](README_zh.md) · [Demo](docs/DEMO.md) · [Architecture](docs/ARCHITECTURE.md) · [API](docs/API.md) · [Validation](docs/VALIDATION.md) · [MIT](LICENSE)
-A Linux Web UI for discovering known industrial devices, checking protocol responses, and generating inspection reports. Configuration-driven matching connects serial, network, and PCI interfaces to the equipment being tested.
+A local acceptance station for industrial device vendors and system integrators. Turn an expected equipment list into a traceable inspection: record what was tested, preserve protocol evidence, and compare results after repairs.
-## Capabilities
+
-| Interface | Checks |
+## A workflow for delivery and repair
+
+| Field problem | Workbench behavior |
| --- | --- |
-| Serial | Modbus RTU read functions 01–04; configured custom requests and response matching |
-| Network | Modbus TCP, TCP connectivity, custom TCP/UDP probes |
-| PCI | Passive inspection of link state, driver binding, and associated interface nodes |
+| A disconnected device disappears from a discovery list | Every selected configured device gets a result; missing devices remain failures. |
+| A report cannot be tied to the configuration used | Each task keeps the full configuration snapshot, SHA-256, station/unit ID, batch, operator and timestamps. |
+| Rechecking a repair loses the original evidence | Failed-only retests retain their parent task and original snapshot. Comparison shows recovery, regression and scope limits. |
+| A demonstration needs field hardware | `--demo` uses an independent synthetic catalog, visibly stamps reports `SIMULATED`, and separates demo/live statistics. |
+
+The intended position is a lightweight Linux field acceptance workflow with local evidence ownership. The project owner has confirmed the project was tested and is usable. See the [design and positioning rationale](docs/superpowers/specs/2026-10-07-field-acceptance-design.md) and [verification records](docs/VALIDATION.md).
-The workflow supports full scans, individual interface scans, and device retests. Reports are generated in HTML, TXT, and JSON. The `via_device` configuration models devices reached through a shared PLC or gateway connection.
+## Try the demo
-## Run
+For a ready-to-install bundle, download the Windows x64 or Linux x86_64 package from [Releases](https://github.com/KaiserIIII/QLDeviceCheck_Generic_WebUI_Linux/releases). Bundles include offline runtime dependencies. Install CPython 3.10+ first; then double-click `Start-Demo.cmd` on Windows or run `bash run_demo.sh` on Linux. [Release guide](docs/RELEASE.md).
-Requirements: Linux, Python 3.7+, and permission to access the selected interfaces.
+Python **3.10+**. The demo and offline checks also run on Windows.
```bash
-bash setup_linux.sh
-bash run_web.sh
+python -m venv .venv
+source .venv/bin/activate
+python -m pip install --require-hashes -r requirements-runtime.lock
+python web_app.py --demo --data-dir data/demo
```
-Open
.
+On PowerShell, activate with `.\.venv\Scripts\Activate.ps1`. Open [localhost:8080](http://127.0.0.1:8080), create a **mixed faults** task, inspect its evidence, then retest with **healthy**. [Demo walkthrough](docs/DEMO.md).
-## Configure and verify
-
-Define equipment in [config/device_list.json](config/device_list.json), using [device_list_examples.json](config/device_list_examples.json) and the [configuration guide](config/CONFIG_GUIDE.md).
+## Field mode
```bash
-python3 validate_config.py
-python3 self_test.py
+bash setup_linux.sh
+bash run_web.sh --config config/device_list.json --data-dir data/live
```
-Configuration validation and the internal self-test do not access field devices.
+Review the target equipment and custom request bytes before executing a live task. Built-in acceptance rejects write-labelled operations and Modbus functions outside 01–04. PCI checks are passive. Communication checks do not establish electrical performance or complete business functionality.
-## Implementation
+| Interface | Existing inspection capabilities |
+| --- | --- |
+| Serial | Modbus RTU reads 01–04; configured custom request/response matching |
+| Network | Modbus TCP, TCP connectivity, custom TCP/UDP checks |
+| PCI | Link/driver/interface inspection, including configured child devices |
-- [web_app.py](web_app.py): HTTP API, task coordination, and report generation.
-- [core/config_manager.py](core/config_manager.py): configuration loading and validation.
-- [core/generic_detector.py](core/generic_detector.py): discovery and inspection workflow.
-- [core/protocol_engine.py](core/protocol_engine.py): protocol requests and response validation.
-- [web/index.html](web/index.html): browser interface.
+The original scan UI remains at `/legacy` in live mode, sharing the station execution lock. Its reports remain separate from acceptance history. See [configuration](config/CONFIG_GUIDE.md) and [deployment](docs/DEPLOYMENT.md) for operational boundaries.
-## Device access
+## Engineering
+
+The new `inspection/` domain, service, store, adapters, HTTP and report modules isolate the browser workflow from the existing protocol core. SQLite persists task state and evidence; restart marks unfinished work interrupted without replaying hardware operations. A single process owns each station database.
+
+The browser uses local assets without a CDN or frontend framework. Default binding is `127.0.0.1`; network binding requires `QLDC_ACCESS_TOKEN`. The service includes bounded inputs, Host/Origin checks and escaped exports. It does not include multi-user roles or a TLS terminator.
+
+```bash
+python -m pip install --require-hashes -r requirements.lock
+python validate_config.py
+python self_test.py
+python -m pytest -q
+```
-Built-in Modbus checks use read-only function codes; PCI checks are passive. Custom requests can have different semantics and must be reviewed for the target equipment. Use an isolated test environment before field testing, and keep the Web UI on a trusted network.
+[Validation](docs/VALIDATION.md) records tested paths and remaining hardware risks. [Dependencies](docs/DEPENDENCIES.md) records pinned releases, source commits, licenses and artifact hashes. CI covers offline checks on Windows/Linux with Python 3.10/3.13.
diff --git a/README_zh.md b/README_zh.md
index 54fe538..57aaa80 100644
--- a/README_zh.md
+++ b/README_zh.md
@@ -1,49 +1,65 @@
-# 工业设备检测系统
+# QLDeviceCheck · 工控设备验收工作台
-[English](README.md) · [配置指南](config/CONFIG_GUIDE.md) · [MIT 许可证](LICENSE)
+[English](README.md) · [演示](docs/DEMO.md) · [架构](docs/ARCHITECTURE.md) · [API](docs/API.md) · [验证记录](docs/VALIDATION.md) · [MIT](LICENSE)
-面向 Linux 的设备发现与协议连通性检测工具,通过 Web 界面完成扫描、测试和报告生成。系统根据配置匹配串口、网络和 PCI 接口与已知设备,支持按接口、设备或全量执行检查。
+面向工控设备厂商和系统集成商的本地验收工位。以应检设备清单为起点,记录本次检查范围、协议证据与交付信息,修复后保留原始记录并比较复测结果。
-## 检测能力
+
-| 接口 | 检查内容 |
+## 围绕交付与维修组织功能
+
+| 现场问题 | 工作台的处理方式 |
| --- | --- |
-| 串口 | Modbus RTU 只读功能码 01–04;自定义请求与响应匹配 |
-| 网络 | Modbus TCP、TCP 连通性、自定义 TCP/UDP 探测 |
-| PCI | 被动检查链路状态、驱动绑定及关联接口节点 |
+| 设备断开后从扫描列表消失 | 每个选定的配置设备都有结果,缺失设备保留失败记录。 |
+| 报告无法对应当时的配置 | 每次任务保存完整配置快照、SHA-256、工位或设备编号、批次、操作者和时间。 |
+| 修复后重跑覆盖原有证据 | 失败项复测关联原任务并沿用原配置;比较恢复、退化及覆盖范围。 |
+| 演示依赖现场设备 | `--demo` 使用独立合成清单,报告标注 `SIMULATED`,模拟与真实任务分别统计。 |
-支持全量扫描、单接口扫描和单设备复测,生成 HTML、TXT、JSON 报告。`via_device` 配置用于表示通过 PLC 或网关共享连接的下级设备。
+产品定位是轻量的 Linux 现场验收流程与本地证据管理。项目方已确认项目经过测试、可以使用;[设计文档](docs/superpowers/specs/2026-10-07-field-acceptance-design.md)说明定位依据,[验证记录](docs/VALIDATION.md)记录本次升级的检查结果。
-## 运行
+## 体验演示
-需要 Linux、Python 3.7+ 及对应接口的访问权限。
+直接安装可从 [Releases](https://github.com/KaiserIIII/QLDeviceCheck_Generic_WebUI_Linux/releases) 下载 Windows x64 或 Linux x86_64 包,内含离线运行依赖。先安装 CPython 3.10+,Windows 双击 `Start-Demo.cmd`,Linux 运行 `bash run_demo.sh`。[Release 使用说明](docs/RELEASE.md)。
+
+需要 Python **3.10+**;Windows 也可运行演示及离线测试。
```bash
-bash setup_linux.sh
-bash run_web.sh
+python -m venv .venv
+source .venv/bin/activate
+python -m pip install --require-hashes -r requirements-runtime.lock
+python web_app.py --demo --data-dir data/demo
```
-浏览器访问 。
+PowerShell 使用 `.\.venv\Scripts\Activate.ps1` 激活环境。浏览器打开 [localhost:8080](http://127.0.0.1:8080),创建“混合故障”任务、查看报文证据,再选择“全部正常”复测。[完整演示步骤](docs/DEMO.md)。
-## 配置与验证
-
-在 [config/device_list.json](config/device_list.json) 中定义设备,参考 [device_list_examples.json](config/device_list_examples.json) 和 [配置指南](config/CONFIG_GUIDE.md)。
+## 现场运行
```bash
-python3 validate_config.py
-python3 self_test.py
+bash setup_linux.sh
+bash run_web.sh --config config/device_list.json --data-dir data/live
```
-配置校验与内部自测不会访问现场设备。
+执行真实任务前审核目标设备及自定义报文。内置验收拒绝标注为写操作的配置及 01–04 以外的 Modbus 功能码,PCI 检查采用被动方式。通信检查结果不代表电气性能或完整业务功能通过。
-## 实现
+| 接口 | 现有检测能力 |
+| --- | --- |
+| 串口 | Modbus RTU 只读功能码 01–04;自定义请求与响应匹配 |
+| 网络 | Modbus TCP、TCP 连通性、自定义 TCP/UDP 检查 |
+| PCI | 链路、驱动、接口节点检查,支持配置下级设备 |
+
+真实模式保留 `/legacy` 原扫描界面,与验收任务共用工位执行锁;其报告单独存放,不计入新验收历史。现场接入参考[配置指南](config/CONFIG_GUIDE.md)和[部署说明](docs/DEPLOYMENT.md)。
+
+## 工程实现
-- [web_app.py](web_app.py):HTTP API、检测任务协调与报告生成。
-- [core/config_manager.py](core/config_manager.py):配置加载与校验。
-- [core/generic_detector.py](core/generic_detector.py):设备发现与检测流程。
-- [core/protocol_engine.py](core/protocol_engine.py):协议请求与响应校验。
-- [web/index.html](web/index.html):浏览器界面。
+新增 `inspection/` 领域、服务、存储、适配器、HTTP 与报告模块,将浏览器验收流程和已有协议核心分开。SQLite 持久化任务与证据;重启后将未完成任务标记为中断,不自动重放硬件操作。每个工位数据库只允许一个服务进程使用。
-## 设备访问
+前端使用本地资源,无 CDN 或前端框架。默认监听 `127.0.0.1`,网络监听必须设置 `QLDC_ACCESS_TOKEN`;提供输入限制、Host/Origin 校验及导出转义。当前未实现多用户权限和 TLS 终止。
+
+```bash
+python -m pip install --require-hashes -r requirements.lock
+python validate_config.py
+python self_test.py
+python -m pytest -q
+```
-内置 Modbus 检查使用只读功能码,PCI 检查采用被动方式。自定义报文的行为取决于设备协议,使用前应单独审核。现场测试前先在隔离环境验证,并将 Web 界面限制在可信网络内。
+[验证记录](docs/VALIDATION.md)列明已测路径与未覆盖的真实硬件风险;[依赖记录](docs/DEPENDENCIES.md)提供固定版本、来源 commit、许可证及制品哈希。CI 在 Windows/Linux、Python 3.10/3.13 运行离线检查。
diff --git a/docs/API.md b/docs/API.md
new file mode 100644
index 0000000..6c48551
--- /dev/null
+++ b/docs/API.md
@@ -0,0 +1,45 @@
+# API v3 / 本地验收接口
+
+Base URL defaults to `http://127.0.0.1:8080`. JSON is UTF-8. When `QLDC_ACCESS_TOKEN` is set, send `Authorization: Bearer ` for `/api/*` and `/report/*`; never place credentials in query strings. Browser requests must use the station's own origin. POST bodies are JSON objects with a 64 KiB maximum.
+
+| Method | Route | Behavior |
+| --- | --- | --- |
+| GET | `/api/health` | Service mode, version, active job and persistence status |
+| GET | `/api/catalog` | Current expected devices, full configuration, SHA-256 and available scenarios |
+| POST | `/api/config/validate` | Validate `{ "config": {...} }` without saving or running it |
+| POST | `/api/jobs` | Create a task; returns 202 and `{ok,job}` |
+| GET | `/api/jobs` | List `{ok,jobs,total,limit,offset}`; `limit` 1–100, `offset` 0–1,000,000, optional `status`, `query` and `sort=newest/oldest` |
+| GET | `/api/jobs/{id}` | Full persisted task record |
+| POST | `/api/jobs/{id}/cancel` | Request cooperative cancellation; finished tasks remain unchanged |
+| POST | `/api/jobs/{id}/retest` | Create non-passing-device retest using original snapshot; returns 202 |
+| GET | `/api/jobs/{id}/export?format=html` | Download HTML, JSON or CSV with evidence and snapshot |
+| GET | `/api/compare?baseline={id}¤t={id}` | Scope-aware result difference, comparability reasons and coverage flags |
+| GET | `/api/insights` | Separate `demo` and `live` counts; passed/failed count completed tasks only |
+
+Example task payload:
+
+```json
+{
+ "station_id": "DEMO-FAT-01",
+ "batch": "DEMO-2026-10",
+ "operator": "Demo engineer",
+ "notes": "Synthetic walkthrough",
+ "scenario": "faults"
+}
+```
+
+Omit `device_ids` to include the complete catalog; provide an array of known unique IDs to select a subset. Metadata strings have 128-character limits, except notes at 2,000. `scenario` is `healthy`, `faults`, `timeout`, `crc` or `missing` in demo; live accepts only `healthy` (the scenario label does not predetermine actual device results). Metadata may alternatively be nested under `metadata`.
+
+Retest accepts `scenario`, `operator`, `notes`, and optional non-passing `device_ids`; it cannot change unit/batch, widen scope or switch mode. An empty eligible set is rejected. The returned task has `parent_job_id`.
+
+## Record semantics
+
+Task IDs are 32 lowercase hex characters. Times are ISO 8601 UTC. Status: `queued`, `running`, `cancelling`, `completed`, `cancelled`, `failed`, `interrupted`. Execution completion is independent of inspection success: `completed` can have a `FAIL` summary. Each result includes device/interface/protocol identity, scope, verdict, fault code, summary, rule-based suggestion, request, response, duration, `simulated`, structured `attempts` and `supporting_checks` for parent-carrier evidence.
+
+`communication_path` describes the configured connectivity/protocol check. `pci_passive` describes passive PCI observations. Neither establishes full device functionality. A partial retest's successful summary only covers its selected devices. Comparable repair conclusions require matching nonempty station/unit IDs and matching batch, mode, configuration and evidence scope. Differences remain visible as observations when those conditions fail. Compare flags and report coverage must be considered before drawing wider conclusions.
+
+Errors use `{ "ok": false, "error": "..." }`: 400 invalid fields/configuration, 401 token required, 403 origin/host or mode restriction, 404 missing resource, 409 station busy/service conflict, 413 oversized input, 500 unexpected failure. Errors omit exception traces and private paths. Do not automatically replay a hardware POST after an ambiguous connection failure; first inspect current task/history.
+
+## Legacy interfaces
+
+`/api/scan`, `/api/scan-interface`, `/api/run`, `/api/test-device`, `/api/standards` retain the original live interface and return `legacy: true`. They are disabled in demo. `/legacy` is the compatibility UI; `/report/` serves existing report files. Legacy operations share the acceptance execution lock but do not create new SQLite acceptance records.
diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md
new file mode 100644
index 0000000..652719c
--- /dev/null
+++ b/docs/ARCHITECTURE.md
@@ -0,0 +1,53 @@
+# Architecture / 架构
+
+## Data and execution flow
+
+```mermaid
+flowchart LR
+ Browser[Local browser assets] --> HTTP[inspection.http]
+ HTTP --> Service[InspectionService]
+ Service --> Domain[Catalog / verdicts / immutable snapshot]
+ Service --> Store[(SQLite task records)]
+ Service --> Demo[DemoAdapter: synthetic only]
+ Service --> Live[LiveAdapter]
+ Live --> Core[Existing GenericDetector / protocol core]
+ HTTP --> Reports[HTML / JSON / CSV]
+ HTTP --> Compare[Scope-aware comparison]
+ HTTP --> Legacy[Legacy API + shared hardware lock]
+```
+
+`web_app.py` parses station options and owns service shutdown. `inspection/http.py` serves an allowlisted set of assets and handles API access checks. `domain.py` defines validation and verdict aggregation. `service.py` owns the worker, state transitions and snapshot-based retests. `store.py` commits whole job records atomically in SQLite WAL mode. Adapters normalize results from either synthetic devices or the existing detector. Reports and comparisons operate on stored records without accessing hardware.
+
+## State and evidence
+
+```mermaid
+stateDiagram-v2
+ [*] --> queued
+ queued --> running
+ queued --> cancelling
+ running --> cancelling
+ running --> completed
+ running --> failed
+ cancelling --> cancelled
+ queued --> interrupted: restart / shutdown
+ running --> interrupted: restart / shutdown
+ cancelling --> interrupted: restart / shutdown
+```
+
+Each selected configured device starts `NOT_RUN`. The adapter preserves missing devices as `FAIL`; task summary aggregates `PASS`, `FAIL`, `REVIEW`, `NOT_RUN`. A cancelled, failed or interrupted task cannot be classified as fully passed even when previously emitted results passed. Progress advances after device results; live carrier/child groups execute together. Cancellation waits for a group boundary and does not forcibly interrupt a serial/socket call.
+
+Task creation deep-copies configuration, hashes canonical JSON and commits it with metadata before execution. Results and events persist after each update. Retests use that original full snapshot and allow only original non-passing device IDs. Parent IDs retain the evidence chain. Structured attempts preserve every attributed protocol probe, including earlier failed attempts; child-only results retain parent-carrier supporting checks without adding the carrier to the selected result scope. The SHA-256 identifies configuration content; it is not a digital signature or tamper-proof audit log.
+
+Comparisons check unit/station identity, batch, mode, configuration hash and selected/evidence scope. Related failed-only retests can compare shared devices, while declaring partial coverage. Communication-path and passive PCI checks are separate from electrical or business acceptance.
+
+## Ownership and compatibility
+
+One process owns one database, enforced by an OS-held ownership lease acquired before recovery. A second service cannot recover or execute against an owned database; process exit releases the lease. New tasks and legacy calls also share a station mutex. Do not operate the same physical station through different data directories: database ownership does not provide hardware scheduling across separate databases.
+
+The original `web_app.py` implementation is preserved as `legacy_web.py`; the old UI is served at `/legacy`. Legacy reports under `report/` remain independent of SQLite history. The configured active TCP path now assembles fragmented responses with bounded reads; Modbus validation checks frame length, byte counts, identity and expected quantity.
+
+## Local deployment choices
+
+The design uses Python's standard-library HTTP server, threads and SQLite to keep a single-station deployment small. This is intended for a trusted local workstation, not a multi-tenant Internet service. A network-bound station requires a bearer token; managed TLS, user roles, retention, signed reports, distributed scheduling and production service supervision would be separate work.
+
+Demo mode constructs an independent catalog and never imports the real detector through the normal service/API workflow. Scenarios and exports are explicitly marked synthetic. Hardware operations in tests use Fake adapters or loopback sockets.
diff --git a/docs/DEMO.md b/docs/DEMO.md
new file mode 100644
index 0000000..5f210c3
--- /dev/null
+++ b/docs/DEMO.md
@@ -0,0 +1,26 @@
+# Demo / 演示验收与修复闭环
+
+No hardware is needed. Start with `python web_app.py --demo --data-dir data/demo`, then open [localhost:8080](http://127.0.0.1:8080). Keep demo/live data directories separate for a clear operating workflow; statistics are also separated by stored mode.
+
+1. Open **新建验收**, enter `DEMO-FAT-01`, batch `DEMO-2026-10`, and an operator. Select **混合故障**.
+2. Create the task. Three synthetic devices are expected; one PLC has a synthetic timeout and stays in the result list.
+3. Open **查看证据**. Request/response, fault category and a rule-based suggestion remain available. `SIMULATED` means the bytes and verdicts were generated for the demonstration.
+4. Export HTML, JSON or CSV. The report includes metadata, the configuration snapshot and coverage. HTML can be opened offline or printed.
+5. Select **全部正常**, then **复测未通过设备**. Only the failed device is selected; original successful devices are not silently marked as rechecked.
+6. Choose **与原任务比较**. The shared device shows recovery; the page explicitly declares partial scope. This does not establish whole-unit recovery.
+7. Refresh the browser and open **任务历史**. Both tasks and their evidence persist. Search by station, batch or operator; select oldest/newest ordering.
+8. In **配置校验**, load the current configuration and validate it. This does not change the station configuration or execute device checks.
+
+Additional scenarios: **响应超时**, **CRC 错误**, **设备缺失**, **全部正常**. These are deterministic demonstrations rather than real protocol measurements. Demo mode refuses legacy hardware APIs and does not load the real detector.
+
+
+
+## Browser regression script
+
+`tests/browser_workflow.cjs` uses an already available Playwright installation and a disposable running demo server. It creates a fault task, inspects evidence, downloads a report, retests, compares, reloads history, validates configuration and checks the 390 px layout. It fails on page script errors. It is optional and does not install browser binaries or add a production dependency.
+
+```bash
+NODE_PATH=/path/to/node_modules PLAYWRIGHT_CHANNEL=chrome node tests/browser_workflow.cjs http://127.0.0.1:18318
+```
+
+Screenshots in this repository show synthetic demo data only. They are not records from an industrial customer or device.
diff --git a/docs/DEPLOYMENT.md b/docs/DEPLOYMENT.md
new file mode 100644
index 0000000..2e60569
--- /dev/null
+++ b/docs/DEPLOYMENT.md
@@ -0,0 +1,32 @@
+# Deployment / 工位部署
+
+## Local station
+
+Use Python 3.10+ and a dedicated virtual environment. On Linux run `bash setup_linux.sh`; it installs only pinned runtime dependencies with artifact hash verification. `bash run_web.sh --config /path/device_list.json --data-dir /path/station-data` starts a live station bound to `127.0.0.1:8080`. Add `--demo` to use only synthetic devices; demo ignores the live device configuration.
+
+Use an absolute data directory when launching through a service manager. Task records and evidence live in `jobs.db`, with SQLite WAL/SHM companions while running. Stop the service before making a file-copy backup, or use SQLite's supported backup mechanism. Back up configuration separately. Protect the directory using OS permissions: records can contain unit IDs, operator notes and network endpoints. Retention and automatic deletion are not implemented.
+
+**Run exactly one process per physical station.** The database has an exclusive OS-held ownership lease: a second process fails before modifying the first process's jobs. Separate database files do not coordinate concurrent access to the same hardware. The `.owner` companion file may remain after shutdown; ownership depends on the OS lock, not the presence of that file.
+
+Restart marks unfinished jobs `interrupted`, retains evidence and does not resume hardware operations. Cancellation waits for the current device/carrier group. Device timeout settings bound most calls; service shutdown waits briefly, marks remaining work interrupted and rejects late result updates. Validate driver behavior and timeouts on the actual station before relying on unattended operation.
+
+## Network access
+
+Network binding requires `QLDC_ACCESS_TOKEN`:
+
+```bash
+export QLDC_ACCESS_TOKEN=''
+bash run_web.sh --host 0.0.0.0 --port 8080 --data-dir /path/station-data
+```
+
+Enter the token with **访问凭证** in the browser. It is kept in tab session storage, not a URL. CLI clients use a bearer header. The built-in server provides HTTP, not encrypted transport: use network access only inside a controlled station environment with suitable transport protection. Reverse proxies must preserve the service Host/Origin behavior; arbitrary hostname rewriting or TLS termination requires deliberate configuration and validation. This release has no user roles, multi-tenant isolation or public hosting workflow.
+
+## Device scope
+
+Review [configuration examples](../device_list_examples.json) and [the guide](../config/CONFIG_GUIDE.md). Acceptance creates results for all selected expected devices, not only discovered devices. Legacy scan remains available for diagnosis, but discovery success does not establish acceptance coverage.
+
+Use an isolated test station before real equipment. Verify serial permissions, network routes, installed PCI tools/drivers, carrier/child addressing and response matching. Acceptance rejects explicit writes and Modbus functions outside 01–04; arbitrary custom bytes still require protocol review. PCI evidence is passive; register reads and response patterns prove only the configured communication condition. Electrical actions and business behavior need their own validated procedures.
+
+## Compatibility
+
+The old UI is at `/legacy` in live mode. It shares the station lock, produces original reports in `report/`, and is excluded from new task statistics. The original helper `preserve_scan_interfaces` remains available for the offline self-test. Use the new workflow for persistent, configuration-bound evidence and repair comparisons.
diff --git a/docs/VALIDATION.md b/docs/VALIDATION.md
new file mode 100644
index 0000000..58477df
--- /dev/null
+++ b/docs/VALIDATION.md
@@ -0,0 +1,48 @@
+# Validation / 验证记录
+
+On 2026-10-07 the project owner confirmed the project had already been tested and could be used. The details of that earlier test are owner-provided context. This upgrade's reproducible automated validation uses synthetic adapters, temporary databases and loopback sockets. No real industrial device was operated by the upgrade automation.
+
+## Reproduce
+
+```bash
+python -m venv .venv
+source .venv/bin/activate
+python -m pip install --require-hashes -r requirements.lock
+python validate_config.py
+python self_test.py
+python -m coverage run --source=inspection,core.protocol_engine,core.transport -m pytest -q
+python -m coverage report --show-missing
+python -m compileall -q inspection core web_app.py legacy_web.py
+node --check web/assets/app.js
+```
+
+See [DEMO.md](DEMO.md) for the optional browser workflow. CI runs the Python offline checks on `ubuntu-latest` and `windows-latest`, Python 3.10/3.13; inspect the linked PR checks for actual remote outcomes.
+
+## What is exercised
+
+| Area | Evidence |
+| --- | --- |
+| Coverage and verdicts | Missing expected devices, unfinished tasks, adapter failure and non-passing retests |
+| State and persistence | Concurrent creation, lock conflicts, cancellation boundaries, restart recovery, bounded shutdown, no late passes |
+| Configuration | Snapshot survives file changes; original snapshot reused in retest; explicit write rejection |
+| HTTP | Real ephemeral loopback requests, task lifecycle, history/search, comparison/export, malformed payloads, caps, traversal, Host/Origin/token checks |
+| Demo isolation | Subprocess assertion that the normal demo workflow never imports the hardware detector or legacy server |
+| Protocol | Fragmented configured active TCP, EOF, timeout/reset with partial evidence, Modbus CRC/truncation/identity/byte counts/quantity and write refusal |
+| Browser | Fault task, evidence dialog, report download, repair retest, partial comparison, persisted history, configuration validation, desktop/390 px layout and page script errors |
+
+## Recorded results — 2026-10-07
+
+- Clean hash-locked environment: CPython 3.13.0, Windows 11 build 26200, SQLite 3.45.3. `pip install --require-hashes -r requirements.lock` and dependency consistency check passed.
+- Pytest: **85 passed**, independent clean-environment run completed in 19.90 seconds. Includes cross-process ownership, configuration-injected legacy requests, stable demo outcomes, identity-aware comparisons, every-probe/carrier evidence, incoming/outgoing TCP framing and response/lock release timing.
+- Line coverage: **91%** for the new `inspection/` modules (693 statements, 62 uncovered); **85%** for the combined inspection/protocol-engine/transport selection (941 statements, 140 uncovered). These percentages do not cover the complete legacy detector. Windows run leaves POSIX locking branches uncovered locally; the CI matrix exercises POSIX separately.
+- Original self-test: **23 offline checks passed**. Configuration validation: 5 root devices, 1 child, writes disabled. Python compile checks, JavaScript syntax checks and shell/PowerShell parsing passed.
+- Browser: Node 24.19.0, Playwright 1.62.1, installed Chrome 155.0.8059.40. Desktop and 390 px workflow passed with **zero page script errors**. Structured evidence rendering used an explicitly labelled browser-only fixture to verify escaped content; screenshots show normal synthetic demo records.
+- Release candidate `8872651`: Windows package extraction, fresh offline runtime installation with `PIP_NO_INDEX=1`, launcher argument forwarding, actual localhost server, fault task, export, healthy failed-only retest, scope comparison and restart/history preservation passed. Final bundles are rebuilt from the final tagged commit and checked again before upload.
+
+## Scope and limits
+
+Coverage is collected for `inspection/`, `core.protocol_engine` and `core.transport`, not the entire legacy detector or repository. The original offline self-test adds loopback checks but does not establish field hardware compatibility. Browser tests are separate from the Python CI job and use an existing local browser runtime.
+
+This automated suite does not measure serial driver behavior, electrical performance, sustained station load, power-loss filesystems or custom vendor protocols. The earlier usage-test confirmation is separate from these automated results. OS-level device exclusivity across different station data directories is not provided.
+
+Reports describe configured communication/passive checks; certification and customer performance claims are outside this test record. See the Release notes and PR checks for the final uploaded revision and remote CI results.
diff --git a/docs/screenshots/mobile.png b/docs/screenshots/mobile.png
new file mode 100644
index 0000000000000000000000000000000000000000..0b9d28cad0402c363e3c9b5b4173aee877cafd15
GIT binary patch
literal 88823
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 RzVdK5)_y;pBXTuxWM
zeDW!kyzi}`-s)BGatJ?+WK(+l`r*Uluh{aXflYYn%6{vRHkHAo3)$~^d$B1m{vDZkh@@aujS;uv37md%k@`PcXHriiM3eBkDy@q~mSxm&zc2
zv4@N4{@UJE9;2L$;+O?L;(8>?tO5&%&$UG8)uypAOG9@u~H2-_7W!
zRThrL`j)gmrEO4_cP4||TlTo%G+)|3AD
z?}5J!Q&J>jn|S3uOg&a9nTEKwnH`qQ7M0KU3DP8nU<
zXk+DNGghflpkY+~MQ=GL-S$<>cPx8{Fv24)Psb>#E+~rLFF&b28&Hn}@0}AWV7Mj>
zNPhKe^GuKp^Eb?8sHI~e_$y6anE%D+JXyfM=c2zfK~on)m27V~rJy)L=Ov~2shWRe
z*(n@vS-t)%3>5hIu)fQD7R5I5SMu_!@K0O+PTSY(HbO`EXhOvndxWM0jMH7PuffLa
z*N5~=e&(=aF=!DGviokYg>CZMR}=vrS|nB=*t~am&Y6
z#WMxBfYZ{c+hKN?Ry2X5gt1pV;x`S$FeYmg*&G)=n?m`OX>cWhgR*oEq!%t-(ZfK|F886|BC_t6LkcvhV+kB@A%h_
zSQq8>Br(8S^Wat;JLtXH^X8{1c<15}pjKK|JQA=rjHW(t>|IXMSz8^_TiM
z!-;wamwJ*;ye@y1W*@_SBQ}Sjc~#+85!sylp8omjrs@rk1gp%Fyd?^jFJi-qb`s;uV?D7k_ILjavZEfw2IW;lEm
z1q5i!(k`xU1niGJ?0B|3fPVdHaQZ2
zT}}7kW{1Ljywrk&v(o;(eadT$RC|YqC7lvw-ixF{-9sh91iOtIAC+y9T6Jwj5|y`~
zKTjOhm_1|NPP&X9^h)haVdgpV_PRoSlAp9XuQMAHATalRdj
zO5=tvyWT2zjj@xkX#=3jk~Mr-6#rq-Cw|$&D1yA(AT~U{g8`qW-Z7X?E)UVVU|gf>
z{bVfh-iXH5vd%<2mkid5k=~i|wGA{~d;SD+cjMhrNvr(_O4}|0-|1DJKR=xS6`a>C
zEE9z_ddgy}Cj-;2Os$?LW{2cV0Tv;Z>qpG4oJ;uiR+ZTOWc8ls2gl$CYf}L^RvdAl
z>fZJq+<9-5(Fv}|4@HYEQhb5l|}U53qb^29Ikz9&1o6(57I=R*4jMogYT
za4{m@S4*aS)ML=X4stD*@zlM-0|{8dK0kz#%DuySRJ`(L46}ud*a?FC#}H_vH;91F
zMz_V(enb^HvcS0^mTn_dW-pHiOaJ~`+;0c`1;MJoznspg_fnP&XD_pl5idaE%ahWG9GRf#5kTPRdKd
zsgCmrb4UwInn(?O*z-QR+#lRS#RSpy!Gr*eE{=Vc@9HwTy4)$R6DwYLf5P1o
zaoXHJMfw|8QsFXB>(ieQaFNSr_>
z*MHk=;_tt{Z`8(!8-Ljan}k^S)ku-7l>d3uJ81+i?6AKAL-Bif@mG$c=AUR!-6
zeL8N19u&TrZ9aIvoPYKbn7cI<2rW;Pjd&7`EOxmPUnd|Ea6s6X+G!Qp-T4rt6Y
z^oSW|?sHxoiM@m~tN?&;P4NKp45CL{pw^!b32Ljwh4
zWX+7_^aD!T{`F)_79Mq;H2+Y0$F{)iogiEK^4<$Eul(7TyJu&*2{dd#y#Rn(GUa9A
zBCMqY!oxDk%VdA@)8Syz-!45h?;?T=wF5o64l5SZQG0eYmd!VCAK<}QnRP6BEI_MB
zoD(4K`;dmd61{yx`_opb^l)f4OP9GvpRxfIV)i90OcuL6n()OhK2WRb_W*?l7b;Y%+Ujv%t#*Df
z=Uo_-2B&`(ilH1elAKA2S3+FdvDX7^uUuta@cyoYT3sI>?t<%s6q}k4w4>kjD%AWH
zluX7xp_2FQV@*Y-1fL4d{`~6ND!)>~F=VCFtA_SLV{qJj2Rc8fkAK!h*EVan{M2EM
z|0$Zi%Rs(j254wV%S`(qjn?S}9^0gS4|44re^Y@WHKz{|S)s%WAsRNCa+NH7J@2+_
zk8a=E7P~y$zGwVymQJY8zx*dQ?KI^@u?3La@fVb=zk_jiW|mFnj-P=zC-#_(|+1|s8xGXqlZssNc-<$3^(r?o
zCuwJfChN00=dYw}&y25!p;&t+a^BQ&`MTgcgxiGDW`Y}#ua}R%9xD^!wb=;9PTP7{
zEqk}ydk~bK_a3WmR>}R_-R`C@7^IWpC~f&S{@q&rNmyDy<9AoiAAro_S}11U2o)$L
zs*|vmB0B-J;S1tOILr^}xW2RVBa+~q5|b?D#l#|o2b)`1h~P3^>pXyNWF%It7X%)>
z{>3N;cK|5P`m%m)#iSz_eYY>BBsqnb2sVDX%_G04b=D=y47iYLs0Q(
z+zgBy?c#VVUl8=>_b0d)0U!&lL~uyXWTm`s#D4_<7H;&sI}K$5>ljZ9kGWvPhuZ7C
z=hv1{DPDX`PV8cPuk-ZY8O~fLoh?(_nrKf6|I#Koer+irZUxhp%|mS^@JW4$Mo~UE
z0os~0qtYDj@AtiBO?Il7rkS8N=N~sSDeoq_<<@IWt<{GW+%SkT6N7--Blf9)2=%*K
z`xGWo3qIvz5Mi?&wL-<#=)^EB$)mebGc2~*OI@J*Srhcr$&;hc@qNPXP&6D~
z6YaSJ7Ybxfy(X}d48Ntgu&>l*NIHdi69y2d(zqbt1h88gi+_;EH@gH-l9CSDRI>!A(Nj<
zq-iXMU0(7wVgr{3#9bc-;AGDO*^D10W;Dc-B>bLgpwV0&Jf4DmSwbWjAv%crGAOwP
z1iW9vh@mwT1n68h{{kv-B~qwY<;&CgmNp_@)0zU@X$#0GZ$<~wBe38ZGpJj11eQ)^
zt=Lu4Io**nsI&3
zjEzP~FS|}H^kqC}EotonIx`!y^#J+y_`duN2)CZF9JYfi!YKLs^}4`kp02H86DGSS
zc;EJi7QT5e1LI!d0$@=mUgIC9@wrY5h38oT-A$eHXZVJMSHVn`fnYrWiiMah7a
zf(=bSqT$`CMCvsJ1kHEo6bh79m!~8KM`@43kZTgGu1#vsQ^JysYA0}W+Z7!4u6;#yRpcflKzmpf6((Zi^Zf6T`y1w;E2uls(4q3`
zgVdP68&aSwn@wQ7y<}lMr6|&%b-4=EVcXseuAnzzJ%9NK!W~H|)Z2L$l#Puhr}vJ;
zMN+>jyK&sADxQed0jU%MjZ~+c9068e?%p<)1=7K4myM4Ew9jj@ixD#J>s3B#G!A($V#_Bj*(`pWDw((C@s
zKZZL??_`L4qfLoAHk~c=zqx=cjBabPBL1`HqVd`AA;UMff^P#YdP#9Ol5aK!w3in8
zQ^?QXr`@!hzhG8X{B`Kg{y52Zdxw5z3Xsfrqa)Mc|V!RjC?;y)owJiaco#7`LJicnzuhhO_6T+{GL5!hFJZ8=bIWCv%jZ{+Igadr
z7|XyFnMku4m+x;cKj#PSy#qLx-L(DOd-nB;#bIYuGt+kqcljnT(Ql75I!2@gq$7;)
z!t@0?r{!KUkv<#vnhn{^n&18znG2u2_ApB9E3ZDEb>}?OAlH@LopTg;J=$cHTK@4fUFN6CweBW4q5O4QZf$l;wNkj+#n?6OgrR{nU-Md;&z%N
z8$ZZ!u+iA08-xDTx(r@TXdTe=W0m4Lp;v8qe`6};Z72Zlb26e3
z&`<=u4T3!a7f_0*;Wz)Ll&javT~|?uW&psaP&^@F4Q{1b?~8*8RbL}iifq@f+VtC7
z-XzRkMnJ3Z5p(1rT*1t?*^@CN8@jZ*4!9+*4Dl~b((sHr?as%)b>UvNAzZE9-j}lq
z=*1<4V;W~&C4nHxTWbM`jK*gg-;MI}3p4${kCl_)kQaW>ks%8k&ua*D8jkPSoEzJ)Ei?#cyB0&V2Q&Q()Gcxf}8dY`YHS&Je
z%M5NLpH_Nh)Vi|3w8X}ZsZ~0v$HUh{UTZO{;V0kUbXgHurAfs}sJ_(Rwx+UIH>
zLBk%^%iZSF`D@i6RqESalHDuKhM;BxIMK0>j$%}fqa!}JL8sEip54#~9?z8Bu>yAT
zB80JS9Zc*XvaSCtVtHZXZnV@JMV-;NMvk;Mx{-$HWRqf&jrDx5m2P>?@4jx3
z+qYaXU7cU!c_}fW?^}>&IVI`~P6h#Ry!d3d_r7R7B?Z6Qc~&SX6=e%be`mYeO=z9g
z8^pW1V+R^<(uJt#u^j8!u8X|eTaFpAdB<|XLPfX}O`F(m`mcM-AKX%x*}zvKVp>e9
zR9`nYx}8G5@ep(FwYGdU8LlC>1mf1ITt$R<1!_6VVIWndnu(0HxU&4wHKJ6DrHrAe
zEmQMAyS2fo5VdC*q_T{eVc*oPx45v`fL8FtE!&`pl16pPiEL{TI(JgB@8g`u^v7Tt
z$l(wfkwRs=3+rssJcY(V^Tbe+e$(1JE}gM>)Pa>SMV|T9(+-Q(Rf&Ar2sBa?7**0`8i4I={kJJ(EXC3)54r7oGc#wbs&HIjEfT
zYrr9fzr(23E`X+@2Ds-*#S1kmK)>d8Fnn_mu551AG`wTJ+>o>ONyYOI2b@eOR$!mh
zfNMZ1ZA*>QXeJg$o_x#1D>cL&uLL8s;~;BQpFTTubFNB(85tRDylDae8qFE
zeRJ^4%^#JlL=yhglrp2sLneoHqce<#FObozH3-j3)3e9OJS_4b$^5|E`(+v7yBpa0
z9DS2KfP*y&$8BPS5sIcf@AkHtQTDnC?}@(W*t&dq&%r}!Xd}kjr9Lu~^;ct=B(EBE
zrsjfFF4K9E&eyYPQ`(W@`YT-5#2;6`YfR~gCLLIT*88;K0LQ<&O~CgSE6S?VBOKi}
z7af=|f&_rpHS?mip*9`1h-R(#-WwAk9gQr`{vVp9osbI=0M!@(JLeZ1Y+DvLK+OUm9&wRGM%qpHT
zaM}FU!OaoForfOC@0+|Do`1Sjg-g~XyJc<|k^r-5dhv}6Yavxj@%lY4Wf7lmuc-X_
z?9j8PxS1|#wVx)?Q?cgD#CcM@?Y`{gJHoygFt1+j0)L9~$pvGVN@R;@k6^v^&s&t<
zKqXd|a(ftcdf?!OXZpAP{^UKC6QET__(BpK@?z`HIVs>fgF^wn3@c4l9H|&h%cKQc
z)#4eTw1z?kAIHV#se{NiN$N}tf1F>kK(9b}(m|;Yd+3OZobm4eC9ZiLE
z#!jX`Yr4s8r%i^|YIwlQCNOHNq%iVQ_{|k83`xftQC1lQ)2y0YKZ|c^d}s
zDF&14wtY|9l(v#uJZMkFGXJ=6l~m8rjg5lKVHXr^Q_WM;-_iC;*nwP(6umvO?VyQ@WwZ$?0k<`u#Z~n
z%OjG?{kto5#ojp4-=-YiHqrrybeDvFlXqQIIptkw6TOm&sJihHz(Z*%@Ho-d0{Zpl
zYc3b%B^~^JEwqM5h;;Pr8@XnId?aH><&AXK&UH+BQnt8$g)f3>A-^&8iPVc!5R(<1
zgk14yczV+zVYLySf8`9G-N%VLhf%WE@R?fOve}_MA+tHzOj)^9+78}p2{$RTC+^Xk
z=g34PnBb&x7kTZ653!s?n^1)oXp{wM3NFf-PI-nV+chA~*#NkKd9v}hI9G%{M)+oDXI8da|Qw?kQ*k=b$02wiHl~B
zDJgZ-^U-ohT}XNI?l>?Eq>^l9u)jECY0Mlvc-_)=k-AqJNK3dQ!ar?5rrQ`&JU}
zD=oKey~@xqBFAD_)lft~05CJb}bgHc7x4>HluMq>
za!VmC?}QiFwGNqY2J{RYU|Nv1h}t0HOANnTb6={Mu(^G>I)RTm&9=xy$&9s7!(_^=sx`JwLQJ+9bjI$Oy$p5kED
zbY|zws_6Y`|7W_%$%f>kqHE^_mbVfd36u<-?4eQwiQm&(XNS*?V%S&MC&KNuk9nG5
zC11`QOhZMd*bF~q?v*R#{Gx$rG@eAitNL6imO+l%2f2u}K~|JpGP+
z)$Z9NkMczR;ZUEFCP1?zjO~d!_4;JL_Qb?e>z_E~9pV9BpSc$YJf{Y}`(`p6d;c^U
zm{)?vyd0Z#vwkw&emsrfZ_H7MCsyblH?ssDOWmzOWDMD?Tdx{h0YbNY^P>bBqKZXm
zYQBuq-wL)}wWm^F*;tiN_Ocx~gU!M`>1N9gDJs;85K?c|adAb+;%T1b$C6aUKe0nE
z=7H4D7OS$N0F%{sfxX)JJG33yzhZ87tru&+e!c6&2~1^?TVIYnuSxMw^h{~^wm-DL
zJcuC}1Z~>X2NO>LoJ-)p)WIvF0n4+7|kM0hKjAgULoCKaF>f#b{{g8slfXuPF*B&-p4G`qA$fsLcdF9R+J2iE1QUzMONj%hB
zuR-u`{I0s-QQKO_DqKm>Zuqn9TJT0_aB~C)b)g+29dS0ffY_^(NIa7HkoY$yc5}16vGep&uC%P%I0)Ymea{VClD(Vd={7%J@OgACt?UbjM
zH5VHmWT0RJ_*Cjwq;h8)kCTzqB+7U(MGmhXzu&=ra5Q=Isxu9i=ZlA$(~<(zXWEFP
zo75i{5Ln<=Gy?Gs9A|0tcya3f_(++LoMHx?Sy2jX`Y3+=P!!nYoYzSq?NIWm9vSR*
zUUbXi7KSr|J}T&fEe@wwokxxFbwqe9Ep~YHR!@`aKFrD=;JaU2?N7ih?2vkcgG5!U
z4Hkk{njEb9lcS1C^ubSjPd~!^$F}|dAlLm5GTvM-5T5Wel_%1C%VR-r&6eGZ
zTn|vFd5-ThV3q%tHf*6YwVF+Xwt=Z!=Ygz&h1nS`YcL-q3F^2#pZT55B3?mLwEahz
zRA~ff&1PSi$?m>eV?(P=c0t9z=4-UcIp)23t
zXY@^XkN=#}ebb}w_pZHb+gfYB?|i09go-4wndBTL)HE7F3ROT0CvWD9LMU?%gt1p}
zOe?CcCOya2sXto%o^<-gu6SWXf3uD$KFE8fRj8p@v6Qped9V#q8cy9xbM^P+86-fOb%++N4PCp#|}852%PudJVJJ(rWH0Dw;~a~q*K
z$38Y0{TS9VExM9SW#D3!Uxf4PG(%xrZQYA^)@(twz%9)QMTd~=Uq{eI3U_TU&FS|N
z3+;0hG*+CGBOrcbwsvO=MH+)c(jOd1f~uvu+Prw{D5LKyP0g5%yyisO@{#C=n`GaE
zpKD5IFU<4^>LKln$I&xCww?DLW5W<3l}2hvcj_cT8PHEiN_lj@)l{`db=)`B@uk>)
zz`%^y8D2I;I0D~QLSp<4n0>$Ml>T57H1?}%dBI6M4o=D-F$?NpgxZ+_23)M1R>MPc
zJCA*ymRnvZD*?a`z(lUr+Qw2HFRtgaVwhM4R=AmjM60W2zWmd3G)^<6G*jZS#2Cer18BUA7)6R-M;Z@wRAcpL
zql>Gs2QrXMnez-{34<(<#$)C4gJXkGk05cQQ4p(hr}tJU#HB)SM)qd9W;j(g31DGw
z;Zd|Tv?lvNM;wp5G}(N>t^2X>&)@W6n9UMKO}VM+oL`Scx-GGNGQ$N-`-%`0N0T&s
z|CsS#YlihL=JogakH6X7Ah$_F)uJw9h>ybsSwH~gF=jh7^xYnCzR>vIiTk3Yd~n>c
z!(zPAsqMJ!!!sPNeay;C@iwdI$CT>CPF%}K{q2#}4)cH60IR8kk;b1aB
z%^5M&r
z8#r_^6kL7_a3vPe;?}yE>DjribCfT|uc~ruOHyo3d|T!pE5RT1poUDIzqFoknqT4&
z|K=e5U*i4zPo(4jK@;+*M3oeMxmM|Yg++byk;VB7+Q~SZ6q(&wKI`zhBSW0}yut!-
zKjU6|zs^(K6!8*%zB}CL(BTzB+b!Fx>H6K>8+Ln7I=9xbc)fh~`g9Y-Gd*zLGQUxE
zzWkc@VdYgN0^IH=(NI?2boSOOv*PP5d2IRJ_tObwzRX8g>DZ1QS
zaCKF72@Ejk6M=*hkCP_DaO?uA?1L#g5#|XG=qoaCL0li=;V7`ASaOs8Zu_o%o-GUk
z9@PQi%4n?tpJ)D%@ES$Lzj6PiiC|D***dJPP_P_=zk)MNwwMS+9nt?28~MN0@#qpj
zr5CIBy46Jz1lJMWQ2}vNT%UPe@@@vhy{UItASN*M&7We#T
zU20XN!U{!(G2fV8TOSRxv6HXo!W=H~8p^JI2b
z#wg!mZ_12I^_i-Y50;-{Bo**H9+GIKGvHX^pU{HbeGFk4Rd4bcl4q_qkp%ikbP%~w
z+4!^W9p?e`!zZ||Ria4@=?zPKY1iNsxJNDXt(o*Y^fInmz98DqWUCPaFRjk}u#j<*`@48Ym)OW6
zO1Zg{emu?R)I(>JZdr=eV@Z8Q^S9*G&e!ofe3yu6ZXc2N#7`*a?ViAcnG@&1~Kcf?{gSIKmUeKGc^Y;PMnL)PPnyJIzUo
zZT{8hjaAmD14NvHha9BkRZvazGMX~1+^@A>$)l%sGkvoxI{Ch-DT7sRZUcLWrQ6P+
z0idKYq;XUzh3D53w@M>CdGF*f_rYI$f40IlW+hT<2rzl_L-OfL^E_PtMCx>xVlhnR
zB>AogcuijMvNkWJ?>(c07q6yQ8!L_ZikUt31)vbbb-Dg2Vy?8lhY*>r~QDWZ8I*W1w+e`?c&i5|irwZhbROmc$V?#U@YrK;`sj_&TCK
zn4lIE#q@L2%trO9^g7Fn1%^^C3mSOefu%K96)0El3C*M9Cc3pH|dLFsN
zG}~H2`D{8Qe0O?oYRWciELX4^ngGHH&Ggjr;ps@VXnv)JUrpk-!{HyoiBt6AG~Pi)
zKR&`FU?&g7Yqb~b_vJIg<|j)#vhqS5UZyJH?vr$o82+>;SQIfbG7_2g8)DU2)rT(B
z(`C9svGF@5Iw3JH%W#kPK9-_;scc~0&h^|cGl6=1_h45Uru~XB6CvMD58XyeJB&~
zlBYc)pv~iI{EXBHTB1!uaFP8+mO7ODa{jo@Q7+imK^>OTzI2#Z%)rx!WxQ%sLK!9p
zDlI%mD;LzCvBR2(GmIv_Vl%f>oY>8);2^EyyMCI}w9vhl!+#EM|Eh~(U}Vxgl-Bpi
zc&13R&N9{%Jcu8`(=)D0^lr;ltN%4`#!}o4G}dQP79Y}vBGp#HRLuGLGKl(-HJYMB
zJO*`|-g3L-aPu|B7f9GB$>`-!;3>9&p6_cJTxzG$R46-sw>b(pON29hT=oDKX
zH1*JE^}}7A#fRq=FUrC8=`W+ev_|#Cjk3zJ
zcD2omMScIk)Wd};ovw`zB$uaS>-&%6v*I9i4e$1=&+E3A#Ajfl2{W@(lgl&e)PmI(
zjoz&?1PStoDq*amB;Mn*`HaJ3i~Hkig=RZ48j{yr&C@LJy?JhT!m|wg!@$rZH~>Rg
zWp%rbJ%^aNMOUYh;94v^zYe$iS+eGIy{TR9rp+UZ4;@5oXuxM;1S3*(VFEKdrioxN
z?V^hL6pOt*5W8TBbP@cIseX6k4Ww@tu=)SlP9DH|mD!GjB`VOKehPrD?}Y-R`r?$J^xdF~6!
zz3~jF{x*>a;a359uCd)tHB=1vru^Gz$E<{UK{KYO-_FZ2dE~FxpGe1)A6RZ~m*#yw
zXg}!Jnl_A4IQobD!~}qDh7RHnmiwcIT*C^dCNg7RUS!)fSI}j9fRa=JLOlKLJ$bVZ
z!gne1yOXuVq-ciuD>s`iPwT(O@Z6IAD1Lcjr>Fn1;~uc71~6>YSH~gFk^wPHZHV|<34i5_B`ullmqyjSB$
zd2E9fo1wYeAg9>FOq;}UP=s_qMQMNs+j5cE?F5;ML2DX6oFj~ctHn*lJ~8wIDOQ`9
zxZ$;X?^1nh#!{{L#6%848_J&!He)&j?RkU@*W8O>fY>x>pI9KJt6qAq9{Ql}*$nX~
zTQ}gnchdSNoan;mIrp+|CqJf63b~vpKb@P#7?i#*Fvd(~^dx>_r=9G%K3N=yV5rIh
zbns01Gw$YGD@5$zIGgUvsQ>7Tw4fUHcq(TtuKzwGd5n@?seMQ`CgOjg)wo4Ci+g37
zF6Px~GltZuCA}-tUKYORhjJ!OmY8}yeO$;HzJ#tpAUR%yXe{y~Q7qmEf}B2#r~1Xu
zwZYlI6#8dsJGF2Z&3(2CQEBi&JN3EPrbVuZVc<5-9uz_OyBJi61to>o-)dGw=mkevy*s(}Q~We-NhAUk#JNh`
z65Ws#(?!9dq^#;(VH2c34Fs%dy7qruAi{390L*=bI+&gcmSROBiTQo_qR7kLHw@<`
zjGLSW+%r0-;%cUDrqCU1;uqQ*tSC#hf%iYp8j&P;2n{MxCc@OK!oOL|dchbUrXy}j#qz+?T94n#}
z((5sndJH?+i^m{I)n>va{e_=nf^$1xR%1pJEbWM#Bj)Q<`C$=S-fS^UAS7gW*$E8T
z3mAV+O40!2K@N=RHlp=?xU5$-WdsGFEj389Z=hHHkcMmsWAar~Wrmdf6`rbMg6UME
z!Wrnm%~-=HSmeQOP4I)4yfvXryAg7`uu&pgxa0n+=l(GEYXzPSPq;S1$MhV)
zmE%$(0YYa#Y3*~77hP_PBVz=J2m?XB^kCH60Z@#-`;HS;v5Eu!|7iKSe3@%M>{
zRTNT}7|WM!7jtI;QVgRYWoeRaA+*9|p)_3FK~Q|1l2brJzO!97U>{0d3p@d~_^-k&
zI8XC=_6eDwK;N3LV;eut2~wJ&Jh}@^3G>D1C~i@E_yy&T!MJDqqAP|l=c%ihlFQ!B
zQx5PjC+#qW!{HPac$t9Xe&ixTQOh*ti8t^G7&`RPr(-ALl=gZM;!9OM9?)X1+4fmJ
z{DLq|v*DWD_N!{%0VU=V$~?s9uWF2ue=n}=rZC*7U-J(&m0f->at
z8xxd%J_3@b*Dlh4yaH%0-igwaseIy;aBW`&3OUmloG=VgJ(mb=)qEn%DR|LzDab89
zb>#`dan(rI98sSQ7ir8(dklsrx`pro(4icE>K!@Fj6~aq=%9~|$EB@Y&Nx1uXG1y_
zCgoBTUqU%^GN~FtmBeqw@cna?*Jgd+kH~0+H)}cbRflF?xUYRAn_AIl#V|B~U&eb9
zQp4kY3|x!c6**BBCTP330adW{?J2;yULvh){I=5;qj+^u;w_>XmYAChbVXDRPoM7V
zqhX-=3=dm&@+UGazE8Xa7z#E-oSja=SF#{s}aZuyp;&ut!FUlFwxQWlv_|6bA)ssL1z@qogv3&vsUmeib;j6cm4rGn1;q
z9rbqcJXLOa01aM+x=sH$GQt><87onWC2IWF04QLj8Ma;-L()do0+ZN>PD^Gi=y-xQ
zj{@Y9)v}8dgz~mseDAM&iiilhK_5N5IUV
z^@{xoy~96ozEs{TE@>sZ|4dz(`LSNT?#nxzQKe8VO}h5)_AaMesPGT(SZm6URwgCu
z+)YS_vw8GREcyPKRm!7=D864=)siB#vCebDRQU)NB3tN!U@WX}&sPPTTVD201@+FK
zD7~fbzMl{;%hZ(sanDx5ATKQ7Eavm37S9&Dl+H(~v77BSvA6QbfE37--;6iWd@mWh~fSN3jX;A
zrvbxd?Z1F7|KCfQL7+s!c76e#3>MQ@qSvEpPUh@y5jHKXdjj?IldU~oeV^{)mD1dr
zk_l<=e9K#8Ua}dSUdQ}-NQbT3s$)d?Txym-YZRYEzD-8R75TKemp{)O?wcEBYz|IH
z>afCJO0f30Z4@+(rOZ*t>f9fcXEFABfi{J7oV==W0)`ozxccqFhm~lZV$EW6%n@VZ
zTfNn8zWUgMJ~#v4)45ai79hq@P)zPW*`7r>U1`>dYE#Ici25A8TzDFHh^udX241YNIO&h|~+)iQMDi<)!a1Kez*2B?NDUc5&KIOYE+V(>rh-lU?`
ztYE*=clAQk=8FnD9Wj
zb5lsRB#h&EQOT1~95^1^9X(pq{DhhVyW6VCqF_Gp#g2(_aBcnTaJJ?#yVcpt%x;J1
zufqG1bcgxuA(mVnP=
zW^zSQA0J2*Rq;gLbAjntt1PPuAnxXN_n;}7)^`rwdQ~mqP2L{jx8h1gID%+0xS7~G
zUKeXK46_L6t)JNBAb-wh+G|~)t*ftiW~4!JAT*}n_dg!E<*Y?eYFVwsQ4>&Bo&R+P
zhhBUwKy%YB?i=_YS^ykTNK8{ybCGqQP{E{M=fv0|xuC&PLJI<~;vr9}*!jypuJ2DtOkTpMtX)phTe=Nlx>gFH
z^!=waEGI7o*CXgb5^GTE_=f#Rxk}uv%)23Lnxl2^0dw%u_%cm>2`V3^p0eBite&<@
z%;@#AUpc2EG3rNKV!axY$#M~l3_X@bvO9L}O5$;9tI@bS)CS^7y?EL`4+-K>hYBB_
zmqf|i-%Mh0Kasq~CvVPrR!5xtK%*UX<>}~N`-+#{_-@q{bOJ)mI>SQGQo46WRdV8<
z5;YxE3x&7l&1jD5kUwffN%-nID=N93W4QbKg@JQTR^vKX7@zNGUg;(+!(7CiH8J+G
zzN1IKW^7$Po2dDZLU?3F_>hCnEGNA>>9aYeab6UFGIHRBnKykxcq>&Bm@N%$y3LlT
zHEl?pJ-@a*yB;-UXrBE&h>_*EnX|b?Fj<(RrY+vPGpg{AWZh>!^z?g<0*7)Fh~?wl
zFe~}n#3CfnSkTv!yAiaJA*Ic+2BgjriS6zi=iz<+mRw=$58!FoY4zX$^_^e@2ga}@
zen!`ygR@glC@GIb_h3D6sqMVRBEH3*9%P-ORUr1JpWY5}Hl@~4aC@2RfqYZ%V3y(VQ28~Zng_q^j~*_P;)
zfTrGIXRRd&z13kmX^q-C+vi21b!=qgIl-h#KC@w4Mr0L@uO%Sqmqv~m2&-jZZJIHI
zv-y-)h0-kOCRR+!6m}vkX0kNw&tem0ws$yA-_u9zf743)8nuJm`@Jw
zRmuZ1WuXVbtnxM8-L>DQy=C31h~RRelD<9%h6kF?{!kknNdvA#X%ZRwZ>;R&5rm
zdm>gDphI1_=cs?<<>?n7Ap*O%uPZ=A_7?efaW3C&NZ1qvJ`%Zf+?eA|tX2{j2vSR|
zdym;ljWlP0!59OjCv0I~N(?*axSFEso{_0b~PpUd|bCQ9HhJE-xF%oH=jD=Fn>U
z+=^z@tSm3697hmjwC=gy$a#Ry2yw9}Q%reum+z0Tb!Nci0W}^PBP0W-LjJ)_vBxGmHAon+rhDxYF?oA
z;54oR!z+zLd~egqzLr=aQjaG#HtY0)sq@v!=?i9n=MNo3K{YGv$auW8z*AjU$1ekL
zMuz1hPib!pzW_}O>M=mwJaxeLFF;KNPJ>+zO;@OZ{mR0?gwpSqn5|-I7edctCW(nA
z3mW)8pR#T3j43gOPOX4$x}sT@gpHC_wDGXR^Endq);`@KUZi*Uc
zPus`XBQBlPJ#3gwt7oe7n@CE-)6My~`BCh%aSf!3)%shrxU=xkA7zggrxaujQVcv5
zmjw^TGT}A?E-FBhoNrk9K#))v|IKcTd}6QgJ!jpB?TL^V@1>xRM}{y$o7cxu)hWNv
zof+qQTOSPrj;rRQ4;f`(`aUKV
zZ$SyGlfx(JRkQyA6PYW3iNbcVJDP!;*y+<4%X%GwYxfmO&;V4a?}!!vh6>@*n&-jy
z3X-4uxAf}d(1`2VQUBT&%|daX2bIWeMbrEku|)OOxMosYtmi`@lX0nP`*@D-C!%>h
z;>BKB(&`4zTE*rwo}0y>?gYZt2!X&PlV6@((Y;XE)d)`_feFlMQyU`+Ro-^Rb^5(G
z=7I3 rLaN~woAc(C1GaO&}N^9JiSA%?Jp?5vO)MR)!^aHN!#L|RH-)4MMi0Qgu<+H%!`f0Gj@x-?e(vXb#?Zb`k?P;%NSqiS=opyJ%&d}waYiW#PO{U
zAh{!H^w#O;(~K6?VvTN1NrqzdZ`m
zShjIgKBxaQ(*2(aEwW8xEd#F`z#V!P0Zp=?B1hcRA!u>5Xf^fe?O3hr|GO-sX|Qis
zJM^#8`Sr(H2f1YeW4$`Y&OT-u>*xg%v(8RB(YA}%z90d&h
zC?ia~B#uo;a0Gya#gZI_6)0UjIlW;Nb9>X>eN}^Ll0vS}1YH=zMwuw1Cna8X8~YE#
z#=iVbxCKuAJk0$IoPP)cI@HmNOWY2RD^UGHc_{Wlq_s>Hho^AN5T229I0C>n*CU-w
z|0u!~t0isCM>ujjj-wtux4?NVJ^pWN-y0?5jMfN-fhh5dp=7e=CBgd}
z`z6SR=2#z=ZQ^jQ3oPu`BnUkX`9!_0mEZSxu$|Y*O+dT}FcpC~a1E8Ni;?aQpJd5`L8Co{3
zVQQ6uC7)EQuCdT8IfREkS
zg$Dv}w({(B-vti1p98eE=6sL>?#OrfLzdmp|pBVBFO+uIc`k
z0{Xl(_kUt$LEr_t2otBCtNyap_{m7+CawM@3c3KeHC#P5i2kjUS>Iu`yw5Z6to8~E
zK=kdbE=EXwdo(tVY%uT9o{^0Bmp&@#3%DD%0QP>XrYm>nXyG)CM7aI-Xtl~4^$^2C
z5v0qD-YnmVqn=Dqn93^4eh&oP+SYL-jnBWyg{l2y*&>w_m@2_6v4sAj5rcsBUEk^!=xkUEl>q9FHT16sV{X5F%}b=gL82
z#gs9t_iLIw*hI~LF{W47AO*^$d8*l7szr(zj}~}L3`c~4MJ%b4T`LMTc~d!
zXwD1|lV0PK7uD7^=9+tzrIozdnx{otI?>8}OKQzv)gkc7)d}F?Mh34pO-f;S3q9xl
zQHm+NTgnJsNuC7NWo<>)N!FriyV-4L(_EpPQH7yqV7WV`BF
zv~Ml$*&~BlJ}`E_y=4uT6pku)Bm9bXuOAF
z>*>ZqP{Uj5lL&cv9Ekh&_QLuH9ts4si8q7E#5c#&?Sq`ej?0+BZTdQ+ETytDt@$0r
zl725Epf8Hg1ev8kDoHj+tJ=ah&&7>?RXkXCxm;2gUu967TkxUEZHEAQBs!v{)@$#x
z6wB@N%{iWiRboA@l^6wQJls!xm?&!=z}J}``v-*C{F*Y^bu3<`Me_&lx6mI%Kes=}`kUiM1SjhV*gr(p_FUW{Kl#s!6g$MS#8sk3P;1!V
zsgKPDgT8^m$q<1P5Vq#5Q(S#t)R4*P)$S0d3nFTgicUF6AId8c{VQh?EGJ+=k+YH~
zY1u7-H9&@Sh+fU`>R5eD@Y}`F{HEqbjXm!AxS@
zkX4NVDQyt}6BxYu16FD+d=vag0R+Ey-nFW3n|EbP#SdurgaK;OM!aV2PM)`mM?br#
zaME^(@H+NH6tZ@E*nQbwEIvaZFR6~8siI1YQO=9tGz^gz*DcJg&~s$R%xp<)*p--?
zH^hk`Dav5h?My_#SErH+p~9=aKkLx^;#M5W~LARU2#RQ?@h*vF`7cB!!3UY_5&8RAHG!s$4d2)>P)o0e@8hug~pdT
zlaWanfel6*5XNT*M(bwfZ_oZ322jf{P9x5sCa%=;s55Bse^{@I{N{3Dg3K6Mdw^6D
zK5O}V2MTWH5xoxt73KQG9-~)BL`z4n`-Uz6c{I(Q5wpiY1$~oL9MTiRG`V;WIdf4W0wB^C0e@Mf?($qIE
zt8x``THFCh0+4p@`6+yvt`*P{n!P5moL&^|vNv9*uc=O-`0&nzy5cT2lgKi-Dde?O
zd~)VirZEH`8vFkF3qK0ySy?s2*4z5b&oxjO4GId4?}Jx4hI{`_7A9XHeDemwFx?-K
zs7YUkQzz}qERoO!lu64`|A*jPtVD$fhxBgYqK{Q3vxR<>N=u}d;OUPM9Yjf8C1xN|93d
z?V7fkT6NaHcF;Q8n1*35nHJjD8gHXC2nkWy{3=t2P-VjIxvfw))sf3Z^qO~QfX=5n
zRIn`jXgWdh4;`eed~U}N@8fLZ2xMY{CW+}ga+GzI-s{dNz*g;5HtefT;=}r6!KQY#
zOJ?{-p6S;&nR~{f==aQ*NrwfL`{46$PX~;qS2P$Z1(x*%9{dFY
zw@u|oh3sv&M~F&R3$`I{E(Oe%<}BW@6=N*IQS9c}V{`$S;uTm3M^zbTzwZYS58Rtg
z)1k%zpw}6LXEV$c7G*i!ON5)PtFg5OsP@2APj$bx+-GGB-=C`vO7b=Kb|X)l)yPw)
zt~FnaAQE3x^wh8)#{-{4z42lYb${~(nDJ&RR=D~_Wccf3q$(9%wVlu36XfdndfiU~
zgm1^sc-N?*W&e;Kl}T-eEN+m3dfD9Oou`)3hY(;7T0RuW<<+MzC8uyEn2v7L2R2lg
zu+tx6df11x4-}Q@+>=aN9lK3Weo%%5g2T>mO7Z*QW}(BpJ9`8LX3woH4D$i;>&R_>db*iz`9*CnW~Bbp>Vt$nV;2PKcIf`evKQIvbnLfR
zyG&t}f@EF+L`B=HGkv|U=^b7sTgh+}mXrKC94?t*xeC2Ui9IR_LQerpXao#mrAY1HOdv(iZ
zFX8J?9ArxBa>c!0hx{LRtH@|ntLMK3e3;T8XW;Z;Bjg=lxnmy_sm7Bm%Fb#(iWz}F
zH-QGypxYU7;b%W5hqetSRHehGQ37+Z7*wV0^?u2*Wc#+hR#9ah-VHmP&!G~YdsSXp
zc=CQ3Mnyj=fc#2AZG@T!j=*h*mj1~}xOhPv+NH4JFFkccgV$FmDrhuU_K{;fFDWx!
zILCtz-oy*GC=jE_sSZxT0D4#x|K)3ob1NWh*n}_w
z&U{&UbP3wBEnGjG?~r+wto3x{rtGw#M{>bX(nq>^|%vB(O
z;w<-J@V3qHK#kflA3c1sVEU<-?v&dzJ{r;a8;8R6%Z_CasZl;tSoh#&1;t)KEg}C%
zowKw$jq9${ZBGskZk%#{uHCSgb$E6B&38!$H{Q#O8();%7qOtZ(FD&v>9w2Beu>GX
zOP0W%=;%t1w3BRkSEu2+c$T+|&I2xDYJu2+2_duARDQTaPPI
zG7>2KO!s)^ICWVO!=1T)Hdnc2-{qpSH78=aaAW{3I|a_tgF4k6MqLaC
zfwNnuW6UKtDLjr7L*iXqvavQtQ_cE@80pkq5HhGH_PvS3eiMiXlkhCP{_aB887tLhO5v?PDB}Q4~S8
z@uef#tGU7&Y4PyXqWkh;X;q<^AW?7%8|%6GQDK@}hu6@FqAmU@i$5$2LD`v%JQ>qA
z9?Yx6_3q-J?I>(2>868qwsjTIr_R_Y<>l0$DfsiV{u5PYE?XW5tq~fFR$Zb`oQ5~#
zr)p(bAB({dK|fas^k^2IAwqnUpA#ZyGq(smxuN6NZV^4c%0hO=2s(j?t0AyIm6J}v
z)F!9lJGWv1Fxo6u?!?g(;-UFomTk;)l!2
zk(9uB07I;Z+NE#~rrr*B
zp7pvlOYwZc-<#U_ClixeK=ICOOcw(Gxig~X5Cye8*2&v5=Hq;3Phw62LSag`-@M??
zlBW4Nc7h_rHK3r?omd@Fd6g3-S>y=d7jI^%_mJj=BqAUENDhgR`aHdyLpXWrBy2|q
zu$2LTNWhbPzaQ4An5?IC6Jqr
z-bQR*P@UhudAT%mb>#kuH)=iWc)B@ZAuU_1KUd!IYi#t118B#oxM=46R0S_S{`^Ly
zw%zWj5${S8Dh&%;Y=XfC!u#KDxo;k*yPn?yr9KQ3-S%K?PWRc5u4On(w6GG&mn%HI
zS!{7CtE?Ds8&_k-S0U1)FLMi96;Y$YK0bY^YuwM``zeVduga3kk%yrzC#wndw?&)_
z8XV^trY{#k&6@Yq(VprQ$!`;lbmdd*u?Z2%
zSURwqtZe%C0fZ`|OQ!G#mh#D>`Z1`d(hT&DR+8pj2~O)Q1iU6LJQOJ#Bm-hGGdH-#
z>sm^7`Q#)^Rcjs2XBL9|A6me+gCPy}?GIXpdTNUP$}-;d
z(NS0dFZ{271jquTxero|@ZxPq=?M1DhL*XrK=>$J0irSqoXai2okSzC$bx7r!!E2}
zP@^e5hDEVo8?8R;f^XZn8mB%;;++_r>hwun*e<j|49RZpkmZq8A(tvLme?5FqKy83!$!w+GLI6C}Q(PkgM19$tydxTzb{AdH
zdf~R8to9`DC`Sn;t}S_x*#kcGgcxD5L$CP%K}V&7V3kN!{Hp%knf{a5wl|E;6*|JNP<->5rOy%6nz%MS88e-rFyh(2NS!u+|hpQm|{
zfd9{At^EroA>j<7f;ZTl(Dae%@<%0;B-rf4f0XxjtMI-kIm>c48Kl@k*zH(qgv0`i}b%dfKMaFpYJvx+1
zZ0LbJhEGuRx6+737_toUh$p)Y-T8EWD%o99X~bDT{9hArHjcoAqx^=rC@p|0G%b-Q
zaUg);cKy)C_GGOwLl--fI}bCAOm?v#_+TS>9Q)|c4m|Vix~^V?AN!`ev0hgQX#_=(
z)bq1~WDBvu0EGiMsO^s$7Dg*_njF@s10@Mm4o{qI(FHxQ7|%}^^T^00k*YSQnpj{d
zUCE&M-rle)NY6t08OUUxMb8tzb8d&rWd}Wcx
zYB#*Bj$6W4v58#&Z07J0JsoWQ2yTA~+ts0l#=TIB#bnk=;$xj6Mk}<=Yt;fB(`>s9
zF9waM24h`A)9-iEmvsU^7uU6}=pTtis#UMFK2CfaVx?l!uxLzshVhmTEy9AWC}eUY
zw2}0g=dM bT1DET2EAVlH+6<&clj3RU-u5FVl*^J#I4&ewQypq~vu-EuIglCRV7(ZtoGyIv6oBT_bZ&GHJobvZnHP8d30|-2FRk|HhkPnISa$N`}jn
zUdnD8CJY@nYDqk*%ADOl&lZZ)&v#y9H81)l@i?O!`ox@%`uA|5Y)@}x!G#)}QNUlh
zx$P%lsYit)Htm)p
z-eVv&UZqZ-+OKdw#S4D=3B`z1n4TR&Vns|FQV%k*
zut_-+WHIVWOmljrjx>`nr4+P{oAZD*qc;>KZr%k*$zk>Q5{n;kwsOOJ1g_0ZvgQ`F
znXJc~60X@o7X1A|wC$QeJ~VLoA%SJv-;JqoXL5ixREOO0=`jL>6r+gFp}
zHOQ<^4grmKe0h^AXnBmYVJs8g?0Kpd-6e@If(70I#k2_$_ds@E$JHm&Ar6-UrO$w(
zHoSy77T#|VS$svYKg$XYdEoU%+~l`Axa;+3?eC~Ttn|1Mt`7stLXwGf^H(b~+#%@f
zeO+16(h|0>?12G@ZxV#oGzGJuf*}DeLNv}Fvlc?_uHleHCs5smF$rDfg?*~Y$9-2F
zR=*x;nLU<$GzQ3eUzrRd^F-4~WAmWP%klX;>1@ZE82(G9D`!n?w7q)c?137P_Dn}UgF646QaqH?DuZ5uvkZtddA;hv>L=`NL8!Gb4c_U_9NIRUa5^OvP
z0#PM#z7c4UN-73V^w|Y_ejk)atsdWYi7c#f%i-4i@lWErfE0(_56dN$5OB@l_psqM
ziQig`3Tc23V9LZzSBH)}#YT59XnlJ}x|mDT>mp0|!tys0rbpy25?=5MovGkfpg5~f
zfA+Kl_!bXsK^R)^_7f%No1NfCCG_3b$G`0Cg|p4(Oo5S62fOiv352FjG68P?n=6WY|cyy#gf2SOfuO6+T)ZL
zdX>Rwl+Jojf04{BBY?ue%Jl215Cn#8+M4pa&m+%X2Jf@IUELs1^1qi5+R}#FTy_@q
zYmC}R4?1xsu#-uk@jg}+2KTHEfHi&9fjcHSc(MtCUvwM#&IDzcPI+siV}%r3k<041
z;ez8fdB$c)L$l)G2QqiuN?=yQPSl9iM37jMNV0T@Utnm8r>fORiJ-l|;)rxRaa=V+
zv~yTDno?N6Y_?W6q%gNb^y>SQ8cS7bG9l(}6`Pp2y`C5t0vxFR376y7=Sq+|c@;Yk
zS6+6ymHNnTVWqYdRzlg
zwDsTEd&{Ug;xyYECxqbc1P-pj-R1bxzg4YVZBq4~D*HRw>nIxorFqhU3YNf)zX!4PDz7_gEHln}wy4
zyisjZY*}t1wrF}|p95ICVBHecncrAlkee;07oxO2edm{>p+@NS?o7gR*dZ%IR!7J{
z$w)0N{icd<39|P=%%$--Gk(4>^8Sh=E_~1I
z7i1mP%gPJZj^~U9l+S5|T8XSYe|n*04hXy(c_SK|IN_q@JE__V*NfqBQ0fBKde5kV
z)^W)fo0=|R%0}FOQaa~8w|5TN`V^{Lawob!=IJ)!gn;99m(7^*MV#MkXkTda2aYPV
zB=jM^)NkK}7{^+|Qo71gz)^m)7ua;cN)*xEyZ!0bcQb1~Q?fFaz3ZIg?|K9(NwwVD
zD}g`4eU=ffaju~yK!=Qo!oD>+Zcy^dueS|aITOJOwMA&
z%nVhoJ|?NNwzYwMLkxjQRfYiv<*@(>if-niq8dY9teIyOc}^sgYw`5;5M+NZvS%*G#R
zuZam#h!fM`XYRmQi@Pf+VCYUr>ArWh
zQT_JoU=wIr{O*|qA%7+ABBQd)4txB5V!%OfAFu#AjA$d@$oyGw1;
z%HQ_(Xq}(pc8?{AxXiDx1T|5q)XGl+v2RIbGT`UGYU*^we&39M5ZJkuSa%P<5I*x&Oz
z>#bbHJd+-^e47&9aI8NNo%4%|l=peF=R@Dze$$=W0|j7b7V&hyx5!=S217h432oJZ
z4i8P)_RjQEPRCq$ojS}B&V6%wqkiTkvzy#ES3lcKN$|tdz_?ApIaJxr_#l?&x$G5>+djdMfyeB?eNSIbkOsef?yj2BPwl(`c8Ht
zz~PUV+cM5dGcRNX5ai+irUj@@8KZ8IizRNg*;$l&@hl%7dB~|SW7F%QbamEAFGv~+
zNtLUj@(_ZAro8Qz*DnJIK7}1YvdEz$eD?8?DMl0fG9BX`n`Iuh3*`aw4(e?`v|HBX
z*iU_{?Sz4PDJsp4J16e(kGmo(E4NiU3-{+ff?7Fu2ECM}tH(Ul@2{#T8
zn#{4O6kMob#U^ox?V!KJJ64fHee#!$4PXD7KmL}O)VDe|ig~hR;!(0njJI^dwOUfl
z&rS0C+upZ}OV@_OO5y$i_;r38jk)9BtK^|i%zf!#7VwgJBqlct(3QK}%PFgylD&@!
z*`MxZUu!R`hp1zd-4c^tkmLENf9)xd_$p{BqbaaQe<;B`PG~gf0(=7$u7h5=zx|j5
zCzeaN!>=j8Ho0a@kRv`g6gzr?-b~*cb
zc*vFg^&{ReuMO%?8JM_cCnIN+52l7f**qjT>`N@^I4TdR_KVFLLUSvPng%fJRZFt!
z1hdTuJhkw2EXJBRBS*}BXzeMU-YxANqeEf;
zKL7n=wHWtLg3m4r-hv~4?-qPa|BGAj+{$`Mh&?3tYxZ_J6_D0OMcDHf*?#QuHvPw#
z*!EHJ1B8t!b*gahpg)w0lLXhJwPU^d8`mN=fgg+16$<#$Ki`LVtW0pI|Jo5bi-FIK6qrf*
zn7w`23UX^G14`YE(D0_274)09;uMhoBh`KY`~wN*41M5Y;Qpp{-r}jbm=9R_jPC~t
zBgH;8v03#Q#$ys}<~9H>9;{Tyle9s4Bw5i{ZTx!R+eUXDPGL#1q8>
z8>Rq%tZClGNOicF{djyV<))PD+!_V$jz+o^Akrk@#2*j_k9%po7&$v;6D=nxp`NK$
zD~E+hVO6>b`Fsa>)U^Yz(|baBKP`
zW-&iFOs=db)FyBZI*f=2_<32S_1vUVhqbR$I9{nEd87GMKtp`%0l>gxY$hhHn(Nn$
zG-4oQQo_nOcN}3z!3%^o{Jr&V$=)gV(KzQ#jm@|d3cD3psjhO4Zuev6Wd(TGnzZWo
zF)^<8pM}je;gHO*r44z2Tl$pb7CR-8+ocQ9u{ntm=($%Y5?!X9wD1fq7&s;4{k3vg
zv~LV@VIL(b2==jT$WprTR}6Vr^rA;7euH!#D8Qots+S6ly5w$trx
z=y>X!+EC!=@y5NuikyPQUxj-_BU+5&mrP~i@1J5S9v;KThuS9EOz4>lmEkGhp-3=K
zQedbnXXQ?&!ZYT#b_-6%i?WLQ^rDsnygGl&i@c{KB8x95iZitt;O!}e3!S1ID?0ZO
zvU?9zoq{|*iE0dunxUZl{z)X)d#!!b1(DnX--#Fm&H{+XBeL_&aqdob+24CAN_ZZ7
zl63-+z7T>UkX<`>{rde7-dJ!0+qM>-g7c&Uj_Y2xN6R{V?yY8N)$gr}-qL0+EDX4X=3$%hK0?q|M|a<3XYl8+MmrC7-iG30fH
zW8dKpqMqyP3P%dhV=?(4F^UjWnfJL+WZL3T<8@{WR5h>c<7#rqw5&G5`?!tk0DBOe
zmY^eR$Nw{eHJ@tAK^VEvX;y!-B$Ils{V%Wz8Sef
z@uRGat7ZbYtP|TbipdE&87+qbT%fZj3yzdzBz&7h=Wqh`!0tZ34o!vz5J%Vf;fSDB
zu==ECn8FFvoQNJ~c4or`Cwv5DIu?`P)p)10LT$%0Y8p~-UM8)US`A(WfI`8_f5HdL
zGJ^7UkCsnumL#Srr|f#FaYcpMt|7o);<5l8><$Q4wXoGr2v^JH|7li8R!h2P(I|0A
z_bxr}!IM$g`GTp8LBOYPo|K&XYqMzqN*Gzh+T6YrVD*cGJ-vp7y|j03MMlB14ZRVT
zLnDb?*$a9$#V#0y3`&rT754>Sz%n4Ff#QegZ5u2}H3#fRGq*$;{x$7bEyujHCRylD
zTA(&<5;mAxL}KR6dCJK>9mi~5C=bsmkRi}IJpXT`{eLItdt8tFZ#lm}DcEtgGcV~Y
zu?~z(a7$Y)%T${V)F3=TjPmEmhl?x+$uGnM(_#^|dLQ)z_oTLJXRu5)S!w74iPQJX
z9>H4DCnrEAHZC=Hz7^Ge(RtWZKkFa7N=uT6XVN{l!}iXc4S8YFOqt
zvd*ifVXhJ<5Z4W~1&8RrL^xd43*!g{@|Cn;6F+DQhIwU*qo(X7axl+MYG`V1jsk&Ly$3$~L>&k_#uflZVQemjv=r|3HP|KlM1vHL{-xK28_#TiNlp
zZ(bPRgf;(SxcXg)P)IgKHf0~Hg;nIPCb5uEDzl&aL?(qg(4vzd@+l8OkmR{YFY*!I
zChJv(G;E>O3rS@|{*6`N(Cm9ozwd0Y)HeSO2vKSZr}@UR$r=8Jac4n7fLIrsor(?j
z_?uxS!S>Z9_c|zH4hEnnf<6@F#nW<3c#boIxB{rlWBgr;o^dWhEUfGrKcylmXP6rd@~((mem
z_lVLP{M3E*`8E8!AsO9ndMb>SAQQS%1zP5tL3b9AwhXlgi)%Q9QcEHQ#%m4vAMpXg
zv3Q^?!Id%P(^DUJjSuU{jrIzxN%HUD2q*f}CcpXGNKD_m^FA3y0%$pSQ0D(igu`ve
zZ8m@gci8(a=Zt1h(dH@lzyOGpg{6iSo(H(~z#s`Xs0nls*~
z+r#ypw3ybD(f==az)=)i@NZhcKLiQ>4jvE>BUIU}K+D6fiwl|O@08nB?3Q~+lqbs<
zzqhhlhQdd13qorOH~s)KjVX1U<94b;8|+|T0`(-g2>T~a2kO7m
zyg@ZD}@2>omtI^p(nUE%R~
zc0^g!3hRo*$lp1WF)>gU(@#)fMqWista
zj-(i~wUQs3$~uI8mRaQkX@JF~%I4eB>Uj#tSX76WYT9E^6!c_tZOG2cYOw#0;{hO(
z)Z0#EYTt7H<}xRgo{&NM=L#F=>d`|dIc>0$m61V{ji%E;HcOy-f`6SX|6rX|@(vVP
z@F>5KIPt$q{q;EuIgSM$d?Fd%BDmpLWDJl
z0dnpaGsTX-YE#}bF_%fS8nVqpZ_6@k9j4+yUODSCu`^m$OCF~!+1$k5{Nm2IKY&l5
z2nBfw4fjwJip`lcqob#o=kYLAAWZT|f+&=bl;=ic>2Mc1~
z8dkgLsjy7(5!NSP5bD2t%&9mEvv09A^nlK3Ed2iSsd_S&360Xc=H$IcPelUzVTr2P`g&*)b*ospdQ
zhe$5{u}51AZa;~J>;@QVV*56%F#pcJX`B#de9x>EtTg7|>ztFU#wW{Oz1D;HubBiH
z@IH`c>KK$5xtsIa_leHmf^G$D3`EI*SWWAYVTALhFSYNXR!HZ(M%Q0xfJIa}@5<+|
z$ED?8*HIQRp=3`+ZSBvcO$Q{9X!1?!xeD9(U#6vY6GUzf7qT>61yvG0h~GaGt=D{G
zdvm(t@6+I3L+V01&Z+q}oFTCrk;cXyk-}WAWLJ?ci@O6N@s7SFkobc<5%`Xlv*q}u
zsXPN3bQ_}WDFRlW^U+jvLYi}rsczBm%~zhMsy)dW2HxW=guXmFbHxOABAK4jcrrz&
zaE2q6evYk(2BF-azDKystKaw|_sIuR!-t;4il-C+X&te>X$wa|nRPhn+eJ
znYCz|U~ov3vP8QsQ`Wuz{-g`(Tp@S<##vUw}TAUyCuEXJuBT94Qkd~uqxWmuI
z!{Y8ZK3I!{qlEXsdU(o#FpvqMMCqfr%+(>JVJL@{xdOCD|+U5;EV0UqqO4D9*eHeEaR2S2-P5!lVy^@Z#>DI=P8TX4z8sH_(}CA5Zs
z6){YPNt`l-wjzv>g}Kx38QIRGD{AJ*C}>ArHo2_M3SlFk2*+Tk>YIH&HN?`u(3YAV
z*tQoq_Ht_oCIY`^V9SIYbQR^DT^d)I3hhh$Kk6LlgHjKqAb9N63@Xbu$Xb8)w{=eX
z+euiZQnFI-(CRqX*+i>*-g60Eq^6J0>aM(>sX7|5jUbhBVbn&uf08!Ov*WWCkYoBZ
zaciTDh;8FmeQ)~8wmyM~WJVmilSi_jPzV&iI0Rtdqq)GEq~sx>t>=RtR$wGi^PzlC
zF+CETgYNMDje(Ft$uCtSX=ZUS3m4K7?^>W2yRn_=qV?fRyaczP_BWz14ja@%qh#bJ
zwkp-sp7E^)v}Ee85S4gj47t}hg;x+Mz=(!+wR?0a?e@y|n$tW9u6)78B*}4|LabIzP_HHM_z^_=;MbGU
zA_l&)U!Lm}okmgTjOebCZ$R!mEHkz9fr7Uw#rUQ6*N*uNt-%sH3-0L`p{bOk1{at2
znw8^JJTS{%p;u>K?=&mLNsI7v=nNmgftf%P$3y#31&{j6M;EF|b{3eH0))t&Y7
z&Sd4NF@V0DAtGf1tYP{}Wnm)!*yyK){9omN<@EYJh!Q1$PuZ{Jqu!W|;=a{1E!iUm
zo9)c_Am6@t!sVF^4AEcso!&|pnMW-kE>Z}O_<%~^jGR~^G_jd^eN22ASF+=SWT2>a
zfIs6l0;LFE6z?lLU87k`mE>~(dKf)*IN~Zca+(JC*mdeS*@U2oB($>w3l&ggy|0>s
zov#i>C60s7)~wj9vSKom97G3DRuQwI@Opq(al+orxn`o>gXDkD4FuagU4@Aw=A)8X
zDN7<TNw|1J9=;<`kN8(JkKsB$kjc0QExD{_ozX0;^H6znjafygOm#`d;m`f*wgZQU^9?_vGYY&9e0hb8}n%@xsQ
zwXp8}g9rf$>~OSkzGTsk&+Nt0|J=U5k}EIaUu_08;NN(GIwev;m8-hW_(bjc<(fto
zrXAEPy
zFn@@BU+24nL8lJwSUgF^RRw~k3jt5pq5Gsn)y~ovN80$p!xLE39f(bAaSG)Goyz<3
zeS!_VlH05DGRYe4b>2GCV&fO>8o`-9?%8OpF~{l2%}bY}#gDdwMarvWzEwHq1cF@H
z-b1Hwvwc%*4{7Ptvb*QW#aPO9)3&BUWSbz$&1++v=JYL-tXpuhOE~&@e&GtzwFnwV
zv=U40Hn(Q$JN%F@M`pHL3)ii^DHZij*Jq^_6jA)OyWGz(
z7I3cMGtk7n64TJRY%k}szwy;+&?ilOhWSnsjk+iJTi8iDVbe@PXpr|S?Xu>zEdEB%
zK*HGirao!zSn4gicBO>8p2Z-jQzYUqf$L%cP_8a+0^!g29FqJ7NmPQ=*z*`rXNIuR
zmd$|M4Gn63^N&wG2cI9)fZr^9GoiWZ;0SglH)V}WC=GhTC6Nnf3x+RTHMM_=qW+>*
zz}=jGDY258kNF7McKDNm$3QpL)K7-5`%@rsz5tIf*^#kz5n8Ij*NCT?6*-HFsKBK0
zPvYWxa%!o9Fn{$daKDes%@0~a*)3jZbXJz;b8Ka=1U&ZZaWmGs%z)}YhDeL4)(+RQ
z-iUMJlYev>OBIOf28=>qYnqhH(yamZ_F8hw4==!lD;lAo->aL*u>*+>g@X!OG$4oC
zroD^8*Bz7O{-y;8ViYgfM$C*B{X*E@#PGp>gNTl2k)h0J#841wE7CpmY_77lgKQ
zj+9v%+>HV|sizaK?QpQ^0v86OwFTpp#fCa;TpEGUsrLye^h?4*4jkLHwEEc_xc4K(
zS4zefKNYGm``{oV$v*9n99nBLUy|X^WP>BSg)Eu?n?G2xp8mc$5%Jdup@MZ
z+g1xyiX2e=NftUh`$m!Ib{oRH^v=O_b)DP`Tl8FTnsGtlpav=(f1zAy+
zY=f%?Bsub-@B%g1Sn(6Xz#q_nC1$_(uM5-wRW!(1LCA9Y2m)rfAZ=265&nx~_Wph_
z;Q!HfnooC4rn0yJYJEXQn8F0kEH3;RleAxIv$rPhAA<+_xu9I{n^b~`Ru=biIOOKBT*~pVEs`~j3ivGw8A}u
zVVX2wV@wrq?V9ujgIWZ+@OoA-W_6Ag6j=Yvzx}*hEUB>+#}rCdIR>C0?~#Yc(`+_)akZOs6zrwhMvYB_pK%Jet63n`b7zjnga;o3M?H3(+jNt@*W2guKz~Rr0+?!s)Q*-;0
znv7jqzS$9rg98o1&7gs=+KtcSrZk|_ftJ_i0Q}?;Pd^bqmSFz_1u~qhjx;LOK>W)2
zxI;8#ugud1Gtwx*T4?Ryxi$35@2db=(4~&iKHqfZ^o`x}m^K;~Wb{fnp{uN6m(F&p
zWPtMD|BVr3ATk}`@58NkN$|R@^Cm6nUf)}Ok;8wFnGu(O-6H0gD7^b;^w#oGZn*8E
z+s*Rgy&&6d`oIfiI$q0^n6CPz3P%Ls2L)5kz8EwT9pZI795VMU9SD&yIaD925<$&?
zWy?Mj@!v+%;voPqXJw3kQiC5z10)K+^i{5Z2N#U177mqEbdWUUQy=p>821ncM+MN`
zG)F`pE=pU|m8F>&0LRNtNGvU7bP8^$z(FUN7dIKe#TjnRrZs4Vcl>1*zhqntS%eoi
zn*n$M$A+3uu8Zey4MTR^VdJP`wI4?u?}tq%O(FX90aTK#dB`zD=K=eg35drwRa}wB
zg|wV=D`;q{b6T`@RIy+9DvJ*?&tnm@y|mcW3M*nM8t2@cT*#gVyf>p=UNL=d`bj0~
zk&r)h*OJKXicvE{qNn%?-c!H3CUx4g+PTXAUIeEX!EX@7Gy+-V$k6Jm${)<@`-b88N{qS{AF)D!vKR*XO
zkS|aWG(J&iR8a?q|K{0Pzsr{R#yE|sIJ~+9@>=KP1G?fQ^-^2Ux=F>w5WoSseU$4;
zKAb_1pA&Ih&N#*y42h7p^E$U{Yb&Vx7sH|bziFazVX(p;KMna$@CA3th=x$ax3D8=
zsO1!Q4O-Y$yZ(z80Y~WXH-@
zgb{lx`_!C;hoQhf9)VzjaN^_pqfJ(2I6X=Wnv;?*J|s3N@)aeyx$2y~qWxsH_SjUy
zm4s;~{ceKP?v~^6nIFbl^0aLpYZB3N+-Mf+dSfnFb6<`G*OEKWnn
z3&$`evHq}c8jka#n3wSgW_AN%)Tkjw(3C9M&s>~M2+Mj%^dAre6g(Y~uc7(_+%>OK
zK`-@40G4E;%kPXc(p<1Bnjv5H`1bZER>Ov)NS*f(mo(
z5c3}-qBCk8lj$@|e*jbq
z0y%F=r`a&G+=g(k2M2CYy6cKeFmXBFByUc;-hlw#H8K`2ZASa3$L;P%NkD`2FDi&3
zvd^a>5+O|NGNB00iTw{ldtm0RjcNto@f`z1n
zag$nYY@#AUvTX+D5+sT3pZ%dUHvH45z*J!pU>1d%#euwXiZUz`-MNW|-ZT&`1r#g%
z(9ShRGF{-@5ImZq_&EsFOb`dv$C@pdBp2EB_nVb|rY!_A6^iJlAKek?&*D)Tq^|GA;qFvtyWCri1=G1mDi1
zKde-mddEhH3TUT-dWItU(lI#2hj>?uS=BySp!QiD^c7DBSDwH8ssa_^NP52Zy8U`y
z_|z3vWcR@9`)D5}gehlw2-hdCWljfLVlifLjfByT%gV%pGqthd%_S@Yx>_g-9m`mY
zF#0*O1;?Tt+W(>s?-5$S{K1)FH*dWVX=Zlx_8Msp0DBqa=19!-%bO+_Jio@CsY&9!b25JabtW003$r0YV##9T
ziq2rBT(WWYqb~T>^9Hj|-sSI7`>sa@QJfY3VQOF8DZ#j#WvOeqzX<-@j7%qlPb&|o
z8KTFQSTe7&
z6TkGmA#|(t2XqXixoh81qvk@D+)F^aQr$zJNMhx7U`;`En?_2X;&rs(ol)3z5=!Ebfg7B*J?&cnrhWx=3
z35uF*AVH2O8eaPf1|T+2fk}MRbOnDw9AB5otA%9D{S==ahG!PrSGpHHQ7)g+`K*?b
zVp>C}(MlE=NxGj&hr=}?EML6kEV)$*B=iwDX|$(oKt1s$s0{C)T+cg6&x+SIc}v!-
zD)fWF>m7QQLy7PGVjUa0b%n7tiZ^Op1t4!Mv?S}<0aYoIm#&?RWAnL{p&8YEnqfY(
zVYTSt*fBfHraXOH<2acXKmD5)kOVQ+G18v8@~Le^lb48n-weQ>VdI=4A~VAM&>
zQrOlaIFB{De_ckd-Q<*pB`hr5d6R7lx1CbN1O~};r&wc^FJyTL!>Dww!HIocfpaUX
zGItozD-%WD=d?K^fA3yDKYM-b{aoC^iQv2L75UDp}KlZ=8m${F^QEQlAeAPMv5%9?0*v#}e3?x{tp-7MQcLFuOk
zQn4&9;#mUC;WCT<@Q#VDfa&4lzcB)Z6n)>7_^msC*NqhHv5Wmn+0b?yo(L<7%WN*d
zy1&=rQFE|8R}(Uu1s)p}2pt
zCn(kEjFsJ&3-GvfR;*{RBa7#SIpb;@MGw&gc)OD8fCJ
zh=h-Cr|gQ>-^SoDmQu%udbZo%?2uf=xqUxbN77by>EeHjt=jjKC;sY>f<7fQ;CvB+
zCn>Ty(B&V6_#MlMD=?i0LHWCq&)7!ZJMC9aw6W@Uku5kbRYPEuFTe~Gr
z^Q2+CvR??TZbljU8~7Do5=wRYHvGs^Q~Vi=#>b^eH7`-x(Ehh^mY$nU=>{LU@2!!X
zGPd5n1XAorzsQ~?>`fE+wRE*n!jEmeThEo!yv*{#Cp7CUm$-3$^Or2EUWqj>$
zbpJ(%kmC90lKs&5Oy4R-1`>Dwt>XagYaTm;KL7F)%?d7RTq7H^#pA5n=>2@6VD7u@
z&=bPml_zFBrklJi!*t#QK)ov4#`n~
z^fQYC2v2(JepG-OyX^tryh%jRRJ9|*1^KP!3i*n}yUjN2mEDgwi|KGk+1jn(G~wkK
z2P9b)6t3OE&&^|wMnJOR5>r#$>Ss4sianFtO7;@Y`QlFwMFCA|U(IlPpbI~(wLZ&*046M