Skip to content

Commit 593565b

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 ab37aaf commit 593565b

1 file changed

Lines changed: 302 additions & 0 deletions

File tree

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

0 commit comments

Comments
 (0)