Repository: TapWithUs/tap-python-sdk

Issue:
native raw sensor data scaling

Hints from issue comments:
@ilouzl: resolved with #29 

Solution patch:
diff --git a/History.md b/History.md
index 9383c50..7cbe87c 100644
--- a/History.md
+++ b/History.md
@@ -7,13 +7,9 @@ ______________________
 * Mac and Linux backends unified to posix backend.
 
 ### Known Issues
-* Windows backend -  
-    * Raw sensor data rate might be lower than expected.
-    * Sometimes a Tap strap wouldn't be detected upon connection. In this case try restarting your Tap and/or the Python application. In worst case scenario re-pair your Tap. 
-    * Spatial features are still not available for Windows backend.
-* MacOS & Linux backends - 
-    * Doesn't support multiple Tap strap connections.
-    * Raw sensor data is given unscaled (i.e. unitless), thereforein order to scale to physical units need to multiply by the relevant scale factor
+* Doesn't support multiple Tap strap connections.
+* Windows backend can only connect with a Tap that is not connected to the PC.
+
 
 ## 0.5.1 (2024-01-01)
 ______________________
diff --git a/Readme.md b/Readme.md
index f684931..d9726ca 100644
--- a/Readme.md
+++ b/Readme.md
@@ -85,9 +85,9 @@ For example
     from tapsdk import TapInputMode
     tap_device.set_input_mode(TapInputMode("controller"))
     ```  
-    Also, when instantiating a ```TapInputMode``` for raw sensors mode, additional argument ```sensitivity``` (list with 3 integers) is optional for example
+    Also, when instantiating a ```TapInputMode``` for raw sensors mode, additional arguments ```sensitivity``` (list with 3 integers) and ```scaled``` are optional. When ```scaled``` is set to ```True```, the IMU values will be converted to dps and g according to the given sensitivity.
     ```python
-    tap_device.set_input_mode(TapInputMode("raw", sensitivity=[2,1,4]))
+    tap_device.set_input_mode(TapInputMode("raw", sensitivity=[2,1,4], scaled=True))
 
 2. ```set_input_type(self, input_type:InputType, identifier):```   
     > **Only for TapXR and with Spatial Control experimental firmware**
diff --git a/examples/basic.py b/examples/basic.py
index 4301472..19254b3 100644
--- a/examples/basic.py
+++ b/examples/basic.py
@@ -1,40 +1,45 @@
 import asyncio
+import logging
 import time
 
-from tapsdk import TapInputMode, TapSDK, InputType, AirGestures
+from tapsdk import AirGestures, InputType, TapInputMode, TapSDK
+
+logging.basicConfig(level=logging.INFO)
+logging.getLogger("tapsdk").setLevel(logging.DEBUG)
+logger = logging.getLogger(__name__)
 
 
 def OnDisconnection(identifier):
-    print("Disconnected. ", identifier)
+    logger.info("Disconnected. %s", identifier)
 
 
 def OnConnection(identifier):
-    print("Connected. ", identifier)
+    logger.info("Connected. %s", identifier)
 
 
 def OnMouseModeChange(identifier, mouse_mode):
-    print(str(identifier) + " changed to mode " + str(mouse_mode))
+    logger.info("%s changed to mode %s", identifier, mouse_mode)
 
 
 def OnTapped(identifier, tapcode):
-    print(str(identifier) + " tapped " + str(tapcode))
+    logger.info("%s tapped %s", identifier, tapcode)
 
 
 def OnGesture(identifier, gesture):
-    print(str(identifier) + " gesture " + str(AirGestures(gesture)))
+    logger.info("%s gesture %s", identifier, AirGestures(gesture))
 
 
 def OnMoused(identifier, vx, vy, isMouse):
-    print(str(identifier) + " mouse movement: %d, %d, %d" % (vx, vy, isMouse))
+    logger.info("%s mouse movement: %d, %d, %d", identifier, vx, vy, isMouse)
 
 
 def OnRawData(identifier, packets):
     for m in packets:
-        print(f"{m['type']}, {time.time()}, {m['payload']}")
+        logger.info("%s, %s, %s", m['type'], time.time(), m['payload'])
 
 
-async def run(loop):
-    client = TapSDK(loop=loop)
+async def run():
+    client = TapSDK()
 
     client.register_disconnection_events(OnDisconnection)
     client.register_connection_events(OnConnection)
@@ -44,39 +49,37 @@ async def run(loop):
     client.register_mouse_events(OnMoused)
     client.register_air_gesture_state_events(OnMouseModeChange)
     await client.run()
-    print("Connected: {0}".format(client.client.is_connected))
+    logger.info("Connected: %s", client.client.is_connected)
 
-    print("Set Controller Mode for 5 seconds")
+    logger.info("Set Controller Mode for 5 seconds")
     await client.set_input_mode(TapInputMode("controller"))
     await asyncio.sleep(5)
 
-    print("Force Mouse Mode for 5 seconds")
+    logger.info("Force Mouse Mode for 5 seconds")
     await client.set_input_type(InputType.MOUSE)
     await asyncio.sleep(5)
 
-    print("Force keyboard Mode for 5 seconds")
+    logger.info("Force keyboard Mode for 5 seconds")
     await client.set_input_type(InputType.KEYBOARD)
     await asyncio.sleep(5)
 
-    print("Set auto Mode for 10 seconds")
+    logger.info("Set auto Mode for 10 seconds")
     await client.set_input_type(InputType.AUTO)
     await asyncio.sleep(10)
 
-    print("Set Text Mode for 10 seconds")
+    logger.info("Set Text Mode for 10 seconds")
     await client.set_input_mode(TapInputMode("text"))
     await asyncio.sleep(10)
 
-    print("Send Haptics")
+    logger.info("Send Haptics")
     await client.send_vibration_sequence([100, 200, 100, 200, 500])
     await asyncio.sleep(5)
 
-    print("Set Raw Mode for 5 seconds")
+    logger.info("Set Raw Mode for 5 seconds")
     await asyncio.sleep(2)
-    await client.set_input_mode(TapInputMode("raw", sensitivity=[0, 0, 0]))
+    await client.set_input_mode(TapInputMode("raw", sensitivity=[0, 0, 0], scaled=True))
     await asyncio.sleep(5)
 
 
 if __name__ == "__main__":
-    loop = asyncio.get_event_loop()
-    loop.set_debug(True)
-    loop.run_until_complete(run(loop))
+    asyncio.run(run())
diff --git a/tapsdk/inputmodes.py b/tapsdk/inputmodes.py
index 889a1e8..aa55404 100644
--- a/tapsdk/inputmodes.py
+++ b/tapsdk/inputmodes.py
@@ -1,22 +1,25 @@
 import logging
 from .enumerations import InputType
 
+logger = logging.getLogger(__name__)
+
 
 class TapInputMode:
-    def __init__(self, mode, sensitivity=[0, 0, 0]):
+    def __init__(self, mode, sensitivity=None, scaled=False):
         self._modes = {
                 "text": {"name": "Text Mode", "code": bytearray([0x3, 0xc, 0x0, 0x0])},
                 "controller": {"name": "Controller Mode", "code": bytearray([0x3, 0xc, 0x0, 0x1])},
                 "controller_text": {"name": "Controller and Text Mode", "code": bytearray([0x3, 0xc, 0x0, 0x3])},
                 "raw": {"name": "Raw sensors Mode", "code": bytearray([0x3, 0xc, 0x0, 0xa])}
                 }
-        self.sensitivity = sensitivity
+        self.sensitivity = sensitivity or [0, 0, 0]
+        self.scaled = scaled
         if mode in self._modes.keys():
             self.mode = mode
             if mode == "raw":
-                self._register_sensitivity(sensitivity)
+                self._register_sensitivity(self.sensitivity)
         else:
-            logging.warning("Invalid mode \"%s\". Set to \"text\"" % mode)
+            logger.warning("Invalid mode \"%s\". Set to \"text\"", mode)
             self.mode = "text"
 
     def _register_sensitivity(self, sensitivity):
diff --git a/tapsdk/parsers.py b/tapsdk/parsers.py
index fb83829..0121bcf 100644
--- a/tapsdk/parsers.py
+++ b/tapsdk/parsers.py
@@ -17,9 +17,10 @@ def tap_data_msg(data: bytearray):
     return [data[0]]
 
 
-def raw_data_msg(data: bytearray):
+def raw_data_msg(data: bytearray, scaled: bool = False, sensitivity=None):
     '''
-    raw data is packed into messages with the following structure:
+    Parses raw data messages into structured data with optional scaling.
+    Raw data is packed into messages with the following structure:
          [msg_type (1 bit)][timestamp (31 bit)][payload (12 - 30 bytes)]
              * msg type     - '0' for imu message
                         - '1' for accelerometers message
@@ -34,6 +35,13 @@ def raw_data_msg(data: bytearray):
                                         ...]
 
     '''
+    if sensitivity is None:
+        sensitivity = [0, 0, 0]
+
+    finger_acc_scales = [31.25, 3.91, 7.81, 15.62, 31.25]
+    gyro_scales = [17.5, 4.375, 8.75, 17.5, 35, 70]
+    imu_acc_scales = [0.122, 0.061, 0.122, 0.244, 0.488]
+
     L = len(data)
     ptr = 0
     messages = []
@@ -56,8 +64,19 @@ def raw_data_msg(data: bytearray):
         # parse payload
         payload = []
         for i in range(num_of_samples):
-            payload.append(int.from_bytes(data[ptr:ptr+2], "little", signed=True))
+            val = int.from_bytes(data[ptr:ptr+2], "little", signed=True)
             ptr += 2
+            payload.append(val)
+
+        if scaled:
+            if msg == "imu":
+                g_scale = gyro_scales[sensitivity[1]] / 1000.0
+                xl_scale = imu_acc_scales[sensitivity[2]] / 1000.0
+                payload = [payload[j] * g_scale if j < 3 else payload[j] * xl_scale
+                           for j in range(num_of_samples)]
+            else:  # accl message
+                acc_scale = finger_acc_scales[sensitivity[0]] / 1000.0
+                payload = [v * acc_scale for v in payload]
 
         messages.append({"type": msg, "ts": ts, "payload": payload})
     return messages
diff --git a/tapsdk/tap.py b/tapsdk/tap.py
index f04f35b..434c9a9 100644
--- a/tapsdk/tap.py
+++ b/tapsdk/tap.py
@@ -1,15 +1,15 @@
 import asyncio
+import logging
 import platform
-from asyncio.events import AbstractEventLoop
 from typing import Callable
 
 from bleak import BleakClient, BleakScanner
-from bleak import _logger as logger
 
 from . import parsers
 from .enumerations import InputType, MouseModes
 from .inputmodes import TapInputMode, input_type_command
 
+logger = logging.getLogger(__name__)
 
 tap_service = 'c3ff0001-1d8b-40fd-a56f-c7bd5d0f3370'
 nus_service = '6e400001-b5a3-f393-e0a9-e50e24dcca9e'
@@ -30,8 +30,8 @@ if platform.system() == "Darwin":
         return CBUUID.UUIDWithString_(uuid_str)
 
     class TapClient(BleakClient):
-        def __init__(self, address="", loop=None, **kwargs):
-            super().__init__(address, loop=loop, **kwargs)
+        def __init__(self, address="", **kwargs):
+            super().__init__(address, **kwargs)
 
         async def connect_retrieved(self, **kwargs) -> bool:
             self._central_manager_delegate = CentralManagerDelegate.alloc().init()
@@ -55,17 +55,17 @@ if platform.system() == "Darwin":
 
 elif platform.system() == "Windows":
     class TapClient(BleakClient):
-        def __init__(self, address="", loop=None, **kwargs):
-            super().__init__(address, loop=loop, **kwargs)
+        def __init__(self, address="", **kwargs):
+            super().__init__(address, **kwargs)
 
         async def connect_retrieved(self, **kwargs) -> bool:
             return False
 
 elif platform.system() == "Linux":
     class TapClient(BleakClient):
-        def __init__(self, address=None, loop=None, **kwargs):
+        def __init__(self, address=None, **kwargs):
             address = address if address else get_mac_addr()
-            super().__init__(address, loop=loop, **kwargs)
+            super().__init__(address, **kwargs)
 
         async def connect_retrieved(self, **kwargs) -> bool:
             await self.connect()
@@ -109,10 +109,11 @@ elif platform.system() == "Linux":
                 connected_bt_devices = btdevice_process.stdout.read().splitlines()
                 tap_devices = list(filter(lambda line: line.startswith("Tap"), connected_bt_devices))
                 for d in tap_devices:
-                    logger.info("Found tap device: {}".format(d))
+                    logger.info("Found tap device: %s", d)
                 if len(tap_devices) > 1:
-                    print("Found more than 1 Tap device:")
-                    [print(f"{i+1}. {d}") for i, d in enumerate(tap_devices)]
+                    logger.info("Found more than 1 Tap device:")
+                    for i, d in enumerate(tap_devices):
+                        logger.info("%s. %s", i + 1, d)
                     tap_devices = [tap_devices[int(input("Select the device number: ")) - 1]]
                 if len(tap_devices) == 0:
                     raise ValueError(
@@ -127,9 +128,8 @@ elif platform.system() == "Linux":
 
 
 class TapSDK():
-    def __init__(self, loop: AbstractEventLoop = None, **kwargs):
-        self.client = TapClient(loop=loop, address=kwargs.get("address"))
-        self.loop = loop
+    def __init__(self, **kwargs):
+        self.client = TapClient(address=kwargs.get("address"))
         self.mouse_event_cb = None
         self.tap_event_cb = None
         self.air_gesture_event_cb = None
@@ -178,7 +178,12 @@ class TapSDK():
 
     def on_raw_data(self, identifier, data):
         if self.raw_data_event_cb:
-            args = parsers.raw_data_msg(data)
+            scale = False
+            sensitivity = [0, 0, 0]
+            if isinstance(self.input_mode, TapInputMode) and self.input_mode.mode == "raw":
+                scale = getattr(self.input_mode, "scaled", scale)
+                sensitivity = getattr(self.input_mode, "sensitivity", sensitivity)
+            args = parsers.raw_data_msg(data, scaled=scale, sensitivity=sensitivity)
             self.raw_data_event_cb(identifier, args)
 
     def on_air_gesture(self, identifier, data):
@@ -237,20 +242,20 @@ class TapSDK():
         devices = []
 
         async def detection_cb(device, adv_data):
-            print("detected ", device, adv_data)
+            logger.debug("detected %s %s", device, adv_data)
             if tap_service.lower() in adv_data.service_uuids:
                 if device.address not in [d.address for d in devices]:
                     devices.append(device)
-                    print("detected ", device, adv_data)
+                    logger.debug("detected %s %s", device, adv_data)
                     stop_event.set()
 
         connected = await self.client.connect_retrieved()
         if not connected:
-            print("Couldn't find connected Tap device. Scanning for Tap devices...")
+            logger.info("Couldn't find connected Tap device. Scanning for Tap devices...")
             async with BleakScanner(detection_callback=detection_cb) as _:
                 await stop_event.wait()
 
-            self.client = TapClient(devices[0], loop=self.loop)
+            self.client = TapClient(devices[0])
             await self.client.connect()
             await self.client.pair()
         if self.client.is_connected:


Test patch:
diff --git a/tests/test_inputmodes.py b/tests/test_inputmodes.py
index 1bedc78..6e6fc96 100644
--- a/tests/test_inputmodes.py
+++ b/tests/test_inputmodes.py
@@ -13,5 +13,11 @@ def test_input_mode_raw_with_sensitivity():
     assert mode.get_command() == bytearray([0x3, 0xc, 0x0, 0xa, 1, 2, 3])
 
 
+def test_input_mode_raw_scaled():
+    mode = TapInputMode("raw", scaled=True)
+    assert mode.scaled is True
+    assert mode.get_command() == bytearray([0x3, 0xc, 0x0, 0xa, 0, 0, 0])
+
+
 def test_input_type_command():
     assert input_type_command(InputType.MOUSE) == bytearray([0x3, 0xd, 0x0, InputType.MOUSE.value])
diff --git a/tests/test_parsers.py b/tests/test_parsers.py
index 4a189c6..26e8dea 100644
--- a/tests/test_parsers.py
+++ b/tests/test_parsers.py
@@ -53,3 +53,24 @@ def test_raw_data_msg():
         {'type': 'imu', 'ts': 123, 'payload': imu_samples},
         {'type': 'accl', 'ts': 456, 'payload': accl_samples}
     ]
+
+
+def test_raw_data_msg_scaled():
+    ts = 123
+    imu_ts = ts
+    imu_bytes = imu_ts.to_bytes(4, 'little', signed=False)
+    imu_samples = [100, -100, 200, -200, 300, -300]
+    payload = b''
+    for v in imu_samples:
+        payload += v.to_bytes(2, 'little', signed=True)
+    packet = bytearray(imu_bytes + payload)
+    result = parsers.raw_data_msg(packet, scaled=True, sensitivity=[0, 0, 0])
+    g_scale = 17.5 / 1000
+    a_scale = 0.122 / 1000
+    expected = [imu_samples[i] * g_scale if i < 3 else imu_samples[i] * a_scale
+                for i in range(6)]
+    assert result == [{
+        'type': 'imu',
+        'ts': 123,
+        'payload': expected
+    }]