casambi-bt-revamped 0.3.5__py3-none-any.whl → 0.3.6.dev2__py3-none-any.whl
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.
- CasambiBt/_client.py +63 -11
- CasambiBt/_client_android_parser.py +215 -0
- {casambi_bt_revamped-0.3.5.dist-info → casambi_bt_revamped-0.3.6.dev2.dist-info}/METADATA +1 -1
- {casambi_bt_revamped-0.3.5.dist-info → casambi_bt_revamped-0.3.6.dev2.dist-info}/RECORD +7 -6
- {casambi_bt_revamped-0.3.5.dist-info → casambi_bt_revamped-0.3.6.dev2.dist-info}/WHEEL +0 -0
- {casambi_bt_revamped-0.3.5.dist-info → casambi_bt_revamped-0.3.6.dev2.dist-info}/licenses/LICENSE +0 -0
- {casambi_bt_revamped-0.3.5.dist-info → casambi_bt_revamped-0.3.6.dev2.dist-info}/top_level.txt +0 -0
CasambiBt/_client.py
CHANGED
|
@@ -33,6 +33,12 @@ from .errors import ( # noqa: E402
|
|
|
33
33
|
UnsupportedProtocolVersion,
|
|
34
34
|
)
|
|
35
35
|
|
|
36
|
+
# Import Android parser for comparison
|
|
37
|
+
try:
|
|
38
|
+
from ._client_android_parser import AndroidPacketParser
|
|
39
|
+
except ImportError:
|
|
40
|
+
AndroidPacketParser = None
|
|
41
|
+
|
|
36
42
|
|
|
37
43
|
@unique
|
|
38
44
|
class IncommingPacketType(IntEnum):
|
|
@@ -415,23 +421,47 @@ class CasambiClient:
|
|
|
415
421
|
# TODO: Check incoming counter and direction flag
|
|
416
422
|
self._inPacketCount += 1
|
|
417
423
|
|
|
418
|
-
# Store raw encrypted packet for
|
|
419
|
-
|
|
424
|
+
# Store raw encrypted packet for Android parser analysis
|
|
425
|
+
raw_encrypted_packet = data[:]
|
|
426
|
+
|
|
427
|
+
# Try Android parser on the raw packet BEFORE decryption
|
|
428
|
+
android_switch_event = None
|
|
429
|
+
if AndroidPacketParser:
|
|
430
|
+
try:
|
|
431
|
+
# The Android parser expects unencrypted packets, but let's try anyway
|
|
432
|
+
# to see the structure
|
|
433
|
+
self._logger.debug(f"Attempting Android parser on raw packet: {b2a(raw_encrypted_packet)}")
|
|
434
|
+
android_result = AndroidPacketParser.parse_complete_packet(raw_encrypted_packet)
|
|
435
|
+
self._logger.debug(f"Android parser raw result: {android_result}")
|
|
436
|
+
except Exception as e:
|
|
437
|
+
self._logger.debug(f"Android parser on raw packet failed (expected): {e}")
|
|
420
438
|
|
|
421
439
|
try:
|
|
422
|
-
|
|
440
|
+
decrypted_data = self._encryptor.decryptAndVerify(data, data[:4] + self._nonce[4:])
|
|
423
441
|
except InvalidSignature:
|
|
424
442
|
# We only drop packets with invalid signature here instead of going into an error state
|
|
425
443
|
self._logger.error(f"Invalid signature for packet {b2a(data)}!")
|
|
426
444
|
return
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
445
|
+
|
|
446
|
+
# Now try Android parser on decrypted data
|
|
447
|
+
if AndroidPacketParser:
|
|
448
|
+
try:
|
|
449
|
+
self._logger.debug(f"Attempting Android parser on decrypted packet: {b2a(decrypted_data)}")
|
|
450
|
+
android_result = AndroidPacketParser.parse_complete_packet(decrypted_data)
|
|
451
|
+
if android_result.get('switch_event'):
|
|
452
|
+
android_switch_event = android_result['switch_event']
|
|
453
|
+
self._logger.info(f"Android parser found switch event: {android_switch_event['android_log']}")
|
|
454
|
+
self._logger.debug(f"Android parser decrypted result: {android_result}")
|
|
455
|
+
except Exception as e:
|
|
456
|
+
self._logger.debug(f"Android parser on decrypted packet failed: {e}")
|
|
457
|
+
|
|
458
|
+
packetType = decrypted_data[0]
|
|
459
|
+
self._logger.debug(f"Incoming data of type {packetType}: {b2a(decrypted_data)}")
|
|
430
460
|
|
|
431
461
|
if packetType == IncommingPacketType.UnitState:
|
|
432
|
-
self._parseUnitStates(
|
|
462
|
+
self._parseUnitStates(decrypted_data[1:])
|
|
433
463
|
elif packetType == IncommingPacketType.SwitchEvent:
|
|
434
|
-
self._parseSwitchEvent(
|
|
464
|
+
self._parseSwitchEvent(decrypted_data[1:], self._inPacketCount, raw_encrypted_packet, android_switch_event)
|
|
435
465
|
elif packetType == IncommingPacketType.NetworkConfig:
|
|
436
466
|
# We don't care about the config the network thinks it has.
|
|
437
467
|
# We assume that cloud config and local config match.
|
|
@@ -485,9 +515,13 @@ class CasambiClient:
|
|
|
485
515
|
f"Ran out of data while parsing unit state! Remaining data {b2a(data[oldPos:])} in {b2a(data)}."
|
|
486
516
|
)
|
|
487
517
|
|
|
488
|
-
def _parseSwitchEvent(self, data: bytes, packet_seq: int = None, raw_packet: bytes = None) -> None:
|
|
518
|
+
def _parseSwitchEvent(self, data: bytes, packet_seq: int = None, raw_packet: bytes = None, android_switch_event: dict = None) -> None:
|
|
489
519
|
"""Parse switch event packet which contains multiple message types"""
|
|
490
520
|
self._logger.info(f"Parsing incoming switch event packet... Data: {b2a(data)}")
|
|
521
|
+
|
|
522
|
+
# Log Android parser result if available
|
|
523
|
+
if android_switch_event:
|
|
524
|
+
self._logger.info(f"Android parser comparison: {android_switch_event['android_log']}")
|
|
491
525
|
|
|
492
526
|
pos = 0
|
|
493
527
|
oldPos = 0
|
|
@@ -516,7 +550,7 @@ class CasambiClient:
|
|
|
516
550
|
|
|
517
551
|
# Process based on message type
|
|
518
552
|
if message_type == 0x08 or message_type == 0x10: # Switch/button events
|
|
519
|
-
self._processSwitchMessage(message_type, flags, parameter, payload, data, oldPos, packet_seq, raw_packet)
|
|
553
|
+
self._processSwitchMessage(message_type, flags, parameter, payload, data, oldPos, packet_seq, raw_packet, android_switch_event)
|
|
520
554
|
else:
|
|
521
555
|
# Log other message types for now
|
|
522
556
|
self._logger.debug(
|
|
@@ -532,7 +566,7 @@ class CasambiClient:
|
|
|
532
566
|
f"Remaining data {b2a(data[oldPos:])} in {b2a(data)}."
|
|
533
567
|
)
|
|
534
568
|
|
|
535
|
-
def _processSwitchMessage(self, message_type: int, flags: int, button: int, payload: bytes, full_data: bytes, start_pos: int, packet_seq: int = None, raw_packet: bytes = None) -> None:
|
|
569
|
+
def _processSwitchMessage(self, message_type: int, flags: int, button: int, payload: bytes, full_data: bytes, start_pos: int, packet_seq: int = None, raw_packet: bytes = None, android_switch_event: dict = None) -> None:
|
|
536
570
|
"""Process a switch/button message (types 0x08 or 0x10)"""
|
|
537
571
|
if not payload:
|
|
538
572
|
self._logger.error("Switch message has empty payload")
|
|
@@ -581,6 +615,23 @@ class CasambiClient:
|
|
|
581
615
|
f"Switch event (type 0x{message_type:02x}): button={button}, unit_id={unit_id}, "
|
|
582
616
|
f"action={action_display} ({event_string}), flags=0x{flags:02x}"
|
|
583
617
|
)
|
|
618
|
+
|
|
619
|
+
# Include Android parser comparison if available
|
|
620
|
+
android_comparison = None
|
|
621
|
+
if android_switch_event:
|
|
622
|
+
android_comparison = {
|
|
623
|
+
'unit_id': android_switch_event['unit_id'],
|
|
624
|
+
'button': android_switch_event['button'],
|
|
625
|
+
'state': android_switch_event['state'],
|
|
626
|
+
'param_p': android_switch_event['param_p'],
|
|
627
|
+
'param_s': android_switch_event['param_s'],
|
|
628
|
+
'android_log': android_switch_event['android_log']
|
|
629
|
+
}
|
|
630
|
+
# Log differences
|
|
631
|
+
if android_switch_event['unit_id'] != unit_id:
|
|
632
|
+
self._logger.warning(f"Unit ID mismatch: current={unit_id}, android={android_switch_event['unit_id']}")
|
|
633
|
+
if android_switch_event['button'] != button:
|
|
634
|
+
self._logger.warning(f"Button mismatch: current={button}, android={android_switch_event['button']}")
|
|
584
635
|
|
|
585
636
|
self._dataCallback(
|
|
586
637
|
IncommingPacketType.SwitchEvent,
|
|
@@ -597,6 +648,7 @@ class CasambiClient:
|
|
|
597
648
|
"decrypted_data": b2a(full_data),
|
|
598
649
|
"message_position": start_pos,
|
|
599
650
|
"payload_hex": b2a(payload),
|
|
651
|
+
"android_comparison": android_comparison,
|
|
600
652
|
},
|
|
601
653
|
)
|
|
602
654
|
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Android App Compatible Parser for Casambi Switch Events
|
|
3
|
+
|
|
4
|
+
This module implements the exact parsing logic from the decompiled Android app
|
|
5
|
+
to compare with our current implementation.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import struct
|
|
9
|
+
from typing import Dict, Any, Optional, Tuple
|
|
10
|
+
import logging
|
|
11
|
+
|
|
12
|
+
logger = logging.getLogger(__name__)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class AndroidPacketParser:
|
|
16
|
+
"""Parser that follows the exact Android app implementation"""
|
|
17
|
+
|
|
18
|
+
@staticmethod
|
|
19
|
+
def parse_packet_header(data: bytes, pos: int = 0) -> Tuple[Dict[str, Any], int]:
|
|
20
|
+
"""
|
|
21
|
+
Parse packet header according to Android app C1775b.Q method (lines 230-243)
|
|
22
|
+
|
|
23
|
+
Returns: (header_dict, new_position)
|
|
24
|
+
"""
|
|
25
|
+
if len(data) - pos < 9:
|
|
26
|
+
raise ValueError("Insufficient data for packet header")
|
|
27
|
+
|
|
28
|
+
# Read header (2 bytes)
|
|
29
|
+
unsigned_short = struct.unpack_from('>H', data, pos)[0]
|
|
30
|
+
pos += 2
|
|
31
|
+
|
|
32
|
+
# Extract flags from header
|
|
33
|
+
has_origin_handle = (unsigned_short & 64) != 0 # bit 6
|
|
34
|
+
is_unique = (unsigned_short & 128) != 0 # bit 7
|
|
35
|
+
has_session = (unsigned_short & 256) != 0 # bit 8
|
|
36
|
+
has_origin_handle_alt = (unsigned_short & 512) != 0 # bit 9
|
|
37
|
+
flag_1024 = (unsigned_short & 1024) != 0 # bit 10
|
|
38
|
+
|
|
39
|
+
# Read command type (1 byte) - this is the EnumC1777d ordinal
|
|
40
|
+
command_type = data[pos] & 0xFF
|
|
41
|
+
pos += 1
|
|
42
|
+
|
|
43
|
+
# Read origin (2 bytes)
|
|
44
|
+
origin = struct.unpack_from('>H', data, pos)[0]
|
|
45
|
+
pos += 2
|
|
46
|
+
|
|
47
|
+
# Read target (2 bytes)
|
|
48
|
+
target = struct.unpack_from('>H', data, pos)[0]
|
|
49
|
+
pos += 2
|
|
50
|
+
|
|
51
|
+
# Extract lifetime from header bits 11-14
|
|
52
|
+
lifetime = (unsigned_short >> 11) & 15
|
|
53
|
+
|
|
54
|
+
# Read age (2 bytes)
|
|
55
|
+
age = struct.unpack_from('>H', data, pos)[0]
|
|
56
|
+
pos += 2
|
|
57
|
+
|
|
58
|
+
# Read optional origin handle if flag is set
|
|
59
|
+
origin_handle = None
|
|
60
|
+
if has_origin_handle_alt:
|
|
61
|
+
origin_handle = data[pos] & 0xFF
|
|
62
|
+
pos += 1
|
|
63
|
+
|
|
64
|
+
# Extract payload length from header bits 0-5
|
|
65
|
+
payload_length = unsigned_short & 63
|
|
66
|
+
|
|
67
|
+
header = {
|
|
68
|
+
'header_raw': unsigned_short,
|
|
69
|
+
'command_type': command_type,
|
|
70
|
+
'origin': origin,
|
|
71
|
+
'target': target,
|
|
72
|
+
'lifetime': lifetime,
|
|
73
|
+
'age': age,
|
|
74
|
+
'origin_handle': origin_handle,
|
|
75
|
+
'payload_length': payload_length,
|
|
76
|
+
'flags': {
|
|
77
|
+
'has_origin_handle': has_origin_handle,
|
|
78
|
+
'is_unique': is_unique,
|
|
79
|
+
'has_session': has_session,
|
|
80
|
+
'has_origin_handle_alt': has_origin_handle_alt,
|
|
81
|
+
'flag_1024': flag_1024
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
return header, pos
|
|
86
|
+
|
|
87
|
+
@staticmethod
|
|
88
|
+
def parse_switch_event(header: Dict[str, Any], payload: bytes) -> Optional[Dict[str, Any]]:
|
|
89
|
+
"""
|
|
90
|
+
Parse switch event according to Android app logic (lines 271-280)
|
|
91
|
+
|
|
92
|
+
The Android app checks:
|
|
93
|
+
- target & 0xFF == 6 (lower byte of target must be 6)
|
|
94
|
+
- command_type ordinal must be between 29-36 (FunctionButtonEvent0-7)
|
|
95
|
+
- payload must have at least 3 bytes
|
|
96
|
+
"""
|
|
97
|
+
target_type = header['target'] & 0xFF
|
|
98
|
+
target_unit_id = header['target'] >> 8
|
|
99
|
+
command_type = header['command_type']
|
|
100
|
+
|
|
101
|
+
# Check if this is a switch event
|
|
102
|
+
if target_type != 6:
|
|
103
|
+
return None
|
|
104
|
+
|
|
105
|
+
# Check if command type is in range for button events (29-36)
|
|
106
|
+
if command_type < 29 or command_type > 36:
|
|
107
|
+
return None
|
|
108
|
+
|
|
109
|
+
# Check payload length
|
|
110
|
+
if len(payload) < 3:
|
|
111
|
+
logger.warning(f"Switch event payload too short: {len(payload)} bytes")
|
|
112
|
+
return None
|
|
113
|
+
|
|
114
|
+
# Parse according to Android logic
|
|
115
|
+
first_byte = payload[0]
|
|
116
|
+
button_index = command_type - 29 # 0-7
|
|
117
|
+
|
|
118
|
+
# Extract parameters from first byte
|
|
119
|
+
param_p = (first_byte >> 3) & 15 # bits 3-6
|
|
120
|
+
param_s = first_byte & 7 # bits 0-2
|
|
121
|
+
state = 1 if (first_byte & 128) else 0 # bit 7
|
|
122
|
+
|
|
123
|
+
return {
|
|
124
|
+
'unit_id': target_unit_id,
|
|
125
|
+
'button': button_index,
|
|
126
|
+
'state': state, # 1 = pressed, 0 = released
|
|
127
|
+
'param_p': param_p,
|
|
128
|
+
'param_s': param_s,
|
|
129
|
+
'target_type': target_type,
|
|
130
|
+
'command_type': command_type,
|
|
131
|
+
'payload_hex': payload.hex(),
|
|
132
|
+
'android_log': f"Unit {target_unit_id} Switch event: #{button_index} (P{param_p} S{param_s}) = {state}"
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
@staticmethod
|
|
136
|
+
def parse_complete_packet(data: bytes) -> Dict[str, Any]:
|
|
137
|
+
"""Parse a complete packet and extract switch events if present"""
|
|
138
|
+
try:
|
|
139
|
+
header, payload_start = AndroidPacketParser.parse_packet_header(data)
|
|
140
|
+
|
|
141
|
+
# Read payload
|
|
142
|
+
payload_length = header['payload_length']
|
|
143
|
+
if len(data) - payload_start < payload_length:
|
|
144
|
+
raise ValueError(f"Insufficient data for payload: need {payload_length}, have {len(data) - payload_start}")
|
|
145
|
+
|
|
146
|
+
payload = data[payload_start:payload_start + payload_length]
|
|
147
|
+
|
|
148
|
+
# Try to parse as switch event
|
|
149
|
+
switch_event = AndroidPacketParser.parse_switch_event(header, payload)
|
|
150
|
+
|
|
151
|
+
return {
|
|
152
|
+
'header': header,
|
|
153
|
+
'payload': payload.hex(),
|
|
154
|
+
'switch_event': switch_event
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
except Exception as e:
|
|
158
|
+
logger.error(f"Failed to parse packet: {e}")
|
|
159
|
+
return {'error': str(e), 'data': data.hex()}
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
# Command type constants from EnumC1777d
|
|
163
|
+
class FunctionType:
|
|
164
|
+
"""Function type constants from Android app"""
|
|
165
|
+
BUTTON_EVENT_0 = 29
|
|
166
|
+
BUTTON_EVENT_1 = 30
|
|
167
|
+
BUTTON_EVENT_2 = 31
|
|
168
|
+
BUTTON_EVENT_3 = 32
|
|
169
|
+
BUTTON_EVENT_4 = 33
|
|
170
|
+
BUTTON_EVENT_5 = 34
|
|
171
|
+
BUTTON_EVENT_6 = 35
|
|
172
|
+
BUTTON_EVENT_7 = 36
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def compare_with_current_parser(data: bytes, current_parser_result: Dict[str, Any]) -> Dict[str, Any]:
|
|
176
|
+
"""Compare Android parser results with current implementation"""
|
|
177
|
+
android_result = AndroidPacketParser.parse_complete_packet(data)
|
|
178
|
+
|
|
179
|
+
comparison = {
|
|
180
|
+
'android_parser': android_result,
|
|
181
|
+
'current_parser': current_parser_result,
|
|
182
|
+
'differences': []
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
# Compare results
|
|
186
|
+
if android_result.get('switch_event') and current_parser_result:
|
|
187
|
+
android_evt = android_result['switch_event']
|
|
188
|
+
|
|
189
|
+
# Check unit_id
|
|
190
|
+
if android_evt['unit_id'] != current_parser_result.get('unit_id'):
|
|
191
|
+
comparison['differences'].append({
|
|
192
|
+
'field': 'unit_id',
|
|
193
|
+
'android': android_evt['unit_id'],
|
|
194
|
+
'current': current_parser_result.get('unit_id')
|
|
195
|
+
})
|
|
196
|
+
|
|
197
|
+
# Check button
|
|
198
|
+
if android_evt['button'] != current_parser_result.get('button'):
|
|
199
|
+
comparison['differences'].append({
|
|
200
|
+
'field': 'button',
|
|
201
|
+
'android': android_evt['button'],
|
|
202
|
+
'current': current_parser_result.get('button')
|
|
203
|
+
})
|
|
204
|
+
|
|
205
|
+
# Map Android state to current event names
|
|
206
|
+
android_event = 'button_press' if android_evt['state'] == 1 else 'button_release'
|
|
207
|
+
if android_event != current_parser_result.get('event'):
|
|
208
|
+
comparison['differences'].append({
|
|
209
|
+
'field': 'event',
|
|
210
|
+
'android': android_event,
|
|
211
|
+
'current': current_parser_result.get('event'),
|
|
212
|
+
'note': 'Android only has press/release, no hold detection'
|
|
213
|
+
})
|
|
214
|
+
|
|
215
|
+
return comparison
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
CasambiBt/__init__.py,sha256=TW445xSu5PV3TyMjJfwaA1JoWvQQ8LXhZgGdDTfWf3s,302
|
|
2
2
|
CasambiBt/_cache.py,sha256=KZ2xbiHAHXUPa8Gw_75Nw9NL4QSY_sTWHbyYXYUDaB0,3865
|
|
3
3
|
CasambiBt/_casambi.py,sha256=gLLkhEcObgapqTx5Mk7WRClyG29UyfZYZCCIhhOg4H4,23101
|
|
4
|
-
CasambiBt/_client.py,sha256=
|
|
4
|
+
CasambiBt/_client.py,sha256=PzZTdku7H7majU8j1EnGQBBTTQwP0b3HMEaXNFUabqg,26801
|
|
5
|
+
CasambiBt/_client_android_parser.py,sha256=1lVkVYMO0Nhh9_nkNwgb68hlCS_uD8WxYQDir5hOdHs,7744
|
|
5
6
|
CasambiBt/_constants.py,sha256=_AxkG7Btxl4VeS6mO7GJW5Kc9dFs3s9sDmtJ83ZEKNw,359
|
|
6
7
|
CasambiBt/_discover.py,sha256=H7HpiFYIy9ELvmPXXd_ck-5O5invJf15dDIRk-vO5IE,1696
|
|
7
8
|
CasambiBt/_encryption.py,sha256=CLcoOOrggQqhJbnr_emBnEnkizpWDvb_0yFnitq4_FM,3831
|
|
@@ -11,8 +12,8 @@ CasambiBt/_operation.py,sha256=-BuC1Bvtg-G-zSN_b_0JMvXdHZaR6LbTw0S425jg96c,842
|
|
|
11
12
|
CasambiBt/_unit.py,sha256=YiQWoHmMDWHHo4XAjtW8rHsBqIqpmp9MVdv1Mbu2xw4,17043
|
|
12
13
|
CasambiBt/errors.py,sha256=0JgDjaKlAKDes0poWzA8nrTUYQ8qdNfBb8dfaqqzCRA,1664
|
|
13
14
|
CasambiBt/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
14
|
-
casambi_bt_revamped-0.3.
|
|
15
|
-
casambi_bt_revamped-0.3.
|
|
16
|
-
casambi_bt_revamped-0.3.
|
|
17
|
-
casambi_bt_revamped-0.3.
|
|
18
|
-
casambi_bt_revamped-0.3.
|
|
15
|
+
casambi_bt_revamped-0.3.6.dev2.dist-info/licenses/LICENSE,sha256=TAIIitFxpxEDi6Iju7foW4TDQmWvC-IhLVLhl67jKmQ,11341
|
|
16
|
+
casambi_bt_revamped-0.3.6.dev2.dist-info/METADATA,sha256=3mfGXPkIVbHgcjRW1SAKCwW6GC71ap5-4Cdv_C_tyQU,3049
|
|
17
|
+
casambi_bt_revamped-0.3.6.dev2.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
|
|
18
|
+
casambi_bt_revamped-0.3.6.dev2.dist-info/top_level.txt,sha256=uNbqLjtecFosoFzpGAC89-5icikWODKI8rOjbi8v_sA,10
|
|
19
|
+
casambi_bt_revamped-0.3.6.dev2.dist-info/RECORD,,
|
|
File without changes
|
{casambi_bt_revamped-0.3.5.dist-info → casambi_bt_revamped-0.3.6.dev2.dist-info}/licenses/LICENSE
RENAMED
|
File without changes
|
{casambi_bt_revamped-0.3.5.dist-info → casambi_bt_revamped-0.3.6.dev2.dist-info}/top_level.txt
RENAMED
|
File without changes
|