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 +35 -0
- mk2vsc/assistants.py +105 -0
- mk2vsc/cli.py +249 -0
- mk2vsc/decode.py +79 -0
- mk2vsc/diff.py +155 -0
- mk2vsc/experimental/__init__.py +11 -0
- mk2vsc/experimental/ess_graft.py +151 -0
- mk2vsc/experimental/upload_form.py +170 -0
- mk2vsc/fields.py +243 -0
- mk2vsc/history.py +110 -0
- mk2vsc/qualify.py +121 -0
- mk2vsc/sections.py +230 -0
- mk2vsc/units.py +215 -0
- mk2vsc/writer.py +141 -0
- mk2vsc-0.1.2.dist-info/METADATA +238 -0
- mk2vsc-0.1.2.dist-info/RECORD +20 -0
- mk2vsc-0.1.2.dist-info/WHEEL +5 -0
- mk2vsc-0.1.2.dist-info/entry_points.txt +2 -0
- mk2vsc-0.1.2.dist-info/licenses/LICENSE +21 -0
- mk2vsc-0.1.2.dist-info/top_level.txt +1 -0
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"]
|