diff --git a/python/protocan/trends.py b/python/protocan/trends.py index 94c67fa..71b6f38 100644 --- a/python/protocan/trends.py +++ b/python/protocan/trends.py @@ -54,6 +54,8 @@ class TrendSignal: deviceType: int = 7 device: int = 13 byteOffset: int = 0 + multiplier: float = 1.0 + iq: int = 0 extended: bool = True @classmethod @@ -72,9 +74,11 @@ class TrendSignal: for field in ("id", "name", "source", "address", "color", "valueType"): if type(getattr(self, field)) is not str: raise ValueError(f"{field} must be a string") - for field in ("order", "deviceType", "device", "byteOffset"): + for field in ("order", "deviceType", "device", "byteOffset", "iq"): if type(getattr(self, field)) is not int: raise ValueError(f"{field} must be an integer") + if type(self.multiplier) not in (int, float) or not math.isfinite(self.multiplier): + raise ValueError("multiplier must be a finite number") if type(self.visible) is not bool or type(self.extended) is not bool: raise ValueError("Visibility and CAN format must be boolean") if not self.id.strip() or len(self.id) > 80 or not 1 <= self.order <= 9999: @@ -98,6 +102,11 @@ class TrendSignal: raise ValueError("Invalid ProtoCAN device") if self.source == "CAN_RAW" and not 0 <= self.byteOffset <= 6: raise ValueError("Word offset must be 0..6") + if not 0 <= self.iq <= 30: + raise ValueError("IQ must be 0..30") + + def display_value(self, raw_value: float) -> float: + return raw_value * self.multiplier / (1 << self.iq) def validate_settings(settings: Mapping[str, list[TrendSignal]]) -> None: ids = set() @@ -132,14 +141,15 @@ def decode_settings(text: str) -> dict[str, list[TrendSignal]]: raise ValueError("profiles must be an object") result = {} fields = set(TrendSignal.__dataclass_fields__) + required_fields = fields - {"multiplier", "iq"} for profile, items in profiles.items(): if not isinstance(items, list) or len(items) > MAX_SIGNALS: raise ValueError("Expected up to 64 signals") signals = [] for item in items: - if not isinstance(item, dict) or not fields <= item.keys(): + if not isinstance(item, dict) or not required_fields <= item.keys(): raise ValueError("Missing signal fields") - signals.append(TrendSignal(**{key: item[key] for key in fields})) + signals.append(TrendSignal(**{key: item[key] for key in fields if key in item})) result[profile] = signals validate_settings(result) return result diff --git a/python/tests/test_trends.py b/python/tests/test_trends.py index 9a82963..3613823 100644 --- a/python/tests/test_trends.py +++ b/python/tests/test_trends.py @@ -14,6 +14,16 @@ FIXTURE = Path(__file__).resolve().parents[2] / "c/set-protocol/tests/fixtures/t class TrendTests(unittest.TestCase): + def test_multiplier_and_iq_scale_display_and_old_json_defaults(self): + signal = TrendSignal("scaled", multiplier=2.5, iq=3) + self.assertEqual(10.0, signal.display_value(32.0)) + signal.validate("TMS2812") + payload = json.loads(encode_settings({"TMS2812": [signal]})) + payload["profiles"]["TMS2812"][0].pop("multiplier") + payload["profiles"]["TMS2812"][0].pop("iq") + restored = decode_settings(json.dumps(payload))["TMS2812"][0] + self.assertEqual((1.0, 0), (restored.multiplier, restored.iq)) + def test_shared_kotlin_fixture_and_round_trip(self): settings = decode_settings(FIXTURE.read_text(encoding="utf-8")) self.assertEqual(5, sum(map(len, settings.values())))