diff --git a/README.md b/README.md index 86669dc08..1751c15b6 100644 --- a/README.md +++ b/README.md @@ -317,6 +317,14 @@ Internally the binding holds a device state and these states are mapped to the s * FAILED - A device is considered FAILED if the controller can not communicate with the device. The binding does not control this. FAILED devices are treated in a similar way to DEAD devices however the controller will reduce communications to the device and will timeout quicker. It should be noted that the controller will generally not consider battery devices as failed. FAILED devices will be marked as OFFLINE within the system status. +### Thing Firmware + +* A basic firmware update process is available for Z-Wave devices that support firmware updates (most older devices do not). Firmware files for the Z-Wave device need to be retrieved and downloaded from the manufacturer's site. Be very careful to get the right version for your device and right Z-wave frequency (US, EU, AU). Not all manufacturers provide firmware (or firmware updates). If you are not having an issue, Z-Wave firmware updates are not usually needed. There is always some risk of device malfunction, so you should have a reason. +* Place the file you wish to upload in the OH {userdata}/zwave/firmware/node-xx location. It will be pulled into the UI and evaluated if it is above, below or the same version as the firmware currently on the device. That evaluation may not be accurate due to parsing different manufacturer's naming. If you are sure, proceed. Downgrade and Upgrade do the same thing, transfer the firmware to the device. +* The process can take some time (3 - 20 minutes) based on network traffic (best if less) and proximity to the controller. The UI will provide updates at intervals and let you know if the upload was successful at the end. +* Battery devices will need be awakened for the firmware update to proceed. It is advised to have a full or nearly full battery as the device will stay awake for the duration of the update (several minutes). If possible, temporarily move the battery device closer to the controller for a faster update. + + ### Thing Actions At the bottom of the Thing UI page are actions, some advanced, which can be directed to a specific device (node). diff --git a/src/main/java/org/openhab/binding/zwave/firmwareupdate/FirmwareFile.java b/src/main/java/org/openhab/binding/zwave/firmwareupdate/FirmwareFile.java new file mode 100644 index 000000000..4c717d54d --- /dev/null +++ b/src/main/java/org/openhab/binding/zwave/firmwareupdate/FirmwareFile.java @@ -0,0 +1,321 @@ +/* + * Copyright (c) 2010-2025 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.zwave.firmwareupdate; + +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.nio.ByteBuffer; +import java.nio.ByteOrder; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.Optional; +import java.util.zip.ZipEntry; +import java.util.zip.ZipInputStream; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jdt.annotation.Nullable; + +/** + * Represents a firmware file and provides utilities to detect and extract + * firmware data from various vendor formats (BIN, HEX, GBL, Aeotec EXE, ZIP). + * This class is self-contained and does not rely on external libraries. + * + * @author Bob Eckhoff - Initial contribution + */ +@NonNullByDefault +public final class FirmwareFile { + + public final byte[] data; + public final @Nullable Integer firmwareTarget; + + public FirmwareFile(byte[] data, @Nullable Integer firmwareTarget) { + this.data = data; + this.firmwareTarget = firmwareTarget; + } + + /** + * Supported firmware file formats. + */ + public enum FirmwareFileFormat { + BIN, + HEX, + OTA, + OTZ, + GBL, // Gecko bootloader + AEOTEC, // Aeotec EXE/EX_ + ZIP + } + + /** + * Detects the firmware file format based on the filename and raw data. + */ + public static FirmwareFileFormat detectFormat(String filename, byte[] rawData) { + String lower = filename.toLowerCase(); + + if (lower.endsWith(".bin")) { + return FirmwareFileFormat.BIN; + + } else if (lower.endsWith(".gbl")) { + if (rawData.length >= 4) { + int magic = ((rawData[0] & 0xff) << 24) | ((rawData[1] & 0xff) << 16) | ((rawData[2] & 0xff) << 8) + | (rawData[3] & 0xff); + if (magic == 0xEB17A603) { + return FirmwareFileFormat.GBL; + } + } + throw new IllegalArgumentException("Invalid Gecko GBL firmware file"); + + } else if (lower.endsWith(".exe") || lower.endsWith(".ex_")) { + byte[] marker = "Zensys.ZWave".getBytes(StandardCharsets.UTF_8); + if (indexOf(rawData, marker) >= 0) { + return FirmwareFileFormat.AEOTEC; + } + // Must start with MZ and be large enough to contain footer + if (rawData.length >= 12 && rawData[0] == 'M' && rawData[1] == 'Z') { + return FirmwareFileFormat.AEOTEC; + } + + throw new IllegalArgumentException("Unsupported EXE firmware file"); + + } else if (lower.endsWith(".hex") || lower.endsWith(".ota") || lower.endsWith(".otz")) { + return FirmwareFileFormat.HEX; + + } else if (lower.endsWith(".zip")) { + return FirmwareFileFormat.ZIP; + } + + throw new IllegalArgumentException("Unsupported firmware format: " + filename); + } + + /** + * Extracts the firmware data from the given raw data based on the detected format. + */ + public static FirmwareFile extractFirmware(String filename, byte[] rawData) throws IOException { + FirmwareFileFormat format = detectFormat(filename, rawData); + + switch (format) { + case BIN: + case GBL: + return extractBinary(rawData); + + case HEX: + case OTA: + case OTZ: + return extractHex(rawData); + + case AEOTEC: + return extractAeotec(rawData); + + case ZIP: + Optional container = tryUnzipFirmwareFile(rawData); + if (container.isEmpty()) { + throw new IllegalArgumentException("ZIP does not contain a valid firmware file"); + } + FirmwareFileContainer inner = container.get(); + return extractFirmware(inner.filename, inner.rawData); + + default: + throw new IllegalArgumentException("Unsupported firmware format: " + format); + } + } + + /** + * Extracts the firmware data from a binary file. + */ + public static FirmwareFile extractBinary(byte[] data) { + return new FirmwareFile(data, null); + } + + /** + * Extracts the firmware data from a HEX file (Intel HEX format). + */ + public static FirmwareFile extractHex(byte[] asciiBytes) { + List records = HexParser.parse(asciiBytes); + + int maxAddress = records.stream().mapToInt(r -> r.address + r.data.length).max().orElse(0); + + byte[] image = new byte[maxAddress]; + Arrays.fill(image, (byte) 0xFF); + + for (HexRecord r : records) { + System.arraycopy(r.data, 0, image, r.address, r.data.length); + } + + return new FirmwareFile(image, null); + } + + /** + * Extracts the firmware data from an Aeotec EXE file. + */ + public static FirmwareFile extractAeotec(byte[] data) { + ByteBuffer buf = ByteBuffer.wrap(data).order(ByteOrder.BIG_ENDIAN); + + if ((buf.getShort(0) & 0xffff) != 0x4D5A) { + throw new IllegalArgumentException("Not a valid Aeotec updater (no MZ header)"); + } + + int firmwareStart = buf.getInt(data.length - 8); + int firmwareLength = buf.getInt(data.length - 4); + + if (firmwareStart < 0 || firmwareLength <= 0 || firmwareStart + firmwareLength > data.length) { + throw new IllegalArgumentException("Invalid firmware offsets in Aeotec EXE"); + } + + byte[] firmwareData = Arrays.copyOfRange(data, firmwareStart, firmwareStart + firmwareLength); + + return new FirmwareFile(firmwareData, null); + } + + /** + * Attempts to unzip a firmware file and return its contents if a valid firmware file is found. + */ + private static Optional tryUnzipFirmwareFile(byte[] zipBytes) throws IOException { + try (ZipInputStream zis = new ZipInputStream(new ByteArrayInputStream(zipBytes))) { + ZipEntry entry; + while ((entry = zis.getNextEntry()) != null) { + String name = entry.getName().toLowerCase(); + + if (name.endsWith(".bin") || name.endsWith(".hex") || name.endsWith(".ota") || name.endsWith(".otz") + || name.endsWith(".gbl") || name.endsWith(".exe") || name.endsWith(".ex_")) { + byte[] data = zis.readAllBytes(); + FirmwareFileFormat format = detectFormat(name, data); + return Optional.of(new FirmwareFileContainer(name, format, data)); + } + } + } + return Optional.empty(); + } + + private static final class FirmwareFileContainer { + final String filename; + final FirmwareFileFormat format; + final byte[] rawData; + + FirmwareFileContainer(String filename, FirmwareFileFormat format, byte[] rawData) { + this.filename = filename; + this.format = format; + this.rawData = rawData; + } + } + + /** + * Intel HEX parser + */ + private static final class HexRecord { + final int address; + final byte[] data; + + HexRecord(int address, byte[] data) { + this.address = address; + this.data = data; + } + } + + private static final class HexParser { + + public static List parse(byte[] asciiBytes) { + String text = new String(asciiBytes, StandardCharsets.US_ASCII).replace("\r", ""); // normalize CRLF → LF + + String[] lines = text.split("\n"); + List records = new ArrayList<>(); + + int upperAddress = 0; + + for (String rawLine : lines) { + String line = rawLine.trim(); + if (line.isEmpty()) { + continue; + } + if (!line.startsWith(":")) { + throw new IllegalArgumentException("Invalid HEX line (missing colon): " + line); + } + + // Minimum length: ":" + LL + AAAA + TT + CC = 11 chars + if (line.length() < 11) { + throw new IllegalArgumentException("HEX line too short: " + line); + } + + int byteCount = parseByte(line, 1); + int address = parseWord(line, 3); + int recordType = parseByte(line, 7); + + int dataStart = 9; + int dataEnd = dataStart + (byteCount * 2); + + // Check that the line contains enough characters for data + checksum + if (line.length() < dataEnd + 2) { + throw new IllegalArgumentException("HEX line too short for declared byte count: " + line); + } + + // Validate checksum + int sum = 0; + for (int i = 1; i < dataEnd; i += 2) { + sum += parseByte(line, i); + } + int checksum = parseByte(line, dataEnd); + if (((sum + checksum) & 0xFF) != 0) { + throw new IllegalArgumentException("Invalid checksum in HEX line: " + line); + } + + switch (recordType) { + case 0x00: { // Data record + byte[] data = new byte[byteCount]; + for (int i = 0; i < byteCount; i++) { + int pos = dataStart + (i * 2); + data[i] = (byte) Integer.parseInt(line.substring(pos, pos + 2), 16); + } + records.add(new HexRecord(upperAddress + address, data)); + break; + } + case 0x01: // EOF + return records; + + case 0x04: { // Extended linear address + upperAddress = parseWord(line, dataStart) << 16; + break; + } + default: + // Other record types ignored + break; + } + } + + return records; + } + + private static int parseByte(String line, int pos) { + return Integer.parseInt(line.substring(pos, pos + 2), 16) & 0xFF; + } + + private static int parseWord(String line, int pos) { + return Integer.parseInt(line.substring(pos, pos + 4), 16) & 0xFFFF; + } + } + + /** + * Byte-array search helper + */ + private static int indexOf(byte[] data, byte[] pattern) { + outer: for (int i = 0; i <= data.length - pattern.length; i++) { + for (int j = 0; j < pattern.length; j++) { + if (data[i + j] != pattern[j]) { + continue outer; + } + } + return i; + } + return -1; + } +} diff --git a/src/main/java/org/openhab/binding/zwave/firmwareupdate/ZWaveFirmwareUpdateSession.java b/src/main/java/org/openhab/binding/zwave/firmwareupdate/ZWaveFirmwareUpdateSession.java new file mode 100644 index 000000000..593464107 --- /dev/null +++ b/src/main/java/org/openhab/binding/zwave/firmwareupdate/ZWaveFirmwareUpdateSession.java @@ -0,0 +1,1235 @@ +/* + * Copyright (c) 2010-2025 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.zwave.firmwareupdate; + +import java.io.ByteArrayOutputStream; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.Map; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jdt.annotation.Nullable; +import org.openhab.binding.zwave.handler.ZWaveControllerHandler; +import org.openhab.binding.zwave.internal.protocol.ZWaveNode; +import org.openhab.binding.zwave.internal.protocol.ZWaveTransaction; +import org.openhab.binding.zwave.internal.protocol.commandclass.ZWaveCommandClass.CommandClass; +import org.openhab.binding.zwave.internal.protocol.commandclass.ZWaveFirmwareUpdateCommandClass; +import org.openhab.binding.zwave.internal.protocol.commandclass.ZWaveFirmwareUpdateCommandClass.FirmwareUpdateActivationStatus; +import org.openhab.binding.zwave.internal.protocol.commandclass.ZWaveVersionCommandClass; +import org.openhab.binding.zwave.internal.protocol.commandclass.ZWaveFirmwareUpdateCommandClass.FirmwareUpdateMdRequestStatus; +import org.openhab.binding.zwave.internal.protocol.commandclass.ZWaveFirmwareUpdateCommandClass.FirmwareUpdateMdStatusReport; +import org.openhab.binding.zwave.internal.protocol.event.ZWaveEvent; +import org.openhab.binding.zwave.internal.protocol.event.ZWaveNetworkEvent; +import org.openhab.binding.zwave.internal.protocol.event.ZWaveTransactionCompletedEvent; +import org.openhab.binding.zwave.internal.protocol.transaction.ZWaveCommandClassTransactionPayload; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * The {@link ZWaveFirmwareUpdateSession} class represents an active firmware update session for a Z-Wave node. + * Handles the state and the steps of the Z-Wave firmware update process, including managing firmware fragments, + * tracking progress, handling events, and applying timeout/retry behavior for robustness on noisy networks. + * + * @author Robert Eckhoff - Initial contribution + */ +@NonNullByDefault +public class ZWaveFirmwareUpdateSession { + private static final Logger logger = LoggerFactory.getLogger(ZWaveFirmwareUpdateSession.class); + private static final int DEFAULT_MAX_FRAGMENT_SIZE = 32; // Version 1 & 2 do not send this information. + private static final int MAX_REPORT_NUMBER = 0x7FFF; + private static final int MULTI_FRAGMENT_INTERFRAME_DELAY_MS = 35; // Per Spec + private static final int IMAGE_CHECKSUM_INITIAL = 0x1D0F; + private static final int STATUS_REPORT_WAIT_TIMEOUT_SECONDS = 30; + private static final int PROGRESS_EVENT_STEP_PERCENT = 5; + private static final long DUPLICATE_GET_RESEND_DELAY_MS = TimeUnit.SECONDS.toMillis(10); + + private int startReportNumber; + private int count; + private final ZWaveNode node; + private final ZWaveControllerHandler controller; + private final byte[] firmwareBytes; + private final int firmwareChecksum; + private final int firmwareTarget; + + private volatile boolean active = false; + private volatile State state = State.IDLE; + + private List fragments = List.of(); + private @Nullable FirmwareMetadata sessionMetadata; + private int highestAckedReportNumber = 0; + private volatile int highestTransmittedReportNumber = 0; + private int duplicateGetsForSentReport = 0; + private int lastPublishedProgressPercent = 0; + private final AtomicInteger statusReportTimeoutGeneration = new AtomicInteger(0); + private final Map reportLastSentTimes = new ConcurrentHashMap<>(); + + /** + * Create a new firmware update session for the given node, controller, and firmware image. + * + * @param node the Z-Wave node for which the firmware update session is created + * @param controller the Z-Wave controller handler + * @param firmwareBytes the firmware image bytes + * @param firmwareTarget the firmware target (0 = Z-Wave firmware, other values not supported) + */ + public ZWaveFirmwareUpdateSession(ZWaveNode node, ZWaveControllerHandler controller, byte[] firmwareBytes, + int firmwareTarget) { + this.node = node; + this.controller = controller; + this.firmwareBytes = firmwareBytes; + this.firmwareChecksum = ZWaveFirmwareUpdateCommandClass.crc16Ccitt(firmwareBytes, IMAGE_CHECKSUM_INITIAL); + this.firmwareTarget = firmwareTarget; + } + + /** + * Start the firmware update session by requesting metadata from the device. + * The session then progresses through its state machine as events are received. + */ + public void start() { + logger.info("NODE {}: Firmware session starting", node.getNodeId()); + active = true; + state = State.WAITING_FOR_MD_REPORT; + invalidateStatusReportTimeout(); + highestAckedReportNumber = 0; + highestTransmittedReportNumber = 0; + duplicateGetsForSentReport = 0; + lastPublishedProgressPercent = 0; + reportLastSentTimes.clear(); + + requestMetadata(); // (1) Start the process by requesting devicemetadata. + // Will be queued for battery devices. Not active update until the device wakes up. + } + + public boolean isActive() { + return active; + } + + public State getState() { + return state; + } + + public void abort(String reason) { + if (!active) { + return; + } + + failFirmwareUpdate("Firmware update session aborted: " + reason); + } + + // Sends or queues the initial FIRMWARE_MD_GET to start the process + private void requestMetadata() { + ZWaveFirmwareUpdateCommandClass fw = (ZWaveFirmwareUpdateCommandClass) node + .getCommandClass(CommandClass.COMMAND_CLASS_FIRMWARE_UPDATE_MD); + + ZWaveCommandClassTransactionPayload msg = fw.sendMDGetMessage(); + node.sendMessage(msg); + + logger.debug("NODE {}: Sent Firmware MD Get", node.getNodeId()); + } + + /** + * Firmware update events used to progress through the firmware update process. + * These are diverted from events normally received by the Z-Wave Thing Handler. + */ + public static class FirmwareUpdateEvent extends ZWaveEvent { + private final FirmwareEventType type; + + private final int reportNumber; + private final int numReports; + private final int status; + private final int waitTime; + private final byte[] payload; + private final @Nullable Boolean resume; + private final @Nullable Boolean nonSecure; + + private FirmwareUpdateEvent(int nodeId, int endpoint, FirmwareEventType type, int reportNumber, int numReports, + int status, int waitTime, byte[] payload, @Nullable Boolean resume, @Nullable Boolean nonSecure) { + super(nodeId, endpoint); + this.type = type; + this.reportNumber = reportNumber; + this.numReports = numReports; + this.status = status; + this.waitTime = waitTime; + this.payload = payload; + this.resume = resume; + this.nonSecure = nonSecure; + } + + public static FirmwareUpdateEvent forMDReport(int nodeId, int endpoint, byte[] payload) { + return new FirmwareUpdateEvent(nodeId, endpoint, FirmwareEventType.MD_REPORT, -1, 0, 0, 0, payload, null, + null); + } + + public static FirmwareUpdateEvent forUpdateMdRequestReport(int nodeId, int endpoint, int status, + @Nullable Boolean resume, @Nullable Boolean nonSecure) { + return new FirmwareUpdateEvent(nodeId, endpoint, FirmwareEventType.UPDATE_MD_REQUEST_REPORT, -1, 0, status, + 0, new byte[0], resume, nonSecure); + } + + public static FirmwareUpdateEvent forUpdateMdGet(int nodeId, int endpoint, int reportNumber, int numReports) { + return new FirmwareUpdateEvent(nodeId, endpoint, FirmwareEventType.UPDATE_MD_GET, reportNumber, numReports, + 0, 0, new byte[0], null, null); + } + + public static FirmwareUpdateEvent forUpdateMdStatusReport(int nodeId, int endpoint, int status, int waitTime) { + return new FirmwareUpdateEvent(nodeId, endpoint, FirmwareEventType.UPDATE_MD_STATUS_REPORT, -1, 0, status, + waitTime, new byte[0], null, null); + } + + public static FirmwareUpdateEvent forActivationStatusReport(int nodeId, int endpoint, int status) { + return new FirmwareUpdateEvent(nodeId, endpoint, FirmwareEventType.ACTIVATION_STATUS_REPORT, -1, 0, status, + 0, new byte[0], null, null); + } + + public FirmwareEventType getType() { + return type; + } + + public int getReportNumber() { + return reportNumber; + } + + public int getNumReports() { + return numReports; + } + + public byte[] getPayload() { + return payload; + } + + public @Nullable Boolean getResume() { + return resume; + } + + public @Nullable Boolean getNonSecure() { + return nonSecure; + } + + public int getStatus() { + return status; + } + + public int getWaitTime() { + return waitTime; + } + } + + /** + * Handle firmware update events, including failed transaction completions and firmware-specific reports. + * Routes each event to the appropriate logic based on the current session state and event type. + * + * @param event firmware update related event, either {@link ZWaveTransactionCompletedEvent} or + * {@link FirmwareUpdateEvent} + * @return true if the event was handled as part of the firmware update session, false otherwise + */ + public boolean handleEvent(Object event) { + if (event instanceof ZWaveTransactionCompletedEvent tcEvent) { + // Handle failed Z-Wave transaction completion (!tcEvent) + if (!tcEvent.getState() && tcEvent.getNodeId() == node.getNodeId()) { + ZWaveTransaction completedTransaction = tcEvent.getCompletedTransaction(); + if (!isFirmwareUpdateTransaction(completedTransaction)) { + return false; + } + + int txCommand = getFirmwareUpdateTransactionCommand(completedTransaction); + + if (state == State.WAITING_FOR_MD_REPORT + && txCommand == ZWaveFirmwareUpdateCommandClass.FIRMWARE_MD_GET) { + logger.debug("NODE {}: FIRMWARE_MD_GET transaction failed after all retries", node.getNodeId()); + failFirmwareUpdate("FIRMWARE_MD_GET failed after all retries"); + return true; + } + + if (state == State.WAITING_FOR_UPDATE_MD_REQUEST_REPORT + && txCommand == ZWaveFirmwareUpdateCommandClass.FIRMWARE_UPDATE_MD_REQUEST_GET) { + logger.debug("NODE {}: FIRMWARE_UPDATE_MD_REQUEST_GET transaction failed after all retries", + node.getNodeId()); + failFirmwareUpdate("FIRMWARE_UPDATE_MD_REQUEST_GET failed after all retries"); + return true; + } + + // If a firmware fragment transaction failed while sending fragments, attempt to + // resend when outside the duplicate resend delay window. + if ((state == State.SENDING_FRAGMENTS || state == State.WAITING_FOR_UPDATE_MD_GET) + && txCommand == ZWaveFirmwareUpdateCommandClass.FIRMWARE_UPDATE_MD_REPORT) { + int resendStart = Math.max(1, startReportNumber); + int resendCount = Math.max(1, count); + Long lastSentTime = reportLastSentTimes.get(resendStart); + long elapsedMillis = lastSentTime != null ? currentTimeMillis() - lastSentTime.longValue() + : Long.MAX_VALUE; + + if (lastSentTime != null && elapsedMillis < DUPLICATE_GET_RESEND_DELAY_MS) { + logger.debug( + "NODE {}: Firmware fragment transaction failed/cancelled for fragment {}, but it was sent {}ms ago (<{}ms); skipping immediate requeue and waiting for next UPDATE_MD_GET", + node.getNodeId(), resendStart, elapsedMillis, DUPLICATE_GET_RESEND_DELAY_MS); + state = State.WAITING_FOR_UPDATE_MD_GET; + return true; + } + + logger.debug( + "NODE {}: Firmware fragment transaction failed/cancelled; retrying fragment send from {} (count={})", + node.getNodeId(), resendStart, resendCount); + state = State.SENDING_FRAGMENTS; + sendNextFragment(resendStart, resendCount); + return true; + } + } + return false; + } + + if (!(event instanceof FirmwareUpdateEvent fwEvent)) { + return false; + } + + switch (fwEvent.getType()) { + case MD_REPORT: + return handleMetadataReport(fwEvent); + + case UPDATE_MD_REQUEST_REPORT: + return handleUpdateMdRequestReport(fwEvent); + + case UPDATE_MD_GET: + return handleUpdateMdGet(fwEvent); + + case UPDATE_MD_STATUS_REPORT: + return handleUpdateMdStatusReport(fwEvent); + + case ACTIVATION_STATUS_REPORT: + return handleActivationStatusReport(fwEvent); + default: + break; + } + + return false; + } + + private boolean isFirmwareUpdateTransaction(ZWaveTransaction transaction) { + byte[] txPayload = transaction.getPayloadBuffer(); + return txPayload.length >= 2 && (txPayload[0] & 0xFF) == CommandClass.COMMAND_CLASS_FIRMWARE_UPDATE_MD.getKey(); + } + + private int getFirmwareUpdateTransactionCommand(ZWaveTransaction transaction) { + byte[] txPayload = transaction.getPayloadBuffer(); + if (txPayload.length < 2) { + return -1; + } + return txPayload[1] & 0xFF; + } + + /** + * Handle Metadata Report (2), the first report received after metadata is requested. + * Parses metadata, prepares firmware fragments, and sends UPDATE_MD_REQUEST_GET (3) to continue. + * + * @param event the firmware update event containing the metadata report + * @return true if the event was handled, false otherwise + */ + private boolean handleMetadataReport(FirmwareUpdateEvent event) { + if (state != State.WAITING_FOR_MD_REPORT) { + return false; + } + + logger.debug("NODE {}: Received Metadata Report", node.getNodeId()); + + FirmwareMetadata metadata; + try { + // Parse metadata from the raw payload + metadata = parseMetadata(event.getPayload()); + } catch (IllegalArgumentException e) { + failFirmwareUpdate("Malformed metadata report payload: " + e.getMessage(), e.getMessage()); + return true; + } + + if (event.getPayload().length >= 10 && !metadata.upgradable()) { + failFirmwareUpdate("Metadata report indicates firmware is not upgradable", Integer.valueOf(0)); + return true; + } + + logger.debug( + "NODE {}: Metadata parsed: manufacturerId={}, firmwareId={}, checksum={}, maxFragmentSize={}, hwPresent={}, hwVersion={}, mappedFlags=0x{}", + node.getNodeId(), metadata.manufacturerId(), metadata.firmwareId(), metadata.checksum(), + metadata.maxFragmentSize(), metadata.hardwareVersionPresent(), metadata.hardwareVersion(), + Integer.toHexString(metadata.requestFlags())); + + this.sessionMetadata = metadata; + // Disable sleep on battery devices while the firmware update is active. + node.setFirmwareUpdateInProgress(true); + + // Prepare fragments using maxFragmentSize + if (!prepareFragments(metadata)) { + return true; + } + + // Build and send UPDATE_MD_REQUEST_GET (3) + sendFirmwareUpdateMdRequestGet(metadata); + + state = State.WAITING_FOR_UPDATE_MD_REQUEST_REPORT; + return true; + } + + /** + * Parse the raw metadata-report payload into a structured {@link FirmwareMetadata} object. + * Handles legacy V1/V2 reports (6 bytes) and V3+ reports (10+ bytes), extracting manufacturer ID, + * firmware ID, checksum, maximum fragment size, hardware version, and optional feature flags. + * This parsed data is reused later when constructing request payloads. + * + * @param payload the raw payload from the MD Report + * @return a {@link FirmwareMetadata} object containing the parsed metadata + * @throws IllegalArgumentException if the payload is malformed or too short + */ + private FirmwareMetadata parseMetadata(byte[] payload) { + if (payload.length < 6) { + throw new IllegalArgumentException("payload too short (need at least 6 bytes, got " + payload.length + ")"); + } + + int manufacturerId = ((payload[0] & 0xFF) << 8) | (payload[1] & 0xFF); + int firmwareId = ((payload[2] & 0xFF) << 8) | (payload[3] & 0xFF); + int checksum = ((payload[4] & 0xFF) << 8) | (payload[5] & 0xFF); + + // V1/V2 only provide the first 6 bytes; assume upgradable and use default + // max fragment size. + if (payload.length == 6) { + byte[] report3Payload = buildLegacyReport3Payload(manufacturerId, firmwareId, checksum, false, false, + DEFAULT_MAX_FRAGMENT_SIZE, false, 0, 0); + + return new FirmwareMetadata(manufacturerId, firmwareId, checksum, true, DEFAULT_MAX_FRAGMENT_SIZE, 0, false, + 0, false, 0, report3Payload); + } + + // V3+ metadata requires bytes 6..9. + if (payload.length < 10) { + throw new IllegalArgumentException( + "payload too short for v3+ metadata (need at least 10 bytes, got " + payload.length + ")"); + } + + boolean upgradable = (payload[6] & 0xFF) != 0; + int additionalTargets = payload[7] & 0xFF; + int maxFragmentSize = ((payload[8] & 0xFF) << 8) | (payload[9] & 0xFF); + + int index = 10 + (additionalTargets * 2); + if (index > payload.length) { + throw new IllegalArgumentException("additional target data exceeds payload length (targets=" + + additionalTargets + ", payload=" + payload.length + ")"); + } + + int remaining = payload.length - index; + int parsedVersion; + if (remaining <= 0) { + parsedVersion = 3; + } else if (remaining == 1) { + parsedVersion = 5; + } else if (remaining == 2) { + parsedVersion = 6; + } else { + parsedVersion = 7; + } + + boolean hardwareVersionPresent = parsedVersion >= 5 && remaining >= 1; + int hardwareVersion = hardwareVersionPresent ? payload[index] & 0xFF : 0; + + // V6+: one report-2 flags byte follows hardware version: + // bit0=functionality, bit1=activation, bit2=non-secure, bit3=resume. + Integer report2Flags = parsedVersion >= 6 && remaining >= 2 ? Integer.valueOf(payload[index + 1] & 0xFF) : null; + + boolean ccFunctionalityPresent = report2Flags != null && (report2Flags.intValue() & 0x01) != 0; + + int requestFlags = mapRequestFlags(report2Flags); + + byte[] report3Payload = buildLegacyReport3Payload(manufacturerId, firmwareId, checksum, parsedVersion >= 3, + parsedVersion >= 4, maxFragmentSize, hardwareVersionPresent, hardwareVersion, requestFlags); + + return new FirmwareMetadata(manufacturerId, firmwareId, checksum, upgradable, maxFragmentSize, + additionalTargets, hardwareVersionPresent, hardwareVersion, ccFunctionalityPresent, requestFlags, + report3Payload); + } + + private int getFirmwareUpdateMdVersion() { + ZWaveFirmwareUpdateCommandClass fw = (ZWaveFirmwareUpdateCommandClass) node + .getCommandClass(CommandClass.COMMAND_CLASS_FIRMWARE_UPDATE_MD); + return fw != null ? fw.getVersion() : -1; + } + + private int mapRequestFlags(@Nullable Integer report2Flags) { + if (report2Flags == null) { + return 0; + } + + int source = report2Flags.intValue(); + int requestFlags = 0; + + // source bit3 -> request bit2 (resume) + if ((source & 0x08) != 0) { + requestFlags |= 0x04; + } + // source bit2 -> request bit1 (non-secure) + if ((source & 0x04) != 0) { + requestFlags |= 0x02; + } + // source bit1 -> request bit0 (activation required) + if ((source & 0x02) != 0) { + requestFlags |= 0x01; + } + + return requestFlags; + } + + private byte[] buildLegacyReport3Payload(int manufacturerId, int firmwareId, int checksum, boolean includeV3Fields, + boolean includeReport3Flags, int maxFragmentSize, boolean hardwareVersionPresent, int hardwareVersion, + int requestFlags) { + ByteArrayOutputStream out = new ByteArrayOutputStream(); + + out.write((manufacturerId >> 8) & 0xFF); + out.write(manufacturerId & 0xFF); + + out.write((firmwareId >> 8) & 0xFF); + out.write(firmwareId & 0xFF); + + out.write((checksum >> 8) & 0xFF); + out.write(checksum & 0xFF); + + if (includeV3Fields) { + // V3+: firmware target (always 0) + max fragment size. + out.write(firmwareTarget & 0xFF); + out.write((maxFragmentSize >> 8) & 0xFF); + out.write(maxFragmentSize & 0xFF); + + // V4+: report3 includes flags byte before hardware version. + if (includeReport3Flags) { + out.write(requestFlags & 0xFF); + } + + // V5+: hardware version follows report3 flags byte. + if (hardwareVersionPresent) { + out.write(hardwareVersion & 0xFF); + } + } + return out.toByteArray(); + } + + public record FirmwareMetadata(int manufacturerId, int firmwareId, int checksum, boolean upgradable, + int maxFragmentSize, int additionalTargets, boolean hardwareVersionPresent, int hardwareVersion, + boolean ccFunctionalityPresent, int requestFlags, byte[] report3Payload) { + } + + private byte[] buildFirmwareBaseData(FirmwareMetadata metadata, int ccVersion) { + ByteArrayOutputStream out = new ByteArrayOutputStream(); + + out.write((metadata.manufacturerId() >> 8) & 0xFF); + out.write(metadata.manufacturerId() & 0xFF); + + out.write((metadata.firmwareId() >> 8) & 0xFF); + out.write(metadata.firmwareId() & 0xFF); + + out.write((firmwareChecksum >> 8) & 0xFF); + out.write(firmwareChecksum & 0xFF); + + out.write(firmwareTarget & 0xFF); + + if (ccVersion >= 5 && metadata.hardwareVersionPresent()) { + out.write(metadata.hardwareVersion() & 0xFF); + } + + return out.toByteArray(); + } + + private byte[] buildMdRequestGet(FirmwareMetadata md) { + byte[] payload = Arrays.copyOf(md.report3Payload(), md.report3Payload().length); + if (payload.length >= 6) { + payload[4] = (byte) ((firmwareChecksum >> 8) & 0xFF); + payload[5] = (byte) (firmwareChecksum & 0xFF); + } + return payload; + } + + /** + * Prepares the firmware fragments for transmission based on the metadata. + * Each fragment contains a portion of the firmware data along with its report + * number and a flag indicating if it is the last fragment. + * + * @param metadata the firmware metadata containing information about the + * firmware update + * @return true if fragments were successfully prepared, false otherwise + */ + private boolean prepareFragments(FirmwareMetadata metadata) { + fragments = new ArrayList<>(); + + // maxFragmentSize specifies the firmware DATA bytes per fragment only; + // the CC/CMD/reportNum/CRC overhead is added by the serializer on top of this. + int usable = metadata.maxFragmentSize(); + + if (usable <= 0) { + failFirmwareUpdate( + "Max fragment size too small for firmware update (max=" + metadata.maxFragmentSize() + ")", + Integer.valueOf(metadata.maxFragmentSize())); + return false; + } + + int offset = 0; + int reportNumber = 1; + + while (offset < firmwareBytes.length) { + if (reportNumber > MAX_REPORT_NUMBER) { + failFirmwareUpdate("Firmware requires more than " + MAX_REPORT_NUMBER + " reports", + Integer.valueOf(MAX_REPORT_NUMBER)); + fragments = List.of(); + return false; + } + + int remaining = firmwareBytes.length - offset; + int chunkSize = Math.min(usable, remaining); + + byte[] chunk = Arrays.copyOfRange(firmwareBytes, offset, offset + chunkSize); + + boolean isLast = (offset + chunkSize) >= firmwareBytes.length; + + fragments.add(new FirmwareFragment(reportNumber, isLast, chunk)); + + offset += chunkSize; + reportNumber++; + } + + logger.debug("NODE {}: Prepared {} fragments (usable={} bytes each)", node.getNodeId(), fragments.size(), + usable); + return true; + } + + /** + * Represents a fragment of the firmware being transmitted. + * Each fragment contains a portion of the firmware data along with its report + * number and a flag indicating if it is the last fragment. + */ + public static class FirmwareFragment { + private final int reportNumber; + private final boolean isLast; + private final byte[] data; + + public FirmwareFragment(int reportNumber, boolean isLast, byte[] data) { + this.reportNumber = reportNumber; + this.isLast = isLast; + this.data = data; + } + + public int getReportNumber() { + return reportNumber; + } + + public boolean isLast() { + return isLast; + } + + public byte[] getData() { + return data; + } + } + + // Sends the FIRMWARE_MD_REQUEST_GET with metadata from the initial report, to + // confirm update parameters + private void sendFirmwareUpdateMdRequestGet(FirmwareMetadata metadata) { + ZWaveFirmwareUpdateCommandClass fw = (ZWaveFirmwareUpdateCommandClass) node + .getCommandClass(CommandClass.COMMAND_CLASS_FIRMWARE_UPDATE_MD); + + byte[] payload = buildMdRequestGet(metadata); + + ZWaveCommandClassTransactionPayload msg = fw.sendMDRequestGetMessage(payload); + + node.sendMessage(msg); + + logger.debug("NODE {}: Sent Firmware MD RequestGet", node.getNodeId()); + } + + /** + * Handle Update MD Request Report (4) from the device. + * This indicates whether the device accepts the parameters from the earlier MD Request Get. + * If accepted, the device should next send UPDATE_MD_GET (5) frames. + * + * @param event the Update MD Request Report + * @return true if the event was handled in the current state, false otherwise + */ + private boolean handleUpdateMdRequestReport(FirmwareUpdateEvent event) { + if (state != State.WAITING_FOR_UPDATE_MD_REQUEST_REPORT) { + return false; + } + + // Version 8, resume = devices agrees to resume a previously interrupted update, + // nonSecure = device agrees to accept firmware without security encoding + FirmwareUpdateMdRequestStatus requestStatus = FirmwareUpdateMdRequestStatus.from(event.getStatus()); + logger.debug("NODE {}: Received Update MD Request Report", node.getNodeId()); + logger.debug("NODE {}: Status={} ({}), resume={}, nonSecure={}", node.getNodeId(), event.getStatus(), + requestStatus, event.getResume(), event.getNonSecure()); + + if (requestStatus != FirmwareUpdateMdRequestStatus.OK) { + failFirmwareUpdate("Device rejected firmware update request: " + requestStatus, requestStatus.name()); + return true; + } + + state = State.WAITING_FOR_UPDATE_MD_GET; + return true; + } + + /** + * Handle UPDATE_MD_GET from the device. + * Includes safeguards for out-of-sequence requests, duplicate report requests, implicit acknowledgments, + * and retry behavior under slow multi-hop paths and transient communication failures. + * + * @param event the firmware update event representing UPDATE_MD_GET + * @return true if the event was handled successfully, false otherwise + */ + private boolean handleUpdateMdGet(FirmwareUpdateEvent event) { + if (state != State.WAITING_FOR_UPDATE_MD_GET && state != State.SENDING_FRAGMENTS + && state != State.WAITING_FOR_UPDATE_MD_STATUS_REPORT + && state != State.WAITING_FOR_UPDATE_MD_REQUEST_REPORT) { + return false; + } + // Accept UPDATE_MD_GET as an implicit OK in case of missing + // UPDATE_MD_REQUEST_REPORT. + if (state == State.WAITING_FOR_UPDATE_MD_REQUEST_REPORT) { + logger.debug( + "NODE {}: Received UPDATE_MD_GET before UPDATE_MD_REQUEST_REPORT; treating as implicit request acceptance", + node.getNodeId()); + } + + int requestedStartReport = event.getReportNumber(); + int requestedCount = event.getNumReports(); + if (requestedCount <= 0) { + logger.debug("NODE {}: Received UPDATE_MD_GET with invalid count {} - normalizing to 1", node.getNodeId(), + requestedCount); + requestedCount = 1; + } + + if (requestedStartReport < 1 || requestedStartReport > MAX_REPORT_NUMBER) { + logger.warn("NODE {}: Received UPDATE_MD_GET with invalid start fragment {}", node.getNodeId(), + requestedStartReport); + return true; + } + + logger.debug("NODE {}: Received UPDATE_MD_GET for fragment {} (count={})", node.getNodeId(), + requestedStartReport, requestedCount); + + // Ignore out-of-sequence forward jumps. + if (isOutOfSequenceForwardRequest(requestedStartReport)) { + return true; + } + + // A GET for fragment N implicitly ACKs everything below N. Advance the ACK anchor + // accordingly, then ignore any GET at or below the new anchor — the device already + // confirmed receipt of those fragments by previously requesting something higher. + int impliedAckReport = Math.min(highestTransmittedReportNumber, requestedStartReport - 1); + if (impliedAckReport > highestAckedReportNumber) { + if (requestedStartReport == highestTransmittedReportNumber + 1 + && highestTransmittedReportNumber == startReportNumber) { + logger.debug( + "NODE {}: Advancing ACK anchor from {} to {} based on sequential UPDATE_MD_GET start {} after completed fragment {} transmission", + node.getNodeId(), highestAckedReportNumber, impliedAckReport, requestedStartReport, + highestTransmittedReportNumber); + } else { + logger.debug("NODE {}: Advancing ACK anchor from {} to {} based on UPDATE_MD_GET start {}", + node.getNodeId(), highestAckedReportNumber, impliedAckReport, requestedStartReport); + } + highestAckedReportNumber = impliedAckReport; + } + + if (requestedStartReport <= highestAckedReportNumber) { + logger.debug("NODE {}: Ignoring UPDATE_MD_GET for already ACKed fragment {} (ackAnchor={})", + node.getNodeId(), requestedStartReport, highestAckedReportNumber); + return true; + } + + // If the device requests a fragment higher than the one that is currently + // sent, advance to that higher fragment for the next transmission. + // Some nodes send duplicate GETs for an already-sent report. + // Ignore these near-term, but allow a late + // retry so the device can recover if the fragment was truly missed. + if (requestedStartReport > startReportNumber && startReportNumber > 0) { + if (requestedStartReport == highestTransmittedReportNumber + 1 + && highestTransmittedReportNumber == startReportNumber) { + logger.debug( + "NODE {}: Received sequential UPDATE_MD_GET for fragment {} after fragment {} transmission completion; continuing with requested fragment", + node.getNodeId(), requestedStartReport, startReportNumber); + } else { + logger.debug( + "NODE {}: Received UPDATE_MD_GET for fragment {} while trying fragment {}; treating this as implicit ACK of fragment {} and continuing with the higher fragment", + node.getNodeId(), requestedStartReport, startReportNumber, startReportNumber); + } + duplicateGetsForSentReport = 0; + } else if (requestedStartReport <= highestTransmittedReportNumber) { + Long lastSentTime = reportLastSentTimes.get(requestedStartReport); + long elapsedMillis = lastSentTime != null ? currentTimeMillis() - lastSentTime.longValue() : Long.MAX_VALUE; + + if (lastSentTime != null && elapsedMillis < DUPLICATE_GET_RESEND_DELAY_MS) { + duplicateGetsForSentReport++; + logger.debug( + "NODE {}: Ignoring duplicate UPDATE_MD_GET for already-transmitted fragment {} (highestTransmitted={}, duplicateCount={}, elapsedMs={})", + node.getNodeId(), requestedStartReport, highestTransmittedReportNumber, + duplicateGetsForSentReport, elapsedMillis); + return true; + } + + logger.debug( + "NODE {}: Re-sending previously transmitted fragment {} after duplicate UPDATE_MD_GET (elapsedMs={}, resendWindowMs={})", + node.getNodeId(), requestedStartReport, + lastSentTime != null ? Long.valueOf(elapsedMillis) : "unknown", DUPLICATE_GET_RESEND_DELAY_MS); + + // Consume the resend window immediately so another closely-spaced GET + // does not trigger a second resend before transmission bookkeeping updates. + reportLastSentTimes.put(requestedStartReport, currentTimeMillis()); + duplicateGetsForSentReport = 0; + } + duplicateGetsForSentReport = 0; + + if (fragments.isEmpty()) { + fail("No fragments prepared"); + return true; + } + + int remainingFragments = fragments.size() - requestedStartReport + 1; + if (remainingFragments <= 0) { + logger.warn("NODE {}: Received UPDATE_MD_GET start {} beyond available fragments {}", node.getNodeId(), + requestedStartReport, fragments.size()); + return true; + } + + int cappedCount = Math.min(requestedCount, remainingFragments); + if (cappedCount != requestedCount) { + logger.debug("NODE {}: Capping UPDATE_MD_GET count from {} to {} (remaining from start={} is {})", + node.getNodeId(), requestedCount, cappedCount, requestedStartReport, remainingFragments); + } + + // Device is asking for the next fragment. + this.startReportNumber = requestedStartReport; + this.count = cappedCount; + state = State.SENDING_FRAGMENTS; + sendNextFragment(startReportNumber, count); + + return true; + } + + private boolean isOutOfSequenceForwardRequest(int requestedStartReport) { + if (requestedStartReport <= 1) { + return false; + } + + if (highestTransmittedReportNumber <= 0) { + logger.warn( + "NODE {}: Ignoring out-of-sequence UPDATE_MD_GET for fragment {} before fragment 1 was transmitted", + node.getNodeId(), requestedStartReport); + return true; + } + + int nextExpectedReport = highestTransmittedReportNumber + 1; + if (requestedStartReport > nextExpectedReport) { + logger.warn( + "NODE {}: Ignoring out-of-sequence UPDATE_MD_GET for fragment {} (highestTransmitted={}, nextExpected={})", + node.getNodeId(), requestedStartReport, highestTransmittedReportNumber, nextExpectedReport); + return true; + } + + return false; + } + + /** + * Sends one or more fragments in response to UPDATE_MD_GET. + * Supports: + * - Single fragment requests + * - Repeated fragment requests + * - Multi-fragment requests (with required 35ms delay) + */ + private void sendNextFragment(int startReportNumber, int count) { + if (fragments == null || fragments.isEmpty()) { + fail("No fragments prepared"); + return; + } + + if (count <= 0) { + logger.warn("NODE {}: Invalid fragment count request: {}", node.getNodeId(), count); + return; + } + + // Defensive bounds check + if (startReportNumber < 1 || startReportNumber > fragments.size()) { + logger.warn("NODE {}: Invalid fragment request: {}", node.getNodeId(), startReportNumber); + return; + } + + ZWaveFirmwareUpdateCommandClass fw = (ZWaveFirmwareUpdateCommandClass) node + .getCommandClass(CommandClass.COMMAND_CLASS_FIRMWARE_UPDATE_MD); + + if (fw == null) { + fail("Firmware Update MD CC missing"); + return; + } + + // Multi-fragment request: device may ask for N fragments at once + for (int i = 0; i < count; i++) { + + int reportNumber = startReportNumber + i; + + if (reportNumber > fragments.size()) { + logger.warn("NODE {}: Device requested fragment {} beyond available {}", node.getNodeId(), reportNumber, + fragments.size()); + break; + } + + FirmwareFragment fragment = fragments.get(reportNumber - 1); + + logger.debug("NODE {}: Sending fragment {} (last={})", node.getNodeId(), fragment.getReportNumber(), + fragment.isLast()); + + // Convert session fragment → CC fragment + ZWaveFirmwareUpdateCommandClass.FirmwareFragment ccFragment = new ZWaveFirmwareUpdateCommandClass.FirmwareFragment( + fragment.isLast(), fragment.getReportNumber(), fragment.getData(), null); + + ZWaveCommandClassTransactionPayload msg = fw.sendFirmwareUpdateReport(ccFragment); + + if (logger.isTraceEnabled()) { + int advertisedMaxFragmentSize = sessionMetadata != null ? sessionMetadata.maxFragmentSize() : -1; + byte[] txPayload = msg.getPayloadBuffer(); + int crcMsb = txPayload.length >= 2 ? txPayload[txPayload.length - 2] & 0xFF : -1; + int crcLsb = txPayload.length >= 1 ? txPayload[txPayload.length - 1] & 0xFF : -1; + logger.trace( + "NODE {}: Fragment TX details report={}, isLast={}, advertisedMaxDataLen={}, dataLen={}, payloadLen={}, crc=0x{}{}, payload={}", + node.getNodeId(), fragment.getReportNumber(), fragment.isLast(), + advertisedMaxFragmentSize >= 0 ? advertisedMaxFragmentSize : null, fragment.getData().length, + txPayload.length, crcMsb >= 0 ? String.format("%02X", crcMsb) : "??", + crcLsb >= 0 ? String.format("%02X", crcLsb) : "??", toHex(txPayload)); + } + + node.sendMessage(msg); + long sentAtMillis = currentTimeMillis(); + reportLastSentTimes.put(fragment.getReportNumber(), sentAtMillis); + highestTransmittedReportNumber = Math.max(highestTransmittedReportNumber, fragment.getReportNumber()); + publishFirmwareUpdateProgressIfNeeded(); + + // If this was the last fragment, transition to waiting for status + if (fragment.isLast()) { + logger.debug("NODE {}: Last fragment sent, waiting for status report", node.getNodeId()); + state = State.WAITING_FOR_UPDATE_MD_STATUS_REPORT; + scheduleStatusReportTimeout(); + return; + } + + // Required delay when multiple fragments are requested + if (count > 1 && i < count - 1) { + try { + Thread.sleep(MULTI_FRAGMENT_INTERFRAME_DELAY_MS); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + } + } + + // Normal case: after sending a fragment, remain in SENDING_FRAGMENTS + state = State.SENDING_FRAGMENTS; + } + + private void scheduleStatusReportTimeout() { + int generation = statusReportTimeoutGeneration.incrementAndGet(); + + CompletableFuture.runAsync(() -> { + if (!active) { + return; + } + if (statusReportTimeoutGeneration.get() != generation) { + return; + } + if (state != State.WAITING_FOR_UPDATE_MD_STATUS_REPORT) { + return; + } + + logger.warn("NODE {}: Timed out waiting for Firmware Update MD Status Report", node.getNodeId()); + failFirmwareUpdate("Timed out waiting for Firmware Update MD Status Report"); + }, CompletableFuture.delayedExecutor(getStatusReportWaitTimeoutSeconds(), TimeUnit.SECONDS)); + } + + protected int getStatusReportWaitTimeoutSeconds() { + return STATUS_REPORT_WAIT_TIMEOUT_SECONDS; + } + + private void invalidateStatusReportTimeout() { + statusReportTimeoutGeneration.incrementAndGet(); + } + + private boolean handleUpdateMdStatusReport(FirmwareUpdateEvent event) { + // A status report is only valid after the last fragment has been sent and + // we are explicitly waiting for the device's final update status. + // Any out-of-sequence status report is treated as a protocol failure. + if (state != State.WAITING_FOR_UPDATE_MD_STATUS_REPORT) { + if (!active) { + return false; + } + logger.warn("NODE {}: Received unexpected UPDATE_MD_STATUS_REPORT in state {} - treating as protocol error", + node.getNodeId(), state); + } + + // Any status report means the waiting timer is no longer authoritative. + invalidateStatusReportTimeout(); + + FirmwareUpdateMdStatusReport updateStatus = FirmwareUpdateMdStatusReport.from(event.getStatus()); + logger.debug("NODE {}: Received Status Report: {}", node.getNodeId(), updateStatus); + + switch (updateStatus) { + case ERROR_CHECKSUM: + case ERROR_TRANSMISSION_FAILED: + case ERROR_INVALID_MANUFACTURER_ID: + case ERROR_INVALID_FIRMWARE_ID: + case ERROR_INVALID_FIRMWARE_TARGET: + case ERROR_INVALID_HEADER_INFORMATION: + case ERROR_INVALID_HEADER_FORMAT: + case ERROR_INSUFFICIENT_MEMORY: + case ERROR_INVALID_HARDWARE_VERSION: + case UNKNOWN: + failFirmwareUpdate("Device reported firmware update status: " + updateStatus, updateStatus.name()); + return true; + + case OK_WAITING_FOR_ACTIVATION: + return handleWaitingForActivationStatus(); + + case OK_NO_RESTART: + publishFirmwareUpdateNetworkEvent(ZWaveNetworkEvent.State.Success, Integer.valueOf(event.getStatus())); + completeSuccess(); + return true; + + case OK_RESTART_PENDING: + scheduleNopAfterWaitTime(event.getWaitTime()); + publishFirmwareUpdateNetworkEvent(ZWaveNetworkEvent.State.Success, Integer.valueOf(event.getStatus())); + completeSuccess(false); + return true; + + default: + failFirmwareUpdate("Unhandled firmware update status: " + updateStatus, + Integer.valueOf(event.getStatus())); + return true; + } + } + + private boolean handleWaitingForActivationStatus() { + int ccVersion = getFirmwareUpdateMdVersion(); + if (ccVersion < 4) { + failFirmwareUpdate("Device reported activation required, but Firmware Update MD CC version " + ccVersion + + " does not support activation command", Integer.valueOf(ccVersion)); + return true; + } + + FirmwareMetadata metadata = sessionMetadata; + if (metadata == null) { + failFirmwareUpdate("Cannot send activation - metadata unavailable", Integer.valueOf(-1)); + return true; + } + + byte[] firmwareBaseData = buildFirmwareBaseData(metadata, ccVersion); + + ZWaveFirmwareUpdateCommandClass fw = (ZWaveFirmwareUpdateCommandClass) node + .getCommandClass(CommandClass.COMMAND_CLASS_FIRMWARE_UPDATE_MD); + if (fw == null) { + failFirmwareUpdate("Firmware Update MD CC missing", Integer.valueOf(-1)); + return true; + } + + ZWaveCommandClassTransactionPayload msg = fw.setFirmwareActivation(firmwareBaseData); + node.sendMessage(msg); + state = State.WAITING_FOR_ACTIVATION_STATUS_REPORT; + + logger.debug("NODE {}: Sent Firmware Update Activation Set", node.getNodeId()); + return true; + } + + private boolean handleActivationStatusReport(FirmwareUpdateEvent event) { + if (state != State.WAITING_FOR_ACTIVATION_STATUS_REPORT) { + return false; + } + + FirmwareUpdateActivationStatus activationStatus = FirmwareUpdateActivationStatus.from(event.getStatus()); + logger.debug("NODE {}: Received Activation Status Report: {}", node.getNodeId(), activationStatus); + + switch (activationStatus) { + case SUCCESS: + publishFirmwareUpdateNetworkEvent(ZWaveNetworkEvent.State.Success, Integer.valueOf(event.getStatus())); + completeSuccess(); + return true; + + case ERROR_ACTIVATING_FIRMWARE: + case INVALID_PAYLOAD: + case UNKNOWN: + default: + failFirmwareUpdate("Firmware activation failed: " + activationStatus, activationStatus.name()); + return true; + } + } + + private void scheduleNopAfterWaitTime(int waitTimeSeconds) { + if (waitTimeSeconds < 2) { + waitTimeSeconds = 2; + } + + final int delay = waitTimeSeconds; + logger.debug("NODE {}: Scheduling NOP ping after {} seconds", node.getNodeId(), delay); + + CompletableFuture.runAsync(() -> { + logger.debug("NODE {}: Sending delayed NOP ping after firmware restart wait", node.getNodeId()); + try { + node.pingNode(); + scheduleVersionRefresh(); + } finally { + // Keep the firmware-update awake hold active until the delayed post-restart + // follow-up has been queued/sent. + node.setFirmwareUpdateInProgress(false); + } + }, CompletableFuture.delayedExecutor(delay, TimeUnit.SECONDS)); + } + + private void scheduleVersionRefresh() { + ZWaveVersionCommandClass versionCC = (ZWaveVersionCommandClass) node + .getCommandClass(CommandClass.COMMAND_CLASS_VERSION); + if (versionCC != null) { + logger.debug("NODE {}: Requesting firmware version refresh after update", node.getNodeId()); + ZWaveCommandClassTransactionPayload msg = versionCC.getVersionMessage(); + node.sendMessage(msg); + } else { + logger.warn("NODE {}: Version command class not available for version refresh", node.getNodeId()); + } + } + + private void completeSuccess() { + completeSuccess(true); + } + + private void completeSuccess(boolean releaseFirmwareUpdateHold) { + logger.info("NODE {}: Firmware update completed", node.getNodeId()); + invalidateStatusReportTimeout(); + if (releaseFirmwareUpdateHold) { + node.setFirmwareUpdateInProgress(false); + } + state = State.SUCCESS; + active = false; + } + + private void fail(String reason) { + logger.error("NODE {}: Firmware update failed: {}", node.getNodeId(), reason); + invalidateStatusReportTimeout(); + node.setFirmwareUpdateInProgress(false); + state = State.FAILURE; + active = false; + } + + private void failFirmwareUpdate(String reason, Object value) { + publishFirmwareUpdateNetworkEvent(ZWaveNetworkEvent.State.Failure, value); + fail(reason); + } + + private void failFirmwareUpdate(String reason) { + failFirmwareUpdate(reason, reason); + } + + private void publishFirmwareUpdateNetworkEvent(ZWaveNetworkEvent.State state, Object value) { + ZWaveNetworkEvent event = new ZWaveNetworkEvent(ZWaveNetworkEvent.Type.FirmwareUpdate, node.getNodeId(), state, + value); + + if (controller.getController() != null) { + controller.getController().notifyEventListeners(event); + return; + } + + // Fallback for early-session or test scenarios where the internal controller is not available. + controller.ZWaveIncomingEvent(event); + } + + private void publishFirmwareUpdateProgressIfNeeded() { + if (fragments.isEmpty()) { + return; + } + + int steppedPercentComplete = getSteppedTransferProgressPercent(); + + // Keep 100% reserved for terminal success status event. + if (steppedPercentComplete >= 100) { + steppedPercentComplete = 95; + } + + if (steppedPercentComplete <= 0 + || steppedPercentComplete < lastPublishedProgressPercent + PROGRESS_EVENT_STEP_PERCENT) { + return; + } + + lastPublishedProgressPercent = steppedPercentComplete; + publishFirmwareUpdateNetworkEvent(ZWaveNetworkEvent.State.Progress, Integer.valueOf(steppedPercentComplete)); + } + + private int getSteppedTransferProgressPercent() { + int percentComplete = getCurrentTransferProgressPercent(); + return (percentComplete / PROGRESS_EVENT_STEP_PERCENT) * PROGRESS_EVENT_STEP_PERCENT; + } + + /** + * Returns the current transfer progress based on sent fragments. + * This can be used by higher layers to restore UI status after transient + * communication drops. + * + * @return progress percentage in range 0..99 while transfer is active + */ + public int getCurrentTransferProgressPercent() { + if (fragments.isEmpty()) { + return 0; + } + + int transmitted = Math.min(highestTransmittedReportNumber, fragments.size()); + int percentComplete = (transmitted * 100) / fragments.size(); + + // Keep 100% reserved for terminal success status event. + return Math.min(percentComplete, 99); + } + + private String toHex(byte[] data) { + StringBuilder sb = new StringBuilder(data.length * 3); + for (int i = 0; i < data.length; i++) { + if (i > 0) { + sb.append(' '); + } + sb.append(String.format("%02X", data[i] & 0xFF)); + } + return sb.toString(); + } + + protected long currentTimeMillis() { + return System.currentTimeMillis(); + } + + /** + * Firmware update event types, used to route events from the Z-Wave protocol + * layer into the session logic. + */ + public enum FirmwareEventType { + MD_REPORT, + UPDATE_MD_REQUEST_REPORT, + UPDATE_MD_GET, + UPDATE_MD_STATUS_REPORT, + ACTIVATION_STATUS_REPORT // optional, depending on your flow + } + + /** + * Firmware update session states, used to track the progress of a firmware + * update for a node. + */ + public enum State { + IDLE, + WAITING_FOR_MD_REPORT, + WAITING_FOR_UPDATE_MD_REQUEST_REPORT, + WAITING_FOR_UPDATE_MD_GET, + SENDING_FRAGMENTS, + WAITING_FOR_UPDATE_MD_STATUS_REPORT, + WAITING_FOR_ACTIVATION_STATUS_REPORT, // optional, depending on your flow + SUCCESS, + FAILURE + } +} diff --git a/src/main/java/org/openhab/binding/zwave/firmwareupdate/ZWaveLocalFirmwareProvider.java b/src/main/java/org/openhab/binding/zwave/firmwareupdate/ZWaveLocalFirmwareProvider.java new file mode 100644 index 000000000..e7b0fb9fe --- /dev/null +++ b/src/main/java/org/openhab/binding/zwave/firmwareupdate/ZWaveLocalFirmwareProvider.java @@ -0,0 +1,211 @@ +/* + * Copyright (c) 2010-2025 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.zwave.firmwareupdate; + +import java.io.IOException; +import java.io.InputStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.Comparator; +import java.util.Locale; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import java.util.stream.Stream; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jdt.annotation.Nullable; +import org.openhab.binding.zwave.ZWaveBindingConstants; +import org.openhab.core.OpenHAB; +import org.openhab.core.thing.Thing; +import org.openhab.core.thing.binding.firmware.Firmware; +import org.openhab.core.thing.binding.firmware.FirmwareBuilder; +import org.openhab.core.thing.firmware.FirmwareProvider; +import org.osgi.service.component.annotations.Component; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Exposes local Z-Wave firmware files to the openHAB firmware UI. + * + * This provider implements the Z-Wave storage model intact by sourcing firmware + * metadata and bytes from userdata/zwave/firmware/node-{nodeId}. + * Also supports some Zooz and Aeotec manufacturer-specific version patterns + * to compare firmware versions. + * + * @author Bob Eckhoff - Initial contribution + */ +@Component(service = FirmwareProvider.class) +@NonNullByDefault +public class ZWaveLocalFirmwareProvider implements FirmwareProvider { + private static final Logger logger = LoggerFactory.getLogger(ZWaveLocalFirmwareProvider.class); + + private static final Set SUPPORTED_FIRMWARE_EXTENSIONS = Set.of(".bin", ".hex", ".ota", ".otz", ".gbl", + ".zip", ".exe", ".ex_"); + + /** Matches patterns like V02R40, v2r40, V1_06, V10_0 and extracts major/minor revision numbers. */ + private static final Pattern VERSION_PATTERN = Pattern.compile("[Vv](\\d+)[Rr_](\\d+)"); + + /** Matches patterns like V403 (no separator) — first digit is major, last two digits are minor, e.g. 4.3. */ + private static final Pattern VERSION_PATTERN_PLAIN = Pattern.compile("[Vv](\\d)(\\d{2})(?!\\d)"); + + /** Matches patterns like _5.54 (e.g. ZW4009_Jasco_46563_5.54.bin) and extracts major/minor numbers. */ + private static final Pattern VERSION_PATTERN_DOTTED = Pattern.compile("(?:^|_)(\\d+)\\.(\\d+)(?=\\.[^.]+$|$)"); + + // TEMPORARY test toggle: set to true to restore regex-based version extraction. + private static final boolean ENABLE_VERSION_PATTERN_MATCHING = true; + + @Override + public @Nullable Firmware getFirmware(Thing thing, String version) { + return getFirmware(thing, version, null); + } + + @Override + public @Nullable Firmware getFirmware(Thing thing, String version, @Nullable Locale locale) { + Set firmwares = getFirmwares(thing, locale); + if (firmwares == null) { + return null; + } + + Optional matchingFirmware = firmwares.stream() + .filter(firmware -> firmware.getVersion().equals(version)).findFirst(); + if (matchingFirmware.isPresent()) { + return matchingFirmware.get(); + } + return null; + } + + @Override + public @Nullable Set getFirmwares(Thing thing) { + return getFirmwares(thing, null); + } + + @Override + public @Nullable Set getFirmwares(Thing thing, @Nullable Locale locale) { + if (!"zwave".equals(thing.getThingTypeUID().getBindingId())) { + return null; + } + + Integer nodeId = readNodeId(thing); + if (nodeId == null) { + return null; + } + + Path folder = Paths.get(OpenHAB.getUserDataFolder(), "zwave", "firmware", "node-" + nodeId); + if (!Files.isDirectory(folder)) { + return null; + } + + try (Stream files = Files.list(folder)) { + Optional localFirmware = files.filter(Files::isRegularFile) + .filter(ZWaveLocalFirmwareProvider::isSupportedFirmwareFile) + .sorted(Comparator.comparing(path -> path.getFileName().toString().toLowerCase(Locale.ROOT))) + .findFirst(); + + if (localFirmware.isEmpty()) { + return null; + } + + Firmware firmware = toFirmware(thing, localFirmware.get()); + if (firmware == null) { + return null; + } + return Set.of(firmware); + } catch (IOException e) { + logger.warn("Cannot list local Z-Wave firmware files from {}", folder, e); + return null; + } + } + + private static @Nullable Integer readNodeId(Thing thing) { + Object nodeId = thing.getConfiguration().get(ZWaveBindingConstants.CONFIGURATION_NODEID); + if (nodeId instanceof Number number) { + return number.intValue(); + } + + if (nodeId instanceof String string) { + try { + return Integer.parseInt(string); + } catch (NumberFormatException e) { + return null; + } + } + + return null; + } + + private static boolean isSupportedFirmwareFile(Path file) { + String name = file.getFileName().toString().toLowerCase(Locale.ROOT); + return SUPPORTED_FIRMWARE_EXTENSIONS.stream().anyMatch(name::endsWith); + } + + private @Nullable Firmware toFirmware(Thing thing, Path file) { + String fileName = file.getFileName().toString(); + String version = extractVersion(fileName); + + try { + InputStream inputStream = Files.newInputStream(file); + return FirmwareBuilder.create(thing.getThingTypeUID(), version) + .withDescription("Local Z-Wave firmware file: " + fileName).withInputStream(inputStream) + .withProperties(Map.of("zwave.firmware.file", fileName)).build(); + } catch (IOException e) { + logger.warn("Cannot open local Z-Wave firmware file {}", file, e); + return null; + } + } + + /** + * Extracts a numeric version from a firmware filename. + * Converts manufacturer patterns like "ZEN73_V02R40.gbl" → "2.40" and + * "ZEN23_V403.gbl" → "4.3" and "ZW4009_Jasco_46563_5.54.bin" → "5.54" + * so that openHAB Core can compare it numerically + * against the device's current firmware version. + * Falls back to the bare filename (no extension) when no version pattern is found. + */ + private static String extractVersion(String fileName) { + if (ENABLE_VERSION_PATTERN_MATCHING) { + Matcher matcher = VERSION_PATTERN.matcher(fileName); + if (matcher.find()) { + int major = Integer.parseInt(matcher.group(1)); + int minor = Integer.parseInt(matcher.group(2)); + return major + "." + minor; + } + Matcher plainMatcher = VERSION_PATTERN_PLAIN.matcher(fileName); + if (plainMatcher.find()) { + int major = Integer.parseInt(plainMatcher.group(1)); + int minor = Integer.parseInt(plainMatcher.group(2)); + return major + "." + minor; + } + Matcher dottedMatcher = VERSION_PATTERN_DOTTED.matcher(fileName); + if (dottedMatcher.find()) { + int major = Integer.parseInt(dottedMatcher.group(1)); + int minor = Integer.parseInt(dottedMatcher.group(2)); + return major + "." + minor; + } + } + + // During test mode, keep raw filename (without extension) as the version token. + return stripExtension(fileName); + } + + private static String stripExtension(String fileName) { + int dot = fileName.lastIndexOf('.'); + if (dot <= 0) { + return fileName; + } + return fileName.substring(0, dot); + } +} diff --git a/src/main/java/org/openhab/binding/zwave/handler/ZWaveThingHandler.java b/src/main/java/org/openhab/binding/zwave/handler/ZWaveThingHandler.java index 5416e574b..ccc50e1b2 100644 --- a/src/main/java/org/openhab/binding/zwave/handler/ZWaveThingHandler.java +++ b/src/main/java/org/openhab/binding/zwave/handler/ZWaveThingHandler.java @@ -12,17 +12,23 @@ */ package org.openhab.binding.zwave.handler; +import java.io.IOException; import java.math.BigDecimal; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; import java.text.DateFormat; import java.text.SimpleDateFormat; import java.util.ArrayList; import java.util.Arrays; import java.util.Collection; import java.util.Collections; +import java.util.Comparator; import java.util.Date; import java.util.HashMap; import java.util.HashSet; import java.util.List; +import java.util.Locale; import java.util.Map; import java.util.Map.Entry; import java.util.Objects; @@ -30,10 +36,15 @@ import java.util.TimeZone; import java.util.concurrent.ScheduledFuture; import java.util.concurrent.TimeUnit; +import java.util.stream.Collectors; +import java.util.stream.Stream; import org.eclipse.jdt.annotation.Nullable; import org.openhab.binding.zwave.ZWaveBindingConstants; import org.openhab.binding.zwave.actions.ZWaveThingActions; +import org.openhab.binding.zwave.firmwareupdate.FirmwareFile; +import org.openhab.binding.zwave.firmwareupdate.ZWaveFirmwareUpdateSession; +import org.openhab.binding.zwave.firmwareupdate.ZWaveFirmwareUpdateSession.FirmwareUpdateEvent; import org.openhab.binding.zwave.handler.ZWaveThingChannel.DataType; import org.openhab.binding.zwave.internal.ZWaveConfigProvider; import org.openhab.binding.zwave.internal.ZWaveProduct; @@ -49,12 +60,14 @@ import org.openhab.binding.zwave.internal.protocol.commandclass.ZWaveConfigurationCommandClass; import org.openhab.binding.zwave.internal.protocol.commandclass.ZWaveConfigurationCommandClass.ZWaveConfigurationParameterEvent; import org.openhab.binding.zwave.internal.protocol.commandclass.ZWaveDoorLockCommandClass; +import org.openhab.binding.zwave.internal.protocol.commandclass.ZWaveFirmwareUpdateCommandClass; import org.openhab.binding.zwave.internal.protocol.commandclass.ZWaveNodeNamingCommandClass; import org.openhab.binding.zwave.internal.protocol.commandclass.ZWavePlusCommandClass; import org.openhab.binding.zwave.internal.protocol.commandclass.ZWaveSwitchAllCommandClass; import org.openhab.binding.zwave.internal.protocol.commandclass.ZWaveUserCodeCommandClass; import org.openhab.binding.zwave.internal.protocol.commandclass.ZWaveUserCodeCommandClass.UserIdStatusType; import org.openhab.binding.zwave.internal.protocol.commandclass.ZWaveUserCodeCommandClass.ZWaveUserCodeValueEvent; +import org.openhab.binding.zwave.internal.protocol.commandclass.ZWaveVersionCommandClass; import org.openhab.binding.zwave.internal.protocol.commandclass.ZWaveWakeUpCommandClass; import org.openhab.binding.zwave.internal.protocol.commandclass.ZWaveWakeUpCommandClass.ZWaveWakeUpEvent; import org.openhab.binding.zwave.internal.protocol.event.ZWaveAssociationEvent; @@ -68,6 +81,7 @@ import org.openhab.binding.zwave.internal.protocol.event.ZWaveTransactionCompletedEvent; import org.openhab.binding.zwave.internal.protocol.initialization.ZWaveNodeSerializer; import org.openhab.binding.zwave.internal.protocol.transaction.ZWaveCommandClassTransactionPayload; +import org.openhab.core.OpenHAB; import org.openhab.core.config.core.Configuration; import org.openhab.core.config.core.status.ConfigStatusMessage; import org.openhab.core.config.core.validation.ConfigValidationException; @@ -83,6 +97,10 @@ import org.openhab.core.thing.binding.ConfigStatusThingHandler; import org.openhab.core.thing.binding.ThingHandler; import org.openhab.core.thing.binding.ThingHandlerService; +import org.openhab.core.thing.binding.firmware.Firmware; +import org.openhab.core.thing.binding.firmware.FirmwareUpdateHandler; +import org.openhab.core.thing.binding.firmware.ProgressCallback; +import org.openhab.core.thing.binding.firmware.ProgressStep; import org.openhab.core.thing.type.ThingType; import org.openhab.core.types.Command; import org.openhab.core.types.RefreshType; @@ -94,15 +112,14 @@ * Thing Handler for ZWave devices * * @author Chris Jackson - Initial contribution + * @author Bob Eckhoff - Firmware update handling, file import, events * */ -public class ZWaveThingHandler extends ConfigStatusThingHandler implements ZWaveEventListener { +public class ZWaveThingHandler extends ConfigStatusThingHandler implements ZWaveEventListener, FirmwareUpdateHandler { private final Logger logger = LoggerFactory.getLogger(ZWaveThingHandler.class); private ZWaveControllerHandler controllerHandler; - private boolean finalTypeSet = false; - private int nodeId; private List thingChannelsCmd = Collections.emptyList(); private List thingChannelsState = Collections.emptyList(); @@ -111,19 +128,38 @@ public class ZWaveThingHandler extends ConfigStatusThingHandler implements ZWave private final Map subParameters = new HashMap(); private final Map pendingCfg = new HashMap(); + private boolean finalTypeSet = false; + + private byte[] pendingFirmwareBytes; + private Integer pendingFirmwareTarget = 0; + private @Nullable ZWaveFirmwareUpdateSession firmwareSession; + private @Nullable ProgressCallback firmwareProgressCallback; + private int firmwareProgressStepIndex = -1; + private @Nullable Integer lastFirmwareUpdateProgressPercent; + private @Nullable String lastFirmwareFailureDescription; + private static final List FIRMWARE_PROGRESS_UI_MILESTONES = List.of(5, 25, 50, 75); + private static final Set SUPPORTED_FIRMWARE_EXTENSIONS = Set.of(".bin", ".hex", ".ota", ".otz", ".gbl", + ".zip", ".exe", ".ex_"); + private static boolean isSupportedFirmwareFile(Path file) { + String name = file.getFileName().toString().toLowerCase(Locale.ROOT); + return SUPPORTED_FIRMWARE_EXTENSIONS.stream().anyMatch(name::endsWith); + } + private Path getNodeFirmwareFolder() { + return Paths.get(OpenHAB.getUserDataFolder(), "zwave", "firmware", "node-" + nodeId); + } private final Object pollingSync = new Object(); private ScheduledFuture pollingJob = null; - private final long POLLING_PERIOD_MIN = 15; - private final long POLLING_PERIOD_MAX = 864000; - private final long POLLING_PERIOD_DEFAULT = 86400; - private final long DELAYED_POLLING_PERIOD_MAX = 10; - private final long REFRESH_POLL_DELAY = 50; + private static final long POLLING_PERIOD_MIN = 15; + private static final long POLLING_PERIOD_MAX = 864000; + private static final long POLLING_PERIOD_DEFAULT = 86400; + private static final long DELAYED_POLLING_PERIOD_MAX = 10; + private static final long REFRESH_POLL_DELAY = 50; private long pollingPeriod = POLLING_PERIOD_DEFAULT; - private final long REPOLL_PERIOD_MIN = 100; - private final long REPOLL_PERIOD_MAX = 15000; - private final long REPOLL_PERIOD_DEFAULT = 1500; + private static final long REPOLL_PERIOD_MIN = 100; + private static final long REPOLL_PERIOD_MAX = 15000; + private static final long REPOLL_PERIOD_DEFAULT = 1500; private long commandPollDelay = 1500; @@ -156,7 +192,8 @@ public void initialize() { return; } - // We need to set the status to OFFLINE so that the framework calls our notification handlers + // We need to set the status to OFFLINE so that the framework calls our + // notification handlers updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.BRIDGE_OFFLINE, ZWaveBindingConstants.OFFLINE_CTLR_OFFLINE); // Make sure the thingType is set correctly from the database @@ -201,7 +238,8 @@ protected Map getZWaveProperties(String properties) { void initialiseNode() { logger.debug("NODE {}: Initialising Thing Node...", nodeId); - // Note that for dynamic channels, it seems that defaults can either be not set, or set with the incorrect + // Note that for dynamic channels, it seems that defaults can either be not set, + // or set with the incorrect // type. So, we read back as an Object to avoid casting problems. pollingPeriod = POLLING_PERIOD_DEFAULT; @@ -474,7 +512,8 @@ public void run() { } } - // Start polling with a random initial delay, but right after the thing is initialised (DONE). + // Start polling with a random initial delay, but right after the thing is + // initialised (DONE). // 30 seconds mimimum, 90 seconds maximum private void startPolling() { startPolling(30000 + 60 * (int) (1000 * Math.random())); @@ -484,7 +523,8 @@ private void startPolling() { public void channelLinked(ChannelUID channelUID) { logger.debug("NODE {}: Channel {} linked - polling started.", nodeId, channelUID); - // We keep track of what channels are used and only poll channels that the framework is using + // We keep track of what channels are used and only poll channels that the + // framework is using thingChannelsPoll.add(channelUID); } @@ -492,7 +532,8 @@ public void channelLinked(ChannelUID channelUID) { public void channelUnlinked(ChannelUID channelUID) { logger.debug("NODE {}: Channel {} unlinked - polling stopped.", nodeId, channelUID); - // We keep track of what channels are used and only poll channels that the framework is using + // We keep track of what channels are used and only poll channels that the + // framework is using thingChannelsPoll.remove(channelUID); } @@ -522,7 +563,8 @@ public void bridgeStatusChanged(ThingStatusInfo bridgeStatusInfo) { logger.debug("NODE {}: Controller is ONLINE. Starting device initialisation.", nodeId); - // We might not be notified that the controller is online until it's completed a lot of initialisation, so + // We might not be notified that the controller is online until it's completed a + // lot of initialisation, so // make sure we know the device state. ZWaveNode node = bridgeHandler.getNode(nodeId); if (node == null) { @@ -536,6 +578,7 @@ public void bridgeStatusChanged(ThingStatusInfo bridgeStatusInfo) { case AWAKE: case ALIVE: updateStatus(ThingStatus.ONLINE); + restoreFirmwareUpdateProgressStatusIfNeeded(); break; case DEAD: case FAILED: @@ -558,7 +601,8 @@ public void bridgeStatusChanged(ThingStatusInfo bridgeStatusInfo) { } // Add the listener for ZWave events. - // This ensures we get called whenever there's an event we might be interested in + // This ensures we get called whenever there's an event we might be interested + // in if (bridgeHandler.addEventListener(this) == false) { logger.warn("NODE {}: Controller failed to register event handler.", nodeId); return; @@ -594,6 +638,13 @@ public void dispose() { } } + if (firmwareSession != null && firmwareSession.isActive()) { + firmwareSession.abort("handler disposed"); + firmwareSession = null; + } + + clearFirmwareUpdateProgressStatus(); + controllerHandler = null; } @@ -616,8 +667,10 @@ public void handleConfigurationUpdate(Map configurationParameter return; } - // Wakeup targets are not set immediately during the config as we need to correlate multiple settings - // Record them in these variables and set them at the end if one or both are configured + // Wakeup targets are not set immediately during the config as we need to + // correlate multiple settings + // Record them in these variables and set them at the end if one or both are + // configured Integer wakeupNode = null; Integer wakeupInterval = null; @@ -634,6 +687,7 @@ public void handleConfigurationUpdate(Map configurationParameter logger.debug("NODE {}: Configuration update set {} to {} ({})", nodeId, configurationParameter.getKey(), valueObject, valueObject == null ? "null" : valueObject.getClass().getSimpleName()); + String[] cfg = configurationParameter.getKey().split("_"); switch (cfg[0]) { case "config": @@ -675,7 +729,8 @@ public void handleConfigurationUpdate(Map configurationParameter writeOnly = true; } - // If we have specified a bitmask, then we need to process this and save for later + // If we have specified a bitmask, then we need to process this and save for + // later if (cfg.length >= 4 && cfg[3].length() == 8) { int bitmask = 0xffffffff; try { @@ -828,14 +883,16 @@ public void handleConfigurationUpdate(Map configurationParameter logger.debug("NODE {}: Members after controller update {}", nodeId, newMembers); } - // If there are no known associations in the group, then let's clear the group completely + // If there are no known associations in the group, then let's clear the group + // completely // This ensures we don't end up with strange ghost associations if (newMembers.getAssociationCnt() == 0) { logger.debug("NODE {}: Association group {} contains no members. Clearing.", nodeId, groupIndex); node.sendMessage(node.clearAssociation(groupIndex)); } else { - // Loop through the current members and remove anything that's not in the new members list + // Loop through the current members and remove anything that's not in the new + // members list for (ZWaveAssociation member : currentMembers.getAssociations()) { // Is the current association still in the newMembers list? if (newMembers.isAssociated(member) == false) { @@ -846,7 +903,8 @@ public void handleConfigurationUpdate(Map configurationParameter } } - // Now loop through the new members and add anything not in the current members list + // Now loop through the new members and add anything not in the current members + // list for (ZWaveAssociation member : newMembers.getAssociations()) { // Is the new association still in the currentMembers list? if (currentMembers.isAssociated(member) == false) { @@ -1051,7 +1109,8 @@ public void handleConfigurationUpdate(Map configurationParameter if (wakeupCommandClass == null) { logger.debug("NODE {}: Error getting wakeupCommandClass", nodeId); } else { - // Handle the situation where there is only part of the data defined in this update + // Handle the situation where there is only part of the data defined in this + // update if (wakeupInterval == null) { // First try using the current wakeup interval from the thing config final BigDecimal cfgInterval = (BigDecimal) getConfig() @@ -1102,6 +1161,14 @@ public void handleConfigurationUpdate(Map configurationParameter } } + // Start of Thing Actions + + /** + * Checks whether the node has failed according to the controller's failure + * detection + * + * @return Status message indicating the result of the node failure check + */ public String checkIsNodeFailed() { ZWaveNode node = controllerHandler.getNode(nodeId); if (!node.isListening() && !node.isFrequentlyListening()) { @@ -1114,6 +1181,13 @@ public String checkIsNodeFailed() { return "Node is already in FAILED state"; } + /** + * Initiates the replacement of a node that has been marked as FAILED + * according to the controller's failure detection + * + * @return Status message indicating the result of the failed node replacement + * attempt + */ public String replaceFailedNode() { ZWaveNode node = controllerHandler.getNode(nodeId); if (!node.isListening() && !node.isFrequentlyListening()) { @@ -1194,6 +1268,8 @@ public String pollLinkedChannels() { return "NODE " + nodeId + " Starting refresh of pollable, linked channels on node"; } + // End of Actions exposed via the Thing's Action handlers + private Object getAssociationConfigList(List groupMembers) { List newAssociationsList = new ArrayList(); for (ZWaveAssociation association : groupMembers) { @@ -1249,7 +1325,8 @@ public void handleCommand(ChannelUID channelUID, Command commandParam) { ZWaveThingChannel cmdChannel = cmdChannels.get(dataType); if (cmdChannel == null && !cmdChannels.isEmpty()) { - // nothing by expected datatype found, try to find one where the datatype can be converted to + // nothing by expected datatype found, try to find one where the datatype can be + // converted to for (ZWaveThingChannel channel : cmdChannels.values()) { command = convertCommandToDataType(channelUID, channel.getDataType(), command, dataType); if (command != null) { @@ -1292,7 +1369,8 @@ public void handleCommand(ChannelUID channelUID, Command commandParam) { controllerHandler.sendData(message); } - // Restart the polling so we get an update on the channel shortly after this command is sent + // Restart the polling so we get an update on the channel shortly after this + // command is sent if (commandPollDelay != 0) { startPolling(commandPollDelay); } @@ -1301,7 +1379,6 @@ public void handleCommand(ChannelUID channelUID, Command commandParam) { @Nullable Command convertCommandToDataType(ChannelUID channelUID, DataType channelDataType, Command command, DataType dataType) { - if (!(command instanceof State)) { logger.debug("NODE {}: Received commands datatype {} doesn't support conversion", nodeId, dataType); return null; @@ -1341,6 +1418,18 @@ public void ZWaveIncomingEvent(ZWaveEvent incomingEvent) { logger.debug("NODE {}: Got an event from Z-Wave network: {}", nodeId, incomingEvent.getClass().getSimpleName()); + // Firmware UpdateSession events are routed to the session for handling + if (firmwareSession != null) { + if (firmwareSession.isActive()) { + if (firmwareSession.handleEvent(incomingEvent)) { + return; + } + } else if (incomingEvent instanceof FirmwareUpdateEvent firmwareEvent) { + logger.debug("NODE {}: Ignoring firmware event {} because firmware session is inactive (state={})", + nodeId, firmwareEvent.getType(), firmwareSession.getState()); + } + } + // Handle command class value events. if (incomingEvent instanceof ZWaveCommandClassValueEvent) { // Cast to a command class event @@ -1365,7 +1454,8 @@ public void ZWaveIncomingEvent(ZWaveEvent incomingEvent) { parameter.getSize(), parameter.getValue()); // Check for any sub parameter processing... - // If we have requested the current state of a parameter and t's waiting to be updated, then we + // If we have requested the current state of a parameter and t's waiting to be + // updated, then we // check this here, update the value and send the request... // Do this first so we only process the data if we're not waiting to send ZWaveConfigSubParameter subParameter = subParameters.get(parameter.getIndex()); @@ -1424,13 +1514,10 @@ public void ZWaveIncomingEvent(ZWaveEvent incomingEvent) { // Build the configuration value // for (ZWaveAssociation groupMember : groupMembers) { - // logger.debug("NODE {}: Update ASSOCIATION group_{}: Adding {}", nodeId, groupId, - // groupMember); - // group.add(groupMember.toString()); - // } - // logger.debug("NODE {}: Update ASSOCIATION group_{}: {} members", nodeId, groupId, group.size()); - - // cfgUpdated = true; + // logger.debug("NODE {}: Update ASSOCIATION group_{}: Adding {}", nodeId, + // groupId, groupMember); group.add(groupMember.toString());} + // logger.debug("NODE {}: Update ASSOCIATION group_{}: {} members", nodeId, + // groupId, group.size()); cfgUpdated = true; configuration.put("group_" + groupId, getAssociationConfigList(groupMembers)); removePendingConfig("group_" + groupId); // } @@ -1481,6 +1568,10 @@ public void ZWaveIncomingEvent(ZWaveEvent incomingEvent) { removePendingConfig(codeParameterName); break; + case COMMAND_CLASS_VERSION: + updateNodeProperties(); + break; + default: break; } @@ -1513,7 +1604,8 @@ public void ZWaveIncomingEvent(ZWaveEvent incomingEvent) { return; } - // logger.debug("NODE {}: Processing event as channel {} {}", nodeId, channel.getUID(), + // logger.debug("NODE {}: Processing event as channel {} {}", nodeId, + // channel.getUID(), // channel.dataType); State state = channel.getConverter().handleEvent(channel, event); if (state != null) { @@ -1571,6 +1663,7 @@ public void ZWaveIncomingEvent(ZWaveEvent incomingEvent) { case ALIVE: logger.debug("NODE {}: Setting ONLINE", nodeId); updateStatus(ThingStatus.ONLINE); + restoreFirmwareUpdateProgressStatusIfNeeded(); break; case DEAD: case FAILED: @@ -1594,7 +1687,8 @@ public void ZWaveIncomingEvent(ZWaveEvent incomingEvent) { if (finalTypeSet == false) { if (updateThingType() == true) { // We updated the type. - // The thing will have already been disposed of so let's get the hell out of here! + // The thing will have already been disposed of so let's get the hell out of + // here! return; } } @@ -1626,6 +1720,7 @@ public void ZWaveIncomingEvent(ZWaveEvent incomingEvent) { break; case DONE: updateStatus(ThingStatus.ONLINE); + restoreFirmwareUpdateProgressStatusIfNeeded(); break; default: if (finalTypeSet) { @@ -1647,6 +1742,56 @@ public void ZWaveIncomingEvent(ZWaveEvent incomingEvent) { updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.NONE, ZWaveBindingConstants.OFFLINE_NODE_NOTFOUND); } + // Firmware update events (Progress, success, failure). + if (networkEvent.getEvent() == ZWaveNetworkEvent.Type.FirmwareUpdate) { + if (networkEvent.getState() == ZWaveNetworkEvent.State.Progress) { + if (!isFirmwareSessionActive()) { + logger.debug( + "NODE {}: Ignoring firmware progress event because no active firmware session exists", + nodeId); + } else { + ProgressCallback progressCallback = this.firmwareProgressCallback; + Object progressValue = networkEvent.getValue(); + if (progressValue instanceof Number number) { + int progressPercent = number.intValue(); + updateFirmwareProgressStatusForUiMilestone(progressPercent); + + if (progressCallback != null && firmwareProgressStepIndex < 2) { + progressCallback = advanceFirmwareProgressTo(2, progressCallback); + if (progressCallback == null) { + this.firmwareProgressCallback = null; + } + } + } else { + if (progressCallback == null) { + updateStatus(ThingStatus.ONLINE, ThingStatusDetail.CONFIGURATION_PENDING, + "Firmware update in progress"); + } + } + } + } + + if (networkEvent.getState() == ZWaveNetworkEvent.State.Success) { + onFirmwareUpdateSucceeded(); + } + + if (networkEvent.getState() == ZWaveNetworkEvent.State.Failure) { + Object failureValue = networkEvent.getValue(); + String description = "Firmware update failed"; + String callbackFailureDetail = description; + + if (failureValue instanceof Number number) { + description = "Firmware update failed (status " + number.intValue() + ")"; + callbackFailureDetail = "status " + number.intValue(); + } else if (failureValue instanceof String string && !string.isBlank()) { + description = "Firmware update failed: " + string; + callbackFailureDetail = string; + } + + onFirmwareUpdateFailed(description, callbackFailureDetail); + } + } + if (networkEvent.getEvent() == ZWaveNetworkEvent.Type.FailedNode) { updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, ZWaveBindingConstants.EVENT_MARKED_AS_FAILED); @@ -1655,7 +1800,8 @@ public void ZWaveIncomingEvent(ZWaveEvent incomingEvent) { if (networkEvent.getEvent() == ZWaveNetworkEvent.Type.ReplaceFailedNodeDone) { updateStatus(ThingStatus.ONLINE, ThingStatusDetail.NONE, ZWaveBindingConstants.EVENT_REPLACEMENT_COMPLETED); - // Re-initialise the node now. Properties will be updated as part of this process + // Re-initialise the node now. Properties will be updated as part of this + // process reinitNode(); logger.debug("NODE {}: Will need to delete Thing (not exclude) and do inbox SCAN to update UI page", nodeId); @@ -1771,6 +1917,7 @@ private void updateNodeProperties() { properties.put(ZWaveBindingConstants.PROPERTY_DEVICEID, Integer.toString(node.getDeviceId())); } properties.put(ZWaveBindingConstants.PROPERTY_VERSION, node.getApplicationVersion()); + properties.put(Thing.PROPERTY_FIRMWARE_VERSION, node.getApplicationVersion()); properties.put(ZWaveBindingConstants.PROPERTY_CLASS_BASIC, node.getDeviceClass().getBasicDeviceClass().toString()); @@ -1812,8 +1959,10 @@ private void updateNodeProperties() { } // We need to synchronise the configuration between the ZWave library and ESH. - // This is especially important when the device is first added as the ESH representation of the config - // will be set to defaults. We will also not have any defaults for association groups, wakeup etc. + // This is especially important when the device is first added as the ESH + // representation of the config + // will be set to defaults. We will also not have any defaults for association + // groups, wakeup etc. Configuration config = editConfiguration(); // Process CONFIGURATION @@ -1895,7 +2044,6 @@ private void updateNodeProperties() { private boolean updateConfigurationParameter(Configuration configuration, int paramIndex, int paramSize, int paramValue) { - boolean cfgUpdated = false; for (String key : configuration.keySet()) { @@ -1972,7 +2120,8 @@ public void addBitmask(int bitmask, int value) { } /** - * Get the updated value, given the current value, and updating it based on the internal bitmask/value + * Get the updated value, given the current value, and updating it based on the + * internal bitmask/value * * @param value * @return @@ -2018,8 +2167,7 @@ private static String getISO8601StringForCurrentDate() { /** * Return an ISO 8601 combined date and time string for specified date/time * - * @param date - * Date + * @param date Date to convert to ISO 8601 string * @return String with format "yyyy-MM-dd'T'HH:mm:ss'Z'" */ private static String getISO8601StringForDate(Date date) { @@ -2027,9 +2175,377 @@ private static String getISO8601StringForDate(Date date) { dateFormat.setTimeZone(TimeZone.getTimeZone("UTC")); return dateFormat.format(date); } + + /** + * Starting point for a firmware update process for this Z-Wave node. + * Initiates an OH core firmware update for the given firmware object, reporting + * progress success and failure events back to the OH core through the provided + * ProgressCallback. + */ + @Override + public void updateFirmware(Firmware firmware, ProgressCallback progressCallback) { + ProgressCallback activeProgressCallback = keepCallbackIfUsable(progressCallback, "defineSequence()", + () -> progressCallback.defineSequence(ProgressStep.DOWNLOADING, ProgressStep.WAITING, + ProgressStep.TRANSFERRING, ProgressStep.UPDATING)); + resetFirmwareProgressSequence(); + activeProgressCallback = advanceFirmwareProgressTo(0, activeProgressCallback); + + // Clear any previous callback state before arming this run. + this.firmwareProgressCallback = null; + this.lastFirmwareFailureDescription = null; + + String loadError = loadPendingFirmwareFromRepository(); + if (loadError != null) { + logger.warn("NODE {}: Firmware update failed: {}", nodeId, loadError); + ProgressCallback callbackRef = activeProgressCallback; + keepCallbackIfUsable(callbackRef, "failed()", + () -> callbackRef.failed("actions.firmware-update.error", loadError)); + clearFirmwareUpdateProgressStatus(); + resetFirmwareProgressSequence(); + this.firmwareProgressCallback = null; + return; + } + + // Arm callback before start to avoid races where rapid terminal events arrive + // before callback assignment. + this.firmwareProgressCallback = activeProgressCallback; + + String result = startFirmwareUpdateSession(); + if (!result.startsWith("Firmware upload started")) { + logger.warn("NODE {}: Firmware update failed: {}", nodeId, result); + ProgressCallback callbackRef = activeProgressCallback; + keepCallbackIfUsable(callbackRef, "failed()", + () -> callbackRef.failed("actions.firmware-update.error", result)); + clearFirmwareUpdateProgressStatus(); + resetFirmwareProgressSequence(); + this.firmwareProgressCallback = null; + return; + } + + activeProgressCallback = advanceFirmwareProgressTo(1, activeProgressCallback); + if (activeProgressCallback == null) { + this.firmwareProgressCallback = null; + } + } + + // Advances the firmware progress sequence to the given step index. + private @Nullable ProgressCallback advanceFirmwareProgressTo(int targetStepIndex, + @Nullable ProgressCallback callback) { + if (callback == null) { + return null; + } + + while (firmwareProgressStepIndex < targetStepIndex) { + ProgressCallback usableCallback = keepCallbackIfUsable(callback, "next()", callback::next); + if (usableCallback == null) { + return null; + } + firmwareProgressStepIndex++; + } + + return callback; + } + + /** + * Loads firmware bytes from userdata/zwave/firmware/node-. + * Policy: exactly one supported file must exist. + * + * @return null on success, otherwise a user-facing error message. + */ + private @Nullable String loadPendingFirmwareFromRepository() { + Path folder = getNodeFirmwareFolder(); + + if (!Files.exists(folder)) { + return "No firmware directory found for this node: " + folder; + } + + if (!Files.isDirectory(folder)) { + return "Firmware path is not a directory: " + folder; + } + + List candidates; + try (Stream files = Files.list(folder)) { + candidates = files.filter(Files::isRegularFile).filter(ZWaveThingHandler::isSupportedFirmwareFile) + .sorted(Comparator.comparing(p -> p.getFileName().toString().toLowerCase(Locale.ROOT))).toList(); + } catch (IOException e) { + logger.error("NODE {}: Error listing firmware directory {}", nodeId, folder, e); + return "Error reading firmware directory: " + folder; + } + + if (candidates.isEmpty()) { + return "No firmware file found in " + folder; + } + + if (candidates.size() > 1) { + String names = candidates.stream().map(p -> p.getFileName().toString()).collect(Collectors.joining(", ")); + return "Multiple firmware files found for this node. Keep only one: " + names; + } + + Path selected = candidates.get(0); + try { + byte[] raw = Files.readAllBytes(selected); + FirmwareFile parsed = FirmwareFile.extractFirmware(selected.getFileName().toString(), raw); + + this.pendingFirmwareBytes = parsed.data; + this.pendingFirmwareTarget = (parsed.firmwareTarget != null ? parsed.firmwareTarget : 0); + + logger.debug("NODE {}: Firmware file loaded from repository: {}", nodeId, selected); + logger.debug("NODE {}: Parsed firmware target={} size={} bytes", nodeId, pendingFirmwareTarget, raw.length); + return null; + } catch (Exception e) { + logger.error("NODE {}: Failed to load firmware file {}", nodeId, selected, e); + return "Failed to load firmware file: " + selected.getFileName(); + } + } + + private String startFirmwareUpdateSession() { + if (!isUpdateExecutable()) { + return "Firmware update is not executable in current thing state"; + } + + ZWaveNode node = controllerHandler.getNode(nodeId); + if (node == null) { + return "Node not available"; + } + + ZWaveFirmwareUpdateCommandClass fw = (ZWaveFirmwareUpdateCommandClass) node + .getCommandClass(CommandClass.COMMAND_CLASS_FIRMWARE_UPDATE_MD); + + if (fw == null) { + return "Firmware Update Metadata command class not supported on node"; + } + + // Request a version refresh to ensure we have the latest CC version information + // before we try to update. Previously included devices were set to Version 1, + // so this avoids a full device reinitialization. + // There are compatibility issues between version 1 and later versions of the + // Firmware Update CC, so knowing the device CC version is critical. + requestFirmwareUpdateVersionRefresh(node, fw); + + if (pendingFirmwareBytes == null || pendingFirmwareBytes.length == 0) { + return "No firmware available"; + } + + clearFirmwareUpdateProgressStatus(); + + firmwareSession = new ZWaveFirmwareUpdateSession(node, controllerHandler, pendingFirmwareBytes, + pendingFirmwareTarget); + + updateStatus(ThingStatus.ONLINE, ThingStatusDetail.CONFIGURATION_PENDING, "Firmware upload in progress (0%)"); + firmwareSession.start(); + + return "Firmware upload started, check status for progress"; + } + + @Override + public boolean isUpdateExecutable() { + if (getThing().getStatus() != ThingStatus.ONLINE) { + return false; + } + + ThingStatusInfo statusInfo = getThing().getStatusInfo(); + if (statusInfo.getStatusDetail() == ThingStatusDetail.FIRMWARE_UPDATING) { + return false; + } + + return firmwareSession == null || !firmwareSession.isActive(); + } + + private void requestFirmwareUpdateVersionRefresh(ZWaveNode node, + ZWaveFirmwareUpdateCommandClass firmwareCommandClass) { + int versionBefore = firmwareCommandClass.getVersion(); + if (versionBefore != 1) { + logger.debug( + "NODE {}: Skipping Firmware Update command class version refresh because current version is {} (refresh is only needed for legacy version 1)", + nodeId, versionBefore); + return; + } + + ZWaveVersionCommandClass versionCommandClass = (ZWaveVersionCommandClass) node + .getCommandClass(CommandClass.COMMAND_CLASS_VERSION); + if (versionCommandClass == null) { + logger.debug( + "NODE {}: Cannot refresh Firmware Update command class version because VERSION CC is unavailable", + nodeId); + return; + } + + ZWaveCommandClassTransactionPayload message = versionCommandClass.checkVersion(firmwareCommandClass); + if (message == null) { + return; + } + + node.sendMessage(message); + logger.debug("NODE {}: Requested Firmware Update command class version refresh", nodeId); + } + + private boolean isFirmwareSessionActive() { + ZWaveFirmwareUpdateSession session = firmwareSession; + return session != null && session.isActive(); + } + + @Override + public void cancel() { + if (firmwareSession != null && firmwareSession.isActive()) { + firmwareSession.abort("cancelled by firmware update service"); + firmwareSession = null; + } + + ProgressCallback progressCallback = this.firmwareProgressCallback; + if (progressCallback != null) { + keepCallbackIfUsable(progressCallback, "canceled()", progressCallback::canceled); + } + this.firmwareProgressCallback = null; + clearFirmwareUpdateProgressStatus(); + resetFirmwareProgressSequence(); + } + + private void clearFirmwareUpdateProgressStatus() { + lastFirmwareUpdateProgressPercent = null; + } + + private int rememberFirmwareProgressPercentMonotonic(int candidatePercent) { + if (candidatePercent <= 0) { + return lastFirmwareUpdateProgressPercent != null ? lastFirmwareUpdateProgressPercent.intValue() : 0; + } + + Integer knownPercent = lastFirmwareUpdateProgressPercent; + int effectivePercent = knownPercent == null ? candidatePercent + : Math.max(knownPercent.intValue(), candidatePercent); + lastFirmwareUpdateProgressPercent = Integer.valueOf(effectivePercent); + return effectivePercent; + } + + private void resetFirmwareProgressSequence() { + firmwareProgressStepIndex = -1; + } + + private @Nullable ProgressCallback keepCallbackIfUsable(@Nullable ProgressCallback callback, String operation, + Runnable invocation) { + if (callback == null) { + return null; + } + + try { + invocation.run(); + return callback; + } catch (RuntimeException e) { + logger.warn("NODE {}: Firmware progress callback {} failed ({}); disabling callback for this run", nodeId, + operation, e.getMessage()); + logger.debug("NODE {}: Firmware progress callback {} failure detail", nodeId, operation, e); + if (Objects.equals(this.firmwareProgressCallback, callback)) { + this.firmwareProgressCallback = null; + } + resetFirmwareProgressSequence(); + return null; + } + } + + // Reasonable (IMO) milestones for multi-minute firmware updates. + private @Nullable Integer getFirmwareUiMilestone(int progressPercent) { + Integer milestone = null; + for (Integer candidate : FIRMWARE_PROGRESS_UI_MILESTONES) { + if (progressPercent >= candidate.intValue()) { + milestone = candidate; + } + } + return milestone; + } + + // This method ensures that progress is reflected in the UI so the user + // can see progress without having to check the event log. + private void updateFirmwareProgressStatusForUiMilestone(int progressPercent) { + Integer milestone = getFirmwareUiMilestone(progressPercent); + if (milestone == null) { + return; + } + + Integer knownPercent = lastFirmwareUpdateProgressPercent; + int effectiveProgressPercent = rememberFirmwareProgressPercentMonotonic(milestone.intValue()); + if (effectiveProgressPercent > milestone.intValue()) { + return; + } + + if (Objects.equals(knownPercent, milestone)) { + return; + } + + lastFirmwareUpdateProgressPercent = milestone; + updateStatus(ThingStatus.ONLINE, ThingStatusDetail.CONFIGURATION_PENDING, + "Firmware update in progress (" + milestone + "%)"); + } + + /** + * Restores the last known firmware update progress status in the UI + * if a firmware update is in progress and the node comes back online. + */ + private void restoreFirmwareUpdateProgressStatusIfNeeded() { + ZWaveFirmwareUpdateSession session = firmwareSession; + if (session == null) { + return; + } + + if (!session.isActive()) { + // Session ended. If it failed, re-apply the failure status so that ALIVE/DONE + // events do not silently overwrite the failure message with plain ONLINE. + if (session.getState() == ZWaveFirmwareUpdateSession.State.FAILURE) { + String failDesc = lastFirmwareFailureDescription; + if (failDesc != null) { + updateStatus(ThingStatus.ONLINE, ThingStatusDetail.CONFIGURATION_ERROR, failDesc); + } + } + return; + } + + // Session is active - restore progress display. + int sessionProgressPercent = session.getCurrentTransferProgressPercent(); + rememberFirmwareProgressPercentMonotonic(sessionProgressPercent); + Integer progressPercent = lastFirmwareUpdateProgressPercent; + if (progressPercent != null) { + updateStatus(ThingStatus.ONLINE, ThingStatusDetail.CONFIGURATION_PENDING, + "Firmware update in progress (" + progressPercent + "%)"); + } else { + updateStatus(ThingStatus.ONLINE, ThingStatusDetail.CONFIGURATION_PENDING, "Firmware update in progress"); + } + } + + private void onFirmwareUpdateSucceeded() { + clearFirmwareUpdateProgressStatus(); + lastFirmwareFailureDescription = null; + updateStatus(ThingStatus.ONLINE, ThingStatusDetail.NONE, "Firmware update completed"); + ProgressCallback progressCallback = this.firmwareProgressCallback; + if (progressCallback != null) { + if (firmwareProgressStepIndex < 3) { + progressCallback = advanceFirmwareProgressTo(3, progressCallback); + } + + ProgressCallback callbackRef = progressCallback; + if (callbackRef != null) { + keepCallbackIfUsable(callbackRef, "success()", callbackRef::success); + } + this.firmwareProgressCallback = null; + resetFirmwareProgressSequence(); + } + } + + private void onFirmwareUpdateFailed(String description, String callbackFailureDetail) { + clearFirmwareUpdateProgressStatus(); + lastFirmwareFailureDescription = description; + updateStatus(ThingStatus.ONLINE, ThingStatusDetail.CONFIGURATION_ERROR, description); + ProgressCallback progressCallback = this.firmwareProgressCallback; + if (progressCallback != null) { + ProgressCallback callbackRef = progressCallback; + keepCallbackIfUsable(callbackRef, "failed()", + () -> callbackRef.failed("actions.firmware-update.error", callbackFailureDetail)); + this.firmwareProgressCallback = null; + } + resetFirmwareProgressSequence(); + // End of firmware update handling + } /** - * Parse the provided new set of properties and set the thing's semantic equipment tag + * Parse the provided new set of properties and set the thing's semantic + * equipment tag */ private void updateSemanticTag(Map properties) { SemanticTag equipmentTag = null; @@ -2295,7 +2811,8 @@ private void updateSemanticTag(Map properties) { } /* - * TODO reviewer please advise if there could ever be actual cases where specificProperty might not yield + * TODO reviewer please advise if there could ever be actual cases where + * specificProperty might not yield * a tag value and yet genericProperty could nevertheless still yield one */ if (equipmentTag == null) { diff --git a/src/main/java/org/openhab/binding/zwave/internal/protocol/ZWaveController.java b/src/main/java/org/openhab/binding/zwave/internal/protocol/ZWaveController.java index 700e4b5de..3296df070 100644 --- a/src/main/java/org/openhab/binding/zwave/internal/protocol/ZWaveController.java +++ b/src/main/java/org/openhab/binding/zwave/internal/protocol/ZWaveController.java @@ -30,7 +30,6 @@ import org.openhab.binding.zwave.internal.protocol.ZWaveTransaction.TransactionState; import org.openhab.binding.zwave.internal.protocol.commandclass.ZWaveCommandClass; import org.openhab.binding.zwave.internal.protocol.commandclass.ZWaveMultiInstanceCommandClass; -import org.openhab.binding.zwave.internal.protocol.commandclass.ZWaveNoOperationCommandClass; import org.openhab.binding.zwave.internal.protocol.commandclass.ZWaveSecurityCommandClass; import org.openhab.binding.zwave.internal.protocol.event.ZWaveEvent; import org.openhab.binding.zwave.internal.protocol.event.ZWaveInclusionEvent; diff --git a/src/main/java/org/openhab/binding/zwave/internal/protocol/ZWaveNode.java b/src/main/java/org/openhab/binding/zwave/internal/protocol/ZWaveNode.java index 3f131e4e1..577775f48 100644 --- a/src/main/java/org/openhab/binding/zwave/internal/protocol/ZWaveNode.java +++ b/src/main/java/org/openhab/binding/zwave/internal/protocol/ZWaveNode.java @@ -54,8 +54,8 @@ /** * Z-Wave node class. Represents a node in the Z-Wave network. * - * @author Chris Jackson - * @author Brian Crosby + * @author Chris Jackson - Initial contribution + * @author Brian Crosby - Contribution */ @XStreamAlias("node") public class ZWaveNode { @@ -100,6 +100,8 @@ public class ZWaveNode { private TimerTask timerTask = null; @XStreamOmitField private boolean awake = false; + @XStreamOmitField + private volatile boolean firmwareUpdateInProgress = false; // Half second intervals to check if Done and no more messages private final int sleepDelay = 500; @@ -1346,8 +1348,8 @@ public List processCommand(ZWaveCommandClassPayload pa } for (ZWaveCommandClassPayload command : commands) { // Check for WAKEUP_NOTIFICATION - if (payload.getCommandClassId() == CommandClass.COMMAND_CLASS_WAKE_UP.getKey() - && payload.getCommandClassCommand() == 0x07) { + if (command.getCommandClassId() == CommandClass.COMMAND_CLASS_WAKE_UP.getKey() + && command.getCommandClassCommand() == 0x07) { setAwake(true); continue; } @@ -1414,6 +1416,38 @@ public void setInclusionTimer() { inclusionTimer = System.nanoTime(); } + /** + * Enables/disables a temporary awake hold used during firmware update. + * While enabled, backstop sleep handling is bypassed for sleeping nodes, + * allowing the device to stay awake without forced sleep once it wakes naturally. + * Does NOT artificially wake the node—just prevents forced sleep during session. + * + * @param inProgress true when firmware update is active + */ + public synchronized void setFirmwareUpdateInProgress(boolean inProgress) { + firmwareUpdateInProgress = inProgress; + + if (inProgress) { + resetSleepTimer(); + } else if (awake) { + setSleepTimer(); + } + } + + /** + * Forces this node into sleep state regardless of firmware update hold. + * Used when transport-layer failures indicate the node is no longer reachable. + */ + public synchronized void forceSleep() { + if (listening == true || frequentlyListening == true) { + return; + } + + logger.debug("NODE {}: Force sleep", getNodeId()); + awake = false; + resetSleepTimer(); + } + /** * Sets the device as awake if the device is normally not listening. * @@ -1433,13 +1467,24 @@ public void setAwake(boolean awake) { timer = new Timer(); } + if (!awake && firmwareUpdateInProgress) { + logger.debug("NODE {}: Ignore sleep while firmware update is active", getNodeId()); + this.awake = true; + resetSleepTimer(); + return; + } + // Start the timer if (!this.awake) { // We're awake logger.debug("NODE {}: Is awake with {} messages in the queue, state {}", getNodeId(), controller.getSendQueueLength(getNodeId()), getNodeInitStage()); - setSleepTimer(); + if (firmwareUpdateInProgress) { + resetSleepTimer(); + } else { + setSleepTimer(); + } // Notify application ZWaveEvent event = new ZWaveNodeStatusEvent(getNodeId(), ZWaveNodeState.AWAKE); @@ -1494,6 +1539,12 @@ public void run() { logger.trace("NODE {}: WakeupTimerTask Already asleep", getNodeId()); return; } + + if (firmwareUpdateInProgress) { + logger.trace("NODE {}: WakeupTimerTask bypassed during firmware update", getNodeId()); + return; + } + count = count + 1; logger.debug("NODE {}: WakeupTimerTask {} Messages waiting, state {} count {}", getNodeId(), controller.getSendQueueLength(getNodeId()), getNodeInitStage(), count); diff --git a/src/main/java/org/openhab/binding/zwave/internal/protocol/ZWaveTransactionManager.java b/src/main/java/org/openhab/binding/zwave/internal/protocol/ZWaveTransactionManager.java index d19b39640..1f9a15d89 100644 --- a/src/main/java/org/openhab/binding/zwave/internal/protocol/ZWaveTransactionManager.java +++ b/src/main/java/org/openhab/binding/zwave/internal/protocol/ZWaveTransactionManager.java @@ -739,7 +739,7 @@ public void run() { // which means the device didn't respond. Treat as ASLEEP. logger.debug("NODE {}: Transaction failed waiting for REQUEST, assume sleeping device.", currentTransaction.getNodeId()); - node.setAwake(false); + node.forceSleep(); } // Handle retries diff --git a/src/main/java/org/openhab/binding/zwave/internal/protocol/commandclass/ZWaveFirmwareUpdateCommandClass.java b/src/main/java/org/openhab/binding/zwave/internal/protocol/commandclass/ZWaveFirmwareUpdateCommandClass.java index 645b2fa5e..e1aa4ddc9 100644 --- a/src/main/java/org/openhab/binding/zwave/internal/protocol/commandclass/ZWaveFirmwareUpdateCommandClass.java +++ b/src/main/java/org/openhab/binding/zwave/internal/protocol/commandclass/ZWaveFirmwareUpdateCommandClass.java @@ -12,9 +12,19 @@ */ package org.openhab.binding.zwave.internal.protocol.commandclass; +import java.nio.ByteBuffer; +import java.util.Arrays; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jdt.annotation.Nullable; +import org.openhab.binding.zwave.firmwareupdate.ZWaveFirmwareUpdateSession.FirmwareUpdateEvent; +import org.openhab.binding.zwave.internal.protocol.ZWaveCommandClassPayload; import org.openhab.binding.zwave.internal.protocol.ZWaveController; import org.openhab.binding.zwave.internal.protocol.ZWaveEndpoint; import org.openhab.binding.zwave.internal.protocol.ZWaveNode; +import org.openhab.binding.zwave.internal.protocol.ZWaveTransaction.TransactionPriority; +import org.openhab.binding.zwave.internal.protocol.transaction.ZWaveCommandClassTransactionPayload; +import org.openhab.binding.zwave.internal.protocol.transaction.ZWaveCommandClassTransactionPayloadBuilder; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -22,16 +32,34 @@ import com.thoughtworks.xstream.annotations.XStreamOmitField; /** - * Handles the firmware update command class. - * - * @author Chris Jackson - * + * Merged implementation adapted from zwave-js FirmwareUpdateMetaDataCC. + * Integrates firmware update command class data structures, (de)serialization + * and CRC16-CCITT helper methods. + * + * @author Chris Jackson - initial contribution + * @author Bob Eckhoff - contributions to firmware update handling and + * refactoring */ @XStreamAlias("COMMAND_CLASS_FIRMWARE_UPDATE_MD") +@NonNullByDefault public class ZWaveFirmwareUpdateCommandClass extends ZWaveCommandClass { @XStreamOmitField private static final Logger logger = LoggerFactory.getLogger(ZWaveFirmwareUpdateCommandClass.class); + private static final int MAX_SUPPORTED_VERSION = 8; + private static final int CRC16_CCITT_INITIAL = 0x1D0F; + + public static final int FIRMWARE_MD_GET = 0x01; // To Device + public static final int FIRMWARE_MD_REPORT = 0x02; // From Device + public static final int FIRMWARE_UPDATE_MD_REQUEST_GET = 0x03; // To Device + public static final int FIRMWARE_UPDATE_MD_REQUEST_REPORT = 0x04; // From Device + public static final int FIRMWARE_UPDATE_MD_GET = 0x05; // From Device if ready to receive. + public static final int FIRMWARE_UPDATE_MD_REPORT = 0x06; // To Device for fragment data. + public static final int FIRMWARE_UPDATE_MD_STATUS_REPORT = 0x07; // From Device + public static final int FIRMWARE_UPDATE_ACTIVATION_SET = 0x08; // To Device + public static final int FIRMWARE_UPDATE_ACTIVATION_STATUS_REPORT = 0x09; // From Device + public static final int FIRMWARE_UPDATE_PREPARE_GET = 0x0A; // To Device requesting it send current firmware -Future + public static final int FIRMWARE_UPDATE_PREPARE_REPORT = 0x0B; // From Device current firmware to binding -Future /** * Creates a new instance of the ZWaveFirmwareUpdateCommandClass class. @@ -42,10 +70,479 @@ public class ZWaveFirmwareUpdateCommandClass extends ZWaveCommandClass { */ public ZWaveFirmwareUpdateCommandClass(ZWaveNode node, ZWaveController controller, ZWaveEndpoint endpoint) { super(node, controller, endpoint); + versionMax = MAX_SUPPORTED_VERSION; + } + + @Override + public void initialise(@Nullable ZWaveNode node, @Nullable ZWaveController controller, + @Nullable ZWaveEndpoint endpoint) { + super.initialise(node, controller, endpoint); + // versionMax is @XStreamOmitField so it is not persisted. Restore it here so that + // setVersion() does not cap the version to 0 when called on a deserialized node. + versionMax = MAX_SUPPORTED_VERSION; } @Override public CommandClass getCommandClass() { return CommandClass.COMMAND_CLASS_FIRMWARE_UPDATE_MD; } + + /** + * Create a transaction payload for Firmware Meta Data Get (1). + * The message requests the supporting node to return a Firmware Meta Data + * Report (2). + */ + public ZWaveCommandClassTransactionPayload sendMDGetMessage() { + logger.debug("NODE {}: Creating new message for application command FIRMWARE_MD_GET", + this.getNode().getNodeId()); + + return new ZWaveCommandClassTransactionPayloadBuilder(getNode().getNodeId(), getCommandClass(), FIRMWARE_MD_GET) + .withPriority(TransactionPriority.Config).withExpectedResponseCommand(FIRMWARE_MD_REPORT).build(); + } + + /** + * Create a transaction payload for Firmware Update MD Request Get (3). + * The message requests the supporting node to return a Firmware Update MD + * Request Report (4) indicating whether the device is ready to receive the + * firmware data and whether this is a resume of an interrupted update. + */ + public ZWaveCommandClassTransactionPayload sendMDRequestGetMessage(byte[] payload) { + logger.debug("NODE {}: Creating new message for FIRMWARE_MD_REQUEST_GET", getNode().getNodeId()); + + return new ZWaveCommandClassTransactionPayloadBuilder(getNode().getNodeId(), getCommandClass(), + FIRMWARE_UPDATE_MD_REQUEST_GET).withPayload(payload).withPriority(TransactionPriority.Config) + .withExpectedResponseCommand(FIRMWARE_UPDATE_MD_REQUEST_REPORT).build(); + } + + /** + * Create a transaction payload for Firmware Update MD Report (6). + * This sends a single firmware fragment to the device. Retries are managed in the Session. + */ + public ZWaveCommandClassTransactionPayload sendFirmwareUpdateReport(FirmwareFragment fragment) { + logger.debug("NODE {}: Creating FIRMWARE_UPDATE_MD_REPORT for fragment {}, isLast={}", getNode().getNodeId(), + fragment.reportNumber, fragment.isLast); + + byte[] payload = fragment.toBytes(getVersion(), getCommandClass().getKey(), FIRMWARE_UPDATE_MD_REPORT); + + ZWaveCommandClassTransactionPayload message = new ZWaveCommandClassTransactionPayloadBuilder( + getNode().getNodeId(), getCommandClass(), FIRMWARE_UPDATE_MD_REPORT).withPayload(payload) + .withPriority(TransactionPriority.Config).build(); + message.setMaxAttempts(1); + return message; + } + + /** + * Create a transaction payload for Firmware Update MD Get (5). + * This requests one or more firmware fragments from the node. + * This would be used for downloading firmware from the device, + * but is not currently used in the binding as we only support uploading. + */ + public ZWaveCommandClassTransactionPayload sendFirmwareUpdateMdGet(int reportNumber, int numberOfReports) { + logger.debug("NODE {}: Creating new message for FIRMWARE_UPDATE_MD_GET report={}, count={}", + getNode().getNodeId(), reportNumber, numberOfReports); + + byte[] payload = new byte[] { (byte) (numberOfReports & 0xFF), (byte) ((reportNumber >> 8) & 0xFF), + (byte) (reportNumber & 0xFF) }; + + return new ZWaveCommandClassTransactionPayloadBuilder(getNode().getNodeId(), getCommandClass(), + FIRMWARE_UPDATE_MD_GET).withPayload(payload).withPriority(TransactionPriority.Config) + .withExpectedResponseCommand(FIRMWARE_UPDATE_MD_REPORT).build(); + } + + /** + * Create a transaction payload for Firmware Update Activation Set (8). + * The message initiates the activation of the new firmware after all fragments + * have been sent. + * The device will respond with a Firmware Update Activation Status Report (9) + * indicating the result. + */ + public ZWaveCommandClassTransactionPayload setFirmwareActivation(byte[] firmwareBaseData) { + logger.debug("NODE {}: Creating new message for FIRMWARE_UPDATE_ACTIVATION_SET", getNode().getNodeId()); + + return new ZWaveCommandClassTransactionPayloadBuilder(getNode().getNodeId(), getCommandClass(), + FIRMWARE_UPDATE_ACTIVATION_SET).withPayload(firmwareBaseData).withPriority(TransactionPriority.Config) + .withExpectedResponseCommand(FIRMWARE_UPDATE_ACTIVATION_STATUS_REPORT).build(); + } + + /** + * Create a transaction payload for Firmware Update Prepare Get (10). + * The message requests the device to prepare to send its current firmware + * information, which will be returned in a Firmware Update Prepare Report (11). + * This can be used to retrieve the current firmware information before starting + * an update. + * Payload format: + * manufacturerId(2), firmwareId(2), firmwareTarget(1), fragmentSize(2), + * [hardwareVersion(1)]. + */ + public ZWaveCommandClassTransactionPayload setFirmwarePrepareGet(byte[] prepareRequestData) { + logger.debug("NODE {}: Creating new message for FIRMWARE_UPDATE_PREPARE_GET", getNode().getNodeId()); + + return new ZWaveCommandClassTransactionPayloadBuilder(getNode().getNodeId(), getCommandClass(), + FIRMWARE_UPDATE_PREPARE_GET).withPayload(prepareRequestData).withPriority(TransactionPriority.Config) + .withExpectedResponseCommand(FIRMWARE_UPDATE_PREPARE_REPORT).build(); + } + + /** + * Handle Firmware Meta Data Report (2) from device, which contains information + * about the firmware and the device's capabilities related to firmware update. + * The payload contains manufacturer ID, firmware ID, checksum, max fragment + * size and optionally hardware version. + * + * @param payload the command payload containing manufacturer ID, firmware ID, checksum, + * max fragment size and optionally hardware version + * @param endpoint the endpoint from which the MD Report was received + */ + @ZWaveResponseHandler(id = FIRMWARE_MD_REPORT, name = "FIRMWARE_MD_REPORT") + public void handleMetaDataReport(ZWaveCommandClassPayload payload, int endpoint) { + byte[] data = payload.getPayloadBuffer(); + + byte[] payloadMD = Arrays.copyOfRange(data, 2, data.length); + + // Payload is processed in the session. As the information is used for the session. + getController() + .notifyEventListeners(FirmwareUpdateEvent.forMDReport(getNode().getNodeId(), endpoint, payloadMD)); + } + + /** + * Handle Firmware Update MD Request Report (4) from device, which indicates the + * result of the firmware update request and whether the device is ready to + * receive the firmware data. + * The payload contains status byte and optional flags for versions. + * + * @param payload the command payload containing the status byte and optional flags. + * @param endpoint the endpoint from which the report was received + */ + @ZWaveResponseHandler(id = FIRMWARE_UPDATE_MD_REQUEST_REPORT, name = "FIRMWARE_UPDATE_MD_REQUEST_REPORT") + public void handleMetaDataRequestReport(ZWaveCommandClassPayload payload, int endpoint) { + byte[] data = payload.getPayloadBuffer(); + + if (data.length < 3) { + logger.warn("NODE {}: Firmware Update MD Request Report payload too short", getNode().getNodeId()); + getController().notifyEventListeners( + FirmwareUpdateEvent.forUpdateMdRequestReport(getNode().getNodeId(), endpoint, -1, null, null)); + return; + } + + int status = data[2] & 0xFF; + + @Nullable + Boolean resume = null; + @Nullable + Boolean nonSecure = null; + + if (data.length >= 4) { + int flags = data[3] & 0xFF; + resume = (flags & 0b100) != 0; + nonSecure = (flags & 0b10) != 0; + } + + getController().notifyEventListeners(FirmwareUpdateEvent.forUpdateMdRequestReport(getNode().getNodeId(), + endpoint, status, resume, nonSecure)); + } + + /** + * Handle Firmware Update MD Get (5) from device, which indicates that the + * device is ready to receive the next firmware fragment. The payload contains + * the report number and total number of reports. + * + * @param payload the command payload containing the report number and total number of reports + * @param endpoint the endpoint from which the MD Get was received + */ + @ZWaveResponseHandler(id = FIRMWARE_UPDATE_MD_GET, name = "FIRMWARE_UPDATE_MD_GET") + public void handleFirmwareDownloadGet(ZWaveCommandClassPayload payload, int endpoint) { + byte[] data = payload.getPayloadBuffer(); + + if (data.length < 5) { + logger.debug("NODE {}: Firmware Download Get payload too short", getNode().getNodeId()); + return; + } + + int numReports = data[2] & 0xFF; + int reportNumber = ((data[3] & 0xFF) << 8) | (data[4] & 0xFF); + reportNumber &= 0x7FFF; // mask reserved bit + + logger.debug("NODE {}: Received Firmware Download Get", getNode().getNodeId()); + logger.debug("NODE {}: Number of reports = {}", getNode().getNodeId(), numReports); + logger.debug("NODE {}: Report number = {}", getNode().getNodeId(), reportNumber); + + getController().notifyEventListeners( + FirmwareUpdateEvent.forUpdateMdGet(getNode().getNodeId(), endpoint, reportNumber, numReports)); + } + + /** + * Handle Firmware Update MD Report (6) from device during firmware download. + * Payload format matches fragment data layout and is routed to the firmware session. + * + * @param payload command payload + * @param endpoint endpoint id + */ + @ZWaveResponseHandler(id = FIRMWARE_UPDATE_MD_REPORT, name = "FIRMWARE_UPDATE_MD_REPORT") + public void handleFirmwareUpdateMdReport(ZWaveCommandClassPayload payload, int endpoint) { + byte[] data = payload.getPayloadBuffer(); + + if (data.length < 4) { + logger.debug("NODE {}: Firmware Update MD Report payload too short", getNode().getNodeId()); + return; + } + + byte[] fragmentPayload = Arrays.copyOfRange(data, 2, data.length); + + getController().notifyEventListeners( + FirmwareUpdateEvent.forMDReport(getNode().getNodeId(), endpoint, fragmentPayload)); + } + + @ZWaveResponseHandler(id = FIRMWARE_UPDATE_MD_STATUS_REPORT, name = "FIRMWARE_UPDATE_MD_STATUS_REPORT") + public void handleFirmwareUpdateMdStatusReport(ZWaveCommandClassPayload payload, int endpoint) { + byte[] data = payload.getPayloadBuffer(); + + if (data.length < 3) { + logger.debug("NODE {}: Firmware Update MD Status Report payload too short", getNode().getNodeId()); + return; + } + + int status = data[2] & 0xFF; + int waitTime = 0; + if (data.length >= 5) { + waitTime = ((data[3] & 0xFF) << 8) | (data[4] & 0xFF); + } + + logger.debug("NODE {}: Received Firmware Update MD Status Report: status={}, waitTime={}", + getNode().getNodeId(), status, waitTime); + + getController().notifyEventListeners( + FirmwareUpdateEvent.forUpdateMdStatusReport(getNode().getNodeId(), endpoint, status, waitTime)); + } + + /** + * Handle Firmware Update Activation Status Report (9) from device, which + * indicates the result of the activation attempt. + * The payload contains manufacturer ID, firmware ID, checksum, target, + * activation status and optionally hardware version. + * + * @param payload the command payload containing manufacturer ID, firmware ID, checksum, + * firmware target, activation status and optionally hardware version + * @param endpoint the endpoint from which the activation status report was received + */ + @ZWaveResponseHandler(id = FIRMWARE_UPDATE_ACTIVATION_STATUS_REPORT, name = "FIRMWARE_UPDATE_ACTIVATION_STATUS_REPORT") + public void handleFirmwareActivationStatusReport(ZWaveCommandClassPayload payload, int endpoint) { + byte[] data = payload.getPayloadBuffer(); + + if (data.length < 11) { + logger.debug("NODE {}: Firmware Activation Status Report payload too short", getNode().getNodeId()); + return; + } + // Skip CC + command (2 bytes) + ByteBuffer bb = ByteBuffer.wrap(data, 2, data.length - 2); + + int manufacturerId = bb.getShort() & 0xFFFF; + int firmwareId = bb.getShort() & 0xFFFF; + int checksum = bb.getShort() & 0xFFFF; + int firmwareTarget = bb.get() & 0xFF; + FirmwareUpdateActivationStatus status = FirmwareUpdateActivationStatus.from(bb.get() & 0xFF); + Integer hardwareVersion = null; + if (bb.hasRemaining()) { + hardwareVersion = Integer.valueOf(bb.get() & 0xFF); + } + + logger.debug( + "NODE {}: Received Firmware Activation Status Report: manufacturerId=0x{}, firmwareId=0x{}, checksum=0x{}, target={}, status={}, hwVersion={}", + getNode().getNodeId(), Integer.toHexString(manufacturerId), Integer.toHexString(firmwareId), + Integer.toHexString(checksum), firmwareTarget, status, hardwareVersion); + + getController().notifyEventListeners( + FirmwareUpdateEvent.forActivationStatusReport(getNode().getNodeId(), endpoint, status.getId())); + } + + /** + * Handle Firmware Update Prepare Report (11) from device, which contains the + * current firmware information of the device. The payload contains checksum and + * status. Not implemented and used for now, but can be used to retrieve current firmware. + * + * @param payload the command payload containing the firmware checksum and status + * @param endpoint the endpoint from which the prepare report was received + */ + @ZWaveResponseHandler(id = FIRMWARE_UPDATE_PREPARE_REPORT, name = "FIRMWARE_UPDATE_PREPARE_REPORT") + public void handleFirmwarePrepareReport(ZWaveCommandClassPayload payload, int endpoint) { + byte[] data = payload.getPayloadBuffer(); + + if (data.length < 5) { + logger.debug("NODE {}: Firmware Prepare Report payload too short", getNode().getNodeId()); + return; + } + // Skip CC + command (2 bytes) + ByteBuffer bb = ByteBuffer.wrap(data, 2, data.length - 2); + + FirmwareDownloadStatus status = FirmwareDownloadStatus.from(bb.get() & 0xFF); + int checksum = bb.getShort() & 0xFFFF; + + logger.debug("NODE {}: Received Firmware Prepare Report: checksum=0x{}, status={}", getNode().getNodeId(), + Integer.toHexString(checksum), status); + + //getController().notifyEventListeners( + // FirmwareUpdateEvent.forUpdatePrepareReport(getNode().getNodeId(), endpoint, status.getId(), checksum)); + } + + public enum FirmwareUpdateMdRequestStatus { + ERROR_INVALID_MANUFACTURER_OR_FIRMWARE_ID(0x00), + ERROR_AUTHENTICATION_EXPECTED(0x01), + ERROR_FRAGMENT_SIZE_TOO_LARGE(0x02), + ERROR_NOT_UPGRADABLE(0x03), + ERROR_INVALID_HARDWARE_VERSION(0x04), + ERROR_FIRMWARE_UPGRADE_IN_PROGRESS(0x05), + ERROR_BATTERY_LOW(0x06), + OK(0xFF), + UNKNOWN(-1); + + private final int id; + + FirmwareUpdateMdRequestStatus(int id) { + this.id = id; + } + + public int getId() { + return id; + } + + public static FirmwareUpdateMdRequestStatus from(int v) { + for (FirmwareUpdateMdRequestStatus status : values()) { + if (status.id == v) { + return status; + } + } + return UNKNOWN; + } + } + + public enum FirmwareUpdateMdStatusReport { + ERROR_CHECKSUM(0x00), + ERROR_TRANSMISSION_FAILED(0x01), + ERROR_INVALID_MANUFACTURER_ID(0x02), + ERROR_INVALID_FIRMWARE_ID(0x03), + ERROR_INVALID_FIRMWARE_TARGET(0x04), + ERROR_INVALID_HEADER_INFORMATION(0x05), + ERROR_INVALID_HEADER_FORMAT(0x06), + ERROR_INSUFFICIENT_MEMORY(0x07), + ERROR_INVALID_HARDWARE_VERSION(0x08), + OK_WAITING_FOR_ACTIVATION(0xFD), + OK_NO_RESTART(0xFE), + OK_RESTART_PENDING(0xFF), + UNKNOWN(-1); + + private final int id; + + FirmwareUpdateMdStatusReport(int id) { + this.id = id; + } + + public int getId() { + return id; + } + + public static FirmwareUpdateMdStatusReport from(int v) { + for (FirmwareUpdateMdStatusReport status : values()) { + if (status.id == v) { + return status; + } + } + return UNKNOWN; + } + } + + public enum FirmwareUpdateActivationStatus { + INVALID_PAYLOAD(0x00), + ERROR_ACTIVATING_FIRMWARE(0x01), + SUCCESS(0xFF), + UNKNOWN(-1); + + private final int id; + + FirmwareUpdateActivationStatus(int id) { + this.id = id; + } + + public int getId() { + return id; + } + + public static FirmwareUpdateActivationStatus from(int v) { + for (FirmwareUpdateActivationStatus status : values()) { + if (status.id == v) { + return status; + } + } + return UNKNOWN; + } + } + + public enum FirmwareDownloadStatus { + INVALID_PAYLOAD(0x00), + EXPECTED_AUTHORIZATION_EVENT(0x01), + FRAGMENT_SIZE_EXCEEDED(0x02), + FIRMWARE_TARGET_NOT_DOWNLOADABLE(0x03), + INVALID_HARDWARE_VERSION(0x04), + SUCCESS(0xFF), + UNKNOWN(-1); + + private final int id; + + FirmwareDownloadStatus(int id) { + this.id = id; + } + + public int getId() { + return id; + } + + public static FirmwareDownloadStatus from(int v) { + for (FirmwareDownloadStatus status : values()) { + if (status.id == v) { + return status; + } + } + return UNKNOWN; + } + } + + /* CRC16-CCITT (poly 0x1021) implementation */ + public static int crc16Ccitt(byte[] data, int initial) { + int crc = initial & 0xffff; + for (byte b : data) { + crc ^= (b & 0xff) << 8; + for (int i = 0; i < 8; i++) { + if ((crc & 0x8000) != 0) { + crc = (crc << 1) ^ 0x1021; + } else { + crc <<= 1; + } + } + crc &= 0xffff; + } + return crc & 0xffff; + } + + /** Data structure representing a firmware fragment to be sent to the device. */ + public record FirmwareFragment(boolean isLast, int reportNumber, byte[] firmwareData, @Nullable Integer crc16) { + public byte[] toBytes(int ccVersion, int ccId, int ccCommand) { + int len = 2 + firmwareData.length + (ccVersion >= 2 ? 2 : 0); + ByteBuffer bb = ByteBuffer.allocate(len); + + int word = (reportNumber & 0x7fff) | (isLast ? 0x8000 : 0); + bb.putShort((short) word); + bb.put(firmwareData); + + if (ccVersion >= 2) { + int crc; + if (crc16 != null) { + crc = crc16.intValue() & 0xFFFF; + } else { + byte[] commandBuffer = Arrays.copyOfRange(bb.array(), 0, bb.position()); + crc = crc16Ccitt(new byte[] { (byte) ccId, (byte) ccCommand }, CRC16_CCITT_INITIAL); + crc = crc16Ccitt(commandBuffer, crc); + } + bb.putShort((short) crc); + } + + return bb.array(); + } + } } diff --git a/src/main/java/org/openhab/binding/zwave/internal/protocol/commandclass/ZWaveVersionCommandClass.java b/src/main/java/org/openhab/binding/zwave/internal/protocol/commandclass/ZWaveVersionCommandClass.java index 019d84476..f1806a3ca 100644 --- a/src/main/java/org/openhab/binding/zwave/internal/protocol/commandclass/ZWaveVersionCommandClass.java +++ b/src/main/java/org/openhab/binding/zwave/internal/protocol/commandclass/ZWaveVersionCommandClass.java @@ -20,6 +20,7 @@ import org.openhab.binding.zwave.internal.protocol.ZWaveEndpoint; import org.openhab.binding.zwave.internal.protocol.ZWaveNode; import org.openhab.binding.zwave.internal.protocol.ZWaveTransaction.TransactionPriority; +import org.openhab.binding.zwave.internal.protocol.event.ZWaveCommandClassValueEvent; import org.openhab.binding.zwave.internal.protocol.transaction.ZWaveCommandClassTransactionPayload; import org.openhab.binding.zwave.internal.protocol.transaction.ZWaveCommandClassTransactionPayloadBuilder; import org.slf4j.Logger; @@ -91,6 +92,9 @@ public void handleVersionReport(ZWaveCommandClassPayload payload, int endpoint) hardwareVersion = payload.getPayloadByte(7); logger.debug("NODE {}: Hardware Version = {}", getNode().getNodeId(), hardwareVersion); } + + getController().notifyEventListeners(new ZWaveCommandClassValueEvent(getNode().getNodeId(), endpoint, + CommandClass.COMMAND_CLASS_VERSION, applicationVersion)); } @ZWaveResponseHandler(id = VERSION_COMMAND_CLASS_REPORT, name = "VERSION_COMMAND_CLASS_REPORT") diff --git a/src/main/java/org/openhab/binding/zwave/internal/protocol/event/ZWaveNetworkEvent.java b/src/main/java/org/openhab/binding/zwave/internal/protocol/event/ZWaveNetworkEvent.java index bab8be220..aebbb291f 100644 --- a/src/main/java/org/openhab/binding/zwave/internal/protocol/event/ZWaveNetworkEvent.java +++ b/src/main/java/org/openhab/binding/zwave/internal/protocol/event/ZWaveNetworkEvent.java @@ -68,6 +68,7 @@ public enum Type { AssociationUpdate, DeleteNode, FailedNode, + FirmwareUpdate, RequestNetworkUpdate, FailedNodeFailed, ReplaceFailedNode, @@ -76,6 +77,7 @@ public enum Type { } public enum State { + Progress, Success, Failure } diff --git a/src/main/java/org/openhab/binding/zwave/internal/protocol/initialization/ZWaveNodeSerializer.java b/src/main/java/org/openhab/binding/zwave/internal/protocol/initialization/ZWaveNodeSerializer.java index e8b303db2..241e5ce56 100644 --- a/src/main/java/org/openhab/binding/zwave/internal/protocol/initialization/ZWaveNodeSerializer.java +++ b/src/main/java/org/openhab/binding/zwave/internal/protocol/initialization/ZWaveNodeSerializer.java @@ -41,12 +41,14 @@ * ZWaveNodeSerializer class. Serializes nodes to XML and back again. * * @author Chris Jackson - Initial contribution - * @author Jan-Willem Spuij + * @author Jan-Willem Spuij - Contribution + * @author Bob Eckhoff - Added firmware folder creation. */ public class ZWaveNodeSerializer { private static final Logger logger = LoggerFactory.getLogger(ZWaveNodeSerializer.class); private final XStream stream = new XStream(new StaxDriver()); private final String folderName; + private static final String FIRMWARE_FOLDER = "firmware"; /** * Constructor. Creates a new instance of the {@link ZWaveNodeSerializer} class. @@ -103,8 +105,8 @@ public ZWaveNodeSerializer() { /** * Serializes an XML tree of a {@link ZWaveNode} * - * @param node - * the node to serialize + * @param node the node to serialize + * */ public void serializeNode(ZWaveNode node) { synchronized (stream) { @@ -116,6 +118,8 @@ public void serializeNode(ZWaveNode node) { return; } + ensureNodeFirmwareFolder(node.getNodeId()); + File file = new File(folderName, String.format("network_%08x__node_%d.xml", node.getHomeId(), node.getNodeId())); BufferedWriter writer = null; @@ -139,11 +143,29 @@ public void serializeNode(ZWaveNode node) { } } + private void ensureNodeFirmwareFolder(int nodeId) { + if (nodeId == 1) { + return; + } + + File firmwareFolder = new File(new File(folderName, FIRMWARE_FOLDER), "node-" + nodeId); + if (firmwareFolder.exists()) { + return; + } + + if (firmwareFolder.mkdirs()) { + logger.debug("NODE {}: Created firmware folder {}", nodeId, firmwareFolder.getPath()); + return; + } + + logger.warn("NODE {}: Failed to create firmware folder {}", nodeId, firmwareFolder.getPath()); + } + /** * Deserializes an XML tree of a {@link ZWaveNode} * - * @param nodeId - * the number of the node to deserialize + * @param homeId the home ID of the node + * @param nodeId the node ID to deserialize * @return returns the Node or null in case Serialization failed. */ public ZWaveNode deserializeNode(int homeId, int nodeId) { @@ -179,13 +201,37 @@ public ZWaveNode deserializeNode(int homeId, int nodeId) { * Deletes the persistence store for the specified node. * * @param nodeId The node ID to remove - * @return true if the file was deleted + * @return true if XML and node firmware folder are absent after cleanup */ public boolean deleteNode(int homeId, int nodeId) { synchronized (stream) { File file = new File(folderName, String.format("network_%08x__node_%d.xml", homeId, nodeId)); + File firmwareFolder = new File(new File(folderName, FIRMWARE_FOLDER), "node-" + nodeId); - return file.delete(); + boolean xmlDeleted = !file.exists() || file.delete(); + boolean firmwareDeleted = !firmwareFolder.exists() || deleteRecursively(firmwareFolder); + + if (!xmlDeleted) { + logger.warn("NODE {}: Failed to delete node XML {}", nodeId, file.getPath()); + } + if (!firmwareDeleted) { + logger.warn("NODE {}: Failed to delete firmware folder {}", nodeId, firmwareFolder.getPath()); + } + + return xmlDeleted && firmwareDeleted; + } + } + + private boolean deleteRecursively(File target) { + File[] children = target.listFiles(); + if (children != null) { + for (File child : children) { + if (!deleteRecursively(child)) { + return false; + } + } } + + return target.delete(); } } diff --git a/src/main/resources/OH-INF/i18n/actions.properties b/src/main/resources/OH-INF/i18n/actions.properties index 296bc4d3c..524d2035a 100644 --- a/src/main/resources/OH-INF/i18n/actions.properties +++ b/src/main/resources/OH-INF/i18n/actions.properties @@ -30,3 +30,5 @@ actions.node-ping.description=Send a ping to the node to check if it is reachabl actions.poll-linked-channels.label=Refresh linked channels (nee Poll) actions.poll-linked-channels.description=Refresh values on updatable linked channels. Not all channels are pollable (e.g. Notifications, Scenes). + +actions.firmware-update.error=Firmware update failed: {0} diff --git a/src/test/java/org/openhab/binding/zwave/firmwareupdate/FirmwareFileTest.java b/src/test/java/org/openhab/binding/zwave/firmwareupdate/FirmwareFileTest.java new file mode 100644 index 000000000..6ffe01cb7 --- /dev/null +++ b/src/test/java/org/openhab/binding/zwave/firmwareupdate/FirmwareFileTest.java @@ -0,0 +1,177 @@ +/* + * Copyright (c) 2010-2025 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.zwave.firmwareupdate; + +import static org.junit.jupiter.api.Assertions.*; + +import java.io.ByteArrayOutputStream; +import java.nio.ByteBuffer; +import java.nio.ByteOrder; +import java.nio.charset.StandardCharsets; +import java.util.zip.ZipEntry; +import java.util.zip.ZipOutputStream; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.junit.jupiter.api.Test; + +/** + * Unit tests for the {@link FirmwareFile} class, which is responsible for + * representing a firmware file and providing utilities to detect and extract + * firmware data from various Zwave vendor firmware formats (BIN, HEX, GBL, Aeotec EXE, ZIP). + * + * @author Bob Eckhoff - Initial contribution + */ +@NonNullByDefault +public class FirmwareFileTest { + + @Test + public void testExtractBin() throws Exception { + byte[] raw = new byte[] { 0x11, 0x22, 0x33 }; + + FirmwareFile file = FirmwareFile.extractFirmware("firmware.bin", raw); + + assertArrayEquals(raw, file.data); + assertNull(file.firmwareTarget); + } + + @Test + public void testExtractHex() throws Exception { + String hex = ":020000040000FA\n" + // extended linear address = 0 + ":10000000000102030405060708090A0B0C0D0E0F78\n" + ":00000001FF\n"; + + byte[] raw = hex.getBytes(StandardCharsets.US_ASCII); + + FirmwareFile file = FirmwareFile.extractFirmware("firmware.hex", raw); + + // Expect 16 bytes from 0x0000 to 0x000F + assertEquals(16, file.data.length); + + for (int i = 0; i < 16; i++) { + assertEquals((byte) i, file.data[i]); + } + } + + @Test + public void testExtractGbl() throws Exception { + byte[] raw = new byte[] { (byte) 0xEB, 0x17, (byte) 0xA6, 0x03, // Gecko magic + 0x11, 0x22, 0x33 }; + + FirmwareFile file = FirmwareFile.extractFirmware("firmware.gbl", raw); + + assertArrayEquals(raw, file.data); + } + + @Test + public void testExtractAeotecExe() throws Exception { + // Fake EXE layout: + // [MZ][padding...][firmware][start][length] + byte[] firmware = new byte[] { 0x55, 0x66, 0x77 }; + + byte[] exe = new byte[64]; + exe[0] = 0x4D; // 'M' + exe[1] = 0x5A; // 'Z' + + int firmwareStart = 16; + System.arraycopy(firmware, 0, exe, firmwareStart, firmware.length); + + // Write start/length at end + ByteBuffer buf = ByteBuffer.wrap(exe).order(ByteOrder.BIG_ENDIAN); + buf.putInt(exe.length - 8, firmwareStart); + buf.putInt(exe.length - 4, firmware.length); + + FirmwareFile file = FirmwareFile.extractFirmware("firmware.exe", exe); + + assertArrayEquals(firmware, file.data); + } + + @Test + public void testExtractZipWithBin() throws Exception { + byte[] inner = new byte[] { 0x01, 0x02, 0x03 }; + + // Build ZIP in memory + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + ZipOutputStream zos = new ZipOutputStream(baos); + + ZipEntry entry = new ZipEntry("firmware.bin"); + zos.putNextEntry(entry); + zos.write(inner); + zos.closeEntry(); + zos.close(); + + byte[] zipBytes = baos.toByteArray(); + + FirmwareFile file = FirmwareFile.extractFirmware("firmware.zip", zipBytes); + + assertArrayEquals(inner, file.data); + } + + @Test + public void testExtractZipWithHex() throws Exception { + String hex = ":020000040000FA\n" + ":0400000001020304F2\n" + ":00000001FF\n"; + + byte[] hexBytes = hex.getBytes(StandardCharsets.US_ASCII); + + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + ZipOutputStream zos = new ZipOutputStream(baos); + + zos.putNextEntry(new ZipEntry("firmware.hex")); + zos.write(hexBytes); + zos.closeEntry(); + zos.close(); + + FirmwareFile file = FirmwareFile.extractFirmware("firmware.zip", baos.toByteArray()); + + assertEquals(4, file.data.length); + assertArrayEquals(new byte[] { 1, 2, 3, 4 }, file.data); + } + + @Test + public void testDetectFormatInvalid() { + assertThrows(IllegalArgumentException.class, () -> { + FirmwareFile.detectFormat("firmware.xyz", new byte[] { 0x00 }); + }); + } + + @Test + public void testInvalidGblMagic() { + byte[] raw = new byte[] { 0x00, 0x11, 0x22, 0x33 }; + + assertThrows(IllegalArgumentException.class, () -> { + FirmwareFile.extractFirmware("firmware.gbl", raw); + }); + } + + @Test + public void testInvalidAeotecExeHeader() { + byte[] raw = new byte[32]; // no MZ header + + assertThrows(IllegalArgumentException.class, () -> { + FirmwareFile.extractFirmware("firmware.exe", raw); + }); + } + + @Test + public void testZipNoValidFirmware() throws Exception { + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + ZipOutputStream zos = new ZipOutputStream(baos); + + zos.putNextEntry(new ZipEntry("readme.txt")); + zos.write("hello".getBytes(StandardCharsets.UTF_8)); + zos.closeEntry(); + zos.close(); + + assertThrows(IllegalArgumentException.class, () -> { + FirmwareFile.extractFirmware("firmware.zip", baos.toByteArray()); + }); + } +} diff --git a/src/test/java/org/openhab/binding/zwave/firmwareupdate/ZWaveFirmwareUpdateSessionTest.java b/src/test/java/org/openhab/binding/zwave/firmwareupdate/ZWaveFirmwareUpdateSessionTest.java new file mode 100644 index 000000000..e78f158b3 --- /dev/null +++ b/src/test/java/org/openhab/binding/zwave/firmwareupdate/ZWaveFirmwareUpdateSessionTest.java @@ -0,0 +1,857 @@ +/* + * Copyright (c) 2010-2025 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.zwave.firmwareupdate; + +import static org.junit.jupiter.api.Assertions.*; + +import java.lang.reflect.Field; +import java.lang.reflect.InvocationTargetException; +import java.lang.reflect.Method; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.TimeUnit; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.mockito.Mockito; +import org.openhab.binding.zwave.handler.ZWaveControllerHandler; +import org.openhab.binding.zwave.internal.protocol.ZWaveNode; +import org.openhab.binding.zwave.internal.protocol.ZWaveTransaction; +import org.openhab.binding.zwave.internal.protocol.ZWaveTransaction.TransactionPriority; +import org.openhab.binding.zwave.internal.protocol.commandclass.ZWaveCommandClass.CommandClass; +import org.openhab.binding.zwave.internal.protocol.commandclass.ZWaveFirmwareUpdateCommandClass; +import org.openhab.binding.zwave.internal.protocol.commandclass.ZWaveVersionCommandClass; +import org.openhab.binding.zwave.internal.protocol.event.ZWaveNetworkEvent; +import org.openhab.binding.zwave.internal.protocol.event.ZWaveTransactionCompletedEvent; +import org.openhab.binding.zwave.internal.protocol.transaction.ZWaveCommandClassTransactionPayload; + +/** + * Unit tests for for the Firmware Update Session, which is responsible for + * managing the state of a firmware update process for a single node, including + * parsing metadata, building requests, and handling events related to the + * firmware update process. Tests involve the various versions of the Command Class + * as they have changed over time. + * {@link ZWaveFirmwareUpdateSession}. + * + * @author Bob Eckhoff - Initial contribution + */ +@NonNullByDefault +public class ZWaveFirmwareUpdateSessionTest { + + private static class TestableZWaveFirmwareUpdateSession extends ZWaveFirmwareUpdateSession { + private long nowMillis; + private int statusReportWaitTimeoutSeconds = 30; + + public TestableZWaveFirmwareUpdateSession(ZWaveNode node, ZWaveControllerHandler controller, + byte[] firmwareBytes, int firmwareTarget) { + super(node, controller, firmwareBytes, firmwareTarget); + } + + public void setCurrentTimeMillis(long nowMillis) { + this.nowMillis = nowMillis; + } + + public void setStatusReportWaitTimeoutSeconds(int statusReportWaitTimeoutSeconds) { + this.statusReportWaitTimeoutSeconds = statusReportWaitTimeoutSeconds; + } + + @Override + protected long currentTimeMillis() { + return nowMillis; + } + + @Override + protected int getStatusReportWaitTimeoutSeconds() { + return statusReportWaitTimeoutSeconds; + } + } + + private ZWaveFirmwareUpdateSession newSession() { + ZWaveNode node = Mockito.mock(ZWaveNode.class); + ZWaveControllerHandler controller = Mockito.mock(ZWaveControllerHandler.class); + Mockito.when(node.getNodeId()).thenReturn(1); + return new ZWaveFirmwareUpdateSession(node, controller, new byte[] { 0x01, 0x02 }, 0); + } + + private void setState(ZWaveFirmwareUpdateSession session, ZWaveFirmwareUpdateSession.State state) throws Exception { + Method method = ZWaveFirmwareUpdateSession.class.getDeclaredMethod("handleEvent", Object.class); + Method unused = method; // moves the unused warning to here from method. + java.lang.reflect.Field field = ZWaveFirmwareUpdateSession.class.getDeclaredField("state"); + field.setAccessible(true); + field.set(session, state); + } + + private Object parseMetadata(ZWaveFirmwareUpdateSession session, byte[] payload) throws Exception { + Method method = ZWaveFirmwareUpdateSession.class.getDeclaredMethod("parseMetadata", byte[].class); + method.setAccessible(true); + return method.invoke(session, payload); + } + + private byte[] buildMdRequestGet(ZWaveFirmwareUpdateSession session, Object metadata) throws Exception { + Method method = ZWaveFirmwareUpdateSession.class.getDeclaredMethod("buildMdRequestGet", + ZWaveFirmwareUpdateSession.FirmwareMetadata.class); + method.setAccessible(true); + return (byte[]) method.invoke(session, metadata); + } + + private int expectedSessionChecksum() { + return ZWaveFirmwareUpdateCommandClass.crc16Ccitt(new byte[] { 0x01, 0x02 }, 0x1D0F); + } + + @Test + public void testParseMetadataV7PlusMapsRequestFlagsAndReordersForReport3() throws Exception { + ZWaveFirmwareUpdateSession session = newSession(); + + // Version 7+ example with all optional fields present, additional target count of 1, and all request flags set + byte[] payload = new byte[] { 0x12, 0x34, // manufacturer + 0x56, 0x78, // firmware + (byte) 0x9A, (byte) 0xBC, // checksum + 0x01, // upgradable + 0x01, // additional targets + 0x01, (byte) 0xF4, // max fragment size + 0x00, 0x02, // one additional target entry + 0x05, // hardware version + 0x0F // report-2 flags: b3,b2,b1,b0 set + }; + + ZWaveFirmwareUpdateSession.FirmwareMetadata metadata = (ZWaveFirmwareUpdateSession.FirmwareMetadata) parseMetadata( + session, payload); + + assertEquals(0x1234, metadata.manufacturerId()); + assertEquals(0x5678, metadata.firmwareId()); + assertEquals(0x9ABC, metadata.checksum()); + assertEquals(0x01F4, metadata.maxFragmentSize()); + assertEquals(1, metadata.additionalTargets()); + assertTrue(metadata.hardwareVersionPresent()); + assertEquals(0x05, metadata.hardwareVersion()); + assertTrue(metadata.ccFunctionalityPresent()); + assertEquals(0x07, metadata.requestFlags()); + + assertArrayEquals( + new byte[] { 0x12, 0x34, 0x56, 0x78, (byte) 0x9A, (byte) 0xBC, 0x00, 0x01, (byte) 0xF4, 0x07, 0x05 }, + metadata.report3Payload()); + + byte[] requestPayload = buildMdRequestGet(session, metadata); + assertArrayEquals( + new byte[] { 0x12, 0x34, 0x56, 0x78, (byte) ((expectedSessionChecksum() >> 8) & 0xFF), + (byte) (expectedSessionChecksum() & 0xFF), 0x00, 0x01, (byte) 0xF4, 0x07, 0x05 }, + requestPayload); + } + + @Test + public void testParseMetadataV5HasHardwareAndZeroActivationInReport3() throws Exception { + ZWaveFirmwareUpdateSession session = newSession(); + + // Version 5 example with hardware version present. + byte[] payload = new byte[] { 0x02, 0x7A, 0x00, 0x03, 0x00, 0x00, (byte) 0xFF, 0x00, 0x00, 0x28, 0x02 }; + + ZWaveFirmwareUpdateSession.FirmwareMetadata metadata = (ZWaveFirmwareUpdateSession.FirmwareMetadata) parseMetadata( + session, payload); + + assertEquals(0, metadata.requestFlags()); + assertTrue(metadata.hardwareVersionPresent()); + assertEquals(0x02, metadata.hardwareVersion()); + assertFalse(metadata.ccFunctionalityPresent()); + + assertArrayEquals(new byte[] { 0x02, 0x7A, 0x00, 0x03, 0x00, 0x00, 0x00, 0x00, 0x28, 0x00, 0x02 }, + metadata.report3Payload()); + + byte[] requestPayload = buildMdRequestGet(session, metadata); + assertArrayEquals(new byte[] { 0x02, 0x7A, 0x00, 0x03, (byte) ((expectedSessionChecksum() >> 8) & 0xFF), + (byte) (expectedSessionChecksum() & 0xFF), 0x00, 0x00, 0x28, 0x00, 0x02 }, requestPayload); + } + + @Test + public void testParseMetadataV1V2UsesOnlyFirstSixBytesForReport3() throws Exception { + ZWaveFirmwareUpdateSession session = newSession(); + + byte[] payload = new byte[] { 0x01, 0x02, 0x03, 0x04, 0x05, 0x06 }; + + ZWaveFirmwareUpdateSession.FirmwareMetadata metadata = (ZWaveFirmwareUpdateSession.FirmwareMetadata) parseMetadata( + session, payload); + + assertTrue(metadata.upgradable()); + assertEquals(32, metadata.maxFragmentSize()); + assertFalse(metadata.hardwareVersionPresent()); + assertFalse(metadata.ccFunctionalityPresent()); + assertEquals(0, metadata.requestFlags()); + + assertArrayEquals(new byte[] { 0x01, 0x02, 0x03, 0x04, 0x05, 0x06 }, metadata.report3Payload()); + + byte[] requestPayload = buildMdRequestGet(session, metadata); + assertArrayEquals(new byte[] { 0x01, 0x02, 0x03, 0x04, (byte) ((expectedSessionChecksum() >> 8) & 0xFF), + (byte) (expectedSessionChecksum() & 0xFF) }, requestPayload); + } + + @Test + public void testParseMetadataV3BuildsReport3WithTargetAndFragmentOnly() throws Exception { + ZWaveFirmwareUpdateSession session = newSession(); + + // Version 3 example. + byte[] payload = new byte[] { 0x0A, 0x0B, 0x0C, 0x0D, 0x0E, 0x0F, 0x01, 0x00, 0x00, 0x40 }; + + ZWaveFirmwareUpdateSession.FirmwareMetadata metadata = (ZWaveFirmwareUpdateSession.FirmwareMetadata) parseMetadata( + session, payload); + + assertTrue(metadata.upgradable()); + assertFalse(metadata.hardwareVersionPresent()); + assertFalse(metadata.ccFunctionalityPresent()); + assertEquals(0, metadata.requestFlags()); + + assertArrayEquals(new byte[] { 0x0A, 0x0B, 0x0C, 0x0D, 0x0E, 0x0F, 0x00, 0x00, 0x40 }, + metadata.report3Payload()); + + byte[] requestPayload = buildMdRequestGet(session, metadata); + assertArrayEquals(new byte[] { 0x0A, 0x0B, 0x0C, 0x0D, (byte) ((expectedSessionChecksum() >> 8) & 0xFF), + (byte) (expectedSessionChecksum() & 0xFF), 0x00, 0x00, 0x40 }, requestPayload); + } + + @Test + public void testParseMetadataV6UsesSingleFlagsByteForFunctionalityOnly() throws Exception { + ZWaveFirmwareUpdateSession session = newSession(); + + // Version 6 example with functionality flags in report-2 flags byte. + byte[] payload = new byte[] { 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x01, 0x00, 0x00, 0x30, 0x09, 0x01 }; + + ZWaveFirmwareUpdateSession.FirmwareMetadata metadata = (ZWaveFirmwareUpdateSession.FirmwareMetadata) parseMetadata( + session, payload); + + assertTrue(metadata.hardwareVersionPresent()); + assertTrue(metadata.ccFunctionalityPresent()); + assertEquals(0, metadata.requestFlags()); + + assertArrayEquals(new byte[] { 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x00, 0x00, 0x30, 0x00, 0x09 }, + metadata.report3Payload()); + } + + @Test + public void testParseMetadataRejectsInvalidAdditionalTargetLength() throws Exception { + ZWaveFirmwareUpdateSession session = newSession(); + + // Version 4 example with invalid additional target length. + byte[] payload = new byte[] { 0x00, 0x01, 0x00, 0x02, 0x00, 0x03, 0x01, 0x02, 0x00, 0x20, 0x55 }; + + InvocationTargetException ex = assertThrows(InvocationTargetException.class, + () -> parseMetadata(session, payload)); + assertTrue(ex.getCause() instanceof IllegalArgumentException); + assertTrue(ex.getCause().getMessage().contains("additional target data exceeds payload length")); + } + + @Test + public void testHandleMetadataReportMalformedPayloadNotifiesFailureEvent() throws Exception { + ZWaveNode node = Mockito.mock(ZWaveNode.class); + Mockito.when(node.getNodeId()).thenReturn(7); + ZWaveControllerHandler controller = Mockito.mock(ZWaveControllerHandler.class); + ZWaveFirmwareUpdateSession session = new ZWaveFirmwareUpdateSession(node, controller, new byte[] { 0x01 }, 0); + setState(session, ZWaveFirmwareUpdateSession.State.WAITING_FOR_MD_REPORT); + + boolean handled = session.handleEvent( + ZWaveFirmwareUpdateSession.FirmwareUpdateEvent.forMDReport(7, 0, new byte[] { 0x01, 0x02, 0x03 })); + + assertTrue(handled); + assertFalse(session.isActive()); + Mockito.verify(controller) + .ZWaveIncomingEvent(Mockito.argThat(event -> event instanceof ZWaveNetworkEvent + && ((ZWaveNetworkEvent) event).getEvent() == ZWaveNetworkEvent.Type.FirmwareUpdate + && ((ZWaveNetworkEvent) event).getState() == ZWaveNetworkEvent.State.Failure)); + } + + @Test + public void testHandleMetadataReportNonUpgradableNotifiesFailureEvent() throws Exception { + ZWaveNode node = Mockito.mock(ZWaveNode.class); + Mockito.when(node.getNodeId()).thenReturn(8); + ZWaveControllerHandler controller = Mockito.mock(ZWaveControllerHandler.class); + ZWaveFirmwareUpdateSession session = new ZWaveFirmwareUpdateSession(node, controller, new byte[] { 0x01 }, 0); + setState(session, ZWaveFirmwareUpdateSession.State.WAITING_FOR_MD_REPORT); + + byte[] payload = new byte[] { 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x00, 0x00, 0x00, 0x20 }; + + boolean handled = session + .handleEvent(ZWaveFirmwareUpdateSession.FirmwareUpdateEvent.forMDReport(8, 0, payload)); + + assertTrue(handled); + assertFalse(session.isActive()); + Mockito.verify(controller) + .ZWaveIncomingEvent(Mockito.argThat(event -> event instanceof ZWaveNetworkEvent + && ((ZWaveNetworkEvent) event).getEvent() == ZWaveNetworkEvent.Type.FirmwareUpdate + && ((ZWaveNetworkEvent) event).getState() == ZWaveNetworkEvent.State.Failure)); + } + + @Test + public void testFailedMetadataGetPublishesDescriptiveFailureMessage() throws Exception { + ZWaveNode node = Mockito.mock(ZWaveNode.class); + Mockito.when(node.getNodeId()).thenReturn(9); + ZWaveControllerHandler controller = Mockito.mock(ZWaveControllerHandler.class); + ZWaveFirmwareUpdateSession session = new ZWaveFirmwareUpdateSession(node, controller, new byte[] { 0x01 }, 0); + setState(session, ZWaveFirmwareUpdateSession.State.WAITING_FOR_MD_REPORT); + setActive(session, true); + + ZWaveCommandClassTransactionPayload tx = new ZWaveCommandClassTransactionPayload(9, + new byte[] { (byte) CommandClass.COMMAND_CLASS_FIRMWARE_UPDATE_MD.getKey(), + (byte) ZWaveFirmwareUpdateCommandClass.FIRMWARE_MD_GET }, + TransactionPriority.Config, null, null); + + boolean handled = session + .handleEvent(new ZWaveTransactionCompletedEvent(new ZWaveTransaction(tx), null, false)); + + assertTrue(handled); + assertFalse(session.isActive()); + assertEquals(ZWaveFirmwareUpdateSession.State.FAILURE, getState(session)); + Mockito.verify(controller) + .ZWaveIncomingEvent(Mockito.argThat(event -> event instanceof ZWaveNetworkEvent + && ((ZWaveNetworkEvent) event).getEvent() == ZWaveNetworkEvent.Type.FirmwareUpdate + && ((ZWaveNetworkEvent) event).getState() == ZWaveNetworkEvent.State.Failure + && "FIRMWARE_MD_GET failed after all retries".equals(((ZWaveNetworkEvent) event).getValue()))); + } + + private void setActive(ZWaveFirmwareUpdateSession session, boolean active) throws Exception { + Field field = ZWaveFirmwareUpdateSession.class.getDeclaredField("active"); + field.setAccessible(true); + field.set(session, active); + } + + private ZWaveFirmwareUpdateSession.State getState(ZWaveFirmwareUpdateSession session) throws Exception { + Field field = ZWaveFirmwareUpdateSession.class.getDeclaredField("state"); + field.setAccessible(true); + return (ZWaveFirmwareUpdateSession.State) field.get(session); + } + + private void setSessionMetadata(ZWaveFirmwareUpdateSession session, + ZWaveFirmwareUpdateSession.FirmwareMetadata metadata) throws Exception { + java.lang.reflect.Field field = ZWaveFirmwareUpdateSession.class.getDeclaredField("sessionMetadata"); + field.setAccessible(true); + field.set(session, metadata); + } + + private void setFragments(ZWaveFirmwareUpdateSession session, + List fragments) throws Exception { + Field field = ZWaveFirmwareUpdateSession.class.getDeclaredField("fragments"); + field.setAccessible(true); + field.set(session, fragments); + } + + private int getHighestTransmittedReportNumber(ZWaveFirmwareUpdateSession session) throws Exception { + Field field = ZWaveFirmwareUpdateSession.class.getDeclaredField("highestTransmittedReportNumber"); + field.setAccessible(true); + return field.getInt(session); + } + + private int getHighestAckedReportNumber(ZWaveFirmwareUpdateSession session) throws Exception { + Field field = ZWaveFirmwareUpdateSession.class.getDeclaredField("highestAckedReportNumber"); + field.setAccessible(true); + return field.getInt(session); + } + + private void setLastPublishedProgressPercent(ZWaveFirmwareUpdateSession session, int percent) throws Exception { + Field field = ZWaveFirmwareUpdateSession.class.getDeclaredField("lastPublishedProgressPercent"); + field.setAccessible(true); + field.setInt(session, percent); + } + + private void setHighestTransmittedReportNumber(ZWaveFirmwareUpdateSession session, int reportNumber) + throws Exception { + Field field = ZWaveFirmwareUpdateSession.class.getDeclaredField("highestTransmittedReportNumber"); + field.setAccessible(true); + field.setInt(session, reportNumber); + } + + private void setStartReportNumber(ZWaveFirmwareUpdateSession session, int reportNumber) throws Exception { + Field field = ZWaveFirmwareUpdateSession.class.getDeclaredField("startReportNumber"); + field.setAccessible(true); + field.setInt(session, reportNumber); + } + + private void setCount(ZWaveFirmwareUpdateSession session, int count) throws Exception { + Field field = ZWaveFirmwareUpdateSession.class.getDeclaredField("count"); + field.setAccessible(true); + field.setInt(session, count); + } + + private void invokePublishFirmwareUpdateProgressIfNeeded(ZWaveFirmwareUpdateSession session) throws Exception { + Method method = ZWaveFirmwareUpdateSession.class.getDeclaredMethod("publishFirmwareUpdateProgressIfNeeded"); + method.setAccessible(true); + method.invoke(session); + } + + private void waitForSessionToStop(ZWaveFirmwareUpdateSession session, long timeoutMillis) + throws InterruptedException { + long deadline = System.currentTimeMillis() + timeoutMillis; + while (session.isActive() && System.currentTimeMillis() < deadline) { + Thread.sleep(20); + } + } + + @Test + public void testUpdateMdStatusReportOkNoRestartMarksSuccess() throws Exception { + ZWaveNode node = Mockito.mock(ZWaveNode.class); + Mockito.when(node.getNodeId()).thenReturn(11); + ZWaveControllerHandler controller = Mockito.mock(ZWaveControllerHandler.class); + + ZWaveFirmwareUpdateSession session = new ZWaveFirmwareUpdateSession(node, controller, new byte[] { 0x01 }, 0); + setState(session, ZWaveFirmwareUpdateSession.State.WAITING_FOR_UPDATE_MD_STATUS_REPORT); + setActive(session, true); + + boolean handled = session + .handleEvent(ZWaveFirmwareUpdateSession.FirmwareUpdateEvent.forUpdateMdStatusReport(11, 0, 0xFE, 0)); + + assertTrue(handled); + assertFalse(session.isActive()); + assertEquals(ZWaveFirmwareUpdateSession.State.SUCCESS, getState(session)); + Mockito.verify(controller) + .ZWaveIncomingEvent(Mockito.argThat(event -> event instanceof ZWaveNetworkEvent + && ((ZWaveNetworkEvent) event).getEvent() == ZWaveNetworkEvent.Type.FirmwareUpdate + && ((ZWaveNetworkEvent) event).getState() == ZWaveNetworkEvent.State.Success)); + } + + @Test + public void testUpdateMdStatusReportErrorMarksFailure() throws Exception { + ZWaveNode node = Mockito.mock(ZWaveNode.class); + Mockito.when(node.getNodeId()).thenReturn(12); + ZWaveControllerHandler controller = Mockito.mock(ZWaveControllerHandler.class); + + ZWaveFirmwareUpdateSession session = new ZWaveFirmwareUpdateSession(node, controller, new byte[] { 0x01 }, 0); + setState(session, ZWaveFirmwareUpdateSession.State.WAITING_FOR_UPDATE_MD_STATUS_REPORT); + setActive(session, true); + + boolean handled = session + .handleEvent(ZWaveFirmwareUpdateSession.FirmwareUpdateEvent.forUpdateMdStatusReport(12, 0, 0x01, 0)); + + assertTrue(handled); + assertFalse(session.isActive()); + assertEquals(ZWaveFirmwareUpdateSession.State.FAILURE, getState(session)); + Mockito.verify(controller) + .ZWaveIncomingEvent(Mockito.argThat(event -> event instanceof ZWaveNetworkEvent + && ((ZWaveNetworkEvent) event).getEvent() == ZWaveNetworkEvent.Type.FirmwareUpdate + && ((ZWaveNetworkEvent) event).getState() == ZWaveNetworkEvent.State.Failure)); + } + + @Test + public void testUpdateMdStatusReportRestartPendingSchedulesNopPing() throws Exception { + ZWaveNode node = Mockito.mock(ZWaveNode.class); + Mockito.when(node.getNodeId()).thenReturn(13); + ZWaveVersionCommandClass versionCC = Mockito.mock(ZWaveVersionCommandClass.class); + ZWaveControllerHandler controller = Mockito.mock(ZWaveControllerHandler.class); + + ZWaveCommandClassTransactionPayload versionTx = new ZWaveCommandClassTransactionPayload(13, + new byte[] { (byte) 0x86, 0x11 }, TransactionPriority.Config, null, null); + Mockito.when(node.getCommandClass(CommandClass.COMMAND_CLASS_VERSION)).thenReturn(versionCC); + Mockito.when(versionCC.getVersionMessage()).thenReturn(versionTx); + + ZWaveFirmwareUpdateSession session = new ZWaveFirmwareUpdateSession(node, controller, new byte[] { 0x01 }, 0); + setState(session, ZWaveFirmwareUpdateSession.State.WAITING_FOR_UPDATE_MD_STATUS_REPORT); + setActive(session, true); + + boolean handled = session + .handleEvent(ZWaveFirmwareUpdateSession.FirmwareUpdateEvent.forUpdateMdStatusReport(13, 0, 0xFF, 0)); + + assertTrue(handled); + assertFalse(session.isActive()); + assertEquals(ZWaveFirmwareUpdateSession.State.SUCCESS, getState(session)); + Mockito.verify(node, Mockito.after(200).never()).setFirmwareUpdateInProgress(false); + Mockito.verify(node, Mockito.timeout((int) TimeUnit.SECONDS.toMillis(3))).pingNode(); + Mockito.verify(node, Mockito.timeout((int) TimeUnit.SECONDS.toMillis(3))).sendMessage(versionTx); + Mockito.verify(node, Mockito.timeout((int) TimeUnit.SECONDS.toMillis(3))).setFirmwareUpdateInProgress(false); + } + + @Test + public void testUpdateMdStatusReportWaitingForActivationSendsActivationSet() throws Exception { + ZWaveNode node = Mockito.mock(ZWaveNode.class); + Mockito.when(node.getNodeId()).thenReturn(14); + ZWaveControllerHandler controller = Mockito.mock(ZWaveControllerHandler.class); + ZWaveFirmwareUpdateCommandClass fw = Mockito.mock(ZWaveFirmwareUpdateCommandClass.class); + + Mockito.when(node.getCommandClass(CommandClass.COMMAND_CLASS_FIRMWARE_UPDATE_MD)).thenReturn(fw); + Mockito.when(fw.getVersion()).thenReturn(5); + + ZWaveCommandClassTransactionPayload activationTx = new ZWaveCommandClassTransactionPayload(14, + new byte[] { 0x7A }, TransactionPriority.Config, null, null); + Mockito.when(fw.setFirmwareActivation(Mockito.any())).thenReturn(activationTx); + + ZWaveFirmwareUpdateSession session = new ZWaveFirmwareUpdateSession(node, controller, new byte[] { 0x01 }, 0); + setState(session, ZWaveFirmwareUpdateSession.State.WAITING_FOR_UPDATE_MD_STATUS_REPORT); + setActive(session, true); + + ZWaveFirmwareUpdateSession.FirmwareMetadata metadata = new ZWaveFirmwareUpdateSession.FirmwareMetadata(0x1234, + 0x5678, 0x9ABC, true, 64, 0, true, 0x05, false, 0, new byte[0]); + setSessionMetadata(session, metadata); + + boolean handled = session + .handleEvent(ZWaveFirmwareUpdateSession.FirmwareUpdateEvent.forUpdateMdStatusReport(14, 0, 0xFD, 0)); + + assertTrue(handled); + assertEquals(ZWaveFirmwareUpdateSession.State.WAITING_FOR_ACTIVATION_STATUS_REPORT, getState(session)); + + ArgumentCaptor payloadCaptor = ArgumentCaptor.forClass(byte[].class); + Mockito.verify(fw).setFirmwareActivation(payloadCaptor.capture()); + int expectedActivationChecksum = ZWaveFirmwareUpdateCommandClass.crc16Ccitt(new byte[] { 0x01 }, 0x1D0F); + assertArrayEquals(new byte[] { 0x12, 0x34, 0x56, 0x78, (byte) ((expectedActivationChecksum >> 8) & 0xFF), + (byte) (expectedActivationChecksum & 0xFF), 0x00, 0x05 }, payloadCaptor.getValue()); + Mockito.verify(node).sendMessage(activationTx); + } + + @Test + public void testActivationStatusReportSuccessRequires0xFF() throws Exception { + ZWaveNode node = Mockito.mock(ZWaveNode.class); + Mockito.when(node.getNodeId()).thenReturn(15); + ZWaveControllerHandler controller = Mockito.mock(ZWaveControllerHandler.class); + + ZWaveFirmwareUpdateSession session = new ZWaveFirmwareUpdateSession(node, controller, new byte[] { 0x01 }, 0); + setState(session, ZWaveFirmwareUpdateSession.State.WAITING_FOR_ACTIVATION_STATUS_REPORT); + setActive(session, true); + + boolean handled = session + .handleEvent(ZWaveFirmwareUpdateSession.FirmwareUpdateEvent.forActivationStatusReport(15, 0, 0xFF)); + + assertTrue(handled); + assertFalse(session.isActive()); + assertEquals(ZWaveFirmwareUpdateSession.State.SUCCESS, getState(session)); + Mockito.verify(controller) + .ZWaveIncomingEvent(Mockito.argThat(event -> event instanceof ZWaveNetworkEvent + && ((ZWaveNetworkEvent) event).getEvent() == ZWaveNetworkEvent.Type.FirmwareUpdate + && ((ZWaveNetworkEvent) event).getState() == ZWaveNetworkEvent.State.Success)); + } + + @Test + public void testActivationStatusReportErrorCodesFail() throws Exception { + ZWaveNode node = Mockito.mock(ZWaveNode.class); + Mockito.when(node.getNodeId()).thenReturn(16); + ZWaveControllerHandler controller = Mockito.mock(ZWaveControllerHandler.class); + + ZWaveFirmwareUpdateSession session = new ZWaveFirmwareUpdateSession(node, controller, new byte[] { 0x01 }, 0); + setState(session, ZWaveFirmwareUpdateSession.State.WAITING_FOR_ACTIVATION_STATUS_REPORT); + setActive(session, true); + + boolean handled = session + .handleEvent(ZWaveFirmwareUpdateSession.FirmwareUpdateEvent.forActivationStatusReport(16, 0, 0x00)); + + assertTrue(handled); + assertFalse(session.isActive()); + assertEquals(ZWaveFirmwareUpdateSession.State.FAILURE, getState(session)); + Mockito.verify(controller) + .ZWaveIncomingEvent(Mockito.argThat(event -> event instanceof ZWaveNetworkEvent + && ((ZWaveNetworkEvent) event).getEvent() == ZWaveNetworkEvent.Type.FirmwareUpdate + && ((ZWaveNetworkEvent) event).getState() == ZWaveNetworkEvent.State.Failure)); + } + + @Test + public void testDuplicateUpdateMdGetWithinResendWindowIsIgnored() throws Exception { + ZWaveNode node = Mockito.mock(ZWaveNode.class); + Mockito.when(node.getNodeId()).thenReturn(17); + ZWaveControllerHandler controller = Mockito.mock(ZWaveControllerHandler.class); + ZWaveFirmwareUpdateCommandClass fw = Mockito.mock(ZWaveFirmwareUpdateCommandClass.class); + Mockito.when(node.getCommandClass(CommandClass.COMMAND_CLASS_FIRMWARE_UPDATE_MD)).thenReturn(fw); + + ZWaveCommandClassTransactionPayload tx = new ZWaveCommandClassTransactionPayload(17, new byte[] { 0x7A }, + TransactionPriority.Config, null, null); + Mockito.when(fw.sendFirmwareUpdateReport(Mockito.any())).thenReturn(tx); + + TestableZWaveFirmwareUpdateSession session = new TestableZWaveFirmwareUpdateSession(node, controller, + new byte[] { 0x01 }, 0); + setState(session, ZWaveFirmwareUpdateSession.State.WAITING_FOR_UPDATE_MD_GET); + setActive(session, true); + setFragments(session, List.of(new ZWaveFirmwareUpdateSession.FirmwareFragment(1, true, new byte[] { 0x55 }))); + + session.setCurrentTimeMillis(1_000L); + assertTrue(session.handleEvent(ZWaveFirmwareUpdateSession.FirmwareUpdateEvent.forUpdateMdGet(17, 0, 1, 1))); + assertEquals(ZWaveFirmwareUpdateSession.State.WAITING_FOR_UPDATE_MD_STATUS_REPORT, getState(session)); + assertEquals(1, getHighestTransmittedReportNumber(session)); + + session.setCurrentTimeMillis(6_000L); + assertTrue(session.handleEvent(ZWaveFirmwareUpdateSession.FirmwareUpdateEvent.forUpdateMdGet(17, 0, 1, 1))); + + Mockito.verify(node, Mockito.times(1)).sendMessage(tx); + } + + @Test + public void testDuplicateUpdateMdGetAfterResendWindowResendsReport() throws Exception { + ZWaveNode node = Mockito.mock(ZWaveNode.class); + Mockito.when(node.getNodeId()).thenReturn(18); + ZWaveControllerHandler controller = Mockito.mock(ZWaveControllerHandler.class); + ZWaveFirmwareUpdateCommandClass fw = Mockito.mock(ZWaveFirmwareUpdateCommandClass.class); + Mockito.when(node.getCommandClass(CommandClass.COMMAND_CLASS_FIRMWARE_UPDATE_MD)).thenReturn(fw); + + ZWaveCommandClassTransactionPayload tx = new ZWaveCommandClassTransactionPayload(18, new byte[] { 0x7B }, + TransactionPriority.Config, null, null); + Mockito.when(fw.sendFirmwareUpdateReport(Mockito.any())).thenReturn(tx); + + TestableZWaveFirmwareUpdateSession session = new TestableZWaveFirmwareUpdateSession(node, controller, + new byte[] { 0x01 }, 0); + setState(session, ZWaveFirmwareUpdateSession.State.WAITING_FOR_UPDATE_MD_GET); + setActive(session, true); + setFragments(session, List.of(new ZWaveFirmwareUpdateSession.FirmwareFragment(1, true, new byte[] { 0x66 }))); + + session.setCurrentTimeMillis(1_000L); + assertTrue(session.handleEvent(ZWaveFirmwareUpdateSession.FirmwareUpdateEvent.forUpdateMdGet(18, 0, 1, 1))); + assertEquals(ZWaveFirmwareUpdateSession.State.WAITING_FOR_UPDATE_MD_STATUS_REPORT, getState(session)); + assertEquals(1, getHighestTransmittedReportNumber(session)); + + session.setCurrentTimeMillis(TimeUnit.SECONDS.toMillis(25)); + assertTrue(session.handleEvent(ZWaveFirmwareUpdateSession.FirmwareUpdateEvent.forUpdateMdGet(18, 0, 1, 1))); + + Mockito.verify(node, Mockito.times(2)).sendMessage(tx); + assertEquals(ZWaveFirmwareUpdateSession.State.WAITING_FOR_UPDATE_MD_STATUS_REPORT, getState(session)); + } + + @Test + public void testLateRetryGetDoesNotRewindHighestTransmittedProgressBaseline() throws Exception { + ZWaveNode node = Mockito.mock(ZWaveNode.class); + Mockito.when(node.getNodeId()).thenReturn(26); + ZWaveControllerHandler controller = Mockito.mock(ZWaveControllerHandler.class); + ZWaveFirmwareUpdateCommandClass fw = Mockito.mock(ZWaveFirmwareUpdateCommandClass.class); + Mockito.when(node.getCommandClass(CommandClass.COMMAND_CLASS_FIRMWARE_UPDATE_MD)).thenReturn(fw); + + ZWaveCommandClassTransactionPayload tx = new ZWaveCommandClassTransactionPayload(26, new byte[] { 0x7D }, + TransactionPriority.Config, null, null); + Mockito.when(fw.sendFirmwareUpdateReport(Mockito.any())).thenReturn(tx); + + TestableZWaveFirmwareUpdateSession session = new TestableZWaveFirmwareUpdateSession(node, controller, + new byte[] { 0x01 }, 0); + setState(session, ZWaveFirmwareUpdateSession.State.WAITING_FOR_UPDATE_MD_GET); + setActive(session, true); + setFragments(session, + java.util.stream.IntStream.rangeClosed(1, 3000) + .mapToObj(i -> new ZWaveFirmwareUpdateSession.FirmwareFragment(i, false, new byte[] { 0x01 })) + .toList()); + + setHighestTransmittedReportNumber(session, 2689); + setLastPublishedProgressPercent(session, 85); + + session.setCurrentTimeMillis(TimeUnit.SECONDS.toMillis(30)); + assertTrue(session.handleEvent(ZWaveFirmwareUpdateSession.FirmwareUpdateEvent.forUpdateMdGet(26, 0, 2215, 1))); + + assertEquals(2689, getHighestTransmittedReportNumber(session)); + assertEquals(2214, getHighestAckedReportNumber(session)); + } + + @Test + public void testOutOfSequenceForwardGetIsIgnored() throws Exception { + ZWaveNode node = Mockito.mock(ZWaveNode.class); + Mockito.when(node.getNodeId()).thenReturn(27); + ZWaveControllerHandler controller = Mockito.mock(ZWaveControllerHandler.class); + ZWaveFirmwareUpdateCommandClass fw = Mockito.mock(ZWaveFirmwareUpdateCommandClass.class); + Mockito.when(node.getCommandClass(CommandClass.COMMAND_CLASS_FIRMWARE_UPDATE_MD)).thenReturn(fw); + + ZWaveCommandClassTransactionPayload tx = new ZWaveCommandClassTransactionPayload(27, new byte[] { 0x7E }, + TransactionPriority.Config, null, null); + Mockito.when(fw.sendFirmwareUpdateReport(Mockito.any())).thenReturn(tx); + + TestableZWaveFirmwareUpdateSession session = new TestableZWaveFirmwareUpdateSession(node, controller, + new byte[] { 0x01 }, 0); + setState(session, ZWaveFirmwareUpdateSession.State.WAITING_FOR_UPDATE_MD_GET); + setActive(session, true); + setFragments(session, + java.util.stream.IntStream.rangeClosed(1, 3000) + .mapToObj( + i -> new ZWaveFirmwareUpdateSession.FirmwareFragment(i, i == 3000, new byte[] { 0x01 })) + .toList()); + + assertTrue(session.handleEvent(ZWaveFirmwareUpdateSession.FirmwareUpdateEvent.forUpdateMdGet(27, 0, 1, 1))); + assertEquals(1, getHighestTransmittedReportNumber(session)); + + assertTrue(session.handleEvent(ZWaveFirmwareUpdateSession.FirmwareUpdateEvent.forUpdateMdGet(27, 0, 2561, 4))); + + Mockito.verify(node, Mockito.times(1)).sendMessage(tx); + assertEquals(1, getHighestTransmittedReportNumber(session)); + assertEquals(ZWaveFirmwareUpdateSession.State.SENDING_FRAGMENTS, getState(session)); + } + + @Test + public void testMissingStatusAfterLastFragmentTimesOutToFailure() throws Exception { + ZWaveNode node = Mockito.mock(ZWaveNode.class); + Mockito.when(node.getNodeId()).thenReturn(21); + ZWaveControllerHandler controller = Mockito.mock(ZWaveControllerHandler.class); + ZWaveFirmwareUpdateCommandClass fw = Mockito.mock(ZWaveFirmwareUpdateCommandClass.class); + Mockito.when(node.getCommandClass(CommandClass.COMMAND_CLASS_FIRMWARE_UPDATE_MD)).thenReturn(fw); + + ZWaveCommandClassTransactionPayload tx = new ZWaveCommandClassTransactionPayload(21, new byte[] { 0x7C }, + TransactionPriority.Config, null, null); + Mockito.when(fw.sendFirmwareUpdateReport(Mockito.any())).thenReturn(tx); + + TestableZWaveFirmwareUpdateSession session = new TestableZWaveFirmwareUpdateSession(node, controller, + new byte[] { 0x01 }, 0); + session.setStatusReportWaitTimeoutSeconds(1); + setState(session, ZWaveFirmwareUpdateSession.State.WAITING_FOR_UPDATE_MD_GET); + setActive(session, true); + setFragments(session, List.of(new ZWaveFirmwareUpdateSession.FirmwareFragment(1, true, new byte[] { 0x77 }))); + + assertTrue(session.handleEvent(ZWaveFirmwareUpdateSession.FirmwareUpdateEvent.forUpdateMdGet(21, 0, 1, 1))); + assertEquals(ZWaveFirmwareUpdateSession.State.WAITING_FOR_UPDATE_MD_STATUS_REPORT, getState(session)); + + waitForSessionToStop(session, TimeUnit.SECONDS.toMillis(3)); + + assertFalse(session.isActive()); + assertEquals(ZWaveFirmwareUpdateSession.State.FAILURE, getState(session)); + Mockito.verify(node, Mockito.timeout((int) TimeUnit.SECONDS.toMillis(2))).setFirmwareUpdateInProgress(false); + Mockito.verify(controller, Mockito.timeout((int) TimeUnit.SECONDS.toMillis(2))) + .ZWaveIncomingEvent(Mockito.argThat(event -> event instanceof ZWaveNetworkEvent + && ((ZWaveNetworkEvent) event).getEvent() == ZWaveNetworkEvent.Type.FirmwareUpdate + && ((ZWaveNetworkEvent) event).getState() == ZWaveNetworkEvent.State.Failure)); + } + + @Test + public void testProgressEventsUseFivePercentSteps() throws Exception { + ZWaveNode node = Mockito.mock(ZWaveNode.class); + Mockito.when(node.getNodeId()).thenReturn(22); + ZWaveControllerHandler controller = Mockito.mock(ZWaveControllerHandler.class); + List progressValues = new ArrayList<>(); + + Mockito.doAnswer(invocation -> { + Object event = invocation.getArgument(0); + if (event instanceof ZWaveNetworkEvent networkEvent + && networkEvent.getEvent() == ZWaveNetworkEvent.Type.FirmwareUpdate + && networkEvent.getState() == ZWaveNetworkEvent.State.Progress + && networkEvent.getValue() instanceof Number number) { + progressValues.add(number.intValue()); + } + return null; + }).when(controller).ZWaveIncomingEvent(Mockito.any()); + + ZWaveFirmwareUpdateSession session = new ZWaveFirmwareUpdateSession(node, controller, new byte[] { 0x01 }, 0); + setFragments(session, + java.util.stream.IntStream.rangeClosed(1, 39) + .mapToObj(i -> new ZWaveFirmwareUpdateSession.FirmwareFragment(i, false, new byte[] { 0x01 })) + .toList()); + + setHighestTransmittedReportNumber(session, 27); + invokePublishFirmwareUpdateProgressIfNeeded(session); + + setHighestTransmittedReportNumber(session, 29); + invokePublishFirmwareUpdateProgressIfNeeded(session); + + setHighestTransmittedReportNumber(session, 31); + invokePublishFirmwareUpdateProgressIfNeeded(session); + + assertEquals(List.of(Integer.valueOf(65), Integer.valueOf(70), Integer.valueOf(75)), progressValues); + } + + @Test + public void testImplicitAckWhenHigherFragmentRequested() throws Exception { + // Scenario: far-away node with poor radio conditions + // We're retrying fragment 2086, but device sends GET for 2087 (higher). + // This means device received 2086, so we should move to 2087, not resend 2086. + + ZWaveNode node = Mockito.mock(ZWaveNode.class); + Mockito.when(node.getNodeId()).thenReturn(19); + ZWaveControllerHandler controller = Mockito.mock(ZWaveControllerHandler.class); + ZWaveFirmwareUpdateCommandClass fw = Mockito.mock(ZWaveFirmwareUpdateCommandClass.class); + Mockito.when(node.getCommandClass(CommandClass.COMMAND_CLASS_FIRMWARE_UPDATE_MD)).thenReturn(fw); + + ZWaveCommandClassTransactionPayload tx = new ZWaveCommandClassTransactionPayload(19, new byte[] { 0x7A }, + TransactionPriority.Config, null, null); + Mockito.when(fw.sendFirmwareUpdateReport(Mockito.any())).thenReturn(tx); + + // Create fragments 2085, 2086, 2087 + List frags = java.util.stream.IntStream.rangeClosed(1, 2087) + .mapToObj(i -> new ZWaveFirmwareUpdateSession.FirmwareFragment(i, i == 2087, new byte[] { 0x01 })) + .toList(); + + TestableZWaveFirmwareUpdateSession session = new TestableZWaveFirmwareUpdateSession(node, controller, + new byte[] { 0x01 }, 0); + setState(session, ZWaveFirmwareUpdateSession.State.WAITING_FOR_UPDATE_MD_GET); + setActive(session, true); + setFragments(session, frags); + + // Simulate that we're retrying fragment 2086 (startReportNumber = 2086, count = 1) + setStartReportNumber(session, 2086); + setCount(session, 1); + setHighestTransmittedReportNumber(session, 2087); // We already sent up to 2087 + + // Now a GET arrives for fragment 2087 (higher than the 2086 we're retrying) + // This is implicit ACK that 2086 made it, so we should send 2087, not resend 2086 + ArgumentCaptor captor = ArgumentCaptor + .forClass(ZWaveFirmwareUpdateCommandClass.FirmwareFragment.class); + + assertTrue(session.handleEvent(ZWaveFirmwareUpdateSession.FirmwareUpdateEvent.forUpdateMdGet(19, 0, 2087, 1))); + + // Verify that sendFirmwareUpdateReport was called with fragment 2087, not a resend of 2086 + Mockito.verify(fw, Mockito.atLeastOnce()).sendFirmwareUpdateReport(captor.capture()); + // The last call should be for fragment 2087 (status=WAITING_FOR_UPDATE_MD_STATUS_REPORT for last fragment) + ZWaveFirmwareUpdateCommandClass.FirmwareFragment lastCall = captor.getValue(); + assertEquals(2087, lastCall.reportNumber(), "Should send fragment 2087, not resend 2086"); + assertTrue(lastCall.isLast(), "Fragment 2087 should be marked as last"); + } + + @Test + public void testLowerGetIgnoredAfterAckAnchorAdvancesOnHigherGet() throws Exception { + ZWaveNode node = Mockito.mock(ZWaveNode.class); + Mockito.when(node.getNodeId()).thenReturn(28); + ZWaveControllerHandler controller = Mockito.mock(ZWaveControllerHandler.class); + ZWaveFirmwareUpdateCommandClass fw = Mockito.mock(ZWaveFirmwareUpdateCommandClass.class); + Mockito.when(node.getCommandClass(CommandClass.COMMAND_CLASS_FIRMWARE_UPDATE_MD)).thenReturn(fw); + + ZWaveCommandClassTransactionPayload tx = new ZWaveCommandClassTransactionPayload(28, new byte[] { 0x7A }, + TransactionPriority.Config, null, null); + Mockito.when(fw.sendFirmwareUpdateReport(Mockito.any())).thenReturn(tx); + + TestableZWaveFirmwareUpdateSession session = new TestableZWaveFirmwareUpdateSession(node, controller, + new byte[] { 0x01 }, 0); + setState(session, ZWaveFirmwareUpdateSession.State.WAITING_FOR_UPDATE_MD_GET); + setActive(session, true); + setFragments(session, + java.util.stream.IntStream.rangeClosed(1, 3000) + .mapToObj( + i -> new ZWaveFirmwareUpdateSession.FirmwareFragment(i, i == 3000, new byte[] { 0x01 })) + .toList()); + setStartReportNumber(session, 2867); + setCount(session, 1); + setHighestTransmittedReportNumber(session, 2867); + + session.setCurrentTimeMillis(TimeUnit.SECONDS.toMillis(30)); + assertTrue(session.handleEvent(ZWaveFirmwareUpdateSession.FirmwareUpdateEvent.forUpdateMdGet(28, 0, 2868, 1))); + assertEquals(2867, getHighestAckedReportNumber(session)); + + session.setCurrentTimeMillis(TimeUnit.SECONDS.toMillis(60)); + assertTrue(session.handleEvent(ZWaveFirmwareUpdateSession.FirmwareUpdateEvent.forUpdateMdGet(28, 0, 2867, 1))); + + Mockito.verify(fw, Mockito.times(1)).sendFirmwareUpdateReport(Mockito.any()); + } + + @Test + public void testMultiCountGetAdvancesAnchorToStartMinusOneOnly() throws Exception { + ZWaveNode node = Mockito.mock(ZWaveNode.class); + Mockito.when(node.getNodeId()).thenReturn(29); + ZWaveControllerHandler controller = Mockito.mock(ZWaveControllerHandler.class); + ZWaveFirmwareUpdateCommandClass fw = Mockito.mock(ZWaveFirmwareUpdateCommandClass.class); + Mockito.when(node.getCommandClass(CommandClass.COMMAND_CLASS_FIRMWARE_UPDATE_MD)).thenReturn(fw); + + ZWaveCommandClassTransactionPayload tx = new ZWaveCommandClassTransactionPayload(29, new byte[] { 0x7A }, + TransactionPriority.Config, null, null); + Mockito.when(fw.sendFirmwareUpdateReport(Mockito.any())).thenReturn(tx); + + TestableZWaveFirmwareUpdateSession session = new TestableZWaveFirmwareUpdateSession(node, controller, + new byte[] { 0x01 }, 0); + setState(session, ZWaveFirmwareUpdateSession.State.WAITING_FOR_UPDATE_MD_GET); + setActive(session, true); + setFragments(session, + java.util.stream.IntStream.rangeClosed(1, 3000) + .mapToObj( + i -> new ZWaveFirmwareUpdateSession.FirmwareFragment(i, i == 3000, new byte[] { 0x01 })) + .toList()); + setStartReportNumber(session, 2867); + setCount(session, 1); + setHighestTransmittedReportNumber(session, 2869); + + session.setCurrentTimeMillis(TimeUnit.SECONDS.toMillis(30)); + assertTrue(session.handleEvent(ZWaveFirmwareUpdateSession.FirmwareUpdateEvent.forUpdateMdGet(29, 0, 2868, 4))); + assertEquals(2867, getHighestAckedReportNumber(session)); + + session.setCurrentTimeMillis(TimeUnit.SECONDS.toMillis(60)); + assertTrue(session.handleEvent(ZWaveFirmwareUpdateSession.FirmwareUpdateEvent.forUpdateMdGet(29, 0, 2867, 1))); + assertTrue(session.handleEvent(ZWaveFirmwareUpdateSession.FirmwareUpdateEvent.forUpdateMdGet(29, 0, 2869, 1))); + + // First send is from start=2868,count=4 and includes 2868/2869/2870/2871. + // The GET for 2867 is ignored due to the ACK anchor. GET 2869 is allowed and re-sent after window. + Mockito.verify(fw, Mockito.times(5)).sendFirmwareUpdateReport(Mockito.any()); + } +} diff --git a/src/test/java/org/openhab/binding/zwave/handler/ZWaveThingHandlerTest.java b/src/test/java/org/openhab/binding/zwave/handler/ZWaveThingHandlerTest.java index bb775ebf8..d340f2389 100644 --- a/src/test/java/org/openhab/binding/zwave/handler/ZWaveThingHandlerTest.java +++ b/src/test/java/org/openhab/binding/zwave/handler/ZWaveThingHandlerTest.java @@ -19,22 +19,36 @@ import java.util.ArrayList; import java.util.Arrays; import java.util.Collection; +import java.util.Collections; import java.util.HashMap; import java.util.List; import java.util.Map; +import java.util.stream.Stream; import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; import org.mockito.ArgumentCaptor; import org.mockito.ArgumentMatchers; import org.mockito.Mockito; import org.openhab.binding.zwave.ZWaveBindingConstants; +import org.openhab.binding.zwave.firmwareupdate.ZWaveFirmwareUpdateSession; import org.openhab.binding.zwave.internal.protocol.ZWaveAssociationGroup; import org.openhab.binding.zwave.internal.protocol.ZWaveController; +import org.openhab.binding.zwave.internal.protocol.ZWaveDeviceClass; +import org.openhab.binding.zwave.internal.protocol.ZWaveDeviceClass.Basic; +import org.openhab.binding.zwave.internal.protocol.ZWaveDeviceClass.Generic; +import org.openhab.binding.zwave.internal.protocol.ZWaveDeviceClass.Specific; import org.openhab.binding.zwave.internal.protocol.ZWaveNode; +import org.openhab.binding.zwave.internal.protocol.ZWaveNodeState; import org.openhab.binding.zwave.internal.protocol.commandclass.ZWaveAssociationCommandClass; import org.openhab.binding.zwave.internal.protocol.commandclass.ZWaveCommandClass.CommandClass; import org.openhab.binding.zwave.internal.protocol.commandclass.ZWaveNodeNamingCommandClass; import org.openhab.binding.zwave.internal.protocol.commandclass.ZWaveWakeUpCommandClass; +import org.openhab.binding.zwave.internal.protocol.event.ZWaveCommandClassValueEvent; +import org.openhab.binding.zwave.internal.protocol.event.ZWaveNetworkEvent; +import org.openhab.binding.zwave.internal.protocol.event.ZWaveNodeStatusEvent; import org.openhab.binding.zwave.internal.protocol.transaction.ZWaveCommandClassTransactionPayload; import org.openhab.core.config.core.Configuration; import org.openhab.core.config.core.status.ConfigStatusMessage; @@ -43,9 +57,13 @@ import org.openhab.core.library.types.StopMoveType; import org.openhab.core.thing.ChannelUID; import org.openhab.core.thing.Thing; +import org.openhab.core.thing.ThingStatus; +import org.openhab.core.thing.ThingStatusDetail; +import org.openhab.core.thing.ThingStatusInfo; import org.openhab.core.thing.ThingUID; import org.openhab.core.thing.binding.ThingHandlerCallback; import org.openhab.core.thing.binding.builder.ThingBuilder; +import org.openhab.core.thing.binding.firmware.ProgressCallback; import org.openhab.core.thing.type.ThingType; import org.openhab.core.thing.type.ThingTypeBuilder; import org.openhab.core.types.Command; @@ -54,6 +72,7 @@ * Test of the ZWaveThingHandler * * @author Chris Jackson - Initial contribution + * @author Robert Eckhoff - Firmware update tests * */ public class ZWaveThingHandlerTest { @@ -71,6 +90,49 @@ protected void validateConfigurationParameters(Map configuration } } + class ZWaveThingHandlerStatusCaptureTest extends ZWaveThingHandler { + private ThingStatusInfo statusInfo = new ThingStatusInfo(ThingStatus.UNINITIALIZED, ThingStatusDetail.NONE, + null); + + public ZWaveThingHandlerStatusCaptureTest(Thing zwaveDevice) { + super(zwaveDevice); + } + + @Override + protected void validateConfigurationParameters(Map configurationParameters) { + } + + @Override + protected void updateStatus(ThingStatus status, ThingStatusDetail statusDetail, String description) { + statusInfo = new ThingStatusInfo(status, statusDetail, description); + } + + public ThingStatusInfo getCapturedStatusInfo() { + return statusInfo; + } + } + + class ZWaveThingHandlerPropertiesCaptureTest extends ZWaveThingHandler { + private Map properties = Map.of(); + + public ZWaveThingHandlerPropertiesCaptureTest(Thing zwaveDevice) { + super(zwaveDevice); + } + + @Override + protected void validateConfigurationParameters(Map configurationParameters) { + } + + @Override + protected void updateProperties(Map properties) { + this.properties = new HashMap<>(properties); + } + + public Map getCapturedProperties() { + return properties; + } + } + private ZWaveThingHandler doConfigurationUpdate(String param, Object value) { ThingType thingType = ThingTypeBuilder.instance("bindingId", "thingTypeId", "label").build(); @@ -111,11 +173,11 @@ private ZWaveThingHandler doConfigurationUpdate(String param, Object value) { Mockito.when(node.getCommandClass(ArgumentMatchers.eq(CommandClass.COMMAND_CLASS_NODE_NAMING))) .thenReturn(namingClass); } catch (NoSuchFieldException | SecurityException e) { - e.printStackTrace(); + fail(e); } catch (IllegalArgumentException e) { - e.printStackTrace(); + fail(e); } catch (IllegalAccessException e) { - e.printStackTrace(); + fail(e); } Map config = new HashMap(); @@ -138,6 +200,36 @@ private List doConfigurationUpdateCommands( return payloadCaptor.getAllValues(); } + private void setNodeId(ZWaveThingHandler thingHandler, int nodeId) { + try { + Field field = ZWaveThingHandler.class.getDeclaredField("nodeId"); + field.setAccessible(true); + field.setInt(thingHandler, nodeId); + } catch (NoSuchFieldException | IllegalAccessException e) { + fail(e); + } + } + + private void setFirmwareProgressCallback(ZWaveThingHandler thingHandler, ProgressCallback progressCallback) { + try { + Field field = ZWaveThingHandler.class.getDeclaredField("firmwareProgressCallback"); + field.setAccessible(true); + field.set(thingHandler, progressCallback); + } catch (NoSuchFieldException | IllegalAccessException e) { + fail(e); + } + } + + private void setFirmwareSession(ZWaveThingHandler thingHandler, ZWaveFirmwareUpdateSession firmwareSession) { + try { + Field field = ZWaveThingHandler.class.getDeclaredField("firmwareSession"); + field.setAccessible(true); + field.set(thingHandler, firmwareSession); + } catch (NoSuchFieldException | IllegalAccessException e) { + fail(e); + } + } + @Test public void testConfigurationLocation() { ZWaveCommandClassTransactionPayload msg; @@ -316,4 +408,159 @@ public void getZWaveProperties() { assertTrue(properties.containsKey("arg4")); assertNull(properties.get("arg4")); } + + static Stream firmwareFailureCases() { + return Stream.of(Arguments.of(Integer.valueOf(1), "Firmware update failed (status 1)", "status 1"), + Arguments.of("ERROR_TRANSMISSION_FAILED", "Firmware update failed: ERROR_TRANSMISSION_FAILED", + "ERROR_TRANSMISSION_FAILED")); + } + + @ParameterizedTest + @MethodSource("firmwareFailureCases") + public void testFirmwareUpdateFailureSetsConfigurationErrorStatusAndReportsCallback(Object failureValue, + String expectedDescription, String expectedCallbackDetail) { + ThingType thingType = ThingTypeBuilder.instance("bindingId", "thingTypeId", "label").build(); + Thing thing = ThingBuilder.create(thingType.getUID(), new ThingUID(thingType.getUID(), "thingId")) + .withConfiguration(new Configuration()).build(); + + ZWaveThingHandlerStatusCaptureTest handler = new ZWaveThingHandlerStatusCaptureTest(thing); + ProgressCallback progressCallback = Mockito.mock(ProgressCallback.class); + setNodeId(handler, 12); + setFirmwareProgressCallback(handler, progressCallback); + + handler.ZWaveIncomingEvent(new ZWaveNetworkEvent(ZWaveNetworkEvent.Type.FirmwareUpdate, 12, + ZWaveNetworkEvent.State.Failure, failureValue)); + + ThingStatusInfo statusInfo = handler.getCapturedStatusInfo(); + assertEquals(ThingStatus.ONLINE, statusInfo.getStatus()); + assertEquals(ThingStatusDetail.CONFIGURATION_ERROR, statusInfo.getStatusDetail()); + assertEquals(expectedDescription, statusInfo.getDescription()); + Mockito.verify(progressCallback).failed("actions.firmware-update.error", expectedCallbackDetail); + } + + @Test + public void testFirmwareUpdateFailureIgnoresCallbackRuntimeException() { + ThingType thingType = ThingTypeBuilder.instance("bindingId", "thingTypeId", "label").build(); + Thing thing = ThingBuilder.create(thingType.getUID(), new ThingUID(thingType.getUID(), "thingId")) + .withConfiguration(new Configuration()).build(); + + ZWaveThingHandlerStatusCaptureTest handler = new ZWaveThingHandlerStatusCaptureTest(thing); + ProgressCallback progressCallback = Mockito.mock(ProgressCallback.class); + Mockito.doThrow(new IllegalStateException("Timer already cancelled.")).when(progressCallback) + .failed(Mockito.anyString(), Mockito.anyString()); + + setNodeId(handler, 12); + setFirmwareProgressCallback(handler, progressCallback); + + assertDoesNotThrow(() -> handler.ZWaveIncomingEvent(new ZWaveNetworkEvent(ZWaveNetworkEvent.Type.FirmwareUpdate, + 12, ZWaveNetworkEvent.State.Failure, "ERROR_TRANSMISSION_FAILED"))); + + ThingStatusInfo statusInfo = handler.getCapturedStatusInfo(); + assertEquals(ThingStatus.ONLINE, statusInfo.getStatus()); + assertEquals(ThingStatusDetail.CONFIGURATION_ERROR, statusInfo.getStatusDetail()); + assertEquals("Firmware update failed: ERROR_TRANSMISSION_FAILED", statusInfo.getDescription()); + } + + @Test + public void testFirmwareUpdateProgressRestoredAfterCommunicationDrop() { + ThingType thingType = ThingTypeBuilder.instance("bindingId", "thingTypeId", "label").build(); + Thing thing = ThingBuilder.create(thingType.getUID(), new ThingUID(thingType.getUID(), "thingId")) + .withConfiguration(new Configuration()).build(); + + ZWaveThingHandlerStatusCaptureTest handler = new ZWaveThingHandlerStatusCaptureTest(thing); + ZWaveFirmwareUpdateSession firmwareSession = Mockito.mock(ZWaveFirmwareUpdateSession.class); + Mockito.when(firmwareSession.isActive()).thenReturn(true); + Mockito.when(firmwareSession.getCurrentTransferProgressPercent()).thenReturn(79); + + setNodeId(handler, 12); + setFirmwareSession(handler, firmwareSession); + + handler.ZWaveIncomingEvent( + new ZWaveNetworkEvent(ZWaveNetworkEvent.Type.FirmwareUpdate, 12, ZWaveNetworkEvent.State.Progress, 75)); + assertEquals("Firmware update in progress (75%)", handler.getCapturedStatusInfo().getDescription()); + + handler.ZWaveIncomingEvent(new ZWaveNodeStatusEvent(12, ZWaveNodeState.DEAD)); + assertEquals(ThingStatus.OFFLINE, handler.getCapturedStatusInfo().getStatus()); + assertEquals(ThingStatusDetail.COMMUNICATION_ERROR, handler.getCapturedStatusInfo().getStatusDetail()); + + handler.ZWaveIncomingEvent(new ZWaveNodeStatusEvent(12, ZWaveNodeState.ALIVE)); + assertEquals(ThingStatus.ONLINE, handler.getCapturedStatusInfo().getStatus()); + assertEquals(ThingStatusDetail.CONFIGURATION_PENDING, handler.getCapturedStatusInfo().getStatusDetail()); + assertEquals("Firmware update in progress (79%)", handler.getCapturedStatusInfo().getDescription()); + } + + @Test + public void testFirmwareUpdateProgressIgnoredWhenSessionInactive() { + ThingType thingType = ThingTypeBuilder.instance("bindingId", "thingTypeId", "label").build(); + Thing thing = ThingBuilder.create(thingType.getUID(), new ThingUID(thingType.getUID(), "thingId")) + .withConfiguration(new Configuration()).build(); + + ZWaveThingHandlerStatusCaptureTest handler = new ZWaveThingHandlerStatusCaptureTest(thing); + ZWaveFirmwareUpdateSession firmwareSession = Mockito.mock(ZWaveFirmwareUpdateSession.class); + Mockito.when(firmwareSession.isActive()).thenReturn(false); + + setNodeId(handler, 12); + setFirmwareSession(handler, firmwareSession); + + handler.ZWaveIncomingEvent(new ZWaveNetworkEvent(ZWaveNetworkEvent.Type.FirmwareUpdate, 12, + ZWaveNetworkEvent.State.Failure, "ERROR_TRANSMISSION_FAILED")); + + ThingStatusInfo failedStatus = handler.getCapturedStatusInfo(); + assertEquals(ThingStatusDetail.CONFIGURATION_ERROR, failedStatus.getStatusDetail()); + assertEquals("Firmware update failed: ERROR_TRANSMISSION_FAILED", failedStatus.getDescription()); + + handler.ZWaveIncomingEvent( + new ZWaveNetworkEvent(ZWaveNetworkEvent.Type.FirmwareUpdate, 12, ZWaveNetworkEvent.State.Progress, 50)); + + ThingStatusInfo statusAfterProgress = handler.getCapturedStatusInfo(); + assertEquals(ThingStatusDetail.CONFIGURATION_ERROR, statusAfterProgress.getStatusDetail()); + assertEquals("Firmware update failed: ERROR_TRANSMISSION_FAILED", statusAfterProgress.getDescription()); + } + + @Test + public void testVersionValueEventRefreshesFirmwareProperties() { + ThingType thingType = ThingTypeBuilder.instance("bindingId", "thingTypeId", "label").build(); + Thing thing = ThingBuilder.create(thingType.getUID(), new ThingUID(thingType.getUID(), "thingId")) + .withConfiguration(new Configuration()).build(); + + ZWaveThingHandlerPropertiesCaptureTest handler = new ZWaveThingHandlerPropertiesCaptureTest(thing); + ZWaveControllerHandler controllerHandler = Mockito.mock(ZWaveControllerHandler.class); + ZWaveNode node = Mockito.mock(ZWaveNode.class); + + setNodeId(handler, 12); + + try { + Field fieldControllerHandler = ZWaveThingHandler.class.getDeclaredField("controllerHandler"); + fieldControllerHandler.setAccessible(true); + fieldControllerHandler.set(handler, controllerHandler); + } catch (NoSuchFieldException | IllegalAccessException e) { + fail(e); + } + + Mockito.when(controllerHandler.getNode(12)).thenReturn(node); + Mockito.when(node.getManufacturer()).thenReturn(Integer.MAX_VALUE); + Mockito.when(node.getDeviceType()).thenReturn(Integer.MAX_VALUE); + Mockito.when(node.getDeviceId()).thenReturn(Integer.MAX_VALUE); + Mockito.when(node.getApplicationVersion()).thenReturn("9.8"); + Mockito.when(node.getDeviceClass()).thenReturn(new ZWaveDeviceClass(Basic.BASIC_TYPE_UNKNOWN, + Generic.GENERIC_TYPE_NOT_USED, Specific.SPECIFIC_TYPE_NOT_USED)); + Mockito.when(node.isListening()).thenReturn(false); + Mockito.when(node.isFrequentlyListening()).thenReturn(false); + Mockito.when(node.isBeaming()).thenReturn(false); + Mockito.when(node.isRouting()).thenReturn(false); + Mockito.when(node.isSecure()).thenReturn(false); + Mockito.when(node.getAssociationGroups()).thenReturn(Collections.emptyMap()); + Mockito.when(node.getCommandClass(CommandClass.COMMAND_CLASS_ZWAVEPLUS_INFO)).thenReturn(null); + Mockito.when(node.getCommandClass(CommandClass.COMMAND_CLASS_CONFIGURATION)).thenReturn(null); + Mockito.when(node.getCommandClass(CommandClass.COMMAND_CLASS_WAKE_UP)).thenReturn(null); + Mockito.when(node.getCommandClass(CommandClass.COMMAND_CLASS_SWITCH_ALL)).thenReturn(null); + Mockito.when(node.getCommandClass(CommandClass.COMMAND_CLASS_NODE_NAMING)).thenReturn(null); + + handler.ZWaveIncomingEvent( + new ZWaveCommandClassValueEvent(12, 0, CommandClass.COMMAND_CLASS_VERSION, "9.8")); + + Map properties = handler.getCapturedProperties(); + assertEquals("9.8", properties.get(ZWaveBindingConstants.PROPERTY_VERSION)); + assertEquals("9.8", properties.get(Thing.PROPERTY_FIRMWARE_VERSION)); + } } diff --git a/src/test/java/org/openhab/binding/zwave/internal/protocol/commandclass/ZWaveFirmwareUpdateCommandClassTest.java b/src/test/java/org/openhab/binding/zwave/internal/protocol/commandclass/ZWaveFirmwareUpdateCommandClassTest.java new file mode 100644 index 000000000..b9bab711e --- /dev/null +++ b/src/test/java/org/openhab/binding/zwave/internal/protocol/commandclass/ZWaveFirmwareUpdateCommandClassTest.java @@ -0,0 +1,156 @@ +/* + * Copyright (c) 2010-2025 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.zwave.internal.protocol.commandclass; + +import static org.junit.jupiter.api.Assertions.*; +import static org.openhab.binding.zwave.internal.protocol.commandclass.ZWaveFirmwareUpdateCommandClass.crc16Ccitt; + +import java.nio.ByteBuffer; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.mockito.Mockito; +import org.openhab.binding.zwave.firmwareupdate.ZWaveFirmwareUpdateSession.FirmwareUpdateEvent; +import org.openhab.binding.zwave.internal.protocol.ZWaveCommandClassPayload; +import org.openhab.binding.zwave.internal.protocol.ZWaveController; +import org.openhab.binding.zwave.internal.protocol.ZWaveEndpoint; +import org.openhab.binding.zwave.internal.protocol.ZWaveNode; +import org.openhab.binding.zwave.internal.protocol.commandclass.ZWaveCommandClass.CommandClass; +import org.openhab.binding.zwave.internal.protocol.commandclass.ZWaveFirmwareUpdateCommandClass.FirmwareFragment; +import org.openhab.binding.zwave.internal.protocol.event.ZWaveEvent; +import org.openhab.binding.zwave.internal.protocol.transaction.ZWaveCommandClassTransactionPayload; + +/** + * Unit tests for {@link ZWaveFirmwareUpdateCommandClass} helper methods. + * + * @author Bob Eckhoff - Initial contribution + */ +@NonNullByDefault +public class ZWaveFirmwareUpdateCommandClassTest { + + private static final ZWaveController MOCKEDCONTROLLER = Mockito.mock(ZWaveController.class); + + private static final ZWaveNode SHARED_NODE = new ZWaveNode(0, 0, MOCKEDCONTROLLER); + + private static final ZWaveEndpoint SHARED_ENDPOINT = new ZWaveEndpoint(0); + + private static final ZWaveFirmwareUpdateCommandClass SHARED_CLS = new ZWaveFirmwareUpdateCommandClass(SHARED_NODE, + MOCKEDCONTROLLER, SHARED_ENDPOINT); + + @Test + public void testGetMetaDataGetMessagePayload() { + ZWaveCommandClassTransactionPayload msg = SHARED_CLS.sendMDGetMessage(); + assertNotNull(msg); + + byte[] expected = new byte[] { (byte) CommandClass.COMMAND_CLASS_FIRMWARE_UPDATE_MD.getKey(), + (byte) ZWaveFirmwareUpdateCommandClass.FIRMWARE_MD_GET }; + + assertArrayEquals(expected, msg.getPayloadBuffer()); + assertEquals(CommandClass.COMMAND_CLASS_FIRMWARE_UPDATE_MD, msg.getExpectedResponseCommandClass()); + assertEquals((Integer) ZWaveFirmwareUpdateCommandClass.FIRMWARE_MD_REPORT, + msg.getExpectedResponseCommandClassCommand()); + } + + @Test + public void testFirmwareFragmentV1() { + // Synthetic data + boolean isLast = false; + int reportNumber = 5; + byte[] firmwareData = new byte[] { 0x11, 0x22, 0x33, 0x44 }; + + FirmwareFragment fragment = new FirmwareFragment(isLast, reportNumber, firmwareData, null); + + byte[] actual = fragment.toBytes(1, 0x7A, 0x06); // ccVersion=1, ccId/ccCommand ignored for v1 + + // Expected: + // Header word = 0x0005 (isLast=0, reportNumber=5) + // Data = 11 22 33 44 + byte[] expected = new byte[] { 0x00, 0x05, // header + 0x11, 0x22, 0x33, 0x44 }; + + assertArrayEquals(expected, actual); + } + + @Test + public void testFirmwareFragmentV2() { + boolean isLast = true; + int reportNumber = 3; + byte[] firmwareData = new byte[] { (byte) 0xAA, (byte) 0xBB, (byte) 0xCC }; + + FirmwareFragment fragment = new FirmwareFragment(isLast, reportNumber, firmwareData, null); + + int ccVersion = 2; + int ccId = 0x7A; // Firmware Update CC + int ccCommand = 0x06; // Fragment command + + byte[] actual = fragment.toBytes(ccVersion, ccId, ccCommand); + + // Compute expected CRC using the same helper + // Header word = 0x8003 (isLast=1, reportNumber=3) + byte[] headerAndData = new byte[] { (byte) 0x80, 0x03, (byte) 0xAA, (byte) 0xBB, (byte) 0xCC }; + + int crc = crc16Ccitt(new byte[] { (byte) ccId, (byte) ccCommand }, 0x1D0F); + crc = crc16Ccitt(headerAndData, crc); + + byte[] expected = ByteBuffer.allocate(headerAndData.length + 2).put(headerAndData).putShort((short) crc) + .array(); + + assertArrayEquals(expected, actual); + } + + @Test + public void testHandleFirmwareUpdateMdReportPublishesFragmentEvent() { + ZWaveController controller = Mockito.mock(ZWaveController.class); + ZWaveNode node = new ZWaveNode(0, 7, controller); + ZWaveEndpoint endpoint = new ZWaveEndpoint(0); + ZWaveFirmwareUpdateCommandClass cls = new ZWaveFirmwareUpdateCommandClass(node, controller, endpoint); + + byte[] frame = new byte[] { (byte) CommandClass.COMMAND_CLASS_FIRMWARE_UPDATE_MD.getKey(), + (byte) ZWaveFirmwareUpdateCommandClass.FIRMWARE_UPDATE_MD_REPORT, (byte) 0x80, 0x01, 0x11, 0x22 }; + + cls.handleFirmwareUpdateMdReport(new ZWaveCommandClassPayload(frame), 0); + + ArgumentCaptor eventCaptor = ArgumentCaptor.forClass(ZWaveEvent.class); + Mockito.verify(controller, Mockito.times(1)).notifyEventListeners(eventCaptor.capture()); + + assertTrue(eventCaptor.getValue() instanceof FirmwareUpdateEvent); + FirmwareUpdateEvent event = (FirmwareUpdateEvent) eventCaptor.getValue(); + assertEquals(FirmwareUpdateEvent.forMDReport(7, 0, new byte[0]).getType(), event.getType()); + assertArrayEquals(new byte[] { (byte) 0x80, 0x01, 0x11, 0x22 }, event.getPayload()); + } + + @Test + public void testHandleFirmwareUpdateMdStatusReportExtractsWaitTimeFromFrame() { + ZWaveController controller = Mockito.mock(ZWaveController.class); + ZWaveNode node = new ZWaveNode(0, 7, controller); + ZWaveEndpoint endpoint = new ZWaveEndpoint(0); + ZWaveFirmwareUpdateCommandClass cls = new ZWaveFirmwareUpdateCommandClass(node, controller, endpoint); + cls.setVersion(2); + + byte[] frame = new byte[] { (byte) CommandClass.COMMAND_CLASS_FIRMWARE_UPDATE_MD.getKey(), + (byte) ZWaveFirmwareUpdateCommandClass.FIRMWARE_UPDATE_MD_STATUS_REPORT, (byte) 0xFF, 0x01, 0x00 }; + + cls.handleFirmwareUpdateMdStatusReport(new ZWaveCommandClassPayload(frame), 0); + + ArgumentCaptor eventCaptor = ArgumentCaptor.forClass(ZWaveEvent.class); + Mockito.verify(controller, Mockito.times(1)).notifyEventListeners(eventCaptor.capture()); + + assertTrue(eventCaptor.getValue() instanceof FirmwareUpdateEvent); + FirmwareUpdateEvent event = (FirmwareUpdateEvent) eventCaptor.getValue(); + assertEquals(FirmwareUpdateEvent.forUpdateMdStatusReport(7, 0, 0, 0).getType(), event.getType()); + assertEquals(0xFF, event.getStatus()); + assertEquals(0x0100, event.getWaitTime()); + } + +}