"""
The mode for working with the BBC micro:bit. Conatains most of the origial
functionality from Mu when it was only a micro:bit related editor.
Copyright (c) 2015-2017 Nicholas H.Tollervey and others (see the AUTHORS file).
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
"""
import os
import sys
import os.path
import logging
import semver
from tokenize import TokenError
from mu.logic import HOME_DIRECTORY, sniff_newline_convention
import uflash
from mu.contrib import microfs
from mu.modes.api import MICROBIT_APIS, SHARED_APIS
from mu.modes.base import MicroPythonMode, FileManager
from mu.interface.panes import CHARTS
from PyQt5.QtCore import QThread, pyqtSignal, QTimer
# We can run without nudatus
can_minify = True
try:
import nudatus
except ImportError: # pragma: no cover
can_minify = False
logger = logging.getLogger(__name__)
[docs]class DeviceFlasher(QThread):
"""
Used to flash the micro:bit in a non-blocking manner.
"""
# Emitted when flashing the micro:bit fails for any reason.
on_flash_fail = pyqtSignal(str)
def __init__(self, paths_to_microbits, python_script, path_to_runtime):
"""
The paths_to_microbits should be a list containing filesystem paths to
attached micro:bits to flash. The python_script should be the text of
the script to flash onto the device. The path_to_runtime should be the
path of the hex file for the MicroPython runtime to use. If the
path_to_runtime is None, the default MicroPython runtime is used by
default.
"""
QThread.__init__(self)
self.paths_to_microbits = paths_to_microbits
self.python_script = python_script
self.path_to_runtime = path_to_runtime
[docs] def run(self):
"""
Flash the device.
"""
try:
uflash.flash(paths_to_microbits=self.paths_to_microbits,
python_script=self.python_script,
path_to_runtime=self.path_to_runtime)
except Exception as ex:
# Catch everything so Mu can recover from all of the wide variety
# of possible exceptions that could happen at this point.
logger.error(ex)
self.on_flash_fail.emit(str(ex))
[docs]class MicrobitMode(MicroPythonMode):
"""
Represents the functionality required by the micro:bit mode.
"""
name = _('BBC micro:bit')
description = _("Write MicroPython for the BBC micro:bit.")
icon = 'microbit'
fs = None #: Reference to filesystem navigator.
flash_thread = None
flash_timer = None
file_extensions = ['hex']
valid_boards = [
(0x0D28, 0x0204), # micro:bit USB VID, PID
]
valid_serial_numbers = [9900, 9901] # Serial numbers of supported boards.
python_script = ''
[docs] def actions(self):
"""
Return an ordered list of actions provided by this module. An action
is a name (also used to identify the icon) , description, and handler.
"""
buttons = [
{
'name': 'flash',
'display_name': _('Flash'),
'description': _('Flash your code onto the micro:bit.'),
'handler': self.flash,
'shortcut': 'F3',
},
{
'name': 'files',
'display_name': _('Files'),
'description': _('Access the file system on the micro:bit.'),
'handler': self.toggle_files,
'shortcut': 'F4',
},
{
'name': 'repl',
'display_name': _('REPL'),
'description': _('Use the REPL to live-code on the '
'micro:bit.'),
'handler': self.toggle_repl,
'shortcut': 'Ctrl+Shift+I',
}, ]
if CHARTS:
buttons.append({
'name': 'plotter',
'display_name': _('Plotter'),
'description': _('Plot incoming REPL data.'),
'handler': self.toggle_plotter,
'shortcut': 'CTRL+Shift+P',
})
return buttons
[docs] def api(self):
"""
Return a list of API specifications to be used by auto-suggest and call
tips.
"""
return SHARED_APIS + MICROBIT_APIS
[docs] def flash(self):
"""
Takes the currently active tab, compiles the Python script therein into
a hex file and flashes it all onto the connected device.
WARNING: This method is getting more complex due to several edge
cases. Ergo, it's a target for refactoring.
"""
user_defined_microbit_path = None
self.python_script = ''
logger.info('Preparing to flash script.')
# The first thing to do is check the script is valid and of the
# expected length.
# Grab the Python script.
tab = self.view.current_tab
if tab is None:
# There is no active text editor. Exit.
return
# Check the script's contents.
python_script = tab.text().encode('utf-8')
logger.debug('Python script:')
logger.debug(python_script)
# Check minification status.
minify = False
if uflash.get_minifier():
minify = self.editor.minify
# Attempt and handle minification.
if len(python_script) >= uflash._MAX_SIZE:
message = _('Unable to flash "{}"').format(tab.label)
if minify and can_minify:
orginal = len(python_script)
script = python_script.decode('utf-8')
try:
mangled = nudatus.mangle(script).encode('utf-8')
except TokenError as e:
msg, (line, col) = e.args
logger.debug('Minify failed')
logger.exception(e)
message = _("Problem with script")
information = _("{} [{}:{}]").format(msg, line, col)
self.view.show_message(message, information, 'Warning')
return
saved = orginal - len(mangled)
percent = saved / orginal * 100
logger.debug('Script minified, {} bytes ({:.2f}%) saved:'
.format(saved, percent))
logger.debug(mangled)
python_script = mangled
if len(python_script) >= 8192:
information = _("Our minifier tried but your "
"script is too long!")
self.view.show_message(message, information, 'Warning')
return
elif minify and not can_minify:
information = _("Your script is too long and the minifier"
" isn't available")
self.view.show_message(message, information, 'Warning')
return
else:
information = _("Your script is too long!")
self.view.show_message(message, information, 'Warning')
return
# By this point, there's a valid Python script in "python_script".
# Assign this to an attribute for later processing in a different
# method.
self.python_script = python_script
# Next step: find the microbit port and serial number.
path_to_microbit = uflash.find_microbit()
logger.info('Path to micro:bit: {}'.format(path_to_microbit))
port = None
serial_number = None
try:
port, serial_number = self.find_device()
logger.info('Serial port: {}'.format(port))
logger.info('Device serial number: {}'.format(serial_number))
except Exception as ex:
logger.warning('Unable to make serial connection to micro:bit.')
logger.warning(ex)
# Determine the location of the BBC micro:bit. If it can't be found
# fall back to asking the user to locate it.
if path_to_microbit is None:
# Ask the user to locate the device.
path_to_microbit = self.view.get_microbit_path(HOME_DIRECTORY)
user_defined_microbit_path = path_to_microbit
logger.debug('User defined path to micro:bit: {}'.format(
user_defined_microbit_path))
# Check the path and that it exists simply because the path maybe based
# on stale data.
if path_to_microbit and os.path.exists(path_to_microbit):
force_flash = False # If set to true, fully flash the device.
# If there's no port but there's a path_to_microbit, then we're
# probably running on Windows with an old device, so force flash.
if not port:
force_flash = True
if not self.python_script.strip():
# If the script is empty, this is a signal to simply force a
# flash.
logger.info("Python script empty. Forcing flash.")
force_flash = True
logger.info("Checking target device.")
# Get the version of MicroPython on the device.
try:
version_info = microfs.version()
logger.info(version_info)
board_info = version_info['version'].split()
if (board_info[0] == 'micro:bit' and
board_info[1].startswith('v')):
# New style versions, so the correct information will be
# in the "release" field.
try:
# Check the release is a correct semantic version.
semver.parse(version_info['release'])
board_version = version_info['release']
except ValueError:
# If it's an invalid semver, set to unknown version to
# force flash.
board_version = '0.0.1'
else:
# 0.0.1 indicates an old unknown version. This is just a
# valid arbitrary flag for semver comparison a couple of
# lines below.
board_version = '0.0.1'
logger.info('Board MicroPython: {}'.format(board_version))
logger.info(
'Mu MicroPython: {}'.format(uflash.MICROPYTHON_VERSION))
# If there's an older version of MicroPython on the device,
# update it with the one packaged with Mu.
if semver.compare(board_version,
uflash.MICROPYTHON_VERSION) < 0:
force_flash = True
except Exception:
# Could not get version of MicroPython. This means either the
# device has a really old version of MicroPython or is running
# something else. In any case, flash MicroPython onto the
# device.
logger.warning('Could not detect version of MicroPython.')
force_flash = True
# Check use of custom runtime.
rt_hex_path = self.editor.microbit_runtime.strip()
message = _('Flashing "{}" onto the micro:bit.').format(tab.label)
if (rt_hex_path and os.path.exists(rt_hex_path)):
message = message + _(" Runtime: {}").format(rt_hex_path)
force_flash = True # Using a custom runtime, so flash it.
else:
rt_hex_path = None
self.editor.microbit_runtime = ''
# Check for use of user defined path (to save hex onto local
# file system.
if user_defined_microbit_path:
force_flash = True
# If we need to flash the device with a clean hex, do so now.
if force_flash:
logger.info('Flashing new MicroPython runtime onto device')
self.editor.show_status_message(message, 10)
self.set_buttons(flash=False)
if user_defined_microbit_path or not port:
# The user has provided a path to a location on the
# filesystem. In this case save the combined hex/script
# in the specified path_to_microbit.
# Or... Mu has a path to a micro:bit but can't establish
# a serial connection, so use the combined hex/script
# to flash the device.
self.flash_thread = DeviceFlasher([path_to_microbit],
self.python_script,
rt_hex_path)
# Reset python_script so Mu doesn't try to copy it as the
# main.py file.
self.python_script = ''
else:
# We appear to need to flash a connected micro:bit device,
# so just flash the Python hex with no embedded Python
# script, since this will be copied over when the
# flashing operation has finished.
model_serial_number = int(serial_number[:4])
if rt_hex_path:
# If the user has specified a bespoke runtime hex file
# assume they know what they're doing and hope for the
# best.
self.flash_thread = DeviceFlasher([path_to_microbit],
b'', rt_hex_path)
elif model_serial_number in self.valid_serial_numbers:
# The connected board has a serial number that
# indicates the MicroPython hex bundled with Mu
# supports it. In which case, flash it.
self.flash_thread = DeviceFlasher([path_to_microbit],
b'', None)
else:
message = _('Unsupported BBC micro:bit.')
information = _("Your device is newer than this "
"version of Mu. Please update Mu "
"to the latest version to support "
"this device.\n\n"
"https://codewith.mu/")
self.view.show_message(message, information)
return
if sys.platform == 'win32':
# Windows blocks on write.
self.flash_thread.finished.connect(self.flash_finished)
else:
if user_defined_microbit_path:
# Call the flash_finished immediately the thread
# finishes if Mu is writing the hex file to a user
# defined location on the local filesystem.
self.flash_thread.finished.connect(self.flash_finished)
else:
# Other platforms don't block, so schedule the finish
# call for 10 seconds (approximately how long flashing
# the connected device takes).
self.flash_timer = QTimer()
self.flash_timer.timeout.connect(self.flash_finished)
self.flash_timer.setSingleShot(True)
self.flash_timer.start(10000)
self.flash_thread.on_flash_fail.connect(self.flash_failed)
self.flash_thread.start()
else:
try:
self.copy_main()
except IOError as ioex:
# There was a problem with the serial communication with
# the device, so revert to forced flash... "old style".
# THIS IS A HACK! :-(
logger.warning('Could not copy file to device.')
logger.error(ioex)
logger.info('Falling back to old-style flashing.')
self.flash_thread = DeviceFlasher([path_to_microbit],
self.python_script,
rt_hex_path)
self.python_script = ''
if sys.platform == 'win32':
# Windows blocks on write.
self.flash_thread.finished.connect(self.flash_finished)
else:
self.flash_timer = QTimer()
self.flash_timer.timeout.connect(self.flash_finished)
self.flash_timer.setSingleShot(True)
self.flash_timer.start(10000)
self.flash_thread.on_flash_fail.connect(self.flash_failed)
self.flash_thread.start()
except Exception as ex:
self.flash_failed(ex)
else:
# Try to be helpful... essentially there is nothing Mu can do but
# prompt for patience while the device is mounted and/or do the
# classic "have you tried switching it off and on again?" trick.
# This one's for James at the Raspberry Pi Foundation. ;-)
message = _('Could not find an attached BBC micro:bit.')
information = _("Please ensure you leave enough time for the BBC"
" micro:bit to be attached and configured"
" correctly by your computer. This may take"
" several seconds."
" Alternatively, try removing and re-attaching the"
" device or saving your work and restarting Mu if"
" the device remains unfound.")
self.view.show_message(message, information)
[docs] def flash_finished(self):
"""
Called when the thread used to flash the micro:bit has finished.
"""
self.set_buttons(flash=True)
self.editor.show_status_message(_("Finished flashing."))
self.flash_thread = None
self.flash_timer = None
if self.python_script:
try:
self.copy_main()
except Exception as ex:
self.flash_failed(ex)
[docs] def copy_main(self):
"""
If the attribute self.python_script contains any code, copy it onto the
connected micro:bit as main.py, then restart the board (CTRL-D).
"""
if self.python_script.strip():
script = self.python_script
logger.info('Copying main.py onto device')
commands = [
"fd = open('main.py', 'wb')",
"f = fd.write",
]
while script:
line = script[:64]
commands.append('f(' + repr(line) + ')')
script = script[64:]
commands.append('fd.close()')
logger.info(commands)
serial = microfs.get_serial()
out, err = microfs.execute(commands, serial)
logger.info((out, err))
if err:
raise IOError(microfs.clean_error(err))
# Reset the device.
serial.write(b'import microbit\r\n')
serial.write(b'microbit.reset()\r\n')
self.editor.show_status_message(_('Copied code onto micro:bit.'))
self.python_script = ''
[docs] def flash_failed(self, error):
"""
Called when the thread used to flash the micro:bit encounters a
problem.
"""
logger.error(error)
message = _("There was a problem flashing the micro:bit.")
information = _("Please do not disconnect the device until flashing"
" has completed. Please check the logs for more"
" information.")
self.view.show_message(message, information, 'Warning')
if self.flash_timer:
self.flash_timer.stop()
self.flash_timer = None
self.set_buttons(flash=True)
self.flash_thread = None
[docs] def toggle_repl(self, event):
"""
Check for the existence of the file pane before toggling REPL.
"""
if self.fs is None:
super().toggle_repl(event)
if self.repl:
self.set_buttons(flash=False, files=False)
elif not (self.repl or self.plotter):
self.set_buttons(flash=True, files=True)
else:
message = _("REPL and file system cannot work at the same time.")
information = _("The REPL and file system both use the same USB "
"serial connection. Only one can be active "
"at any time. Toggle the file system off and "
"try again.")
self.view.show_message(message, information)
[docs] def toggle_plotter(self, event):
"""
Check for the existence of the file pane before toggling plotter.
"""
if self.fs is None:
super().toggle_plotter(event)
if self.plotter:
self.set_buttons(flash=False, files=False)
elif not (self.repl or self.plotter):
self.set_buttons(flash=True, files=True)
else:
message = _("The plotter and file system cannot work at the same "
"time.")
information = _("The plotter and file system both use the same "
"USB serial connection. Only one can be active "
"at any time. Toggle the file system off and "
"try again.")
self.view.show_message(message, information)
[docs] def toggle_files(self, event):
"""
Check for the existence of the REPL or plotter before toggling the file
system navigator for the micro:bit on or off.
"""
if (self.repl or self.plotter):
message = _("File system cannot work at the same time as the "
"REPL or plotter.")
information = _("The file system and the REPL and plotter "
"use the same USB serial connection. Toggle the "
"REPL and plotter off and try again.")
self.view.show_message(message, information)
else:
if self.fs is None:
self.add_fs()
if self.fs:
logger.info('Toggle filesystem on.')
self.set_buttons(flash=False, repl=False, plotter=False)
else:
self.remove_fs()
logger.info('Toggle filesystem off.')
self.set_buttons(flash=True, repl=True, plotter=True)
[docs] def add_fs(self):
"""
Add the file system navigator to the UI.
"""
# Check for micro:bit
port, serial_number = self.find_device()
if not port:
message = _('Could not find an attached BBC micro:bit.')
information = _("Please make sure the device is plugged "
"into this computer.\n\nThe device must "
"have MicroPython flashed onto it before "
"the file system will work.\n\n"
"Finally, press the device's reset button "
"and wait a few seconds before trying "
"again.")
self.view.show_message(message, information)
return
self.file_manager_thread = QThread(self)
self.file_manager = FileManager(port)
self.file_manager.moveToThread(self.file_manager_thread)
self.file_manager_thread.started.\
connect(self.file_manager.on_start)
self.fs = self.view.add_filesystem(self.workspace_dir(),
self.file_manager,
_("micro:bit"))
self.fs.set_message.connect(self.editor.show_status_message)
self.fs.set_warning.connect(self.view.show_message)
self.file_manager_thread.start()
[docs] def remove_fs(self):
"""
Remove the file system navigator from the UI.
"""
self.view.remove_filesystem()
self.file_manager = None
self.file_manager_thread = None
self.fs = None
[docs] def on_data_flood(self):
"""
Ensure the Files button is active before the REPL is killed off when
a data flood of the plotter is detected.
"""
self.set_buttons(files=True)
super().on_data_flood()
[docs] def open_file(self, path):
"""
Tries to open a MicroPython hex file with an embedded Python script.
Returns the embedded Python script and newline convention.
"""
text = None
if path.lower().endswith('.hex'):
# Try to open the hex and extract the Python script
try:
with open(path, newline='') as f:
text = uflash.extract_script(f.read())
except Exception:
return None, None
return text, sniff_newline_convention(text)
else:
return None, None