"""Handle MySensors OTA FW updates.""" import binascii import logging import os import struct import crcmod.predefined from intelhex import IntelHex, IntelHexError FIRMWARE_BLOCK_SIZE = 16 _LOGGER = logging.getLogger(__name__) def fw_hex_to_int(hex_str, words): """Unpack hex string into integers. Use little-endian and unsigned int format. Specify number of words to unpack with argument words. """ return struct.unpack(f"<{words}H", binascii.unhexlify(hex_str)) def fw_int_to_hex(*args): """Pack integers into hex string. Use little-endian and unsigned int format. """ return binascii.hexlify(struct.pack(f"<{len(args)}H", *args)).decode("utf-8") def compute_crc(data): """Compute CRC16 of data and return an int.""" crc16 = crcmod.predefined.Crc("modbus") crc16.update(data) return int(crc16.hexdigest(), 16) def load_fw(path): """Open firmware file and return a binary string.""" fname = os.path.realpath(path) exists = os.path.isfile(fname) if not exists or not os.access(fname, os.R_OK): _LOGGER.error("Firmware path %s does not exist or is not readable", path) return None try: intel_hex = IntelHex() with open(path, "r", encoding="utf-8") as file_handle: intel_hex.fromfile(file_handle, format="hex") return intel_hex.tobinstr() except (IntelHexError, TypeError, ValueError) as exc: _LOGGER.error("Firmware not valid, check the hex file at %s: %s", path, exc) return None def prepare_fw(bin_string): """Check that firmware is valid and return dict with binary data.""" pads = len(bin_string) % 128 # 128 bytes per page for atmega328 for _ in range(128 - pads): # pad up to even 128 bytes bin_string += b"\xff" fware = { "blocks": int(len(bin_string) / FIRMWARE_BLOCK_SIZE), "crc": compute_crc(bin_string), "data": bin_string, } return fware class OTAFirmware: """Organize OTAFirmware updates.""" def __init__(self, sensors, const): """Set up OTA firmware instance.""" self._sensors = sensors self._const = const self.firmware = {} self.requested = {} self.started = {} self.unstarted = {} def _get_fw(self, msg, updates, req_fw_type=None, req_fw_ver=None): """Get firmware type, version and a dict holding binary data.""" fw_type = None fw_ver = None if not isinstance(updates, tuple): updates = (updates,) for store in updates: fw_id = store.pop(msg.node_id, None) if fw_id is not None: fw_type, fw_ver = fw_id updates[-1][msg.node_id] = fw_id break if fw_type is None or fw_ver is None: _LOGGER.debug("Node %s is not set for firmware update", msg.node_id) return None, None, None if req_fw_type is not None and req_fw_ver is not None: fw_type, fw_ver = req_fw_type, req_fw_ver fware = self.firmware.get((fw_type, fw_ver)) if fware is None: _LOGGER.debug( "No firmware of type %s and version %s found", fw_type, fw_ver ) return None, None, None return fw_type, fw_ver, fware def respond_fw(self, msg): """Respond to a firmware request.""" req_fw_type, req_fw_ver, req_blk = fw_hex_to_int(msg.payload, 3) _LOGGER.debug( "Received firmware request with firmware type %s, " "firmware version %s, block index %s", req_fw_type, req_fw_ver, req_blk, ) fw_type, fw_ver, fware = self._get_fw( msg, (self.unstarted, self.started), req_fw_type, req_fw_ver ) if fware is None: return None blk_data = fware["data"][ req_blk * FIRMWARE_BLOCK_SIZE : req_blk * FIRMWARE_BLOCK_SIZE + FIRMWARE_BLOCK_SIZE ] msg = msg.copy(sub_type=self._const.Stream.ST_FIRMWARE_RESPONSE) msg.payload = fw_int_to_hex(fw_type, fw_ver, req_blk) # format blk_data into payload format msg.payload = msg.payload + binascii.hexlify(blk_data).decode("utf-8") return msg def respond_fw_config(self, msg): """Respond to a firmware config request.""" (req_fw_type, req_fw_ver, req_blocks, req_crc, bloader_ver) = fw_hex_to_int( msg.payload, 5 ) _LOGGER.debug( "Received firmware config request with firmware type %s, " "firmware version %s, %s blocks, CRC %s, bootloader %s", req_fw_type, req_fw_ver, req_blocks, req_crc, bloader_ver, ) fw_type, fw_ver, fware = self._get_fw(msg, (self.requested, self.unstarted)) if fware is None: return None if fw_type != req_fw_type: _LOGGER.warning( "Firmware type %s of update is not identical to existing " "firmware type %s for node %s", fw_type, req_fw_type, msg.node_id, ) _LOGGER.info( "Updating node %s to firmware type %s version %s from type %s " "version %s", msg.node_id, fw_type, fw_ver, req_fw_type, req_fw_ver, ) msg = msg.copy(sub_type=self._const.Stream.ST_FIRMWARE_CONFIG_RESPONSE) msg.payload = fw_int_to_hex(fw_type, fw_ver, fware["blocks"], fware["crc"]) return msg def make_update(self, nids, fw_type, fw_ver, fw_bin=None): """Start firmware update process for one or more node_id.""" try: fw_type, fw_ver = int(fw_type), int(fw_ver) except ValueError: _LOGGER.error( "Firmware type %s or version %s not valid, please enter integers", fw_type, fw_ver, ) return if fw_bin is not None: fware = prepare_fw(fw_bin) self.firmware[fw_type, fw_ver] = fware if (fw_type, fw_ver) not in self.firmware: _LOGGER.error( "No firmware of type %s and version %s found, " "please enter path to firmware in call", fw_type, fw_ver, ) return if not isinstance(nids, list): nids = [nids] for node_id in nids: if node_id not in self._sensors: continue for store in self.unstarted, self.started: store.pop(node_id, None) self.requested[node_id] = fw_type, fw_ver self._sensors[node_id].reboot = True