ust-format-checker 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- ust_format_checker/__init__.py +16 -0
- ust_format_checker/__main__.py +5 -0
- ust_format_checker/build.py +34 -0
- ust_format_checker/checks.py +253 -0
- ust_format_checker/cli.py +90 -0
- ust_format_checker/compat.py +229 -0
- ust_format_checker/components/gnss.yaml +12 -0
- ust_format_checker/components/i2c_eeprom.yaml +8 -0
- ust_format_checker/components/sht31.yaml +9 -0
- ust_format_checker/components/spi_adc_pulse.yaml +9 -0
- ust_format_checker/findings.py +38 -0
- ust_format_checker/firmware.py +139 -0
- ust_format_checker/messages.yaml +112 -0
- ust_format_checker/parser_layer.py +141 -0
- ust_format_checker/platformio.py +51 -0
- ust_format_checker/pytest_plugin.py +79 -0
- ust_format_checker/report.py +94 -0
- ust_format_checker/rules.py +211 -0
- ust_format_checker/scenario.py +271 -0
- ust_format_checker/schema.py +81 -0
- ust_format_checker/simulate.py +92 -0
- ust_format_checker/simulator/runner.c +422 -0
- ust_format_checker/stimulus_checks.py +204 -0
- ust_format_checker-0.1.0.dist-info/METADATA +199 -0
- ust_format_checker-0.1.0.dist-info/RECORD +29 -0
- ust_format_checker-0.1.0.dist-info/WHEEL +5 -0
- ust_format_checker-0.1.0.dist-info/entry_points.txt +7 -0
- ust_format_checker-0.1.0.dist-info/licenses/LICENSE +674 -0
- ust_format_checker-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
from .checks import check_log
|
|
2
|
+
from .findings import Finding, Level, counts_by_level, has_errors
|
|
3
|
+
from .parser_layer import check_with_parser
|
|
4
|
+
from .report import render
|
|
5
|
+
from .schema import load_schema
|
|
6
|
+
|
|
7
|
+
__all__ = [
|
|
8
|
+
"Finding",
|
|
9
|
+
"Level",
|
|
10
|
+
"check_log",
|
|
11
|
+
"check_with_parser",
|
|
12
|
+
"counts_by_level",
|
|
13
|
+
"has_errors",
|
|
14
|
+
"load_schema",
|
|
15
|
+
"render",
|
|
16
|
+
]
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import argparse
|
|
2
|
+
import sys
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
from .scenario import build, load_board, load_scenario, write_stimulus
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def main(argv: list[str] | None = None) -> int:
|
|
9
|
+
parser = argparse.ArgumentParser(
|
|
10
|
+
prog="xdos-build",
|
|
11
|
+
description="Sestaví z board.yaml a scénáře vstup pro simulátor.",
|
|
12
|
+
)
|
|
13
|
+
parser.add_argument("--board", type=Path, required=True)
|
|
14
|
+
parser.add_argument("--scenario", type=Path, help="bez něj se použije výchozí scénář z osazení desky")
|
|
15
|
+
parser.add_argument("--out", type=Path, required=True, help="kam uložit direktivy pro runner")
|
|
16
|
+
parser.add_argument("--stimulus-out", type=Path, help="kam uložit vyslané hodnoty pro kontrolu výstupu")
|
|
17
|
+
args = parser.parse_args(argv)
|
|
18
|
+
|
|
19
|
+
board = load_board(args.board)
|
|
20
|
+
scenario = load_scenario(args.scenario) if args.scenario else None
|
|
21
|
+
stimulus = build(board, scenario)
|
|
22
|
+
|
|
23
|
+
args.out.parent.mkdir(parents=True, exist_ok=True)
|
|
24
|
+
args.out.write_text(stimulus.directives, encoding="utf-8")
|
|
25
|
+
if args.stimulus_out:
|
|
26
|
+
args.stimulus_out.parent.mkdir(parents=True, exist_ok=True)
|
|
27
|
+
write_stimulus(stimulus, args.stimulus_out)
|
|
28
|
+
|
|
29
|
+
print(f"{board.device} / {stimulus.scenario}: {args.out} (seed {stimulus.seed})")
|
|
30
|
+
return 0
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
if __name__ == "__main__":
|
|
34
|
+
sys.exit(main())
|
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
import re
|
|
2
|
+
from dataclasses import dataclass, field
|
|
3
|
+
|
|
4
|
+
from .findings import Finding, Level
|
|
5
|
+
from .rules import RULES, LogState
|
|
6
|
+
from .schema import FieldSpec, MessageSpec, Schema, load_schema
|
|
7
|
+
|
|
8
|
+
_UINT_RE = re.compile(r"^\d+$")
|
|
9
|
+
_INT_RE = re.compile(r"^-?\d+$")
|
|
10
|
+
_TM_RE = re.compile(r"^(\d+)\.(\d{1,2})$")
|
|
11
|
+
_DATETIME_RE = re.compile(r"^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$")
|
|
12
|
+
|
|
13
|
+
TYPE_DESCRIPTIONS = {
|
|
14
|
+
"uint": "celé nezáporné číslo",
|
|
15
|
+
"int": "celé číslo (může být záporné)",
|
|
16
|
+
"float": "desetinné číslo",
|
|
17
|
+
"tm": "čas ve tvaru <tm>.<tm_s100>, kde tm_s100 je 0-99",
|
|
18
|
+
"datetime": "časové razítko ve tvaru YYYY-MM-DD HH:MM:SS",
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass
|
|
23
|
+
class ParsedMessage:
|
|
24
|
+
spec: MessageSpec
|
|
25
|
+
tokens: list[str]
|
|
26
|
+
values: dict[str, object] = field(default_factory=dict)
|
|
27
|
+
line_number: int = 0
|
|
28
|
+
line: str = ""
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _validate(spec: FieldSpec, token: str) -> tuple[bool, object, str]:
|
|
32
|
+
"""Return (ok, parsed value, human description of what was expected)."""
|
|
33
|
+
if spec.type == "text":
|
|
34
|
+
return True, token, "text"
|
|
35
|
+
if spec.type == "uint":
|
|
36
|
+
return (True, int(token), "") if _UINT_RE.match(token) else (False, None, TYPE_DESCRIPTIONS["uint"])
|
|
37
|
+
if spec.type == "int":
|
|
38
|
+
return (True, int(token), "") if _INT_RE.match(token) else (False, None, TYPE_DESCRIPTIONS["int"])
|
|
39
|
+
if spec.type == "float":
|
|
40
|
+
try:
|
|
41
|
+
return True, float(token), ""
|
|
42
|
+
except ValueError:
|
|
43
|
+
return False, None, TYPE_DESCRIPTIONS["float"]
|
|
44
|
+
if spec.type == "tm":
|
|
45
|
+
match = _TM_RE.match(token)
|
|
46
|
+
if not match or int(match.group(2)) > 99:
|
|
47
|
+
return False, None, TYPE_DESCRIPTIONS["tm"]
|
|
48
|
+
return True, (int(match.group(1)), int(match.group(2))), ""
|
|
49
|
+
if spec.type == "datetime":
|
|
50
|
+
if not _DATETIME_RE.match(token):
|
|
51
|
+
return False, None, TYPE_DESCRIPTIONS["datetime"]
|
|
52
|
+
return True, token, ""
|
|
53
|
+
if spec.type == "hex":
|
|
54
|
+
pattern = rf"^[0-9a-fA-F]{{{spec.length}}}$"
|
|
55
|
+
if not re.match(pattern, token):
|
|
56
|
+
return False, None, f"{spec.length} hexadecimálních znaků"
|
|
57
|
+
return True, token, ""
|
|
58
|
+
if spec.type == "enum":
|
|
59
|
+
if token not in spec.values:
|
|
60
|
+
return False, None, " nebo ".join(spec.values)
|
|
61
|
+
return True, token, ""
|
|
62
|
+
if spec.type == "labeled_hex":
|
|
63
|
+
if not re.match(rf"^{re.escape(spec.label)}=0x[0-9a-fA-F]+$", token):
|
|
64
|
+
return False, None, f"{spec.label}=0x<hex>"
|
|
65
|
+
return True, token.split("=", 1)[1], ""
|
|
66
|
+
raise ValueError(f"unknown field type: {spec.type}")
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _check_fields(message: ParsedMessage) -> list[Finding]:
|
|
70
|
+
findings = []
|
|
71
|
+
for index, spec in enumerate(message.spec.fields, start=1):
|
|
72
|
+
if index >= len(message.tokens):
|
|
73
|
+
break
|
|
74
|
+
token = message.tokens[index]
|
|
75
|
+
ok, value, expected = _validate(spec, token)
|
|
76
|
+
if not ok:
|
|
77
|
+
findings.append(
|
|
78
|
+
Finding(
|
|
79
|
+
level=spec.level,
|
|
80
|
+
code="FIELD_TYPE",
|
|
81
|
+
title=f"{message.spec.prefix}: pole {index} ({spec.name}) je {token!r}, očekává se {expected}",
|
|
82
|
+
line_number=message.line_number,
|
|
83
|
+
line=message.line,
|
|
84
|
+
shape=message.spec.shape,
|
|
85
|
+
why="Konzumenti čtou pole podle pozice a typu. Neplatná hodnota se buď "
|
|
86
|
+
"zahodí, nebo se přečte jako něco jiného.",
|
|
87
|
+
help=f"Vypisuj {spec.name} jako {expected}.",
|
|
88
|
+
)
|
|
89
|
+
)
|
|
90
|
+
continue
|
|
91
|
+
|
|
92
|
+
message.values[spec.name] = value
|
|
93
|
+
|
|
94
|
+
if spec.max is not None and isinstance(value, int) and value > spec.max:
|
|
95
|
+
findings.append(
|
|
96
|
+
Finding(
|
|
97
|
+
level=Level.ERROR,
|
|
98
|
+
code="FIELD_MAX",
|
|
99
|
+
title=f"{message.spec.prefix}: {spec.name} = {value} přesahuje maximum {spec.max}",
|
|
100
|
+
line_number=message.line_number,
|
|
101
|
+
line=message.line,
|
|
102
|
+
shape=message.spec.shape,
|
|
103
|
+
why="Hodnota se nevejde do rozsahu, se kterým formát počítá.",
|
|
104
|
+
help=f"Zkontroluj zdroj hodnoty {spec.name}.",
|
|
105
|
+
)
|
|
106
|
+
)
|
|
107
|
+
if spec.warn_min is not None and isinstance(value, (int, float)) and not (
|
|
108
|
+
spec.warn_min <= value <= spec.warn_max
|
|
109
|
+
):
|
|
110
|
+
unit = f" {spec.unit}" if spec.unit else ""
|
|
111
|
+
findings.append(
|
|
112
|
+
Finding(
|
|
113
|
+
level=Level.WARNING,
|
|
114
|
+
code="FIELD_RANGE",
|
|
115
|
+
title=f"{message.spec.prefix}: {spec.name} = {value}{unit} je mimo očekávaný "
|
|
116
|
+
f"rozsah {spec.warn_min} až {spec.warn_max}{unit}",
|
|
117
|
+
line_number=message.line_number,
|
|
118
|
+
line=message.line,
|
|
119
|
+
shape=message.spec.shape,
|
|
120
|
+
why="Hodnota mimo rozsah senzoru obvykle znamená prohozená nebo posunutá pole.",
|
|
121
|
+
help=f"Ověř, že na pozici {index} je opravdu {spec.name}.",
|
|
122
|
+
)
|
|
123
|
+
)
|
|
124
|
+
return findings
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def _check_trailing(message: ParsedMessage) -> list[Finding]:
|
|
128
|
+
spec = message.spec.trailing
|
|
129
|
+
findings = []
|
|
130
|
+
values = []
|
|
131
|
+
for index in range(message.spec.known_tokens, len(message.tokens)):
|
|
132
|
+
ok, value, expected = _validate(spec, message.tokens[index])
|
|
133
|
+
if not ok:
|
|
134
|
+
findings.append(
|
|
135
|
+
Finding(
|
|
136
|
+
level=Level.ERROR,
|
|
137
|
+
code="FIELD_TYPE",
|
|
138
|
+
title=f"{message.spec.prefix}: {spec.name}[{index - message.spec.known_tokens}] "
|
|
139
|
+
f"je {message.tokens[index]!r}, očekává se {expected}",
|
|
140
|
+
line_number=message.line_number,
|
|
141
|
+
line=message.line,
|
|
142
|
+
shape=message.spec.shape,
|
|
143
|
+
why="Neplatná hodnota v histogramu posune odečet ostatních kanálů.",
|
|
144
|
+
help=f"Vypisuj {spec.name} jako {expected}.",
|
|
145
|
+
)
|
|
146
|
+
)
|
|
147
|
+
else:
|
|
148
|
+
values.append(value)
|
|
149
|
+
message.values[spec.name] = values
|
|
150
|
+
return findings
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def _check_token_count(message: ParsedMessage) -> list[Finding]:
|
|
154
|
+
spec = message.spec
|
|
155
|
+
count = len(message.tokens)
|
|
156
|
+
if count < spec.required_tokens:
|
|
157
|
+
missing = [f.name for f in spec.fields[count - 1:] if not f.optional]
|
|
158
|
+
return [
|
|
159
|
+
Finding(
|
|
160
|
+
level=Level.ERROR,
|
|
161
|
+
code="MISSING_FIELDS",
|
|
162
|
+
title=f"{spec.prefix} má {count - 1} polí, očekává se aspoň {spec.required_tokens - 1}",
|
|
163
|
+
line_number=message.line_number,
|
|
164
|
+
line=message.line,
|
|
165
|
+
shape=spec.shape,
|
|
166
|
+
why="Konzumenti čtou pole podle pozice. Chybějící pole posune význam všech dalších "
|
|
167
|
+
"a zprávu obvykle zahodí celou.",
|
|
168
|
+
help=f"Doplň chybějící pole ({', '.join(missing)}) a zachovej pořadí.",
|
|
169
|
+
)
|
|
170
|
+
]
|
|
171
|
+
if spec.trailing is None and spec.extra_tokens and count > spec.known_tokens:
|
|
172
|
+
extra = count - spec.known_tokens
|
|
173
|
+
return [
|
|
174
|
+
Finding(
|
|
175
|
+
level=spec.extra_tokens,
|
|
176
|
+
code="EXTRA_FIELDS",
|
|
177
|
+
title=f"{spec.prefix} má {extra} pole navíc oproti schématu",
|
|
178
|
+
line_number=message.line_number,
|
|
179
|
+
line=message.line,
|
|
180
|
+
shape=spec.shape,
|
|
181
|
+
why="Rozšiřování na konec je povolené, ale konzumenti o nových polích nevědí, "
|
|
182
|
+
"dokud se nepřidají do schématu a dokumentace.",
|
|
183
|
+
help="Pokud je rozšíření záměrné, přidej pole do schématu a do dokumentace formátu.",
|
|
184
|
+
)
|
|
185
|
+
]
|
|
186
|
+
return []
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def check_log(text: str, schema: Schema | None = None) -> list[Finding]:
|
|
190
|
+
"""Check one captured log against the message schema (layer 1 of docs/CHECKS.md)."""
|
|
191
|
+
schema = schema or load_schema()
|
|
192
|
+
state = LogState()
|
|
193
|
+
findings: list[Finding] = []
|
|
194
|
+
unknown: dict[str, int] = {}
|
|
195
|
+
deferred: dict[str, int] = {}
|
|
196
|
+
|
|
197
|
+
for line_number, raw_line in enumerate(text.splitlines(), start=1):
|
|
198
|
+
line = raw_line.strip()
|
|
199
|
+
if not line or line.startswith("#"):
|
|
200
|
+
continue
|
|
201
|
+
|
|
202
|
+
tokens = line.split(",")
|
|
203
|
+
prefix = tokens[0]
|
|
204
|
+
if prefix in schema.deferred_prefixes:
|
|
205
|
+
deferred[prefix] = deferred.get(prefix, 0) + 1
|
|
206
|
+
continue
|
|
207
|
+
spec = schema.messages.get(prefix)
|
|
208
|
+
if spec is None:
|
|
209
|
+
unknown[prefix] = unknown.get(prefix, 0) + 1
|
|
210
|
+
continue
|
|
211
|
+
|
|
212
|
+
message = ParsedMessage(spec=spec, tokens=tokens, line_number=line_number, line=line)
|
|
213
|
+
count_findings = _check_token_count(message)
|
|
214
|
+
findings.extend(count_findings)
|
|
215
|
+
if any(f.code == "MISSING_FIELDS" for f in count_findings):
|
|
216
|
+
continue
|
|
217
|
+
|
|
218
|
+
findings.extend(_check_fields(message))
|
|
219
|
+
if spec.trailing is not None:
|
|
220
|
+
findings.extend(_check_trailing(message))
|
|
221
|
+
for rule_name in spec.rules:
|
|
222
|
+
findings.extend(RULES[rule_name](message, state))
|
|
223
|
+
state.observe(message)
|
|
224
|
+
|
|
225
|
+
findings.extend(_summarize_prefixes(deferred, unknown))
|
|
226
|
+
return findings
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def _summarize_prefixes(deferred: dict[str, int], unknown: dict[str, int]) -> list[Finding]:
|
|
230
|
+
findings = []
|
|
231
|
+
if deferred:
|
|
232
|
+
listed = ", ".join(f"{prefix} ({count}x)" for prefix, count in sorted(deferred.items()))
|
|
233
|
+
findings.append(
|
|
234
|
+
Finding(
|
|
235
|
+
level=Level.INFO,
|
|
236
|
+
code="DEFERRED_PREFIX",
|
|
237
|
+
title=f"Zprávy zatím mimo schéma: {listed}",
|
|
238
|
+
why="Tyhle zprávy zná parser nebo firmware, ale dokumentace formátu je nepopisuje.",
|
|
239
|
+
help="Doplnění schématu je fáze F7, viz docs/DOC_DEVIATIONS.md (D2, D3).",
|
|
240
|
+
)
|
|
241
|
+
)
|
|
242
|
+
if unknown:
|
|
243
|
+
listed = ", ".join(f"{prefix} ({count}x)" for prefix, count in sorted(unknown.items()))
|
|
244
|
+
findings.append(
|
|
245
|
+
Finding(
|
|
246
|
+
level=Level.INFO,
|
|
247
|
+
code="UNKNOWN_PREFIX",
|
|
248
|
+
title=f"Neznámé zprávy: {listed}",
|
|
249
|
+
why="Zpráva není ve schématu, takže se nekontroluje.",
|
|
250
|
+
help="Pokud jde o novou zprávu formátu, přidej ji do messages.yaml a do dokumentace.",
|
|
251
|
+
)
|
|
252
|
+
)
|
|
253
|
+
return findings
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import argparse
|
|
2
|
+
import sys
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
from .checks import check_log
|
|
6
|
+
from .compat import compare_to_golden, unchecked_messages
|
|
7
|
+
from .findings import Finding, Level, has_errors
|
|
8
|
+
from .parser_layer import check_with_parser
|
|
9
|
+
from .report import render
|
|
10
|
+
from .scenario import read_stimulus
|
|
11
|
+
from .stimulus_checks import check_against_stimulus
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def run(
|
|
15
|
+
path: Path,
|
|
16
|
+
with_parser: bool = True,
|
|
17
|
+
golden: Path | None = None,
|
|
18
|
+
stimulus: Path | None = None,
|
|
19
|
+
summary: bool = True,
|
|
20
|
+
) -> list[Finding]:
|
|
21
|
+
raw = path.read_bytes()
|
|
22
|
+
text = raw.decode("utf-8", errors="replace")
|
|
23
|
+
|
|
24
|
+
golden_text = golden.read_text(encoding="utf-8", errors="replace") if golden else None
|
|
25
|
+
|
|
26
|
+
findings = check_log(text)
|
|
27
|
+
if stimulus is not None:
|
|
28
|
+
findings.extend(check_against_stimulus(text, read_stimulus(stimulus)))
|
|
29
|
+
if golden_text is not None:
|
|
30
|
+
findings.extend(compare_to_golden(text, golden_text))
|
|
31
|
+
if with_parser:
|
|
32
|
+
findings.extend(check_with_parser(raw))
|
|
33
|
+
if summary:
|
|
34
|
+
not_emitted = unchecked_messages(text, golden_text)
|
|
35
|
+
if not_emitted is not None:
|
|
36
|
+
findings.append(not_emitted)
|
|
37
|
+
return findings
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def main(argv: list[str] | None = None) -> int:
|
|
41
|
+
parser = argparse.ArgumentParser(
|
|
42
|
+
prog="xdos-check",
|
|
43
|
+
description="Zkontroluje zachycený log xDOS zařízení proti schématu formátu.",
|
|
44
|
+
)
|
|
45
|
+
parser.add_argument("log", type=Path, help="soubor se zachyceným výstupem zařízení")
|
|
46
|
+
parser.add_argument("--golden", type=Path, help="referenční záznam z posledního release")
|
|
47
|
+
parser.add_argument(
|
|
48
|
+
"--stimulus", type=Path, help="JSON s vyslanými hodnotami (výstup xdos-build)"
|
|
49
|
+
)
|
|
50
|
+
parser.add_argument(
|
|
51
|
+
"--accept",
|
|
52
|
+
action="store_true",
|
|
53
|
+
help="uloží tenhle záznam jako nový referenční (vyžaduje --golden)",
|
|
54
|
+
)
|
|
55
|
+
parser.add_argument("--no-parser", action="store_true", help="vynechá kontrolu parserem DOSPORTAL")
|
|
56
|
+
parser.add_argument("--warnings-as-errors", action="store_true", help="režim CI: varování blokují")
|
|
57
|
+
args = parser.parse_args(argv)
|
|
58
|
+
|
|
59
|
+
# a console that cannot encode the report (Windows cp1252) must not crash on it; the
|
|
60
|
+
# encoding itself is left alone so report.marks() can fall back to ASCII
|
|
61
|
+
if hasattr(sys.stdout, "reconfigure"):
|
|
62
|
+
sys.stdout.reconfigure(errors="replace")
|
|
63
|
+
|
|
64
|
+
if args.accept:
|
|
65
|
+
if args.golden is None:
|
|
66
|
+
parser.error("--accept vyžaduje --golden s cestou, kam se má záznam uložit")
|
|
67
|
+
args.golden.parent.mkdir(parents=True, exist_ok=True)
|
|
68
|
+
args.golden.write_bytes(args.log.read_bytes())
|
|
69
|
+
print(f"Referenční záznam uložen: {args.golden}")
|
|
70
|
+
return 0
|
|
71
|
+
|
|
72
|
+
golden = args.golden if args.golden and args.golden.exists() else None
|
|
73
|
+
findings = run(
|
|
74
|
+
args.log, with_parser=not args.no_parser, golden=golden, stimulus=args.stimulus
|
|
75
|
+
)
|
|
76
|
+
print(render(findings, source=args.log.name))
|
|
77
|
+
|
|
78
|
+
if args.golden and golden is None:
|
|
79
|
+
print(f"Referenční záznam {args.golden} zatím neexistuje, kompatibilita se nekontrolovala.")
|
|
80
|
+
print(f"Vytvoříš ho: xdos-check {args.log} --golden {args.golden} --accept")
|
|
81
|
+
|
|
82
|
+
if has_errors(findings):
|
|
83
|
+
return 1
|
|
84
|
+
if args.warnings_as_errors and any(f.level is Level.WARNING for f in findings):
|
|
85
|
+
return 1
|
|
86
|
+
return 0
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
if __name__ == "__main__":
|
|
90
|
+
sys.exit(main())
|
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
"""Layer 2 of docs/CHECKS.md: compare a capture against a golden capture from the last release.
|
|
2
|
+
|
|
3
|
+
The reference is a plain captured log, not a derived file, so a format change shows up as a
|
|
4
|
+
readable diff in the device repository.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import re
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
|
|
10
|
+
from .findings import Finding, Level
|
|
11
|
+
from .schema import Schema, load_schema
|
|
12
|
+
|
|
13
|
+
_UINT_RE = re.compile(r"^\d+$")
|
|
14
|
+
_INT_RE = re.compile(r"^-?\d+$")
|
|
15
|
+
_DATETIME_RE = re.compile(r"^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$")
|
|
16
|
+
_HEX_RE = re.compile(r"^[0-9a-fA-F]+$")
|
|
17
|
+
_LABELED_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*=0x[0-9a-fA-F]+$")
|
|
18
|
+
|
|
19
|
+
TYPE_NAMES = {
|
|
20
|
+
"uint": "celé nezáporné číslo",
|
|
21
|
+
"int": "celé číslo",
|
|
22
|
+
"float": "desetinné číslo",
|
|
23
|
+
"datetime": "časové razítko",
|
|
24
|
+
"hex": "hexadecimální řetězec",
|
|
25
|
+
"labeled": "pojmenovaná hodnota (název=0x…)",
|
|
26
|
+
"text": "text",
|
|
27
|
+
"empty": "prázdná hodnota",
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def classify(token: str) -> str:
|
|
32
|
+
"""A time <tm>.<tm_s100> has no type of its own here: it looks exactly like any other
|
|
33
|
+
decimal number, so telling them apart would only invent differences. The schema layer
|
|
34
|
+
checks tm where the schema says a field is one."""
|
|
35
|
+
if token == "":
|
|
36
|
+
return "empty"
|
|
37
|
+
if _UINT_RE.match(token):
|
|
38
|
+
return "uint"
|
|
39
|
+
if _INT_RE.match(token):
|
|
40
|
+
return "int"
|
|
41
|
+
if _DATETIME_RE.match(token):
|
|
42
|
+
return "datetime"
|
|
43
|
+
if _LABELED_RE.match(token):
|
|
44
|
+
return "labeled"
|
|
45
|
+
try:
|
|
46
|
+
float(token)
|
|
47
|
+
return "float"
|
|
48
|
+
except ValueError:
|
|
49
|
+
pass
|
|
50
|
+
if _HEX_RE.match(token):
|
|
51
|
+
return "hex"
|
|
52
|
+
return "text"
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
@dataclass
|
|
56
|
+
class MessageProfile:
|
|
57
|
+
prefix: str
|
|
58
|
+
occurrences: int = 0
|
|
59
|
+
min_tokens: int | None = None
|
|
60
|
+
max_tokens: int = 0
|
|
61
|
+
types: list[set[str]] = None
|
|
62
|
+
|
|
63
|
+
def observe(self, tokens: list[str]) -> None:
|
|
64
|
+
self.occurrences += 1
|
|
65
|
+
count = len(tokens)
|
|
66
|
+
self.min_tokens = count if self.min_tokens is None else min(self.min_tokens, count)
|
|
67
|
+
self.max_tokens = max(self.max_tokens, count)
|
|
68
|
+
if self.types is None:
|
|
69
|
+
self.types = []
|
|
70
|
+
while len(self.types) < count:
|
|
71
|
+
self.types.append(set())
|
|
72
|
+
for index, token in enumerate(tokens):
|
|
73
|
+
self.types[index].add(classify(token))
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def profile_log(text: str) -> dict[str, MessageProfile]:
|
|
77
|
+
profiles: dict[str, MessageProfile] = {}
|
|
78
|
+
for raw_line in text.splitlines():
|
|
79
|
+
line = raw_line.strip()
|
|
80
|
+
if not line or line.startswith("#") or not line.startswith("$"):
|
|
81
|
+
continue
|
|
82
|
+
tokens = line.split(",")
|
|
83
|
+
profile = profiles.setdefault(tokens[0], MessageProfile(prefix=tokens[0]))
|
|
84
|
+
profile.observe(tokens)
|
|
85
|
+
return profiles
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def compare_to_golden(current: str, golden: str, schema: Schema | None = None) -> list[Finding]:
|
|
89
|
+
schema = schema or load_schema()
|
|
90
|
+
now = profile_log(current)
|
|
91
|
+
before = profile_log(golden)
|
|
92
|
+
findings: list[Finding] = []
|
|
93
|
+
|
|
94
|
+
for prefix, reference in sorted(before.items()):
|
|
95
|
+
spec = schema.messages.get(prefix)
|
|
96
|
+
shape = spec.shape if spec else None
|
|
97
|
+
observed = now.get(prefix)
|
|
98
|
+
if observed is None:
|
|
99
|
+
findings.append(
|
|
100
|
+
Finding(
|
|
101
|
+
level=Level.ERROR,
|
|
102
|
+
code="MESSAGE_DISAPPEARED",
|
|
103
|
+
title=f"{prefix} je v referenčním záznamu ({reference.occurrences}x), "
|
|
104
|
+
"ale v novém výstupu chybí",
|
|
105
|
+
shape=shape,
|
|
106
|
+
why="Konzumenti, kteří zprávu čtou, o data přijdou. Odstranění zprávy je "
|
|
107
|
+
"nekompatibilní změna formátu.",
|
|
108
|
+
help="Pokud má zpráva zmizet, vyžaduje to novou verzi formátu a úpravu "
|
|
109
|
+
"parseru v DOSPORTAL. Jinak ji vypisuj dál.",
|
|
110
|
+
)
|
|
111
|
+
)
|
|
112
|
+
continue
|
|
113
|
+
findings.extend(_compare_message(prefix, reference, observed, spec, shape))
|
|
114
|
+
|
|
115
|
+
for prefix, observed in sorted(now.items()):
|
|
116
|
+
if prefix not in before:
|
|
117
|
+
findings.append(
|
|
118
|
+
Finding(
|
|
119
|
+
level=Level.INFO,
|
|
120
|
+
code="MESSAGE_ADDED",
|
|
121
|
+
title=f"{prefix} je nová oproti referenčnímu záznamu ({observed.occurrences}x)",
|
|
122
|
+
why="Nová zpráva je zpětně kompatibilní, konzumenti neznámý prefix ignorují.",
|
|
123
|
+
help="Popiš ji v dokumentaci formátu a přidej do messages.yaml.",
|
|
124
|
+
)
|
|
125
|
+
)
|
|
126
|
+
return findings
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def _compare_message(prefix, reference, observed, spec, shape) -> list[Finding]:
|
|
130
|
+
has_tail = spec is not None and spec.trailing is not None
|
|
131
|
+
fixed = spec.known_tokens if has_tail else None
|
|
132
|
+
findings = _compare_types(prefix, reference, observed, shape, fixed if has_tail else None)
|
|
133
|
+
|
|
134
|
+
# a field inserted in the middle also lengthens the message; reporting the length on top of
|
|
135
|
+
# the type change would just repeat the same finding
|
|
136
|
+
if findings:
|
|
137
|
+
return findings
|
|
138
|
+
|
|
139
|
+
if not has_tail:
|
|
140
|
+
if observed.min_tokens < reference.min_tokens:
|
|
141
|
+
findings.append(
|
|
142
|
+
Finding(
|
|
143
|
+
level=Level.ERROR,
|
|
144
|
+
code="FIELDS_REMOVED",
|
|
145
|
+
title=f"{prefix} má nově jen {observed.min_tokens - 1} polí, referenční "
|
|
146
|
+
f"záznam měl {reference.min_tokens - 1}",
|
|
147
|
+
shape=shape,
|
|
148
|
+
why="Formát se smí rozšiřovat jen na konec. Ubrané pole posune význam "
|
|
149
|
+
"všech následujících.",
|
|
150
|
+
help="Vrať pole zpět. Když má opravdu zmizet, je to nová verze formátu.",
|
|
151
|
+
)
|
|
152
|
+
)
|
|
153
|
+
elif observed.max_tokens > reference.max_tokens:
|
|
154
|
+
findings.append(
|
|
155
|
+
Finding(
|
|
156
|
+
level=Level.INFO,
|
|
157
|
+
code="FIELDS_APPENDED",
|
|
158
|
+
title=f"{prefix} má nově až {observed.max_tokens - 1} polí, referenční "
|
|
159
|
+
f"záznam měl {reference.max_tokens - 1}",
|
|
160
|
+
shape=shape,
|
|
161
|
+
why="Rozšíření na konec je povolené, ale konzumenti o novém poli nevědí.",
|
|
162
|
+
help="Přidej pole do dokumentace formátu a do messages.yaml.",
|
|
163
|
+
)
|
|
164
|
+
)
|
|
165
|
+
elif observed.max_tokens != reference.max_tokens:
|
|
166
|
+
findings.append(
|
|
167
|
+
Finding(
|
|
168
|
+
level=Level.INFO,
|
|
169
|
+
code="TRAILING_LENGTH_CHANGED",
|
|
170
|
+
title=f"{prefix}: délka části s proměnným počtem polí se změnila "
|
|
171
|
+
f"({reference.max_tokens - fixed} → {observed.max_tokens - fixed})",
|
|
172
|
+
shape=shape,
|
|
173
|
+
why="Histogram na konci zprávy nemá pevnou délku, takže tuhle změnu nejde "
|
|
174
|
+
"odlišit od přidaného pole.",
|
|
175
|
+
help="Až se délka histogramu popíše v dokumentaci, začne se kontrolovat "
|
|
176
|
+
"(viz docs/DOC_DEVIATIONS.md, D6).",
|
|
177
|
+
)
|
|
178
|
+
)
|
|
179
|
+
|
|
180
|
+
return findings
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def _compare_types(prefix, reference, observed, shape, fixed: int | None) -> list[Finding]:
|
|
184
|
+
limit = min(len(reference.types), len(observed.types))
|
|
185
|
+
if fixed is not None:
|
|
186
|
+
limit = min(limit, fixed)
|
|
187
|
+
findings = []
|
|
188
|
+
for index in range(1, limit):
|
|
189
|
+
was, now_types = reference.types[index], observed.types[index]
|
|
190
|
+
if was & now_types:
|
|
191
|
+
continue
|
|
192
|
+
findings.append(
|
|
193
|
+
Finding(
|
|
194
|
+
level=Level.ERROR,
|
|
195
|
+
code="FIELD_TYPE_CHANGED",
|
|
196
|
+
title=f"{prefix}: pole {index} bylo {_names(was)}, nově je {_names(now_types)}",
|
|
197
|
+
shape=shape,
|
|
198
|
+
why="Změna typu na dané pozici znamená, že se pole přesunulo nebo změnilo "
|
|
199
|
+
"význam. Konzumenti čtou pozice, ne názvy.",
|
|
200
|
+
help="Nové údaje přidávej na konec zprávy, stávající pozice nech beze změny.",
|
|
201
|
+
)
|
|
202
|
+
)
|
|
203
|
+
return findings
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def _names(types: set[str]) -> str:
|
|
207
|
+
return " / ".join(sorted(TYPE_NAMES.get(name, name) for name in types))
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def unchecked_messages(
|
|
211
|
+
text: str, golden: str | None = None, schema: Schema | None = None
|
|
212
|
+
) -> Finding | None:
|
|
213
|
+
"""R8: a message the device never emits is not an error, but it must not be passed over
|
|
214
|
+
in silence either. One the reference did emit is already reported as a regression."""
|
|
215
|
+
schema = schema or load_schema()
|
|
216
|
+
seen = set(profile_log(text))
|
|
217
|
+
if golden is not None:
|
|
218
|
+
seen |= set(profile_log(golden))
|
|
219
|
+
missing = sorted(set(schema.messages) - seen)
|
|
220
|
+
if not missing:
|
|
221
|
+
return None
|
|
222
|
+
return Finding(
|
|
223
|
+
level=Level.INFO,
|
|
224
|
+
code="MESSAGES_NOT_EMITTED",
|
|
225
|
+
title=f"Zařízení nevypisuje: {', '.join(missing)}, kontroly těchto zpráv se přeskočily",
|
|
226
|
+
why="Zpráva, kterou hardware nemá čím naplnit, není chyba formátu.",
|
|
227
|
+
help="Jestli některá z nich chybí omylem, porovnej výstup s referenčním záznamem "
|
|
228
|
+
"(--golden).",
|
|
229
|
+
)
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
name: gnss
|
|
2
|
+
summary: přijímač GNSS - věty NMEA na UART a sekundový pulz 1PPS na pinu
|
|
3
|
+
bus: uart
|
|
4
|
+
pins: [pps]
|
|
5
|
+
directive: pps {pps_port} {pps_pin} {pps_start} {pps_count}
|
|
6
|
+
params:
|
|
7
|
+
pps_start: {type: float, default: 0.9}
|
|
8
|
+
# musí vydržet po celou dobu, kterou smí simulace běžet: až pulzy dojdou, hodiny zařízení
|
|
9
|
+
# se zastaví, zatímco millis běží dál, a vypadá to jako zamrzlý čas ve firmwaru
|
|
10
|
+
pps_count: {type: int, default: 45}
|
|
11
|
+
stimulus: nmea
|
|
12
|
+
emits: [$TIME]
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
name: i2c_eeprom
|
|
2
|
+
summary: I2C paměť s adresou registru, ze které firmware čte sériová čísla a konfiguraci
|
|
3
|
+
bus: i2c
|
|
4
|
+
params:
|
|
5
|
+
reg_bytes: {type: int, default: 2}
|
|
6
|
+
offset: {type: hex, default: "0000"}
|
|
7
|
+
data: {type: hex, default: FFFFFFFFFFFFFFFF}
|
|
8
|
+
directive: i2c_memory {addr_hex} {reg_bytes} {offset} {data}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
name: sht31
|
|
2
|
+
summary: teploměr a vlhkoměr SHT31-DIS na I2C
|
|
3
|
+
bus: i2c
|
|
4
|
+
params:
|
|
5
|
+
temp_c: {type: float, default: 21.5, min: -45, max: 130, unit: °C}
|
|
6
|
+
humidity: {type: float, default: 40.0, min: 0, max: 100, unit: "%"}
|
|
7
|
+
directive: sht31 {addr_hex} {temp_c} {humidity}
|
|
8
|
+
provides: [temperature, humidity]
|
|
9
|
+
emits: [$ENV]
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
name: spi_adc_pulse
|
|
2
|
+
summary: >
|
|
3
|
+
analogová část detektoru z pohledu MCU - pin CONV oznámí hotovou konverzi, hodnota přijde
|
|
4
|
+
dvěma bajty po SPI a firmware ji potvrdí pulzem na DRESET
|
|
5
|
+
bus: spi
|
|
6
|
+
pins: [conv, reset]
|
|
7
|
+
directive: adc {conv_port} {conv_pin} {reset_port} {reset_pin}
|
|
8
|
+
stimulus: pulses
|
|
9
|
+
emits: [$E, $STOP]
|