feat: add HIL diagnostics and meter health handling
This commit is contained in:
@@ -0,0 +1,15 @@
|
||||
# Local HIL tests
|
||||
|
||||
The Python unit tests validate trace parsing, simulator fixtures, and strict evidence correlation without opening hardware. The `hil`-marked test reads an already completed artifact directory; pytest itself never opens or flashes a serial device.
|
||||
|
||||
```bash
|
||||
python3 -m venv .venv-hil
|
||||
.venv-hil/bin/pip install -r requirements-hil.txt
|
||||
.venv-hil/bin/pytest -v
|
||||
HIL_ARTIFACT=artifacts/hil/<timestamp> .venv-hil/bin/pytest -v -m hil hil_tests/
|
||||
HIL_ARTIFACT=artifacts/hil/<fault-timestamp> HIL_FAULT=suppress_ack .venv-hil/bin/pytest -v -m hil hil_tests/
|
||||
```
|
||||
|
||||
A missing real meter is represented as `BLOCKED`/JUnit `skipped`, not as a firmware failure. A missing boot, role, time-sync, batch, decode, or ACK event is a failure because there is no corresponding device evidence.
|
||||
|
||||
Live serial access is performed only by the explicitly invoked tools in `tools/hil/`, after `.hil/local.toml` approval checks.
|
||||
@@ -0,0 +1,104 @@
|
||||
#include "meter_parser.h"
|
||||
#include "meter_health.h"
|
||||
|
||||
#include <assert.h>
|
||||
#include <math.h>
|
||||
#include <stdio.h>
|
||||
#include <string.h>
|
||||
#include <string>
|
||||
|
||||
static std::string frame(const char *energy_line = "1-0:1.8.0*255(001234.567*kWh)",
|
||||
const char *seconds = "00000064",
|
||||
bool terminator = true) {
|
||||
std::string value = "/DD3HIL\r\n0-0:96.8.0*255(";
|
||||
value += seconds;
|
||||
value += ")\r\n";
|
||||
if (energy_line) {
|
||||
value += energy_line;
|
||||
value += "\r\n";
|
||||
}
|
||||
value += "1-0:16.7.0*255(000950*W)\r\n";
|
||||
value += "1-0:36.7.0*255(000500*W)\r\n";
|
||||
value += "1-0:56.7.0*255(000450*W)\r\n";
|
||||
value += "1-0:76.7.0*255(000000*W)\r\n";
|
||||
if (terminator) value += "!\r\n";
|
||||
return value;
|
||||
}
|
||||
|
||||
static MeterParseResult parse(const std::string &telegram) {
|
||||
MeterParseResult result = {};
|
||||
meter_parser_parse(telegram.data(), telegram.size(), result);
|
||||
return result;
|
||||
}
|
||||
|
||||
int main() {
|
||||
MeterHealthState health_state = {};
|
||||
MeterHealthEvaluation health = meter_health_evaluate(health_state, false, UINT32_MAX, 15000);
|
||||
assert(!health.ok && health.fault_started && !health.recovered);
|
||||
health = meter_health_evaluate(health_state, false, UINT32_MAX, 15000);
|
||||
assert(!health.ok && !health.fault_started && !health.recovered);
|
||||
health = meter_health_evaluate(health_state, true, 100, 15000);
|
||||
assert(health.ok && !health.fault_started && health.recovered);
|
||||
health = meter_health_evaluate(health_state, true, 15001, 15000);
|
||||
assert(!health.ok && health.fault_started && !health.recovered);
|
||||
|
||||
MeterParseResult valid = parse(frame());
|
||||
assert(valid.valid);
|
||||
assert(valid.status == MeterParseStatus::Valid);
|
||||
assert(valid.fields_mask == METER_REQUIRED_FIELDS_MASK);
|
||||
assert(valid.meter_seconds_valid && valid.meter_seconds == 100);
|
||||
assert(fabsf(valid.energy_total_kwh - 1234.567f) < 0.001f);
|
||||
|
||||
MeterParseResult wh = parse(frame("1-0:1.8.0*255(1234567*Wh)"));
|
||||
assert(wh.valid);
|
||||
assert(fabsf(wh.energy_total_kwh - 1234.567f) < 0.001f);
|
||||
|
||||
MeterParseResult missing = parse(frame(nullptr));
|
||||
assert(!missing.valid && missing.status == MeterParseStatus::MissingRequired);
|
||||
|
||||
MeterParseResult bad_seconds = parse(frame(nullptr, "GGGGGGGG"));
|
||||
assert(!bad_seconds.valid && bad_seconds.status == MeterParseStatus::InvalidMeterSeconds);
|
||||
|
||||
MeterParseResult bad_unit = parse(frame("1-0:1.8.0*255(1234*J)"));
|
||||
assert(!bad_unit.valid && bad_unit.status == MeterParseStatus::MalformedValue);
|
||||
|
||||
MeterParseResult no_end = parse(frame(nullptr, "00000064", false));
|
||||
assert(!no_end.valid && no_end.status == MeterParseStatus::MissingTerminator);
|
||||
|
||||
MeterParseResult false_prefix = parse(frame("X1-0:1.8.0*255(1234*kWh)"));
|
||||
assert(!false_prefix.valid && false_prefix.status == MeterParseStatus::MissingRequired);
|
||||
|
||||
MeterFrameCollector collector = {};
|
||||
meter_frame_collector_init(collector);
|
||||
uint32_t now = 0;
|
||||
for (char c : std::string("/truncated\r\n")) {
|
||||
meter_frame_collector_feed(collector, c, now += 20);
|
||||
}
|
||||
std::string recovery = frame();
|
||||
assert(meter_frame_collector_feed(collector, recovery[0], now += 20) == MeterFrameEvent::Resync);
|
||||
MeterFrameEvent event = MeterFrameEvent::None;
|
||||
for (size_t i = 1; i < recovery.size(); ++i) {
|
||||
event = meter_frame_collector_feed(collector, recovery[i], now += 20);
|
||||
if (event == MeterFrameEvent::Complete) break;
|
||||
}
|
||||
assert(event == MeterFrameEvent::Complete);
|
||||
MeterParseResult recovered = {};
|
||||
assert(meter_parser_parse(collector.buffer, collector.length, recovered));
|
||||
|
||||
meter_frame_collector_init(collector);
|
||||
meter_frame_collector_feed(collector, '/', 10);
|
||||
assert(meter_frame_collector_check_timeout(collector, 1510, 1500) == MeterFrameEvent::None);
|
||||
assert(meter_frame_collector_check_timeout(collector, 1511, 1500) == MeterFrameEvent::Timeout);
|
||||
|
||||
meter_frame_collector_init(collector);
|
||||
meter_frame_collector_feed(collector, '/', 0);
|
||||
event = MeterFrameEvent::None;
|
||||
for (size_t i = 0; i < METER_FRAME_MAX; ++i) {
|
||||
event = meter_frame_collector_feed(collector, 'A', static_cast<uint32_t>(i + 1));
|
||||
if (event == MeterFrameEvent::Overflow) break;
|
||||
}
|
||||
assert(event == MeterFrameEvent::Overflow);
|
||||
|
||||
puts("meter parser and collector tests passed");
|
||||
return 0;
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(ROOT / "tools" / "hil"))
|
||||
|
||||
from hil_common import parse_hil_line
|
||||
from meter_simulator import fixture, frame
|
||||
from report import evaluate
|
||||
|
||||
|
||||
def test_parse_structured_trace():
|
||||
parsed = parse_hil_line(b'noise HIL:{"event":"boot","ms":12}\r\n')
|
||||
assert parsed == {"event": "boot", "ms": 12}
|
||||
assert parse_hil_line(b'HIL:not-json\n') is None
|
||||
|
||||
|
||||
def test_meter_fixtures_are_bounded_and_recoverable():
|
||||
valid = frame()
|
||||
assert valid.startswith(b"/") and b"0-0:96.8.0*255" in valid and b"1-0:1.8.0*255" in valid
|
||||
assert valid.rstrip().endswith(b"!")
|
||||
malformed_then_valid = fixture("malformed_then_valid")
|
||||
assert len(malformed_then_valid) == 2
|
||||
assert malformed_then_valid[-1][0] == valid
|
||||
assert len(fixture("oversized")[0][0]) > 512
|
||||
|
||||
|
||||
def test_report_requires_end_to_end_batch_correlation():
|
||||
events = [
|
||||
{"event": "role", "source": "sender", "role": "sender"},
|
||||
{"event": "role", "source": "receiver", "role": "receiver"},
|
||||
{"event": "boot", "source": "sender"},
|
||||
{"event": "boot", "source": "receiver"},
|
||||
{"event": "time_bootstrap", "source": "sender", "stage": "complete", "ok": True},
|
||||
{"event": "meter_frame", "source": "sender", "ok": True},
|
||||
{"event": "health", "source": "sender"},
|
||||
{"event": "health", "source": "receiver"},
|
||||
{"event": "batch_created", "source": "sender", "batch_id": 7},
|
||||
{"event": "reassembly_complete", "source": "receiver", "batch_id": 7},
|
||||
{"event": "payload_decode", "source": "receiver", "batch_id": 7, "ok": True},
|
||||
{"event": "ack_tx", "source": "receiver", "batch_id": 7, "ok": True},
|
||||
{"event": "ack_rx", "source": "sender", "batch_id": 7, "ok": True},
|
||||
]
|
||||
results = {check.name: check.status for check in evaluate(events)}
|
||||
assert results["batch and ACK correlation"] == "pass"
|
||||
assert all(status == "pass" for status in results.values())
|
||||
@@ -0,0 +1,54 @@
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(ROOT / "tools" / "hil"))
|
||||
|
||||
from hil_common import read_events
|
||||
from report import evaluate
|
||||
|
||||
|
||||
@pytest.mark.hil
|
||||
def test_captured_baseline_evidence():
|
||||
artifact_text = os.environ.get("HIL_ARTIFACT", "")
|
||||
if not artifact_text:
|
||||
pytest.skip("set HIL_ARTIFACT to a completed artifacts/hil/<timestamp> directory")
|
||||
artifact = Path(artifact_text)
|
||||
checks = evaluate(read_events(artifact))
|
||||
failures = [f"{check.name}: {check.detail}" for check in checks if check.status == "fail"]
|
||||
assert not failures, "\n".join(failures)
|
||||
|
||||
|
||||
@pytest.mark.hil
|
||||
def test_captured_fault_recovery_evidence():
|
||||
artifact_text = os.environ.get("HIL_ARTIFACT", "")
|
||||
requested_fault = os.environ.get("HIL_FAULT", "")
|
||||
if not artifact_text or not requested_fault:
|
||||
pytest.skip("set HIL_ARTIFACT and HIL_FAULT to a completed fault-run artifact")
|
||||
checks = evaluate(read_events(Path(artifact_text)), requested_fault)
|
||||
fault_check = next(check for check in checks if check.name == f"fault {requested_fault}")
|
||||
assert fault_check.status == "pass", fault_check.detail
|
||||
|
||||
|
||||
@pytest.mark.hil
|
||||
def test_meter_outage_is_counted_once_and_not_published():
|
||||
artifact_text = os.environ.get("HIL_ARTIFACT", "")
|
||||
expect_no_meter = os.environ.get("HIL_EXPECT_NO_METER", "") == "1"
|
||||
if not artifact_text or not expect_no_meter:
|
||||
pytest.skip("set HIL_ARTIFACT and HIL_EXPECT_NO_METER=1 for a no-meter capture")
|
||||
events = read_events(Path(artifact_text))
|
||||
sender = [event for event in events if event.get("source") == "sender"]
|
||||
samples = [event for event in sender if event.get("event") == "meter_sample"]
|
||||
active_faults = [event for event in sender
|
||||
if event.get("event") == "meter_fault" and event.get("state") == "active"]
|
||||
assert samples, "no meter sample evidence captured"
|
||||
assert all(event.get("ok") is False and event.get("classification") == "no_data"
|
||||
for event in samples)
|
||||
assert len(active_faults) == 1, active_faults
|
||||
assert not [event for event in sender if event.get("event") == "batch_created"]
|
||||
assert not [event for event in sender
|
||||
if event.get("event") == "queue" and event.get("action") == "enqueue"]
|
||||
@@ -0,0 +1,33 @@
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
def test_production_meter_parser_and_collector(tmp_path):
|
||||
executable = tmp_path / "meter_parser_test"
|
||||
compile_result = subprocess.run(
|
||||
[
|
||||
"g++",
|
||||
"-std=c++17",
|
||||
"-Wall",
|
||||
"-Wextra",
|
||||
"-Werror",
|
||||
f"-I{ROOT / 'include'}",
|
||||
str(ROOT / "src" / "meter_health.cpp"),
|
||||
str(ROOT / "src" / "meter_parser.cpp"),
|
||||
str(ROOT / "hil_tests" / "meter_parser_harness.cpp"),
|
||||
"-o",
|
||||
str(executable),
|
||||
],
|
||||
check=False,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
assert compile_result.returncode == 0, compile_result.stderr
|
||||
run_result = subprocess.run(
|
||||
[str(executable)], check=False, capture_output=True, text=True
|
||||
)
|
||||
assert run_result.returncode == 0, run_result.stderr
|
||||
assert "tests passed" in run_result.stdout
|
||||
Reference in New Issue
Block a user