Metadata-Version: 2.4
Name: ust-format-checker
Version: 0.1.3
Summary: Checks that xDOS firmware keeps its output format backward compatible
Author: Universal Scientific Technologies
License-Expression: GPL-3.0-or-later
Project-URL: Repository, https://github.com/UniversalScientificTechnologies/DOSPORTAL
Project-URL: Issues, https://github.com/UniversalScientificTechnologies/DOSPORTAL/issues
Keywords: xdos,dosimetry,firmware,simavr,platformio,backward-compatibility
Classifier: Development Status :: 3 - Alpha
Classifier: Intended Audience :: Developers
Classifier: Operating System :: POSIX :: Linux
Classifier: Programming Language :: Python :: 3
Classifier: Topic :: Software Development :: Embedded Systems
Classifier: Topic :: Software Development :: Testing
Requires-Python: >=3.10
Description-Content-Type: text/markdown
License-File: LICENSE
Requires-Dist: PyYAML>=6
Dynamic: license-file

# UST Format Checker

Checks that xDOS firmware keeps its output format backward compatible and in step with the
documentation, by running the firmware in [simavr](https://github.com/buserror/simavr) against
a deterministic scenario and inspecting what it prints on the UART.

Plan and requirements: [PLAN.md](docs/PLAN.md). Per-message rules: [CHECKS.md](docs/CHECKS.md).
Where docs, parser and firmware disagree: [DOC_DEVIATIONS.md](docs/DOC_DEVIATIONS.md).

**The checker only knows format version 2.1.** Every capture has to name its own format with
`$DATAFORMAT`; without it the check fails. Older data is still readable by DOSPORTAL, but
firmware is only released as 2.1.

## Installing

```sh
pip install ust-format-checker
```

Needs simavr to run a firmware (`apt install simavr libsimavr-dev libelf-dev gcc`). Installed
commands: `xdos-check` (a capture), `xdos-check-firmware` (an ELF, end to end), `xdos-build`
(board + scenario -> runner directives).

In a device repository you normally do not install it by hand - the PlatformIO hook pins a
version in `xdos/checker.txt` and builds its own venv from it.

Installed standalone, layer 3 is skipped and says so: `packages.parsing` lives in DOSPORTAL and
is not a package of its own yet. Run from a DOSPORTAL checkout to get it.

## Layout

- [messages.yaml](messages.yaml) - the message schema: fields, types, ranges, which rules apply.
- [components/](components) - what a component is, which runner directive drives it and which messages it makes the device produce.
- [schema.py](schema.py), [checks.py](checks.py), [rules.py](rules.py) - schema loading and layer 1 checks.
- [emitted.py](emitted.py) - a message a component on the board produces must come out.
- [parser_layer.py](parser_layer.py) - layer 3: what `packages.parsing` reads out of the same log.
- [stimulus_checks.py](stimulus_checks.py) - the value the simulation injected has to be the value that comes out.
- [findings.py](findings.py), [report.py](report.py), [cli.py](cli.py) - findings, the rustc-style report and the `xdos-check` entry point.
- [simulate.py](simulate.py) - compiles the runner and runs it; no container in the loop.
- [firmware.py](firmware.py) - the whole chain in one command, plus the verdict cache.
- [platformio.py](platformio.py) - what the device-repository hook calls; holds every path convention.
- [pytest_plugin.py](pytest_plugin.py) - the `capture` fixture, so a device repository needs no conftest.
- [regressions/](regressions) - real captures carrying format bugs the checker must keep catching.
  Only genuine captures belong here; a hand-written one shares its blind spots with the rule it
  guards, so that case goes in a unit test.
- Which component sits at which address or pin, and what the simulation injects, belong to the
  device repository as `xdos/board.yaml` and `xdos/scenarios/*.yaml`. A device with no scenario
  of its own gets one generated from its board.
- [scenario.py](scenario.py), [build_scenario.py](build_scenario.py) - board + scenario -> runner directives + the stimulus to check against.
- [simulator/runner.c](simulator/runner.c) - loads the firmware ELF into simavr, attaches component models, replays the directives, prints the UART.

## Checking a captured log

```sh
cd backend
python -m packages.ust_format_checker <log>                        # schema + parser layer
python -m packages.ust_format_checker <log> --board <board.yaml>   # + messages the board must produce
python -m packages.ust_format_checker <log> --stimulus <json>      # + injected values (from xdos-build)
python -m packages.ust_format_checker <log> --no-parser            # schema only
python -m packages.ust_format_checker <log> --warnings-as-errors   # CI profile
```

Exit code 1 means the log failed. Layer 3 (`packages.parsing`) never produces an error on its
own: the parser can be wrong just as easily as the firmware, so its findings are warnings that
say so.

With `--board`, a message that a component on the board produces and the capture does not
contain is an error. Without it the checker cannot tell "this device has no such sensor" from
"the firmware stopped printing it", and has to treat both as the same piece of information.

## Checking a firmware ELF

The whole chain in one command - this is what the PlatformIO hook in a device repository calls:

```sh
xdos-check-firmware firmware.elf --board xdos/board.yaml --scenario xdos/scenarios/basic.yaml \
    --cache .pio/build/<env>/xdos/last.json
```

`--cache` remembers the verdict against the ELF fingerprint: an unchanged binary skips the run
but the finding is printed again, so it cannot quietly disappear after the first build. A
missing simulator is reported and exits 0, so a firmware build never fails because the checker
could not run.

The simulation also reports any I2C address the firmware talked to that `board.yaml` does not
know (`UNMODELLED_DEVICE`). Without it an incomplete board model makes the check silently test
less than it appears to.

## In a device repository

The hook and the path conventions live in [platformio.py](platformio.py), so changing any of
them is a version bump rather than a pull request against every device repository:

```sh
python -m ust_format_checker.platformio <project_dir> <build_dir> <elf>        # local, warns
python -m ust_format_checker.platformio --ci <project_dir> <build_dir> <elf>   # CI, blocks
```

`--ci` turns warnings into failures and drops the verdict cache. Under GitHub Actions the
findings are also emitted as annotations, on stderr so they stay out of the report itself.

Tests of the device's own behaviour need nothing but a test file - the package registers as a
pytest plugin and supplies the `capture` fixture:

```python
def test_events_only_appear_inside_a_block(capture):
    assert len(capture.messages("$START")) == len(capture.messages("$STOP"))
```

## Working on the checker

Linux, natively, against an ELF PlatformIO has already built. No container in the loop - the
run takes under a second, so the edit-run cycle stays tight:

```sh
sudo apt install simavr libsimavr-dev libelf-dev gcc   # once
cd backend
A=~/AIRDOS03/fw/AIRDOS03_USTDFF

python -m packages.ust_format_checker.firmware $A/.pio/build/TFUNIPAYLOAD01_uart/firmware.elf \
    --board $A/xdos/board.yaml --scenario $A/xdos/scenarios/basic.yaml \
    --work-dir /tmp/xdos-dev --no-parser
```

`--work-dir` keeps what the run produced: `capture.txt`, the compiled `run.scenario` and
`run.stimulus.json`. Reading `capture.txt` is usually the fastest way to understand a finding.

Most of the test suite needs none of this and runs in under two seconds. The `-c /dev/null` is
there because the DOSPORTAL checkout carries a `pytest.ini` full of Django plugins this package
does not use:

```sh
python -m pytest packages/ust_format_checker -q -c /dev/null
```

To see the checker the way a firmware developer does, run `pio run` in the device repository
with `XDOS_CHECKER_PATH` pointing at this checkout - see that repository's `xdos/README.md`.

## Board and scenario

A device is described by which components it carries:

```yaml
# xdos/board.yaml
device: AIRDOS03B
mcu: atmega1284
f_cpu: 8000000
millis_symbol: timer0_millis     # the counter that paces a measurement block
seconds_symbol: rtc_seconds      # the device clock, moved forward along with it
parts:
  - {component: i2c_eeprom, addr: 0x5B, params: {offset: "0800", data: "0011...EEFF"}}
  - {component: sht31, addr: 0x45}
  - {component: spi_adc_pulse, pins: {conv: PB0, reset: PC2}}
  - {component: gnss, pins: {pps: PD4}}
```

A scenario says what to inject. Values may be `rand(min, max)` within the component's range;
the seed comes from the scenario name, so runs stay reproducible:

```yaml
name: basic
stop_blocks: 4
sensors:
  sht31:
    temp_c: rand(-40, 85)
    humidity: rand(0, 100)
events:
  at: 1.2
  channels: [0, 12, 12, 40, 63, 64, 100, 500, 1023]
gnss:
  fix_at: 1.0
  unix: 1789560000
```

Write a `rand(min, max)` on its own line, never inside a `{...}` flow mapping: the comma inside
the call splits the mapping, so `{temp_c: rand(-40, 85)}` silently becomes `temp_c: 'rand(-40'`
plus a key named `85)`. The scenario is rejected with an error that says so.

Skipping the idle wait between measurement blocks means moving `millis_symbol` forward. A device
whose clock runs off something else - AIRDOS03 counts 1PPS pulses - would then have the two drift
apart, and the time in the output could not be compared with anything. That is what
`seconds_symbol` is for: both counters move together, so `$STOP` times stay meaningful and the
run still finishes in under a second. Without it the time checks are skipped.

`xdos-build` turns the two into runner directives plus a stimulus file, and `--stimulus` then
checks that every injected event, sensor reading and GNSS time comes back out of the firmware.
A device with no scenario of its own gets one generated from its board.

## Runner directives

The compiled intermediate the simulator reads, one directive per line, times in simulated
seconds:

| Directive | Meaning |
|---|---|
| `i2c_memory <addr> <reg_bytes> <offset> <hex>` | I2C memory (EEPROM) with preset bytes |
| `sht31 <addr> <temp_c> <humidity>` | SHT31 returning a fixed reading |
| `adc <conv_port> <conv_pin> <reset_port> <reset_pin>` | USTSIPIN ADC: CONV high until DRESET, value over SPI |
| `adc_pulse <t> <value>` | one detected pulse |
| `pps <port> <pin> <t> <count>` | 1PPS pulses every second |
| `uart1_line <t> <text>` | text line sent to UART1 (GNSS) |
| `warp_u32 <symbol\|0xaddr> <t> <add> [period] [count]` | adds to a 32-bit counter in the firmware's RAM; the symbol is resolved from the ELF |

`warp_u32` reschedules itself when `period` and `count` are given, which is how one directive
covers a whole run: simavr has only 64 timer slots and the peripherals have to fit in too, so
one warp per step would break the simulation.

Generated from YAML, so the only reason to read them is debugging a simulation.
