difonlib 0.2.2a1__tar.gz → 0.2.4__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.
- {difonlib-0.2.2a1 → difonlib-0.2.4}/PKG-INFO +5 -4
- {difonlib-0.2.2a1 → difonlib-0.2.4}/pyproject.toml +11 -7
- difonlib-0.2.4/src/difonlib/bt_scanner.py +277 -0
- {difonlib-0.2.2a1 → difonlib-0.2.4}/src/difonlib/bt_utils.py +128 -23
- {difonlib-0.2.2a1 → difonlib-0.2.4}/src/difonlib/input_devs.py +34 -1
- {difonlib-0.2.2a1 → difonlib-0.2.4}/src/difonlib/utils.py +20 -8
- {difonlib-0.2.2a1 → difonlib-0.2.4}/src/difonlib.egg-info/PKG-INFO +5 -4
- {difonlib-0.2.2a1 → difonlib-0.2.4}/src/difonlib.egg-info/SOURCES.txt +1 -0
- difonlib-0.2.4/src/difonlib.egg-info/requires.txt +10 -0
- {difonlib-0.2.2a1 → difonlib-0.2.4}/tests/test_bt_utils.py +2 -1
- difonlib-0.2.4/tests/test_utils.py +103 -0
- difonlib-0.2.2a1/src/difonlib.egg-info/requires.txt +0 -9
- difonlib-0.2.2a1/tests/test_utils.py +0 -6
- {difonlib-0.2.2a1 → difonlib-0.2.4}/README.md +0 -0
- {difonlib-0.2.2a1 → difonlib-0.2.4}/setup.cfg +0 -0
- {difonlib-0.2.2a1 → difonlib-0.2.4}/src/difonlib/__init__.py +0 -0
- {difonlib-0.2.2a1 → difonlib-0.2.4}/src/difonlib/ng_lib.py +0 -0
- {difonlib-0.2.2a1 → difonlib-0.2.4}/src/difonlib/py.typed +0 -0
- {difonlib-0.2.2a1 → difonlib-0.2.4}/src/difonlib/remctrl.py +0 -0
- {difonlib-0.2.2a1 → difonlib-0.2.4}/src/difonlib/tuya_devs.py +0 -0
- {difonlib-0.2.2a1 → difonlib-0.2.4}/src/difonlib.egg-info/dependency_links.txt +0 -0
- {difonlib-0.2.2a1 → difonlib-0.2.4}/src/difonlib.egg-info/top_level.txt +0 -0
|
@@ -1,17 +1,18 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: difonlib
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.4
|
|
4
4
|
Summary: python libraries
|
|
5
5
|
Requires-Python: >=3.10
|
|
6
6
|
Description-Content-Type: text/markdown
|
|
7
|
+
Requires-Dist: bleak>=3.0.1
|
|
7
8
|
Requires-Dist: evdev>=1.9.2
|
|
9
|
+
Requires-Dist: hid>=1.0.9
|
|
8
10
|
Requires-Dist: nicegui>=3.2.0
|
|
9
11
|
Requires-Dist: pexpect>=4.9.0
|
|
12
|
+
Requires-Dist: pydbus>=0.6.0
|
|
13
|
+
Requires-Dist: pygobject>=3.56.2
|
|
10
14
|
Requires-Dist: pyyaml>=6.0.3
|
|
11
15
|
Requires-Dist: tinytuya>=1.17.4
|
|
12
|
-
Requires-Dist: types-pexpect>=4.9.0.20250916
|
|
13
|
-
Requires-Dist: types-PyYAML>=6.0.12.20250915
|
|
14
|
-
Requires-Dist: types-xmltodict>=1.0.1.20250920
|
|
15
16
|
Requires-Dist: xmltodict>=1.0.2
|
|
16
17
|
|
|
17
18
|
# Python libraries
|
|
@@ -1,18 +1,19 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "difonlib"
|
|
3
|
-
version = "0.2.
|
|
3
|
+
version = "0.2.4"
|
|
4
4
|
description = "python libraries"
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
requires-python = ">=3.10"
|
|
7
7
|
dependencies = [
|
|
8
|
+
"bleak>=3.0.1",
|
|
8
9
|
"evdev>=1.9.2",
|
|
10
|
+
"hid>=1.0.9",
|
|
9
11
|
"nicegui>=3.2.0",
|
|
10
12
|
"pexpect>=4.9.0",
|
|
13
|
+
"pydbus>=0.6.0",
|
|
14
|
+
"pygobject>=3.56.2",
|
|
11
15
|
"pyyaml>=6.0.3",
|
|
12
16
|
"tinytuya>=1.17.4",
|
|
13
|
-
"types-pexpect>=4.9.0.20250916",
|
|
14
|
-
"types-PyYAML>=6.0.12.20250915",
|
|
15
|
-
"types-xmltodict>=1.0.1.20250920",
|
|
16
17
|
"xmltodict>=1.0.2",
|
|
17
18
|
]
|
|
18
19
|
|
|
@@ -36,9 +37,9 @@ warn_return_any = true
|
|
|
36
37
|
mypy_path = ["src"]
|
|
37
38
|
|
|
38
39
|
|
|
39
|
-
[tool.pytest.ini_options]
|
|
40
|
-
addopts = "-v"
|
|
41
|
-
testpaths = ["tests"]
|
|
40
|
+
# [tool.pytest.ini_options]
|
|
41
|
+
# addopts = "-v"
|
|
42
|
+
# testpaths = ["tests"]
|
|
42
43
|
|
|
43
44
|
[dependency-groups]
|
|
44
45
|
dev = [
|
|
@@ -47,6 +48,9 @@ dev = [
|
|
|
47
48
|
"black>=24.8.0",
|
|
48
49
|
"mypy>=1.11.0",
|
|
49
50
|
"twine>=5.1.1",
|
|
51
|
+
"types-pexpect>=4.9.0.20250916",
|
|
52
|
+
"types-PyYAML>=6.0.12.20250915",
|
|
53
|
+
"types-xmltodict>=1.0.1.20250920",
|
|
50
54
|
]
|
|
51
55
|
|
|
52
56
|
[tool.ruff]
|
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Bluetooth HID Device Scanner
|
|
3
|
+
Requires: pip install bleak hid
|
|
4
|
+
On Linux may also need: sudo apt install libhidapi-hidraw0 libhidapi-libusb0
|
|
5
|
+
Run with: python bt_hid_scanner.py
|
|
6
|
+
https://claude.ai/share/54cf3394-15f2-4ca9-8730-b70bf2973646
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import asyncio
|
|
10
|
+
import hid
|
|
11
|
+
import pydbus
|
|
12
|
+
from bleak import BleakScanner
|
|
13
|
+
from bleak.backends.device import BLEDevice
|
|
14
|
+
from bleak.backends.scanner import AdvertisementData
|
|
15
|
+
from ctypes import LittleEndianStructure, c_uint32
|
|
16
|
+
|
|
17
|
+
# HID Usage Page 0x01 = Generic Desktop
|
|
18
|
+
# HID Usages: 0x02=Mouse, 0x04=Joystick, 0x05=Gamepad, 0x06=Keyboard, 0x08=Multi-axis
|
|
19
|
+
HID_USAGE_NAMES = {
|
|
20
|
+
0x01: "Pointer",
|
|
21
|
+
0x02: "Mouse",
|
|
22
|
+
0x04: "Joystick",
|
|
23
|
+
0x05: "Gamepad",
|
|
24
|
+
0x06: "Keyboard",
|
|
25
|
+
0x07: "Keypad",
|
|
26
|
+
0x08: "Multi-axis Controller",
|
|
27
|
+
0x09: "Tablet PC Controls",
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
# Bluetooth HID Service UUID
|
|
32
|
+
HID_SERVICE_UUID = "00001812-0000-1000-8000-00805f9b34fb"
|
|
33
|
+
|
|
34
|
+
MAJOR_CLASSES = {
|
|
35
|
+
0x00: "Miscellaneous",
|
|
36
|
+
0x01: "Computer",
|
|
37
|
+
0x02: "Phone",
|
|
38
|
+
0x03: "LAN/Network Access Point",
|
|
39
|
+
0x04: "Audio/Video",
|
|
40
|
+
0x05: "HID Device",
|
|
41
|
+
0x06: "Imaging",
|
|
42
|
+
0x07: "Wearable",
|
|
43
|
+
0x08: "Toy",
|
|
44
|
+
0x09: "Health",
|
|
45
|
+
0x1F: "Uncategorized",
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
# === Class of Device (CoD)===
|
|
50
|
+
class CoD(LittleEndianStructure):
|
|
51
|
+
_fields_ = [
|
|
52
|
+
("FormatType", c_uint32, 2), # 2 бита
|
|
53
|
+
("MinorDevClass", c_uint32, 6), # 6 бит
|
|
54
|
+
("MajorDevClass", c_uint32, 5), # 5 бит
|
|
55
|
+
("ServiceClass", c_uint32, 11), # 11 бит
|
|
56
|
+
("Reserved", c_uint32, 8), # оставшиеся до 32
|
|
57
|
+
]
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def bt_parse_cod(cod_val: int) -> str:
|
|
61
|
+
"""Разобрать Class of Device (CoD) в словарь с описанием"""
|
|
62
|
+
# cod32 = c_uint32(int(cod_val, 0))
|
|
63
|
+
cod32 = c_uint32(cod_val)
|
|
64
|
+
fields = CoD.from_buffer_copy(cod32)
|
|
65
|
+
major = fields.MajorDevClass
|
|
66
|
+
return MAJOR_CLASSES.get(major, f"0x{major:02X}")
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
async def adapter_restart(adapter_path: str = "/org/bluez/hci0", duration: float = 0.5) -> None:
|
|
70
|
+
bus = pydbus.SystemBus()
|
|
71
|
+
adapter = bus.get("org.bluez", adapter_path)
|
|
72
|
+
adapter.StartDiscovery()
|
|
73
|
+
await asyncio.sleep(duration)
|
|
74
|
+
try:
|
|
75
|
+
adapter.StopDiscovery()
|
|
76
|
+
except Exception:
|
|
77
|
+
pass
|
|
78
|
+
await asyncio.sleep(0.5)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
# ─── USB HID Devices (connected right now) ────────────────────────────────────
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def scan_usb_hid_devices() -> list[dict]:
|
|
85
|
+
"""Return a list of currently connected USB HID devices."""
|
|
86
|
+
devices = []
|
|
87
|
+
seen = set()
|
|
88
|
+
|
|
89
|
+
for info in hid.enumerate():
|
|
90
|
+
key = (info["vendor_id"], info["product_id"], info["usage_page"], info["usage"])
|
|
91
|
+
if key in seen:
|
|
92
|
+
continue
|
|
93
|
+
seen.add(key)
|
|
94
|
+
|
|
95
|
+
devices.append(
|
|
96
|
+
{
|
|
97
|
+
"name": info.get("product_string") or "Unknown",
|
|
98
|
+
"manufacturer": info.get("manufacturer_string") or "Unknown",
|
|
99
|
+
"vid": f"0x{info['vendor_id']:04X}",
|
|
100
|
+
"pid": f"0x{info['product_id']:04X}",
|
|
101
|
+
"usage_page": f"0x{info['usage_page']:04X}",
|
|
102
|
+
"usage": HID_USAGE_NAMES.get(info["usage"], f"0x{info['usage']:04X}"),
|
|
103
|
+
"serial": info.get("serial_number") or "—",
|
|
104
|
+
"path": info.get("path", b"").decode(errors="replace"),
|
|
105
|
+
"interface": info.get("interface_number", -1),
|
|
106
|
+
"transport": "USB / HID",
|
|
107
|
+
}
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
return devices
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
# ─── Bluetooth LE HID Devices (nearby) ────────────────────────────────────────
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
class BluetoothScanner:
|
|
117
|
+
def __init__(self, duration: float = 5.0, dev_type: str = ""):
|
|
118
|
+
self.duration = duration
|
|
119
|
+
self.found: dict[str, dict] = {} # addr -> device info
|
|
120
|
+
self.dev_type = dev_type
|
|
121
|
+
self.available_types = [v for v in MAJOR_CLASSES.values()]
|
|
122
|
+
|
|
123
|
+
def _on_device(self, device: BLEDevice, adv: AdvertisementData) -> None:
|
|
124
|
+
# print(f"device: {device}")
|
|
125
|
+
# print(f"Class: {device.details['Class']}")
|
|
126
|
+
# print(f"adv: {adv}")
|
|
127
|
+
service_uuids = [u.lower() for u in (adv.service_uuids or [])]
|
|
128
|
+
ble_hid_dev = HID_SERVICE_UUID in service_uuids
|
|
129
|
+
dclass = device.details.get("props", {}).get("Class")
|
|
130
|
+
# print(f" = DETAILS: {device.details}")
|
|
131
|
+
dev_class = None
|
|
132
|
+
if ble_hid_dev:
|
|
133
|
+
dev_class = "HID Device"
|
|
134
|
+
elif dclass:
|
|
135
|
+
dev_class = bt_parse_cod(dclass)
|
|
136
|
+
|
|
137
|
+
addr = device.address
|
|
138
|
+
if addr in self.found:
|
|
139
|
+
# update RSSI
|
|
140
|
+
self.found[addr]["rssi"] = adv.rssi
|
|
141
|
+
return
|
|
142
|
+
|
|
143
|
+
self.found[addr] = {
|
|
144
|
+
"name": device.name or "Unknown",
|
|
145
|
+
"address": addr,
|
|
146
|
+
"dev_class": dev_class,
|
|
147
|
+
"rssi": adv.rssi,
|
|
148
|
+
"services": service_uuids,
|
|
149
|
+
"tx_power": adv.tx_power,
|
|
150
|
+
}
|
|
151
|
+
# print(
|
|
152
|
+
# f" [+] Found: {device.name or 'Unknown':30s} {addr} RSSI {adv.rssi} dBm"
|
|
153
|
+
# )
|
|
154
|
+
# print(f" service_uuids: {service_uuids}")
|
|
155
|
+
|
|
156
|
+
def _filter(self, devs: list[dict], dev_type: str) -> list[dict]:
|
|
157
|
+
_devs = []
|
|
158
|
+
for dev in devs:
|
|
159
|
+
if dev["dev_class"] == dev_type:
|
|
160
|
+
_devs.append(dev)
|
|
161
|
+
return _devs
|
|
162
|
+
|
|
163
|
+
async def run(self) -> list[dict]:
|
|
164
|
+
print(f" Scanning BLE for {self.duration}s ...")
|
|
165
|
+
async with BleakScanner(detection_callback=self._on_device):
|
|
166
|
+
await asyncio.sleep(self.duration)
|
|
167
|
+
if self.dev_type in self.available_types:
|
|
168
|
+
return self._filter(list(self.found.values()), self.dev_type)
|
|
169
|
+
elif self.dev_type != "":
|
|
170
|
+
print(" =!= Device Type ERROR")
|
|
171
|
+
print(f" Type '{self.dev_type}' is not available.")
|
|
172
|
+
print(f" Available types: {self.available_types}")
|
|
173
|
+
return list(self.found.values())
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
# def get_bt_hid_devs(devs:list[dict]) -> list[dict]:
|
|
177
|
+
# hid_devs = []
|
|
178
|
+
# for dev in devs:
|
|
179
|
+
# if HID_SERVICE_UUID in dev["services"] or dev['dev_class'] == "HID Device":
|
|
180
|
+
# hid_devs.append(dev)
|
|
181
|
+
# return hid_devs
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
# ─── Pretty printing ───────────────────────────────────────────────────────────
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def print_separator(char: str = "─", width: int = 60) -> None:
|
|
188
|
+
print(char * width)
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def print_usb_devices(devices: list[dict]) -> None:
|
|
192
|
+
print_separator("═")
|
|
193
|
+
print(f" USB HID DEVICES ({len(devices)} found)")
|
|
194
|
+
print_separator("═")
|
|
195
|
+
|
|
196
|
+
if not devices:
|
|
197
|
+
print(" No USB HID devices found.\n")
|
|
198
|
+
return
|
|
199
|
+
|
|
200
|
+
for i, d in enumerate(devices, 1):
|
|
201
|
+
print(f"\n [{i}] {d['name']}")
|
|
202
|
+
print(f" Manufacturer : {d['manufacturer']}")
|
|
203
|
+
print(f" VID / PID : {d['vid']} / {d['pid']}")
|
|
204
|
+
print(f" Usage : {d['usage']} (page {d['usage_page']})")
|
|
205
|
+
print(f" Serial : {d['serial']}")
|
|
206
|
+
print(f" Interface : {d['interface']}")
|
|
207
|
+
print(f" Path : {d['path']}")
|
|
208
|
+
|
|
209
|
+
print()
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def print_bt_devices(devices: list[dict], type: str = "DEVICES") -> None:
|
|
213
|
+
print_separator("═")
|
|
214
|
+
print(f" BLUETOOTH AVAILABLE {type} ({len(devices)} found)")
|
|
215
|
+
print_separator("═")
|
|
216
|
+
|
|
217
|
+
if not devices:
|
|
218
|
+
print(" No devices found.\n")
|
|
219
|
+
return
|
|
220
|
+
|
|
221
|
+
for i, d in enumerate(devices, 1):
|
|
222
|
+
tx = f"{d['tx_power']} dBm" if d["tx_power"] is not None else "—"
|
|
223
|
+
print(f"\n [{i}] {d['name']}")
|
|
224
|
+
print(f" Address : {d['address']}")
|
|
225
|
+
print(f" Services : {d['services']}")
|
|
226
|
+
print(f" Device Class : {d['dev_class']}")
|
|
227
|
+
print(f" RSSI : {d['rssi']} dBm")
|
|
228
|
+
print(f" TX Power : {tx}")
|
|
229
|
+
# print(f" Transport : {d['transport']}")
|
|
230
|
+
|
|
231
|
+
print()
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
# ─── Main ──────────────────────────────────────────────────────────────────────
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
async def main() -> None:
|
|
238
|
+
print("\n╔══════════════════════════════════════════════════════════╗")
|
|
239
|
+
print("║ Bluetooth Device Scanner ║")
|
|
240
|
+
print("╚══════════════════════════════════════════════════════════╝\n")
|
|
241
|
+
|
|
242
|
+
# # 1. USB HID
|
|
243
|
+
# print("Scanning USB HID devices ...")
|
|
244
|
+
# usb_devices = scan_usb_hid_devices()
|
|
245
|
+
# print_usb_devices(usb_devices)
|
|
246
|
+
|
|
247
|
+
# # Сначала — inquiry, чтобы BlueZ узнал о Classic устройствах
|
|
248
|
+
# print("Triggering Classic BT inquiry via bluetoothctl...")
|
|
249
|
+
# await trigger_classic_inquiry(duration=2)
|
|
250
|
+
|
|
251
|
+
await adapter_restart()
|
|
252
|
+
|
|
253
|
+
# 2. BLE HID
|
|
254
|
+
try:
|
|
255
|
+
# bt_scanner = BluetoothScanner(duration=6.0, dev_type="Audio/Video")
|
|
256
|
+
dev_type = "HID Device"
|
|
257
|
+
# dev_type = ""
|
|
258
|
+
bt_scanner = BluetoothScanner(duration=6.0, dev_type=dev_type)
|
|
259
|
+
bt_devices = await bt_scanner.run()
|
|
260
|
+
print_bt_devices(bt_devices, type=dev_type)
|
|
261
|
+
except Exception as e:
|
|
262
|
+
print(f" Bluetooth device scan failed: {e}")
|
|
263
|
+
print(" Make sure Bluetooth is enabled and bleak is installed.\n")
|
|
264
|
+
bt_devices = []
|
|
265
|
+
|
|
266
|
+
# # 3. Summary
|
|
267
|
+
# print_separator("═")
|
|
268
|
+
# total = len(usb_devices) + len(ble_devices)
|
|
269
|
+
# print(
|
|
270
|
+
# f" SUMMARY: {len(usb_devices)} USB HID + {len(ble_devices)} BLE HID = {total} total devices"
|
|
271
|
+
# )
|
|
272
|
+
# print_separator("═")
|
|
273
|
+
# print(f"bt_devices: {bt_devices}")
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
if __name__ == "__main__":
|
|
277
|
+
asyncio.run(main())
|
|
@@ -1,17 +1,26 @@
|
|
|
1
1
|
#!/usr/bin/env python
|
|
2
2
|
|
|
3
|
+
import sys
|
|
4
|
+
import time
|
|
3
5
|
import os
|
|
4
6
|
import re
|
|
5
7
|
import pexpect
|
|
6
|
-
import sys
|
|
7
|
-
import time
|
|
8
8
|
from typing import Optional
|
|
9
9
|
from ctypes import LittleEndianStructure, c_uint32
|
|
10
|
+
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
10
13
|
from difonlib.utils import logdbg
|
|
11
14
|
from difonlib.input_devs import get_connected_input_devices
|
|
12
15
|
|
|
16
|
+
import asyncio
|
|
17
|
+
import pydbus
|
|
18
|
+
from bleak import BleakScanner
|
|
19
|
+
from bleak.backends.device import BLEDevice
|
|
20
|
+
from bleak.backends.scanner import AdvertisementData
|
|
13
21
|
|
|
14
22
|
dbg = logdbg
|
|
23
|
+
# dbg = print
|
|
15
24
|
|
|
16
25
|
MAJOR_CLASSES = {
|
|
17
26
|
0x00: "Miscellaneous",
|
|
@@ -39,15 +48,81 @@ class CoD(LittleEndianStructure):
|
|
|
39
48
|
]
|
|
40
49
|
|
|
41
50
|
|
|
42
|
-
def bt_parse_cod(cod_val:
|
|
51
|
+
def bt_parse_cod(cod_val: Any) -> str:
|
|
43
52
|
"""Разобрать Class of Device (CoD) в словарь с описанием"""
|
|
44
|
-
cod32 = c_uint32(int(cod_val
|
|
53
|
+
cod32 = c_uint32(int(cod_val))
|
|
45
54
|
fields = CoD.from_buffer_copy(cod32)
|
|
46
55
|
major = fields.MajorDevClass
|
|
47
56
|
return MAJOR_CLASSES.get(major, f"0x{major:02X}")
|
|
48
57
|
|
|
49
58
|
|
|
50
|
-
|
|
59
|
+
# Bluetooth HID Service UUID
|
|
60
|
+
HID_SERVICE_UUID = "00001812-0000-1000-8000-00805f9b34fb"
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class BluetoothScanner:
|
|
64
|
+
def __init__(self, duration: float = 5.0, dev_type: str = ""):
|
|
65
|
+
self.duration = duration
|
|
66
|
+
self.found: dict[str, dict] = {} # addr -> device info
|
|
67
|
+
self.dev_type = dev_type
|
|
68
|
+
self.available_types = [v for v in MAJOR_CLASSES.values()]
|
|
69
|
+
|
|
70
|
+
def _on_device(self, device: BLEDevice, adv: AdvertisementData) -> None:
|
|
71
|
+
# print(f"device: {device}")
|
|
72
|
+
# print(f"Class: {device.details['Class']}")
|
|
73
|
+
# print(f"adv: {adv}")
|
|
74
|
+
service_uuids = [u.lower() for u in (adv.service_uuids or [])]
|
|
75
|
+
ble_hid_dev = HID_SERVICE_UUID in service_uuids
|
|
76
|
+
dclass = device.details.get("props", {}).get("Class")
|
|
77
|
+
# print(f" = DETAILS: {device.details}")
|
|
78
|
+
dev_class = None
|
|
79
|
+
if ble_hid_dev:
|
|
80
|
+
dev_class = "HID Device"
|
|
81
|
+
elif dclass:
|
|
82
|
+
dev_class = bt_parse_cod(dclass)
|
|
83
|
+
|
|
84
|
+
addr = device.address
|
|
85
|
+
if addr in self.found:
|
|
86
|
+
# update RSSI
|
|
87
|
+
self.found[addr]["rssi"] = adv.rssi
|
|
88
|
+
return
|
|
89
|
+
|
|
90
|
+
self.found[addr] = {
|
|
91
|
+
"name": device.name or "Unknown",
|
|
92
|
+
"address": addr,
|
|
93
|
+
"dev_class": dev_class,
|
|
94
|
+
"rssi": adv.rssi,
|
|
95
|
+
"services": service_uuids,
|
|
96
|
+
"tx_power": adv.tx_power,
|
|
97
|
+
}
|
|
98
|
+
print(".", end="", flush=True)
|
|
99
|
+
# print(
|
|
100
|
+
# f" [+] Found: {device.name or 'Unknown':30s} {addr} RSSI {adv.rssi} dBm"
|
|
101
|
+
# )
|
|
102
|
+
# print(f" service_uuids: {service_uuids}")
|
|
103
|
+
|
|
104
|
+
def _filter(self, devs: list[dict], dev_type: str) -> list[dict]:
|
|
105
|
+
_devs = []
|
|
106
|
+
for dev in devs:
|
|
107
|
+
if dev["dev_class"] == dev_type:
|
|
108
|
+
_devs.append(dev)
|
|
109
|
+
return _devs
|
|
110
|
+
|
|
111
|
+
async def run(self) -> list[dict]:
|
|
112
|
+
print(f" Scanning for bluetooth devices {self.duration}s ...", end="")
|
|
113
|
+
async with BleakScanner(detection_callback=self._on_device):
|
|
114
|
+
await asyncio.sleep(self.duration)
|
|
115
|
+
print(" END\n")
|
|
116
|
+
if self.dev_type in self.available_types:
|
|
117
|
+
return self._filter(list(self.found.values()), self.dev_type)
|
|
118
|
+
elif self.dev_type != "":
|
|
119
|
+
print(" =!= Device Type ERROR")
|
|
120
|
+
print(f" Type '{self.dev_type}' is not available.")
|
|
121
|
+
print(f" Available types: {self.available_types}")
|
|
122
|
+
return list(self.found.values())
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def bt_scan_hcitool_inq() -> list:
|
|
51
126
|
# px = pexpect.spawn("hcitool scan", encoding="utf-8")
|
|
52
127
|
devs = []
|
|
53
128
|
# inquery remote devices
|
|
@@ -68,16 +143,7 @@ def bt_scan() -> list:
|
|
|
68
143
|
return devs
|
|
69
144
|
|
|
70
145
|
|
|
71
|
-
def
|
|
72
|
-
devs = bt_scan()
|
|
73
|
-
hid_devs = []
|
|
74
|
-
for dev in devs:
|
|
75
|
-
if dev["class"] == "HID Device":
|
|
76
|
-
hid_devs.append(dev)
|
|
77
|
-
return hid_devs
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
def bt_scan2() -> list:
|
|
146
|
+
def bt_scan_hcitool_scan() -> list:
|
|
81
147
|
# px = pexpect.spawn("hcitool scan", encoding="utf-8")
|
|
82
148
|
devs = []
|
|
83
149
|
_devs = os.popen("hcitool scan").readlines()
|
|
@@ -95,6 +161,40 @@ def bt_scan2() -> list:
|
|
|
95
161
|
return devs
|
|
96
162
|
|
|
97
163
|
|
|
164
|
+
def bt_scan_hid_devs() -> list:
|
|
165
|
+
devs = bt_scan_hcitool_inq()
|
|
166
|
+
hid_devs = []
|
|
167
|
+
for dev in devs:
|
|
168
|
+
if dev["class"] == "HID Device":
|
|
169
|
+
hid_devs.append(dev)
|
|
170
|
+
return hid_devs
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
async def bt_scan_devs( # universally version for classic and ble devices
|
|
174
|
+
dev_type: str = "HID Device",
|
|
175
|
+
inquiry_warmup: float = 4.0, # время для Classic Inquiry
|
|
176
|
+
scan_duration: float = 6.0, # время bleak сканирования
|
|
177
|
+
adapter_path: str = "/org/bluez/hci0",
|
|
178
|
+
) -> list[dict]:
|
|
179
|
+
bus = pydbus.SystemBus()
|
|
180
|
+
adapter = bus.get("org.bluez", adapter_path)
|
|
181
|
+
adapter.SetDiscoveryFilter({})
|
|
182
|
+
adapter.StartDiscovery()
|
|
183
|
+
await asyncio.sleep(inquiry_warmup)
|
|
184
|
+
try:
|
|
185
|
+
bt_scanner = BluetoothScanner(duration=scan_duration, dev_type=dev_type)
|
|
186
|
+
bt_devices = await bt_scanner.run()
|
|
187
|
+
except Exception as e:
|
|
188
|
+
print(f" Bluetooth scan failed: {e}")
|
|
189
|
+
bt_devices = []
|
|
190
|
+
finally:
|
|
191
|
+
try:
|
|
192
|
+
adapter.StopDiscovery()
|
|
193
|
+
except Exception as e:
|
|
194
|
+
print(f" StopDiscovery() error: {e}")
|
|
195
|
+
return bt_devices
|
|
196
|
+
|
|
197
|
+
|
|
98
198
|
def btctl_add(mac_address: str, timeout: int = 10) -> Optional[dict]:
|
|
99
199
|
"""
|
|
100
200
|
Подключает HID или любое Bluetooth устройство по MAC через bluetoothctl.
|
|
@@ -319,18 +419,23 @@ def bt_hid_conn_devs() -> list:
|
|
|
319
419
|
|
|
320
420
|
if __name__ == "__main__":
|
|
321
421
|
|
|
322
|
-
dev_mac = "41:42:68:D8:DA:39"
|
|
422
|
+
# dev_mac = "41:42:68:D8:DA:39"
|
|
423
|
+
|
|
424
|
+
# from difonlib.utils import print_dicts_list
|
|
323
425
|
|
|
324
|
-
|
|
426
|
+
# print(" ======== ALL connected devices ==========")
|
|
427
|
+
# all_connected_devs = get_connected_input_devices()
|
|
428
|
+
# print_dicts_list(all_connected_devs)
|
|
325
429
|
|
|
326
|
-
print(" ========
|
|
327
|
-
|
|
328
|
-
print_dicts_list(
|
|
430
|
+
# print(" ======== HID connected devices ==========")
|
|
431
|
+
# conn_devs = bt_hid_conn_devs()
|
|
432
|
+
# print_dicts_list(conn_devs)
|
|
329
433
|
|
|
330
|
-
|
|
331
|
-
conn_devs = bt_hid_conn_devs()
|
|
332
|
-
print_dicts_list(conn_devs)
|
|
434
|
+
# input("ssssssssssssssssssss")
|
|
333
435
|
|
|
436
|
+
# devs = asyncio.run(bt_scan_devs(dev_type=""))
|
|
437
|
+
devs = asyncio.run(bt_scan_devs())
|
|
438
|
+
print(f"devs: {devs}")
|
|
334
439
|
# available_bt_devs = bt_scan()
|
|
335
440
|
# print_lists(available_bt_devs) # //Dima
|
|
336
441
|
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
from pathlib import Path
|
|
2
|
-
from evdev import InputDevice, categorize
|
|
2
|
+
from evdev import InputDevice, categorize, ecodes, list_devices
|
|
3
3
|
from evdev.events import KeyEvent
|
|
4
4
|
from typing import Dict, Any, List, Optional
|
|
5
|
+
|
|
5
6
|
from difonlib.utils import logdbg
|
|
6
7
|
from dataclasses import dataclass
|
|
7
8
|
import re
|
|
@@ -31,6 +32,38 @@ class IDevKbdKey:
|
|
|
31
32
|
keycode: str | tuple = ""
|
|
32
33
|
|
|
33
34
|
|
|
35
|
+
def idev_get_connected_devs() -> List[InputDevice]:
|
|
36
|
+
return [InputDevice(p) for p in list_devices()]
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def is_remote_ctrl(dev: InputDevice) -> bool:
|
|
40
|
+
caps = dev.capabilities()
|
|
41
|
+
if ecodes.EV_KEY not in caps:
|
|
42
|
+
return False
|
|
43
|
+
keys = set(caps[ecodes.EV_KEY])
|
|
44
|
+
remote_keys = {
|
|
45
|
+
ecodes.KEY_UP,
|
|
46
|
+
ecodes.KEY_DOWN,
|
|
47
|
+
ecodes.KEY_LEFT,
|
|
48
|
+
ecodes.KEY_RIGHT,
|
|
49
|
+
ecodes.KEY_OK,
|
|
50
|
+
ecodes.KEY_SELECT,
|
|
51
|
+
ecodes.KEY_BACK,
|
|
52
|
+
ecodes.KEY_PLAYPAUSE,
|
|
53
|
+
ecodes.KEY_VOLUMEUP,
|
|
54
|
+
ecodes.KEY_VOLUMEDOWN,
|
|
55
|
+
ecodes.KEY_HOME,
|
|
56
|
+
ecodes.KEY_MENU,
|
|
57
|
+
}
|
|
58
|
+
return bool(remote_keys & keys)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def idev_get_dev_by_uniq(uniq: str) -> Optional[InputDevice]:
|
|
62
|
+
devs = idev_get_connected_devs()
|
|
63
|
+
matched = [dev for dev in devs if dev.uniq == uniq and is_remote_ctrl(dev)]
|
|
64
|
+
return matched[0] if matched else None
|
|
65
|
+
|
|
66
|
+
|
|
34
67
|
def get_connected_input_devices() -> List[Dict[str, Any]]:
|
|
35
68
|
path = Path("/proc/bus/input/devices")
|
|
36
69
|
devices: List[Dict[str, Any]] = []
|
|
@@ -39,11 +39,23 @@ MSG_COLOR = f"\x1b[{RESET};38;5;{45}m"
|
|
|
39
39
|
# DEBUG 10
|
|
40
40
|
# NOTSET 0
|
|
41
41
|
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
42
|
+
|
|
43
|
+
# Глобальные логгеры библиотек — заглушить
|
|
44
|
+
logging.getLogger().setLevel(logging.WARNING) # root logger
|
|
45
|
+
|
|
46
|
+
# Свой именованный логгер
|
|
47
|
+
logger = logging.getLogger("DifonLibLogger")
|
|
48
|
+
logger.setLevel(logging.DEBUG)
|
|
49
|
+
|
|
50
|
+
# Handler только на свой логгер
|
|
51
|
+
handler = logging.StreamHandler()
|
|
52
|
+
handler.setFormatter(
|
|
53
|
+
logging.Formatter(f"{MSG_COLOR}[%(filename)s:%(lineno)d]: %(message)s{COLOR_OFF}")
|
|
45
54
|
)
|
|
46
|
-
|
|
55
|
+
logger.addHandler(handler)
|
|
56
|
+
logger.propagate = False # не пускать в root logger
|
|
57
|
+
|
|
58
|
+
logdbg = logger.debug
|
|
47
59
|
|
|
48
60
|
|
|
49
61
|
class UtilsError(Exception):
|
|
@@ -182,14 +194,14 @@ class YamlConfig:
|
|
|
182
194
|
def load(self) -> None:
|
|
183
195
|
if not self.config_path.exists():
|
|
184
196
|
self.save()
|
|
185
|
-
with self.config_path.open() as
|
|
186
|
-
self.config = yaml.safe_load(
|
|
197
|
+
with self.config_path.open() as fcfg:
|
|
198
|
+
self.config = yaml.safe_load(fcfg) or {}
|
|
187
199
|
|
|
188
200
|
def save(self) -> None:
|
|
189
|
-
with self.config_path.open("w") as
|
|
201
|
+
with self.config_path.open("w") as fcfg:
|
|
190
202
|
yaml.safe_dump(
|
|
191
203
|
self.config,
|
|
192
|
-
|
|
204
|
+
fcfg,
|
|
193
205
|
sort_keys=False,
|
|
194
206
|
allow_unicode=True,
|
|
195
207
|
)
|
|
@@ -1,17 +1,18 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: difonlib
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.4
|
|
4
4
|
Summary: python libraries
|
|
5
5
|
Requires-Python: >=3.10
|
|
6
6
|
Description-Content-Type: text/markdown
|
|
7
|
+
Requires-Dist: bleak>=3.0.1
|
|
7
8
|
Requires-Dist: evdev>=1.9.2
|
|
9
|
+
Requires-Dist: hid>=1.0.9
|
|
8
10
|
Requires-Dist: nicegui>=3.2.0
|
|
9
11
|
Requires-Dist: pexpect>=4.9.0
|
|
12
|
+
Requires-Dist: pydbus>=0.6.0
|
|
13
|
+
Requires-Dist: pygobject>=3.56.2
|
|
10
14
|
Requires-Dist: pyyaml>=6.0.3
|
|
11
15
|
Requires-Dist: tinytuya>=1.17.4
|
|
12
|
-
Requires-Dist: types-pexpect>=4.9.0.20250916
|
|
13
|
-
Requires-Dist: types-PyYAML>=6.0.12.20250915
|
|
14
|
-
Requires-Dist: types-xmltodict>=1.0.1.20250920
|
|
15
16
|
Requires-Dist: xmltodict>=1.0.2
|
|
16
17
|
|
|
17
18
|
# Python libraries
|
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
from difonlib.utils import logdbg
|
|
2
2
|
from difonlib.bt_utils import bt_hid_conn_devs, get_connected_input_devices
|
|
3
3
|
|
|
4
|
-
|
|
4
|
+
import pytest
|
|
5
5
|
|
|
6
6
|
dbg = logdbg
|
|
7
7
|
|
|
8
8
|
|
|
9
|
+
@pytest.mark.slow
|
|
9
10
|
def test_bt_utils():
|
|
10
11
|
assert type(bt_hid_conn_devs()) is list, "Return value type is not list"
|
|
11
12
|
assert type(get_connected_input_devices()) is list, "Return value type is not list"
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
import pytest
|
|
3
|
+
from difonlib.utils import (
|
|
4
|
+
logdbg,
|
|
5
|
+
is_mac_address,
|
|
6
|
+
# is_mac_address2,
|
|
7
|
+
mac_format,
|
|
8
|
+
UtilsError,
|
|
9
|
+
to_signed,
|
|
10
|
+
swap16,
|
|
11
|
+
swap32,
|
|
12
|
+
fs_remove_dir_content,
|
|
13
|
+
file_get_latest,
|
|
14
|
+
YamlConfig,
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@pytest.mark.parametrize(
|
|
19
|
+
"mac",
|
|
20
|
+
[
|
|
21
|
+
"AA:BB:CC:DD:EE:FF",
|
|
22
|
+
"aa:bb:cc:dd:ee:ff",
|
|
23
|
+
"01:23:45:67:89:aB",
|
|
24
|
+
],
|
|
25
|
+
)
|
|
26
|
+
def test_is_mac_address_valid(mac):
|
|
27
|
+
assert is_mac_address(mac)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@pytest.mark.parametrize(
|
|
31
|
+
"mac",
|
|
32
|
+
[
|
|
33
|
+
"AA-BB-CC-DD-EE-FF",
|
|
34
|
+
"AABBCCDDEEFF",
|
|
35
|
+
"GG:HH:II:JJ:KK:LL",
|
|
36
|
+
],
|
|
37
|
+
)
|
|
38
|
+
def test_is_mac_address_invalid(mac):
|
|
39
|
+
assert not is_mac_address(mac)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def test_mac_format_ok():
|
|
43
|
+
assert mac_format("112233445566") == "11:22:33:44:55:66"
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def test_mac_format_invalid():
|
|
47
|
+
with pytest.raises(UtilsError):
|
|
48
|
+
mac_format("1234")
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def test_to_signed_positive():
|
|
52
|
+
assert to_signed(10, 8) == 10
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def test_to_signed_negative():
|
|
56
|
+
assert to_signed(0b11111111, 8) == -1
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def test_swap16():
|
|
60
|
+
assert swap16(0x1234) == 0x3412
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def test_swap32():
|
|
64
|
+
assert swap32(0x11223344) == 0x44332211
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def test_fs_remove_dir_content(tmp_dir: Path):
|
|
68
|
+
f = tmp_dir / "a.txt"
|
|
69
|
+
f.write_text("hello")
|
|
70
|
+
|
|
71
|
+
fs_remove_dir_content(str(tmp_dir))
|
|
72
|
+
|
|
73
|
+
assert list(tmp_dir.iterdir()) == []
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def test_file_get_latest(tmp_dir: Path):
|
|
77
|
+
f1 = tmp_dir / "a.txt"
|
|
78
|
+
f2 = tmp_dir / "b.txt"
|
|
79
|
+
f1.write_text("1")
|
|
80
|
+
f2.write_text("2")
|
|
81
|
+
|
|
82
|
+
latest = file_get_latest(str(tmp_dir), "*.txt")
|
|
83
|
+
if latest:
|
|
84
|
+
assert latest.endswith("b.txt")
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def test_yaml_config_create_and_save(yaml_config_path):
|
|
88
|
+
cfg = YamlConfig(str(yaml_config_path))
|
|
89
|
+
cfg.config["a"] = 1
|
|
90
|
+
cfg.save()
|
|
91
|
+
|
|
92
|
+
cfg2 = YamlConfig(str(yaml_config_path))
|
|
93
|
+
assert cfg2.config["a"] == 1
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def test_log_dbg():
|
|
97
|
+
logdbg("hello 12345")
|
|
98
|
+
assert True
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def test_show_tmp(tmp_path):
|
|
102
|
+
# Show value of tmp_path fixture
|
|
103
|
+
print(f"Show value of tmp_path fixture: {tmp_path}")
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|