diff --git a/src/examples/api_tests.py b/src/examples/api_tests.py index 1075c3c..e6fd39f 100644 --- a/src/examples/api_tests.py +++ b/src/examples/api_tests.py @@ -78,6 +78,9 @@ async def run_api_tests(argv: Sequence[str]) -> None: # noqa: PLR0915 condition = await api.get_watch_condition() logger.info(f"condition: {condition}") + lifelog_steps = await api.get_lifelog_steps() + logger.info(f"lifelog steps: {lifelog_steps}") + settings_local = await api.get_basic_settings() logger.info(f"settings: {pformat(settings_local)}") diff --git a/src/gshock_api/connection.py b/src/gshock_api/connection.py index fb17653..fc81eab 100644 --- a/src/gshock_api/connection.py +++ b/src/gshock_api/connection.py @@ -85,6 +85,14 @@ async def connect(self, watch_filter: WatchFilter = None) -> bool: CasioConstants.CASIO_ALL_FEATURES_CHARACTERISTIC_UUID, self.notification_handler, ) + await self.client.start_notify( + CasioConstants.CASIO_DATA_REQUEST_SP_CHARACTERISTIC_UUID, + lambda _c, data: message_dispatcher.MessageDispatcher.on_drsp_received(data), + ) + await self.client.start_notify( + CasioConstants.CASIO_CONVOY_CHARACTERISTIC_UUID, + lambda _c, data: message_dispatcher.MessageDispatcher.on_convoy_received(data), + ) return True @@ -118,7 +126,8 @@ async def write(self, handle: int, data: bytes) -> None: return # 0x0E is CASIO_ALL_FEATURES_CHARACTERISTIC_UUID (requires response) - response_type: bool = handle == 0x0E + # 0x11 is CASIO_DATA_REQUEST_SP (lifelog needs response) + response_type: bool = handle in (0x0E, 0x11) cmd_data: bytes = to_casio_cmd(data) @@ -157,4 +166,4 @@ def init_handles_map(self) -> HandleMap: # Replaced Any with TypeVar T async def send_message(self, message: T) -> None: """Sends a message to the watch using the message dispatcher.""" - await message_dispatcher.MessageDispatcher.send_to_watch(message) \ No newline at end of file + await message_dispatcher.MessageDispatcher.send_to_watch(message) diff --git a/src/gshock_api/gshock_api.py b/src/gshock_api/gshock_api.py index d7f70f4..deff5db 100644 --- a/src/gshock_api/gshock_api.py +++ b/src/gshock_api/gshock_api.py @@ -11,6 +11,7 @@ from gshock_api.iolib.app_notification_io import AppNotificationIO from gshock_api.iolib.button_pressed_io import WatchButton from gshock_api.iolib.dst_watch_state_io import DtsState +from gshock_api.iolib.lifelog_io import LifelogIO from gshock_api.utils import ( to_compact_string, to_hex_string, @@ -191,6 +192,10 @@ async def get_watch_condition(self) -> object: result: object = await message_dispatcher.WatchConditionIO.request(self.connection) return result + async def get_lifelog_steps(self) -> int: + """Get lifelog steps count.""" + return await LifelogIO.request(self.connection) + async def get_time_adjustment(self) -> bool: """Determine if auto-tame adjustment is set or not""" # Assuming TimeAdjustmentIO.request returns a boolean diff --git a/src/gshock_api/iolib/lifelog_io.py b/src/gshock_api/iolib/lifelog_io.py new file mode 100644 index 0000000..d1acf57 --- /dev/null +++ b/src/gshock_api/iolib/lifelog_io.py @@ -0,0 +1,93 @@ +from __future__ import annotations + +import struct + +from gshock_api.cancelable_result import CancelableResult +from gshock_api.iolib.connection_protocol import ConnectionProtocol +from gshock_api.utils import to_compact_string, to_hex_string + + +class LifelogIO: + """Handles lifelog transfer over Data Request SP + Convoy.""" + + _connection: ConnectionProtocol | None = None + _result: CancelableResult[int] | None = None + _buffer: bytearray = bytearray() + _expected_len: int | None = None + + # ABL-100: steps appear at this offset in the 400-byte payload. + _STEPS_OFFSET = 374 + + @staticmethod + async def request(connection: ConnectionProtocol, timeout: float = 60.0) -> int: + """Start lifelog transfer and return steps.""" + LifelogIO._connection = connection + LifelogIO._buffer = bytearray() + LifelogIO._expected_len = None + LifelogIO._result = CancelableResult[int](timeout=timeout) + + # DRSP start: command=0x00, category=0x11, length=0 (24-bit LE) + start_cmd = bytes([0x00, 0x11, 0x00, 0x00, 0x00]) + await connection.write(0x11, to_compact_string(to_hex_string(start_cmd))) + + return await LifelogIO._result.get_result() + + @staticmethod + def on_drsp_received(data: bytes) -> None: + """Handle Data Request SP notification/indication.""" + if len(data) < 5: + return + + command = data[0] + category = data[1] + if category != 0x11: + return + + length = data[2] | (data[3] << 8) | (data[4] << 16) + if command == 0x00: + LifelogIO._expected_len = length + + if command == 0x04: + # End transaction from watch + LifelogIO._finalize_if_ready() + + @staticmethod + def on_convoy_received(data: bytes) -> None: + """Handle Convoy notification payload.""" + if not data: + return + + LifelogIO._buffer.extend(data) + if LifelogIO._expected_len is not None and len(LifelogIO._buffer) >= LifelogIO._expected_len: + # DRSP end: command=0x04, category=0x11, length=0 + if LifelogIO._connection is not None: + end_cmd = bytes([0x04, 0x11, 0x00, 0x00, 0x00]) + # Best effort; failures should not block parsing. + try: + # fire-and-forget; connection.write is async, but we can't await here + # callers should rely on finalization below + import asyncio + + asyncio.create_task( + LifelogIO._connection.write( + 0x11, to_compact_string(to_hex_string(end_cmd)) + ) + ) + except Exception: + pass + LifelogIO._finalize_if_ready() + + @staticmethod + def _finalize_if_ready() -> None: + if LifelogIO._result is None: + return + + if LifelogIO._expected_len is None: + return + + if len(LifelogIO._buffer) < LifelogIO._expected_len: + return + + if len(LifelogIO._buffer) >= LifelogIO._STEPS_OFFSET + 4: + steps = struct.unpack_from(" None: else: MessageDispatcher.data_received_messages[key](data) + @staticmethod + def on_drsp_received(data: bytes) -> None: + """Handles Data Request SP notifications (lifelog).""" + LifelogIO.on_drsp_received(data) + + @staticmethod + def on_convoy_received(data: bytes) -> None: + """Handles Convoy notifications (lifelog).""" + LifelogIO.on_convoy_received(data) + # Usage example (unchanged logic) if __name__ == "__main__": # Simulated messages @@ -135,4 +146,4 @@ def on_received(data: bytes) -> None: # This line is just for illustration in the if __name__ == "__main__" block # MessageDispatcher.send_to_watch(sample_message) # noqa: ERA001 # MessageDispatcher.on_received(sample_data) # noqa: ERA001 - pass \ No newline at end of file + pass