src/eric7/MicroPython/Devices/Nrf52Devices.py

branch
eric7
changeset 11167
a3f5af773bc7
child 11208
f776db7cc222
equal deleted inserted replaced
11166:fd914f897dcf 11167:a3f5af773bc7
1 # -*- coding: utf-8 -*-
2
3 # Copyright (c) 2025 Detlev Offenbach <detlev@die-offenbachs.de>
4 #
5
6 """
7 Module implementing the device interface class for NRF52 boards with UF2 support.
8 """
9
10 import ast
11 import json
12
13 from PyQt6.QtCore import QUrl, pyqtSlot
14 from PyQt6.QtNetwork import QNetworkReply, QNetworkRequest
15 from PyQt6.QtWidgets import QMenu
16
17 from eric7 import EricUtilities, Preferences
18 from eric7.EricWidgets import EricMessageBox
19 from eric7.EricWidgets.EricApplication import ericApp
20
21 from ..MicroPythonWidget import HAS_QTCHART
22 from . import FirmwareGithubUrls
23 from .DeviceBase import BaseDevice
24
25
26 class Nrf52Device(BaseDevice):
27 """
28 Class implementing the device for NRF52 boards with UF2 support.
29 """
30
31 def __init__(self, microPythonWidget, deviceType, parent=None):
32 """
33 Constructor
34
35 @param microPythonWidget reference to the main MicroPython widget
36 @type MicroPythonWidget
37 @param deviceType device type assigned to this device interface
38 @type str
39 @param parent reference to the parent object
40 @type QObject
41 """
42 super().__init__(microPythonWidget, deviceType, parent)
43
44 self.__createNrfMenu()
45
46 def setButtons(self):
47 """
48 Public method to enable the supported action buttons.
49 """
50 super().setButtons()
51
52 self.microPython.setActionButtons(
53 run=True, repl=True, files=True, chart=HAS_QTCHART
54 )
55
56 def forceInterrupt(self):
57 """
58 Public method to determine the need for an interrupt when opening the
59 serial connection.
60
61 @return flag indicating an interrupt is needed
62 @rtype bool
63 """
64 return False
65
66 def deviceName(self):
67 """
68 Public method to get the name of the device.
69
70 @return name of the device
71 @rtype str
72 """
73 return self.tr("NRF52 with UF2")
74
75 def canStartRepl(self):
76 """
77 Public method to determine, if a REPL can be started.
78
79 @return tuple containing a flag indicating it is safe to start a REPL
80 and a reason why it cannot.
81 @rtype tuple of (bool, str)
82 """
83 return True, ""
84
85 def canStartPlotter(self):
86 """
87 Public method to determine, if a Plotter can be started.
88
89 @return tuple containing a flag indicating it is safe to start a
90 Plotter and a reason why it cannot.
91 @rtype tuple of (bool, str)
92 """
93 return True, ""
94
95 def canRunScript(self):
96 """
97 Public method to determine, if a script can be executed.
98
99 @return tuple containing a flag indicating it is safe to start a
100 Plotter and a reason why it cannot.
101 @rtype tuple of (bool, str)
102 """
103 return True, ""
104
105 def runScript(self, script):
106 """
107 Public method to run the given Python script.
108
109 @param script script to be executed
110 @type str
111 """
112 pythonScript = script.split("\n")
113 self.sendCommands(pythonScript)
114
115 def canStartFileManager(self):
116 """
117 Public method to determine, if a File Manager can be started.
118
119 @return tuple containing a flag indicating it is safe to start a
120 File Manager and a reason why it cannot.
121 @rtype tuple of (bool, str)
122 """
123 return True, ""
124
125 def __createNrfMenu(self):
126 """
127 Private method to create the NRF52 submenu.
128 """
129 self.__nrfMenu = QMenu(self.tr("NRF52 Functions"))
130
131 self.__showMpyAct = self.__nrfMenu.addAction(
132 self.tr("Show MicroPython Versions"), self.__showFirmwareVersions
133 )
134 self.__nrfMenu.addSeparator()
135 self.__bootloaderAct = self.__nrfMenu.addAction(
136 self.tr("Activate Bootloader"), self.__activateBootloader
137 )
138 self.__flashMpyAct = self.__nrfMenu.addAction(
139 self.tr("Flash MicroPython Firmware"), self.__flashPython
140 )
141 self.__nrfMenu.addSeparator()
142 self.__resetAct = self.__nrfMenu.addAction(
143 self.tr("Reset Device"), self.__resetDevice
144 )
145
146 def addDeviceMenuEntries(self, menu):
147 """
148 Public method to add device specific entries to the given menu.
149
150 @param menu reference to the context menu
151 @type QMenu
152 """
153 connected = self.microPython.isConnected()
154 linkConnected = self.microPython.isLinkConnected()
155
156 self.__showMpyAct.setEnabled(connected)
157 self.__bootloaderAct.setEnabled(connected)
158 self.__flashMpyAct.setEnabled(not linkConnected)
159 self.__resetAct.setEnabled(connected)
160
161 menu.addMenu(self.__nrfMenu)
162
163 def hasFlashMenuEntry(self):
164 """
165 Public method to check, if the device has its own flash menu entry.
166
167 @return flag indicating a specific flash menu entry
168 @rtype bool
169 """
170 return True
171
172 @pyqtSlot()
173 def __flashPython(self):
174 """
175 Private slot to flash a MicroPython firmware to the device.
176 """
177 from ..UF2FlashDialog import UF2FlashDialog
178
179 dlg = UF2FlashDialog(boardType="nrf52", parent=self.microPython)
180 dlg.exec()
181
182 @pyqtSlot()
183 def __activateBootloader(self):
184 """
185 Private slot to switch the board into 'bootloader' mode.
186 """
187 if self.microPython.isConnected():
188 self.executeCommands(
189 [
190 "import machine",
191 "machine.bootloader()",
192 ],
193 mode=self._submitMode,
194 )
195 # simulate pressing the disconnect button
196 self.microPython.on_connectButton_clicked()
197
198 @pyqtSlot()
199 def __showFirmwareVersions(self):
200 """
201 Private slot to show the firmware version of the connected device and the
202 available firmware version.
203 """
204 if self.microPython.isConnected():
205 if self._deviceData["mpy_name"] != "micropython":
206 EricMessageBox.critical(
207 self.microPython,
208 self.tr("Show MicroPython Versions"),
209 self.tr(
210 """The firmware of the connected device cannot be"""
211 """ determined or the board does not run MicroPython."""
212 """ Aborting..."""
213 ),
214 )
215 else:
216 if self._deviceData["mpy_variant"] == "Pimoroni Pico":
217 # MicroPython with Pimoroni add-on libraries
218 url = QUrl(FirmwareGithubUrls["pimoroni_pico"])
219 else:
220 url = QUrl(FirmwareGithubUrls["micropython"])
221 ui = ericApp().getObject("UserInterface")
222 request = QNetworkRequest(url)
223 reply = ui.networkAccessManager().head(request)
224 reply.finished.connect(lambda: self.__firmwareVersionResponse(reply))
225
226 @pyqtSlot(QNetworkReply)
227 def __firmwareVersionResponse(self, reply):
228 """
229 Private slot handling the response of the latest version request.
230
231 @param reply reference to the reply object
232 @type QNetworkReply
233 """
234 latestUrl = reply.url().toString()
235 tag = latestUrl.rsplit("/", 1)[-1]
236 while tag and not tag[0].isdecimal():
237 # get rid of leading non-decimal characters
238 tag = tag[1:]
239 latestVersion = EricUtilities.versionToTuple(tag)
240
241 if self._deviceData["mpy_version"] == "unknown":
242 currentVersionStr = self.tr("unknown")
243 currentVersion = (0, 0, 0)
244 else:
245 currentVersionStr = (
246 self._deviceData["mpy_variant_version"]
247 if bool(self._deviceData["mpy_variant_version"])
248 else self._deviceData["mpy_version"]
249 )
250 currentVersion = EricUtilities.versionToTuple(currentVersionStr)
251
252 msg = self.tr(
253 "<h4>MicroPython Version Information</h4>"
254 "<table>"
255 "<tr><td>Installed:</td><td>{0}</td></tr>"
256 "<tr><td>Available:</td><td>{1}</td></tr>"
257 "{2}"
258 "</table>"
259 ).format(
260 currentVersionStr,
261 tag,
262 (
263 self.tr("<tr><td>Variant:</td><td>{0}</td></tr>").format(
264 self._deviceData["mpy_variant"]
265 )
266 if self._deviceData["mpy_variant"]
267 else ""
268 ),
269 )
270 if self._deviceData["mpy_variant"] in ["Pimoroni Pico"] and not bool(
271 self._deviceData["mpy_variant_version"]
272 ):
273 # cannot derive update info
274 msg += self.tr("<p>Update may be available.</p>")
275 elif currentVersion < latestVersion:
276 msg += self.tr("<p><b>Update available!</b></p>")
277
278 EricMessageBox.information(
279 self.microPython,
280 self.tr("MicroPython Version"),
281 msg,
282 )
283
284 @pyqtSlot()
285 def __resetDevice(self):
286 """
287 Private slot to reset the connected device.
288 """
289 if self.microPython.isConnected():
290 self.executeCommands(
291 "import machine\nmachine.reset()\n", mode=self._submitMode
292 )
293
294 def getDocumentationUrl(self):
295 """
296 Public method to get the device documentation URL.
297
298 @return documentation URL of the device
299 @rtype str
300 """
301 return Preferences.getMicroPython("MicroPythonDocuUrl")
302
303 def getDownloadMenuEntries(self):
304 """
305 Public method to retrieve the entries for the downloads menu.
306
307 @return list of tuples with menu text and URL to be opened for each
308 entry
309 @rtype list of tuple of (str, str)
310 """
311 return [
312 (
313 self.tr("MicroPython Firmware"),
314 Preferences.getMicroPython("MicroPythonFirmwareUrl"),
315 ),
316 ("<separator>", ""),
317 (
318 self.tr("CircuitPython Firmware"),
319 Preferences.getMicroPython("CircuitPythonFirmwareUrl"),
320 ),
321 (
322 self.tr("CircuitPython Libraries"),
323 Preferences.getMicroPython("CircuitPythonLibrariesUrl"),
324 ),
325 ]
326
327 ##################################################################
328 ## Methods below implement Bluetooth related methods
329 ##################################################################
330
331 def hasBluetooth(self):
332 """
333 Public method to check the availability of Bluetooth.
334
335 @return flag indicating the availability of Bluetooth
336 @rtype bool
337 @exception OSError raised to indicate an issue with the device
338 """
339 command = """
340 def has_bt():
341 try:
342 import ble
343 if ble.address():
344 return True
345 except (ImportError, OSError):
346 pass
347
348 return False
349
350 print(has_bt())
351 del has_bt
352 """
353 out, err = self.executeCommands(command, mode=self._submitMode, timeout=10000)
354 if err:
355 raise OSError(self._shortError(err))
356 return out.strip() == b"True"
357
358 def getBluetoothStatus(self):
359 """
360 Public method to get Bluetooth status data of the connected board.
361
362 @return list of tuples containing the translated status data label and
363 the associated value
364 @rtype list of tuples of (str, str)
365 @exception OSError raised to indicate an issue with the device
366 """
367 command = """
368 def ble_status():
369 import ble
370 import json
371
372 res = {
373 'active': bool(ble.enabled()),
374 'mac': ble.address(),
375 }
376
377 print(json.dumps(res))
378
379 ble_status()
380 del ble_status
381 """
382 out, err = self.executeCommands(command, mode=self._submitMode)
383 if err:
384 raise OSError(self._shortError(err))
385
386 bleStatus = json.loads(out.decode("utf-8"))
387 status = [
388 (self.tr("Active"), self.bool2str(bleStatus["active"])),
389 (self.tr("MAC-Address"), bleStatus["mac"]),
390 ]
391
392 return status
393
394 def activateBluetoothInterface(self):
395 """
396 Public method to activate the Bluetooth interface.
397
398 @return flag indicating the new state of the Bluetooth interface
399 @rtype bool
400 @exception OSError raised to indicate an issue with the device
401 """
402 command = """
403 def activate_ble():
404 import ble
405
406 if not ble.enabled():
407 ble.enable()
408 print(bool(ble.enabled()))
409
410 activate_ble()
411 del activate_ble
412 """
413 out, err = self.executeCommands(command, mode=self._submitMode)
414 if err:
415 raise OSError(self._shortError(err))
416
417 return out.strip() == b"True"
418
419 def deactivateBluetoothInterface(self):
420 """
421 Public method to deactivate the Bluetooth interface.
422
423 @return flag indicating the new state of the Bluetooth interface
424 @rtype bool
425 @exception OSError raised to indicate an issue with the device
426 """
427 command = """
428 def deactivate_ble():
429 import ble
430
431 if ble.enabled():
432 ble.disable()
433 print(bool(ble.enabled()))
434
435 deactivate_ble()
436 del deactivate_ble
437 """
438 out, err = self.executeCommands(command, mode=self._submitMode)
439 if err:
440 raise OSError(self._shortError(err))
441
442 return out.strip() == b"True"
443
444 def getDeviceScan(self, timeout=10):
445 """
446 Public method to perform a Bluetooth device scan.
447
448 @param timeout duration of the device scan in seconds (defaults
449 to 10)
450 @type int (optional)
451 @return tuple containing a dictionary with the scan results and
452 an error string
453 @rtype tuple of (dict, str)
454 """
455 from ..BluetoothDialogs.BluetoothAdvertisement import (
456 SCAN_RSP,
457 BluetoothAdvertisement,
458 )
459
460 command = """
461 def ble_scan():
462 import ble
463 import ubluepy as ub
464
465 ble_active = ble.enabled()
466 if not ble_active:
467 ble.enable()
468
469 sc = ub.Scanner()
470 scanResults = sc.scan({0} * 1000)
471 for res in scanResults:
472 try:
473 scanData = res.getScanData()
474 if res.addr():
475 for data in scanData:
476 print({{
477 'address': res.addr(),
478 'rssi': res.rssi(),
479 'adv_type': data[0],
480 'advertisement': bytes(data[2]),
481 }})
482 except MemoryError:
483 pass
484
485 if not ble_active:
486 ble.disable()
487
488 ble_scan()
489 del ble_scan
490 """.format(
491 timeout
492 )
493 out, err = self.executeCommands(
494 command, mode=self._submitMode, timeout=(timeout + 5) * 1000
495 )
496 if err:
497 return {}, err
498
499 scanResults = {}
500 tempResults = {}
501
502 for line in out.decode("utf-8").splitlines():
503 res = ast.literal_eval(line)
504 address = res["address"]
505 if address not in tempResults:
506 tempResults[address] = {
507 "advertisements": {},
508 }
509 tempResults[address]["rssi"] = res["rssi"]
510 tempResults[address]["advertisements"][res["adv_type"]] = res[
511 "advertisement"
512 ]
513
514 for address in tempResults:
515 advertisements = bytearray()
516 for advType, advertisement in tempResults[address][
517 "advertisements"
518 ].items():
519 advertisements += (
520 (len(advertisement) + 1).to_bytes()
521 + advType.to_bytes()
522 + advertisement
523 )
524 scanResults[address] = BluetoothAdvertisement(address)
525 scanResults[address].update(
526 SCAN_RSP, tempResults[address]["rssi"], advertisements
527 )
528
529 return scanResults, ""
530
531 def supportsDeviceScan(self):
532 """
533 Public method to indicate, that the Bluetooth implementation supports
534 scanning for devices.
535
536 @return flag indicating that the scanning function is supported
537 @rtype bool
538 """
539 return True
540
541
542 def createDevice(microPythonWidget, deviceType, _vid, _pid, _boardName, _serialNumber):
543 """
544 Function to instantiate a MicroPython device object.
545
546 @param microPythonWidget reference to the main MicroPython widget
547 @type MicroPythonWidget
548 @param deviceType device type assigned to this device interface
549 @type str
550 @param _vid vendor ID (unused)
551 @type int
552 @param _pid product ID (unused)
553 @type int
554 @param _boardName name of the board (unused)
555 @type str
556 @param _serialNumber serial number of the board (unused)
557 @type str
558 @return reference to the instantiated device object
559 @rtype RP2Device
560 """
561 return Nrf52Device(microPythonWidget, deviceType)

eric ide

mercurial