casambi-bt-revamped 0.3.12.dev13__tar.gz → 0.3.12.dev15__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {casambi_bt_revamped-0.3.12.dev13/src/casambi_bt_revamped.egg-info → casambi_bt_revamped-0.3.12.dev15}/PKG-INFO +1 -1
- {casambi_bt_revamped-0.3.12.dev13 → casambi_bt_revamped-0.3.12.dev15}/setup.cfg +1 -1
- {casambi_bt_revamped-0.3.12.dev13 → casambi_bt_revamped-0.3.12.dev15}/src/CasambiBt/_client.py +244 -55
- {casambi_bt_revamped-0.3.12.dev13 → casambi_bt_revamped-0.3.12.dev15}/src/CasambiBt/_version.py +1 -1
- {casambi_bt_revamped-0.3.12.dev13 → casambi_bt_revamped-0.3.12.dev15/src/casambi_bt_revamped.egg-info}/PKG-INFO +1 -1
- {casambi_bt_revamped-0.3.12.dev13 → casambi_bt_revamped-0.3.12.dev15}/LICENSE +0 -0
- {casambi_bt_revamped-0.3.12.dev13 → casambi_bt_revamped-0.3.12.dev15}/README.md +0 -0
- {casambi_bt_revamped-0.3.12.dev13 → casambi_bt_revamped-0.3.12.dev15}/pyproject.toml +0 -0
- {casambi_bt_revamped-0.3.12.dev13 → casambi_bt_revamped-0.3.12.dev15}/src/CasambiBt/__init__.py +0 -0
- {casambi_bt_revamped-0.3.12.dev13 → casambi_bt_revamped-0.3.12.dev15}/src/CasambiBt/_cache.py +0 -0
- {casambi_bt_revamped-0.3.12.dev13 → casambi_bt_revamped-0.3.12.dev15}/src/CasambiBt/_casambi.py +0 -0
- {casambi_bt_revamped-0.3.12.dev13 → casambi_bt_revamped-0.3.12.dev15}/src/CasambiBt/_classic_crypto.py +0 -0
- {casambi_bt_revamped-0.3.12.dev13 → casambi_bt_revamped-0.3.12.dev15}/src/CasambiBt/_constants.py +0 -0
- {casambi_bt_revamped-0.3.12.dev13 → casambi_bt_revamped-0.3.12.dev15}/src/CasambiBt/_discover.py +0 -0
- {casambi_bt_revamped-0.3.12.dev13 → casambi_bt_revamped-0.3.12.dev15}/src/CasambiBt/_encryption.py +0 -0
- {casambi_bt_revamped-0.3.12.dev13 → casambi_bt_revamped-0.3.12.dev15}/src/CasambiBt/_invocation.py +0 -0
- {casambi_bt_revamped-0.3.12.dev13 → casambi_bt_revamped-0.3.12.dev15}/src/CasambiBt/_keystore.py +0 -0
- {casambi_bt_revamped-0.3.12.dev13 → casambi_bt_revamped-0.3.12.dev15}/src/CasambiBt/_network.py +0 -0
- {casambi_bt_revamped-0.3.12.dev13 → casambi_bt_revamped-0.3.12.dev15}/src/CasambiBt/_operation.py +0 -0
- {casambi_bt_revamped-0.3.12.dev13 → casambi_bt_revamped-0.3.12.dev15}/src/CasambiBt/_switch_events.py +0 -0
- {casambi_bt_revamped-0.3.12.dev13 → casambi_bt_revamped-0.3.12.dev15}/src/CasambiBt/_unit.py +0 -0
- {casambi_bt_revamped-0.3.12.dev13 → casambi_bt_revamped-0.3.12.dev15}/src/CasambiBt/errors.py +0 -0
- {casambi_bt_revamped-0.3.12.dev13 → casambi_bt_revamped-0.3.12.dev15}/src/CasambiBt/py.typed +0 -0
- {casambi_bt_revamped-0.3.12.dev13 → casambi_bt_revamped-0.3.12.dev15}/src/casambi_bt_revamped.egg-info/SOURCES.txt +0 -0
- {casambi_bt_revamped-0.3.12.dev13 → casambi_bt_revamped-0.3.12.dev15}/src/casambi_bt_revamped.egg-info/dependency_links.txt +0 -0
- {casambi_bt_revamped-0.3.12.dev13 → casambi_bt_revamped-0.3.12.dev15}/src/casambi_bt_revamped.egg-info/requires.txt +0 -0
- {casambi_bt_revamped-0.3.12.dev13 → casambi_bt_revamped-0.3.12.dev15}/src/casambi_bt_revamped.egg-info/top_level.txt +0 -0
- {casambi_bt_revamped-0.3.12.dev13 → casambi_bt_revamped-0.3.12.dev15}/tests/test_classic_protocol.py +0 -0
- {casambi_bt_revamped-0.3.12.dev13 → casambi_bt_revamped-0.3.12.dev15}/tests/test_legacy_protocol_handling.py +0 -0
- {casambi_bt_revamped-0.3.12.dev13 → casambi_bt_revamped-0.3.12.dev15}/tests/test_switch_event_logs.py +0 -0
- {casambi_bt_revamped-0.3.12.dev13 → casambi_bt_revamped-0.3.12.dev15}/tests/test_unit_state_logs.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: casambi-bt-revamped
|
|
3
|
-
Version: 0.3.12.
|
|
3
|
+
Version: 0.3.12.dev15
|
|
4
4
|
Summary: Forked Casambi Bluetooth client library with switch event support, use original if no special need. https://github.com/lkempf/casambi-bt
|
|
5
5
|
Home-page: https://github.com/rankjie/casambi-bt
|
|
6
6
|
Author: rankjie
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[metadata]
|
|
2
2
|
name = casambi-bt-revamped
|
|
3
|
-
version = 0.3.12.
|
|
3
|
+
version = 0.3.12.dev15
|
|
4
4
|
author = rankjie
|
|
5
5
|
author_email = rankjie@gmail.com
|
|
6
6
|
description = Forked Casambi Bluetooth client library with switch event support, use original if no special need. https://github.com/lkempf/casambi-bt
|
{casambi_bt_revamped-0.3.12.dev13 → casambi_bt_revamped-0.3.12.dev15}/src/CasambiBt/_client.py
RENAMED
|
@@ -9,7 +9,7 @@ from binascii import b2a_hex as b2a
|
|
|
9
9
|
from collections.abc import Callable
|
|
10
10
|
from enum import Enum, IntEnum, auto, unique
|
|
11
11
|
from hashlib import sha256
|
|
12
|
-
from typing import Any, Final
|
|
12
|
+
from typing import Any, Final, Literal
|
|
13
13
|
|
|
14
14
|
from bleak import BleakClient
|
|
15
15
|
from bleak.backends.characteristic import BleakGATTCharacteristic
|
|
@@ -366,6 +366,24 @@ class CasambiClient:
|
|
|
366
366
|
f"Classic connection hash read failed/too short (len={0 if raw_hash is None else len(raw_hash)})."
|
|
367
367
|
)
|
|
368
368
|
self._classicConnHash8 = bytes(raw_hash[:8])
|
|
369
|
+
|
|
370
|
+
# Parse Android's extended connection hash fields for diagnostics.
|
|
371
|
+
# Offset 8: unitId, 9: flags_lo, 10: MTU, 11: protocolVersion, 12: flags_hi
|
|
372
|
+
if len(raw_hash) >= 13:
|
|
373
|
+
ext_unit_id = raw_hash[8]
|
|
374
|
+
ext_flags_lo = raw_hash[9]
|
|
375
|
+
ext_mtu = raw_hash[10]
|
|
376
|
+
ext_proto_ver = raw_hash[11]
|
|
377
|
+
ext_flags_hi = raw_hash[12]
|
|
378
|
+
self._logger.warning(
|
|
379
|
+
"[CASAMBI_CLASSIC_CONN_HASH_EXT] variant=legacy unitId=%d flags=0x%04x mtu=%d protocolVersion=%d raw=%s",
|
|
380
|
+
ext_unit_id,
|
|
381
|
+
(ext_flags_hi << 8) | ext_flags_lo,
|
|
382
|
+
ext_mtu,
|
|
383
|
+
ext_proto_ver,
|
|
384
|
+
b2a(bytes(raw_hash[:min(len(raw_hash), 20)])),
|
|
385
|
+
)
|
|
386
|
+
|
|
369
387
|
# Android seeds the command divider with a random byte on startup (u1.C1751c).
|
|
370
388
|
self._classicCmdDiv = int.from_bytes(os.urandom(1), "big") or 1
|
|
371
389
|
self._classicTxSeq = 0
|
|
@@ -516,6 +534,24 @@ class CasambiClient:
|
|
|
516
534
|
self._classicHeaderMode = "conformant"
|
|
517
535
|
self._classicHashSource = "ca52_0001"
|
|
518
536
|
self._classicConnHash8 = bytes(first[:8])
|
|
537
|
+
|
|
538
|
+
# Parse Android's extended connection hash fields for diagnostics.
|
|
539
|
+
# Offset 8: unitId, 9: flags_lo, 10: MTU, 11: protocolVersion, 12: flags_hi
|
|
540
|
+
if len(first) >= 13:
|
|
541
|
+
ext_unit_id = first[8]
|
|
542
|
+
ext_flags_lo = first[9]
|
|
543
|
+
ext_mtu = first[10]
|
|
544
|
+
ext_proto_ver = first[11]
|
|
545
|
+
ext_flags_hi = first[12]
|
|
546
|
+
self._logger.warning(
|
|
547
|
+
"[CASAMBI_CLASSIC_CONN_HASH_EXT] variant=conformant unitId=%d flags=0x%04x mtu=%d protocolVersion=%d raw=%s",
|
|
548
|
+
ext_unit_id,
|
|
549
|
+
(ext_flags_hi << 8) | ext_flags_lo,
|
|
550
|
+
ext_mtu,
|
|
551
|
+
ext_proto_ver,
|
|
552
|
+
b2a(bytes(first[:min(len(first), 20)])),
|
|
553
|
+
)
|
|
554
|
+
|
|
519
555
|
self._classicCmdDiv = int.from_bytes(os.urandom(1), "big") or 1
|
|
520
556
|
self._classicTxSeq = 0
|
|
521
557
|
|
|
@@ -764,6 +800,11 @@ class CasambiClient:
|
|
|
764
800
|
await self._activityLock.acquire()
|
|
765
801
|
try:
|
|
766
802
|
self._callbackMulitplexer(handle, data)
|
|
803
|
+
except Exception:
|
|
804
|
+
self._logger.warning(
|
|
805
|
+
"[CASAMBI_CALLBACK_ERROR] unhandled exception in callback multiplexer",
|
|
806
|
+
exc_info=True,
|
|
807
|
+
)
|
|
767
808
|
finally:
|
|
768
809
|
self._callbackQueue.task_done()
|
|
769
810
|
self._activityLock.release()
|
|
@@ -775,6 +816,10 @@ class CasambiClient:
|
|
|
775
816
|
def _callbackMulitplexer(
|
|
776
817
|
self, handle: BleakGATTCharacteristic, data: bytes
|
|
777
818
|
) -> None:
|
|
819
|
+
if self._logger.isEnabledFor(logging.DEBUG):
|
|
820
|
+
self._logger.debug(
|
|
821
|
+
"[CASAMBI_MUX] state=%s len=%d", self._connectionState, len(data)
|
|
822
|
+
)
|
|
778
823
|
if self._connectionState == ConnectionState.CONNECTED:
|
|
779
824
|
self._exchNofityCallback(handle, data)
|
|
780
825
|
elif self._connectionState == ConnectionState.KEY_EXCHANGED:
|
|
@@ -1045,11 +1090,18 @@ class CasambiClient:
|
|
|
1045
1090
|
else:
|
|
1046
1091
|
return bytes([counter, unit_id & 0xFF, 1, dimmer & 0xFF])
|
|
1047
1092
|
|
|
1048
|
-
async def _sendClassic(
|
|
1093
|
+
async def _sendClassic(
|
|
1094
|
+
self,
|
|
1095
|
+
command_bytes: bytes,
|
|
1096
|
+
*,
|
|
1097
|
+
target_uuid: str | None = None,
|
|
1098
|
+
key_preference: Literal["auto", "visitor", "manager"] = "auto",
|
|
1099
|
+
response: bool | None = None,
|
|
1100
|
+
) -> None:
|
|
1049
1101
|
self._checkState(ConnectionState.AUTHENTICATED)
|
|
1050
1102
|
if self._protocolMode != ProtocolMode.CLASSIC:
|
|
1051
1103
|
raise ProtocolError("Classic send called while not in Classic protocol mode.")
|
|
1052
|
-
tx_uuid = self._classicTxCharUuid or self._dataCharUuid
|
|
1104
|
+
tx_uuid = target_uuid or self._classicTxCharUuid or self._dataCharUuid
|
|
1053
1105
|
if not tx_uuid:
|
|
1054
1106
|
raise ProtocolError("Classic TX characteristic UUID not set.")
|
|
1055
1107
|
if self._classicConnHash8 is None:
|
|
@@ -1086,26 +1138,41 @@ class CasambiClient:
|
|
|
1086
1138
|
# If parsing fails, keep fields as None.
|
|
1087
1139
|
pass
|
|
1088
1140
|
|
|
1089
|
-
#
|
|
1090
|
-
#
|
|
1091
|
-
#
|
|
1092
|
-
#
|
|
1093
|
-
#
|
|
1141
|
+
# Classic key selection:
|
|
1142
|
+
#
|
|
1143
|
+
# Android (v3.16) explicitly uses "visitor" signing (auth_level=2 / 4-byte sig)
|
|
1144
|
+
# for the Classic enable-notify bootstrap packets (sendVersion + sendTime), even
|
|
1145
|
+
# when a managerKey exists.
|
|
1146
|
+
#
|
|
1147
|
+
# For normal commands we keep the historical behavior ("auto" == prefer manager
|
|
1148
|
+
# when the cloud session is manager), but allow overrides so init can match Android.
|
|
1094
1149
|
key_name = "none"
|
|
1095
|
-
auth_level = 0x02
|
|
1096
|
-
key = None
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
1150
|
+
auth_level = 0x02
|
|
1151
|
+
key: bytes | None = None
|
|
1152
|
+
|
|
1153
|
+
if key_preference == "visitor":
|
|
1154
|
+
if visitor_key is not None:
|
|
1155
|
+
key_name, auth_level, key = "visitor", 0x02, visitor_key
|
|
1156
|
+
elif manager_key is not None:
|
|
1157
|
+
# Fallback: some networks have managerKey only.
|
|
1158
|
+
key_name, auth_level, key = "manager", 0x03, manager_key
|
|
1159
|
+
else:
|
|
1160
|
+
key_name, auth_level, key = "none", 0x02, None
|
|
1161
|
+
elif key_preference == "manager":
|
|
1162
|
+
if manager_key is not None:
|
|
1163
|
+
key_name, auth_level, key = "manager", 0x03, manager_key
|
|
1164
|
+
elif visitor_key is not None:
|
|
1165
|
+
key_name, auth_level, key = "visitor", 0x02, visitor_key
|
|
1166
|
+
else:
|
|
1167
|
+
key_name, auth_level, key = "none", 0x03, None
|
|
1168
|
+
else:
|
|
1169
|
+
# "auto" (legacy behavior)
|
|
1170
|
+
if manager_key is not None and getattr(self._network, "isManager", lambda: False)():
|
|
1171
|
+
key_name, auth_level, key = "manager", 0x03, manager_key
|
|
1172
|
+
elif visitor_key is not None:
|
|
1173
|
+
key_name, auth_level, key = "visitor", 0x02, visitor_key
|
|
1174
|
+
elif manager_key is not None:
|
|
1175
|
+
key_name, auth_level, key = "manager", 0x03, manager_key
|
|
1109
1176
|
|
|
1110
1177
|
header_mode = self._classicHeaderMode or "conformant"
|
|
1111
1178
|
|
|
@@ -1182,11 +1249,14 @@ class CasambiClient:
|
|
|
1182
1249
|
b2a(bytes(pkt[: min(len(pkt), 24)])),
|
|
1183
1250
|
)
|
|
1184
1251
|
|
|
1185
|
-
#
|
|
1186
|
-
#
|
|
1252
|
+
# Android uses WRITE_TYPE_NO_RESPONSE (1) for version/state writes (n0 path)
|
|
1253
|
+
# and WRITE_TYPE_DEFAULT (2) = with-response for time-sync (X path).
|
|
1254
|
+
# If caller didn't specify, default to True for backward compatibility
|
|
1255
|
+
# (also needed for long writes with 16-byte manager signature).
|
|
1256
|
+
use_response = response if response is not None else True
|
|
1187
1257
|
tx_result = "pending"
|
|
1188
1258
|
try:
|
|
1189
|
-
await self._gattClient.write_gatt_char(tx_uuid, bytes(pkt), response=
|
|
1259
|
+
await self._gattClient.write_gatt_char(tx_uuid, bytes(pkt), response=use_response)
|
|
1190
1260
|
tx_result = "ok"
|
|
1191
1261
|
except Exception as e:
|
|
1192
1262
|
tx_result = f"error: {type(e).__name__}: {e}"
|
|
@@ -1278,13 +1348,17 @@ class CasambiClient:
|
|
|
1278
1348
|
)
|
|
1279
1349
|
|
|
1280
1350
|
async def classicSendInit(self) -> None:
|
|
1281
|
-
"""Send Classic post-connection initialization (time-sync).
|
|
1351
|
+
"""Send Classic post-connection initialization (version + time-sync).
|
|
1282
1352
|
|
|
1283
|
-
Ground truth
|
|
1284
|
-
|
|
1285
|
-
|
|
1286
|
-
|
|
1287
|
-
|
|
1353
|
+
Ground truth (casambi-android v3.16):
|
|
1354
|
+
- Enable notify on CA52/0001 (CCCD) (handled by Bleak start_notify)
|
|
1355
|
+
- Send "version" on CA52/0001: bytes [0,1,11]
|
|
1356
|
+
(`Z0/AbstractC0151u.h0()` in Android)
|
|
1357
|
+
- Then send time-sync on CA51/0002 (cmd 10 legacy / 7 conformant)
|
|
1358
|
+
(`Z0/AbstractC0142k.X()` in Android)
|
|
1359
|
+
|
|
1360
|
+
Classic often stays silent until this bootstrap is completed, so we do it
|
|
1361
|
+
right after the BLE connection is established.
|
|
1288
1362
|
|
|
1289
1363
|
The payload is sent raw via _sendClassic (NOT wrapped in buildClassicCommand).
|
|
1290
1364
|
"""
|
|
@@ -1292,6 +1366,33 @@ class CasambiClient:
|
|
|
1292
1366
|
if self._protocolMode != ProtocolMode.CLASSIC:
|
|
1293
1367
|
return
|
|
1294
1368
|
|
|
1369
|
+
# Ensure notify setup has a moment to settle (Android delays ~100ms after CCCD write).
|
|
1370
|
+
await asyncio.sleep(0.1)
|
|
1371
|
+
|
|
1372
|
+
# 1) Send Classic "version" packet on CA52/0001.
|
|
1373
|
+
version_uuid = self._classicTxCharUuid or self._dataCharUuid
|
|
1374
|
+
if version_uuid:
|
|
1375
|
+
try:
|
|
1376
|
+
self._logger.warning(
|
|
1377
|
+
"[CASAMBI_CLASSIC_INIT] sending version len=3 target_uuid=%s header_mode=%s",
|
|
1378
|
+
version_uuid,
|
|
1379
|
+
self._classicHeaderMode,
|
|
1380
|
+
)
|
|
1381
|
+
await self._sendClassic(
|
|
1382
|
+
b"\x00\x01\x0b",
|
|
1383
|
+
target_uuid=version_uuid,
|
|
1384
|
+
# Android uses visitor auth for this bootstrap packet.
|
|
1385
|
+
key_preference="visitor",
|
|
1386
|
+
# Android n0():998 uses WRITE_TYPE_NO_RESPONSE for classic.
|
|
1387
|
+
response=False,
|
|
1388
|
+
)
|
|
1389
|
+
self._logger.warning("[CASAMBI_CLASSIC_INIT] version sent successfully")
|
|
1390
|
+
except Exception:
|
|
1391
|
+
self._logger.warning(
|
|
1392
|
+
"[CASAMBI_CLASSIC_INIT] version send failed (continuing with time-sync)",
|
|
1393
|
+
exc_info=True,
|
|
1394
|
+
)
|
|
1395
|
+
|
|
1295
1396
|
import datetime as _dt
|
|
1296
1397
|
|
|
1297
1398
|
now = _dt.datetime.now()
|
|
@@ -1304,12 +1405,22 @@ class CasambiClient:
|
|
|
1304
1405
|
if offset is not None:
|
|
1305
1406
|
utc_offset_minutes = int(offset.total_seconds()) // 60
|
|
1306
1407
|
|
|
1408
|
+
# Determine time-sync target and command byte per Android AbstractC1717h.X():
|
|
1409
|
+
# - Non-conformant: write to CA51, command byte 10
|
|
1410
|
+
# - Conformant: write to 0002 (mapped CA51), command byte 7
|
|
1411
|
+
if self._classicHeaderMode == "conformant":
|
|
1412
|
+
timesync_uuid = CASA_CLASSIC_CONFORMANT_CA51_CHAR_UUID # 0002
|
|
1413
|
+
timesync_cmd = 7
|
|
1414
|
+
else:
|
|
1415
|
+
timesync_uuid = CASA_CLASSIC_HASH_CHAR_UUID # CA51
|
|
1416
|
+
timesync_cmd = 10
|
|
1417
|
+
|
|
1307
1418
|
# Build the time-sync payload.
|
|
1308
|
-
# Format: [
|
|
1419
|
+
# Format: [cmd][year:2BE][month:1][day:1][hour:1][min:1][sec:1]
|
|
1309
1420
|
# [tz_offset:2BE signed][dst_transition:4BE][dst_change:1]
|
|
1310
1421
|
# [timestamp1:3BE][timestamp2:3BE][zero:2][millis:3BE][extra:1]
|
|
1311
1422
|
payload = bytearray()
|
|
1312
|
-
payload.append(
|
|
1423
|
+
payload.append(timesync_cmd)
|
|
1313
1424
|
payload.extend(struct.pack(">H", now.year))
|
|
1314
1425
|
payload.append(now.month)
|
|
1315
1426
|
payload.append(now.day)
|
|
@@ -1320,30 +1431,61 @@ class CasambiClient:
|
|
|
1320
1431
|
# DST transition data and change minutes (0 = no DST info).
|
|
1321
1432
|
payload.extend(struct.pack(">I", 0))
|
|
1322
1433
|
payload.append(0)
|
|
1323
|
-
# Classic extra bytes:
|
|
1434
|
+
# Classic extra bytes: lon/lat (fixed-point), zero short, millis, trailing lon high byte.
|
|
1324
1435
|
# Android AbstractC1717h.X() lines 323-328: j() = 3-byte big-endian write
|
|
1325
1436
|
# (Q2.t.java:59-63), NOT 4-byte. Plus trailing writeByte(iK0 >> 24).
|
|
1326
|
-
|
|
1327
|
-
|
|
1328
|
-
|
|
1329
|
-
|
|
1330
|
-
|
|
1331
|
-
|
|
1437
|
+
#
|
|
1438
|
+
# In casambi-android v3.16 these values are derived from:
|
|
1439
|
+
# - longitude: round(longitude * 65536)
|
|
1440
|
+
# - latitude: round(latitude * 65536)
|
|
1441
|
+
# and sent as:
|
|
1442
|
+
# j(lon32) + j(lat32) + ... + writeByte(lon32 >> 24)
|
|
1443
|
+
lon_fp32 = 0
|
|
1444
|
+
lat_fp32 = 0
|
|
1445
|
+
try:
|
|
1446
|
+
raw = getattr(self._network, "rawNetworkData", None)
|
|
1447
|
+
net = raw.get("network") if isinstance(raw, dict) else None
|
|
1448
|
+
if isinstance(net, dict):
|
|
1449
|
+
lon = net.get("longitude")
|
|
1450
|
+
lat = net.get("latitude")
|
|
1451
|
+
if isinstance(lon, (int, float, str)):
|
|
1452
|
+
lon_fp32 = int(round(float(lon) * 65536.0))
|
|
1453
|
+
if isinstance(lat, (int, float, str)):
|
|
1454
|
+
lat_fp32 = int(round(float(lat) * 65536.0))
|
|
1455
|
+
except Exception:
|
|
1456
|
+
# Never fail init due to missing location; send zeros.
|
|
1457
|
+
lon_fp32 = 0
|
|
1458
|
+
lat_fp32 = 0
|
|
1459
|
+
|
|
1460
|
+
for v in (lon_fp32, lat_fp32):
|
|
1461
|
+
payload.append((v >> 16) & 0xFF)
|
|
1462
|
+
payload.append((v >> 8) & 0xFF)
|
|
1463
|
+
payload.append(v & 0xFF)
|
|
1332
1464
|
payload.extend(struct.pack(">H", 0)) # writeShort(0)
|
|
1333
1465
|
millis_val = now.microsecond // 1000 * 1000
|
|
1334
1466
|
payload.append((millis_val >> 16) & 0xFF)
|
|
1335
1467
|
payload.append((millis_val >> 8) & 0xFF)
|
|
1336
1468
|
payload.append(millis_val & 0xFF)
|
|
1337
|
-
payload.append((
|
|
1469
|
+
payload.append((lon_fp32 >> 24) & 0xFF) # writeByte(lon >> 24)
|
|
1338
1470
|
|
|
1339
1471
|
self._logger.warning(
|
|
1340
|
-
"[CASAMBI_CLASSIC_INIT] sending time-sync len=%d hex=%s",
|
|
1472
|
+
"[CASAMBI_CLASSIC_INIT] sending time-sync len=%d cmd=%d target_uuid=%s header_mode=%s hex=%s",
|
|
1341
1473
|
len(payload),
|
|
1474
|
+
timesync_cmd,
|
|
1475
|
+
timesync_uuid,
|
|
1476
|
+
self._classicHeaderMode,
|
|
1342
1477
|
b2a(bytes(payload)),
|
|
1343
1478
|
)
|
|
1344
1479
|
|
|
1345
1480
|
try:
|
|
1346
|
-
await self._sendClassic(
|
|
1481
|
+
await self._sendClassic(
|
|
1482
|
+
bytes(payload),
|
|
1483
|
+
target_uuid=timesync_uuid,
|
|
1484
|
+
# Android uses visitor auth for this bootstrap packet.
|
|
1485
|
+
key_preference="visitor",
|
|
1486
|
+
# Android X():314 uses WRITE_TYPE_DEFAULT (2) = with-response.
|
|
1487
|
+
response=True,
|
|
1488
|
+
)
|
|
1347
1489
|
self._logger.warning("[CASAMBI_CLASSIC_INIT] time-sync sent successfully")
|
|
1348
1490
|
except Exception:
|
|
1349
1491
|
self._logger.warning(
|
|
@@ -1361,9 +1503,19 @@ class CasambiClient:
|
|
|
1361
1503
|
except Exception:
|
|
1362
1504
|
handle_uuid = ""
|
|
1363
1505
|
if handle_uuid and handle_uuid in self._classicNotifyCharUuids:
|
|
1506
|
+
self._logger.debug(
|
|
1507
|
+
"[CASAMBI_NOTIFY_ROUTE] classic_by_uuid uuid=%s len=%d",
|
|
1508
|
+
handle_uuid,
|
|
1509
|
+
len(data),
|
|
1510
|
+
)
|
|
1364
1511
|
self._classicEstablishedNotifyCallback(handle, data)
|
|
1365
1512
|
return
|
|
1366
1513
|
if self._protocolMode == ProtocolMode.CLASSIC:
|
|
1514
|
+
self._logger.debug(
|
|
1515
|
+
"[CASAMBI_NOTIFY_ROUTE] classic_by_mode uuid=%s len=%d",
|
|
1516
|
+
handle_uuid,
|
|
1517
|
+
len(data),
|
|
1518
|
+
)
|
|
1367
1519
|
self._classicEstablishedNotifyCallback(handle, data)
|
|
1368
1520
|
return
|
|
1369
1521
|
|
|
@@ -1583,6 +1735,25 @@ class CasambiClient:
|
|
|
1583
1735
|
"payload": payload,
|
|
1584
1736
|
}
|
|
1585
1737
|
|
|
1738
|
+
def _parse_raw(raw_bytes: bytes) -> dict[str, Any] | None:
|
|
1739
|
+
"""Parse as raw (unsigned) Classic data.
|
|
1740
|
+
|
|
1741
|
+
Android classic gateway a1.c.V() receives raw bytes with NO CMAC
|
|
1742
|
+
header — byte 0 is the unit_id directly. Adding this as a candidate
|
|
1743
|
+
avoids silently dropping unsigned notifications.
|
|
1744
|
+
"""
|
|
1745
|
+
if not raw_bytes:
|
|
1746
|
+
return None
|
|
1747
|
+
return {
|
|
1748
|
+
"mode": "raw",
|
|
1749
|
+
"auth_level": None,
|
|
1750
|
+
"sig_len": 0,
|
|
1751
|
+
"seq": None,
|
|
1752
|
+
"key_name": None,
|
|
1753
|
+
"verified": None, # raw = unverifiable
|
|
1754
|
+
"payload": raw_bytes,
|
|
1755
|
+
}
|
|
1756
|
+
|
|
1586
1757
|
# Try the currently selected header mode first, then fall back.
|
|
1587
1758
|
# Some mixed/legacy setups differ between CA52 (legacy) and auth-UUID (conformant).
|
|
1588
1759
|
parsed_candidates: list[dict[str, Any]] = []
|
|
@@ -1604,6 +1775,12 @@ class CasambiClient:
|
|
|
1604
1775
|
if r is not None:
|
|
1605
1776
|
parsed_candidates.append(r)
|
|
1606
1777
|
|
|
1778
|
+
# Add a raw (unsigned) candidate — needed because Android classic
|
|
1779
|
+
# gateway receives raw bytes with no CMAC header.
|
|
1780
|
+
raw_candidate = _parse_raw(raw)
|
|
1781
|
+
if raw_candidate is not None:
|
|
1782
|
+
parsed_candidates.append(raw_candidate)
|
|
1783
|
+
|
|
1607
1784
|
if not parsed_candidates:
|
|
1608
1785
|
self._classicRxParseFail += 1
|
|
1609
1786
|
if self._logLimiter.allow("classic_rx_parse_fail", burst=5, window_s=60.0):
|
|
@@ -1677,7 +1854,8 @@ class CasambiClient:
|
|
|
1677
1854
|
)
|
|
1678
1855
|
|
|
1679
1856
|
# Auto-correct header mode if the other format parses much better.
|
|
1680
|
-
|
|
1857
|
+
# Never switch to "raw" — raw is not a header mode, only a fallback parse.
|
|
1858
|
+
if best["mode"] != preferred and best["mode"] in ("conformant", "legacy"):
|
|
1681
1859
|
# Only switch if we got a stronger signal (verified or plausible payload with fewer assumptions).
|
|
1682
1860
|
if best["score"] >= 50 and self._logLimiter.allow("classic_rx_mode_switch", burst=3, window_s=3600.0):
|
|
1683
1861
|
self._logger.warning(
|
|
@@ -1879,11 +2057,11 @@ class CasambiClient:
|
|
|
1879
2057
|
def _parseClassicUnitStates(self, data: bytes) -> None:
|
|
1880
2058
|
"""Parse Classic unit state records.
|
|
1881
2059
|
|
|
1882
|
-
Ground truth: casambi-android
|
|
2060
|
+
Ground truth: casambi-android a1.c.V() (line 226+).
|
|
1883
2061
|
Format is completely different from EVO _parseUnitStates:
|
|
1884
2062
|
- flags lower nibble = state_len (EVO uses a separate byte)
|
|
1885
|
-
- flags bit 5 = extra1
|
|
1886
|
-
- unit_id 0xF0 = command response (
|
|
2063
|
+
- flags bit 4 = priority 14, bit 5 = extra1, bit 6 = extra2, bit 7 = online
|
|
2064
|
+
- unit_id 0xF0 = command response (cmd_id + seq + payload)
|
|
1887
2065
|
"""
|
|
1888
2066
|
self._logger.debug("Parsing Classic unit states...")
|
|
1889
2067
|
if self._logger.isEnabledFor(logging.DEBUG):
|
|
@@ -1893,7 +2071,8 @@ class CasambiClient:
|
|
|
1893
2071
|
old_pos = 0
|
|
1894
2072
|
records_parsed = 0
|
|
1895
2073
|
try:
|
|
1896
|
-
|
|
2074
|
+
# Android uses fVar.available() >= 3 as loop guard.
|
|
2075
|
+
while pos + 3 <= len(data):
|
|
1897
2076
|
unit_id = data[pos]
|
|
1898
2077
|
flags = data[pos + 1]
|
|
1899
2078
|
pos += 2
|
|
@@ -1901,10 +2080,21 @@ class CasambiClient:
|
|
|
1901
2080
|
state_len = flags & 0x0F
|
|
1902
2081
|
has_extra1 = (flags & 0x20) != 0
|
|
1903
2082
|
has_extra2 = (flags & 0x40) != 0
|
|
1904
|
-
|
|
2083
|
+
# Android a1.c.java:286: (b6 & 128) != 0 → online (NOT offline).
|
|
2084
|
+
# Confirmed by N1.java:1298 log "Set unit ONLINE=" + z6.
|
|
2085
|
+
online = (flags & 0x80) != 0
|
|
1905
2086
|
|
|
1906
|
-
# 0xF0 = command response record
|
|
2087
|
+
# 0xF0 = command response record (Android a1.c.java:260-270).
|
|
2088
|
+
# Format: cmd_id(1) + seq(1) + payload(state_len - 2).
|
|
1907
2089
|
if unit_id == 0xF0:
|
|
2090
|
+
cmd_id = data[pos] if pos < len(data) else None
|
|
2091
|
+
seq_byte = data[pos + 1] if pos + 1 < len(data) else None
|
|
2092
|
+
self._logger.debug(
|
|
2093
|
+
"[CASAMBI_CLASSIC_CMD_RESP] cmd_id=%s seq=%s state_len=%d",
|
|
2094
|
+
cmd_id,
|
|
2095
|
+
seq_byte,
|
|
2096
|
+
state_len,
|
|
2097
|
+
)
|
|
1908
2098
|
pos += state_len
|
|
1909
2099
|
continue
|
|
1910
2100
|
|
|
@@ -1933,17 +2123,15 @@ class CasambiClient:
|
|
|
1933
2123
|
if records_parsed <= 10 or self._logger.isEnabledFor(logging.DEBUG):
|
|
1934
2124
|
self._logger.warning(
|
|
1935
2125
|
"[CASAMBI_CLASSIC_STATE_PARSED] unit=%d flags=0x%02x state_len=%d "
|
|
1936
|
-
"
|
|
2126
|
+
"online=%s extra1=%d extra2=%d state=%s",
|
|
1937
2127
|
unit_id,
|
|
1938
2128
|
flags,
|
|
1939
2129
|
state_len,
|
|
1940
|
-
|
|
2130
|
+
online,
|
|
1941
2131
|
extra1,
|
|
1942
2132
|
extra2,
|
|
1943
2133
|
b2a(state),
|
|
1944
2134
|
)
|
|
1945
|
-
|
|
1946
|
-
online = not is_offline
|
|
1947
2135
|
# Let Unit.is_on derive actual on/off from state bytes (dimmer, onoff).
|
|
1948
2136
|
on = True
|
|
1949
2137
|
|
|
@@ -1955,7 +2143,8 @@ class CasambiClient:
|
|
|
1955
2143
|
"on": on,
|
|
1956
2144
|
"state": state,
|
|
1957
2145
|
"flags": flags,
|
|
1958
|
-
|
|
2146
|
+
# Android a1.c.java:291: (b6 & 16) != 0 ? 14 : 0
|
|
2147
|
+
"prio": 14 if (flags & 0x10) else 0,
|
|
1959
2148
|
"state_len": state_len,
|
|
1960
2149
|
"padding_len": 0,
|
|
1961
2150
|
"con": None,
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: casambi-bt-revamped
|
|
3
|
-
Version: 0.3.12.
|
|
3
|
+
Version: 0.3.12.dev15
|
|
4
4
|
Summary: Forked Casambi Bluetooth client library with switch event support, use original if no special need. https://github.com/lkempf/casambi-bt
|
|
5
5
|
Home-page: https://github.com/rankjie/casambi-bt
|
|
6
6
|
Author: rankjie
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{casambi_bt_revamped-0.3.12.dev13 → casambi_bt_revamped-0.3.12.dev15}/src/CasambiBt/__init__.py
RENAMED
|
File without changes
|
{casambi_bt_revamped-0.3.12.dev13 → casambi_bt_revamped-0.3.12.dev15}/src/CasambiBt/_cache.py
RENAMED
|
File without changes
|
{casambi_bt_revamped-0.3.12.dev13 → casambi_bt_revamped-0.3.12.dev15}/src/CasambiBt/_casambi.py
RENAMED
|
File without changes
|
|
File without changes
|
{casambi_bt_revamped-0.3.12.dev13 → casambi_bt_revamped-0.3.12.dev15}/src/CasambiBt/_constants.py
RENAMED
|
File without changes
|
{casambi_bt_revamped-0.3.12.dev13 → casambi_bt_revamped-0.3.12.dev15}/src/CasambiBt/_discover.py
RENAMED
|
File without changes
|
{casambi_bt_revamped-0.3.12.dev13 → casambi_bt_revamped-0.3.12.dev15}/src/CasambiBt/_encryption.py
RENAMED
|
File without changes
|
{casambi_bt_revamped-0.3.12.dev13 → casambi_bt_revamped-0.3.12.dev15}/src/CasambiBt/_invocation.py
RENAMED
|
File without changes
|
{casambi_bt_revamped-0.3.12.dev13 → casambi_bt_revamped-0.3.12.dev15}/src/CasambiBt/_keystore.py
RENAMED
|
File without changes
|
{casambi_bt_revamped-0.3.12.dev13 → casambi_bt_revamped-0.3.12.dev15}/src/CasambiBt/_network.py
RENAMED
|
File without changes
|
{casambi_bt_revamped-0.3.12.dev13 → casambi_bt_revamped-0.3.12.dev15}/src/CasambiBt/_operation.py
RENAMED
|
File without changes
|
|
File without changes
|
{casambi_bt_revamped-0.3.12.dev13 → casambi_bt_revamped-0.3.12.dev15}/src/CasambiBt/_unit.py
RENAMED
|
File without changes
|
{casambi_bt_revamped-0.3.12.dev13 → casambi_bt_revamped-0.3.12.dev15}/src/CasambiBt/errors.py
RENAMED
|
File without changes
|
{casambi_bt_revamped-0.3.12.dev13 → casambi_bt_revamped-0.3.12.dev15}/src/CasambiBt/py.typed
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{casambi_bt_revamped-0.3.12.dev13 → casambi_bt_revamped-0.3.12.dev15}/tests/test_classic_protocol.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{casambi_bt_revamped-0.3.12.dev13 → casambi_bt_revamped-0.3.12.dev15}/tests/test_unit_state_logs.py
RENAMED
|
File without changes
|