|
| 1 | +#!/usr/bin/env python3 |
| 2 | + |
| 3 | +"""Annotate events on Infuse Tags""" |
| 4 | + |
| 5 | +__author__ = "Aeyohan Furtado" |
| 6 | +__copyright__ = "Copyright 2026, Embeint Holdings Pty Ltd" |
| 7 | + |
| 8 | +import enum |
| 9 | +import json |
| 10 | +import signal |
| 11 | +import sys |
| 12 | +from datetime import datetime |
| 13 | +from pathlib import Path |
| 14 | +from threading import Thread |
| 15 | + |
| 16 | +from rich.live import Live |
| 17 | +from rich.status import Status |
| 18 | + |
| 19 | +from infuse_iot.commands import InfuseCommand |
| 20 | +from infuse_iot.epacket.packet import Auth |
| 21 | +from infuse_iot.generated.rpc_definitions import annotate, rpc_enum_data_logger, time_get, time_set |
| 22 | +from infuse_iot.rpc_client import RpcClient |
| 23 | +from infuse_iot.rpc_wrappers.annotate import annotate as annotate_wrapper |
| 24 | +from infuse_iot.socket_comms import ( |
| 25 | + ClientNotificationConnectionDropped, |
| 26 | + GatewayRequestConnectionRequest, |
| 27 | + LocalClient, |
| 28 | + default_multicast_address, |
| 29 | +) |
| 30 | +from infuse_iot.time import InfuseTime |
| 31 | +from infuse_iot.util.console import choose_one |
| 32 | +from infuse_iot.zephyr.errno import errno |
| 33 | + |
| 34 | + |
| 35 | +class LabelType(enum.Enum): |
| 36 | + CUSTOM = "custom" |
| 37 | + MANUAL = "manual" |
| 38 | + |
| 39 | +class TimeCheckType(enum.Enum): |
| 40 | + NONE = "none" |
| 41 | + FORCE = "force" |
| 42 | + AUTO = "auto" |
| 43 | + DEFAULT = "default" |
| 44 | + |
| 45 | +class SubCommand(InfuseCommand): |
| 46 | + NAME = "annotate_events" |
| 47 | + HELP = "Annotate events on Infuse Tags" |
| 48 | + DESCRIPTION = "Save labelled event annotations live on Infuse Tags" |
| 49 | + |
| 50 | + _label_type: LabelType | Path |
| 51 | + _labels: list[str] |
| 52 | + _time_check: TimeCheckType |
| 53 | + _tag_unix_time: float | None |
| 54 | + _time_of_sync: datetime | None |
| 55 | + |
| 56 | + @staticmethod |
| 57 | + def test_config_json(raw: str) -> Path: |
| 58 | + path = Path(raw) |
| 59 | + if not path.exists(): |
| 60 | + sys.exit(f"Config file '{raw}' does not exist") |
| 61 | + if not path.is_file(): |
| 62 | + sys.exit(f"Config file '{raw}' is not a file") |
| 63 | + return path |
| 64 | + |
| 65 | + @classmethod |
| 66 | + def add_parser(cls, parser): |
| 67 | + # Logger Selection parameters. |
| 68 | + logger_parser = parser.add_mutually_exclusive_group(required=True) |
| 69 | + logger_parser.add_argument("--onboard", dest="logger", action="store_const", |
| 70 | + const=rpc_enum_data_logger.FLASH_ONBOARD) |
| 71 | + logger_parser.add_argument("--external", dest="logger", action="store_const", |
| 72 | + const=rpc_enum_data_logger.FLASH_REMOVABLE) |
| 73 | + logger_parser.add_argument("--logger", "-l", type=annotate_wrapper.parse_logger, |
| 74 | + help="TDF Data Logger to write the event to") |
| 75 | + |
| 76 | + # Label selection parameters. |
| 77 | + label_group = parser.add_mutually_exclusive_group(required=True) |
| 78 | + label_group.add_argument( |
| 79 | + "--preset-labels", "-p", dest="labels", type=cls.test_config_json, |
| 80 | + help="JSON file containing labels" |
| 81 | + ) |
| 82 | + label_group.add_argument( |
| 83 | + "--custom-labels", "-c", dest="labels", action="store_const", const=LabelType.CUSTOM, |
| 84 | + help="Specify custom labels at runtime" |
| 85 | + ) |
| 86 | + label_group.add_argument( |
| 87 | + "--manual-labels", "-m", dest="labels", action="store_const", const=LabelType.MANUAL, |
| 88 | + help="Manually enter labels for each event" |
| 89 | + ) |
| 90 | + |
| 91 | + # Time sync parameters. |
| 92 | + time_group = parser.add_mutually_exclusive_group() |
| 93 | + time_group.add_argument( |
| 94 | + "--force-time", "-f", dest="time", action="store_const", const=TimeCheckType.FORCE, |
| 95 | + help="Forcibly update the tag's time before writing annotations" |
| 96 | + ) |
| 97 | + time_group.add_argument( |
| 98 | + "--auto-time", "-a", dest="time", action="store_const", const=TimeCheckType.AUTO, |
| 99 | + help="Automatically update the tag's time if it is not current" |
| 100 | + ) |
| 101 | + time_group.add_argument( |
| 102 | + "--skip-time", "-s", dest="time", action="store_const", const=TimeCheckType.NONE, |
| 103 | + help="Do not update the tag's time before writing annotations" |
| 104 | + ) |
| 105 | + |
| 106 | + parser.add_argument("--id", type=lambda x: int(x, 0), help="Device to log events to") |
| 107 | + |
| 108 | + def __init__(self, args): |
| 109 | + self._label_type = args.labels |
| 110 | + self._time_check = args.time or TimeCheckType.DEFAULT |
| 111 | + |
| 112 | + if isinstance(self._label_type, Path): |
| 113 | + with self._label_type.open() as f: |
| 114 | + try: |
| 115 | + # Use a dict to remove duplicates while preserving order (set does not). |
| 116 | + self._labels = list({v: v for v in json.load(f)}.values()) |
| 117 | + for label in self._labels: |
| 118 | + # Ensure each label is a string. |
| 119 | + if not isinstance(label, str): |
| 120 | + sys.exit(f"Labels must be strings. '{label}' in config file is not.") |
| 121 | + except json.JSONDecodeError as e: |
| 122 | + sys.exit(f"Failed to parse labels from '{self._label_type}': {e}") |
| 123 | + elif self._label_type == LabelType.CUSTOM: |
| 124 | + # Prompt user for labels. |
| 125 | + print("Enter events label, one per line (or leave empty to finish):") |
| 126 | + self._labels = [] |
| 127 | + while True: |
| 128 | + label = input("> ").strip() |
| 129 | + if not label: |
| 130 | + if not self._labels: |
| 131 | + print("At least one label must be entered") |
| 132 | + continue |
| 133 | + break |
| 134 | + self._labels.append(label) |
| 135 | + else: |
| 136 | + # Manual label entry mode, no pre-defined labels. |
| 137 | + self._labels = [] |
| 138 | + |
| 139 | + self._logger: rpc_enum_data_logger = args.logger |
| 140 | + self._client = LocalClient(default_multicast_address(), 1.0) |
| 141 | + self._device_id = args.id |
| 142 | + self.rpc_client: RpcClient | None = None |
| 143 | + self.connected = False |
| 144 | + self.complete = False |
| 145 | + |
| 146 | + def get_tags_current_gps_time(self) -> float: |
| 147 | + now = datetime.now() |
| 148 | + assert self._time_of_sync is not None |
| 149 | + assert self._tag_unix_time is not None |
| 150 | + elapsed = now - self._time_of_sync |
| 151 | + return InfuseTime.gps_seconds_from_unix(int(self._tag_unix_time + elapsed.total_seconds())) |
| 152 | + |
| 153 | + def load_tag_time(self): |
| 154 | + params = time_get.request() |
| 155 | + sync_request_sent = datetime.now() |
| 156 | + assert self.rpc_client is not None |
| 157 | + hdr, rsp = self.rpc_client.run_standard_cmd( |
| 158 | + time_get.COMMAND_ID, |
| 159 | + Auth.DEVICE, |
| 160 | + bytes(params), |
| 161 | + time_get.response.from_buffer_copy |
| 162 | + ) |
| 163 | + sync_response_received = datetime.now() |
| 164 | + |
| 165 | + if hdr is None: |
| 166 | + raise RuntimeError("Failed to get time from tag") |
| 167 | + if hdr.return_code != 0: |
| 168 | + raise RuntimeError(f"Error getting time from tag ({hdr.return_code}): " |
| 169 | + f"{errno.strerror(-hdr.return_code)}") |
| 170 | + |
| 171 | + assert isinstance(rsp, time_get.response) |
| 172 | + time_response: time_get.response = rsp |
| 173 | + self._tag_unix_time = InfuseTime.unix_time_from_epoch(time_response.epoch_time) |
| 174 | + self._time_of_sync = sync_request_sent + (sync_response_received - sync_request_sent) / 2 |
| 175 | + |
| 176 | + def check_tag_needs_sync(self) -> bool: |
| 177 | + assert self._tag_unix_time is not None |
| 178 | + assert self._time_of_sync is not None |
| 179 | + if self._time_check in [TimeCheckType.AUTO, TimeCheckType.DEFAULT]: |
| 180 | + # Update the tag time if it exceeds 1 minute of the current time. |
| 181 | + tag_datetime_now = datetime.fromtimestamp(self._tag_unix_time) |
| 182 | + update = (self._time_of_sync - tag_datetime_now).total_seconds() > 60 |
| 183 | + if update and self._time_check == TimeCheckType.DEFAULT: |
| 184 | + # Tag is out of sync with current time. Check if it needs to be updated. |
| 185 | + try: |
| 186 | + selection, _ = choose_one( |
| 187 | + f"Tag's clock is out of sync. Update the tag's time?\n" |
| 188 | + f"Tag: {tag_datetime_now}\n" |
| 189 | + f"System: {self._time_of_sync}", |
| 190 | + ["Yes", "No"] |
| 191 | + ) |
| 192 | + update = not bool(selection) |
| 193 | + except IndexError: |
| 194 | + update = False |
| 195 | + return update |
| 196 | + return self._time_check == TimeCheckType.FORCE |
| 197 | + |
| 198 | + def sync_tag_time(self): |
| 199 | + # Update the tag's time to the current time. |
| 200 | + now = datetime.now().timestamp() |
| 201 | + params = time_set.request( |
| 202 | + InfuseTime.epoch_time_from_unix(now) |
| 203 | + ) |
| 204 | + |
| 205 | + sync_request_sent = datetime.now() |
| 206 | + assert self.rpc_client is not None |
| 207 | + hdr, _ = self.rpc_client.run_standard_cmd( |
| 208 | + time_set.COMMAND_ID, |
| 209 | + Auth.DEVICE, |
| 210 | + bytes(params), |
| 211 | + time_set.response.from_buffer_copy |
| 212 | + ) |
| 213 | + |
| 214 | + sync_response_received = datetime.now() |
| 215 | + if hdr is None: |
| 216 | + raise RuntimeError("Failed to set time on tag") |
| 217 | + if hdr.return_code != 0: |
| 218 | + raise RuntimeError(f"Error setting time on tag ({hdr.return_code}): " |
| 219 | + f"{errno.strerror(-hdr.return_code)}") |
| 220 | + |
| 221 | + # Update sync point to reflect new time on tag, assuming the tag's time doesn't change for |
| 222 | + # the duration of the connection. |
| 223 | + self._time_of_sync = sync_request_sent + (sync_response_received - sync_request_sent) / 2 |
| 224 | + self._tag_unix_time = now |
| 225 | + |
| 226 | + def draw_connecting(self): |
| 227 | + return Status(f"Connecting to {self._device_id:016x}...\n") |
| 228 | + |
| 229 | + def connection_listener(self): |
| 230 | + # Listen for incoming events in case the connection is dropped |
| 231 | + while not self.complete: |
| 232 | + # Wait till connection is established. This prevents dropping the connected event. |
| 233 | + if not self.connected: |
| 234 | + continue |
| 235 | + evt = self._client.receive() |
| 236 | + if evt is None: |
| 237 | + continue |
| 238 | + if isinstance(evt, ClientNotificationConnectionDropped) and \ |
| 239 | + evt.infuse_id == self._device_id and not self.complete: |
| 240 | + # Ensure the connection wasn't caused by the script existing. |
| 241 | + print("\n" * (len(self._labels))) # Clear any pending input lines |
| 242 | + print(f"Lost connection to {self._device_id:016x}") |
| 243 | + self.complete = True |
| 244 | + # Need to use SIGTERM to interrupt the main thread's input() call |
| 245 | + # Couldn't get KeyboardInterrupt to trigger on main thread. |
| 246 | + signal.raise_signal(signal.SIGTERM) |
| 247 | + |
| 248 | + def run(self): |
| 249 | + if not self._client.comms_check(): |
| 250 | + sys.exit("No communications gateway detected (infuse gateway/bt_native)") |
| 251 | + |
| 252 | + cl = Thread(target=self.connection_listener, daemon=True) |
| 253 | + cl.start() |
| 254 | + |
| 255 | + while not self.complete: |
| 256 | + with Live(self.draw_connecting(), refresh_per_second=4) as live, \ |
| 257 | + self._client.connection( |
| 258 | + self._device_id, GatewayRequestConnectionRequest.DataType.COMMAND |
| 259 | + ) as mtu: |
| 260 | + self.connected = True |
| 261 | + live.transient = True |
| 262 | + live.stop() |
| 263 | + print(f"Connected to {self._device_id:016x}") |
| 264 | + self.rpc_client = RpcClient(self._client, mtu, self._device_id) |
| 265 | + |
| 266 | + # On connection, check the tag's current time & sync if required. |
| 267 | + self.load_tag_time() |
| 268 | + if self.check_tag_needs_sync(): |
| 269 | + self.sync_tag_time() |
| 270 | + |
| 271 | + while True: |
| 272 | + if self._label_type == LabelType.MANUAL: |
| 273 | + # Prompt user for label for each event |
| 274 | + try: |
| 275 | + label = input("Enter event label (or leave empty to exit): ").strip() |
| 276 | + except KeyboardInterrupt as e: |
| 277 | + # End current line and exit gracefully on Ctrl+C |
| 278 | + print() |
| 279 | + raise e |
| 280 | + if not label: |
| 281 | + self.complete = True |
| 282 | + break |
| 283 | + else: |
| 284 | + # Let the user select from their predefined labels |
| 285 | + try: |
| 286 | + _, label = choose_one("Select an event:", [*self._labels, "Exit"]) |
| 287 | + except IndexError: |
| 288 | + label = "Exit" |
| 289 | + if label == "Exit": |
| 290 | + self.complete = True |
| 291 | + break |
| 292 | + |
| 293 | + timestamp = self.get_tags_current_gps_time() |
| 294 | + now = datetime.now() |
| 295 | + params = annotate_wrapper.annotate_value_factory(self._logger, timestamp, label) |
| 296 | + |
| 297 | + hdr, _ = self.rpc_client.run_standard_cmd( |
| 298 | + annotate.COMMAND_ID, |
| 299 | + Auth.DEVICE, |
| 300 | + bytes(params), |
| 301 | + annotate.response.from_buffer_copy |
| 302 | + ) |
| 303 | + |
| 304 | + if hdr is None: |
| 305 | + print("Failed to send annotation event to tag") |
| 306 | + continue |
| 307 | + annotate_wrapper.handle_response_generic( |
| 308 | + hdr.return_code, self._logger, now, label |
| 309 | + ) |
0 commit comments