Skip to content

Commit e6a9fcb

Browse files
committed
tools: annotate_events: added
Added annotation tool that can be used to log events. Configure whether the tool should update the tag's time. Supply a label file (or labels at runtime) and then select a label from the list to annotate the vent. Otherwise, type a custom event label to be sent to the tag. usage `infuse annotate_events --help` for more details. Signed-off-by: Aeyohan Furtado <aeyohan@embeint.com>
1 parent 556f846 commit e6a9fcb

1 file changed

Lines changed: 309 additions & 0 deletions

File tree

Lines changed: 309 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,309 @@
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: int | bool = (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+
update, _ = 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(update)
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

Comments
 (0)