Wed, 08 Mar 2023 14:25:24 +0100
MicroPython
- Added support for Bluetooth scans for CircuitPython devices.
--- a/eric7.epj Tue Mar 07 16:23:03 2023 +0100 +++ b/eric7.epj Wed Mar 08 14:25:24 2023 +0100 @@ -333,6 +333,7 @@ "src/eric7/IconEditor/IconSizeDialog.ui", "src/eric7/JediInterface/RefactoringPreviewDialog.ui", "src/eric7/MicroPython/AddEditDevicesDialog.ui", + "src/eric7/MicroPython/BluetoothDialogs/BluetoothScanWindow.ui", "src/eric7/MicroPython/BluetoothDialogs/BluetoothStatusDialog.ui", "src/eric7/MicroPython/BoardDataDialog.ui", "src/eric7/MicroPython/ConnectionSelectionDialog.ui", @@ -1290,7 +1291,9 @@ "src/eric7/JediInterface/RefactoringPreviewDialog.py", "src/eric7/JediInterface/__init__.py", "src/eric7/MicroPython/AddEditDevicesDialog.py", + "src/eric7/MicroPython/BluetoothDialogs/BluetoothAdvertisement.py", "src/eric7/MicroPython/BluetoothDialogs/BluetoothController.py", + "src/eric7/MicroPython/BluetoothDialogs/BluetoothScanWindow.py", "src/eric7/MicroPython/BluetoothDialogs/BluetoothStatusDialog.py", "src/eric7/MicroPython/BluetoothDialogs/__init__.py", "src/eric7/MicroPython/BoardDataDialog.py",
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/src/eric7/MicroPython/BluetoothDialogs/BluetoothAdvertisement.py Wed Mar 08 14:25:24 2023 +0100 @@ -0,0 +1,236 @@ +# -*- coding: utf-8 -*- + +# Copyright (c) 2023 Detlev Offenbach <detlev@die-offenbachs.de> +# + +""" +Module implementing a class to parse and store the Bluetooth device advertisement data. +""" + +import struct +import uuid + +ADV_IND = 0 +ADV_SCAN_IND = 2 +ADV_NONCONN_IND = 3 +SCAN_RSP = 4 + +ADV_TYPE_UUID16_INCOMPLETE = 0x02 +ADV_TYPE_UUID16_COMPLETE = 0x03 +ADV_TYPE_UUID32_INCOMPLETE = 0x04 +ADV_TYPE_UUID32_COMPLETE = 0x05 +ADV_TYPE_UUID128_INCOMPLETE = 0x06 +ADV_TYPE_UUID128_COMPLETE = 0x07 +ADV_TYPE_SHORT_NAME = 0x08 +ADV_TYPE_COMPLETE_NAME = 0x09 +ADV_TYPE_TX_POWER_LEVEL = 0x0A +ADV_TYPE_SVC_DATA = 0x16 +ADV_TYPE_MANUFACTURER = 0xFF + +ManufacturerId = { + 0x4C: "Apple, Inc.", + 0xE0: "Google", + 0x75: "Samsung Electronics Co. Ltd.", + 0x87: "Garmin International Inc.", +} + + +class BluetoothAdvertisement: + """ + Class to parse and store the Bluetooth device advertisement data. + """ + + def __init__(self, address): + """ + Constructor + + @param address address of the device advertisement + @type str + """ + self.__address = address + self.__name = "" + self.__rssi = 0 + self.__connectable = False + + self.__advData = None + self.__respData = None + + def update(self, advType, rssi, advData): + """ + Public method to update the advertisement data. + + @param advType type of advertisement data + @type int + @param rssi RSSI value in dBm + @type int + @param advData advertisement data + @type bytes + """ + if rssi != self.__rssi: + self.__rssi = rssi + + if advType in (ADV_IND, ADV_NONCONN_IND): + if advData != self.__advData: + self.__advData = advData + self.__connectable = advType == ADV_IND + elif advType == ADV_SCAN_IND: + self.__advData = advData + elif advType == SCAN_RSP and advData and advData != self.__respData: + self.__respData = advData + + def __str__(self): + """ + Special method to generate a string representation. + + @return string representation + @rtype str + """ + return "Scan result: {0} {1}".format(self.__address, self.__rssi) + + def __decodeField(self, *advType): + """ + Private method to get all fields of the specified types. + + @param *advType type of fields to be extracted + @type int + @yield requested fields + @ytype bytes + """ + # Advertising payloads are repeated packets of the following form: + # 1 byte data length (N + 1) + # 1 byte type (see constants below) + # N bytes type-specific data + for payload in (self.__advData, self.__respData): + if not payload: + continue + + i = 0 + while i + 1 < len(payload): + if payload[i + 1] in advType: + yield payload[i + 2 : i + payload[i] + 1] + i += 1 + payload[i] + + def __splitBytes(self, data, chunkSize): + """ + Private method to split some data into chunks of given size. + + @param data data to be chunked + @type bytes, bytearray, str + @param chunkSize size for each chunk + @type int + @return list of chunks + @rtype list of bytes, bytearray, str + """ + start = 0 + dataChunks = [] + while start < len(data): + end = start + chunkSize + dataChunks.append(data[start:end]) + start = end + return dataChunks + + @property + def name(self): + """ + Public method to get the complete or shortened advertised name, if available. + + @return advertised name + @rtype str + """ + for n in self.__decodeField(ADV_TYPE_COMPLETE_NAME, ADV_TYPE_SHORT_NAME): + return str(n, "utf-8").replace("\x00", "") if n else "" + + return "" + + @property + def rssi(self): + """ + Public method to get the RSSI value. + + @return RSSI value in dBm + @rtype int + """ + return self.__rssi + + @property + def address(self): + """ + Public method to get the address string. + + @return address of the device + @rtype str + """ + return self.__address + + @property + def txPower(self): + """ + Public method to get the advertised power level in dBm. + + @return transmit power of the device (in dBm) + @rtype int + """ + for txLevel in self.__decodeField(ADV_TYPE_TX_POWER_LEVEL): + return struct.unpack("<b", txLevel) + + return 0 + + @property + def services(self): + """ + Public method to get the service IDs. + + @return list of tuples containing the advertised service ID and a + flag indicating a complete ID + @rtype list of tuple of (str, bool) + """ + result = [] + + for u in self.__decodeField(ADV_TYPE_UUID16_INCOMPLETE): + for v in self.__splitBytes(u, 2): + result.append((hex(struct.unpack("<H", v)[0]), False)) + for u in self.__decodeField(ADV_TYPE_UUID16_COMPLETE): + for v in self.__splitBytes(u, 2): + result.append((hex(struct.unpack("<H", v)[0]), True)) + + for u in self.__decodeField(ADV_TYPE_UUID32_INCOMPLETE): + for v in self.__splitBytes(u, 4): + result.append((hex(struct.unpack("<I", v)), False)) + for u in self.__decodeField(ADV_TYPE_UUID32_COMPLETE): + for v in self.__splitBytes(u, 4): + result.append((hex(struct.unpack("<I", v)), True)) + + for u in self.__decodeField(ADV_TYPE_UUID128_INCOMPLETE): + for v in self.__splitBytes(u, 16): + uid = uuid.UUID(bytes=bytes(reversed(v))) + result.append((str(uid), False)) + for u in self.__decodeField(ADV_TYPE_UUID128_COMPLETE): + for v in self.__splitBytes(u, 16): + uid = uuid.UUID(bytes=bytes(reversed(v))) + result.append((str(uid), True)) + + return result + + def manufacturer(self, filterId=None, withName=False): + """ + Public method to get the manufacturer data. + + @param filterId manufacturer ID to filter on (defaults to None) + @type int (optional) + @param withName flag indicating to report the manufacturer name as well + (if available) (defaults to False) + @type bool + @return tuple containing the manufacturer ID, associated data and manufacturer + name + @rtype tuple of (int, bytes, str) + """ + result = [] + for u in self.__decodeField(ADV_TYPE_MANUFACTURER): + if len(u) < 2: + continue + + m = struct.unpack("<H", u[0:2])[0] + if filter is None or m == filterId: + name = ManufacturerId.get(m, "") if withName else None + result.append((m, u[2:], name)) + return result
--- a/src/eric7/MicroPython/BluetoothDialogs/BluetoothController.py Tue Mar 07 16:23:03 2023 +0100 +++ b/src/eric7/MicroPython/BluetoothDialogs/BluetoothController.py Wed Mar 08 14:25:24 2023 +0100 @@ -44,6 +44,7 @@ btMenu.setTearOffEnabled(True) btMenu.addAction(self.tr("Show Bluetooth Status"), self.__showBtStatus) btMenu.addSeparator() + btMenu.addAction(self.tr("Perform Scan"), self.__scan) btMenu.addSeparator() btMenu.addAction( self.tr("Activate Bluetooth Interface"), @@ -118,3 +119,13 @@ ) except Exception as exc: self.__mpy.showError("deactivateBluetoothInterface()", str(exc)) + + @pyqtSlot() + def __scan(self): + """ + Private slot to scan for Bluetooth devices. + """ + from .BluetoothScanWindow import BluetoothScanWindow + + win = BluetoothScanWindow(self.__mpy.getDevice(), self.__mpy) + win.show()
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/src/eric7/MicroPython/BluetoothDialogs/BluetoothScanWindow.py Wed Mar 08 14:25:24 2023 +0100 @@ -0,0 +1,131 @@ +# -*- coding: utf-8 -*- + +# Copyright (c) 2023 Detlev Offenbach <detlev@die-offenbachs.de> +# + +""" +Module implementing a dialog to scan for Bluetooth devices. +""" + +from PyQt6.QtCore import Qt, pyqtSlot +from PyQt6.QtWidgets import QHeaderView, QTreeWidgetItem, QWidget + +from eric7.EricGui.EricOverrideCursor import EricOverrideCursor +from eric7.EricWidgets import EricMessageBox + +from .Ui_BluetoothScanWindow import Ui_BluetoothScanWindow + + +class BluetoothScanWindow(QWidget, Ui_BluetoothScanWindow): + """ + Class implementing a dialog to scan for Bluetooth devices. + """ + + def __init__(self, device, parent=None): + """ + Constructor + + @param device reference to the connected device + @type BaseDevice + @param parent reference to the parent widget (defaults to None) + @type QWidget (optional) + """ + super().__init__(parent) + self.setupUi(self) + + windowFlags = self.windowFlags() + windowFlags |= Qt.WindowType.Window + windowFlags |= Qt.WindowType.WindowContextHelpButtonHint + self.setWindowFlags(windowFlags) + + self.__device = device + + self.devicesList.setColumnCount(4) + self.devicesList.headerItem().setText(3, "") + + self.scanButton.clicked.connect(self.scanDevices) + + self.devicesList.sortByColumn(0, Qt.SortOrder.AscendingOrder) + + @pyqtSlot() + def scanDevices(self): + """ + Public slot to ask the device for a Bluetooth scan and display the result. + """ + self.devicesList.clear() + self.statusLabel.clear() + + self.scanButton.setEnabled(False) + with EricOverrideCursor(): + scanResults, error = self.__device.getDeviceScan( + timeout=self.durationSpinBox.value() + ) + self.scanButton.setEnabled(True) + + if error: + EricMessageBox.warning( + self, + self.tr("Bluetooth Scan"), + self.tr( + """<p>The scan for available devices failed.</p>""" + """<p>Reason: {0}</p>""" + ).format(error), + ) + + else: + for res in scanResults.values(): + name = res.name + if not name: + name = self.tr("N/A") + itm = QTreeWidgetItem( + self.devicesList, [name, res.address, str(res.rssi)] + ) + itm.setTextAlignment(1, Qt.AlignmentFlag.AlignHCenter) + itm.setTextAlignment(2, Qt.AlignmentFlag.AlignHCenter) + + for service, isComplete in res.services: + if len(service) == 6: + bits = 16 + elif len(service) == 10: + bits = 32 + else: + bits = 128 + template = ( + self.tr("Complete {0}-bit Service UUID: {1}") + if isComplete + else self.tr("Incomplete {0}-bit Service UUID: {1}") + ) + sitm = QTreeWidgetItem(itm, [template.format(bits, service)]) + sitm.setFirstColumnSpanned(True) + + for mid, _, mname in res.manufacturer(withName=True): + mitm = QTreeWidgetItem( + itm, + [ + self.tr("Manufacturer ID: 0x{0:x} ({1})").format(mid, mname) + if bool(mname) + else self.tr("Manufacturer ID: 0x{0:x}").format(mid) + ], + ) + mitm.setFirstColumnSpanned(True) + + self.__resizeColumns() + self.__resort() + + def __resort(self): + """ + Private method to resort the devices list. + """ + self.devicesList.sortItems( + self.devicesList.sortColumn(), + self.devicesList.header().sortIndicatorOrder(), + ) + + def __resizeColumns(self): + """ + Private method to resize the columns of the result list. + """ + self.devicesList.header().resizeSections( + QHeaderView.ResizeMode.ResizeToContents + ) + self.devicesList.header().setStretchLastSection(True)
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/src/eric7/MicroPython/BluetoothDialogs/BluetoothScanWindow.ui Wed Mar 08 14:25:24 2023 +0100 @@ -0,0 +1,135 @@ +<?xml version="1.0" encoding="UTF-8"?> +<ui version="4.0"> + <class>BluetoothScanWindow</class> + <widget class="QWidget" name="BluetoothScanWindow"> + <property name="geometry"> + <rect> + <x>0</x> + <y>0</y> + <width>650</width> + <height>600</height> + </rect> + </property> + <property name="windowTitle"> + <string>Bluetooth Scan</string> + </property> + <layout class="QVBoxLayout" name="verticalLayout"> + <item> + <widget class="QTreeWidget" name="devicesList"> + <property name="alternatingRowColors"> + <bool>true</bool> + </property> + <property name="selectionMode"> + <enum>QAbstractItemView::NoSelection</enum> + </property> + <property name="sortingEnabled"> + <bool>true</bool> + </property> + <column> + <property name="text"> + <string>Name</string> + </property> + </column> + <column> + <property name="text"> + <string>MAC-Address</string> + </property> + </column> + <column> + <property name="text"> + <string>RSSI [dBm]</string> + </property> + </column> + <column> + <property name="text"> + <string notr="true"/> + </property> + </column> + </widget> + </item> + <item> + <widget class="QLabel" name="statusLabel"/> + </item> + <item> + <layout class="QHBoxLayout" name="horizontalLayout"> + <item> + <widget class="QLabel" name="label"> + <property name="text"> + <string>Scan Duration:</string> + </property> + </widget> + </item> + <item> + <widget class="QSpinBox" name="durationSpinBox"> + <property name="toolTip"> + <string>Enter the scan duration in seconds</string> + </property> + <property name="alignment"> + <set>Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter</set> + </property> + <property name="suffix"> + <string> s</string> + </property> + <property name="minimum"> + <number>1</number> + </property> + <property name="maximum"> + <number>60</number> + </property> + <property name="value"> + <number>10</number> + </property> + </widget> + </item> + <item> + <widget class="QPushButton" name="scanButton"> + <property name="sizePolicy"> + <sizepolicy hsizetype="Expanding" vsizetype="Fixed"> + <horstretch>0</horstretch> + <verstretch>0</verstretch> + </sizepolicy> + </property> + <property name="toolTip"> + <string>Press to scan for available WiFi networks.</string> + </property> + <property name="text"> + <string>Scan</string> + </property> + </widget> + </item> + </layout> + </item> + <item> + <widget class="QDialogButtonBox" name="buttonBox"> + <property name="standardButtons"> + <set>QDialogButtonBox::Close</set> + </property> + </widget> + </item> + </layout> + </widget> + <tabstops> + <tabstop>devicesList</tabstop> + <tabstop>durationSpinBox</tabstop> + <tabstop>scanButton</tabstop> + </tabstops> + <resources/> + <connections> + <connection> + <sender>buttonBox</sender> + <signal>rejected()</signal> + <receiver>BluetoothScanWindow</receiver> + <slot>close()</slot> + <hints> + <hint type="sourcelabel"> + <x>486</x> + <y>581</y> + </hint> + <hint type="destinationlabel"> + <x>647</x> + <y>534</y> + </hint> + </hints> + </connection> + </connections> +</ui>
--- a/src/eric7/MicroPython/BluetoothDialogs/BluetoothStatusDialog.py Tue Mar 07 16:23:03 2023 +0100 +++ b/src/eric7/MicroPython/BluetoothDialogs/BluetoothStatusDialog.py Wed Mar 08 14:25:24 2023 +0100 @@ -1,5 +1,8 @@ # -*- coding: utf-8 -*- +# Copyright (c) 2023 Detlev Offenbach <detlev@die-offenbachs.de> +# + """ Module implementing BluetoothStatusDialog. """
--- a/src/eric7/MicroPython/Devices/CircuitPythonDevices.py Tue Mar 07 16:23:03 2023 +0100 +++ b/src/eric7/MicroPython/Devices/CircuitPythonDevices.py Wed Mar 08 14:25:24 2023 +0100 @@ -1153,6 +1153,7 @@ @return flag indicating the availability of Bluetooth @rtype bool + @exception OSError raised to indicate an issue with the device """ command = """ def has_bt(): @@ -1182,6 +1183,7 @@ @return list of tuples containing the translated status data label and the associated value @rtype list of tuples of (str, str) + @exception OSError raised to indicate an issue with the device """ command = """ def ble_status(): @@ -1235,6 +1237,7 @@ @return flag indicating the new state of the Bluetooth interface @rtype bool + @exception OSError raised to indicate an issue with the device """ command = """ def activate_ble(): @@ -1260,6 +1263,7 @@ @return flag indicating the new state of the Bluetooth interface @rtype bool + @exception OSError raised to indicate an issue with the device """ command = """ def deactivate_ble(): @@ -1279,6 +1283,83 @@ return out.strip() == b"True" + def getDeviceScan(self, timeout=10): + """ + Public method to perform a Bluetooth device scan. + + @param timeout duration of the device scan in seconds (defaults + to 10) + @type int (optional) + @return tuple containing a dictionary with the scan results and + an error string + @rtype tuple of (dict, str) + """ + from ..BluetoothDialogs.BluetoothAdvertisement import ( + ADV_IND, + ADV_SCAN_IND, + SCAN_RSP, + BluetoothAdvertisement, + ) + + command = """ +def ble_scan(): + import _bleio + import binascii + import time + + a = _bleio.adapter + + ble_enabled = a.enabled + if not ble_enabled: + a.enabled = True + + scanResults = a.start_scan( + buffer_size=1024, extended=True, timeout={0}, minimum_rssi=-100, active=True + ) + time.sleep(10) + a.stop_scan() + + for res in scanResults: + print({{ + 'address': binascii.hexlify( + bytes(reversed(res.address.address_bytes)), ':' + ).decode(), + 'advertisement': res.advertisement_bytes, + 'connectable': res.connectable, + 'rssi': res.rssi, + 'scan_response': res.scan_response, + }}) + + if not ble_enabled: + a.enabled = False + +ble_scan() +del ble_scan +""".format( + timeout + ) + out, err = self._interface.execute( + command, mode=self._submitMode, timeout=(timeout + 5) * 1000 + ) + if err: + return {}, err + + scanResults = {} + for line in out.decode("utf-8").splitlines(): + res = ast.literal_eval(line) + address = res["address"] + if address not in scanResults: + scanResults[address] = BluetoothAdvertisement(address) + if res["scan_response"]: + advType = SCAN_RSP + elif res["connectable"]: + advType = ADV_IND + else: + advType = ADV_SCAN_IND + scanResults[address].update(advType, res["rssi"], res["advertisement"]) + + return scanResults, "" + def createDevice(microPythonWidget, deviceType, vid, pid, boardName, serialNumber): """
--- a/src/eric7/MicroPython/Devices/DeviceBase.py Tue Mar 07 16:23:03 2023 +0100 +++ b/src/eric7/MicroPython/Devices/DeviceBase.py Wed Mar 08 14:25:24 2023 +0100 @@ -1510,6 +1510,19 @@ """ return False + def getDeviceScan(self, timeout=10): + """ + Public method to perform a Bluetooth device scan. + + @param timeout duration of the device scan in seconds (defaults + to 10) + @type int (optional) + @return tuple containing a dictionary with the scan results and + an error string + @rtype tuple of (dict, str) + """ + return {}, "" + ################################################################## ## Methods below implement some utility methods ################################################################## @@ -1530,5 +1543,6 @@ else: return self.tr("yes") if val else self.tr("no") + # # eflag: noqa = M613
--- a/src/eric7/MicroPython/Devices/EspDevices.py Tue Mar 07 16:23:03 2023 +0100 +++ b/src/eric7/MicroPython/Devices/EspDevices.py Wed Mar 08 14:25:24 2023 +0100 @@ -1048,6 +1048,7 @@ @return flag indicating the availability of Bluetooth @rtype bool + @exception OSError raised to indicate an issue with the device """ command = """ def has_bt(): @@ -1077,6 +1078,7 @@ @return list of tuples containing the translated status data label and the associated value @rtype list of tuples of (str, str) + @exception OSError raised to indicate an issue with the device """ command = """ def ble_status(): @@ -1125,9 +1127,7 @@ status.append( (self.tr("Rx-Buffer"), self.tr("{0} Bytes").format(bleStatus["rxbuf"])) ) - status.append( - (self.tr("MTU"), self.tr("{0} Bytes").format(bleStatus["mtu"])) - ) + status.append((self.tr("MTU"), self.tr("{0} Bytes").format(bleStatus["mtu"]))) return status @@ -1137,6 +1137,7 @@ @return flag indicating the new state of the Bluetooth interface @rtype bool + @exception OSError raised to indicate an issue with the device """ command = """ def activate_ble(): @@ -1162,6 +1163,7 @@ @return flag indicating the new state of the Bluetooth interface @rtype bool + @exception OSError raised to indicate an issue with the device """ command = """ def deactivate_ble():
--- a/src/eric7/MicroPython/WifiDialogs/WifiNetworksWindow.py Tue Mar 07 16:23:03 2023 +0100 +++ b/src/eric7/MicroPython/WifiDialogs/WifiNetworksWindow.py Wed Mar 08 14:25:24 2023 +0100 @@ -47,9 +47,10 @@ self.networkList.sortByColumn(0, Qt.SortOrder.AscendingOrder) + @pyqtSlot() def scanNetworks(self): """ - Public method to ask the device for a network scan and display the result. + Public slot to ask the device for a network scan and display the result. """ self.networkList.clear() self.statusLabel.clear()
--- a/src/eric7/MicroPython/WifiDialogs/WifiNetworksWindow.ui Tue Mar 07 16:23:03 2023 +0100 +++ b/src/eric7/MicroPython/WifiDialogs/WifiNetworksWindow.ui Wed Mar 08 14:25:24 2023 +0100 @@ -95,6 +95,9 @@ </item> <item> <widget class="QSpinBox" name="intervalSpinBox"> + <property name="toolTip"> + <string>Enter the scan interval in seconds</string> + </property> <property name="alignment"> <set>Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter</set> </property> @@ -114,6 +117,9 @@ </item> <item> <widget class="QCheckBox" name="periodicCheckBox"> + <property name="toolTip"> + <string>Select to perform a periodic WiFi network scan</string> + </property> <property name="text"> <string>Periodic Scan</string> </property> @@ -143,8 +149,8 @@ <slot>close()</slot> <hints> <hint type="sourcelabel"> - <x>269</x> - <y>563</y> + <x>278</x> + <y>590</y> </hint> <hint type="destinationlabel"> <x>271</x>