From 4518255231c1af751202684b0d5b4d4138f385b9 Mon Sep 17 00:00:00 2001 From: Jordan Yates Date: Tue, 5 Aug 2025 20:29:48 +1000 Subject: [PATCH 1/4] tools: rpc_cloud: fallback to raw parameters Use the new `params_encoded` option to fallback to sending binary parameters if the RPC command doesn't support json args. Signed-off-by: Jordan Yates --- src/infuse_iot/tools/rpc_cloud.py | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/src/infuse_iot/tools/rpc_cloud.py b/src/infuse_iot/tools/rpc_cloud.py index 8f7e02f..3906e23 100644 --- a/src/infuse_iot/tools/rpc_cloud.py +++ b/src/infuse_iot/tools/rpc_cloud.py @@ -67,11 +67,17 @@ def queue(self, client: Client): assert hasattr(command, "COMMAND_ID") try: + # Get the human readable arguments if implementated params = RPCParams.from_dict(command.request_json()) + rpc_req = NewRPCReq(command_id=command.COMMAND_ID, params=params) except NotImplementedError: - sys.exit(f"Command '{command.__class__.__name__}' has not implemented cloud support") - req = NewRPCMessage(infuse_id, NewRPCReq(command.COMMAND_ID, params=params), timeout_ms) - rsp = send_rpc.sync(client=client, body=req) + # Otherwise, encode the raw binary struct + struct_bytes = bytes(command.request_struct()) + params_encoded = base64.b64encode(struct_bytes).decode("utf-8") + rpc_req = NewRPCReq(command_id=command.COMMAND_ID, params_encoded=params_encoded) + + rpc_msg = NewRPCMessage(infuse_id, rpc_req, timeout_ms) + rsp = send_rpc.sync(client=client, body=rpc_msg) if isinstance(rsp, Error) or rsp is None: sys.exit(f"Failed to queue RPC ({rsp})") print(f"Queued RPC ID: {rsp.id}") From fd65ba55ddbc88caa2f36aa4193c402762345531 Mon Sep 17 00:00:00 2001 From: Jordan Yates Date: Tue, 5 Aug 2025 20:28:33 +1000 Subject: [PATCH 2/4] rpc_wrappers: coap_download: query length and CRC Query the length and CRC of the specified file from the Infuse API. Signed-off-by: Jordan Yates --- src/infuse_iot/rpc_wrappers/coap_download.py | 43 +++++++++++++++++--- 1 file changed, 37 insertions(+), 6 deletions(-) diff --git a/src/infuse_iot/rpc_wrappers/coap_download.py b/src/infuse_iot/rpc_wrappers/coap_download.py index 45c79a2..053869d 100644 --- a/src/infuse_iot/rpc_wrappers/coap_download.py +++ b/src/infuse_iot/rpc_wrappers/coap_download.py @@ -1,27 +1,57 @@ #!/usr/bin/env python3 import ctypes +import sys +from http import HTTPStatus +from json import loads import infuse_iot.generated.rpc_definitions as defs +from infuse_iot.api_client import Client +from infuse_iot.api_client.api.coap import get_coap_file_stats from infuse_iot.commands import InfuseRpcCommand +from infuse_iot.credentials import get_api_key from infuse_iot.generated.rpc_definitions import rpc_enum_file_action from infuse_iot.util.ctypes import UINT32_MAX from infuse_iot.zephyr.errno import errno +def coap_server_file_stats(server: str, resource: str) -> tuple[int, int]: + if server == coap_download.INFUSE_COAP_SERVER_ADDR: + # Validate file prefix + if not resource.startswith("file/"): + sys.exit("Infuse-IoT COAP files start with 'file/'") + api_filename = resource.removeprefix("file/") + # Get COAP file information + client = Client(base_url="https://api.infuse-iot.com").with_headers({"x-api-key": f"Bearer {get_api_key()}"}) + with client as client: + response = get_coap_file_stats.sync_detailed(client=client, filename=api_filename) + decoded = loads(response.content.decode("utf-8")) + if response.status_code != HTTPStatus.OK: + sys.exit(f"<{response.status_code}>: {decoded['message']}") + return (decoded["len"], decoded["crc"]) + else: + # Unknown, let the COAP download automatically determine + # This does mean that duplicate file are not detected + print("Custom COAP server, duplicate file detection disabled") + return (UINT32_MAX, UINT32_MAX) + + class coap_download(InfuseRpcCommand, defs.coap_download): + INFUSE_COAP_SERVER_ADDR = "coap.dev.infuse-iot.com" + INFUSE_COAP_SERVER_PORT = 5684 + @classmethod def add_parser(cls, parser): parser.add_argument( "--server", type=str, - default="coap.dev.infuse-iot.com", + default=cls.INFUSE_COAP_SERVER_ADDR, help="COAP server name", ) parser.add_argument( "--port", type=int, - default=5684, + default=cls.INFUSE_COAP_SERVER_PORT, help="COAP server port", ) parser.add_argument( @@ -66,6 +96,7 @@ def __init__(self, args): self.port = args.port self.resource = args.resource.encode("utf-8") self.action = args.action + self.file_len, self.file_crc = coap_server_file_stats(args.server, args.resource) def request_struct(self): class request(ctypes.LittleEndianStructure): @@ -85,8 +116,8 @@ class request(ctypes.LittleEndianStructure): self.port, 2000, self.action, - UINT32_MAX, - UINT32_MAX, + self.file_len, + self.file_crc, self.resource, ) @@ -96,8 +127,8 @@ def request_json(self): "server_port": str(self.port), "block_timeout_ms": "2000", "action": self.action.name, - "resource_len": str(UINT32_MAX), - "resource_crc": str(UINT32_MAX), + "resource_len": str(self.file_len), + "resource_crc": str(self.file_crc), "resource": self.resource.decode("utf-8"), } From 5162e782c725a0857f40e47b3e4e15b4526fed2d Mon Sep 17 00:00:00 2001 From: Jordan Yates Date: Wed, 6 Aug 2025 21:24:55 +1000 Subject: [PATCH 3/4] rpc_wrappers: coap_download: support `FILE_FOR_COPY` Add support for the `FILE_FOR_COPY` action. Signed-off-by: Jordan Yates --- src/infuse_iot/rpc_wrappers/coap_download.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/infuse_iot/rpc_wrappers/coap_download.py b/src/infuse_iot/rpc_wrappers/coap_download.py index 053869d..aff3a40 100644 --- a/src/infuse_iot/rpc_wrappers/coap_download.py +++ b/src/infuse_iot/rpc_wrappers/coap_download.py @@ -90,6 +90,13 @@ def add_parser(cls, parser): const=rpc_enum_file_action.NRF91_MODEM_DIFF, help="nRF91 LTE modem diff upgrade", ) + group.add_argument( + "--for-copy", + dest="action", + action="store_const", + const=rpc_enum_file_action.FILE_FOR_COPY, + help="File to copy to other device", + ) def __init__(self, args): self.server = args.server.encode("utf-8") From b963ccce86ae86968dc6655a893d0755a6a2f79b Mon Sep 17 00:00:00 2001 From: Jordan Yates Date: Tue, 5 Aug 2025 20:31:38 +1000 Subject: [PATCH 4/4] rpc_wrappers: bt_file_copy_coap: add wrapper Add a wrapper for the OTA upgrade command `bt_file_copy_coap`. Signed-off-by: Jordan Yates --- src/infuse_iot/generated/rpc_definitions.py | 32 +++++ .../rpc_wrappers/bt_file_copy_coap.py | 120 ++++++++++++++++++ 2 files changed, 152 insertions(+) create mode 100644 src/infuse_iot/rpc_wrappers/bt_file_copy_coap.py diff --git a/src/infuse_iot/generated/rpc_definitions.py b/src/infuse_iot/generated/rpc_definitions.py index 267c5c8..89e303a 100644 --- a/src/infuse_iot/generated/rpc_definitions.py +++ b/src/infuse_iot/generated/rpc_definitions.py @@ -863,6 +863,38 @@ class response(VLACompatLittleEndianStruct): _pack_ = 1 +class bt_file_copy_coap: + """Copy a file fetched from COAP to a remote device over Bluetooth""" + + HELP = "Copy a file fetched from COAP to a remote device over Bluetooth" + DESCRIPTION = "Copy a file fetched from COAP to a remote device over Bluetooth" + COMMAND_ID = 53 + + class request(VLACompatLittleEndianStruct): + _fields_ = [ + ("peer", rpc_struct_bt_addr_le), + ("conn_timeout_ms", ctypes.c_uint16), + ("action", ctypes.c_uint8), + ("file_idx", ctypes.c_uint8), + ("ack_period", ctypes.c_uint8), + ("pipelining", ctypes.c_uint8), + ("server_address", 48 * ctypes.c_char), + ("server_port", ctypes.c_uint16), + ("block_timeout_ms", ctypes.c_uint16), + ("resource_len", ctypes.c_uint32), + ("resource_crc", ctypes.c_uint32), + ] + vla_field = ("resource", 0 * ctypes.c_char) + _pack_ = 1 + + class response(VLACompatLittleEndianStruct): + _fields_ = [ + ("resource_len", ctypes.c_uint32), + ("resource_crc", ctypes.c_uint32), + ] + _pack_ = 1 + + class gravity_reference_update: """Store the current accelerometer vector as the gravity reference""" diff --git a/src/infuse_iot/rpc_wrappers/bt_file_copy_coap.py b/src/infuse_iot/rpc_wrappers/bt_file_copy_coap.py new file mode 100644 index 0000000..8a14199 --- /dev/null +++ b/src/infuse_iot/rpc_wrappers/bt_file_copy_coap.py @@ -0,0 +1,120 @@ +#!/usr/bin/env python3 + +import ctypes + +import infuse_iot.generated.rpc_definitions as defs +from infuse_iot.commands import InfuseRpcCommand +from infuse_iot.generated.rpc_definitions import rpc_enum_bt_le_addr_type, rpc_enum_file_action, rpc_struct_bt_addr_le +from infuse_iot.rpc_wrappers.coap_download import coap_download, coap_server_file_stats +from infuse_iot.util.argparse import BtLeAddress +from infuse_iot.util.ctypes import bytes_to_uint8 +from infuse_iot.zephyr.errno import errno + + +class bt_file_copy_coap(InfuseRpcCommand, defs.bt_file_copy_coap): + @classmethod + def add_parser(cls, parser): + parser.add_argument( + "--server", + type=str, + default=coap_download.INFUSE_COAP_SERVER_ADDR, + help="COAP server name", + ) + parser.add_argument( + "--port", + type=int, + default=coap_download.INFUSE_COAP_SERVER_PORT, + help="COAP server port", + ) + parser.add_argument( + "--resource", + "-r", + type=str, + required=True, + help="Resource path", + ) + group = parser.add_mutually_exclusive_group(required=True) + group.add_argument( + "--discard", + dest="action", + action="store_const", + const=rpc_enum_file_action.DISCARD, + help="Download file and discard without action", + ) + group.add_argument( + "--dfu", + dest="action", + action="store_const", + const=rpc_enum_file_action.APP_IMG, + help="Download complete image file and perform DFU", + ) + group.add_argument( + "--cpatch", + dest="action", + action="store_const", + const=rpc_enum_file_action.APP_CPATCH, + help="Download CPatch binary diff and perform DFU", + ) + group.add_argument( + "--nrf91-modem", + dest="action", + action="store_const", + const=rpc_enum_file_action.NRF91_MODEM_DIFF, + help="nRF91 LTE modem diff upgrade", + ) + addr_group = parser.add_mutually_exclusive_group(required=True) + addr_group.add_argument("--public", type=BtLeAddress, help="Public Bluetooth address") + addr_group.add_argument("--random", type=BtLeAddress, help="Random Bluetooth address") + parser.add_argument("--conn-timeout", type=int, default=5000, help="Connection timeout (ms)") + parser.add_argument("--bt-pipelining", type=int, default=4, help="Bluetooth data pipelining") + + def __init__(self, args): + self.server = args.server.encode("utf-8") + self.port = args.port + self.resource = args.resource.encode("utf-8") + self.action = args.action + self.conn_timeout = args.conn_timeout + self.pipelining = args.bt_pipelining + if args.public: + self.peer = rpc_struct_bt_addr_le( + rpc_enum_bt_le_addr_type.PUBLIC, + bytes_to_uint8(args.public.to_bytes(6, "little")), + ) + else: + self.peer = rpc_struct_bt_addr_le( + rpc_enum_bt_le_addr_type.RANDOM, + bytes_to_uint8(args.random.to_bytes(6, "little")), + ) + self.file_len, self.file_crc = coap_server_file_stats(args.server, args.resource) + + def request_struct(self): + class request(ctypes.LittleEndianStructure): + _fields_ = [ + *self.request._fields_, + ("resource", (len(self.resource) + 1) * ctypes.c_char), + ] + _pack_ = 1 + + return request( + self.peer, + self.conn_timeout, + self.action, + 0, + 1, + self.pipelining, + self.server, + self.port, + 2000, + self.file_len, + self.file_crc, + self.resource, + ) + + def handle_response(self, return_code, response): + if return_code != 0: + print(f"Failed to download file ({errno.strerror(-return_code)})") + return + else: + print("File downloaded and copied") + print(f"\tLength: {response.resource_len}") + print(f"\t CRC: 0x{response.resource_crc:08x}")