Add River 3 telemetry and schema-based diagnostics
Decode River 3 separately and accumulate partial reports against a field-level checklist. Keep watch mode running after completion. Label fields and nested records from the pinned pr705 schema. Preserve unknown values and document unverified meanings and routing. Add synthetic regressions, the River 3 field reference, schema-generation tooling, and upstream attribution.
This commit is contained in:
@@ -0,0 +1,200 @@
|
||||
"""Schema-based diagnostic rendering with synthetic data only."""
|
||||
|
||||
import importlib.util
|
||||
from pathlib import Path
|
||||
import re
|
||||
import struct
|
||||
|
||||
import pytest
|
||||
|
||||
from bmspy.ecoflow_proto import (
|
||||
decode_payload, decode_schema_protobuf, describe_protobuf_field,
|
||||
)
|
||||
from bmspy.ecoflow_river3 import decode_river3_payload
|
||||
from bmspy.ecoflow_river3_schema import ENUMS, MESSAGES, SCHEMA_COMMIT
|
||||
|
||||
|
||||
def varint(value):
|
||||
value &= (1 << 64) - 1
|
||||
result = bytearray()
|
||||
while value > 127:
|
||||
result.append((value & 127) | 128)
|
||||
value >>= 7
|
||||
result.append(value)
|
||||
return bytes(result)
|
||||
|
||||
|
||||
def integer(number, value):
|
||||
return varint(number << 3) + varint(value)
|
||||
|
||||
|
||||
def blob(number, data):
|
||||
return varint(number << 3 | 2) + varint(len(data)) + data
|
||||
|
||||
|
||||
def floating(number, value):
|
||||
return varint(number << 3 | 5) + struct.pack('<f', value)
|
||||
|
||||
|
||||
def display(data):
|
||||
return '\n'.join(decode_payload(2, 0xFE, 0x15, data))
|
||||
|
||||
|
||||
def sample(number, kind):
|
||||
if kind in MESSAGES or kind == 'string':
|
||||
return blob(number, b'')
|
||||
if kind == 'float':
|
||||
return floating(number, 1.5)
|
||||
assert kind in ('bool', 'uint32', 'int32') or kind in ENUMS
|
||||
return integer(number, 0)
|
||||
|
||||
|
||||
@pytest.mark.parametrize('message,cmd_id,count', [
|
||||
('DisplayPropertyUpload', 0x15, 296), ('RuntimePropertyUpload', 0x16, 98),
|
||||
])
|
||||
def test_every_pinned_top_level_field_has_a_label(message, cmd_id, count):
|
||||
assert len(MESSAGES[message]) == count
|
||||
for number, (name, kind, repeated) in MESSAGES[message].items():
|
||||
text = '\n'.join(decode_payload(2, 0xFE, cmd_id, sample(number, kind)))
|
||||
assert name + ':' in text
|
||||
assert describe_protobuf_field(2, 0xFE, cmd_id, number) == name
|
||||
assert not re.search(r'\bfield\s+\d+:', text)
|
||||
assert not any(word in text for word in ('<invalid', '<malformed', '<wire type'))
|
||||
|
||||
|
||||
def test_error_records_have_named_children_and_preserve_empty_records():
|
||||
item = integer(1, 42) + integer(2, 1234)
|
||||
text = display(blob(141, blob(1, item) + blob(1, b'')))
|
||||
assert 'err_code_record_list: {' in text
|
||||
assert 'list_info: {' in text
|
||||
assert 'errcode: 42' in text
|
||||
assert 'errcode_timestamp: 1234' in text
|
||||
assert 'list_info: {}' in text
|
||||
assert text.count('errcode:') == 1 # Empty records do not become invented errors.
|
||||
assert not re.search(r'\bfield\s+\d+:', text)
|
||||
|
||||
|
||||
def test_statistics_have_named_categories_without_invented_energy_units():
|
||||
items = blob(1, integer(1, 2) + integer(2, 17))
|
||||
items += blob(1, integer(1, 4)) # Content absent, not an explicit zero.
|
||||
items += blob(1, b'')
|
||||
text = display(blob(463, items))
|
||||
assert 'display_statistics_sum:' in text
|
||||
assert 'statistics_object: STATISTICS_OBJECT_AC_OUT_ENERGY (2)' in text
|
||||
assert 'statistics_object: STATISTICS_OBJECT_TYPEC_OUT_ENERGY (4)' in text
|
||||
assert 'statistics_content: 17' in text
|
||||
assert text.count('statistics_content:') == 1
|
||||
assert 'list_info: {}' in text
|
||||
assert ' Wh' not in text
|
||||
assert not re.search(r'\bfield\s+\d+:', text)
|
||||
|
||||
|
||||
def test_strings_are_not_guessed_as_nested_messages():
|
||||
text = display(blob(88, b'') + blob(433, b'TEST-ACCESSORY') + blob(91, b''))
|
||||
assert "plug_in_info_dcp2_sn: ''" in text
|
||||
assert "plug_in_info_dcp_sn: 'TEST-ACCESSORY'" in text
|
||||
assert 'plug_in_info_dcp2_resv: {}' in text
|
||||
invalid = display(blob(433, b'\xff'))
|
||||
assert 'plug_in_info_dcp_sn: <invalid string:' in invalid
|
||||
assert 'raw:' in invalid
|
||||
|
||||
|
||||
def test_packed_and_unpacked_repeated_integers():
|
||||
values = [0, 127, 128, 0xFFFFFFFF]
|
||||
packed = display(blob(432, blob(1, b''.join(varint(n) for n in values))))
|
||||
assert 'resv_info: [0, 127, 128, 4294967295]' in packed
|
||||
unpacked = display(blob(432, b''.join(integer(1, n) for n in values)))
|
||||
for value in values:
|
||||
assert f'resv_info: {value}' in unpacked
|
||||
assert 'resv_info: []' in display(blob(432, blob(1, b'')))
|
||||
assert 'resv_info: <invalid uint32:' in display(blob(432, blob(1, b'\x80')))
|
||||
|
||||
|
||||
def test_signed_boolean_enum_and_unknown_enum_values():
|
||||
assert 'bms_min_cell_temp: -7 °C' in display(integer(258, -7))
|
||||
assert 'xboost_en: true' in display(integer(25, 1))
|
||||
assert 'xboost_en: false' in display(integer(25, 0))
|
||||
assert 'statistics_object: 999 (unknown STATISTICS_OBJECT)' in display(blob(463, blob(1, integer(1, 999))))
|
||||
|
||||
|
||||
def test_known_field_with_wrong_encoding_retains_label_and_raw_bytes():
|
||||
text = display(integer(3, 10))
|
||||
assert 'pow_in_sum_w: <wire type 0, expected 5, raw:' in text
|
||||
assert '10 W' not in text
|
||||
assert 'pow_get_bms: <wire type 2, expected 5' in display(blob(158, b'bad'))
|
||||
|
||||
|
||||
@pytest.mark.parametrize('bad', [b'\x00', b'\x80', b'\x0a\x05\x01', b'\x0d\x00', b'\x09\x00'])
|
||||
def test_malformed_reports_are_not_normal_measurements(bad):
|
||||
text = display(floating(262, 89) + bad)
|
||||
assert '<malformed DisplayPropertyUpload:' in text
|
||||
assert 'raw:' in text
|
||||
assert 'cms_batt_soc: 89' not in text
|
||||
assert decode_river3_payload(2, 0xFE, 0x15, floating(262, 89) + bad) is None
|
||||
|
||||
|
||||
def test_unknown_future_fields_remain_numeric_and_raw():
|
||||
text = display(integer(999, 7) + blob(1000, b'opaque'))
|
||||
assert 'field 999: 7' in text
|
||||
assert 'field 1000:' in text
|
||||
assert b'opaque'.hex() in text
|
||||
assert 'unknown' in text
|
||||
assert decode_payload(0x35, 0x35, 0x20, bytes(8)) is None
|
||||
|
||||
|
||||
def test_runtime_routing_stays_explicitly_unconfirmed():
|
||||
text = '\n'.join(decode_payload(2, 0xFE, 0x16, floating(244, 19.2)))
|
||||
assert 'routing unconfirmed' in text
|
||||
assert 'bms_batt_vol: 19.200 V' in text
|
||||
assert decode_river3_payload(2, 0xFE, 0x16, floating(244, 19.2)) is None
|
||||
|
||||
|
||||
def test_schema_nesting_has_a_depth_limit(monkeypatch):
|
||||
monkeypatch.setitem(MESSAGES, 'SyntheticRecursive', {1: ('child', 'SyntheticRecursive', False)})
|
||||
payload = b''
|
||||
for _ in range(30):
|
||||
payload = blob(1, payload)
|
||||
text = '\n'.join(decode_schema_protobuf(payload, 'SyntheticRecursive'))
|
||||
assert 'maximum nesting depth reached' in text
|
||||
assert 'raw:' in text
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def generator():
|
||||
path = Path(__file__).resolve().parents[1] / 'tools/generate_ecoflow_river3_schema.py'
|
||||
spec = importlib.util.spec_from_file_location('schema_generator', path)
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(module)
|
||||
return module
|
||||
|
||||
|
||||
def test_generator_is_deterministic_and_pinned(generator):
|
||||
path = Path(__file__).resolve().parents[1] / 'bmspy/ecoflow_river3_schema.py'
|
||||
assert re.fullmatch('[a-f0-9]{40}', SCHEMA_COMMIT)
|
||||
assert generator.generate(MESSAGES, ENUMS, SCHEMA_COMMIT) == path.read_text()
|
||||
|
||||
|
||||
def test_generator_keeps_reachable_types_and_ignores_unrelated_nested_types(generator):
|
||||
schema = '''
|
||||
message DisplayPropertyUpload { optional Item values = 1; }
|
||||
message RuntimePropertyUpload { repeated uint32 raw = 2; }
|
||||
message Item { optional Kind kind = 1; }
|
||||
enum Kind { NONE = 0; ACTIVE = 1; }
|
||||
message Unrelated { message Item { optional uint32 unrelated = 1; } }
|
||||
'''
|
||||
messages, enums = generator.parse_schema(schema)
|
||||
assert messages['Item'] == {1: ('kind', 'Kind', False)}
|
||||
assert messages['RuntimePropertyUpload'][2] == ('raw', 'uint32', True)
|
||||
assert enums['Kind'][1] == 'ACTIVE'
|
||||
assert 'Unrelated' not in messages
|
||||
|
||||
|
||||
@pytest.mark.parametrize('body', [
|
||||
'optional Missing value = 1;',
|
||||
'optional uint32 a = 1; optional uint32 b = 1;',
|
||||
'map<string, int32> unsupported = 1;',
|
||||
'message Nested {} optional Nested value = 1;',
|
||||
])
|
||||
def test_generator_rejects_unsupported_or_incomplete_reachable_types(generator, body):
|
||||
with pytest.raises(ValueError):
|
||||
generator.parse_schema('message DisplayPropertyUpload {' + body + '} message RuntimePropertyUpload {}')
|
||||
Reference in New Issue
Block a user