mk2vsc 0.1.2__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.
mk2vsc/__init__.py ADDED
@@ -0,0 +1,35 @@
1
+ """
2
+ rvms -- read, verify, edit and diff Victron VEConfigure ``.rvms`` configuration files without VEConfigure.
3
+
4
+ Zero dependencies. Python 3.9+.
5
+
6
+ Quick tour::
7
+
8
+ from mk2vsc import RvmsFile, units_by_serial, decode_file, set_settings, diff_files
9
+
10
+ f = RvmsFile.load("system.rvms")
11
+ f.all_checksums_ok # every section's integrity trailer validates
12
+ units_by_serial(f)["HQ2414U6FVN"].setting(2) / 100 # absorption voltage
13
+
14
+ Safety model in one paragraph: this library produces *files*. It never talks to an inverter. A valid
15
+ file is necessary, not sufficient: editing the right offset is on you (see ``fields.py`` confidence
16
+ levels), and the only proven-safe edits are length-preserving value changes to the settings array.
17
+ Adding, removing or transplanting an assistant (ESS etc.) by file has never worked for us and has
18
+ disrupted live systems. Read docs/SAFETY.md before uploading anything.
19
+ """
20
+ from .sections import RvmsFile, Section, RvmsParseError, sum32_le, scan_unit_blocks
21
+ from .units import UnitBlock, unit_blocks, units_by_serial
22
+ from .fields import FIELDS, BY_ID, BY_NAME, lookup, Field
23
+ from .decode import decode_file, decode_bytes
24
+ from .writer import set_settings, WriteRefused
25
+ from .diff import diff_files, diff_bytes
26
+ from .qualify import qualify_file, Intent
27
+
28
+ __version__ = "0.1.2"
29
+ __all__ = [
30
+ "RvmsFile", "Section", "RvmsParseError", "sum32_le", "scan_unit_blocks",
31
+ "UnitBlock", "unit_blocks", "units_by_serial",
32
+ "FIELDS", "BY_ID", "BY_NAME", "lookup", "Field",
33
+ "decode_file", "decode_bytes", "set_settings", "WriteRefused", "diff_files", "diff_bytes",
34
+ "qualify_file", "Intent",
35
+ ]
mk2vsc/assistants.py ADDED
@@ -0,0 +1,105 @@
1
+ """
2
+ READ-ONLY parsing of the assistant area that follows the settings array in a unit block.
3
+
4
+ What we can say with evidence (docs/ASSISTANTS.md has the full story):
5
+
6
+ * Bare block (no assistant): the area is the 9 bytes ``ff ff ff ff 00 00 ff 00 0b`` (device form) --
7
+ i.e. an empty record header (``ff ff`` marker, ``ff ff`` subtype, length ``00 00``) plus 3 trailer bytes.
8
+ * Block with the ESS assistant installed by the GUI: one or more records framed
9
+ ``f5 ff <subtype u16> <len u16> <body>``; body lengths 704 and 1152 in every working install we hold
10
+ (one per inverter of the pair; the two bodies differ from each other, and are byte-identical across
11
+ systems except for a single primary/secondary flag byte). The device pads records with ``0xff`` runs
12
+ and appends trailer bytes; the GUI's upload form writes the same records compact.
13
+ * Stub: after VEConfigure accepted one of our transplanted files it wrote a 64-byte empty container
14
+ ``40 00 a7 fe 00 00 57 01`` + ``0xff`` filler + ``c0 0a`` on both inverters and discarded our payload.
15
+ Its presence in a download is the signature of a failed by-file assistant install.
16
+
17
+ We do NOT understand the record body. It looks like a compiled program (entropy ~6.2 bits/byte,
18
+ recurring 2-3 byte opcodes, embedded parameter values such as 48.00 V and 10 %). This module reports
19
+ structure; it does not author it.
20
+ """
21
+ from __future__ import annotations
22
+
23
+ import struct
24
+ from typing import Dict, List
25
+
26
+ from .units import UnitBlock
27
+
28
+ RECORD_MARK = b"\xf5\xff"
29
+ EMPTY_MARK = b"\xff\xff"
30
+ CONTAINER_SIG = b"\xa7\xfe\x00\x00\x57\x01"
31
+ STUB_MAGIC = b"\x40\x00" + CONTAINER_SIG # len 64 + signature, as it appears after the ff ff ff ff header
32
+
33
+
34
+ def parse_records(area: bytes):
35
+ """Walk ``marker(2) subtype(2) len(2) body`` records from the start of the area.
36
+
37
+ Returns (records, tail_offset). Walking stops at the first byte pair that is not a known marker.
38
+ """
39
+ records: List[Dict] = []
40
+ pos = 0
41
+ while pos + 6 <= len(area) and area[pos: pos + 2] in (EMPTY_MARK, RECORD_MARK):
42
+ marker = area[pos: pos + 2]
43
+ subtype, length = struct.unpack_from("<HH", area, pos + 2)
44
+ body = area[pos + 6: pos + 6 + length]
45
+ records.append({"offset": pos, "marker": marker.hex(), "subtype": f"{subtype:04x}", "length": length,
46
+ "body_sha8": _sha8(body) if length else "", "nonpad_bytes": sum(1 for b in body if b != 0xFF),
47
+ "container_signature": body.startswith(CONTAINER_SIG),
48
+ "truncated": len(body) < length})
49
+ pos += 6 + length
50
+ return records, pos
51
+
52
+
53
+ def parse_assistant_area(u: UnitBlock) -> Dict:
54
+ """Describe the assistant area of a unit block.
55
+
56
+ Uniform model (every block in the corpus fits it)::
57
+
58
+ area := record* tail
59
+ record := marker(2) subtype(2) len(2) body[len]
60
+ marker ff ff -> empty slot / container. Bare blocks: len 0. Two June-2026 files from an older
61
+ tool build: len 6, body a7 fe 00 00 57 01. Stub written by VEConfigure after it
62
+ discarded a transplanted assistant: len 64, same signature + 0xff filler.
63
+ f5 ff -> assistant record. GUI-installed ESS: one 704-byte and one 1152-byte record per
64
+ system (one on each inverter), subtype 0101 / 0001.
65
+ tail := padding(0xff)* | ff | u16 free
66
+ On bare, legacy and stub blocks free == 2816 - bytes used; see docs/FORMAT.md for ESS.
67
+
68
+ A ``f5 ff`` header with len 0 where ``ff ff`` is expected is residue seen on downloads taken after a
69
+ rejected or rolled-back assistant upload; functionally bare.
70
+ """
71
+ area = u.assistant_area
72
+ records, tail_off = parse_records(area)
73
+ tail = area[tail_off:]
74
+ out: Dict = {"bytes": len(area), "records": records, "tail_bytes": len(tail), "tail_hex": tail[-8:].hex(" "),
75
+ "stub": False, "kind": "unknown", "summary": ""}
76
+ if len(tail) >= 3 and tail[-3] == 0xFF:
77
+ out["free"] = struct.unpack_from("<H", tail, len(tail) - 2)[0]
78
+ out["used"] = tail_off
79
+ out["free_plus_used"] = out["free"] + tail_off
80
+ if any(r["truncated"] for r in records):
81
+ out["kind"] = "malformed"
82
+ out["summary"] = "record length exceeds the area (malformed)"
83
+ return out
84
+ real = [r for r in records if r["marker"] == "f5ff" and r["length"] > 0]
85
+ containers = [r for r in records if r["marker"] == "ffff" and r["length"] > 0]
86
+ if any(r["length"] >= 64 and r["container_signature"] for r in containers):
87
+ out["kind"], out["stub"] = "stub", True
88
+ out["summary"] = "EMPTY STUB container (signature of a failed by-file install)"
89
+ elif real:
90
+ out["kind"] = "records"
91
+ out["summary"] = "assistant records: " + ", ".join(f"{r['length']}B/{r['subtype']}" for r in real)
92
+ elif containers:
93
+ out["kind"] = "container"
94
+ out["summary"] = f"empty {containers[0]['length']}-byte container (no program)"
95
+ elif records:
96
+ out["kind"] = "none"
97
+ out["summary"] = "no assistant" + (" (empty record residue)" if records[0]["marker"] == "f5ff" else "")
98
+ else:
99
+ out["summary"] = f"{len(area)} unrecognised bytes"
100
+ return out
101
+
102
+
103
+ def _sha8(b: bytes) -> str:
104
+ import hashlib
105
+ return hashlib.sha256(b).hexdigest()[:8]
mk2vsc/cli.py ADDED
@@ -0,0 +1,249 @@
1
+ """
2
+ Command-line interface.
3
+
4
+ mk2vsc info FILE... one-screen summary (structure, inverters, confirmed settings)
5
+ mk2vsc validate FILE... checksum + structure check; exit 1 on any failure
6
+ mk2vsc decode FILE [--json] [--all] every setting with label/confidence
7
+ mk2vsc diff A B [--json] by-serial comparison; says whether only bookkeeping changed
8
+ mk2vsc set IN OUT [--serial S] FIELD=VALUE ... guarded edit (never uploads)
9
+ mk2vsc qualify FILE... --intent intent.json check against intended values
10
+ mk2vsc fix IN OUT recompute every checksum (forensic use only)
11
+ mk2vsc fields print the settings table
12
+ mk2vsc census FILE... one line per file (block lengths, flags, form, assistant kind)
13
+ mk2vsc history FILE... dated change log mined from a library of downloads (by system, by serial)
14
+ mk2vsc experimental graft BASELINE TEMPLATE OUT [--install-state] [--capacity-ah N] --i-accept-the-risk
15
+ mk2vsc experimental to-upload-form DEVICE OUT [--reference GUI_EXPORT] --i-accept-the-risk
16
+ assistant-injection experiments; never produced a running system
17
+ """
18
+ from __future__ import annotations
19
+
20
+ import argparse
21
+ import json
22
+ import sys
23
+
24
+ from . import __version__
25
+ from .sections import RvmsFile, RvmsParseError
26
+ from .units import unit_blocks
27
+ from .decode import decode_file, brief
28
+ from .diff import diff_files, render as render_diff
29
+ from .writer import set_settings_file, WriteRefused
30
+ from .qualify import Intent, qualify_file, render as render_qual
31
+ from .fields import FIELDS
32
+ from .assistants import parse_assistant_area
33
+ from .history import load_snapshots, changes as history_changes, render as render_history
34
+
35
+
36
+ def _load(path):
37
+ try:
38
+ return RvmsFile.load(path)
39
+ except (RvmsParseError, OSError) as e:
40
+ print(f"{path}: {e}", file=sys.stderr)
41
+ return None
42
+
43
+
44
+ def cmd_info(a):
45
+ rc = 0
46
+ for p in a.files:
47
+ try:
48
+ print(f"== {p}")
49
+ print(brief(decode_file(p)))
50
+ except Exception as e: # noqa: BLE001
51
+ rc = 1
52
+ print(f" ERROR {e}")
53
+ return rc
54
+
55
+
56
+ def cmd_validate(a):
57
+ rc = 0
58
+ for p in a.files:
59
+ f = _load(p)
60
+ if f is None:
61
+ rc = 1
62
+ continue
63
+ for name, start, stored, computed, ok in f.checksum_report():
64
+ if not ok or a.verbose:
65
+ print(f"{'OK ' if ok else 'BAD'} {p} {name}@0x{start:x} stored={stored:08x} computed={computed:08x}")
66
+ if not ok:
67
+ rc = 1
68
+ if f.all_checksums_ok:
69
+ print(f"OK {p} ({len(f.sections)} sections, {len(f.unit_sections)} inverters)")
70
+ return rc
71
+
72
+
73
+ def cmd_decode(a):
74
+ try:
75
+ d = decode_file(a.file, include_unknown=a.all)
76
+ except (RvmsParseError, OSError) as e:
77
+ print(f"{a.file}: {e}", file=sys.stderr)
78
+ return 1
79
+ if a.json:
80
+ print(json.dumps(d, indent=1, default=str))
81
+ else:
82
+ print(brief(d))
83
+ for u in d["units"]:
84
+ print(f"\n{u['serial']}: all named settings")
85
+ for s in u["settings"]:
86
+ if s.get("name") or a.all:
87
+ v = s.get("value", s["raw"])
88
+ print(f" {s['id']:3d} {s['offset']} {s.get('name') or '-':30s} raw={s['raw']:6d} value={v!s:>9} "
89
+ f"{s.get('unit','')} [{s['confidence']}]")
90
+ return 0
91
+
92
+
93
+ def cmd_diff(a):
94
+ try:
95
+ d = diff_files(a.a, a.b)
96
+ except (RvmsParseError, OSError, ValueError) as e:
97
+ print(f"cannot diff: {e}", file=sys.stderr)
98
+ return 1
99
+ print(json.dumps(d.as_dict(), indent=1) if a.json else render_diff(d))
100
+ return 0 if (d.identical or d.only_bookkeeping) else 2
101
+
102
+
103
+ def cmd_set(a):
104
+ changes = []
105
+ for kv in a.assignments:
106
+ if "=" not in kv:
107
+ print(f"bad assignment {kv!r}; expected FIELD=VALUE", file=sys.stderr)
108
+ return 2
109
+ k, v = kv.split("=", 1)
110
+ try:
111
+ val = int(v, 0) if v.strip().lstrip("-").isdigit() or v.startswith("0x") else float(v)
112
+ except ValueError:
113
+ print(f"bad value {v!r} for {k}; expected a number", file=sys.stderr)
114
+ return 2
115
+ changes.append((a.serial, k, val))
116
+ try:
117
+ edits = set_settings_file(a.inp, a.out, changes, allow_unverified=a.i_know_this_is_unverified,
118
+ allow_out_of_range=a.allow_out_of_range)
119
+ except KeyError as e:
120
+ print(f"REFUSED: unknown field {e}; see `mk2vsc fields`", file=sys.stderr)
121
+ return 1
122
+ except (WriteRefused, ValueError, RvmsParseError, OSError) as e:
123
+ print(f"REFUSED: {e}", file=sys.stderr)
124
+ return 1
125
+ for e in edits:
126
+ d = e.as_dict()
127
+ print(f"{d['serial']} {d['field']} {d['old']} -> {d['new']} {d['unit']} ({d['block_offset']} / file {d['file_offset']})")
128
+ print(f"wrote {a.out}; verified: only the listed bytes and their section checksums changed")
129
+ return 0
130
+
131
+
132
+ def cmd_qualify(a):
133
+ try:
134
+ intent = Intent.load(a.intent) if a.intent else Intent(settings={})
135
+ except (OSError, ValueError) as e:
136
+ print(f"cannot load intent {a.intent}: {e}", file=sys.stderr)
137
+ return 2
138
+ rc = 0
139
+ for p in a.files:
140
+ try:
141
+ ok, res = qualify_file(p, intent)
142
+ except KeyError as e:
143
+ print(f"{p}: intent names an unknown field {e}; see `mk2vsc fields`", file=sys.stderr)
144
+ return 2
145
+ print(render_qual(ok, res, p))
146
+ rc |= 0 if ok else 1
147
+ return rc
148
+
149
+
150
+ def cmd_fix(a):
151
+ f = _load(a.inp)
152
+ if f is None:
153
+ return 1
154
+ out = f.fixed().to_bytes()
155
+ with open(a.out, "wb") as fh:
156
+ fh.write(out)
157
+ changed = sum(1 for s in f.sections if not s.checksum_ok)
158
+ print(f"wrote {a.out}: {changed} checksum(s) recomputed")
159
+ return 0
160
+
161
+
162
+ def cmd_fields(a):
163
+ print(f"{'id':>3} {'offset':>7} {'name':30s} {'scale':>5} {'unit':6s} {'conf':9s} label")
164
+ for f in FIELDS:
165
+ print(f"{f.id:3d} +0x{f.offset:03x} {f.name:30s} {f.scale:>5g} {f.unit:6s} {f.confidence:9s} {f.label}")
166
+ return 0
167
+
168
+
169
+ def cmd_census(a):
170
+ for p in a.files:
171
+ f = _load(p)
172
+ if f is None:
173
+ continue
174
+ cells = []
175
+ for u in unit_blocks(f):
176
+ asst = parse_assistant_area(u)
177
+ cells.append(f"{u.serial}:{u.length:#x}:{u.assistant_flag:02x}:{'U' if u.is_upload_form else 'd'}:{asst['kind']}")
178
+ print(f"{f.length:6d} {'ok ' if f.all_checksums_ok else 'BAD'} | {' '.join(cells)} | {p}")
179
+ return 0
180
+
181
+
182
+ def cmd_history(a):
183
+ snaps, skipped = load_snapshots(a.files)
184
+ chs = history_changes(snaps)
185
+ if a.json:
186
+ print(json.dumps([{"system": c.system, "serial": c.serial, "what": c.what, "old": c.old, "new": c.new,
187
+ "confidence": c.confidence, "after": c.after.when, "before": c.before.when,
188
+ "file_after": c.after.path, "file_before": c.before.path} for c in chs], indent=1, default=str))
189
+ else:
190
+ print(render_history(snaps, chs, skipped))
191
+ return 0
192
+
193
+
194
+ def cmd_experimental(a):
195
+ if not a.i_accept_the_risk:
196
+ print("experimental commands have never produced a running ESS system and have disrupted live systems; "
197
+ "read docs/ESS_INJECTION.md, then pass --i-accept-the-risk", file=sys.stderr)
198
+ return 2
199
+ from .experimental import graft, to_upload_form, GraftRefused, TransformRefused
200
+ try:
201
+ if a.what == "graft":
202
+ out, checks = graft(open(a.baseline, "rb").read(), open(a.template, "rb").read(),
203
+ install_state=a.install_state, capacity_ah=a.capacity_ah)
204
+ for k, v in checks.items():
205
+ print(f" {k}: {v}")
206
+ else:
207
+ ref = open(a.reference, "rb").read() if a.reference else None
208
+ out = to_upload_form(open(a.device, "rb").read(), reference=ref)
209
+ with open(a.out, "wb") as fh:
210
+ fh.write(out)
211
+ print(f"wrote {a.out} ({len(out)} bytes). EXPERIMENTAL: see docs/ESS_INJECTION.md before uploading.")
212
+ return 0
213
+ except (GraftRefused, TransformRefused, RvmsParseError, OSError) as e:
214
+ print(f"REFUSED: {e}", file=sys.stderr)
215
+ return 1
216
+
217
+
218
+ def main(argv=None):
219
+ ap = argparse.ArgumentParser(prog="mk2vsc", description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
220
+ ap.add_argument("--version", action="version", version=__version__)
221
+ sub = ap.add_subparsers(dest="cmd", required=True)
222
+
223
+ s = sub.add_parser("info"); s.add_argument("files", nargs="+"); s.set_defaults(fn=cmd_info)
224
+ s = sub.add_parser("validate"); s.add_argument("files", nargs="+"); s.add_argument("-v", "--verbose", action="store_true"); s.set_defaults(fn=cmd_validate)
225
+ s = sub.add_parser("decode"); s.add_argument("file"); s.add_argument("--json", action="store_true"); s.add_argument("--all", action="store_true", help="include unknown settings"); s.set_defaults(fn=cmd_decode)
226
+ s = sub.add_parser("diff"); s.add_argument("a"); s.add_argument("b"); s.add_argument("--json", action="store_true"); s.set_defaults(fn=cmd_diff)
227
+ s = sub.add_parser("set"); s.add_argument("inp"); s.add_argument("out"); s.add_argument("--serial", default=None, help="edit one inverter only (default: all)")
228
+ s.add_argument("--i-know-this-is-unverified", action="store_true", help="allow MEDIUM/LOW/UNKNOWN fields")
229
+ s.add_argument("--allow-out-of-range", action="store_true", help="skip the plausibility range and float<=absorption checks")
230
+ s.add_argument("assignments", nargs="+", metavar="FIELD=VALUE"); s.set_defaults(fn=cmd_set)
231
+ s = sub.add_parser("qualify"); s.add_argument("files", nargs="+"); s.add_argument("--intent", help="intent JSON"); s.set_defaults(fn=cmd_qualify)
232
+ s = sub.add_parser("fix"); s.add_argument("inp"); s.add_argument("out"); s.set_defaults(fn=cmd_fix)
233
+ s = sub.add_parser("fields"); s.set_defaults(fn=cmd_fields)
234
+ s = sub.add_parser("census"); s.add_argument("files", nargs="+"); s.set_defaults(fn=cmd_census)
235
+ s = sub.add_parser("history"); s.add_argument("files", nargs="+"); s.add_argument("--json", action="store_true"); s.set_defaults(fn=cmd_history)
236
+ x = sub.add_parser("experimental", help="assistant-injection experiments (read docs/ESS_INJECTION.md)")
237
+ xs = x.add_subparsers(dest="what", required=True)
238
+ g = xs.add_parser("graft"); g.add_argument("baseline"); g.add_argument("template"); g.add_argument("out")
239
+ g.add_argument("--install-state", action="store_true"); g.add_argument("--capacity-ah", type=int, default=None)
240
+ g.add_argument("--i-accept-the-risk", action="store_true"); g.set_defaults(fn=cmd_experimental)
241
+ t = xs.add_parser("to-upload-form"); t.add_argument("device"); t.add_argument("out"); t.add_argument("--reference")
242
+ t.add_argument("--i-accept-the-risk", action="store_true"); t.set_defaults(fn=cmd_experimental)
243
+
244
+ a = ap.parse_args(argv)
245
+ return a.fn(a)
246
+
247
+
248
+ if __name__ == "__main__": # pragma: no cover
249
+ sys.exit(main())
mk2vsc/decode.py ADDED
@@ -0,0 +1,79 @@
1
+ """
2
+ Decode a ``.rvms`` into a plain dictionary: file structure, per-inverter identity, every setting with
3
+ its label/confidence, and the assistant area summary. JSON-serialisable.
4
+ """
5
+ from __future__ import annotations
6
+
7
+ from typing import Dict, List, Optional
8
+
9
+ from .sections import RvmsFile
10
+ from .units import unit_blocks, N_SETTINGS
11
+ from .fields import BY_ID, UNKNOWN
12
+ from .assistants import parse_assistant_area
13
+
14
+
15
+ def decode_bytes(data: bytes, include_unknown: bool = True, include_raw_settings: bool = False) -> Dict:
16
+ f = RvmsFile.parse(data)
17
+ out: Dict = {
18
+ "length": f.length,
19
+ "sections": [
20
+ {"name": s.name.decode(), "start": s.start, "next": s.next_ptr, "payload_bytes": len(s.payload),
21
+ "checksum_stored": f"{s.stored_checksum:08x}", "checksum_computed": f"{s.computed_checksum:08x}",
22
+ "checksum_ok": s.checksum_ok}
23
+ for s in f.sections
24
+ ],
25
+ "all_checksums_ok": f.all_checksums_ok,
26
+ "units": [],
27
+ }
28
+ try:
29
+ mk = f.section(b"Mk2vscInfo")
30
+ vlen = int.from_bytes(mk.payload[4:6], "little")
31
+ out["format_version"] = mk.payload[6: 6 + vlen].decode()
32
+ except Exception: # malformed or missing header
33
+ out["format_version"] = None
34
+
35
+ for u in unit_blocks(f):
36
+ d = u.summary()
37
+ settings = u.settings()
38
+ named: List[Dict] = []
39
+ for sid in range(N_SETTINGS):
40
+ raw = settings[sid]
41
+ fld = BY_ID.get(sid)
42
+ if fld is None:
43
+ if not include_unknown:
44
+ continue
45
+ named.append({"id": sid, "offset": f"+0x{u.setting_offset(sid):03x}", "raw": raw,
46
+ "name": None, "confidence": UNKNOWN})
47
+ continue
48
+ if fld.confidence == UNKNOWN and not include_unknown:
49
+ continue
50
+ entry = {"id": sid, "offset": f"+0x{u.setting_offset(sid):03x}", "raw": raw, "name": fld.name,
51
+ "label": fld.label, "value": fld.decode(raw), "unit": fld.unit, "confidence": fld.confidence}
52
+ if fld.bits:
53
+ entry["bits_set"] = [fld.bits[b] for b in fld.bits if raw & (1 << b)]
54
+ named.append(entry)
55
+ d["settings"] = named
56
+ if include_raw_settings:
57
+ d["settings_raw"] = settings
58
+ d["assistant"] = parse_assistant_area(u)
59
+ out["units"].append(d)
60
+ return out
61
+
62
+
63
+ def decode_file(path: str, **kw) -> Dict:
64
+ with open(path, "rb") as fh:
65
+ return decode_bytes(fh.read(), **kw)
66
+
67
+
68
+ def brief(d: Dict) -> str:
69
+ """Human-readable one-screen summary of a decoded file."""
70
+ lines = [f"format {d.get('format_version')} length {d['length']} checksums {'OK' if d['all_checksums_ok'] else 'BAD'}"]
71
+ for u in d["units"]:
72
+ a = u["assistant"]
73
+ lines.append(f" {u['serial']} fw {u['firmware']} form={u['form']} flag={u['assistant_flag']} "
74
+ f"saved {u['save_time_utc']} assistant: {a['summary']}")
75
+ for s in u["settings"]:
76
+ if s.get("name") and s["confidence"] in ("CONFIRMED", "HIGH"):
77
+ v = s["value"]
78
+ lines.append(f" {s['name']:28s} {v!s:>8} {s['unit']:4s} [{s['confidence']}] ({s['offset']})")
79
+ return "\n".join(lines)
mk2vsc/diff.py ADDED
@@ -0,0 +1,155 @@
1
+ """
2
+ Compare two ``.rvms`` files **by inverter serial** and classify every differing byte.
3
+
4
+ Why by serial: the two blocks of a parallel pair swap file position between downloads of the same
5
+ system. A naive byte diff of two consecutive downloads shows ~44 differences; compared by serial it is
6
+ exactly 6 bookkeeping bytes per block (next-pointer, save timestamp, checksum). That distinction is
7
+ the basis of change verification: after an upload, the re-download must differ from what you uploaded
8
+ *only* in bookkeeping.
9
+
10
+ Classification of a differing block byte:
11
+
12
+ bookkeeping next-pointer (+0x0f..0x10), save timestamp (+0x4f..0x52 device form), checksum trailer
13
+ setting inside the settings array -> reported as (id, name, old, new)
14
+ header other bytes before the settings array (identity, flags, form blob)
15
+ assistant bytes in the assistant area
16
+ """
17
+ from __future__ import annotations
18
+
19
+ from dataclasses import dataclass, field
20
+ from typing import Dict, List, Optional
21
+
22
+ from .sections import RvmsFile
23
+ from .units import units_by_serial, UnitBlock, OFF_NEXT_PTR
24
+ from .fields import BY_ID
25
+
26
+
27
+ @dataclass
28
+ class UnitDiff:
29
+ serial: str
30
+ length_a: int # raw section length (name start .. checksum), position independent
31
+ length_b: int
32
+ form_a: str
33
+ form_b: str
34
+ bookkeeping: List[int] = field(default_factory=list)
35
+ settings: List[Dict] = field(default_factory=list)
36
+ header: List[Dict] = field(default_factory=list)
37
+ assistant: int = 0
38
+ note: str = ""
39
+
40
+ @property
41
+ def only_bookkeeping(self) -> bool:
42
+ same_len = self.length_a == self.length_b or self.form_a != self.form_b
43
+ return not self.settings and not self.header and self.assistant == 0 and same_len
44
+
45
+
46
+ @dataclass
47
+ class FileDiff:
48
+ identical: bool
49
+ length_a: int
50
+ length_b: int
51
+ prologue_identical: bool
52
+ only_in_a: List[str]
53
+ only_in_b: List[str]
54
+ units: List[UnitDiff]
55
+
56
+ @property
57
+ def only_bookkeeping(self) -> bool:
58
+ return (self.prologue_identical and not self.only_in_a and not self.only_in_b
59
+ and all(u.only_bookkeeping for u in self.units))
60
+
61
+ def as_dict(self) -> Dict:
62
+ return {
63
+ "identical": self.identical, "only_bookkeeping": self.only_bookkeeping,
64
+ "length": [self.length_a, self.length_b], "prologue_identical": self.prologue_identical,
65
+ "only_in_a": self.only_in_a, "only_in_b": self.only_in_b,
66
+ "units": [{"serial": u.serial, "length": [u.length_a, u.length_b], "form": [u.form_a, u.form_b],
67
+ "bookkeeping_bytes": [f"+0x{o:03x}" for o in u.bookkeeping],
68
+ "settings": u.settings, "header": u.header, "assistant_bytes_differ": u.assistant,
69
+ "note": u.note} for u in self.units],
70
+ }
71
+
72
+
73
+ def _classify(a: UnitBlock, b: UnitBlock) -> UnitDiff:
74
+ d = UnitDiff(a.serial, len(a.raw), len(b.raw), "upload" if a.is_upload_form else "device",
75
+ "upload" if b.is_upload_form else "device")
76
+ ra, rb = a.raw, b.raw
77
+ cross_form = a.is_upload_form != b.is_upload_form
78
+ if cross_form:
79
+ d.note = ("different forms (device vs upload): offsets shift by 10 after +0x45 and the GUI writes compact "
80
+ "assistant records, so lengths differ by form; settings compared by id")
81
+ elif len(ra) != len(rb):
82
+ d.note = "block length differs (assistant area changed)"
83
+ # settings compared by id regardless of form
84
+ sa, sb = a.settings(), b.settings()
85
+ for sid, (x, y) in enumerate(zip(sa, sb)):
86
+ if x != y:
87
+ f = BY_ID.get(sid)
88
+ d.settings.append({"id": sid, "name": f.name if f else None, "old_raw": x, "new_raw": y,
89
+ "old": f.decode(x) if f else x, "new": f.decode(y) if f else y,
90
+ "confidence": f.confidence if f else "UNKNOWN"})
91
+ # header bytes before the settings array, compared positionally only when forms match
92
+ if a.is_upload_form == b.is_upload_form:
93
+ bk = {OFF_NEXT_PTR, OFF_NEXT_PTR + 1} | {a.settings_offset - 10 + i for i in range(4)} # save ts
94
+ n = min(a.settings_offset, b.settings_offset)
95
+ for i in range(n):
96
+ if ra[i] != rb[i]:
97
+ if i in bk:
98
+ d.bookkeeping.append(i)
99
+ else:
100
+ d.header.append({"offset": f"+0x{i:03x}", "old": f"{ra[i]:02x}", "new": f"{rb[i]:02x}"})
101
+ # assistant area
102
+ aa, ab = a.assistant_area, b.assistant_area
103
+ if aa != ab:
104
+ d.assistant = sum(1 for x, y in zip(aa, ab) if x != y) + abs(len(aa) - len(ab))
105
+ else:
106
+ # cannot compare assistant bytes across forms (padding differs); compare record structure instead
107
+ from .assistants import parse_assistant_area
108
+ ka = [(r["marker"], r["subtype"]) for r in parse_assistant_area(a)["records"]]
109
+ kb = [(r["marker"], r["subtype"]) for r in parse_assistant_area(b)["records"]]
110
+ d.assistant = 0 if ka == kb else 1
111
+ # checksum trailer counted as bookkeeping when it differs
112
+ if ra[-4:] != rb[-4:]:
113
+ d.bookkeeping.extend(range(len(ra) - 4, len(ra)))
114
+ return d
115
+
116
+
117
+ def diff_bytes(data_a: bytes, data_b: bytes) -> FileDiff:
118
+ fa, fb = RvmsFile.parse(data_a), RvmsFile.parse(data_b)
119
+ ua, ub = units_by_serial(fa), units_by_serial(fb)
120
+ pro_a = fa.magic_raw + b"".join(s.raw for s in fa.sections if not s.is_unit)
121
+ pro_b = fb.magic_raw + b"".join(s.raw for s in fb.sections if not s.is_unit)
122
+ # BareSettingInfo carries its own pointer to the first unit block, which is position independent; the
123
+ # Mk2vscInfo/BareSettingInfo payloads are what we call the prologue.
124
+ pro_same = [s.payload for s in fa.sections if not s.is_unit] == [s.payload for s in fb.sections if not s.is_unit]
125
+ units = [_classify(ua[s], ub[s]) for s in sorted(set(ua) & set(ub))]
126
+ return FileDiff(identical=(data_a == data_b), length_a=len(data_a), length_b=len(data_b),
127
+ prologue_identical=pro_same, only_in_a=sorted(set(ua) - set(ub)),
128
+ only_in_b=sorted(set(ub) - set(ua)), units=units)
129
+
130
+
131
+ def diff_files(path_a: str, path_b: str) -> FileDiff:
132
+ with open(path_a, "rb") as fa, open(path_b, "rb") as fb:
133
+ return diff_bytes(fa.read(), fb.read())
134
+
135
+
136
+ def render(d: FileDiff) -> str:
137
+ if d.identical:
138
+ return "identical"
139
+ lines = [f"lengths {d.length_a} -> {d.length_b}; prologue {'same' if d.prologue_identical else 'DIFFERS'}; "
140
+ f"verdict: {'ONLY BOOKKEEPING (settings verbatim)' if d.only_bookkeeping else 'CONTENT CHANGED'}"]
141
+ for s in d.only_in_a:
142
+ lines.append(f" {s}: only in A")
143
+ for s in d.only_in_b:
144
+ lines.append(f" {s}: only in B")
145
+ for u in d.units:
146
+ lines.append(f" {u.serial}: len {u.length_a}->{u.length_b} form {u.form_a}->{u.form_b} "
147
+ f"bookkeeping={len(u.bookkeeping)}B header={len(u.header)}B assistant={u.assistant}B"
148
+ + (f" [{u.note}]" if u.note else ""))
149
+ for s in u.settings:
150
+ lines.append(f" setting {s['id']:3d} {s['name'] or '?':28s} {s['old']} -> {s['new']} [{s['confidence']}]")
151
+ for h in u.header[:12]:
152
+ lines.append(f" header {h['offset']}: {h['old']} -> {h['new']}")
153
+ if len(u.header) > 12:
154
+ lines.append(f" ... {len(u.header)-12} more header bytes")
155
+ return "\n".join(lines)
@@ -0,0 +1,11 @@
1
+ """
2
+ Experimental: assistant (ESS) injection by file.
3
+
4
+ Nothing in this package has produced a running ESS system. It is published so the work can be picked
5
+ up, reviewed, or corrected. Read docs/ESS_INJECTION.md first. The CLI exposes these only behind
6
+ ``mk2vsc experimental ... --i-accept-the-risk``.
7
+ """
8
+ from .ess_graft import graft, GraftRefused, INSTALL_STATE
9
+ from .upload_form import to_upload_form, compare_per_slot, TransformRefused, BLOB12
10
+
11
+ __all__ = ["graft", "GraftRefused", "INSTALL_STATE", "to_upload_form", "compare_per_slot", "TransformRefused", "BLOB12"]