diff --git a/.gitignore b/.gitignore index 3fafd07..ab166da 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,3 @@ __pycache__ *.egg-info +.vscode diff --git a/pyproject.toml b/pyproject.toml index 385047a..e073f3c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -16,6 +16,7 @@ classifiers = [ requires-python = ">=3.10" dependencies = [ "argcomplete", + "aiohttp", "attrs", "cryptography", "colorama", diff --git a/src/infuse_iot/api_client/api/default/get_devices_by_board_id.py b/src/infuse_iot/api_client/api/default/get_devices_by_board_id.py new file mode 100644 index 0000000..687fc16 --- /dev/null +++ b/src/infuse_iot/api_client/api/default/get_devices_by_board_id.py @@ -0,0 +1,194 @@ +from http import HTTPStatus +from typing import Any, Dict, List, Optional, Union, cast + +import httpx + +from ... import errors +from ...client import AuthenticatedClient, Client +from ...models.device import Device +from ...types import UNSET, Response, Unset + + +def _get_kwargs( + id: str, + *, + metadata_name: Union[Unset, str] = UNSET, + metadata_value: Union[Unset, str] = UNSET, +) -> Dict[str, Any]: + params: Dict[str, Any] = {} + + params["metadataName"] = metadata_name + + params["metadataValue"] = metadata_value + + params = {k: v for k, v in params.items() if v is not UNSET and v is not None} + + _kwargs: Dict[str, Any] = { + "method": "get", + "url": f"/board/id/{id}/devices", + "params": params, + } + + return _kwargs + + +def _parse_response( + *, client: Union[AuthenticatedClient, Client], response: httpx.Response +) -> Optional[Union[Any, List["Device"]]]: + if response.status_code == HTTPStatus.OK: + response_200 = [] + _response_200 = response.json() + for response_200_item_data in _response_200: + response_200_item = Device.from_dict(response_200_item_data) + + response_200.append(response_200_item) + + return response_200 + if response.status_code == HTTPStatus.NOT_FOUND: + response_404 = cast(Any, None) + return response_404 + if client.raise_on_unexpected_status: + raise errors.UnexpectedStatus(response.status_code, response.content) + else: + return None + + +def _build_response( + *, client: Union[AuthenticatedClient, Client], response: httpx.Response +) -> Response[Union[Any, List["Device"]]]: + return Response( + status_code=HTTPStatus(response.status_code), + content=response.content, + headers=response.headers, + parsed=_parse_response(client=client, response=response), + ) + + +def sync_detailed( + id: str, + *, + client: Union[AuthenticatedClient, Client], + metadata_name: Union[Unset, str] = UNSET, + metadata_value: Union[Unset, str] = UNSET, +) -> Response[Union[Any, List["Device"]]]: + """Get devices by board id and optional metadata field + + Args: + id (str): + metadata_name (Union[Unset, str]): + metadata_value (Union[Unset, str]): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[Union[Any, List['Device']]] + """ + + kwargs = _get_kwargs( + id=id, + metadata_name=metadata_name, + metadata_value=metadata_value, + ) + + response = client.get_httpx_client().request( + **kwargs, + ) + + return _build_response(client=client, response=response) + + +def sync( + id: str, + *, + client: Union[AuthenticatedClient, Client], + metadata_name: Union[Unset, str] = UNSET, + metadata_value: Union[Unset, str] = UNSET, +) -> Optional[Union[Any, List["Device"]]]: + """Get devices by board id and optional metadata field + + Args: + id (str): + metadata_name (Union[Unset, str]): + metadata_value (Union[Unset, str]): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Union[Any, List['Device']] + """ + + return sync_detailed( + id=id, + client=client, + metadata_name=metadata_name, + metadata_value=metadata_value, + ).parsed + + +async def asyncio_detailed( + id: str, + *, + client: Union[AuthenticatedClient, Client], + metadata_name: Union[Unset, str] = UNSET, + metadata_value: Union[Unset, str] = UNSET, +) -> Response[Union[Any, List["Device"]]]: + """Get devices by board id and optional metadata field + + Args: + id (str): + metadata_name (Union[Unset, str]): + metadata_value (Union[Unset, str]): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[Union[Any, List['Device']]] + """ + + kwargs = _get_kwargs( + id=id, + metadata_name=metadata_name, + metadata_value=metadata_value, + ) + + response = await client.get_async_httpx_client().request(**kwargs) + + return _build_response(client=client, response=response) + + +async def asyncio( + id: str, + *, + client: Union[AuthenticatedClient, Client], + metadata_name: Union[Unset, str] = UNSET, + metadata_value: Union[Unset, str] = UNSET, +) -> Optional[Union[Any, List["Device"]]]: + """Get devices by board id and optional metadata field + + Args: + id (str): + metadata_name (Union[Unset, str]): + metadata_value (Union[Unset, str]): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Union[Any, List['Device']] + """ + + return ( + await asyncio_detailed( + id=id, + client=client, + metadata_name=metadata_name, + metadata_value=metadata_value, + ) + ).parsed diff --git a/src/infuse_iot/api_client/models/__init__.py b/src/infuse_iot/api_client/models/__init__.py index 31286f9..a89386d 100644 --- a/src/infuse_iot/api_client/models/__init__.py +++ b/src/infuse_iot/api_client/models/__init__.py @@ -9,8 +9,10 @@ from .error import Error from .health_check import HealthCheck from .key import Key +from .metadata_field import MetadataField from .new_board import NewBoard from .new_device import NewDevice +from .new_device_metadata import NewDeviceMetadata from .new_organisation import NewOrganisation from .organisation import Organisation @@ -24,8 +26,10 @@ "Error", "HealthCheck", "Key", + "MetadataField", "NewBoard", "NewDevice", + "NewDeviceMetadata", "NewOrganisation", "Organisation", ) diff --git a/src/infuse_iot/api_client/models/board.py b/src/infuse_iot/api_client/models/board.py index 71a2277..fff26b8 100644 --- a/src/infuse_iot/api_client/models/board.py +++ b/src/infuse_iot/api_client/models/board.py @@ -1,10 +1,16 @@ import datetime -from typing import Any, Dict, List, Type, TypeVar +from typing import TYPE_CHECKING, Any, Dict, List, Type, TypeVar, Union from attrs import define as _attrs_define from attrs import field as _attrs_field from dateutil.parser import isoparse +from ..types import UNSET, Unset + +if TYPE_CHECKING: + from ..models.metadata_field import MetadataField + + T = TypeVar("T", bound="Board") @@ -19,6 +25,8 @@ class Board: description (str): Description of board Example: Extended description of board. soc (str): System on Chip (SoC) of board Example: nRF9151. organisation_id (str): ID of organisation for board to exist in + metadata_fields (Union[Unset, List['MetadataField']]): Metadata fields for board Example: [{'name': 'Field + Name', 'required': True, 'unique': False}]. """ id: str @@ -28,6 +36,7 @@ class Board: description: str soc: str organisation_id: str + metadata_fields: Union[Unset, List["MetadataField"]] = UNSET additional_properties: Dict[str, Any] = _attrs_field(init=False, factory=dict) def to_dict(self) -> Dict[str, Any]: @@ -45,6 +54,15 @@ def to_dict(self) -> Dict[str, Any]: organisation_id = self.organisation_id + metadata_fields: Union[Unset, List[Dict[str, Any]]] = UNSET + if not isinstance(self.metadata_fields, Unset): + metadata_fields = [] + for componentsschemas_board_metadata_fields_item_data in self.metadata_fields: + componentsschemas_board_metadata_fields_item = ( + componentsschemas_board_metadata_fields_item_data.to_dict() + ) + metadata_fields.append(componentsschemas_board_metadata_fields_item) + field_dict: Dict[str, Any] = {} field_dict.update(self.additional_properties) field_dict.update( @@ -58,11 +76,15 @@ def to_dict(self) -> Dict[str, Any]: "organisationId": organisation_id, } ) + if metadata_fields is not UNSET: + field_dict["metadataFields"] = metadata_fields return field_dict @classmethod def from_dict(cls: Type[T], src_dict: Dict[str, Any]) -> T: + from ..models.metadata_field import MetadataField + d = src_dict.copy() id = d.pop("id") @@ -78,6 +100,15 @@ def from_dict(cls: Type[T], src_dict: Dict[str, Any]) -> T: organisation_id = d.pop("organisationId") + metadata_fields = [] + _metadata_fields = d.pop("metadataFields", UNSET) + for componentsschemas_board_metadata_fields_item_data in _metadata_fields or []: + componentsschemas_board_metadata_fields_item = MetadataField.from_dict( + componentsschemas_board_metadata_fields_item_data + ) + + metadata_fields.append(componentsschemas_board_metadata_fields_item) + board = cls( id=id, created_at=created_at, @@ -86,6 +117,7 @@ def from_dict(cls: Type[T], src_dict: Dict[str, Any]) -> T: description=description, soc=soc, organisation_id=organisation_id, + metadata_fields=metadata_fields, ) board.additional_properties = d diff --git a/src/infuse_iot/api_client/models/device.py b/src/infuse_iot/api_client/models/device.py index e033a03..958a99f 100644 --- a/src/infuse_iot/api_client/models/device.py +++ b/src/infuse_iot/api_client/models/device.py @@ -1,5 +1,5 @@ import datetime -from typing import Any, Dict, List, Type, TypeVar, Union +from typing import TYPE_CHECKING, Any, Dict, List, Type, TypeVar, Union from attrs import define as _attrs_define from attrs import field as _attrs_field @@ -7,6 +7,10 @@ from ..types import UNSET, Unset +if TYPE_CHECKING: + from ..models.new_device_metadata import NewDeviceMetadata + + T = TypeVar("T", bound="Device") @@ -22,6 +26,7 @@ class Device: organisation_id (str): ID of organisation for board to exist in device_id (Union[Unset, str]): 8 byte DeviceID as a hex string (if not provided will be auto-generated) Example: d291d4d66bf0a955. + metadata (Union[Unset, NewDeviceMetadata]): Metadata fields for device Example: {'Field Name': 'Field Value'}. """ id: str @@ -31,6 +36,7 @@ class Device: board_id: str organisation_id: str device_id: Union[Unset, str] = UNSET + metadata: Union[Unset, "NewDeviceMetadata"] = UNSET additional_properties: Dict[str, Any] = _attrs_field(init=False, factory=dict) def to_dict(self) -> Dict[str, Any]: @@ -48,6 +54,10 @@ def to_dict(self) -> Dict[str, Any]: device_id = self.device_id + metadata: Union[Unset, Dict[str, Any]] = UNSET + if not isinstance(self.metadata, Unset): + metadata = self.metadata.to_dict() + field_dict: Dict[str, Any] = {} field_dict.update(self.additional_properties) field_dict.update( @@ -62,11 +72,15 @@ def to_dict(self) -> Dict[str, Any]: ) if device_id is not UNSET: field_dict["deviceId"] = device_id + if metadata is not UNSET: + field_dict["metadata"] = metadata return field_dict @classmethod def from_dict(cls: Type[T], src_dict: Dict[str, Any]) -> T: + from ..models.new_device_metadata import NewDeviceMetadata + d = src_dict.copy() id = d.pop("id") @@ -82,6 +96,13 @@ def from_dict(cls: Type[T], src_dict: Dict[str, Any]) -> T: device_id = d.pop("deviceId", UNSET) + _metadata = d.pop("metadata", UNSET) + metadata: Union[Unset, NewDeviceMetadata] + if isinstance(_metadata, Unset): + metadata = UNSET + else: + metadata = NewDeviceMetadata.from_dict(_metadata) + device = cls( id=id, created_at=created_at, @@ -90,6 +111,7 @@ def from_dict(cls: Type[T], src_dict: Dict[str, Any]) -> T: board_id=board_id, organisation_id=organisation_id, device_id=device_id, + metadata=metadata, ) device.additional_properties = d diff --git a/src/infuse_iot/api_client/models/metadata_field.py b/src/infuse_iot/api_client/models/metadata_field.py new file mode 100644 index 0000000..8143960 --- /dev/null +++ b/src/infuse_iot/api_client/models/metadata_field.py @@ -0,0 +1,74 @@ +from typing import Any, Dict, List, Type, TypeVar + +from attrs import define as _attrs_define +from attrs import field as _attrs_field + +T = TypeVar("T", bound="MetadataField") + + +@_attrs_define +class MetadataField: + """ + Attributes: + name (str): Name of metadata field Example: Field Name. + required (bool): Whether field is required Example: True. + unique (bool): Whether field is unique (across all devices of the same board) + """ + + name: str + required: bool + unique: bool + additional_properties: Dict[str, Any] = _attrs_field(init=False, factory=dict) + + def to_dict(self) -> Dict[str, Any]: + name = self.name + + required = self.required + + unique = self.unique + + field_dict: Dict[str, Any] = {} + field_dict.update(self.additional_properties) + field_dict.update( + { + "name": name, + "required": required, + "unique": unique, + } + ) + + return field_dict + + @classmethod + def from_dict(cls: Type[T], src_dict: Dict[str, Any]) -> T: + d = src_dict.copy() + name = d.pop("name") + + required = d.pop("required") + + unique = d.pop("unique") + + metadata_field = cls( + name=name, + required=required, + unique=unique, + ) + + metadata_field.additional_properties = d + return metadata_field + + @property + def additional_keys(self) -> List[str]: + return list(self.additional_properties.keys()) + + def __getitem__(self, key: str) -> Any: + return self.additional_properties[key] + + def __setitem__(self, key: str, value: Any) -> None: + self.additional_properties[key] = value + + def __delitem__(self, key: str) -> None: + del self.additional_properties[key] + + def __contains__(self, key: str) -> bool: + return key in self.additional_properties diff --git a/src/infuse_iot/api_client/models/new_board.py b/src/infuse_iot/api_client/models/new_board.py index faa2a12..76d8d5e 100644 --- a/src/infuse_iot/api_client/models/new_board.py +++ b/src/infuse_iot/api_client/models/new_board.py @@ -1,8 +1,14 @@ -from typing import Any, Dict, List, Type, TypeVar +from typing import TYPE_CHECKING, Any, Dict, List, Type, TypeVar, Union from attrs import define as _attrs_define from attrs import field as _attrs_field +from ..types import UNSET, Unset + +if TYPE_CHECKING: + from ..models.metadata_field import MetadataField + + T = TypeVar("T", bound="NewBoard") @@ -14,12 +20,15 @@ class NewBoard: description (str): Description of board Example: Extended description of board. soc (str): System on Chip (SoC) of board Example: nRF9151. organisation_id (str): ID of organisation for board to exist in + metadata_fields (Union[Unset, List['MetadataField']]): Metadata fields for board Example: [{'name': 'Field + Name', 'required': True, 'unique': False}]. """ name: str description: str soc: str organisation_id: str + metadata_fields: Union[Unset, List["MetadataField"]] = UNSET additional_properties: Dict[str, Any] = _attrs_field(init=False, factory=dict) def to_dict(self) -> Dict[str, Any]: @@ -31,6 +40,15 @@ def to_dict(self) -> Dict[str, Any]: organisation_id = self.organisation_id + metadata_fields: Union[Unset, List[Dict[str, Any]]] = UNSET + if not isinstance(self.metadata_fields, Unset): + metadata_fields = [] + for componentsschemas_board_metadata_fields_item_data in self.metadata_fields: + componentsschemas_board_metadata_fields_item = ( + componentsschemas_board_metadata_fields_item_data.to_dict() + ) + metadata_fields.append(componentsschemas_board_metadata_fields_item) + field_dict: Dict[str, Any] = {} field_dict.update(self.additional_properties) field_dict.update( @@ -41,11 +59,15 @@ def to_dict(self) -> Dict[str, Any]: "organisationId": organisation_id, } ) + if metadata_fields is not UNSET: + field_dict["metadataFields"] = metadata_fields return field_dict @classmethod def from_dict(cls: Type[T], src_dict: Dict[str, Any]) -> T: + from ..models.metadata_field import MetadataField + d = src_dict.copy() name = d.pop("name") @@ -55,11 +77,21 @@ def from_dict(cls: Type[T], src_dict: Dict[str, Any]) -> T: organisation_id = d.pop("organisationId") + metadata_fields = [] + _metadata_fields = d.pop("metadataFields", UNSET) + for componentsschemas_board_metadata_fields_item_data in _metadata_fields or []: + componentsschemas_board_metadata_fields_item = MetadataField.from_dict( + componentsschemas_board_metadata_fields_item_data + ) + + metadata_fields.append(componentsschemas_board_metadata_fields_item) + new_board = cls( name=name, description=description, soc=soc, organisation_id=organisation_id, + metadata_fields=metadata_fields, ) new_board.additional_properties = d diff --git a/src/infuse_iot/api_client/models/new_device.py b/src/infuse_iot/api_client/models/new_device.py index 5f12723..e5b58c7 100644 --- a/src/infuse_iot/api_client/models/new_device.py +++ b/src/infuse_iot/api_client/models/new_device.py @@ -1,10 +1,14 @@ -from typing import Any, Dict, List, Type, TypeVar, Union +from typing import TYPE_CHECKING, Any, Dict, List, Type, TypeVar, Union from attrs import define as _attrs_define from attrs import field as _attrs_field from ..types import UNSET, Unset +if TYPE_CHECKING: + from ..models.new_device_metadata import NewDeviceMetadata + + T = TypeVar("T", bound="NewDevice") @@ -17,12 +21,14 @@ class NewDevice: organisation_id (str): ID of organisation for board to exist in device_id (Union[Unset, str]): 8 byte DeviceID as a hex string (if not provided will be auto-generated) Example: d291d4d66bf0a955. + metadata (Union[Unset, NewDeviceMetadata]): Metadata fields for device Example: {'Field Name': 'Field Value'}. """ mcu_id: str board_id: str organisation_id: str device_id: Union[Unset, str] = UNSET + metadata: Union[Unset, "NewDeviceMetadata"] = UNSET additional_properties: Dict[str, Any] = _attrs_field(init=False, factory=dict) def to_dict(self) -> Dict[str, Any]: @@ -34,6 +40,10 @@ def to_dict(self) -> Dict[str, Any]: device_id = self.device_id + metadata: Union[Unset, Dict[str, Any]] = UNSET + if not isinstance(self.metadata, Unset): + metadata = self.metadata.to_dict() + field_dict: Dict[str, Any] = {} field_dict.update(self.additional_properties) field_dict.update( @@ -45,11 +55,15 @@ def to_dict(self) -> Dict[str, Any]: ) if device_id is not UNSET: field_dict["deviceId"] = device_id + if metadata is not UNSET: + field_dict["metadata"] = metadata return field_dict @classmethod def from_dict(cls: Type[T], src_dict: Dict[str, Any]) -> T: + from ..models.new_device_metadata import NewDeviceMetadata + d = src_dict.copy() mcu_id = d.pop("mcuId") @@ -59,11 +73,19 @@ def from_dict(cls: Type[T], src_dict: Dict[str, Any]) -> T: device_id = d.pop("deviceId", UNSET) + _metadata = d.pop("metadata", UNSET) + metadata: Union[Unset, NewDeviceMetadata] + if isinstance(_metadata, Unset): + metadata = UNSET + else: + metadata = NewDeviceMetadata.from_dict(_metadata) + new_device = cls( mcu_id=mcu_id, board_id=board_id, organisation_id=organisation_id, device_id=device_id, + metadata=metadata, ) new_device.additional_properties = d diff --git a/src/infuse_iot/api_client/models/new_device_metadata.py b/src/infuse_iot/api_client/models/new_device_metadata.py new file mode 100644 index 0000000..e34d494 --- /dev/null +++ b/src/infuse_iot/api_client/models/new_device_metadata.py @@ -0,0 +1,48 @@ +from typing import Any, Dict, List, Type, TypeVar + +from attrs import define as _attrs_define +from attrs import field as _attrs_field + +T = TypeVar("T", bound="NewDeviceMetadata") + + +@_attrs_define +class NewDeviceMetadata: + """Metadata fields for device + + Example: + {'Field Name': 'Field Value'} + + """ + + additional_properties: Dict[str, str] = _attrs_field(init=False, factory=dict) + + def to_dict(self) -> Dict[str, Any]: + field_dict: Dict[str, Any] = {} + field_dict.update(self.additional_properties) + + return field_dict + + @classmethod + def from_dict(cls: Type[T], src_dict: Dict[str, Any]) -> T: + d = src_dict.copy() + new_device_metadata = cls() + + new_device_metadata.additional_properties = d + return new_device_metadata + + @property + def additional_keys(self) -> List[str]: + return list(self.additional_properties.keys()) + + def __getitem__(self, key: str) -> str: + return self.additional_properties[key] + + def __setitem__(self, key: str, value: str) -> None: + self.additional_properties[key] = value + + def __delitem__(self, key: str) -> None: + del self.additional_properties[key] + + def __contains__(self, key: str) -> bool: + return key in self.additional_properties diff --git a/src/infuse_iot/database.py b/src/infuse_iot/database.py index b22753e..531a2ed 100644 --- a/src/infuse_iot/database.py +++ b/src/infuse_iot/database.py @@ -46,7 +46,6 @@ def observe_serial( """Update device state based on observed packet""" if self.gateway is None: self.gateway = address - print("GATEWAY", hex(self.gateway)) if address not in self.devices: self.devices[address] = self.DeviceState(address) if network_id is not None: @@ -83,6 +82,9 @@ def observe_security_state( def _serial_key(self, base, time_idx): return hkdf_derive(base, time_idx.to_bytes(4, "little"), b"serial") + def _bt_adv_key(self, base, time_idx): + return hkdf_derive(base, time_idx.to_bytes(4, "little"), b"bt_adv") + def has_public_key(self, address: int): """Does the database have the public key for this device?""" if address not in self.devices: @@ -112,3 +114,26 @@ def serial_device_key(self, address: int, gps_time: int): time_idx = gps_time // (60 * 60 * 24) return self._serial_key(base, time_idx) + + def bt_adv_network_key(self, address: int, gps_time: int): + """Network key for Bluetooth advertising interface""" + if address not in self.devices: + raise NoKeyError + base = self._network_keys[self.devices[address].network_id] + time_idx = gps_time // (60 * 60 * 24) + + return self._bt_adv_key(base, time_idx) + + def bt_adv_device_key(self, address: int, gps_time: int): + """Device key for Bluetooth advertising interface""" + if address not in self.devices: + raise NoKeyError + d = self.devices[address] + if d.device_id is None: + raise NoKeyError + base = self.devices[address].shared_key + if base is None: + raise NoKeyError + time_idx = gps_time // (60 * 60 * 24) + + return self._bt_adv_key(base, time_idx) diff --git a/src/infuse_iot/epacket.py b/src/infuse_iot/epacket.py index aee328b..a4abef7 100644 --- a/src/infuse_iot/epacket.py +++ b/src/infuse_iot/epacket.py @@ -10,7 +10,7 @@ from typing_extensions import Self from infuse_iot.util.crypto import chachapoly_decrypt, chachapoly_encrypt -from infuse_iot.database import DeviceDatabase +from infuse_iot.database import DeviceDatabase, NoKeyError from infuse_iot.time import InfuseTime @@ -235,17 +235,7 @@ def from_json(cls, values: Dict) -> Self: @classmethod def from_serial(cls, database: DeviceDatabase, serial_frame: bytes) -> List[Self]: - header = CtypeSerialFrame.from_buffer_copy(serial_frame) - if header.flags & Flags.ENCR_DEVICE: - database.observe_serial(header.device_id, device_id=header.key_metadata) - key = database.serial_device_key(header.device_id, header.gps_time) - else: - database.observe_serial(header.device_id, network_id=header.key_metadata) - key = database.serial_network_key(header.device_id, header.gps_time) - - decrypted = chachapoly_decrypt( - key, serial_frame[:11], serial_frame[11:23], serial_frame[23:] - ) + header, decrypted = CtypeSerialFrame.decrypt(database, serial_frame) # Packet from local gateway if header.type != InfuseType.RECEIVED_EPACKET: @@ -263,32 +253,65 @@ def from_serial(cls, database: DeviceDatabase, serial_frame: bytes) -> List[Self # Only Bluetooth advertising supported for now if common_header.interface != Interface.BT_ADV: raise NotImplementedError - # Decrypting payloads not currently supported - if common_header.encrypted: - raise NotImplementedError # Extract interface address (Only Bluetooth supported) addr = InterfaceAddress.from_bytes(common_header.interface, packet_bytes) del packet_bytes[: addr.len()] - # Extract payload metadata - decr_header = CtypePacketReceived.DecryptedHeader.from_buffer_copy( - packet_bytes - ) - del packet_bytes[: ctypes.sizeof(decr_header)] - - bt_hop = HopReceived( - decr_header.device_id, - common_header.interface, - addr, - Auth.DEVICE if decr_header.flags & Flags.ENCR_DEVICE else Auth.NETWORK, - decr_header.key_id, - decr_header.gps_time, - decr_header.sequence, - common_header.rssi, - ) - packet = cls( - [bt_hop, header.hop_received()], decr_header.type, bytes(packet_bytes) - ) + + # Decrypting packet + if common_header.encrypted: + try: + bt_header, bt_decrypted = CtypeBtAdvFrame.decrypt( + database, packet_bytes + ) + except NoKeyError: + continue + + bt_hop = HopReceived( + bt_header.device_id, + common_header.interface, + addr, + ( + Auth.DEVICE + if bt_header.flags & Flags.ENCR_DEVICE + else Auth.NETWORK + ), + bt_header.key_metadata, + bt_header.gps_time, + bt_header.sequence, + common_header.rssi, + ) + packet = cls( + [bt_hop, header.hop_received()], + bt_header.type, + bytes(bt_decrypted), + ) + else: + # Extract payload metadata + decr_header = CtypePacketReceived.DecryptedHeader.from_buffer_copy( + packet_bytes + ) + del packet_bytes[: ctypes.sizeof(decr_header)] + + bt_hop = HopReceived( + decr_header.device_id, + common_header.interface, + addr, + ( + Auth.DEVICE + if decr_header.flags & Flags.ENCR_DEVICE + else Auth.NETWORK + ), + decr_header.key_id, + decr_header.gps_time, + decr_header.sequence, + common_header.rssi, + ) + packet = cls( + [bt_hop, header.hop_received()], + decr_header.type, + bytes(packet_bytes), + ) packets.append(packet) return packets @@ -354,9 +377,7 @@ def from_json(cls, values: Dict) -> Self: ) -class CtypeSerialFrame(ctypes.Structure): - """Serial packet header""" - +class CtypeV0VersionedFrame(ctypes.Structure): _fields_ = [ ("version", ctypes.c_uint8), ("_type", ctypes.c_uint8), @@ -395,10 +416,14 @@ def device_id(self, value): def parse(cls, frame: bytes) -> Tuple[Self, int]: """Parse serial frame into header and payload length""" return ( - CtypeSerialFrame.from_buffer_copy(frame), - len(frame) - ctypes.sizeof(CtypeSerialFrame) - 16, + CtypeV0VersionedFrame.from_buffer_copy(frame), + len(frame) - ctypes.sizeof(CtypeV0VersionedFrame) - 16, ) + +class CtypeSerialFrame(CtypeV0VersionedFrame): + """Serial packet header""" + def hop_received(self) -> HopReceived: auth = Auth.DEVICE if self.flags & Flags.ENCR_DEVICE else Auth.NETWORK return HopReceived( @@ -412,6 +437,35 @@ def hop_received(self) -> HopReceived: 0, ) + @classmethod + def decrypt(cls, database: DeviceDatabase, frame: bytes): + header = cls.from_buffer_copy(frame) + if header.flags & Flags.ENCR_DEVICE: + database.observe_serial(header.device_id, device_id=header.key_metadata) + key = database.serial_device_key(header.device_id, header.gps_time) + else: + database.observe_serial(header.device_id, network_id=header.key_metadata) + key = database.serial_network_key(header.device_id, header.gps_time) + + decrypted = chachapoly_decrypt(key, frame[:11], frame[11:23], frame[23:]) + return header, decrypted + + +class CtypeBtAdvFrame(CtypeV0VersionedFrame): + """Bluetooth Advertising packet header""" + + @classmethod + def decrypt(cls, database: DeviceDatabase, frame: bytes): + header = cls.from_buffer_copy(frame) + if header.flags & Flags.ENCR_DEVICE: + raise NotImplementedError + else: + database.observe_serial(header.device_id, network_id=header.key_metadata) + key = database.bt_adv_network_key(header.device_id, header.gps_time) + + decrypted = chachapoly_decrypt(key, frame[:11], frame[11:23], frame[23:]) + return header, decrypted + class CtypePacketReceived: class CommonHeader(ctypes.Structure): diff --git a/src/infuse_iot/generated/rpc_definitions.py b/src/infuse_iot/generated/rpc_definitions.py index 7edfc68..c2f3812 100644 --- a/src/infuse_iot/generated/rpc_definitions.py +++ b/src/infuse_iot/generated/rpc_definitions.py @@ -150,6 +150,7 @@ class rpc_enum_file_action(enum.IntEnum): DISCARD = 0 APP_IMG = 1 BT_CTLR_IMG = 2 + APP_CPATCH = 11 NRF91_MODEM_DIFF = 20 @@ -158,4 +159,5 @@ class rpc_enum_infuse_bt_characteristic(enum.IntEnum): COMMAND = 1 DATA = 2 + LOGGING = 4 diff --git a/src/infuse_iot/generated/tdf_definitions.py b/src/infuse_iot/generated/tdf_definitions.py index 5bcfbdd..50969b0 100644 --- a/src/infuse_iot/generated/tdf_definitions.py +++ b/src/infuse_iot/generated/tdf_definitions.py @@ -17,7 +17,7 @@ def iter_fields(self) -> Generator[str, ctypes._SimpleCData, str, Callable]: else: f_name = field[0] val = getattr(self, f_name) - yield f_name, val, self._postfix_[f_name], self._display_fn_.get(f_name) + yield f_name, val, self._postfix_[f_name], self._display_fmt_[f_name] class tdf_struct_mcuboot_img_sem_ver(_struct_type): """MCUboot semantic versioning struct""" @@ -35,8 +35,11 @@ class tdf_struct_mcuboot_img_sem_ver(_struct_type): "revision": "", "build_num": "", } - _display_fn_ = { - "build_num": hex, + _display_fmt_ = { + "major": "{}", + "minor": "{}", + "revision": "{}", + "build_num": "0x{:08x}", } class tdf_struct_xyz_16bit(_struct_type): @@ -53,7 +56,10 @@ class tdf_struct_xyz_16bit(_struct_type): "y": "", "z": "", } - _display_fn_ = { + _display_fmt_ = { + "x": "{}", + "y": "{}", + "z": "{}", } class tdf_struct_gcs_location(_struct_type): @@ -70,7 +76,10 @@ class tdf_struct_gcs_location(_struct_type): "longitude": "deg", "height": "m", } - _display_fn_ = { + _display_fmt_ = { + "latitude": "{:.5f}", + "longitude": "{:.5f}", + "height": "{:.3f}", } @property @@ -97,7 +106,9 @@ class tdf_struct_lte_cell_id_local(_struct_type): "eci": "", "tac": "", } - _display_fn_ = { + _display_fmt_ = { + "eci": "{}", + "tac": "{}", } class tdf_struct_lte_cell_id_global(_struct_type): @@ -116,7 +127,11 @@ class tdf_struct_lte_cell_id_global(_struct_type): "eci": "", "tac": "", } - _display_fn_ = { + _display_fmt_ = { + "mcc": "{}", + "mnc": "{}", + "eci": "{}", + "tac": "{}", } class readings: @@ -129,12 +144,12 @@ def iter_fields(self) -> Generator[str, ctypes._SimpleCData, str, Callable]: f_name = field[0] val = getattr(self, f_name) if isinstance(val, ctypes.LittleEndianStructure): - for subfield_name, subfield_val, subfield_postfix, display_fn in val.iter_fields(): - yield f'{f_name}.{subfield_name}', subfield_val, subfield_postfix, display_fn + for subfield_name, subfield_val, subfield_postfix, display_fmt in val.iter_fields(): + yield f'{f_name}.{subfield_name}', subfield_val, subfield_postfix, display_fmt elif isinstance(val, ctypes.Array): - yield f_name, list(val), self._postfix_[f_name], self._display_fn_.get(f_name) + yield f_name, list(val), self._postfix_[f_name], self._display_fmt_[f_name] else: - yield f_name, val, self._postfix_[f_name], self._display_fn_.get(f_name) + yield f_name, val, self._postfix_[f_name], self._display_fmt_[f_name] class announce(_reading_type): """Common announcement packet""" @@ -159,9 +174,14 @@ class announce(_reading_type): "reboots": "", "flags": "", } - _display_fn_ = { - "kv_crc": hex, - "flags": hex, + _display_fmt_ = { + "application": "0x{:08x}", + "version": "{}", + "kv_crc": "0x{:08x}", + "blocks": "{}", + "uptime": "{}", + "reboots": "{}", + "flags": "0x{:02x}", } class battery_state(_reading_type): @@ -179,7 +199,10 @@ class battery_state(_reading_type): "current_ua": "uA", "soc": "%", } - _display_fn_ = { + _display_fmt_ = { + "voltage_mv": "{}", + "current_ua": "{}", + "soc": "{}", } class ambient_temp_pres_hum(_reading_type): @@ -194,10 +217,13 @@ class ambient_temp_pres_hum(_reading_type): _pack_ = 1 _postfix_ = { "temperature": "deg", - "pressure": "kPa", + "pressure": "kPA", "humidity": "%", } - _display_fn_ = { + _display_fmt_ = { + "temperature": "{:.3f}", + "pressure": "{:.3f}", + "humidity": "{:.2f}", } @property @@ -223,7 +249,8 @@ class ambient_temperature(_reading_type): _postfix_ = { "temperature": "deg", } - _display_fn_ = { + _display_fmt_ = { + "temperature": "{:.3f}", } @property @@ -243,7 +270,9 @@ class time_sync(_reading_type): "source": "", "shift": "us", } - _display_fn_ = { + _display_fmt_ = { + "source": "{}", + "shift": "{}", } @property @@ -273,10 +302,14 @@ class reboot_info(_reading_type): "param_2": "", "thread": "", } - _display_fn_ = { - "hardware_flags": hex, - "param_1": hex, - "param_2": hex, + _display_fmt_ = { + "reason": "{}", + "hardware_flags": "0x{:08x}", + "count": "{}", + "uptime": "{}", + "param_1": "0x{:08x}", + "param_2": "0x{:08x}", + "thread": "{}", } class acc_2g(_reading_type): @@ -290,7 +323,8 @@ class acc_2g(_reading_type): _postfix_ = { "sample": "", } - _display_fn_ = { + _display_fmt_ = { + "sample": "{}", } class acc_4g(_reading_type): @@ -304,7 +338,8 @@ class acc_4g(_reading_type): _postfix_ = { "sample": "", } - _display_fn_ = { + _display_fmt_ = { + "sample": "{}", } class acc_8g(_reading_type): @@ -318,7 +353,8 @@ class acc_8g(_reading_type): _postfix_ = { "sample": "", } - _display_fn_ = { + _display_fmt_ = { + "sample": "{}", } class acc_16g(_reading_type): @@ -332,7 +368,8 @@ class acc_16g(_reading_type): _postfix_ = { "sample": "", } - _display_fn_ = { + _display_fmt_ = { + "sample": "{}", } class gyr_125dps(_reading_type): @@ -346,7 +383,8 @@ class gyr_125dps(_reading_type): _postfix_ = { "sample": "", } - _display_fn_ = { + _display_fmt_ = { + "sample": "{}", } class gyr_250dps(_reading_type): @@ -360,7 +398,8 @@ class gyr_250dps(_reading_type): _postfix_ = { "sample": "", } - _display_fn_ = { + _display_fmt_ = { + "sample": "{}", } class gyr_500dps(_reading_type): @@ -374,7 +413,8 @@ class gyr_500dps(_reading_type): _postfix_ = { "sample": "", } - _display_fn_ = { + _display_fmt_ = { + "sample": "{}", } class gyr_1000dps(_reading_type): @@ -388,7 +428,8 @@ class gyr_1000dps(_reading_type): _postfix_ = { "sample": "", } - _display_fn_ = { + _display_fmt_ = { + "sample": "{}", } class gyr_2000dps(_reading_type): @@ -402,7 +443,8 @@ class gyr_2000dps(_reading_type): _postfix_ = { "sample": "", } - _display_fn_ = { + _display_fmt_ = { + "sample": "{}", } class gcs_wgs84_llha(_reading_type): @@ -420,7 +462,10 @@ class gcs_wgs84_llha(_reading_type): "h_acc": "m", "v_acc": "m", } - _display_fn_ = { + _display_fmt_ = { + "location": "{}", + "h_acc": "{:.3f}", + "v_acc": "{:.3f}", } @property @@ -506,11 +551,40 @@ class ubx_nav_pvt(_reading_type): "mag_dec": "deg", "mag_acc": "deg", } - _display_fn_ = { - "valid": hex, - "flags": hex, - "flags2": hex, - "flags3": hex, + _display_fmt_ = { + "itow": "{}", + "year": "{}", + "month": "{}", + "day": "{}", + "hour": "{}", + "min": "{}", + "sec": "{}", + "valid": "{}", + "t_acc": "{}", + "nano": "{}", + "fix_type": "{}", + "flags": "{}", + "flags2": "{}", + "num_sv": "{}", + "lon": "{}", + "lat": "{}", + "height": "{}", + "h_msl": "{}", + "h_acc": "{}", + "v_acc": "{}", + "vel_n": "{}", + "vel_e": "{}", + "vel_d": "{}", + "g_speed": "{}", + "head_mot": "{}", + "s_acc": "{}", + "head_acc": "{}", + "p_dop": "{}", + "flags3": "{}", + "reserved0": "{}", + "head_veh": "{}", + "mag_dec": "{}", + "mag_acc": "{}", } @property @@ -602,13 +676,142 @@ class lte_conn_status(_reading_type): "rsrp": "dBm", "rsrq": "dB", } - _display_fn_ = { + _display_fmt_ = { + "cell": "{}", + "earfcn": "{}", + "status": "{}", + "tech": "{}", + "rsrp": "{}", + "rsrq": "{}", } @property def rsrp(self): return self._rsrp * -1 + class globalstar_pkt(_reading_type): + """9 byte payload transmitted over the Globalstar Simplex network""" + + name = "GLOBALSTAR_PKT" + _fields_ = [ + ("payload", 9 * ctypes.c_uint8), + ] + _pack_ = 1 + _postfix_ = { + "payload": "", + } + _display_fmt_ = { + "payload": "{}", + } + + class acc_magnitude_std_dev(_reading_type): + """Accelerometer magnitude standard deviation over a window""" + + name = "ACC_MAGNITUDE_STD_DEV" + _fields_ = [ + ("count", ctypes.c_uint32), + ("std_dev", ctypes.c_uint32), + ] + _pack_ = 1 + _postfix_ = { + "count": "", + "std_dev": "", + } + _display_fmt_ = { + "count": "{}", + "std_dev": "{}", + } + + class activity_metric(_reading_type): + """Generic activity metric""" + + name = "ACTIVITY_METRIC" + _fields_ = [ + ("value", ctypes.c_uint32), + ] + _pack_ = 1 + _postfix_ = { + "value": "", + } + _display_fmt_ = { + "value": "{}", + } + + class algorithm_output(_reading_type): + """Generic activity metric""" + + name = "ALGORITHM_OUTPUT" + _fields_ = [ + ("algorithm_id", ctypes.c_uint32), + ("algorithm_version", ctypes.c_uint16), + ("output", 0 * ctypes.c_uint8), + ] + _pack_ = 1 + _postfix_ = { + "algorithm_id": "", + "algorithm_version": "", + "output": "", + } + _display_fmt_ = { + "algorithm_id": "{}", + "algorithm_version": "{}", + "output": "{}", + } + + class runtime_error(_reading_type): + """Runtime error logging""" + + name = "RUNTIME_ERROR" + _fields_ = [ + ("error_id", ctypes.c_uint32), + ("error_ctx", ctypes.c_uint32), + ] + _pack_ = 1 + _postfix_ = { + "error_id": "", + "error_ctx": "", + } + _display_fmt_ = { + "error_id": "{}", + "error_ctx": "{}", + } + + class charger_en_control(_reading_type): + """Battery charging enable state""" + + name = "CHARGER_EN_CONTROL" + _fields_ = [ + ("enabled", ctypes.c_uint8), + ] + _pack_ = 1 + _postfix_ = { + "enabled": "", + } + _display_fmt_ = { + "enabled": "{}", + } + + class gnss_fix_info(_reading_type): + """Metadata about a GNSS location fix""" + + name = "GNSS_FIX_INFO" + _fields_ = [ + ("time_fix", ctypes.c_uint16), + ("location_fix", ctypes.c_uint16), + ("num_sv", ctypes.c_uint8), + ] + _pack_ = 1 + _postfix_ = { + "time_fix": "", + "location_fix": "", + "num_sv": "", + } + _display_fmt_ = { + "time_fix": "{}", + "location_fix": "{}", + "num_sv": "{}", + } + class array_type(_reading_type): """Example array type""" @@ -620,7 +823,8 @@ class array_type(_reading_type): _postfix_ = { "array": "", } - _display_fn_ = { + _display_fmt_ = { + "array": "{}", } id_type_mapping = { @@ -642,5 +846,12 @@ class array_type(_reading_type): 19: readings.gcs_wgs84_llha, 20: readings.ubx_nav_pvt, 21: readings.lte_conn_status, + 22: readings.globalstar_pkt, + 23: readings.acc_magnitude_std_dev, + 24: readings.activity_metric, + 25: readings.algorithm_output, + 26: readings.runtime_error, + 27: readings.charger_en_control, + 28: readings.gnss_fix_info, 100: readings.array_type, } diff --git a/src/infuse_iot/rpc_wrappers/application_info.py b/src/infuse_iot/rpc_wrappers/application_info.py new file mode 100644 index 0000000..dcc6bad --- /dev/null +++ b/src/infuse_iot/rpc_wrappers/application_info.py @@ -0,0 +1,55 @@ +#!/usr/bin/env python3 + +import ctypes + +from infuse_iot.commands import InfuseRpcCommand +from infuse_iot.generated.rpc_definitions import rpc_struct_mcuboot_img_sem_ver + + +class application_info(InfuseRpcCommand): + HELP = "Get the current application info" + DESCRIPTION = "Get the current application info" + COMMAND_ID = 9 + + class request(ctypes.LittleEndianStructure): + _fields_ = [] + _pack_ = 1 + + class response(ctypes.LittleEndianStructure): + _fields_ = [ + ("application_id", ctypes.c_uint32), + ("version", rpc_struct_mcuboot_img_sem_ver), + ("network_id", ctypes.c_uint32), + ("uptime", ctypes.c_uint32), + ("reboots", ctypes.c_uint32), + ("kv_crc", ctypes.c_uint32), + ("data_blocks_internal", ctypes.c_uint32), + ("data_blocks_external", ctypes.c_uint32), + ] + _pack_ = 1 + + @classmethod + def add_parser(cls, _parser): + pass + + def __init__(self, _args): + pass + + def request_struct(self): + return self.request() + + def handle_response(self, return_code, response): + if return_code != 0: + print(f"Failed to query current time ({return_code})") + return + + r = response + v = r.version + print(f"\tApplication: 0x{r.application_id:08x}") + print(f"\t Version: {v.major}.{v.minor}.{v.revision}+{v.build_num:08x}") + print(f"\t Network: 0x{r.network_id:08x}") + print(f"\t Uptime: {r.uptime}") + print(f"\t Reboots: {r.reboots}") + print(f"\t KV CRC: 0x{r.kv_crc:08x}") + print(f"\t O Blocks: {r.data_blocks_internal}") + print(f"\t E Blocks: {r.data_blocks_external}") diff --git a/src/infuse_iot/rpc_wrappers/bt_connect_infuse.py b/src/infuse_iot/rpc_wrappers/bt_connect_infuse.py index 24bbdc5..3e4c000 100644 --- a/src/infuse_iot/rpc_wrappers/bt_connect_infuse.py +++ b/src/infuse_iot/rpc_wrappers/bt_connect_infuse.py @@ -45,6 +45,11 @@ def add_parser(cls, parser): parser.add_argument( "--data", action="store_true", help="Subscribe to data characteristic" ) + parser.add_argument( + "--logging", + action="store_true", + help="Subscribe to serial logging characteristic", + ) addr_group = parser.add_mutually_exclusive_group(required=True) addr_group.add_argument( "--public", type=BtLeAddress, help="Public Bluetooth address" @@ -72,6 +77,8 @@ def request_struct(self): sub = rpc_enum_infuse_bt_characteristic.COMMAND if self.args.data: sub |= rpc_enum_infuse_bt_characteristic.DATA + if self.args.logging: + sub |= rpc_enum_infuse_bt_characteristic.LOGGING return self.request( peer, diff --git a/src/infuse_iot/rpc_wrappers/file_write_basic.py b/src/infuse_iot/rpc_wrappers/file_write_basic.py index 0a5095a..319109f 100644 --- a/src/infuse_iot/rpc_wrappers/file_write_basic.py +++ b/src/infuse_iot/rpc_wrappers/file_write_basic.py @@ -58,6 +58,13 @@ def add_parser(cls, parser): const=rpc_enum_file_action.APP_IMG, help="Write complete image file and perform DFU", ) + group.add_argument( + "--cpatch", + dest="action", + action="store_const", + const=rpc_enum_file_action.APP_CPATCH, + help="Write complete image file and perform DFU", + ) group.add_argument( "--bt-ctlr-dfu", dest="action", diff --git a/src/infuse_iot/tools/gateway.py b/src/infuse_iot/tools/gateway.py index e32e673..5ab8c32 100644 --- a/src/infuse_iot/tools/gateway.py +++ b/src/infuse_iot/tools/gateway.py @@ -200,6 +200,9 @@ def _handle_serial_frame(self, frame): # Handle any Memfault chunks if pkt.ptype == InfuseType.MEMFAULT_CHUNK: self._handle_memfault_pkt(pkt) + # Proactively requery keys + elif pkt.ptype == InfuseType.KEY_IDS: + self._common.query_device_key(None) # Forward to clients self._common.server.broadcast(pkt) except (ValueError, KeyError) as e: diff --git a/src/infuse_iot/tools/localhost.py b/src/infuse_iot/tools/localhost.py new file mode 100644 index 0000000..6d37aa8 --- /dev/null +++ b/src/infuse_iot/tools/localhost.py @@ -0,0 +1,215 @@ +#!/usr/bin/env python3 + +"""Run a local server for TDF viewing""" + +__author__ = "Jordan Yates" +__copyright__ = "Copyright 2024, Embeint Inc" + +import asyncio +import ctypes +import json +import pathlib +import threading +import time +from aiohttp import web +from aiohttp.web_runner import GracefulExit + +from infuse_iot.epacket import InfuseType, Interface +from infuse_iot.commands import InfuseCommand +from infuse_iot.socket_comms import LocalClient, default_multicast_address +from infuse_iot.tdf import TDF +from infuse_iot.time import InfuseTime + + +class SubCommand(InfuseCommand): + NAME = "localhost" + HELP = "Run a local server for TDF viewing" + DESCRIPTION = "Run a local server for TDF viewing" + + def __init__(self, _): + self._data_lock = threading.Lock() + self._columns = {} + self._data = {} + + self._thread_end = threading.Event() + self._client = LocalClient(default_multicast_address(), 1.0) + self._decoder = TDF() + + # Serve the HTML file + async def handle_index(self, request): + this_folder = pathlib.Path(__file__).parent + + return web.FileResponse(this_folder / "localhost" / "index.html") + + async def websocket_handler(self, request): + ws = web.WebSocketResponse() + await ws.prepare(request) + + try: + while True: + # Example data sent to the client + self._data_lock.acquire(blocking=True) + columns = [ + { + "title": "Metadata", + "headerHozAlign": "center", + "frozen": True, + "columns": [ + { + "title": "Device", + "field": "infuse_id", + "headerHozAlign": "center", + }, + { + "title": "Last Heard", + "field": "time", + "headerHozAlign": "center", + }, + { + "title": "Bluetooth", + "headerHozAlign": "center", + "columns": [ + { + "title": "Address", + "field": "bt_addr", + "headerHozAlign": "center", + }, + { + "title": "RSSI (dBm)", + "field": "bt_rssi", + "headerVertical": "flip", + "hozAlign": "right", + }, + ], + }, + ], + } + ] + for tdf_name in sorted(self._columns): + columns.append( + { + "title": tdf_name, + "field": tdf_name, + "columns": self._columns[tdf_name], + "headerHozAlign": "center", + } + ) + devices = sorted(self._data.keys()) + message = { + "columns": columns, + "rows": [self._data[d] for d in devices], + "tdfs": list(self._columns.keys()), + } + self._data_lock.release() + + await ws.send_str(json.dumps(message)) + await asyncio.sleep(1) + except asyncio.CancelledError: + print("WebSocket connection closed") + finally: + await ws.close() + + return ws + + def tdf_columns(self, tdf): + out = [] + for field in tdf._fields_: + if field[0][0] == "_": + f_name = field[0][1:] + else: + f_name = field[0] + val = getattr(tdf, f_name) + if isinstance(val, ctypes.LittleEndianStructure): + c = [] + for subfield_name, _, postfix, _ in val.iter_fields(): + if postfix != "": + title = f"{subfield_name} ({postfix})" + else: + title = subfield_name + c.append( + { + "title": title, + "field": f"{tdf.name}.{f_name}.{subfield_name}", + "headerVertical": "flip", + "hozAlign": "right", + } + ) + s = {"title": f_name, "headerHozAlign": "center", "columns": c} + else: + if tdf._postfix_[f_name] != "": + title = f"{f_name} ({tdf._postfix_[f_name]})" + else: + title = f_name + + s = { + "title": title, + "field": f"{tdf.name}.{f_name}", + "headerVertical": "flip", + "hozAlign": "right", + } + out.append(s) + return out + + def recv_thread(self): + while True: + msg = self._client.receive() + if self._thread_end.is_set(): + break + if msg is None: + continue + if msg.ptype != InfuseType.TDF: + continue + + decoded = self._decoder.decode(msg.payload) + source = msg.route[0] + + self._data_lock.acquire(blocking=True) + + if source.infuse_id not in self._data: + self._data[source.infuse_id] = { + "infuse_id": f"0x{source.infuse_id:016x}", + } + self._data[source.infuse_id]["time"] = InfuseTime.utc_time_string( + time.time() + ) + if source.interface == Interface.BT_ADV: + addr_bytes = source.interface_address.val.addr_val.to_bytes(6, "big") + addr_str = ":".join([f"{x:02x}" for x in addr_bytes]) + self._data[source.infuse_id]["bt_addr"] = addr_str + self._data[source.infuse_id]["bt_rssi"] = source.rssi + + for tdf in decoded: + t = tdf.data[-1] + if t.name not in self._columns: + self._columns[t.name] = self.tdf_columns(t) + + for n, f, _, d in t.iter_fields(): + f = d.format(f) + if t.name not in self._data[source.infuse_id]: + self._data[source.infuse_id][t.name] = {} + if "." in n: + struct, sub = n.split(".") + if struct not in self._data[source.infuse_id][t.name]: + self._data[source.infuse_id][t.name][struct] = {} + self._data[source.infuse_id][t.name][struct][sub] = f + + self._data[source.infuse_id][t.name][n] = f + + self._data_lock.release() + + def run(self): + app = web.Application() + # Route for serving the HTML file + app.router.add_get("/", self.handle_index) + # Route for WebSocket + app.router.add_get("/ws", self.websocket_handler) + + rx_thread = threading.Thread(target=self.recv_thread) + rx_thread.start() + + # Run server + try: + web.run_app(app, host="localhost", port=8080) + except GracefulExit: + self._thread_end.set() + rx_thread.join(1.0) diff --git a/src/infuse_iot/tools/localhost/index.html b/src/infuse_iot/tools/localhost/index.html new file mode 100644 index 0000000..6713d8b --- /dev/null +++ b/src/infuse_iot/tools/localhost/index.html @@ -0,0 +1,114 @@ + + + + + + + TDF Viewer + + + + + + +

Infuse-IoT TDF Viewer

+

+

+

+

+

Table Control

+
+

+ + + + + diff --git a/src/infuse_iot/tools/provision.py b/src/infuse_iot/tools/provision.py index 9b58af1..e17b691 100644 --- a/src/infuse_iot/tools/provision.py +++ b/src/infuse_iot/tools/provision.py @@ -24,7 +24,7 @@ get_boards, get_board_by_id, ) -from infuse_iot.api_client.models import NewDevice +from infuse_iot.api_client.models import NewDevice, NewDeviceMetadata from infuse_iot.credentials import get_api_key from infuse_iot.commands import InfuseCommand @@ -52,7 +52,18 @@ def add_parser(cls, parser): parser.add_argument("--board", "-b", type=str, help="Board ID") parser.add_argument("--organisation", "-o", type=str, help="Organisation ID") parser.add_argument( - "--id", "-i", type=int, help="Infuse device ID to provision as" + "--id", + "-i", + type=lambda x: int(x, 0), + help="Infuse device ID to provision as", + ) + parser.add_argument( + "--metadata", + "-m", + metavar="KEY=VALUE", + nargs="+", + type=str, + help="Define a number of key-value pairs for metadata", ) def __init__(self, args): @@ -60,6 +71,11 @@ def __init__(self, args): self._board = args.board self._org = args.organisation self._id = args.id + self._metadata = {} + if args.metadata: + for meta in args.metadata: + key, val = meta.strip().split("=", 1) + self._metadata[key.strip()] = val def nrf_device_info(self, api: LowLevel.API) -> tuple[int, int]: """Retrive device ID and customer UICR address""" @@ -138,7 +154,10 @@ def create_device(self, client, soc, hardware_id_str): ) new_board = NewDevice( - mcu_id=hardware_id_str, organisation_id=self._org, board_id=self._board + mcu_id=hardware_id_str, + organisation_id=self._org, + board_id=self._board, + metadata=NewDeviceMetadata.from_dict(self._metadata), ) if self._id: new_board.device_id = f"{self._id:016x}" diff --git a/src/infuse_iot/tools/tdf_list.py b/src/infuse_iot/tools/tdf_list.py index ee6d55b..591c134 100644 --- a/src/infuse_iot/tools/tdf_list.py +++ b/src/infuse_iot/tools/tdf_list.py @@ -44,8 +44,7 @@ def run(self): name = t.name for idx, (n, f, p, d) in enumerate(t.iter_fields()): - if d is not None: - f = d(f) + f = d.format(f) if idx == 0: if tdf.time is not None: if tdf.period is None: