diff --git a/python/lorawan/README.md b/python/lorawan/README.md index a871838..00144ce 100644 --- a/python/lorawan/README.md +++ b/python/lorawan/README.md @@ -230,7 +230,7 @@ it is *transferred*. | `AppKey` | OTAA root key — `apikey` scheme `name: "appKey"` | **yes (runtime)** | | `NwkKey` | OTAA network root key (1.1.x) — `apikey` scheme `name: "nwkKey"` | **yes (runtime)** | -Device metadata that is not LoRaWAN-specific uses [schema.org](https://schema.org): +Device metadata that is not LoRaWAN-specific uses established schemas, such as [schema.org](https://schema.org): `schema:manufacturer` and `schema:mpn` (the manufacturer's part number) identify the model, `schema:version` and `schema:softwareVersion` carry the hardware and firmware revisions, and the Thing's `id`/`title` identify the end device. @@ -257,7 +257,7 @@ The **Tier** column shows how often each term appears across the bundled The rare terms exist because the reference schemas model real vendor payloads, so you can ignore them until a device needs one. -| Term | Meaning | MultiTech | Tier | Seen in | +| Term | Meaning | LoRa Alliance Payload Schema / MultiTech | Tier | Seen in | |------|---------|-----------|------|---------| | `lorav:wireType` | Wire data type (xsd alias or native, e.g. `xsd:short`, `s16`) | `type` | core | every device | | `lorav:endian` | Byte order of this multi-byte value, `"big"` (default) or `"little"` | `endian` | core | every multi-byte value | @@ -294,6 +294,7 @@ Supported `lorav:wireType` values: the sized XSD types (`xsd:byte`, `xsd:short`, | a unit code | `"unit": "Cel"` (already carries UN/CEFACT codes) | | a valid range | `"minimum": -40, "maximum": 85` | | the values a reading may take | `"enum": ["dry", "wet"]` | +| a byte fixed at one value (e.g. a downlink command byte) | `"const": 3` | | manufacturer / part number | `"schema:manufacturer"`, `"schema:mpn"` on the Thing | | hardware / firmware version | `"schema:version"`, `"schema:softwareVersion"` on the Thing | @@ -332,10 +333,10 @@ branch between them are expressed by giving several events the same locator: | A value belongs to one branch of a discriminator | `lorav:presentWhen: { "field": "event", "value": 1 }` | | A value is derived rather than read from the wire | `lorav:wireType: "number"` + `lorav:derived` | -`lorav:presentWhen` always names the value it depends on in `field`, then gates +`lorav:presentWhen` names the value it depends on in `field`, then gates on either a `bit` of it or an exact `value`. -`lorav:bitmask` must select a *contiguous* range of bits. The base value is read +`lorav:bitmask` selects a *contiguous* range of bits. The base value is read once and decoded into every event masking it, and bases wider than one byte (`u16`/`u24`/`u32`) work too, for ranges spanning several bytes. @@ -343,10 +344,16 @@ once and decoded into every event masking it, and bases wider than one byte Some values a device reports are never transmitted. The payload carries a raw count and the vendor's documentation gives the arithmetic that turns it into the -quantity the user asked for, or the value is computed from two other readings. +quantity the user needs, such as, a value is computed from two other sensor readings. Such a value occupies **zero payload bytes**, which is what `lorav:wireType: "number"` records — there is no wire type, because there is nothing to read. +`transform` is the exception. It post-processes a value that *was* read from the +wire, so it sits next to a real `lorav:wireType` and `lorav:byteOffset` rather +than replacing them. A `lorav:derived` carrying only `transform` keeps its wire +type; one carrying `ref`, `polynomial`, `compute` or `guard` declares +`"number"`. + `lorav:derived` is one object with up to five keys, each naming *where the value comes from*. They apply in this order: @@ -358,6 +365,8 @@ comes from*. They apply in this order: | `guard` | A precondition `{ "when": [ … ], "else": v }`; the value falls back to `v` when a `when` clause fails | | `transform` | Ordered post-processing steps, each one of `add` / `div` / `mult` / `round` | +Examples: + Decentlab's 5TM soil sensor transmits a raw dielectric permittivity and documents a fourth-order fit for the water content derived from it: @@ -496,7 +505,7 @@ fails on a fresh clone until you run the generator once. generation from the reference device schemas — across all four layouts and the `flagged` / `match` / `byte_group` / computed shapes (the [device catalog](#device-catalog)). -* **Later:** downlink and actions (write/invoke); `formula`/`value`-style computed +* **Later:** downlink and actions (write/invoke); `formula`-style computed fields; per-`fPort` branched codec generation; further payload layouts. ### Known limitations @@ -505,9 +514,12 @@ The binding covers most common fixed/ports/TLV layouts, but these gaps remain: * **Arrays / nested object payloads** — dynamic `repeat` structures and object/array field values are not representable as flat TD events. -* **Legacy computed-field forms** — the structured descriptors +* **`formula` expressions** — the structured descriptors (`ref`/`polynomial`/`transform`/`compute`/`guard`) are supported, but raw - `formula`/literal-`value` expressions in source schemas are not converted. + `formula` expressions in source schemas are not converted. +* **`bitfield_string` fields** — a field rendering packed bits as text has no + form term and takes its device out of the catalog. Affects 36 Milesight + schemas, which adopted the type in the current submodule pin. * **TLV variants outside `tag_fields` style** — `tag_size`/length-prefixed TLV forms and some non-standard tag-key encodings are not converted. * **Match defaults** — explicit enumerated `match` cases are supported (including @@ -516,11 +528,6 @@ The binding covers most common fixed/ports/TLV layouts, but these gaps remain: * **Alternate source branches** — when one output field switches to a different byte source under an extension/status flag (Dragino `Ext`-style), only the common path is modeled. -* **`lorav:valueMap` tables must start at 0 and be contiguous** — the reference - interpreter indexes the table positionally (`0 <= value < len(table)`), so a - table keyed 1–3 drops its last entry: wire value 3 decodes to `3` rather than - its label. Affects three RadioBridge `rbs30x` events. The leaked integer fails - the event's own `data` schema, so it is at least detectable. * **TS013 JS generator shared-byte gap** — `generate_ts013_codec.py` can drop bare bit-range fields and fail to advance the cursor correctly. This affects generated JavaScript only; `lorawan-wot decode` remains correct. diff --git a/python/lorawan/external/device-payload-schema b/python/lorawan/external/device-payload-schema index 0701777..1174141 160000 --- a/python/lorawan/external/device-payload-schema +++ b/python/lorawan/external/device-payload-schema @@ -1 +1 @@ -Subproject commit 0701777614e5eb82408ae40e40bd7e8c89acc801 +Subproject commit 1174141077b8f6210f7d92d43a16e159360b0d2d diff --git a/python/lorawan/src/lorawan_wot/converter.py b/python/lorawan/src/lorawan_wot/converter.py index 9398a25..adcb763 100644 --- a/python/lorawan/src/lorawan_wot/converter.py +++ b/python/lorawan/src/lorawan_wot/converter.py @@ -210,11 +210,23 @@ def derived(self) -> dict[str, Any]: def is_computed(self) -> bool: """True when this value is derived (zero payload bytes). - A computed value carries ``lorav:wireType`` ``"number"`` and/or a - ``lorav:derived`` descriptor object; the reference interpreter evaluates - it from other already-decoded values rather than reading wire bytes. + A derived value has nothing to read: its ``lorav:wireType`` is + ``"number"``, the marker the reference interpreter evaluates from other + already-decoded values rather than from wire bytes. + + Carrying a ``lorav:derived`` descriptor does not by itself make a value + derived. ``transform`` post-processes a value that *was* read from the + wire, and treating any descriptor as proof of derivation gave a + transform-carrying scalar a byte width of zero: every field after it read + from the wrong offset, and the field itself dropped out of the decode. + That is a wrong Thing Description rather than a missing one, which no + count of converted devices can detect. """ - return self.form.get(vocab.WIRE_TYPE) == vocab.COMPUTED_TYPE or bool(self.derived) + wire = self.form.get(vocab.WIRE_TYPE) + if wire is None: + # Nothing declared to read, so the descriptors are all there is. + return bool(self.derived) + return wire == vocab.COMPUTED_TYPE @property def byte_width(self) -> int: @@ -231,6 +243,11 @@ def byte_width(self) -> int: f"Event {self.name!r}: type {wire!r} requires " f"{vocab.BYTE_LENGTH!r} to determine its byte width." ) + if int(length) == vocab.BYTE_LENGTH_REMAINING: + # Width known only at decode time. Reported as 0 so it does not + # advance a fixed-layout cursor by a nonsense amount: a field that + # eats the rest of the payload has nothing after it to place. + return 0 return int(length) # -- MultiTech field body ------------------------------------------------- @@ -256,6 +273,13 @@ def body(self, default_endian: str) -> dict[str, Any]: # they appear on the form (which preserves the source schema's order). _emit_scaling(field, self.form) + # A wire field may still post-process what it read. Carried here as well + # as on the derived path: dropping it left the bytes read correctly but + # unscaled, so decentlab's air_temperature reported 65535 instead of + # 327.67. + if (transform := self.derived.get("transform")) is not None: + field["transform"] = copy.deepcopy(transform) + # Length for variable-length types (string/bytes/hex/base64). if (length := self.form.get(vocab.BYTE_LENGTH)) is not None: field["length"] = int(length) @@ -289,6 +313,11 @@ def _emit_semantics(self, field: dict[str, Any]) -> None: field["lookup"] = lookup if (valid_range := self._valid_range()) is not None: field["valid_range"] = valid_range + if (const := self.data.get("const")) is not None: + # The byte an encoder must emit. Decoding ignores it -- the + # interpreter reports whatever the payload held -- so this only has to + # survive the round trip. + field["value"] = const def _lookup(self) -> dict[int, Any] | None: """Build the interpreter's ``lookup`` table from the form's value map. diff --git a/python/lorawan/src/lorawan_wot/schema_to_td.py b/python/lorawan/src/lorawan_wot/schema_to_td.py index b49b33d..25f3e0b 100644 --- a/python/lorawan/src/lorawan_wot/schema_to_td.py +++ b/python/lorawan/src/lorawan_wot/schema_to_td.py @@ -24,6 +24,7 @@ from __future__ import annotations import ast +import copy import enum import re from typing import Any @@ -45,6 +46,14 @@ "values", "lookup", "valid_range", + "transform", # post-processing of a value read from the wire + # A byte fixed at this value when *encoding*. It is not a derived value: + # the reference interpreter ignores it when decoding, reading the byte and + # reporting what the payload actually held. Every downlink command in the + # schema library declares its category and command bytes that way, so + # treating it as unconvertible cost 45 devices. It rides on the event's + # data schema as ``const``; see _data_schema. + "value", } ) @@ -61,19 +70,18 @@ "comment", "unece", # UN/CEFACT unit code; TD core's ``unit`` carries the unit "var", # names a field for later $reference; decoding-neutral here + "sensor", # names the sensor a channel belongs to; an annotation like ``semantic`` } ) -#: Field keys / types that mark a *computed* (derived) value the binding cannot -#: express, since it is post-processing math rather than a raw wire field. -_COMPUTED_FIELD_KEYS: frozenset[str] = frozenset( - {"ref", "polynomial", "transform", "formula", "compute", "guard", "value"} -) -_COMPUTED_FIELD_TYPES: frozenset[str] = frozenset({"number", "bitfield_string"}) +#: Field types the binding has no term for. ``number`` is *not* here: it is the +#: derived-value marker, which is routed to the computed path rather than +#: rejected. ``bitfield_string`` renders packed bits as text and has no form term. +_UNSUPPORTED_FIELD_TYPES: frozenset[str] = frozenset({"bitfield_string"}) #: Derived-value descriptors the binding *does* support (carried verbatim onto a -#: ``lorav:`` form). ``formula``/``value`` remain unsupported and keep their -#: fields out of the convertible subset. +#: ``lorav:`` form). ``formula`` remains unsupported and keeps its fields out of +#: the convertible subset. _COMPUTED_DESCRIPTORS: frozenset[str] = frozenset( {"ref", "polynomial", "transform", "compute", "guard"} ) @@ -107,6 +115,8 @@ class SkipReason(enum.Enum): MATCH_CASE_KEY = "non-integer match case key" TLV_TAG = "unsupported tlv tag key" ENUM_TABLE = "unsupported enum table" + LENGTH_NOT_FIXED = "length is not a byte count ($variable)" + INTERNAL_REF = "derived value reads a value the TD does not carry" MALFORMED = "malformed / non-conforming schema" OTHER = "other" @@ -132,18 +142,79 @@ def payload_schema_to_td(schema: dict[str, Any], *, source: str) -> dict[str, An if not isinstance(schema, dict): raise UnsupportedSchemaError("schema is not a mapping", reason=SkipReason.MALFORMED) if "ports" in schema: - return _ports_td(schema, source) + td = _ports_td(schema, source) + else: + fields = schema.get("fields") + if not isinstance(fields, list) or not fields: + raise UnsupportedSchemaError( + "schema has no top-level 'fields' list", reason=SkipReason.MALFORMED + ) + if len(fields) == 1 and isinstance(fields[0], dict) and "tlv" in fields[0]: + td = _tlv_td(schema, source) + else: + td = _fixed_td(schema, source) + _reject_unresolvable_inputs(td.get(vocab.EVENTS) or {}) + return td + - fields = schema.get("fields") - if not isinstance(fields, list) or not fields: +def _referenced_names(descriptor: Any) -> set[str]: + """Every ``$name`` a derived-value descriptor reads, at any nesting depth.""" + found: set[str] = set() + if isinstance(descriptor, str): + if descriptor.startswith("$"): + found.add(descriptor[1:]) + elif isinstance(descriptor, dict): + for value in descriptor.values(): + found |= _referenced_names(value) + elif isinstance(descriptor, list): + for item in descriptor: + found |= _referenced_names(item) + return found + + +def _reject_unresolvable_inputs(events: dict[str, Any]) -> None: + """Reject a Thing Description whose derived values read something it lacks. + + A derived value names its inputs with ``$name``. The forward conversion + rebuilds a payload schema from the events alone, so an input with no event of + its own comes back as nothing and the reference interpreter evaluates the + field against zero -- qingping reported a temperature of -50 where its schema + says 359.5, because ``$_temp_raw`` was internal scratch state. + + Checked against the assembled events rather than by rejecting every internal + name: most internal inputs *do* get an event and resolve correctly, and + rejecting on the name alone cost 22 devices to prevent one wrong Thing + Description. + + Skipped rather than approximated. A Thing Description that decodes to the + wrong number is worse than one that does not exist, and unlike a skip it is + invisible in the catalog count. + """ + available = set(events) + # An internal input that is a bit range cannot survive the round trip: the + # forms are emitted correctly, but rebuilding flattens the byte group into + # top-level bit-range fields, and the interpreter does not store a top-level + # `_`-prefixed field as a variable. Plain internal scalars are unaffected. + masked_internal = { + name + for name, event in events.items() + if name.startswith("_") + and any(vocab.BITMASK in form for form in event.get(vocab.FORMS, [])) + } + missing: dict[str, list[str]] = {} + for name, event in events.items(): + for form in event.get(vocab.FORMS, []): + referenced = _referenced_names(form.get(vocab.DERIVED)) + unresolved = sorted((referenced - available) | (referenced & masked_internal)) + if unresolved: + missing.setdefault(name, []).extend(unresolved) + if missing: + detail = "; ".join(f"{n} reads {sorted(set(refs))}" for n, refs in missing.items()) raise UnsupportedSchemaError( - "schema has no top-level 'fields' list", reason=SkipReason.MALFORMED + f"derived value input is not an event: {detail}", + reason=SkipReason.INTERNAL_REF, ) - if len(fields) == 1 and isinstance(fields[0], dict) and "tlv" in fields[0]: - return _tlv_td(schema, source) - return _fixed_td(schema, source) - # --- layout builders --------------------------------------------------------- @@ -552,27 +623,47 @@ def _check_field_keys(field: dict[str, Any]) -> None: def _reject_computed(field: dict[str, Any]) -> None: - """Reject computed/derived fields (post-processing math, not raw wire data).""" + """Reject fields the binding has no way to express.""" if not isinstance(field, dict): raise UnsupportedSchemaError( f"field is not a mapping: {field!r}", reason=SkipReason.MALFORMED ) - if field.get("type") in _COMPUTED_FIELD_TYPES: + if field.get("type") in _UNSUPPORTED_FIELD_TYPES: raise UnsupportedSchemaError( - f"computed field type {field.get('type')!r}", reason=SkipReason.COMPUTED + f"unsupported field type {field.get('type')!r}", reason=SkipReason.COMPUTED ) - computed = _COMPUTED_FIELD_KEYS & field.keys() - if computed: + if _is_computed_field(field): + # A derived field on the scalar path is a routing mistake rather than a + # schema outside the subset: every caller checks _is_computed_field first. raise UnsupportedSchemaError( - f"computed field uses keys: {sorted(computed)}", reason=SkipReason.COMPUTED + f"derived field {field.get('name')!r} reached the scalar path", + reason=SkipReason.COMPUTED, ) + if "formula" in field: + # A free-text expression, which the binding has no term for. + raise UnsupportedSchemaError("field uses 'formula'", reason=SkipReason.COMPUTED) def _is_computed_field(field: dict[str, Any]) -> bool: - """True when a source field is a derived value rather than a raw wire field.""" + """True when a source field is a derived value rather than a raw wire field. + + A derived value has nothing to read: either ``type: number``, or no type at + all plus a descriptor saying where its value comes from. + + Carrying a descriptor is not itself enough. ``transform`` post-processes a + value that *was* read from the wire, and treating any field carrying one as + derived replaced its wire type with the derived marker, making it zero bytes + wide -- decentlab's ``air_temperature`` (a ``u16`` with + ``transform: [{div: 100}, {add: -327.68}]``) then vanished from the generated + Thing Description and shifted every field after it. A silently wrong + conversion, not a skipped one, so the catalog count stayed quiet about it. + """ if not isinstance(field, dict): return False - return field.get("type") == vocab.COMPUTED_TYPE or bool(_COMPUTED_DESCRIPTORS & field.keys()) + ftype = field.get("type") + if ftype is None: + return bool(_COMPUTED_DESCRIPTORS & field.keys()) + return ftype == vocab.COMPUTED_TYPE def _computed_event(field: dict[str, Any], **locator: Any) -> dict[str, Any]: @@ -733,7 +824,13 @@ def _byte_width(base: str, field: dict[str, Any]) -> int: f"variable-length type {base!r} on field {field.get('name')!r} needs a length", reason=SkipReason.MALFORMED, ) - return int(length) + width = _byte_length(length) + if width == vocab.BYTE_LENGTH_REMAINING: + # Width known only at decode time. Counted as 0 so it does not move the + # cursor by a nonsense amount: a field that eats the rest of the payload + # has nothing after it to place. + return 0 + return width def _build_form( @@ -767,7 +864,14 @@ def _build_form( if "var" in field: form[vocab.ALIAS] = field["var"] if "length" in field: - form[vocab.BYTE_LENGTH] = int(field["length"]) + form[vocab.BYTE_LENGTH] = _byte_length(field["length"]) + if "transform" in field: + # Post-processing applied to a value that *was* read from the wire. It + # lives under lorav:derived because that object names the operation, but + # the field keeps its real wire type: it reads bytes and then adjusts + # them. The stages run in order, after mult/div/add, so the list is + # carried as written. + form[vocab.DERIVED] = {"transform": copy.deepcopy(field["transform"])} return form @@ -831,6 +935,12 @@ def _data_schema(wot_type: str, field: dict[str, Any]) -> dict[str, Any]: if label not in labels: labels.append(label) data["enum"] = labels + if "value" in field: + # A byte the device always sends with this value -- a command or category + # identifier on a downlink. TD core's ``const`` says exactly that, so no + # binding term is needed: it constrains what the value may be, which is a + # fact about the data rather than about how it is transferred. + data["const"] = field["value"] return data @@ -847,6 +957,36 @@ def _wot_type(base: str, field: dict[str, Any]) -> str: return "integer" +def _is_int_key(key: Any) -> bool: + """Whether a lookup table key names an integer value.""" + try: + int(key) + except (TypeError, ValueError): + return False + return True + + +def _byte_length(raw: Any) -> int: + """Turn a source ``length`` into a :data:`vocab.BYTE_LENGTH` byte count. + + ``length: remaining`` consumes everything from the read position to the end + of the payload (PS-014). It becomes ``-1``, the sentinel the form vocabulary + already defines for it and the one the reference interpreter maps the keyword + onto, so the field converts instead of taking its device out of the catalog. + + A ``$variable`` length is rejected: no implementation of the specification + supports it, so there is nothing to round trip to. + """ + if isinstance(raw, str): + if raw.strip().lower() == "remaining": + return vocab.BYTE_LENGTH_REMAINING + if not _is_int_key(raw): + raise UnsupportedSchemaError( + f"unsupported length {raw!r}", reason=SkipReason.LENGTH_NOT_FIXED + ) + return int(raw) + + def _enum(field: dict[str, Any]) -> dict[int, Any] | None: """Canonicalise a ``values``/``lookup`` table to a ``{int: label}`` mapping.""" raw = field.get("values", field.get("lookup")) @@ -855,6 +995,21 @@ def _enum(field: dict[str, Any]) -> dict[int, Any] | None: if isinstance(raw, list): return dict(enumerate(raw)) if isinstance(raw, dict): + # A table may carry a ``default`` label covering every value it does not + # list (PS-269). ``lorav:valueMap`` pairs one wire value with one decoded + # value and has no way to say "anything else", so the field cannot round + # trip: dropping the key would decode an unmapped value as absent where + # the schema names it. Skipped rather than approximated. + # + # Checked before converting, because the bare ``int(key)`` below raised an + # uncaught ValueError that aborted the whole catalog run instead of + # skipping the one device -- a crash, not a skip, and only visible once a + # schema in the library actually used ``default``. + if any(not _is_int_key(key) for key in raw): + raise UnsupportedSchemaError( + f"enum table has a non-integer key: {sorted(map(str, raw))}", + reason=SkipReason.ENUM_TABLE, + ) return {int(key): label for key, label in raw.items()} raise UnsupportedSchemaError(f"unsupported enum table {raw!r}", reason=SkipReason.ENUM_TABLE) diff --git a/python/lorawan/src/lorawan_wot/vocab.py b/python/lorawan/src/lorawan_wot/vocab.py index 6374e97..342dfd8 100644 --- a/python/lorawan/src/lorawan_wot/vocab.py +++ b/python/lorawan/src/lorawan_wot/vocab.py @@ -114,6 +114,11 @@ BITMASK: Final = "lorav:bitmask" # hex mask, e.g. "0x3FFF" BYTE_OFFSET: Final = "lorav:byteOffset" # fixed-layout position (bytes) BYTE_LENGTH: Final = "lorav:byteLength" # byte length for string/bytes/hex +#: :data:`BYTE_LENGTH` value meaning "consume the rest of the payload". The +#: reference interpreter accepts either the keyword ``remaining`` or any negative +#: integer; the negative form is the sentinel its parsers share, so it is the one +#: a numeric term can carry. +BYTE_LENGTH_REMAINING: Final = -1 TAG: Final = "lorav:tag" # tlv/ctv case selector, e.g. [3, 103] #: Scaling terms mapped to their MultiTech field key, in the order the reference diff --git a/python/lorawan/tests/golden/snapshot.json b/python/lorawan/tests/golden/snapshot.json index a8c3fb9..2192547 100644 --- a/python/lorawan/tests/golden/snapshot.json +++ b/python/lorawan/tests/golden/snapshot.json @@ -323,15 +323,15 @@ }, "dragino-lht65n-port2_ext1_basic_branch": { "batteryVoltage": 2.8850000000000002, - "temperature": 27.0, + "temperature": 27, "humidity": 73.3, "extensionCode": 1, "pollMessageStatus": 0, "retransmissionStatus": 0 }, "dragino-lht65n-port2_ext9_ds_branch": { - "batteryVoltage": 2.0, - "temperature": 27.0, + "batteryVoltage": 2, + "temperature": 27, "humidity": 73.3, "extensionCode": 9, "pollMessageStatus": 0, @@ -339,7 +339,7 @@ }, "dragino-lht65n-port2_ext15_no_sht": { "batteryVoltage": 2.8850000000000002, - "temperature": 27.0, + "temperature": 27, "humidity": 73.3, "extensionCode": 15, "pollMessageStatus": 0, @@ -354,7 +354,7 @@ }, "generic-lorawan11-negative_temperature": { "battery": 20, - "temperature": -5.0, + "temperature": -5, "_quality": { "battery": "good" } @@ -375,9 +375,9 @@ }, "mclimate-mc-button-triple_press_zero_temp": { "frameType": 1, - "batteryVoltage": 2.0, + "batteryVoltage": 2, "thermistorProperlyConnected": true, - "sensorTemperature": 0.0, + "sensorTemperature": 0, "pressEvent": 3 }, "mclimate-mc-button-triple_press_temp": { @@ -389,19 +389,19 @@ }, "milesight-em300-th-temperature_humidity": { "temperature": 24.8, - "humidity": 49.0 + "humidity": 49 }, "milesight-em300-th-battery_temperature_humidity": { "battery": 100, "temperature": 24.9, - "humidity": 49.0 + "humidity": 49 }, "milesight-em300-th-negative_temperature": { "temperature": -2.5 }, "milesight-em300-zld-nominal_reading": { "temperature": 32.6, - "humidity": 32.0, + "humidity": 32, "leakage_status": "normal" }, "milesight-em300-zld-nominal_reading_cooler": { @@ -414,88 +414,143 @@ "batteryVoltage": 3.6, "lowBattery": 0, "temperature": 25.38, - "humidity": 68.0 + "humidity": 68 }, "netvox-r718a-negative_temperature": { "reportType": 1, "batteryVoltage": 3.6, "lowBattery": 0, - "temperature": -5.0, - "humidity": 50.0 + "temperature": -5, + "humidity": 50 }, "netvox-r718a-low_battery_flag": { "reportType": 1, "batteryVoltage": 3.6, "lowBattery": 1, "temperature": 25.38, - "humidity": 68.0 + "humidity": 68 } }, "catalog": { - "scanned": 158, - "generated": 157, + "scanned": 218, + "generated": 167, "skipped": { - "mixed/unsupported field shape": 1 + "bit-range field type": 1, + "computed/derived field (ref/polynomial/transform)": 37, + "derived value reads a value the TD does not carry": 1, + "mixed/unsupported field shape": 2, + "unsupported enum table": 1, + "unsupported field keys (repeat/object/array)": 7, + "unsupported tlv tag key": 2 }, "schema_digests": { + "_language-conformance/compute-negative-idiv-mod.yaml": "b63f939832323ab15b1df75c52f80eda7fa0fbdd3c62f27d39da7f32c8c95a6e", + "_language-conformance/round-half-to-even.yaml": "8edba487b164c909d4b51cb2d4a61748245d2e876a47e7f53957ac757cb3adc6", + "_language-conformance/skip-type.yaml": "d13535f8a6ee36f799307c6cf511089322a318f1e925929d42a3fb0c6d5a7074", + "_language-conformance/transform-maths.yaml": "d36ad1e82ec3f1e3c1f1740c6bd429c5277651875d617f550cf67d8a545c34dd", + "_library-composed/alarm_config__set_delta_threshold.yaml": "15c1a5fa25dd5c2412961ef19f11c8f2426804b2e5790619011fb964c903cca7", + "_library-composed/alarm_config__set_temp_alarm.yaml": "da5e37c20eed1a03dae1d24972649b0b5dd6beb1c53b95814da9ddc1aed9bd4e", + "_library-composed/data_logging__clear_log.yaml": "c8794b476445a674616ea034a9640641d22f8a178c65ba5a0bee5447ddb0f7fc", + "_library-composed/data_logging__enable_logging.yaml": "0a61e4c835693376af8da1448efcfd975918e29dc5390b165a730a40c5a6941d", + "_library-composed/data_logging__fetch_all.yaml": "150b368ccb2249e5745de8db5efbbd09fd0023599274a2b12212a9b04ca0ab1e", + "_library-composed/device_management__factory_reset.yaml": "c1173c3b230188da0115317ba4419460da19e139c969397d562ceb78eac3d378", + "_library-composed/device_management__identify_10sec.yaml": "b1c711de0c47d22fc87553e3afaadb87c5c139340c71c1accf200ebbff3ff2f9", + "_library-composed/device_management__reboot_immediate.yaml": "9eaadcabc270965dd61dde602635d474a0b9d04966ab2742df487fc1c0b7f7ee", + "_library-composed/gps_tracker__clear_all_geofences.yaml": "628af42c330f4b38b8b9c91a94477aeee19f46cb46eddf6d39d5a292ac7237a9", + "_library-composed/gps_tracker__request_position.yaml": "55ef65795e3cb031e04c09cc03757f8f7187e53986b5e4b9e2c7518ca41db8da", + "_library-composed/gps_tracker__set_geofence.yaml": "bbcc1d36a72f34defc9fabf57e14fa03b1c1879e759f9b7e4f79f085e1545c07", + "_library-composed/lorawan_frames__join_request.yaml": "d962793fb4f9eb6d7a7daeede0b4eae43c3a969aab5eccf6067bc2acf4548f26", + "_library-composed/lorawan_mac_commands__dev_status_ans.yaml": "e7ab268bf1a9c6224cd041919bdeb49cbe5709e5f3a9afe5955324274ecee3b8", + "_library-composed/lorawan_mac_commands__device_time_ans.yaml": "6f394e237bc987b6d08ecd0f97d1e9abbd1c5286fc7347af90ae3afd55104d67", + "_library-composed/lorawan_mac_commands__link_adr_req_dr5_pwr2.yaml": "6cbf4ac0f675c52911bb6a43f8fbf6a4e881849b16abab3812382367d542ad9b", + "_library-composed/lorawan_mac_commands__link_check_ans.yaml": "c5f25e0c0f1c8a21982656a5df9869237a4f9862dbbb3c85a35bceb3b07d1164", + "_library-composed/lorawan_mac_commands__link_check_req.yaml": "1b10d28509cda174d681150717295b250b153aa74c76f0217846dd059f08e7d6", + "_library-composed/sensor_config__read_all_sensors.yaml": "80ae2a1bfca99e38bdcfca6e38ed433f04db6250e9e6bfa40608511a4163c7ab", + "_library-composed/sensor_config__set_interval_5min.yaml": "cbd5b23ded7ab10007f3c55af61790c646083e002c01fdc17331a150c03a9c90", + "_library-composed/sensor_config__set_temp_calibration.yaml": "2383e6837c8dd1b87a688645d8f8b94e2d556a194db52870523e1b904c1564a5", + "_library-composed/ts003_clock_sync__app_time_ans_negative_correction.yaml": "cbc61a3e2cefb9d354198889bf29c197210496e90440abddbd4d9e2179d3d496", + "_library-composed/ts003_clock_sync__app_time_ans_positive_correction.yaml": "fb76f6256384dcde9abb4e7a1ad9f5b2c71e82886f99036dba099aa194e6ab68", + "_library-composed/ts003_clock_sync__app_time_req_with_ans_required.yaml": "65d3b36bcdbda8954d4a36603103aa85d76bbbfec8bdc312e7aaaaeb5f1266f3", + "_library-composed/ts003_clock_sync__force_resync.yaml": "fb23bc9c63cfa532d8d9c6681272a6e6f937163fc160e9b81be172f208bb2959", + "_library-composed/ts003_clock_sync__package_version_req.yaml": "8f1272e4877c01dbfb35af2558702891884b8ff01bd7a8623f24fc24f5854307", + "_library-composed/ts003_clock_sync__set_periodicity_daily.yaml": "def7c5d61ade2ef18ae49d6ebeac9e520fa263600a6ace52d537d0c55126b966", + "_library-composed/ts004_fragmentation__frag_session_delete.yaml": "baadc29e2532859cde32889bb553e0d54e9a48b0266e782bbc9ee74fae07654d", + "_library-composed/ts004_fragmentation__frag_session_status_query.yaml": "eea5d9830ef33187ad4cb3f0b2cc183c0019a2bde4801424b22dc7ee1aa651b7", + "_library-composed/ts004_fragmentation__package_version_req.yaml": "e7ba65e3f85d4e2ffedc67b2cb67bb514ab0514882cdee3df114a3f16a0f6e88", + "_library-composed/ts005_multicast__mc_group_delete_0.yaml": "99446cd6eae3a7efe29361d63a9ef6b91de6dea065f2cd8d2024d9aaef49c887", + "_library-composed/ts005_multicast__mc_group_status_all.yaml": "a9af8721148580fb3512bb5041822083bb89f283d9c7d700997722ae2c7befb3", + "_library-composed/ts005_multicast__package_version_req.yaml": "9b6666de3be4fd110b60f973eb45975134522bf052190e6a038326603d88a5d6", + "_library-composed/ts006_firmware_mgmt__delete_any_image.yaml": "00bb27a832b82a2c9d2811d428cbac9a5c5e3350090844faadec76e3e2f5c110", + "_library-composed/ts006_firmware_mgmt__dev_version_req.yaml": "4e7a87d2b631820dc9d892fdcb87e19ecbf47bd6443d1bb925f97aa34f2e8697", + "_library-composed/ts006_firmware_mgmt__package_version_req.yaml": "607b002c4291809715cc1cd6a3f417bf70339afb01bb80bacd0c0fe9229b69a8", + "_library-composed/ts006_firmware_mgmt__reboot_cancel.yaml": "39e8c61f8446ffb91471c4552778cce45e40f7e57b38d646f189a89623080ecf", + "_library-composed/ts006_firmware_mgmt__reboot_countdown_5min.yaml": "32a77c4676f33a4c6e4d2af49251cb65c71bb246555cb780be74a63dd21ad37a", + "_library-composed/ts006_firmware_mgmt__reboot_immediate.yaml": "c7572367e66b7580d2d1b39edbb2ff6d290eca5a6e291a6c13b2f7b459912de8", + "_library-composed/ts006_firmware_mgmt__upgrade_image_query.yaml": "9a52d3dddde4a44b67bc1c65987c3cf39a8cdb6b082642cb63fee33cda864e20", + "_library-composed/ts007_multi_package__package_version_req.yaml": "aa35304cff903f07dd23e60f8fc407edb3bd920ee97a3818f990b4733c83f702", + "_library-composed/udp_packet_forwarder__push_ack.yaml": "86cd7d4306964d2d822aaf1b437cf331b1a32be71ccb30e75719c4d596787e66", + "_library-composed/udp_packet_forwarder__push_data_header.yaml": "cd2f3ad803cdc1aa7c1f0bba43eb4b82dd3fe89fa803abd62bd337f0bba948e1", + "_library-composed/utility_meter__reset_all_counters.yaml": "baa7be4e15321e2cbf23bde2b3e7ce4ece68a65e6b0bc1a85bd961e96b10b31e", + "_library-composed/utility_meter__set_ct_100_1.yaml": "bcdf5cbbebe0ad179d65e87082e79ec6d753686612a959c8d6fc979b39b6099c", + "_library-composed/utility_meter__set_tariff.yaml": "e9a7b2fbfb99c71c8e9d04ea7b0315e9042122fd116bd491f4b4df9f3fc750e4", "decentlab/dl-5tm.yaml": "bb7fa33be9e1635bdb27bc42fc9829bd6af2b61cd11ad39de19d64a8b68f223f", - "decentlab/dl-alb.yaml": "0dc2533aa7fa7d5a9d2b7d0ddb8152d4e47a54ccbc5c1a54465ef394786c3762", - "decentlab/dl-atm22.yaml": "619c7f2d1782751d7d72c2b0bbc37d2d6f817c2a83ce5282f945e5e987b2338f", - "decentlab/dl-atm41.yaml": "9f2ec8e96b3b81462a07006cc70a0c8d39538566bd5fd1a162c3124257b221eb", - "decentlab/dl-atm41g2.yaml": "239f76deda668b8ed83a59bd4c8c80b45e4dcfb48a9d054c67a256689e362b7f", - "decentlab/dl-blg.yaml": "54b3a6d0e6813dd4caa5eb611de8ba29d1b650cac46d8e99d7f1bcf2ea81fb43", - "decentlab/dl-ctd10.yaml": "51665c7ac742adfb5621050f34d12c8706d1403e10b822a7b2bb849678c63a5e", - "decentlab/dl-cws.yaml": "bd5ddf274bef90db495a761009d7d317103dfbc7ea654c66be46227d46b1ca2c", - "decentlab/dl-cws2.yaml": "fd9283b212ba42ec5c18a810811c791e49659f21790fc72ef8305063088683fb", - "decentlab/dl-dlr2-002.yaml": "f2956013d238341fe158f6f11c8ae753371cac5f3e3b244a50da11b68556b45c", + "decentlab/dl-alb.yaml": "cec42cd6243bc052e16ca3c6c9c8610ba7e788ab02d11227b16debfa031735f7", + "decentlab/dl-atm22.yaml": "7febc223719d63d130db60e1aa97a11dac99d054af0d71a012dcbb8a49e0802f", + "decentlab/dl-atm41.yaml": "9c7ea6f45cf596b17e5f482ab1119be80d8a9e6b5747326980f400375bfdab4f", + "decentlab/dl-atm41g2.yaml": "2d9cfc622c17d5a6f92cc1738496582daa52d626f81b688c98f8ce9fa8ecd14e", + "decentlab/dl-blg.yaml": "df8728b7da11e3efd9e5aeedc82958e3442ff9f4b19088552131edd1800d1626", + "decentlab/dl-ctd10.yaml": "f122806cf35c034e500f173fcc107f8910371173e1ccd11dae89f5672ddc9eae", + "decentlab/dl-cws.yaml": "3cff45e22499eca9740ae65e476a33c6acb760b63f57ae2b386b248d51037a77", + "decentlab/dl-cws2.yaml": "9b3dba58df4dc74ae25e1f6c97b69c34c70b189d93631dbae83ddacb2aac53a6", + "decentlab/dl-dlr2-002.yaml": "27ab7fabf12fbca96007e69f443db645b106d0cc920d86307501013daa1416b4", "decentlab/dl-dlr2-003.yaml": "1a3bf5805775438fbc6d85a86df8151af2fddab5146d7be1f0ea09d78109d5a8", - "decentlab/dl-dlr2-004-10.yaml": "3c3817d8b672cc555153b837966a7bf29f1f88f50cbbaa0a52e08e74683af54b", - "decentlab/dl-dlr2-005.yaml": "5815d8dd28d2eac1400a6d5bc3603379804d3369c738907f590e711d615595ea", - "decentlab/dl-dlr2-006.yaml": "9f9a8d44bb4a112bab93eff0d47f747be652e675a88c55a8a7c680a8486bf509", - "decentlab/dl-dlr2-008-2000.yaml": "1949010b735161192aba9f58e0e64cfd0407fda6bc5480ec8a3562b6fe41fdb0", - "decentlab/dl-dlr2-009-2000.yaml": "b41b5eda7c28c3b2aec96cb77f1a03e8d132855a07b69db57b2c0868fe442d40", - "decentlab/dl-dlr2-010.yaml": "c02c13a75d259988121d9ac8afa760f4f376f7519b52d3f3e26995511829565c", + "decentlab/dl-dlr2-004-10.yaml": "9262427ae7230ce81c727e56493c5b67e47de28859beaf2f40e16b3f4e2aa104", + "decentlab/dl-dlr2-005.yaml": "f4024f227e60edd06e357b3113fc998c975a840890682e2f7a589e69f9ac1d15", + "decentlab/dl-dlr2-006.yaml": "293d03dc4987ab2f2bc8d51d7c45e63c047f434add9f16abc2f0164235974714", + "decentlab/dl-dlr2-008-2000.yaml": "6d1566499e674c4c86dd45a00982f12130d817123d2bbfd8033deb29ea145b33", + "decentlab/dl-dlr2-009-2000.yaml": "5a9bb6ac499f56370966ffca6b6edf1c48a2d05abb4ea2df39e1d6b9b1b7b0ec", + "decentlab/dl-dlr2-010.yaml": "1edb43bffbcad36cd7d4c914335528ccaa9dfa82c73a56cc2643ed55c22a060f", "decentlab/dl-dlr2-011.yaml": "8c9ff559718fc890e51840b086ba2fae76cc0583ac3bcd30dd28699ae71fd254", - "decentlab/dl-dlr2-012.yaml": "db4604cfa0f5679277cb4d32ac4c4ef78d0729666fee7beafb8ba6e31b8d61ee", - "decentlab/dl-ds18.yaml": "4dd5bc914019447f3a71d1d2662aa307ee31adbc78f1182b1c0c13cb0d6933d6", - "decentlab/dl-dws-232263168-0000302459-1370.yaml": "7f444c6197544c2687465f519a4f62f723aac629947483c9ef793b7562b7703c", - "decentlab/dl-gmm.yaml": "0705765c3bfd8163001b34fc8bf8a9784bd600b9de776e5aab0adeda311f2ad3", - "decentlab/dl-iam.yaml": "76220f337f5a354276d380a382d2f2ca8edbb2e834e82f26d8aa141b67a05ef2", + "decentlab/dl-dlr2-012.yaml": "6976e7e3b3e071dd9fb34d9cf6ca5a535ce03c4745f0abf74c4a306f87add0a6", + "decentlab/dl-ds18.yaml": "4a68885157c9ec9ffdf9f2c3e0f40f6fdc5efb90952d7414405c448b72e8dc09", + "decentlab/dl-dws-232263168-0000302459-1370.yaml": "94f84fbda52604e103b24ce54f5d0f7dddc3133dd7bdd3450acaf648b6305111", + "decentlab/dl-gmm.yaml": "977171aa4d33ec30f08e44ce3b21815d335ab5ab511b20cb8262f30eb5453535", + "decentlab/dl-iam.yaml": "57803ab990ee399c2c1465ce26fc0e5f52eb080028407d2b905d91f2fb530676", "decentlab/dl-ifd.yaml": "32a368de92ee7d7389a2d5c2827ad236697590f9469413fba2c69f1abdd5e57a", - "decentlab/dl-ilt.yaml": "ad025a11982101a9902c113ad528c87ca234216baa65dbbb035d696ddfce954a", + "decentlab/dl-ilt.yaml": "f5408c1f797ab5b4882414430e977768a69f33b72233b34105a9503c20432d97", "decentlab/dl-isd.yaml": "52d37f547a4f5a03e960676bf8e120b06b9ed3b41048111b82e4b4cd4bdd1f9c", - "decentlab/dl-isf.yaml": "7c89bcdc02af0be0cc8f6a3f49f5a403b4123aaf02879ff85f7cff596d39f0b6", - "decentlab/dl-itst.yaml": "18e94b7b87d7dcbae03ceb3751f5742889abfe8f6c3acd18377dc84048c91c5a", - "decentlab/dl-kl66-1538372-464859.yaml": "e2f92787ce3d45ccf98672c15756cb7c85c9b0e0bc9171c1b597b5f1120da9c3", + "decentlab/dl-isf.yaml": "e4dd5c38d3c8f647d312d9a09a101ab0d1defe730e5c613c1f12fc9710947f0c", + "decentlab/dl-itst.yaml": "3bf39a1076ee2b0c4c42679fc4d3c68c827e28e70070b1ba0e50c258da0235d1", + "decentlab/dl-kl66-1538372-464859.yaml": "d0f865b07ffc27a4f93721e1c7d6151799f2a15259357d2a79d0ba546ea6615e", "decentlab/dl-lid.yaml": "903b078160842ae83e379ece410daf7135d1c1e7bd63ed9ee1b3cb6555838e11", - "decentlab/dl-lp8p.yaml": "d2311978ea54874f9cac24f9d707ebe5f4c55009098d9e929323c05bf0327a31", - "decentlab/dl-lpw.yaml": "2141cc985a9529e21fc502b84738ff2a719c06c4d2cd303c5832057f57fb2786", - "decentlab/dl-lws.yaml": "b832af605bcce15c1dbe4ca047bf8a9a6dc0740b31dae7e4cf5b89f096a8a0db", + "decentlab/dl-lp8p.yaml": "dcacc038419959053562a347eaecb8b8cd51edf0265bbcb9940b83a2a301b602", + "decentlab/dl-lpw.yaml": "cf44b4c8317c14855e3b2b65dfe6c1e1da4431696940995dae8bc6b076a714a7", + "decentlab/dl-lws.yaml": "b3b4eb6fb146146ac24c9c20f53f080c4e58f89edb24f811b8a9271bcad250b9", "decentlab/dl-mbx.yaml": "e5300980d283ff5c5eae97fe2cee616094555d9e09620dbbca961cdff5d96d34", - "decentlab/dl-mes5.yaml": "070a29cde5685700f3492304aadc2350bb2546f2e81380b9ddf4a12b610a87e5", - "decentlab/dl-ntu.yaml": "f63b159a13feb9b0edeade9a578c7071ec210442beceac0e633b90bc2e2a5b64", - "decentlab/dl-optod.yaml": "124ac80c411c2ee40f652ddfafb6cb453265e54de826b22d7a6822552a952dbe", - "decentlab/dl-par.yaml": "d25bcd0a0f2b6b1cd3a688d1d4dd4e429fd7b323169fa69e893653f096c2aadf", - "decentlab/dl-pheht.yaml": "599d5b599fa5c281381c6011f495c95e4ef634cf37dc00bb28c18d78c79452e2", - "decentlab/dl-pm.yaml": "dbc7c40f9472f99303a241c0c17477f60d3e0d1ee4f842e8372e2df21a591e6a", - "decentlab/dl-pr21-1-10.yaml": "c05d8202d87b54757f3fde5b6283e7a7f07139eda7aa80ebe859abe190370e6f", - "decentlab/dl-pr26-0-1.yaml": "d7853cac671e03be72015f398992f4a439a657eda4d4db3de97966af4f7475eb", - "decentlab/dl-pr36-8192.yaml": "366b3054d2a7f9160693d8ba5ee9cbd5df6a8640cba3fbf0fac558355dbf4542", - "decentlab/dl-pr36ctd-8192-1024.yaml": "458389d6fe18b02f92c4b84e0f5894ec44d6bd13103525cbdb84179132645198", - "decentlab/dl-pyr.yaml": "130dce3da40904e7b07747b0cfb5836c946950ad4d70b2ec3ada06713b5e4743", - "decentlab/dl-rad.yaml": "45404f39c88cbd3606d5da45cf0ff433fcc274b2c016c004aebf2dd3c1aaccb3", - "decentlab/dl-rhc.yaml": "ea842aa274ab8a4253f721dc987e022542b197c5eda60f7eb61aa64c667f1a08", - "decentlab/dl-sdd.yaml": "740ef7d3d6cbea87a324bc655ca99b165c7bc680c455461f4700802922614c19", - "decentlab/dl-sht35.yaml": "af3483ab5753364231c8712da9d17ccfd30180fa46c9a65e3f2ae51d7eb30218", - "decentlab/dl-smtp.yaml": "55e4155ff7a465823632ed1f00188fc81110443b60faff73d6500a48a87ad8d9", - "decentlab/dl-tbrg-01.yaml": "2f402568fb0f1784a645068cf41fb3fcfa467dbd7e0b315f97b7de647ba95b76", - "decentlab/dl-tp.yaml": "225f192bb99f3416e15a0b4bce09ec804bddccf0743323bb14eb89b4144eb89d", - "decentlab/dl-trs11.yaml": "177c5cbab7bab66c8f6e5b11847b7cbb7b1133f07fa9b19a209f05f2a27f5eb5", - "decentlab/dl-trs12.yaml": "a2200c8f31c9c366cf5b1ad728d1ccad447c74e0f22ac9b56b2fb06eecc59400", - "decentlab/dl-trs21.yaml": "8817f3369849e06cf57585b39073fddcc342d011d29f31832c23a581e380bcdb", - "decentlab/dl-wrm.yaml": "e0a2bd07fc30113296d618e7337574b584e561eb9b548cb1c83cfe085533f935", - "decentlab/dl-zn1.yaml": "607a3cd8350ef24825bf91c58df353e359cdf773caeaf2d4d0307f15e67f4bb0", - "decentlab/dl-zn2.yaml": "ce00162a0fc73905aee7cec1fd2edbf866133e955ec36e049e37b9b6d3c71fe2", + "decentlab/dl-mes5.yaml": "7f3282dfad7b223c5cb443969a6bb938fee2afdc6938980a58dcc56b1d9c43f4", + "decentlab/dl-ntu.yaml": "eb040a2efdec94a5f2236e2a57bda275191acd6ffc2b2278aef7cd7fa98d807e", + "decentlab/dl-optod.yaml": "02542090319e53ca99b0cb1c2f8462d13336e8de6cd987f24a621457b20b2cb9", + "decentlab/dl-par.yaml": "49f2cbdd23a14a048ff23248317268c98d337c7dfadc1eaa6e06bd1d7e4b3adc", + "decentlab/dl-pheht.yaml": "10b8a97ca0c9944840b12ace22e3725480342164edcd232acf06949ad672497c", + "decentlab/dl-pm.yaml": "57910405e011420fe5dace5cd201091e75754f986afd4da3351138dcd13f7d24", + "decentlab/dl-pr21-1-10.yaml": "4d99f7bece91224fd8d4fd82c96ce4638f73460cb667dcac157bc3c652d9bb04", + "decentlab/dl-pr26-0-1.yaml": "d8cc57c49bfc0709052b7e65001ce4de60b7f8e17d761b4ed16a2be28bc7ef19", + "decentlab/dl-pr36-8192.yaml": "bd43ac5eaa2f53188aa9d0043c63daa6bc67573442f5e5857773ab599afc3065", + "decentlab/dl-pr36ctd-8192-1024.yaml": "b2e66934e29d8cd055d77f0e8516c1684ad2147225fd696b0e81422748804579", + "decentlab/dl-pyr.yaml": "82fe73828b0af4f7525490c9eeba07c6a92dbcbc87dbd470b5072515b1ea5130", + "decentlab/dl-rad.yaml": "10c09367b9f3ed29bffb879c0c6379b567cf6931fb0b77291a9d3b9b58ad213d", + "decentlab/dl-rhc.yaml": "cc43a4bb0f3b787e567723fca3571d7c2cf41b3a91e30374e8104aa6956a79b5", + "decentlab/dl-sdd.yaml": "b05c26df42e2ac683b3d0feff322cf673c5019ee31be5691c48cc307a1e20650", + "decentlab/dl-sht35.yaml": "743b8972ac1839767aa3b99677e4959e067ef2d5068458ed1c9da13c85530201", + "decentlab/dl-smtp.yaml": "e201c78c0a3d190d9309d0e1b659290f1db1e0ce601f5f4e782cb94137973161", + "decentlab/dl-tbrg-01.yaml": "98b461522846b2ccb4e3a0ba0c3b370fd1dabcbbd6c30d3c49915cc07d52c1b2", + "decentlab/dl-tp.yaml": "5b82de4387b5cb0501e5d9a920b5d81aa7d8b17eeff6ab5299eede52f03d08a5", + "decentlab/dl-trs11.yaml": "bd933f3fcaa8a23f99d7a741069a97abe37b024e45b0c252adcc794b845a8a0b", + "decentlab/dl-trs12.yaml": "9fa35d67f041992f2f94405bdb8d88e8370beb7e825ade46fc99e22778c0d853", + "decentlab/dl-trs21.yaml": "3991b1d6c5b570c842a1522177a34204f560dcad2d0e4fecf7e23c2ab39ef7da", + "decentlab/dl-wrm.yaml": "484dc1dfa0c43f0e0970458bbe06e1e5f62a409f7725f3c15c355546e30e55b4", + "decentlab/dl-zn1.yaml": "1be039f08241cc8e621cadecc8ad29710ddaf3d84d4e6356d2aaa2421eacfb68", + "decentlab/dl-zn2.yaml": "41f5891f84d60ec0f55243870a0414350e279b20e332b49959111f2f4e615236", "digital-matter/oyster.yaml": "42e4e20b9efdde63eaeccca42e151b0f6f6138479e2b0b50c9f6fb964de3d8e4", "dragino/laq4.yaml": "e913170946f74e08e01442a235607fbb154511b256b09721ab40403efb3c70fc", "elsys/ers.yaml": "ac3d1412cb131e3c4c115f6d0312f80b68f9ef9f982cf9b370e28ef9fa413125", @@ -507,94 +562,55 @@ "makerfabs/soil-monitor.yaml": "eefb4e81f17b92623533158815f492baeba599c501f44e327c1a43762481a909", "mclimate/flood-sensor.yaml": "c452a14afaaf1ea1f1ea5af18f224a6d3758539d35783bb3031cec131d0b2985", "mclimate/t-valve.yaml": "5988378ad1e789db307958a0360c45744f9478de42e63e36827c496cb0ad5dcd", - "mclimate/vicki.yaml": "764775357ead93b3273475489c3ea1ebc0bb6a4d5245c8b0b91ef9ead1eda03b", - "milesight/am102.yaml": "96a6f6dd5dee3bdbb41c02094b586facfb323eb8aae65e31fdd3c5f81f48f53c", - "milesight/am102l.yaml": "d6fa7ad241e26bf8e266432414deeef75979a8a08bc19d186bd9847b0a54e933", - "milesight/am103.yaml": "0a21706dcb0cf3b88e1eaddea2e3fb93eda9af643966ec04c29bf85c144cc4e4", - "milesight/am103l.yaml": "39cf7bd3fa2c18a96099cc11809e2245d35152ed9a5f96747ff1a26dae9bfd2c", - "milesight/am104.yaml": "ed3310bfd06ef5970d318ca203052c23d4ff27c763044966b8c62c5c37f72fc3", - "milesight/am107.yaml": "7ca67d22c5b139fecd5d7636c2aed69e35245ff2e6712f81d143c8899fe41211", - "milesight/am307.yaml": "7208eba5a423cf480196717f357fd2f93cec95c6c3ad90f6fa19aa9503e6d26e", - "milesight/am307l.yaml": "4efbc8d376258e6cbbe638f09bdc9f9b37df73485b4cc8590996ec4fcbdd5b27", - "milesight/am308.yaml": "6cfb021d06f4bd1edd25ec2d1d9b1f77b2a010fd1c51e8683895a9c672d75469", - "milesight/am308l.yaml": "db3ac723173f7b1265cca3762077b80d53ec64ba91c6d11e6fa00ce0f8750fd4", - "milesight/am319.yaml": "3218fb640b5f25d80985ab171d48da56a7abc8a07c14740250f43a5aa4c1e621", - "milesight/am319l.yaml": "5459545834a3d99d6ac4251fc76b9b11769329305b1bcfa07ba2f3b6453b9f4a", - "milesight/at101-fh.yaml": "3f18fa656ab20df07561652659f7c3ed9ea77aafbb79d0b3d540e11c1fc5fc90", - "milesight/at101.yaml": "e35d74fd255731ca3e8301017923cf8ef251746b721532cc89adab27f879e40c", - "milesight/ct101.yaml": "ac8645da58437b83e5b56b2f024bab02999ffc1656e30724f8849921a106ba62", - "milesight/ct103.yaml": "723495ec909ba2a6b9902f388986255999c910f33e60169c119aa84365f2aa3a", - "milesight/ct105.yaml": "b4b11642caa5e107c31e5fa74de7f9ecc9f80c2ece849d98458c51240aa8d147", - "milesight/ct303.yaml": "b3ab3211985da6d3bd9604c784bd34b8245f47bcee77ff0a55d9650229f83129", - "milesight/ct305.yaml": "6d7bf289da74e148e893df4cc90b68e9c6636278839e37316ee3ee0a864f5ccc", - "milesight/ct310.yaml": "74ecf4b745f20535738fbf6f4171b570e93426be701099b096c0e4315b80b02c", - "milesight/em300-di.yaml": "c72400ead8f3a9c6b2b8093681829a82946fe6d6521929fa209d1b819e1a08f7", - "milesight/em300-mcs.yaml": "9ccfde9a9512efcfc531f3f5d2a2bffe3259d47dd086b61908660a6c96e10a13", - "milesight/em300-mld.yaml": "c81dc2a8011263234d77c9b45f7af35f1189654cb9ff3bc67f7758285de576ce", - "milesight/em300-sld.yaml": "44b40db9cc932b416bdacf67ea06b38297cf807d6ae2d6a9a4441d1735490749", - "milesight/em300-th.yaml": "597d4b60492a46f66146ab500a7b1634696fbe40c515ff71c157172fe21eccbf", - "milesight/em300-zld.yaml": "399f87dcde33d4d777a7b17f2923aa5eee701d2d45c7175964129e5de0ceb261", - "milesight/em310-tilt.yaml": "77f1fbef463b97b079668a132b05a9d97891f1d0eca9df62c156d8734858104e", - "milesight/em310-udl.yaml": "2aef72288d3caed2b7b777ee9b2f9881f26191d75856f8b71b389a3c29bf0a1a", - "milesight/em320-th.yaml": "8c0a9ab033ee2c804bc9ffe2f3dde674a59fb249453ef1e93b798e8257ef8276", - "milesight/em320-tilt.yaml": "da3e9ce97b6ce0476d72afa0af44f0589192c130559f0f623c0bba614acc9c16", - "milesight/em400-mud.yaml": "87aff039edc56c58d58cede1b9e385da5f516d318b5c88eb40ea642eecd240dd", - "milesight/em400-tld.yaml": "e08103800ec1f6f45f37044d0c2203e0cdef7369f1506be17756a3cd05fe8237", - "milesight/em400-udl.yaml": "c8cf1dca5bf220d48d1a25db2c0920815285572d0a165d3a6e3c3ae441f6cc13", - "milesight/em410-rdl.yaml": "342056ecbe5e5c7d2e290c69f1256163bd025413c813ae057a6b60ecd2fb2a6a", - "milesight/em500-co2.yaml": "0617d2290df9904e5ad70f17cd26ebf7d0673e1747bc9ca686a6bc4cba9e682e", - "milesight/em500-lgt.yaml": "3466b825d581c90bd5b929ec0a7802775629226b9d42b2c2d8172ff15ad1687c", - "milesight/em500-pp.yaml": "1faa6b722c52f986579ffc9b855696dd6428ff1cb9fe02b7ac1fcf6af4f27011", - "milesight/em500-pt100.yaml": "2d847cf439b73de96fc3a76caee83ea852bd4a19d24ef55d8f1d5e2d44f94777", - "milesight/em500-smt.yaml": "c1d8296d1a0cae0f9186d95016e30b56a08ac5956d687c7f8368ad9031b7b921", - "milesight/em500-smtc.yaml": "58e91e15d911e336237447e25a3bb01146e563c5b56956b512a55201901f07c2", - "milesight/em500-swl.yaml": "d7381efa280c083341a933bbef6c6a81a1d5861649c2ac189a7aa50ecfb0408f", - "milesight/em500-udl.yaml": "8fce773c4845329ece63cc519c4580dd73c1107659a5abefaf9b83fde37efbf7", - "milesight/gs101.yaml": "ff249294b3aec592d81577233879e870479c17dffbe955f6d5e2c520a200b7ce", - "milesight/gs301.yaml": "6a5af1f7f38195a9f06db4d229fdc84857f8e103ad5242480d574ea09e3b7df5", - "milesight/ts101.yaml": "6a5098d44dc608a4add6adccdf6c9b805979e84f2a82be3af668c0b778b31efc", - "milesight/ts201.yaml": "e27ecfb6a80821a9b8c56975e10c4c012f2999d1d728f1db9d77e1d16df204ad", - "milesight/ts201v2.yaml": "d46e09a7a1e5123323adff469814aba8c15b1756f24b7946ced0ea3238795941", - "milesight/ts30x.yaml": "62c3daf62b45fe899923ef3273a2bd16067e15a2b30f7f564c4eacff804019f5", - "milesight/uc100.yaml": "1c2973eaaef5e48e3df532a1857427e9338bd70fc5bb2710b699b9788927d0bb", - "milesight/uc1114.yaml": "ad41205159cf48112978c86214c3625a68a278821fdb47d9e2add44394023d9e", - "milesight/uc1152.yaml": "24163b5012b407245187bbcc60276700822e2016a3c345782b795cdf87052be5", - "milesight/uc300.yaml": "939d7179b1c07a328bf9d161dff3f44f0c358a45482d5cd07247a660064212b6", - "milesight/uc50x.yaml": "bcfb4228fa684814070f4c5ab4b497aa83c59f106cc65698a6d69e3636029658", - "milesight/uc51x.yaml": "88e10bad2b6ac15f40cfe40f22421c1fcdf051b2fa9793ea86537652c4b7b116", + "mclimate/vicki.yaml": "ef3be726f8bdfbf46012def62fddabb9de3d7cf9f830691dcacaa7764bba5d05", + "milesight/am103.yaml": "187f5a2999a48f3077758df6065f73359877893d6e678edf5cf4f22992e28d65", + "milesight/am103l.yaml": "a6a6ab9c070da6be8d357c78ac8d2d60ac7800a55c9218e59abecbbfb06804a8", + "milesight/am104.yaml": "1277218945fe68dff2a68432e60e9ddabee45b3cc675b0f7f3ac5c0d341e40a8", + "milesight/am107.yaml": "978e07175a292d45b978989247939270b9c65e83d2ac6aae24b8cd8598c1e128", + "milesight/am307.yaml": "98da330624b0c1668499120787ff5f7359b063ecb238e156b053966824cbf0c9", + "milesight/am307l.yaml": "4469440abcea818cec8b07b4f3ae7871979c9c8e0cad779e071b9e008bff2fe6", + "milesight/am308.yaml": "388409c5128dea5b8a1373acc240688004bdddb0fa4818b18a9eb258977240f6", + "milesight/am308l.yaml": "2eaf7ae78355b8b4bee96e6da4fd6a9536fa98b2a0fb684f1147af6e5b559105", + "milesight/em300-di.yaml": "aaa8e68083065424477d1b00578d14821d811d12d3141eafc75208d3afb1ab71", + "milesight/em300-mld.yaml": "00b7ba529bfeeba6093c220f844cfdfd20b2f05897c908393f292cddb21e4399", + "milesight/em300-sld.yaml": "51d23bfa482487a609db02291bb460fe11c44c5e92337acb77b56fe9a1300f6d", + "milesight/em300-th.yaml": "02033aa4d84c5b67a37b57bbd1c69bd6a719be44393d5f253a47b648b124a439", + "milesight/em300-zld.yaml": "077b4a704fb1b88ead9cc86984f13a10bfaef0ac151b3e934c9ae8967e20e4ac", + "milesight/em320-th.yaml": "3d42c313db0204b369e158cfd6ae71e81eed49940dc3db000574d339f3ac487b", + "milesight/em320-tilt.yaml": "4de345ebdd205913160b40f780e053c7870505cc877bbba9660ed8126788d310", + "milesight/em400-mud.yaml": "c504dd10fb8f555ef0bde1f0e5338c7ee193907feb08e96156c55d63b2c2b50b", + "milesight/em400-tld.yaml": "4b1353d7f1b2263a182e88152d058f09ec3c731f67fc5ca76b65f33e1ece5074", + "milesight/em400-udl.yaml": "f45c0349be4ecbcc77b38087614b06343cefcb73479b948c5dbcc811948488c3", + "milesight/em500-co2.yaml": "d3cbdd3a2eb692cfb36b0ec21b0dc99096e43fdce67e67e723547f2976a9d726", + "milesight/em500-lgt.yaml": "f85f14fbfeb50c08f1ac3817577fc3884eb422585e33f10710565202d895cb01", + "milesight/em500-pp.yaml": "d4ba0415a448956db188375349b8bb0e748e617596cc96087354c43b77c47d86", + "milesight/em500-pt100.yaml": "6112d540d01b01e74968a9ee3465c8d357b7e2f0ecaea80eacd3ddf423f402fd", + "milesight/em500-smt.yaml": "9ecf982ec3a39c52c02ff02bdfac008cb2bdd26228c2156824474dae602a17c9", + "milesight/em500-smtc.yaml": "3f7de516e045872dcc2b6f6959a2691a56654a235cd523ea01ceb9dfe90d942c", + "milesight/em500-swl.yaml": "9ad650a75458938d0ff4dfef242bd78371910b8e5c7b1ab21d3d39d2f7ee6d7b", + "milesight/em500-udl.yaml": "9677150240d177caebd049ff7d3e3e60ffb67ad70310975e15d249c2321407fb", + "milesight/gs101.yaml": "0160a9c7c508c373789c76a806922b84727c15786ed64715e0bf0925d47b4043", + "milesight/ts101.yaml": "ba0449b42a9bd4db3f530c64b1fee5c895a36cc5c8146889ab9729cf6f8a27ec", + "milesight/uc300.yaml": "e2f251cde7656498145d6b5f04800b34836278a31c14757899c6b484cc51d3e4", "milesight/vs121.yaml": "3e30da96bc9396b61f23a3dceddfb9afd10613791b97347391873cddc15ff8e5", - "milesight/vs135.yaml": "28ba66d01e7c12d7dd373a90bf9517000b3cbe09e8e31fb9d2f9b516e6ba02a5", - "milesight/vs321.yaml": "827057a757da1b198dd93b2e5415e37ac4159e4674d32e2d8afc66f70db41fcd", - "milesight/vs330.yaml": "9913be4228daf3087efee8295c39071a94e881dfef847c7a2764d7736eafb334", - "milesight/vs340.yaml": "894a5bcbd212e48861c98cb6a9fd7c514d1b70670ffdc232a8a425fee2f55c06", - "milesight/vs341.yaml": "ba2331d95022c4cb04ea49dfb9bc908ca7807898609b7849bedd928ce275cda4", - "milesight/vs350.yaml": "9147278272db47a1d6f147c0a5681d7e23c7ed8043bf3ce0369a661eeb34ba6b", - "milesight/vs351.yaml": "1c6aafa15fe0a363ea508179681c505e13f16855f366cdb718d31e836d554643", - "milesight/vs360.yaml": "2044fa3f84b668ba9df24c245142b2653863d2e04ca48178886970e87376d1f3", - "milesight/vs370.yaml": "e4df709fa1cce14ec99482c271cda81a0726cb4a7dba420794e189adc4dd0859", - "milesight/vs373.yaml": "83682858152522541256f0719e6b02fccdd42181fd6ee9ba2f5ea36cdce63141", - "milesight/ws101.yaml": "bf623d299569f6823f40056627fecc5a98cb724742313728855a4c546a2d475a", - "milesight/ws136.yaml": "71c1d6b146650fb323b4438e287d7bd3e9e42a08ef4d43234bb38c2ac50bb98f", - "milesight/ws156.yaml": "e7d90cc0cc57f5ab5c3ea54f345b28870c0b4e144709d67223462d477475c011", - "milesight/ws201.yaml": "b714dc41ad761a6a4c43eaf7ff068c32bf5b04490d09d67e8fc9a6213cf16399", - "milesight/ws202.yaml": "afe754d9b28f14dde9d0898c8cdb7e8793980f0bdf22dc683732005d86a2ba85", - "milesight/ws203.yaml": "e5f640f4bfaff3f9ab36301b935f51d2daae9f28f40b7692ee9adec9a6cff5b6", - "milesight/ws301.yaml": "738cf378d3c9ef88c3c2c1d7affd07ee5f670361890c3909ae877441244b8c23", - "milesight/ws302.yaml": "73d5532ca825066acc7c51b3b6e706911e2047485e479280fc7b1a7b8e0e18e1", - "milesight/ws303.yaml": "1459a1b18b4217d70c0d90b70006ea317627d70a72ecd72e61fb803330976c0e", - "milesight/ws50x.yaml": "5d1344c0ac65f9b54624bdf4b1fdeb3164465949da3215bcf958fef1610c5746", - "milesight/ws515.yaml": "ed2b9b19eb49bbc75e50da4dcbb7d3d698a62dae0ffd39a08dbc2369c6878f21", - "milesight/ws52x.yaml": "120fcc7b3cc589beac67b2f4d459fe236ef0e74d569915f391301f395f18acdf", + "milesight/vs330.yaml": "d43b317e3054355e07529f714a1f8aa5478bb07523036c32fb4b5d10d6a8b2d1", + "milesight/vs340.yaml": "ca0d6090923ef6af6eb2c50d90900594e1239cdce3b2eeed4c7335e4aba9ca3a", + "milesight/vs341.yaml": "90f41d2b447eb4b27b3967bdcd2c5d311405fa63c759ff7ce2718281441f952f", + "milesight/ws101.yaml": "ee8428a9ddd630460d327e3f6f86b94f3cc48cadf67ffe4ed50a498272c74ad5", + "milesight/ws136.yaml": "49d2fc1855f07f9435f30756cd8948b1ecdf312b8a9cc719387e35ebdfc783cb", + "milesight/ws156.yaml": "adfa30a6579f92f9de18fe6f1c78e7955c0d66bc4e41fc789b0fef5cfadd0174", + "milesight/ws201.yaml": "677f0cb20c4ac9f7789900d44479efffc8709ac2abffc48e9d6ffa8d7bfd66e6", + "milesight/ws202.yaml": "71c717e8fb9c65acaca699f423a152c89fadd0638ec9327d5544976379601f6a", + "milesight/ws203.yaml": "a11de35ded619902ec92a7a8647c43bb46c1b31572b3e977ed4b44f03f12a5c3", + "milesight/ws301.yaml": "b6261bc429414fe75aaa555033f88518dcce4416a8df9d02428c4f614fa94ca9", + "milesight/ws302.yaml": "83ff15d834a2739a09283de9bf7d727bec7f951269cf596ece7e05231bf25060", + "milesight/ws303.yaml": "6d7b58a80f1fd5f362b76bfe7b72acfbc89c7cfd1e99d7de7ac96cef81cf9f80", "milesight/ws558.yaml": "cb5e22e4ca62db8d947fc400a70ba75408da54651be8d92aed5eb9cedc18da08", - "milesight/wt101.yaml": "29466768bf77ed6fa462956b56541abdff12fba8a9157847e7b79e99d034f847", - "milesight/wt201v1.yaml": "414c80b6663c4b7198109d48db6680897ee3e1a0259363d3f211bd6fd2675a6b", - "milesight/wt201v2.yaml": "dde8e5e83d30cd6a4c508efc4610b2894072b30060dc5d70f3aead953e36f5bb", - "milesight/wts305.yaml": "64eb4e08cc4715cc1e55e91e17aa37abce058203df695ea6ab65c9cef7e3afe7", - "milesight/wts505.yaml": "7d7227fef0d06696dd1907325f6de0c56e87ce9c57a0b3c22b3689b0afa550c9", - "milesight/wts506.yaml": "8c08bb4f2560f7220d53a3ddad859786ec700e26fdbf98ddf65e3ee809db7459", + "milesight/wts305.yaml": "a5249904ab14c869208ea91977331eb881a499c5c403e079968eb738741fae37", + "milesight/wts505.yaml": "8807fa160c2ee60763e7d2ac2f9032fd71436813fcc2bbd7434e702b1c6209f2", + "milesight/wts506.yaml": "aa31c95f43c5452ec9e70cd4dc20c0529f94eb8efd18872b9d8b1925cfc65f15", "radio-bridge/rbs30x.yaml": "166b7a9ee889b439b0cbdf9ef81673bcd04bd4095cefcd6f638de9b4a89194fb", - "radionode/rn320bth.yaml": "a4d66a334943177e2bbe5b649b71df9fc2215fdc08cededc24c4014d4d11edae", - "rakwireless/qingping.yaml": "58e690384a9b97914b54fddcbaaa25b03ab12b289d73ee3e190f901977057a13" + "radionode/rn320bth.yaml": "a4d66a334943177e2bbe5b649b71df9fc2215fdc08cededc24c4014d4d11edae" } } } diff --git a/python/lorawan/tests/test_decode.py b/python/lorawan/tests/test_decode.py index 1635290..7a01e8a 100644 --- a/python/lorawan/tests/test_decode.py +++ b/python/lorawan/tests/test_decode.py @@ -108,24 +108,24 @@ def test_value_map_decodes_a_wire_value_to_its_label(): assert decode_uplink(td, "01")["state"] == "leak" -def test_value_map_that_does_not_start_at_zero_is_a_known_gap(): - """A table whose wire values skip 0 silently loses its highest entries. - - The MultiTech interpreter applies ``lookup`` positionally -- ``if 0 <= value - < len(lookup)`` -- so it reads a table as a list indexed by the wire value - rather than as a mapping. A table keyed 1..3 therefore has length 3, and wire - value 3 falls outside the guard and comes back as the bare integer instead of - its label. The binding can express such a table (three RadioBridge rbs30x - events in the generated catalog do), so this is pinned rather than asserted - away: if a submodule bump ever fixes the interpreter, this test fails and - tells us the gap has closed. +def test_value_map_that_does_not_start_at_zero_resolves_every_entry(): + """A table whose wire values skip 0 now labels all of them. + + This was pinned as a known gap. The interpreter applied ``lookup`` + positionally -- ``if 0 <= value < len(lookup)`` -- reading the table as a list + indexed by the wire value, so a table keyed 1..3 had length 3 and wire value 3 + fell outside the guard and came back as the bare integer. Three RadioBridge + rbs30x events in the catalog are keyed that way. + + Bumping the schema submodule closed it: the table is looked up as a mapping. + The test is kept, pointed the other way, so the fix cannot regress unnoticed. """ td = _value_map_td([(1, "single"), (2, "double"), (3, "triple")]) assert decode_uplink(td, "01")["state"] == "single" - assert decode_uplink(td, "03")["state"] == 3 # not "triple" + assert decode_uplink(td, "03")["state"] == "triple" - # And the gap is detectable rather than silent: the leaked wire value fails - # the data schema, which is exactly the check the oneOf/const spelling lacked. + # The labels are what the data schema allows, so a decoded value validates. + jsonschema.validate(instance="triple", schema=td[vocab.EVENTS]["state"][vocab.DATA]) with pytest.raises(jsonschema.ValidationError): jsonschema.validate(instance=3, schema=td[vocab.EVENTS]["state"][vocab.DATA]) diff --git a/python/lorawan/tests/test_device_catalog.py b/python/lorawan/tests/test_device_catalog.py index d4a3989..3c0d909 100644 --- a/python/lorawan/tests/test_device_catalog.py +++ b/python/lorawan/tests/test_device_catalog.py @@ -69,6 +69,12 @@ def _project_field(body: dict) -> tuple: for key in ("mult", "div", "add", "length", "unit", "unece"): if key in body: parts[key] = body[key] + # ``remaining`` and a negative count are the same instruction -- read to the + # end of the payload -- and the conversion keeps the numeric spelling the + # vocabulary can carry. Compared as one value so the round trip is not judged + # on which of the two the source happened to use. + if isinstance(parts.get("length"), str) and parts["length"].strip().lower() == "remaining": + parts["length"] = -1 if "valid_range" in body: parts["valid_range"] = _freeze(body["valid_range"]) for key in ("ref", "polynomial", "transform", "compute", "guard"): @@ -208,7 +214,7 @@ def _device_tds() -> list: #: #: Change this number only in the same commit as the change that moves it, so the diff #: shows the coverage cost and a reviewer can weigh it. -EXPECTED_CATALOG_SIZE = 157 +EXPECTED_CATALOG_SIZE = 167 def test_catalog_is_not_empty(): @@ -319,9 +325,13 @@ def _field_width(field: dict) -> int: if field.get("type") == "skip": return int(field.get("length", 0)) if field.get("type") in ("bytes", "string", "ascii", "hex", "base64"): - # Variable-length types declare a byte length; -1 means "consume rest", - # which the reference interpreter treats as zero remaining bytes here. - return max(0, int(field.get("length", 0))) + # Variable-length types declare a byte length; -1 and the keyword + # ``remaining`` both mean "consume the rest", which contributes nothing to + # a synthesized payload's length because there is nothing after it. + length = field.get("length", 0) + if isinstance(length, str) and length.strip().lower() == "remaining": + return 0 + return max(0, int(length)) return _value_width(field["type"]) diff --git a/python/lorawan/tests/test_schema_to_td.py b/python/lorawan/tests/test_schema_to_td.py index 3e8e4f5..2b7a7b0 100644 --- a/python/lorawan/tests/test_schema_to_td.py +++ b/python/lorawan/tests/test_schema_to_td.py @@ -154,6 +154,217 @@ def test_lookup_enum_round_trips_to_strings(): assert td_to_payload_schema(td)["fields"][0]["lookup"] == {0: "N", 1: "S"} +def test_lookup_table_with_a_default_label_is_skipped_not_crashed(): + """A non-integer table key must skip its device, not abort the whole run. + + A table may carry a ``default`` label covering every value it does not list + (PS-269). ``lorav:valueMap`` pairs one wire value with one decoded value and + cannot say "anything else", so the field is outside the subset. + + The reason it is a test rather than a note: the key was converted with a bare + ``int()``, so the first schema in the library to use ``default`` raised an + uncaught ValueError out of the batch script and no catalog was generated at + all. A skip loses one device; a crash loses all of them. + """ + schema = { + "endian": "big", + "fields": [{"name": "state", "type": "u8", "lookup": {0: "off", 1: "on", "default": "?"}}], + } + with pytest.raises(UnsupportedSchemaError) as excinfo: + payload_schema_to_td(schema, source="demo.yaml") + assert excinfo.value.reason is SkipReason.ENUM_TABLE + + +def test_length_remaining_round_trips_through_the_byte_length_sentinel(): + """``length: remaining`` converts, rather than taking its device out. + + The form vocabulary carries a byte count, and ``remaining`` is not one, so + this used to raise an uncaught ValueError. It is expressible though: the + reference interpreter resolves the keyword and any negative length the same + way, and the form schema already documents -1 as that sentinel. Mapping it + keeps the device; skipping it would not. + + The keyword normalises to the sentinel on the way back, rather than being + remembered verbatim. Both spellings decode identically, the numeric one is + what the schema library already writes, and it is the form the vocabulary can + carry -- so there is one spelling downstream instead of two. + """ + schema = { + "endian": "big", + "fields": [{"name": "payload", "type": "hex", "length": "remaining"}], + } + td = payload_schema_to_td(schema, source="demo.yaml") + form = td[vocab.EVENTS]["payload"][vocab.FORMS][0] + assert form[vocab.BYTE_LENGTH] == vocab.BYTE_LENGTH_REMAINING + assert td_to_payload_schema(td)["fields"][0]["length"] == vocab.BYTE_LENGTH_REMAINING + + +def test_length_naming_a_variable_is_skipped(): + """A ``$variable`` length has no implementation to round trip to.""" + schema = { + "endian": "big", + "fields": [{"name": "payload", "type": "hex", "length": "$len"}], + } + with pytest.raises(UnsupportedSchemaError) as excinfo: + payload_schema_to_td(schema, source="demo.yaml") + assert excinfo.value.reason is SkipReason.LENGTH_NOT_FIXED + + +def test_a_wire_field_carrying_a_transform_keeps_its_wire_type_and_width(): + """``transform`` post-processes a value read from the wire; it is not derived. + + Modelled on decentlab's ``air_temperature``: a ``u16`` whose raw count is + scaled after reading. Treating the transform as proof of derivation replaced + the wire type with the derived marker, so the field became zero bytes wide, + ``humidity`` after it read from offset 0 instead of 2, and the temperature + dropped out of the decode entirely. + + Asserted through the byte offsets rather than on the transform alone, because + the damage showed up in the *other* fields -- a test that only checked the + transform survived would have passed throughout. + """ + schema = { + "endian": "big", + "fields": [ + { + "name": "air_temperature", + "type": "u16", + "transform": [{"div": 100}, {"add": -327.68}], + }, + {"name": "humidity", "type": "u16"}, + ], + } + td = payload_schema_to_td(schema, source="demo.yaml") + + temp_form = td[vocab.EVENTS]["air_temperature"][vocab.FORMS][0] + assert temp_form[vocab.WIRE_TYPE] == "u16" + assert temp_form[vocab.DERIVED] == {"transform": [{"div": 100}, {"add": -327.68}]} + assert temp_form[vocab.BYTE_OFFSET] == 0 + # The field still occupies its two bytes, so the next one starts after them. + assert td[vocab.EVENTS]["humidity"][vocab.FORMS][0][vocab.BYTE_OFFSET] == 2 + + # And the stages survive the trip back, or the value would decode unscaled. + rebuilt = td_to_payload_schema(td)["fields"] + assert rebuilt[0]["type"] == "u16" + assert rebuilt[0]["transform"] == [{"div": 100}, {"add": -327.68}] + + +def test_a_value_computed_from_others_is_still_derived(): + """A field with no wire type to read stays on the derived path.""" + schema = { + "endian": "big", + "fields": [ + {"name": "raw", "type": "u16"}, + {"name": "ratio", "ref": "$raw", "polynomial": [0, 0.5]}, + ], + } + td = payload_schema_to_td(schema, source="demo.yaml") + form = td[vocab.EVENTS]["ratio"][vocab.FORMS][0] + assert form[vocab.WIRE_TYPE] == vocab.COMPUTED_TYPE + assert form[vocab.DERIVED]["ref"] == "$raw" + + +def test_a_sensor_annotation_does_not_take_a_device_out_of_the_catalog(): + """``sensor`` names which sensor a channel belongs to; it does not decode. + + An unrecognised field key is rejected, on the principle that silently + ignoring one risks dropping something that changes the reading. This one + cannot: like ``semantic`` and ``ipso`` beside it, it labels the channel's + origin and the reference interpreter never consults it. Rejecting it cost 42 + devices in the next schema library. + """ + schema = { + "endian": "big", + "fields": [{"name": "battery", "type": "u8", "unit": "%", "sensor": "internal"}], + } + td = payload_schema_to_td(schema, source="demo.yaml") + event = td[vocab.EVENTS]["battery"] + assert event[vocab.DATA]["unit"] == "%" + # Dropped rather than carried: nothing downstream has a use for it. + assert "sensor" not in str(event[vocab.FORMS][0]) + + +def test_a_downlink_command_byte_becomes_a_const_on_the_data_schema(): + """``value`` fixes a byte when *encoding*; it does not compute anything. + + It had been read as a derived-value descriptor, which took the field's device + out of the catalog -- 45 of them, since nearly every downlink command in the + schema library identifies itself with a fixed category and command byte. + + The reference interpreter does not use ``value`` when decoding: it reads the + byte and reports what the payload actually contained. So the field is an + ordinary scalar that happens to be constrained, and TD core's ``const`` + already says that. A binding term would have been the wrong place -- the + constraint is a fact about the value, not about how it is transferred. + """ + schema = { + "endian": "big", + "fields": [ + {"name": "category", "type": "u8", "value": 3}, + {"name": "interval", "type": "u16", "unit": "s"}, + ], + } + td = payload_schema_to_td(schema, source="demo.yaml") + + category = td[vocab.EVENTS]["category"] + assert category[vocab.DATA]["const"] == 3 + # Still a real byte on the wire, so the field after it is not shifted. + assert category[vocab.FORMS][0][vocab.WIRE_TYPE] == "u8" + assert td[vocab.EVENTS]["interval"][vocab.FORMS][0][vocab.BYTE_OFFSET] == 1 + + assert td_to_payload_schema(td)["fields"][0]["value"] == 3 + + +def test_a_derived_value_reading_something_the_td_lacks_is_skipped(): + """A wrong Thing Description is worse than a missing one. + + A derived value names its inputs with ``$name``. Rebuilding a payload schema + from the events alone means an input with no event of its own comes back as + nothing, and the reference interpreter then evaluates the field against zero. + qingping decoded a temperature of -50 where its schema says 359.5. + + Unlike a skip, that failure is invisible: the device is in the catalog and + the number is simply wrong. So it is caught after the events are assembled + and the device is skipped instead. + """ + schema = { + "endian": "big", + "fields": [ + { + "byte_group": { + "size": 1, + "fields": [ + {"name": "_flag", "type": "u8[0:3]"}, + {"name": "_count", "type": "u8[4:7]"}, + ], + } + }, + {"name": "reading", "ref": "$_count", "polynomial": [-50, 0.1]}, + ], + } + with pytest.raises(UnsupportedSchemaError) as excinfo: + payload_schema_to_td(schema, source="demo.yaml") + assert excinfo.value.reason is SkipReason.INTERNAL_REF + + +def test_an_internal_input_that_does_survive_the_round_trip_is_kept(): + """The guard is on what the Thing Description carries, not on the name. + + Most `_`-prefixed inputs get an event of their own and resolve correctly. + Rejecting on the leading underscore alone cost 22 devices to prevent the one + wrong Thing Description above. + """ + schema = { + "endian": "big", + "fields": [ + {"name": "_raw", "type": "u16"}, + {"name": "reading", "ref": "$_raw", "polynomial": [-50, 0.1]}, + ], + } + td = payload_schema_to_td(schema, source="demo.yaml") + assert td[vocab.EVENTS]["reading"][vocab.FORMS][0][vocab.DERIVED]["ref"] == "$_raw" + + def test_multi_field_tlv_case_becomes_slotted_events(): """Several fields under one tag become slot-ordered events sharing the tag.""" schema = { diff --git a/python/lorawan/vocab/lorawan-form.schema.json b/python/lorawan/vocab/lorawan-form.schema.json index f84239c..c7212f1 100644 --- a/python/lorawan/vocab/lorawan-form.schema.json +++ b/python/lorawan/vocab/lorawan-form.schema.json @@ -170,11 +170,23 @@ "description": "Scaling is stated once, as either a multiplier or a divisor; giving both would leave the order of the two operations undefined.", "required": ["lorav:multiplier", "lorav:divisor"] }, - "dependencies": { - "lorav:derived": { - "description": "A derived value carries no payload bytes, so its wire type must be 'number'.", - "properties": { "lorav:wireType": { "const": "number" } } - } + "if": { + "properties": { + "lorav:derived": { + "type": "object", + "anyOf": [ + { "required": ["ref"] }, + { "required": ["polynomial"] }, + { "required": ["compute"] }, + { "required": ["guard"] } + ] + } + }, + "required": ["lorav:derived"] + }, + "then": { + "description": "A value computed from other values reads no payload bytes, so its wire type is 'number'. A 'transform' alone does not qualify: it post-processes a value that was read from the wire, and that field keeps its real wire type.", + "properties": { "lorav:wireType": { "const": "number" } } }, "additionalProperties": true }