Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,15 @@
# Changelog

## Unreleased — 2026-10-15 reliability review

- Reject untrusted POST headers before reading request bodies; validate duplicate/ambiguous headers and preserve bounded error-response delivery.
- Protect finished task history from stale/conflicting callbacks, reject work during shutdown and fail closed after an unrecoverable worker persistence error.
- Persist retest parent identity in the initial insert; clean up failed SQLite bootstrap and worker startup.
- Apply shared response constraints to `one_of` candidates, enforce read-function-specific Modbus quantities/address spans and reject non-finite or non-integral settings.
- Reject unsupported device descendants beyond one child level instead of silently reducing the expected-device count.
- Append `parent_job_id` to CSV exports as column 30. Existing 29 columns keep their order and values; consumers requiring exactly 29 columns need updating.
- Add 114 automated regressions above the 100-test public baseline. The isolated Windows candidate passes 214 tests, including combined worker-start/storage failure and a negative authentication-harness control; remote matrix outcomes are recorded separately in PR checks. These changes and tests were developed with AI assistance.

## 3.1 — 2026-10-08

- Add English/Chinese interfaces for the acceptance workbench and compatibility scan page, with browser-language defaults and a remembered preference.
Expand Down
2 changes: 2 additions & 0 deletions config/CONFIG_GUIDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -290,6 +290,8 @@

中间模块 `is_intermediate_module` 设置为 `true`,子设备写在 `child_devices` 里。子设备默认继承父设备接口和通信方式;父设备通信失败时,子设备会标记为无法测试。

当前执行与设备选择仅支持父设备及其直接子设备。子设备不能再包含非空的 `child_devices`;更深层级会在配置校验时拒绝,避免遗漏设备后仍生成“完整配置通过”的报告。省略或清空叶子设备的 `child_devices` 不受影响。

```json
{
"device_id": "SER_IO_001",
Expand Down
27 changes: 17 additions & 10 deletions core/config_manager.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
import copy
import json
import math
import os
import re
from typing import Dict, Iterable, List, Optional

from .protocol_engine import ProtocolConfigError, decode_hex
from .protocol_engine import ProtocolConfigError, decode_hex, validate_modbus_read


DEFAULT_CONFIG = os.path.join(
Expand Down Expand Up @@ -300,6 +301,9 @@ def _validate_device(
seen_ids: Optional[set] = None,
):
device_id = device.get("device_id") or label
if parent is not None and device.get("child_devices"):
errors.append(f"{device_id} child_devices仅支持一级子设备,不支持继续嵌套")
return
for key in ("device_id", "device_name", "device_type"):
if not device.get(key):
errors.append(f"{device_id} 缺少 {key}")
Expand Down Expand Up @@ -353,7 +357,7 @@ def _validate_serial_config(self, config: Dict[str, object], errors: List[str],
errors.append(f"{device_id} parity必须是N、E、O、M或S")
if stopbits not in (1, 1.5, 2):
errors.append(f"{device_id} stopbits必须是1、1.5或2")
if timeout <= 0 or timeout > 60:
if not math.isfinite(timeout) or timeout <= 0 or timeout > 60:
errors.append(f"{device_id} timeout必须在0到60秒之间")

def _validate_network_config(self, config: Dict[str, object], errors: List[str], device_id: str):
Expand Down Expand Up @@ -387,16 +391,19 @@ def _validate_protocol(self, protocol: Dict[str, object], errors: List[str], dev
continue
if protocol_type in ("modbus_rtu", "modbus_tcp"):
try:
function_code = int(probe.get("function_code", protocol.get("function_code", 3)) or 3)
address = int(probe.get("address", 0) or 0)
count = int(probe.get("count", 1) or 1)
except (TypeError, ValueError):
values = (probe.get("function_code", protocol.get("function_code", 3)),
probe.get("address", 0), probe.get("count", 1))
if any(isinstance(value, bool) or isinstance(value, float) and
(not math.isfinite(value) or not value.is_integer()) for value in values):
raise ValueError("Non-integral protocol parameter")
function_code, address, count = (int(value) for value in values)
except (TypeError, ValueError, OverflowError):
errors.append(f"{device_id} 第{index}个Modbus规则参数必须是整数")
continue
if function_code not in (1, 2, 3, 4):
errors.append(f"{device_id} 自动检测只允许Modbus只读功能码1、2、3、4")
if not 0 <= address <= 65535 or not 1 <= count <= 2000:
errors.append(f"{device_id} 第{index}个Modbus规则address/count超出范围")
try:
validate_modbus_read(function_code, address, count)
except ProtocolConfigError as exc:
errors.append(f"{device_id} 第{index}个Modbus规则: {exc}")
operation = str(probe.get("operation") or protocol.get("operation") or "read").lower()
if operation == "write" and not self.allow_write_tests():
errors.append(f"{device_id} 配置了写测试,但settings.allow_write_tests不是true")
Expand Down
30 changes: 19 additions & 11 deletions core/protocol_engine.py
Original file line number Diff line number Diff line change
Expand Up @@ -140,13 +140,6 @@ def match_response(data: bytes, rule: Optional[Dict[str, object]] = None) -> Tup
if not data:
return False, "未收到响应"

if "one_of" in rule:
matches = [match_response(data, item or {}) for item in rule.get("one_of", []) or []]
for ok, note in matches:
if ok:
return True, note
return False, "响应不符合任一候选规则"

length = len(data)
if "min_length" in rule and length < int(rule["min_length"]):
return False, f"响应长度{length}小于要求{rule['min_length']}"
Expand Down Expand Up @@ -181,12 +174,28 @@ def match_response(data: bytes, rule: Optional[Dict[str, object]] = None) -> Tup
return False, "响应十六进制内容不符合正则表达式"
if rule.get("checksum") not in (None, "") and not _check_crc(data, str(rule["checksum"])):
return False, "响应校验和不正确"
if "one_of" in rule:
for item in rule.get("one_of", []) or []:
ok, note = match_response(data, item or {})
if ok:
return True, note
return False, "响应不符合任一候选规则"
return True, f"响应符合配置规则({length}字节)"


def build_modbus_rtu_read(slave_id: int, function_code: int, address: int, count: int) -> bytes:
def validate_modbus_read(function_code: int, address: int, count: int) -> None:
"""Validate read PDU limits before bytes are packed or hardware is opened."""
if any(type(value) is not int for value in (function_code, address, count)):
raise ProtocolConfigError("Modbus功能码、地址和数量必须是整数")
if function_code not in READ_ONLY_MODBUS_FUNCTIONS:
raise ProtocolConfigError(f"Modbus RTU自动检测只允许只读功能码1、2、3、4,当前为{function_code}")
raise ProtocolConfigError(f"Modbus自动检测只允许只读功能码1、2、3、4,当前为{function_code}")
maximum = 125 if function_code in (3, 4) else 2000
if not 0 <= address <= 65535 or not 1 <= count <= maximum or address + count > 65536:
raise ProtocolConfigError(f"Modbus读取地址/数量超出范围:功能码{function_code}数量须为1到{maximum}且不能跨越65535地址")


def build_modbus_rtu_read(slave_id: int, function_code: int, address: int, count: int) -> bytes:
validate_modbus_read(function_code, address, count)
payload = bytes([
slave_id & 0xFF,
function_code & 0xFF,
Expand Down Expand Up @@ -237,8 +246,7 @@ def find_modbus_rtu_response(data: bytes, slave_id: int, function_code: int,

def build_modbus_tcp_read(transaction_id: int, unit_id: int, function_code: int,
address: int, count: int) -> bytes:
if function_code not in READ_ONLY_MODBUS_FUNCTIONS:
raise ProtocolConfigError(f"Modbus TCP自动检测只允许只读功能码1、2、3、4,当前为{function_code}")
validate_modbus_read(function_code, address, count)
pdu = bytes([
function_code & 0xFF,
(address >> 8) & 0xFF,
Expand Down
6 changes: 6 additions & 0 deletions docs/API.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ Base URL defaults to `http://127.0.0.1:8080`. JSON is UTF-8. When `QLDC_ACCESS_T

Exports accept `lang=zh` (default) or `lang=en`. HTML headings, labels and explanations use that language; metadata, configuration and raw evidence stay original. JSON/CSV schemas and content remain unchanged by language. Unsupported language values return400. Example: `/api/jobs/{id}/export?format=html&lang=en`.

CSV appends `parent_job_id` as its final,30th column. An ordinary task (including an older record without that field) exports an empty cell; a retest exports its immediate source task ID. The value comes from the task record, not a per-device field. The previous29 columns keep their positions and values, including quoting of commas, newlines and Unicode evidence. CSV readers should use the header; consumers requiring exactly29 columns need updating for this additive field. JSON and HTML keep their existing parent-link representation.

| Method | Route | Behavior |
| --- | --- | --- |
| GET | `/api/health` | Service mode, version, active job and persistence status |
Expand Down Expand Up @@ -34,6 +36,8 @@ Example task payload:

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`.

The supported catalog hierarchy is a parent with direct children. A child cannot contain further nonempty `child_devices`; configuration validation rejects deeper trees before task persistence or adapter invocation. Empty/omitted child lists remain valid. This prevents an unsupported descendant from silently disappearing from the expected-device count.

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
Expand All @@ -44,6 +48,8 @@ Task IDs are 32 lowercase hex characters. Times are ISO 8601 UTC. Status: `queue

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.

If a worker cannot persist either its outcome or a failed terminal state, `/api/health` reports `persistence_ready: false` and `persistence_error: {"code":"PERSISTENCE_UNAVAILABLE","job_id":"...","restart_required":true}`. New tasks, retests and legacy operations are denied until restart, even if storage becomes writable again. The last durable task can remain `running`; after storage is repaired, restart marks unfinished records `interrupted` without replaying hardware. Back up the database before recovery. HTTP 200 from health only means the endpoint responded; inspect the persistence fields before starting work.

## 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.
19 changes: 19 additions & 0 deletions docs/VALIDATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,8 +48,27 @@ See [DEMO.md](DEMO.md) for the optional browser workflow. CI runs the Python off
- Browser workflow: the new bilingual workflow passed on Chromium with English and Chinese switching, task creation, evidence display, English HTML download, failed-only retest, history filters, cross-page comparison selection retention, reload preference, blocked `localStorage` fallback, the compatibility scan page and 390 px layout. The existing Chinese workflow also passed with zero page errors.
- API boundary: omitted `lang` keeps the Chinese default; `lang=zh` and `lang=en` return the requested report language; unsupported or explicitly blank language values return HTTP 400. No live device write or unknown hardware operation was used by these checks.

## Recorded results — 2026-10-15 reliability review

The AI-assisted review used public baseline `c7e3ddcf6e6594ea9874cd63b870183bdd6850ea`, isolated source copies and an independent clone for the merge candidate. No real devices were accessed and no new dependency was installed during the local verification. Existing CPython 3.13.0, pytest 8.4.2 and coverage 7.10.6 were used on Windows.

- Full isolated-clone regression: **212 passed, zero failed/skipped**, in 38.01 seconds. The public baseline had 100 tests; the added regressions cover lifecycle, real temporary SQLite lock/commit/bootstrap/recovery faults, worker startup failure, stale/conflicting callbacks, repeated cancellation, HTTP admission/framing/teardown, protocol boundaries, nested-device rejection and CSV retest provenance.
- Original offline self-test: **23 checks passed**, including actual TCP/UDP exchange with loopback fixtures. Configuration validation passed with 5 root devices, 1 child and writes disabled. Python compilation passed.
- Broad runtime coverage, using `--branch --source=. --omit=tests/*,tools/*,self_test.py,validate_config.py`: **1,816/3,627 executable lines (50.07%)** and **558/1,414 branches (39.46%)**. This is a broader denominator than earlier targeted figures; it is not an 80% whole-project coverage claim.
- Real bounded HTTP tests deliver 401/403 JSON while a rejected body is already in flight and verify teardown byte/time limits. They do not establish resilience against arbitrary internet load.
- CSV tests exercise an actual three-task Demo retest chain and the loopback export endpoint. All original 29 columns/cells are preserved; column 30 contains the immediate `parent_job_id` (empty for roots). English/Chinese exports share the same CSV schema. Exact-29-column external consumers require adaptation.
- Storage failure cannot manufacture a durable terminal result when writes are unavailable. The tested response is explicit degradation, refusal of further operations, then interrupted-state recovery after repair/restart; there is no automatic hardware retry.

Linux and Python 3.10 were not executed locally during this review. Use the final PR's existing Windows/Linux × Python 3.10/3.13 CI checks for remote results. Browser checks are separate: the earlier 203-test candidate was exercised on desktop/mobile with synthetic faults, retest, reload, keyboard controls and exports; the added CSV lineage column is covered by Python/API tests, not a new browser acceptance claim. No new release bundle or physical-device certification is implied.

Pre-merge review found and reproduced one additional compound failure: a worker could fail to start while its fallback terminal write also failed, leaving a queued record without the persistence diagnostic. The guarded fallback now sets the same restart-required state and preserves the original startup exception. A real temporary SQLite-trigger regression checks blocked creation/retest/legacy work, continued refusal after storage repair, and restart recovery without replay. The final staged Git tree, including normalized CSV/JSON fixtures, passed **213 tests in 34.70 seconds**, plus configuration validation, all 23 self-checks and compilation. Final broad runtime coverage is **1,819/3,630 lines (50.11%)** and **558/1,414 branches (39.46%)**, using the same command above. This final run supersedes the earlier 212-test candidate; no physical hardware or sustained-load conclusion is inferred.

## Scope and limits

The first PR matrix at candidate `17b3f4a` passed three jobs but its Windows/Python 3.13 job timed out waiting eight seconds for a preexisting Node helper subprocess (212 other tests passed). The same candidate's separate push matrix passed all four jobs in [run 38734469352](https://github.com/KaiserIIII/QLDeviceCheck_Generic_WebUI_Linux/actions/runs/38734469352); the failed PR run is retained as [run 38734503505](https://github.com/KaiserIIII/QLDeviceCheck_Generic_WebUI_Linux/actions/runs/38734503505). Those logs do not establish where the delay occurred or prove a product/runtime defect.

The test harness now gives each real fetch a three-second deadline, requires response statuses `[200,200,401]` and unauthorized JSON `ok:false`, emits request/completion evidence, and allows a separate 20-second subprocess lifecycle budget. A server-token mismatch must fail with the specific auth error and no success receipt. It uses natural Node shutdown. The final Git-tree export passed **214 tests in 36.00 seconds** (including both harness controls), with all 23 self-checks, configuration validation and compilation still passing. Runtime coverage is unchanged from the preceding 213-test result. Inspect final PR checks for the new head's cross-platform outcome; the earlier timeout is not hidden or counted as a production bug.

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.
Expand Down
Loading
Loading