Files
bmspy/tests/test_ecoflow_schema.py
tim 452a3bc207 Correct the ups_alarm field spelling
The upstream River 3 schema spells field 355 `ups_alram`. Use `ups_alarm` in the decoder, the generated schema, and the field reference. The schema generator now applies a rename map, so a regenerated schema keeps the corrected name.
2026-10-10 09:03:32 +02:00

209 lines
7.9 KiB
Python

"""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
def test_generator_corrects_upstream_field_spelling(generator):
messages, _ = generator.parse_schema(
'message DisplayPropertyUpload { optional bool ups_alram = 355; } message RuntimePropertyUpload {}'
)
assert messages['DisplayPropertyUpload'][355] == ('ups_alarm', 'bool', False)
assert MESSAGES['DisplayPropertyUpload'][355] == ('ups_alarm', 'bool', False)
@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 {}')