From 304227fc2a20bd4af0566f260736385f7712a895 Mon Sep 17 00:00:00 2001 From: Jeeva Kandasamy Date: Sun, 6 Sep 2026 16:16:13 +0530 Subject: [PATCH 1/2] mysensors: add generic FOTA scripts and OTA duration fields Firmware selection can live in a data_repository script (fota_script). If that label is set, assigned_firmware is never used, even when the script is missing or disabled. Record completed OTA time in seconds and ota_time_taken_str (for example 1m40s). --- Makefile | 47 ++ docs/ota_stm32_ab.md | 287 ++++++++ pkg/api/node/api.go | 54 +- pkg/api/node/api_test.go | 46 ++ pkg/service/resource/service.go | 6 + .../resource/service_data_repository.go | 40 ++ pkg/service/resource/service_firmware.go | 30 +- pkg/types/cmap/types.go | 6 +- pkg/types/fields.go | 3 +- pkg/types/resource_service/types.go | 1 + pkg/utils/javascript/utils.go | 21 +- .../gateway/provider/mysensors_v2/actions.go | 6 + .../gateway/provider/mysensors_v2/constant.go | 10 + .../provider/mysensors_v2/event_listener.go | 44 +- .../provider/mysensors_v2/msg_parser.go | 7 + .../gateway/provider/mysensors_v2/ota_impl.go | 565 +++++++++++++-- .../provider/mysensors_v2/ota_script.go | 470 +++++++++++++ .../provider/mysensors_v2/ota_store.go | 113 ++- .../provider/mysensors_v2/ota_types.go | 29 +- .../provider/mysensors_v2/ota_types_test.go | 663 ++++++++++++++++++ 20 files changed, 2311 insertions(+), 137 deletions(-) create mode 100644 Makefile create mode 100644 docs/ota_stm32_ab.md create mode 100644 pkg/api/node/api_test.go create mode 100644 pkg/service/resource/service_data_repository.go create mode 100644 plugin/gateway/provider/mysensors_v2/ota_script.go create mode 100644 plugin/gateway/provider/mysensors_v2/ota_types_test.go diff --git a/Makefile b/Makefile new file mode 100644 index 00000000..f2de93b2 --- /dev/null +++ b/Makefile @@ -0,0 +1,47 @@ +# Local MyController build +# +# make # server + gateway + handler + client +# make server +# make test + +BIN_DIR ?= builds +VERSION_PKG := github.com/mycontroller-org/server/v2/pkg/version +VERSION ?= $(shell grep '^server=' versions.txt | cut -d= -f2)-devel +GIT_COMMIT ?= $(shell git rev-parse HEAD 2>/dev/null || echo unknown) +BUILD_DATE ?= $(shell date -u +'%Y-%m-%dT%H:%M:%SZ') +LDFLAGS := -X $(VERSION_PKG).version=$(VERSION) -X $(VERSION_PKG).buildDate=$(BUILD_DATE) -X $(VERSION_PKG).gitCommit=$(GIT_COMMIT) + +.PHONY: help all build server gateway handler client test clean + +help: + @echo "Targets:" + @echo " make / make build build all binaries into $(BIN_DIR)/" + @echo " make server $(BIN_DIR)/mycontroller-server" + @echo " make gateway $(BIN_DIR)/mycontroller-gateway" + @echo " make handler $(BIN_DIR)/mycontroller-handler" + @echo " make client $(BIN_DIR)/myc" + @echo " make test go test ./..." + @echo " make clean remove $(BIN_DIR)/" + +all build: server gateway handler client + +$(BIN_DIR): + mkdir -p $(BIN_DIR) + +server: $(BIN_DIR) + go build -trimpath -tags=server -ldflags "$(LDFLAGS)" -o $(BIN_DIR)/mycontroller-server ./cmd/component/server + +gateway: $(BIN_DIR) + go build -trimpath -tags=standalone -ldflags "$(LDFLAGS)" -o $(BIN_DIR)/mycontroller-gateway ./cmd/component/gateway + +handler: $(BIN_DIR) + go build -trimpath -tags=standalone -ldflags "$(LDFLAGS)" -o $(BIN_DIR)/mycontroller-handler ./cmd/component/handler + +client: $(BIN_DIR) + go build -trimpath -ldflags "$(LDFLAGS)" -o $(BIN_DIR)/myc ./cmd/client + +test: + go test ./... + +clean: + rm -rf $(BIN_DIR) diff --git a/docs/ota_stm32_ab.md b/docs/ota_stm32_ab.md new file mode 100644 index 00000000..4335f7a9 --- /dev/null +++ b/docs/ota_stm32_ab.md @@ -0,0 +1,287 @@ +# Sample FOTA script: STM32 A/B (data repository) + +MyController stays generic. This repository entry is the A/B *policy*. +A different bootloader only needs a different repository + node label. + +## Setup + +1. Create a **Data repository** with id e.g. `ota_stm32_ab`. +2. Put the `onConfig` script below in `data.onConfig`. + `data.onBlock` is **optional**; omit it. Core reuses the `firmwareId` chosen in `onConfig`. +3. On the node, set labels: + - `fota_script` = `ota_stm32_ab` (this repository id) + - `assigned_firmware_slot_a` = firmware entity id of the **slot A** signed image + - `assigned_firmware_slot_b` = firmware entity id of the **slot B** signed image +4. On each firmware entity, set label `ms_flash_slot` = `A` or `B` (must match how that `.signed.bin` was linked). The script refuses a swap, and also refuses if `getFirmware().head` is missing or the Reset_Handler is not in slot A or B. +5. Leave `assigned_firmware` unused. While `fota_script` is set, stock + `assigned_firmware` is never used, even if the repository is missing or + `data.disabled` is true. +6. To turn the script off for every node that points at this repository, set + `data.disabled` = `true`. CONFIG_REQUEST is echoed (no update). A missing + repository is an error (still no stock fallback). +7. To turn **all** OTA off for one node (script and stock), set node label + `fota_disabled` = `true`. CONFIG_REQUEST is echoed (no update); block + requests and UI firmware update fail. + +Both slot images of **one release** must share the same MySensors `ms_type_id` / `ms_version_id` +(different binaries / CRC, same type+version). That is what stops A↔B ping-pong: +if the node already reports that type+version, the script returns `noUpdate`. + +When you ship a new release, update **both** slot labels together. + +## Protocol (device side) + +`ST_FIRMWARE_CONFIG_REQUEST` protocol 3.1 payload (18 bytes), filled by `InternalOtaFlash`: + +| Offset | Field | A/B meaning | +|--------|--------|-------------| +| 0–1 | `type` | Running (or staged, if pending confirm) firmware type | +| 2–3 | `version` | Running (or staged) firmware version | +| 11 | `img_committed` | `0xA0` = running **A**, `0xA1` = running **B** | +| 12–13 LE | `img_revision` | Request slot: `0` = serve A, `1` = serve B (inactive) | +| 14–17 | `img_build_num` | `(running<<16) \| (request<<8) \| 0xAB` | + +Always OTA the **request (inactive)** slot image, never the running slot. + +## Script inputs + +| Variable | onConfig | onBlock | Description | +|----------|----------|---------|-------------| +| `request` | yes | yes | Parsed payload: `hex`, `bytes`, `length`, and when present `type`, `version`, `blocks`, `crc`, `blVersion`, `blockSize`, `imgCommitted`, `imgRevision`, `imgBuildNum` (config) or `block` (block request) | +| `requestHex` | yes | yes | Same raw hex as `request.hex` | +| `gatewayId` | yes | yes | Gateway id | +| `nodeId` | yes | yes | MySensors node id | +| `nodeLabels` | yes | yes | Current node labels (keys are lowercase) | +| `getFirmware(id)` | yes | yes | `{id, type, version, labels, checksum, crc, blocks, head}`. `head` is the first 16 image bytes as hex; this script parses the vector table from it | +| `cachedFirmwareId` | yes | yes | `firmwareId` remembered from the last `onConfig` | +| `type` / `version` / `block` | no | yes | From the block request | + +Helpers: `mcUtils.convert.HexStringToBytes`, `ToUInt16LE`, etc. Prefer `request.*`. + +## Script return (object) + +| Field | Required | Description | +|-------|----------|-------------| +| `firmwareId` | one of* | Firmware entity id to serve | +| `responseHex` | one of* | Full hex response; skips core packing | +| `noUpdate` | one of* | Echo the node's current type/version/blocks/crc (no OTA) | +| `blockSize` | no | Node OTA slice size in bytes (8–192, multiple of 8). Core prefers the live CONFIG_REQUEST; use this for UI-triggered empty requests | +| `labels` | no | Merged onto the node. This script sets `ab_running_slot`, `ab_request_slot`, `ab_last_*` (what the node reported, hex16) and `ab_serve_*` (image being offered) | +| `error` | no | Non-empty string aborts OTA | + +\* At least one of `firmwareId`, `responseHex`, or `noUpdate`. + +`onBlock` may be omitted. If present it runs **on every block**; keep it cheap or leave it empty. + +## data.onConfig + +```javascript +function slotLetter(n) { + return n === 0 ? "A" : "B"; +} + +function slotLabels(runSlot, reqSlot) { + return { + ab_capable: "true", + ab_running_slot: slotLetter(runSlot), + ab_request_slot: slotLetter(reqSlot) + }; +} + +function hex16(n) { + var v = (+n) & 0xFFFF; + var s = v.toString(16).toUpperCase(); + while (s.length < 4) { + s = "0" + s; + } + return s; +} + +function noteNodeRequest(labels, req) { + if (!req) { + return labels; + } + if (req.type !== undefined) { + labels.ab_last_type = hex16(req.type); + } + if (req.version !== undefined) { + labels.ab_last_version = hex16(req.version); + } + if (req.crc !== undefined) { + labels.ab_last_crc = hex16(req.crc); + } + return labels; +} + +function noteServe(labels, fw, fwId) { + labels.ab_serve_firmware = fwId || ""; + if (fw && !fw.error) { + labels.ab_serve_type = hex16(fw.type); + labels.ab_serve_version = hex16(fw.version); + if (fw.crc !== undefined) { + labels.ab_serve_crc = hex16(fw.crc); + } + } + return labels; +} + +function pickSlotFirmware(reqSlot) { + var fwKey = reqSlot === 0 ? "assigned_firmware_slot_a" : "assigned_firmware_slot_b"; + var fwId = nodeLabels[fwKey]; + if (!fwId) { + return { error: "missing node label " + fwKey }; + } + return { firmwareId: fwId, fwKey: fwKey }; +} + +// Little-endian u32 from getFirmware().head (hex). Custom to this bootloader. +function u32leHex(hex, byteOff) { + var i = byteOff * 2; + if (!hex || hex.length < i + 8) { + return 0; + } + return parseInt(hex.substr(i + 6, 2) + hex.substr(i + 4, 2) + hex.substr(i + 2, 2) + hex.substr(i, 2), 16); +} + +// Slot this image is *linked* for, from the Cortex-M vector table (Reset_Handler). +// Do not trust ms_flash_slot; that is only what was typed in the UI. +function linkedSlot(fw) { + var SLOT_A = 0x08004000; + var SLOT_B = 0x08021800; + var SLOT_SIZE = 0x1D800; + var reset = u32leHex(fw.head, 4) & 0xFFFFFFFE; + if (reset >= SLOT_A && reset < SLOT_A + SLOT_SIZE) { + return "A"; + } + if (reset >= SLOT_B && reset < SLOT_B + SLOT_SIZE) { + return "B"; + } + return ""; +} + +function rejectWrongSlot(fw, wantSlot, fwId, labels) { + if (!fw || fw.error) { + return { error: (fw && fw.error) ? String(fw.error) : ("getFirmware failed for " + fwId), labels: labels }; + } + if (!fw.head) { + return { error: "firmware " + fwId + " has no head; cannot verify slot link", labels: labels }; + } + var got = linkedSlot(fw); + if (!got) { + return { error: "firmware " + fwId + " Reset_Handler is not in slot A or B", labels: labels }; + } + if (got !== wantSlot) { + return { error: "firmware " + fwId + " is linked for slot " + got + ", node requested " + wantSlot, labels: labels }; + } + return null; +} + +function advertisedBlockSize() { + var fromReq = request && request.blockSize; + if (fromReq >= 8 && fromReq <= 192 && (fromReq % 8) === 0) { + return fromReq; + } + var fromLabel = parseInt(nodeLabels["ms_ota_block_size"], 10); + if (fromLabel >= 8 && fromLabel <= 192 && (fromLabel % 8) === 0 && fromLabel !== 16) { + return fromLabel; + } + return 0; +} + +// UI-triggered config request has no payload: use last known running slot if we have it. +if (!request || !request.hex || request.length === 0) { + var lastRun = nodeLabels["ab_running_slot"]; + if (lastRun !== "A" && lastRun !== "B") { + return { error: "empty config request and unknown ab_running_slot; wait for the node to present" }; + } + var uiBs = advertisedBlockSize(); + if (!uiBs) { + return { error: "empty config request and unknown OTA block size; wait for the node to present protocol 3.1" }; + } + var uiReqSlot = lastRun === "A" ? 1 : 0; + var uiPick = pickSlotFirmware(uiReqSlot); + if (uiPick.error) { + return { error: uiPick.error }; + } + var uiReqFw = getFirmware(uiPick.firmwareId); + var uiLabels = slotLabels(lastRun === "A" ? 0 : 1, uiReqSlot); + if (uiReqFw.error) { + return { error: uiReqFw.error, labels: uiLabels }; + } + var uiWrong = rejectWrongSlot(uiReqFw, slotLetter(uiReqSlot), uiPick.firmwareId, uiLabels); + if (uiWrong) { + return uiWrong; + } + // UI Firmware update is explicit: offer the inactive slot. Radio + // CONFIG_REQUEST still skips when the node CRC matches (below). + return { firmwareId: uiPick.firmwareId, blockSize: uiBs, labels: noteServe(uiLabels, uiReqFw, uiPick.firmwareId) }; +} + +if (request.length < 18) { + return { error: "need protocol 3.1 (18 bytes) for A/B, got " + request.length }; +} + +var imgCommitted = request.imgCommitted; +var reqSlot = request.imgRevision & 0xFF; +if ((imgCommitted & 0xF0) !== 0xA0) { + return { error: "not an A/B node (imgCommitted high nibble != 0xA0)" }; +} +var runSlot = imgCommitted & 0x0F; +if (runSlot > 1 || reqSlot > 1 || runSlot === reqSlot) { + return { error: "invalid slots run=" + runSlot + " req=" + reqSlot }; +} + +var pick = pickSlotFirmware(reqSlot); +if (pick.error) { + return { error: pick.error }; +} + +var labels = noteNodeRequest(slotLabels(runSlot, reqSlot), request); + +var fw = getFirmware(pick.firmwareId); +if (fw.error) { + return { error: fw.error, labels: labels }; +} + +var wantSlot = slotLetter(reqSlot); +var wrong = rejectWrongSlot(fw, wantSlot, pick.firmwareId, labels); +if (wrong) { + return wrong; +} + +var runKey = runSlot === 0 ? "assigned_firmware_slot_a" : "assigned_firmware_slot_b"; +var runFw = getFirmware(nodeLabels[runKey]); +var bs = advertisedBlockSize(); + +// A UI click stores ab_serve_firmware. Keep offering that file on reboot +// CONFIG_REQUEST until the node reports that CRC (OTA finished / confirmed). +var pendingId = nodeLabels["ab_serve_firmware"]; +if (pendingId) { + var pendingFw = getFirmware(pendingId); + if (!pendingFw.error && pendingFw.crc !== undefined && +request.crc !== +pendingFw.crc) { + var pendingWrong = rejectWrongSlot(pendingFw, wantSlot, pendingId, labels); + if (!pendingWrong) { + return { firmwareId: pendingId, blockSize: bs, labels: noteServe(labels, pendingFw, pendingId) }; + } + } +} + +// No unfinished UI offer: skip when the node is already on this release +// (type+version match and CRC matches the running-slot file). Stops A↔B ping-pong. +if (+request.type === +fw.type && +request.version === +fw.version && +request.type !== 0xFFFF && + !runFw.error && runFw.crc !== undefined && +request.crc === +runFw.crc) { + return { noUpdate: true, labels: labels }; +} + +return { firmwareId: pick.firmwareId, blockSize: bs, labels: noteServe(labels, fw, pick.firmwareId) }; +``` + +## data.onBlock + +Leave empty. Core serves the `firmwareId` cached from `onConfig`. + +## Future bootloaders + +Create another data repository (e.g. `ota_future_bl`) with its own `onConfig`, +point `fota_script` at that id, and use whatever node labels that script documents. +No MyController code change. diff --git a/pkg/api/node/api.go b/pkg/api/node/api.go index f66fd5db..0089925d 100644 --- a/pkg/api/node/api.go +++ b/pkg/api/node/api.go @@ -146,23 +146,67 @@ func (n *NodeAPI) UpdateFirmwareState(id string, data map[string]interface{}) er if startTime != nil { node.Others.Set(types.FieldOTAStartTime, startTime, nil) node.Others.Set(types.FieldOTATimeTaken, "", nil) + node.Others.Set(types.FieldOTATimeTakenStr, "", nil) node.Others.Set(types.FieldOTAEndTime, "", nil) } endTime := utils.GetMapValue(data, types.FieldOTAEndTime, nil) if endTime != nil { node.Others.Set(types.FieldOTAEndTime, endTime, nil) - startTime = node.Others.Get(types.FieldOTAStartTime) - if st, stOK := startTime.(time.Time); stOK { - if et, etOK := endTime.(time.Time); etOK { - node.Others.Set(types.FieldOTATimeTaken, et.Sub(st).String(), nil) - } + st, stOK := parseOTATime(node.Others.Get(types.FieldOTAStartTime)) + et, etOK := parseOTATime(endTime) + if stOK && etOK && !et.Before(st) { + d := et.Sub(st) + node.Others.Set(types.FieldOTATimeTaken, int64(d.Round(time.Second)/time.Second), nil) + node.Others.Set(types.FieldOTATimeTakenStr, formatOTADuration(d), nil) } } return n.Save(node, true) } +func parseOTATime(v interface{}) (time.Time, bool) { + if v == nil { + return time.Time{}, false + } + switch t := v.(type) { + case time.Time: + return t, !t.IsZero() + case *time.Time: + return *t, t != nil && !t.IsZero() + case string: + s := t + if s == "" { + return time.Time{}, false + } + if parsed, err := time.Parse(time.RFC3339Nano, s); err == nil { + return parsed, true + } + if parsed, err := time.Parse(time.RFC3339, s); err == nil { + return parsed, true + } + } + return time.Time{}, false +} + +// formatOTADuration is a compact clock string, e.g. 40s, 1m40s, 1h2m3s. +func formatOTADuration(d time.Duration) string { + if d < 0 { + d = 0 + } + sec := int64(d.Round(time.Second) / time.Second) + h := sec / 3600 + m := (sec % 3600) / 60 + s := sec % 60 + if h > 0 { + return fmt.Sprintf("%dh%dm%ds", h, m, s) + } + if m > 0 { + return fmt.Sprintf("%dm%ds", m, s) + } + return fmt.Sprintf("%ds", s) +} + // Verifies node up status by checking the last seen timestamp // if the last seen greater than x minutes/seconds or specified duration in that node // will be marked as down diff --git a/pkg/api/node/api_test.go b/pkg/api/node/api_test.go new file mode 100644 index 00000000..3847a1e2 --- /dev/null +++ b/pkg/api/node/api_test.go @@ -0,0 +1,46 @@ +package node + +import ( + "testing" + "time" +) + +func TestParseOTATime(t *testing.T) { + now := time.Date(2026, 9, 6, 2, 34, 48, 808783163, time.FixedZone("IST", 5*3600+30*60)) + if got, ok := parseOTATime(now); !ok || !got.Equal(now) { + t.Fatalf("time.Time: ok=%v got=%v", ok, got) + } + s := "2026-09-06T02:34:48.808783163+05:30" + got, ok := parseOTATime(s) + if !ok { + t.Fatal("RFC3339Nano string") + } + if !got.Equal(now) { + t.Fatalf("parsed %v want %v", got, now) + } + if _, ok := parseOTATime(""); ok { + t.Fatal("empty string") + } + if _, ok := parseOTATime(nil); ok { + t.Fatal("nil") + } +} + +func TestFormatOTADuration(t *testing.T) { + cases := []struct { + d time.Duration + want string + }{ + {40 * time.Second, "40s"}, + {100 * time.Second, "1m40s"}, + {4*time.Minute + 37*time.Second, "4m37s"}, + {time.Hour + 2*time.Minute + 3*time.Second, "1h2m3s"}, + {0, "0s"}, + {-time.Second, "0s"}, + } + for _, tc := range cases { + if got := formatOTADuration(tc.d); got != tc.want { + t.Fatalf("%v: got %q want %q", tc.d, got, tc.want) + } + } +} diff --git a/pkg/service/resource/service.go b/pkg/service/resource/service.go index 068a630f..98fb25ad 100644 --- a/pkg/service/resource/service.go +++ b/pkg/service/resource/service.go @@ -160,6 +160,12 @@ func (svc *ResourceService) processEvent(item interface{}) error { svc.logger.Error("error on serving firmware service request", zap.Error(err)) } + case rsTY.TypeDataRepository: + err := svc.dataRepositoryService(request) + if err != nil { + svc.logger.Error("error on serving data repository service request", zap.Error(err)) + } + case rsTY.TypeVirtualAssistant: err := svc.virtualAssistantService(request) if err != nil { diff --git a/pkg/service/resource/service_data_repository.go b/pkg/service/resource/service_data_repository.go new file mode 100644 index 00000000..3468068d --- /dev/null +++ b/pkg/service/resource/service_data_repository.go @@ -0,0 +1,40 @@ +package resource + +import ( + "errors" + + rsTY "github.com/mycontroller-org/server/v2/pkg/types/resource_service" + "go.uber.org/zap" +) + +func (svc *ResourceService) dataRepositoryService(reqEvent *rsTY.ServiceEvent) error { + resEvent := &rsTY.ServiceEvent{ + Type: reqEvent.Type, + Command: reqEvent.ReplyCommand, + } + + switch reqEvent.Command { + case rsTY.CommandGet: + data, err := svc.getDataRepository(reqEvent) + if err != nil { + resEvent.Error = err.Error() + } + resEvent.SetData(data) + + default: + return errors.New("unknown command") + } + return svc.postResponse(reqEvent.ReplyTopic, resEvent) +} + +func (svc *ResourceService) getDataRepository(request *rsTY.ServiceEvent) (interface{}, error) { + if request.ID == "" { + return nil, errors.New("id not supplied") + } + cfg, err := svc.api.DataRepository().GetByID(request.ID) + if err != nil { + svc.logger.Debug("data repository get failed", zap.String("id", request.ID), zap.Error(err)) + return nil, err + } + return cfg, nil +} diff --git a/pkg/service/resource/service_firmware.go b/pkg/service/resource/service_firmware.go index 2806a537..651d0a1f 100644 --- a/pkg/service/resource/service_firmware.go +++ b/pkg/service/resource/service_firmware.go @@ -68,32 +68,12 @@ func (svc *ResourceService) sendFirmwareBlocks(reqEvent *rsTY.ServiceEvent) { return } - blockNumber := 0 - totalBytes := len(fwBytes) - for { - positionStart := blockNumber * firmwareTY.BlockSize - positionEnd := positionStart + firmwareTY.BlockSize - - reachedEnd := false - var bytes []byte - if positionEnd < len(fwBytes) { - bytes = fwBytes[positionStart:positionEnd] - } else { - bytes = fwBytes[positionStart:] - reachedEnd = true - } - - err := svc.postFirmwareBlock(reqEvent.ReplyTopic, fw.ID, bytes, blockNumber, totalBytes, reachedEnd) - if err != nil { - svc.logger.Error("error on posting firmware blocks", zap.String("firmwareId", fw.ID), zap.Error(err)) - } - - if reachedEnd { - return - } - blockNumber++ + // One message with the whole file. Chunking into 512-byte bus replies races: + // IsFinal can be handled while earlier chunks are still in flight, and a + // nested CommandGet from the gateway mutates the shared decode buffer. + if err := svc.postFirmwareBlock(reqEvent.ReplyTopic, fw.ID, fwBytes, 0, len(fwBytes), true); err != nil { + svc.logger.Error("error on posting firmware blocks", zap.String("firmwareId", fw.ID), zap.Error(err)) } - } func (svc *ResourceService) postFirmwareBlock(replyTopic, id string, bytes []byte, blockNumber, totalBytes int, isFinal bool) error { diff --git a/pkg/types/cmap/types.go b/pkg/types/cmap/types.go index 48d663c9..19472933 100644 --- a/pkg/types/cmap/types.go +++ b/pkg/types/cmap/types.go @@ -90,7 +90,11 @@ func (csm CustomStringMap) CopyFrom(another CustomStringMap) { // GetBool a value by key func (csm CustomStringMap) GetBool(key string) bool { key = normalize.Key(key) - v, err := strconv.ParseBool(csm.Get(key)) + raw := csm.Get(key) + if raw == "" { + return false + } + v, err := strconv.ParseBool(raw) if err != nil { zap.L().Debug("error on conversion", zap.Error(err), zap.Any("value", v)) } diff --git a/pkg/types/fields.go b/pkg/types/fields.go index 3d9be34b..1447a3f6 100644 --- a/pkg/types/fields.go +++ b/pkg/types/fields.go @@ -18,5 +18,6 @@ const ( FieldOTAStatusOn = "ota_status_on" // time FieldOTAStartTime = "ota_start_time" // start time FieldOTAEndTime = "ota_end_time" // end time - FieldOTATimeTaken = "ota_time_taken" // time taken to complete the update + FieldOTATimeTaken = "ota_time_taken" // duration in seconds + FieldOTATimeTakenStr = "ota_time_taken_str" // compact duration, e.g. 1m40s ) diff --git a/pkg/types/resource_service/types.go b/pkg/types/resource_service/types.go index 9b553edc..416f9468 100644 --- a/pkg/types/resource_service/types.go +++ b/pkg/types/resource_service/types.go @@ -13,6 +13,7 @@ const ( TypeHandler = "handler" TypeScheduler = "scheduler" TypeFirmware = "firmware" + TypeDataRepository = "data_repository" TypeResourceAction = "resource_action" TypeSystemJobs = "system_jobs" TypeVirtualAssistant = "virtual_assistant" diff --git a/pkg/utils/javascript/utils.go b/pkg/utils/javascript/utils.go index d290ad4c..7d164370 100644 --- a/pkg/utils/javascript/utils.go +++ b/pkg/utils/javascript/utils.go @@ -3,6 +3,7 @@ package javascript import ( "errors" "fmt" + "reflect" "time" "github.com/dop251/goja" @@ -35,7 +36,7 @@ func Execute(logger *zap.Logger, scriptString string, variables map[string]inter logger.Warn("error on setting a value", zap.String("name", name), zap.Any("value", value), zap.Error(err)) } } - logger.Debug("executing script", zap.Any("variables", variables), zap.String("scriptString", scriptString)) + logger.Debug("executing script", zap.Any("variables", loggableScriptVariables(variables)), zap.String("scriptString", scriptString)) // include helper functions err := rt.Set(jsHelper.KeyMcUtils, jsHelper.GetHelperUtils()) @@ -65,11 +66,27 @@ func Execute(logger *zap.Logger, scriptString string, variables map[string]inter return nil, err } output := response.Export() - logger.Debug("executed script", zap.String("timeTaken", time.Since(start).String()), zap.Any("variables", variables), zap.String("scriptString", scriptString), zap.Any("output", output)) + logger.Debug("executed script", zap.String("timeTaken", time.Since(start).String()), zap.Any("variables", loggableScriptVariables(variables)), zap.String("scriptString", scriptString), zap.Any("output", output)) return output, nil } +// loggableScriptVariables omits func values (e.g. getFirmware) so zap/json can encode the map. +func loggableScriptVariables(variables map[string]interface{}) map[string]interface{} { + if variables == nil { + return nil + } + out := make(map[string]interface{}, len(variables)) + for k, v := range variables { + if v != nil && reflect.ValueOf(v).Kind() == reflect.Func { + out[k] = fmt.Sprintf("<%T>", v) + continue + } + out[k] = v + } + return out +} + // converts the interface data to map[string]interface{} func ToMap(data interface{}) (map[string]interface{}, error) { if data == nil { diff --git a/plugin/gateway/provider/mysensors_v2/actions.go b/plugin/gateway/provider/mysensors_v2/actions.go index bbe939cb..92f35601 100644 --- a/plugin/gateway/provider/mysensors_v2/actions.go +++ b/plugin/gateway/provider/mysensors_v2/actions.go @@ -80,6 +80,9 @@ func (p *Provider) handleActions(gwCfg *gwTY.Config, fn string, msg *msgTY.Messa if err != nil { return err } + if pl == "" { + return nil + } msMsg.Command = cmdStream msMsg.Type = actionFirmwareConfigResponse msMsg.Payload = strings.ToUpper(pl) @@ -89,6 +92,9 @@ func (p *Provider) handleActions(gwCfg *gwTY.Config, fn string, msg *msgTY.Messa if err != nil { return err } + if pl == "" { + return nil + } msMsg.Command = cmdStream msMsg.Type = actionFirmwareResponse msMsg.Payload = strings.ToUpper(pl) diff --git a/plugin/gateway/provider/mysensors_v2/constant.go b/plugin/gateway/provider/mysensors_v2/constant.go index fac2be1a..89097d05 100644 --- a/plugin/gateway/provider/mysensors_v2/constant.go +++ b/plugin/gateway/provider/mysensors_v2/constant.go @@ -22,6 +22,16 @@ const ( LabelFirmwareVersionID = "ms_version_id" // MySensors firmware version id LabelSmartSleepNode = "ms_smart_sleep_node" // set true, if it is a smart sleeping node + // LabelFotaScript is the data_repository id that holds custom FOTA policy scripts + // (data.onConfig required, data.onBlock optional). When set (non-empty after trim), + // firmware selection is owned by those scripts; assigned_firmware is never used. + // If the repository is missing or data.disabled is true, no firmware is served. + // When empty, stock path uses assigned_firmware. + LabelFotaScript = "fota_script" + // LabelFotaDisabled on a node turns off all OTA for that node (script and stock). + // ParseBool: true/1. Missing or false keeps FOTA enabled. + LabelFotaDisabled = "fota_disabled" + FieldAwakeDuration = "awake_duration" // smart sleep node awake duration FieldSleepDuration = "sleep_duration" // smart sleep node sleep duration ) diff --git a/plugin/gateway/provider/mysensors_v2/event_listener.go b/plugin/gateway/provider/mysensors_v2/event_listener.go index 9054e652..65528851 100644 --- a/plugin/gateway/provider/mysensors_v2/event_listener.go +++ b/plugin/gateway/provider/mysensors_v2/event_listener.go @@ -2,8 +2,10 @@ package mysensors import ( "fmt" + "strings" "github.com/mycontroller-org/server/v2/pkg/types" + repositoryTY "github.com/mycontroller-org/server/v2/pkg/types/data_repository" eventTY "github.com/mycontroller-org/server/v2/pkg/types/event" firmwareTY "github.com/mycontroller-org/server/v2/pkg/types/firmware" nodeTY "github.com/mycontroller-org/server/v2/pkg/types/node" @@ -19,9 +21,10 @@ const ( ) var ( - eventsQueue *queueUtils.Queue - firmwareSubscriptionID = int64(0) - nodeSubscriptionID = int64(0) + eventsQueue *queueUtils.Queue + firmwareSubscriptionID = int64(0) + nodeSubscriptionID = int64(0) + dataRepositorySubscriptionID = int64(0) ) // initEventListener service @@ -39,6 +42,11 @@ func (p *Provider) initEventListener(gatewayID string) error { return err } nodeSubscriptionID = sID + sID, err = p.bus.Subscribe(topicTY.TopicEventDataRepository, p.onEvent) + if err != nil { + return err + } + dataRepositorySubscriptionID = sID return nil } @@ -58,6 +66,13 @@ func (p *Provider) closeEventListener() { p.logger.Error("error on unsubscribe", zap.Error(err), zap.String("topic", topic)) } } + if dataRepositorySubscriptionID != 0 { + topic := topicTY.TopicEventDataRepository + err := p.bus.Unsubscribe(topic, dataRepositorySubscriptionID) + if err != nil { + p.logger.Error("error on unsubscribe", zap.Error(err), zap.String("topic", topic)) + } + } eventsQueue.Close() } @@ -70,7 +85,9 @@ func (p *Provider) onEvent(data *busTY.BusData) { } p.logger.Debug("Received an event", zap.Any("event", event)) - if !(event.EntityType == types.EntityNode || event.EntityType == types.EntityFirmware) || + if !(event.EntityType == types.EntityNode || + event.EntityType == types.EntityFirmware || + event.EntityType == types.EntityDataRepository) || event.Entity == nil { return } @@ -96,7 +113,12 @@ func (p *Provider) processServiceEvent(item interface{}) error { p.logger.Error("error on loading firmware entity", zap.String("eventQuickId", event.EntityQuickID), zap.Error(err)) return nil // Don't requeue invalid events } - fwRawStore.Remove(firmware.ID) + prefix := firmware.ID + "#" + for _, key := range fwRawStore.Keys() { + if key == firmware.ID || strings.HasPrefix(key, prefix) { + fwRawStore.Remove(key) + } + } fwStore.Remove(firmware.ID) case types.EntityNode: @@ -111,6 +133,18 @@ func (p *Provider) processServiceEvent(item interface{}) error { nodeStore.Add(localID, &node) } + case types.EntityDataRepository: + repo := repositoryTY.Config{} + err := event.LoadEntity(&repo) + if err != nil { + p.logger.Error("error on loading data repository entity", zap.String("eventQuickId", event.EntityQuickID), zap.Error(err)) + return nil + } + // invalidate cached FOTA scripts + if repo.ID != "" { + fotaScriptStore.Remove(repo.ID) + } + default: p.logger.Info("received unsupported event", zap.Any("event", event)) } diff --git a/plugin/gateway/provider/mysensors_v2/msg_parser.go b/plugin/gateway/provider/mysensors_v2/msg_parser.go index 9d0b32b0..fb46c70b 100644 --- a/plugin/gateway/provider/mysensors_v2/msg_parser.go +++ b/plugin/gateway/provider/mysensors_v2/msg_parser.go @@ -87,6 +87,13 @@ func (p *Provider) toRawMessage(msg *msgTY.Message) (*msgTY.RawMessage, error) { if err != nil { return nil, err } + if msMsg.Type == "" { + switch payload.Key { + case nodeTY.ActionFirmwareUpdate, "ST_FIRMWARE_CONFIG_REQUEST", "ST_FIRMWARE_REQUEST": + p.logger.Debug("firmware action produced no message", zap.String("action", payload.Key)) + return nil, nil + } + } default: return nil, fmt.Errorf("this command not implemented: %s", msg.Type) diff --git a/plugin/gateway/provider/mysensors_v2/ota_impl.go b/plugin/gateway/provider/mysensors_v2/ota_impl.go index 32eb26bf..90f3af78 100644 --- a/plugin/gateway/provider/mysensors_v2/ota_impl.go +++ b/plugin/gateway/provider/mysensors_v2/ota_impl.go @@ -15,15 +15,16 @@ import ( nodeTY "github.com/mycontroller-org/server/v2/pkg/types/node" rsTY "github.com/mycontroller-org/server/v2/pkg/types/resource_service" busUtils "github.com/mycontroller-org/server/v2/pkg/utils/bus_utils" + converterUtils "github.com/mycontroller-org/server/v2/pkg/utils/convertor" "go.uber.org/zap" ) // executeFirmwareConfigRequest executes firmware config request and response with hex payload func (p *Provider) executeFirmwareConfigRequest(msg *msgTY.Message) (string, error) { startTime := time.Now() - rxPL := msg.Payloads[0].Value.String() + rxPL := sanitizeOtaHex(msg.Payloads[0].Value.String()) - // convert the received hex to matching struct format + // convert the received hex to matching struct format (first 10 bytes when present) fwCfgReq := &firmwareConfigRequest{} if rxPL != "" { err := toStruct(rxPL, fwCfgReq) @@ -39,35 +40,197 @@ func (p *Provider) executeFirmwareConfigRequest(msg *msgTY.Message) (string, err return "", err } - // get firmware raw format - fwRaw, err := p.fetchFirmware(node, fwCfgReq.Type, fwCfgReq.Version, false) + fromReq := otaBlockSizeFromRequest(rxPL) + if fromReq > 0 { + p.rememberOtaBlockSize(node, fromReq) + } + + if isFotaDisabled(node) { + return p.respondFotaDisabled(node, rxPL, fwCfgReq, startTime) + } + + if hasFotaScript(node) { + scriptDisabled, err := p.isFotaScriptDisabled(node) + if err != nil { + p.logger.Error("fota script lookup failed", zap.String("nodeId", node.ID), zap.Error(err)) + return "", err + } + if scriptDisabled { + return p.respondFotaScriptInactive(node, rxPL, fwCfgReq, "fota script disabled in data repository") + } + scriptRes, err := p.runFotaOnConfig(node, rxPL) + if err != nil { + p.logger.Error("fota onConfig failed", zap.String("nodeId", node.ID), zap.Error(err)) + return "", err + } + if node.Labels.GetBool(LabelEraseEEPROM) { + return p.buildFirmwareConfigResponse(node, &firmwareRaw{}, startTime, "") + } + if scriptRes.NoUpdate { + p.logFotaNoUpdate(node, rxPL, fwCfgReq, scriptRes, startTime) + // UI firmware_update has no CONFIG_REQUEST bytes. Do not invent a + // type/version/CRC; a mismatch would start an unwanted OTA. + if rxPL == "" { + return "", nil + } + return p.toHex(&firmwareConfigResponse{ + Type: fwCfgReq.Type, + Version: fwCfgReq.Version, + Blocks: fwCfgReq.Blocks, + CRC: fwCfgReq.CRC, + }) + } + if scriptRes.ResponseHex != "" { + p.logger.Debug("sending firmware config response from fota script", + zap.String("nodeId", node.ID), + zap.String("fotaScript", getFotaScriptID(node)), + zap.String("timeTaken", time.Since(startTime).String()), + ) + return scriptRes.ResponseHex, nil + } + blockSize, err := p.resolveOtaBlockSize(node, rxPL, fromReq, scriptRes.BlockSize) + if err != nil { + p.logger.Error("cannot advertise firmware: unknown OTA block size", + zap.String("nodeId", node.ID), + zap.String("payload", rxPL), + zap.Int("parsedBlockSize", fromReq), + zap.Int("scriptBlockSize", scriptRes.BlockSize), + zap.Int("labelBlockSize", p.otaBlockSizeFromLabel(node)), + zap.Error(err), + ) + return "", err + } + fwRaw, err := p.fetchFirmwareByID(scriptRes.FirmwareID, fwCfgReq.Type, fwCfgReq.Version, false, blockSize) + if err != nil { + p.logger.Error("error to get firmware from fota onConfig", zap.String("firmwareId", scriptRes.FirmwareID), zap.Error(err)) + return "", err + } + fwRaw.LastAccess = time.Now() + p.rememberFotaSession(node, scriptRes.FirmwareID, fwRaw.BlockSize) + return p.buildFirmwareConfigResponse(node, fwRaw, startTime, scriptRes.FirmwareID) + } + + // Stock path: assigned_firmware label (only when fota_script is not set) + blockSize, err := p.resolveOtaBlockSize(node, rxPL, fromReq, 0) + if err != nil { + p.logger.Error("cannot advertise firmware: unknown OTA block size", + zap.String("nodeId", node.ID), + zap.String("payload", rxPL), + zap.Int("parsedBlockSize", fromReq), + zap.Error(err), + ) + return "", err + } + fwRaw, err := p.fetchFirmware(node, fwCfgReq.Type, fwCfgReq.Version, false, blockSize) if err != nil { p.logger.Error("error to get firmware", zap.Any("fwCfgReq", fwCfgReq), zap.Error(err)) return "", err } fwRaw.LastAccess = time.Now() + return p.buildFirmwareConfigResponse(node, fwRaw, startTime, node.Labels.Get(types.LabelNodeAssignedFirmware)) +} + +func (p *Provider) logFotaNoUpdate(node *nodeTY.Node, rxPL string, req *firmwareConfigRequest, scriptRes *fotaScriptResult, startTime time.Time) { + if p == nil || p.logger == nil { + return + } + fields := []zap.Field{ + zap.String("nodeId", node.ID), + zap.String("gatewayId", node.GatewayID), + zap.String("msNodeId", node.NodeID), + zap.String("fotaScript", getFotaScriptID(node)), + zap.String("timeTaken", time.Since(startTime).String()), + zap.String("payload", rxPL), + zap.Any("request", fotaRequestForLog(rxPL)), + } + if req != nil { + fields = append(fields, + zap.Uint16("type", req.Type), + zap.Uint16("version", req.Version), + zap.Uint16("blocks", req.Blocks), + zap.Uint16("crc", req.CRC), + zap.String("crcHex", fmt.Sprintf("%04X", req.CRC)), + ) + } + if scriptRes != nil { + if scriptRes.FirmwareID != "" { + fields = append(fields, zap.String("firmwareId", scriptRes.FirmwareID)) + } + if scriptRes.BlockSize > 0 { + fields = append(fields, zap.Int("blockSize", scriptRes.BlockSize)) + } + if len(scriptRes.Labels) > 0 { + fields = append(fields, zap.Any("labels", scriptRes.Labels)) + } + } + p.logger.Debug("fota onConfig: no update", fields...) +} + +func (p *Provider) respondFotaDisabled(node *nodeTY.Node, rxPL string, req *firmwareConfigRequest, startTime time.Time) (string, error) { + p.logger.Debug("FOTA disabled for node", + zap.String("nodeId", node.ID), + zap.String("msNodeId", node.NodeID), + zap.String("timeTaken", time.Since(startTime).String()), + ) + return p.echoFirmwareConfigOrSkip(rxPL, req) +} - // create firmware config response struct and update required values +func (p *Provider) respondFotaScriptInactive(node *nodeTY.Node, rxPL string, req *firmwareConfigRequest, reason string) (string, error) { + p.logger.Debug(reason, + zap.String("nodeId", node.ID), + zap.String("fotaScript", getFotaScriptID(node)), + ) + return p.echoFirmwareConfigOrSkip(rxPL, req) +} + +func (p *Provider) echoFirmwareConfigOrSkip(rxPL string, req *firmwareConfigRequest) (string, error) { + if rxPL == "" || req == nil { + return "", nil + } + return p.toHex(&firmwareConfigResponse{ + Type: req.Type, + Version: req.Version, + Blocks: req.Blocks, + CRC: req.CRC, + }) +} + +// fotaRequestForLog is parseFotaRequest without raw []byte (JSON would be base64). +func fotaRequestForLog(rxPL string) map[string]interface{} { + parsed := parseFotaRequest(rxPL, true) + out := make(map[string]interface{}, len(parsed)) + for k, v := range parsed { + if _, isBytes := v.([]byte); isBytes { + continue + } + out[k] = v + } + return out +} + +func (p *Provider) buildFirmwareConfigResponse(node *nodeTY.Node, fwRaw *firmwareRaw, startTime time.Time, fwID string) (string, error) { fwCfgRes := &firmwareConfigResponse{} // if erase eeprom set for this node, update erase eeprom command and clear the label on the node detail if node.Labels.GetBool(LabelEraseEEPROM) { p.logger.Debug("erase EEPROM enabled, sending erase EEPROM command to the node", zap.String("nodeId", node.ID)) - // set erase command fwCfgRes.SetEraseEEPROM() - // remove erase config data from node node.Labels.Set(LabelEraseEEPROM, "false") - p.setNodeLabels(node) - } else { // update assigned firmware config details + } else { fwCfgRes.Type = fwRaw.Type fwCfgRes.Version = fwRaw.Version fwCfgRes.Blocks = fwRaw.Blocks fwCfgRes.CRC = fwRaw.CRC } - p.logger.Debug("sending a firmware config respose", zap.Any("request", fwCfgReq), zap.Any("response", fwCfgRes), zap.String("timeTaken", time.Since(startTime).String())) + p.logger.Debug("sending a firmware config response", + zap.Any("response", fwCfgRes), + zap.Int("blockSize", fwRaw.BlockSize), + zap.String("firmwareId", fwID), + zap.String("fotaScript", getFotaScriptID(node)), + zap.String("timeTaken", time.Since(startTime).String()), + ) - // convert the struct to hex string and return return p.toHex(fwCfgRes) } @@ -90,47 +253,287 @@ func (p *Provider) executeFirmwareRequest(msg *msgTY.Message) (string, error) { return "", err } - // get firmware raw format - fwRaw, err := p.fetchFirmware(node, fwReq.Type, fwReq.Version, true) - if err != nil { - return "", fmt.Errorf("error on getting firmware. %s", err.Error()) + // Use the slice size advertised in CONFIG_RESPONSE. Do not re-guess per block. + blockSize := p.getFotaSessionBlockSize(node) + if blockSize <= 0 { + blockSize = p.otaBlockSizeForNode(node) + } + if blockSize <= 0 { + blockSize = defaultFirmwareBlockSize + } + if isFotaDisabled(node) { + p.logger.Debug("FOTA disabled for node", + zap.String("nodeId", node.ID), + zap.String("msNodeId", node.NodeID), + ) + return "", nil } - fwRaw.LastAccess = time.Now() - // create firmware config response struct and update required values - fwRes := &firmwareResponse{ - Type: fwReq.Type, - Version: fwReq.Version, - Block: fwReq.Block, + var fwRaw *firmwareRaw + if hasFotaScript(node) { + scriptDisabled, err := p.isFotaScriptDisabled(node) + if err != nil { + return "", err + } + if scriptDisabled { + p.logger.Debug("fota script disabled in data repository", + zap.String("nodeId", node.ID), + zap.String("fotaScript", getFotaScriptID(node)), + ) + return "", nil + } + scriptRes, err := p.runFotaOnBlock(node, rxPL, fwReq.Type, fwReq.Version, fwReq.Block) + if err != nil { + return "", fmt.Errorf("fota onBlock failed: %w", err) + } + if scriptRes.ResponseHex != "" { + p.logger.Debug("sending firmware block response from fota script", + zap.String("nodeId", node.ID), + zap.Uint16("block", fwReq.Block), + zap.String("timeTaken", time.Since(startTime).String()), + ) + return scriptRes.ResponseHex, nil + } + fwRaw, err = p.fetchFirmwareByID(scriptRes.FirmwareID, fwReq.Type, fwReq.Version, false, blockSize) + if err != nil { + return "", fmt.Errorf("error on getting firmware from fota onBlock: %w", err) + } + } else { + fwRaw, err = p.fetchFirmware(node, fwReq.Type, fwReq.Version, true, blockSize) + if err != nil { + return "", fmt.Errorf("error on getting firmware. %s", err.Error()) + } } + fwRaw.LastAccess = time.Now() - startAddr := fwReq.Block * firmwareBlockSize - endAddr := startAddr + firmwareBlockSize - if int(endAddr) > len(fwRaw.Data) { - p.logger.Error("requested block is not available", zap.Uint16("startAddr", startAddr), zap.Uint16("endAddr", endAddr), zap.Int("maxAvailableAddr", len(fwRaw.Data))) + bs := fwRaw.BlockSize + if bs <= 0 { + bs = blockSize + } + if bs <= 0 { + bs = defaultFirmwareBlockSize + } + startAddr := int(fwReq.Block) * bs + endAddr := startAddr + bs + if endAddr > len(fwRaw.Data) { + p.logger.Error("requested block is not available", zap.Int("startAddr", startAddr), zap.Int("endAddr", endAddr), zap.Int("maxAvailableAddr", len(fwRaw.Data))) return "", fmt.Errorf("requested block is not available: %v", endAddr) } - copy(fwRes.Data[:], fwRaw.Data[startAddr:endAddr]) - p.logger.Debug("sending a firmware response", zap.Any("request", fwReq), zap.Any("response", fwRes), zap.String("timeTaken", time.Since(startTime).String())) + chunk := fwRaw.Data[startAddr:endAddr] + if len(chunk) != bs { + return "", fmt.Errorf("firmware block %d slice length %d != blockSize %d", fwReq.Block, len(chunk), bs) + } + hexPL, err := p.encodeFirmwareBlockResponse(fwReq.Type, fwReq.Version, fwReq.Block, chunk) + if err != nil { + return "", err + } + if len(hexPL) != 2*(6+bs) { + return "", fmt.Errorf("firmware block hex length %d, want %d (blockSize=%d)", len(hexPL), 2*(6+bs), bs) + } + if fwReq.Block == 0 || fwReq.Block%50 == 0 { + p.logger.Debug("sending a firmware response", + zap.Any("request", fwReq), + zap.Int("blockSize", bs), + zap.Int("responseBytes", 6+bs), + zap.Int("responseHexLen", len(hexPL)), + zap.String("timeTaken", time.Since(startTime).String()), + ) + } else { + p.logger.Debug("sending a firmware response", zap.Any("request", fwReq), zap.Int("blockSize", bs), zap.String("timeTaken", time.Since(startTime).String())) + } - p.updateFirmwareProgressStatus(node, int(fwReq.Block), len(fwRaw.Data)) + p.updateFirmwareProgressStatus(node, int(fwReq.Block), len(fwRaw.Data), bs) - // convert the struct to hex string and return - return p.toHex(fwRes) + return hexPL, nil } -// fetchFirmware looks requested firmware on memory store, +func packFirmwareBlockResponse(typeID, versionID, block uint16, data []byte) (string, error) { + buf := make([]byte, 6+len(data)) + binary.LittleEndian.PutUint16(buf[0:2], typeID) + binary.LittleEndian.PutUint16(buf[2:4], versionID) + binary.LittleEndian.PutUint16(buf[4:6], block) + copy(buf[6:], data) + return hexENC.EncodeToString(buf), nil +} + +// encodeFirmwareBlockResponse uses the same 22-byte toHex layout as the +// working 328 path when blockSize is 16. +func (p *Provider) encodeFirmwareBlockResponse(typeID, versionID, block uint16, data []byte) (string, error) { + if len(data) == defaultFirmwareBlockSize { + fwRes := firmwareResponse{Type: typeID, Version: versionID, Block: block} + copy(fwRes.Data[:], data) + return p.toHex(&fwRes) + } + return packFirmwareBlockResponse(typeID, versionID, block, data) +} + +func sanitizeOtaHex(requestHex string) string { + requestHex = strings.TrimSpace(requestHex) + requestHex = strings.TrimPrefix(requestHex, "0x") + requestHex = strings.TrimPrefix(requestHex, "0X") + requestHex = strings.ReplaceAll(requestHex, " ", "") + requestHex = strings.ReplaceAll(requestHex, "\r", "") + requestHex = strings.ReplaceAll(requestHex, "\n", "") + if len(requestHex)%2 == 1 { + requestHex = requestHex[:len(requestHex)-1] + } + return requestHex +} + +func validOtaBlockSize(n int) int { + if n < 8 || n > maxFirmwareBlockSize || n%8 != 0 { + return 0 + } + return n +} + +func otaBlockSizeFromRequest(requestHex string) int { + return validOtaBlockSize(advertisedOtaBlockSize(requestHex)) +} + +// advertisedOtaBlockSize returns the protocol 3.1 size the node reported. +func advertisedOtaBlockSize(requestHex string) int { + requestHex = sanitizeOtaHex(requestHex) + if requestHex == "" { + return 0 + } + req := parseFotaRequest(requestHex, true) + if raw, ok := req["blockSize"]; ok { + n := int(converterUtils.ToInteger(raw)) + if n >= 8 && n%8 == 0 { + return n + } + } + b, err := hexENC.DecodeString(requestHex) + if err != nil { + return 0 + } + if len(b) >= 11 && binary.LittleEndian.Uint16(b[8:10]) == 0x0103 { + n := int(b[10]) + if n >= 8 && n%8 == 0 { + return n + } + } + for i := 0; i+2 < len(b); i++ { + if b[i] == 0x03 && b[i+1] == 0x01 { + n := int(b[i+2]) + if n >= 8 && n%8 == 0 { + return n + } + } + } + return 0 +} + +func (p *Provider) rememberOtaBlockSize(node *nodeTY.Node, blockSize int) { + if node == nil || node.Labels == nil { + return + } + blockSize = validOtaBlockSize(blockSize) + if blockSize <= 0 { + return + } + want := fmt.Sprintf("%d", blockSize) + if node.Labels.Get(LabelOtaBlockSize) == want { + return + } + node.Labels.Set(LabelOtaBlockSize, want) + p.setNodeLabels(node) +} + +func (p *Provider) otaBlockSizeFromLabel(node *nodeTY.Node) int { + if node == nil || node.Labels == nil { + return 0 + } + return validOtaBlockSize(node.Labels.GetInt(LabelOtaBlockSize)) +} + +func (p *Provider) otaBlockSizeForNode(node *nodeTY.Node) int { + if n := p.getFotaSessionBlockSize(node); n > 0 { + return n + } + n := p.otaBlockSizeFromLabel(node) + if n > 0 { + return n + } + if hasFotaScript(node) { + return 0 + } + return defaultFirmwareBlockSize +} + +// resolveOtaBlockSize picks the node's OTA slice size. +// Priority: live CONFIG_REQUEST, script return, trusted label. +// Stock DualOptiboot falls back to 16. Custom FOTA must not advertise 16 +// unless the node actually reported 16. +func (p *Provider) resolveOtaBlockSize(node *nodeTY.Node, requestHex string, fromReq, fromScript int) (int, error) { + if fromReq <= 0 { + fromReq = otaBlockSizeFromRequest(requestHex) + } + fromScript = validOtaBlockSize(fromScript) + fromLabel := p.otaBlockSizeForNode(node) + + blockSize := fromReq + if blockSize == 0 { + blockSize = fromScript + } + if blockSize == 0 { + blockSize = fromLabel + } + if blockSize == 0 && !hasFotaScript(node) { + blockSize = defaultFirmwareBlockSize + } + + if p.logger != nil { + p.logger.Debug("resolved OTA block size", + zap.String("payload", requestHex), + zap.Int("payloadBytes", len(requestHex)/2), + zap.Int("fromRequest", fromReq), + zap.Int("fromScript", fromScript), + zap.Int("fromLabel", p.otaBlockSizeFromLabel(node)), + zap.Int("blockSize", blockSize), + ) + } + + if blockSize == 0 { + if advertised := advertisedOtaBlockSize(requestHex); advertised > maxFirmwareBlockSize { + return 0, fmt.Errorf("node advertised OTA blockSize=%d; controller max is %d (raise maxFirmwareBlockSize if radio/MQTT allow it)", advertised, maxFirmwareBlockSize) + } + return 0, fmt.Errorf("unknown OTA block size: node did not advertise protocol 3.1 blockSize yet (do not default to 16)") + } + if fromReq == 0 && blockSize != defaultFirmwareBlockSize { + p.rememberOtaBlockSize(node, blockSize) + } + return blockSize, nil +} + +func firmwareRawCacheKey(fwID string, blockSize int) string { + return fmt.Sprintf("%s#%d", fwID, blockSize) +} + +// fetchFirmware looks requested firmware on memory store (stock path: assigned_firmware label), // if not available, loads it from disk -func (p *Provider) fetchFirmware(node *nodeTY.Node, typeID, versionID uint16, verifyID bool) (*firmwareRaw, error) { - // get mapped firmware by id +func (p *Provider) fetchFirmware(node *nodeTY.Node, typeID, versionID uint16, verifyID bool, blockSize int) (*firmwareRaw, error) { fwID := node.Labels.Get(types.LabelNodeAssignedFirmware) if fwID == "" { - return nil, fmt.Errorf("firmware not assigned for this node. gatewayId:%s, nodeId:%s, typeId:%d, versionId:%d", node.GatewayID, node.NodeID, typeID, versionID) + return nil, fmt.Errorf("firmware not assigned for this node. gatewayId:%s, nodeId:%s, typeId:%d, versionId:%d", + node.GatewayID, node.NodeID, typeID, versionID) + } + return p.fetchFirmwareByID(fwID, typeID, versionID, verifyID, blockSize) +} + +// fetchFirmwareByID loads/caches firmware raw by entity id. +func (p *Provider) fetchFirmwareByID(fwID string, typeID, versionID uint16, verifyID bool, blockSize int) (*firmwareRaw, error) { + if fwID == "" { + return nil, fmt.Errorf("firmware id is empty") } + if blockSize <= 0 { + blockSize = defaultFirmwareBlockSize + } + cacheKey := firmwareRawCacheKey(fwID, blockSize) // lambda function to load firmware loadFirmwareRawFn := func() (*firmwareRaw, error) { - fw, err := p.getFirmware(fwID) if err != nil { p.logger.Error("error to get firmware raw", zap.Any("fwID", fwID), zap.Error(err)) @@ -144,21 +547,21 @@ func (p *Provider) fetchFirmware(node *nodeTY.Node, typeID, versionID uint16, ve fwTypeID := uint16(fw.Labels.GetInt(LabelFirmwareTypeID)) fwVersionID := uint16(fw.Labels.GetInt(LabelFirmwareVersionID)) - fwRaw, err := p.getFirmwareRaw(fw.ID, fwTypeID, fwVersionID) + fwRaw, err := p.getFirmwareRaw(fw.ID, fwTypeID, fwVersionID, blockSize) if err != nil { p.logger.Error("error on getting firmware data", zap.String("firmwareId", fw.ID), zap.Error(err)) return nil, err } // keep it on memory store - fwRawStore.Add(fwID, fwRaw) + fwRawStore.Add(cacheKey, fwRaw) return fwRaw, nil } // check firmware on memory store // if not found, load it from disk var fwRaw *firmwareRaw - fwRawInf := fwRawStore.Get(fwID) + fwRawInf := fwRawStore.Get(cacheKey) if fwRawInf == nil { _fwRaw, err := loadFirmwareRawFn() if err != nil { @@ -183,20 +586,37 @@ func (p *Provider) fetchFirmware(node *nodeTY.Node, typeID, versionID uint16, ve return fwRaw, nil } +// isIntelHexFile reports whether file bytes look like Intel HEX (leading ':'). +func isIntelHexFile(data []byte) bool { + for _, b := range data { + if b == ' ' || b == '\t' || b == '\r' || b == '\n' { + continue + } + return b == ':' + } + return false +} + // Source: https://en.wikipedia.org/wiki/Intel_HEX // https://github.com/mycontroller-org/mycontroller-v1-legacy/blob/1.5.0.Final/modules/core/src/main/java/org/mycontroller/standalone/firmware/FirmwareUtils.java#L118 // https://github.com/mysensors/MySensorsSampleController/blob/9dbae76081a9c080d5fdd68fba9870626025343f/NodeJsController.js#L172 // I8HEX files use only record types 00 and 01 (16-bit addresses) // 00 - data, 01 - End -// Example, // -// :10010000214601360121470136007EFE09D2190140 -// :100110002146017E17C20001FF5F16002148011928 -// :10012000194E79234623965778239EDA3F01B2CAA7 -// :100130003F0156702B5E712B722B732146013421C7 -// :00000001FF -// :(start) xx(byte count) xxxx(address) xx(record type) xxx...xx(data, checksum) +// Also accepts raw binary (e.g. STM32 signed.bin) when content does not start with ':'. func (p *Provider) hexByteToLocalFormat(typeID, versionID uint16, hexByte []byte, blockSize int) (*firmwareRaw, error) { + if len(hexByte) == 0 { + return nil, errors.New("no data available") + } + if blockSize <= 0 { + return nil, fmt.Errorf("invalid blockSize: %d", blockSize) + } + + // Raw binary path, not Intel HEX + if !isIntelHexFile(hexByte) { + return p.bytesToFirmwareRaw(typeID, versionID, hexByte, blockSize, false) + } + hexString := string(hexByte) hexString = strings.ReplaceAll(hexString, "\r", "") // remove all "\r" char hexLines := strings.Split(hexString, "\n") // split as separate lines @@ -241,15 +661,35 @@ func (p *Provider) hexByteToLocalFormat(typeID, versionID uint16, hexByte []byte return nil, errors.New("no data available") } - // add padding if needed - // ATMega328 has 64 words per page / 128 bytes per page - paddingCount := 128 - (len(actualData) % 128) - for paddingCount > 0 { - actualData = append(actualData, 255) // 255 => 0xFF - paddingCount-- + // DualOptiboot / AVR: pad to 128-byte pages + return p.bytesToFirmwareRaw(typeID, versionID, actualData, blockSize, true) +} + +// bytesToFirmwareRaw builds firmwareRaw from a contiguous image. +// padToAVRPage: when true, pad to 128-byte pages (classic DualOptiboot); when false +// (raw binary), pad only to OTA blockSize with 0xFF. +func (p *Provider) bytesToFirmwareRaw(typeID, versionID uint16, data []byte, blockSize int, padToAVRPage bool) (*firmwareRaw, error) { + actualData := make([]byte, len(data)) + copy(actualData, data) + + if padToAVRPage { + // ATMega328 has 64 words per page / 128 bytes per page + paddingCount := 128 - (len(actualData) % 128) + for paddingCount > 0 { + actualData = append(actualData, 255) // 255 => 0xFF + paddingCount-- + } + } else if rem := len(actualData) % blockSize; rem != 0 { + for i := 0; i < blockSize-rem; i++ { + actualData = append(actualData, 0xFF) + } } - numberOfBlocks := uint16(len(actualData) / blockSize) + nBlocks := len(actualData) / blockSize + if nBlocks > 0xFFFF { + return nil, fmt.Errorf("image too large for OTA block count: %d bytes (%d blocks)", len(actualData), nBlocks) + } + numberOfBlocks := uint16(nBlocks) // calculate crc // Source: https://github.com/mysensors/MySensorsBootloaderRF24/blob/37dcc50bf2825a2639fe904be8f3309df7b5859e/HW.h#L235 @@ -261,19 +701,23 @@ func (p *Provider) hexByteToLocalFormat(typeID, versionID uint16, hexByte []byte } } - fw := &firmwareRaw{ + return &firmwareRaw{ Type: typeID, Version: versionID, Data: actualData, Blocks: numberOfBlocks, CRC: crc, + BlockSize: blockSize, LastAccess: time.Now(), - } - return fw, nil + }, nil } func (p *Provider) setNodeLabels(node *nodeTY.Node) { - busUtils.PostToResourceService(p.logger, p.bus, node.ID, node, rsTY.TypeNode, rsTY.CommandSetLabel, "") + if p == nil || p.bus == nil || node == nil { + return + } + // CommandSetLabel expects label map payload + busUtils.PostToResourceService(p.logger, p.bus, node.ID, node.Labels, rsTY.TypeNode, rsTY.CommandSetLabel, "") } // toHex returns hex string @@ -296,14 +740,17 @@ func toStruct(hex string, out interface{}) error { return binary.Read(r, binary.LittleEndian, out) } -func (p *Provider) updateFirmwareProgressStatus(node *nodeTY.Node, currentBlock, totalBytes int) { +func (p *Provider) updateFirmwareProgressStatus(node *nodeTY.Node, currentBlock, totalBytes, blockSize int) { otaBlockOrder := node.Labels.Get(types.LabelNodeOTABlockOrder) if otaBlockOrder == "" { otaBlockOrder = OTABlockOrderReverse } + if blockSize <= 0 { + blockSize = defaultFirmwareBlockSize + } - totalBlocks := totalBytes / firmwareBlockSize - if totalBytes%firmwareBlockSize != 0 { + totalBlocks := totalBytes / blockSize + if totalBytes%blockSize != 0 { totalBlocks++ } diff --git a/plugin/gateway/provider/mysensors_v2/ota_script.go b/plugin/gateway/provider/mysensors_v2/ota_script.go new file mode 100644 index 00000000..83e328cc --- /dev/null +++ b/plugin/gateway/provider/mysensors_v2/ota_script.go @@ -0,0 +1,470 @@ +package mysensors + +import ( + "encoding/binary" + hexENC "encoding/hex" + "fmt" + "strings" + "time" + + repositoryTY "github.com/mycontroller-org/server/v2/pkg/types/data_repository" + nodeTY "github.com/mycontroller-org/server/v2/pkg/types/node" + rsTY "github.com/mycontroller-org/server/v2/pkg/types/resource_service" + "github.com/mycontroller-org/server/v2/pkg/utils/bus_utils/query" + converterUtils "github.com/mycontroller-org/server/v2/pkg/utils/convertor" + "github.com/mycontroller-org/server/v2/pkg/utils/javascript" + "go.uber.org/zap" +) + +// Data repository keys under Config.Data for FOTA policy scripts. +const ( + fotaDataKeyOnConfig = "onConfig" + fotaDataKeyOnBlock = "onBlock" + fotaDataKeyDisabled = "disabled" + + // script result map keys + fotaResultFirmwareID = "firmwareId" + fotaResultLabels = "labels" + fotaResultResponseHex = "responseHex" + fotaResultError = "error" + fotaResultNoUpdate = "noUpdate" + fotaResultBlockSize = "blockSize" + + // script input variables + fotaVarRequestHex = "requestHex" + fotaVarRequest = "request" + fotaVarGatewayID = "gatewayId" + fotaVarNodeID = "nodeId" + fotaVarNodeLabels = "nodeLabels" + fotaVarType = "type" + fotaVarVersion = "version" + fotaVarBlock = "block" + fotaVarGetFirmware = "getFirmware" + fotaVarCachedFwID = "cachedFirmwareId" + + defaultFotaScriptTimeout = 5 * time.Second +) + +// fotaScriptBundle holds scripts loaded from a data_repository entry. +type fotaScriptBundle struct { + ID string + OnConfig string + OnBlock string + Disabled bool +} + +// fotaScriptResult is the normalized return value from onConfig / onBlock. +type fotaScriptResult struct { + FirmwareID string + ResponseHex string + Labels map[string]string + NoUpdate bool + BlockSize int +} + +// getFotaScriptID returns trimmed data_repository id from node label fota_script, or "". +func getFotaScriptID(node *nodeTY.Node) string { + if node == nil || node.Labels == nil { + return "" + } + return strings.TrimSpace(node.Labels.Get(LabelFotaScript)) +} + +// hasFotaScript reports whether the node has a fota_script label. +func hasFotaScript(node *nodeTY.Node) bool { + return getFotaScriptID(node) != "" +} + +// isFotaDisabled reports node label fota_disabled=true (all OTA off for this node). +func isFotaDisabled(node *nodeTY.Node) bool { + return node != nil && node.Labels != nil && node.Labels.GetBool(LabelFotaDisabled) +} + +// isFotaScriptDisabled is true when the node has a script and the repository has data.disabled. +func (p *Provider) isFotaScriptDisabled(node *nodeTY.Node) (bool, error) { + if !hasFotaScript(node) { + return false, nil + } + bundle, err := p.getFotaScriptBundle(getFotaScriptID(node)) + if err != nil { + return false, err + } + return bundle.Disabled, nil +} + +func bundleFromRepo(cfg *repositoryTY.Config) *fotaScriptBundle { + if cfg == nil { + return nil + } + return &fotaScriptBundle{ + ID: cfg.ID, + OnConfig: strings.TrimSpace(cfg.Data.GetString(fotaDataKeyOnConfig)), + OnBlock: strings.TrimSpace(cfg.Data.GetString(fotaDataKeyOnBlock)), + Disabled: cfg.Data.GetBool(fotaDataKeyDisabled), + } +} + +func (p *Provider) getFotaScriptBundle(id string) (*fotaScriptBundle, error) { + if id == "" { + return nil, fmt.Errorf("fota script id is empty") + } + if cached := fotaScriptStore.Get(id); cached != nil { + if b, ok := cached.(*fotaScriptBundle); ok { + return b, nil + } + fotaScriptStore.Remove(id) + } + + out := &repositoryTY.Config{} + var bundle *fotaScriptBundle + addToStore := func(item interface{}) bool { + cfg, ok := item.(*repositoryTY.Config) + if !ok { + p.logger.Error("error on data conversion for data repository", + zap.String("receivedType", fmt.Sprintf("%T", item))) + return false + } + bundle = bundleFromRepo(cfg) + if bundle != nil { + // keep requested id as cache key even if entity id differs + if bundle.ID == "" { + bundle.ID = id + } + fotaScriptStore.Add(id, bundle) + } + return false + } + + err := query.QueryResource(p.logger, p.bus, id, rsTY.TypeDataRepository, rsTY.CommandGet, + nil, addToStore, out, queryTimeout) + if err != nil { + return nil, fmt.Errorf("load fota script %q: %w", id, err) + } + if bundle == nil { + return nil, fmt.Errorf("fota script %q not found in data repository", id) + } + return bundle, nil +} + +// runFotaOnConfig executes data.onConfig for ST_FIRMWARE_CONFIG_REQUEST. +func (p *Provider) runFotaOnConfig(node *nodeTY.Node, requestHex string) (*fotaScriptResult, error) { + result, err := p.runFotaScript(node, true, requestHex, 0, 0, 0) + if err != nil { + return nil, err + } + if result.NoUpdate { + p.clearFotaSession(node) + return result, nil + } + if result.FirmwareID != "" { + p.rememberFotaFirmware(node, result.FirmwareID) + } + return result, nil +} + +// runFotaOnBlock executes data.onBlock for ST_FIRMWARE_REQUEST. +// onBlock is optional: when empty, the firmwareId cached from onConfig is used. +func (p *Provider) runFotaOnBlock(node *nodeTY.Node, requestHex string, typeID, versionID, block uint16) (*fotaScriptResult, error) { + scriptID := getFotaScriptID(node) + if scriptID == "" { + return nil, fmt.Errorf("fota_script label is empty") + } + bundle, err := p.getFotaScriptBundle(scriptID) + if err != nil { + return nil, err + } + if strings.TrimSpace(bundle.OnBlock) == "" { + fwID := p.getFotaSessionFirmwareID(node) + if fwID == "" { + return nil, fmt.Errorf("data repository %q has empty onBlock and no firmwareId cached from onConfig", scriptID) + } + return &fotaScriptResult{FirmwareID: fwID}, nil + } + + result, err := p.runFotaScript(node, false, requestHex, typeID, versionID, block) + if err != nil { + return nil, err + } + if result.FirmwareID != "" { + p.rememberFotaFirmware(node, result.FirmwareID) + } + return result, nil +} + +func (p *Provider) runFotaScript(node *nodeTY.Node, isConfig bool, requestHex string, typeID, versionID, block uint16) (*fotaScriptResult, error) { + scriptID := getFotaScriptID(node) + if scriptID == "" { + return nil, fmt.Errorf("fota_script label is empty") + } + + bundle, err := p.getFotaScriptBundle(scriptID) + if err != nil { + return nil, err + } + + script := bundle.OnBlock + phaseName := fotaDataKeyOnBlock + if isConfig { + script = bundle.OnConfig + phaseName = fotaDataKeyOnConfig + } + if strings.TrimSpace(script) == "" { + return nil, fmt.Errorf("data repository %q has empty %s script", scriptID, phaseName) + } + + labelsMap := map[string]string{} + if node.Labels != nil { + for k, v := range node.Labels { + labelsMap[k] = v + } + } + + variables := map[string]interface{}{ + fotaVarRequestHex: requestHex, + fotaVarRequest: parseFotaRequest(requestHex, isConfig), + fotaVarGatewayID: node.GatewayID, + fotaVarNodeID: node.NodeID, + fotaVarNodeLabels: labelsMap, + fotaVarGetFirmware: func(id string) map[string]interface{} { + return p.firmwareMetaForScript(id, p.otaBlockSizeForNode(node)) + }, + fotaVarCachedFwID: p.getFotaSessionFirmwareID(node), + } + if !isConfig { + variables[fotaVarType] = typeID + variables[fotaVarVersion] = versionID + variables[fotaVarBlock] = block + } + + // Wrap in IIFE so authors can use top-level `return { ... }` (goja forbids bare return). + wrapped := "(function() {\n" + script + "\n})()" + + timeout := defaultFotaScriptTimeout + raw, err := javascript.Execute(p.logger, wrapped, variables, &timeout) + if err != nil { + return nil, fmt.Errorf("fota script %s (%s): %w", scriptID, phaseName, err) + } + + result, err := parseFotaScriptResult(raw) + if err != nil { + return nil, fmt.Errorf("fota script %s (%s): %w", scriptID, phaseName, err) + } + + // Apply label updates returned by the script (any keys; BL policy owns them) + if len(result.Labels) > 0 { + if node.Labels == nil { + node.Labels = make(map[string]string) + } + changed := false + for k, v := range result.Labels { + if node.Labels.Get(k) != v { + node.Labels.Set(k, v) + changed = true + } + } + if changed { + p.setNodeLabels(node) + } + } + + return result, nil +} + +func (p *Provider) fotaSessionKey(node *nodeTY.Node) string { + if node == nil { + return "" + } + return p.getNodeStoreID(node.GatewayID, node.NodeID) +} + +func (p *Provider) rememberFotaFirmware(node *nodeTY.Node, fwID string) { + p.rememberFotaSession(node, fwID, 0) +} + +func (p *Provider) rememberFotaSession(node *nodeTY.Node, fwID string, blockSize int) { + key := p.fotaSessionKey(node) + fwID = strings.TrimSpace(fwID) + if key == "" || fwID == "" { + return + } + prev := p.getFotaSession(node) + if blockSize <= 0 && prev != nil { + blockSize = prev.BlockSize + } + fotaSessionStore.Add(key, &fotaSession{FirmwareID: fwID, BlockSize: blockSize}) +} + +func (p *Provider) getFotaSession(node *nodeTY.Node) *fotaSession { + key := p.fotaSessionKey(node) + if key == "" { + return nil + } + v := fotaSessionStore.Get(key) + if v == nil { + return nil + } + if s, ok := v.(*fotaSession); ok { + return s + } + // older sessions stored a bare firmwareId string + if id, ok := v.(string); ok && id != "" { + return &fotaSession{FirmwareID: id} + } + return nil +} + +func (p *Provider) getFotaSessionFirmwareID(node *nodeTY.Node) string { + if s := p.getFotaSession(node); s != nil { + return s.FirmwareID + } + return "" +} + +func (p *Provider) getFotaSessionBlockSize(node *nodeTY.Node) int { + if s := p.getFotaSession(node); s != nil { + return validOtaBlockSize(s.BlockSize) + } + return 0 +} + +func (p *Provider) clearFotaSession(node *nodeTY.Node) { + key := p.fotaSessionKey(node) + if key == "" { + return + } + fotaSessionStore.Remove(key) +} + +// firmwareMetaForScript is injected into FOTA JS as getFirmware(id). +func (p *Provider) firmwareMetaForScript(id string, blockSize int) map[string]interface{} { + id = strings.TrimSpace(id) + if id == "" { + return map[string]interface{}{"error": "firmware id is empty"} + } + fw, err := p.getFirmware(id) + if err != nil { + return map[string]interface{}{"id": id, "error": err.Error()} + } + typeID := uint16(fw.Labels.GetInt(LabelFirmwareTypeID)) + versionID := uint16(fw.Labels.GetInt(LabelFirmwareVersionID)) + labels := map[string]string{} + if fw.Labels != nil { + for k, v := range fw.Labels { + labels[k] = v + } + } + out := map[string]interface{}{ + "id": fw.ID, + "type": typeID, + "version": versionID, + "labels": labels, + "checksum": fw.File.Checksum, + } + raw, err := p.getFirmwareRaw(id, typeID, versionID, blockSize) + if err != nil { + out["error"] = err.Error() + return out + } + out["crc"] = raw.CRC + out["blocks"] = raw.Blocks + n := len(raw.Data) + if n > fotaFirmwareHeadBytes { + n = fotaFirmwareHeadBytes + } + if n > 0 { + out["head"] = hexENC.EncodeToString(raw.Data[:n]) + } + return out +} + +// parseFotaRequest exposes the raw hex plus stock / protocol 3.1 fields when present. +// Encoding of img_* (A/B vs mcuboot vs DualOptiboot) stays in the script. +func parseFotaRequest(requestHex string, isConfig bool) map[string]interface{} { + requestHex = sanitizeOtaHex(requestHex) + out := map[string]interface{}{ + "hex": requestHex, + "length": 0, + } + if requestHex == "" { + return out + } + b, err := hexENC.DecodeString(requestHex) + if err != nil { + out["error"] = err.Error() + return out + } + out["bytes"] = b + out["length"] = len(b) + if isConfig { + if len(b) >= 10 { + out["type"] = binary.LittleEndian.Uint16(b[0:2]) + out["version"] = binary.LittleEndian.Uint16(b[2:4]) + out["blocks"] = binary.LittleEndian.Uint16(b[4:6]) + out["crc"] = binary.LittleEndian.Uint16(b[6:8]) + out["blVersion"] = binary.LittleEndian.Uint16(b[8:10]) + } + if len(b) >= 12 { + out["blockSize"] = b[10] + out["imgCommitted"] = b[11] + } + if len(b) >= 14 { + out["imgRevision"] = binary.LittleEndian.Uint16(b[12:14]) + } + if len(b) >= 18 { + out["imgBuildNum"] = binary.LittleEndian.Uint32(b[14:18]) + } + return out + } + if len(b) >= 6 { + out["type"] = binary.LittleEndian.Uint16(b[0:2]) + out["version"] = binary.LittleEndian.Uint16(b[2:4]) + out["block"] = binary.LittleEndian.Uint16(b[4:6]) + } + return out +} + +func parseFotaScriptResult(raw interface{}) (*fotaScriptResult, error) { + if raw == nil { + return nil, fmt.Errorf("script returned nil") + } + m, err := javascript.ToMap(raw) + if err != nil { + return nil, fmt.Errorf("script must return a map/object: %w", err) + } + + if errMsg := strings.TrimSpace(converterUtils.ToString(m[fotaResultError])); errMsg != "" { + return nil, fmt.Errorf("%s", errMsg) + } + + result := &fotaScriptResult{ + FirmwareID: strings.TrimSpace(converterUtils.ToString(m[fotaResultFirmwareID])), + ResponseHex: strings.TrimSpace(converterUtils.ToString(m[fotaResultResponseHex])), + Labels: map[string]string{}, + NoUpdate: converterUtils.ToBool(m[fotaResultNoUpdate]), + BlockSize: validOtaBlockSize(int(converterUtils.ToInteger(m[fotaResultBlockSize]))), + } + + if labelsRaw, ok := m[fotaResultLabels]; ok && labelsRaw != nil { + switch lv := labelsRaw.(type) { + case map[string]string: + for k, v := range lv { + result.Labels[k] = v + } + case map[string]interface{}: + for k, v := range lv { + result.Labels[k] = converterUtils.ToString(v) + } + default: + if lm, err := javascript.ToMap(labelsRaw); err == nil { + for k, v := range lm { + result.Labels[k] = converterUtils.ToString(v) + } + } + } + } + + if !result.NoUpdate && result.FirmwareID == "" && result.ResponseHex == "" { + return nil, fmt.Errorf("script must return firmwareId, responseHex, and/or noUpdate") + } + return result, nil +} diff --git a/plugin/gateway/provider/mysensors_v2/ota_store.go b/plugin/gateway/provider/mysensors_v2/ota_store.go index 5b7863b3..5d723fa9 100644 --- a/plugin/gateway/provider/mysensors_v2/ota_store.go +++ b/plugin/gateway/provider/mysensors_v2/ota_store.go @@ -15,11 +15,19 @@ import ( ) var ( - nodeStore = concurrency.NewStore() - fwStore = concurrency.NewStore() - fwRawStore = concurrency.NewStore() + nodeStore = concurrency.NewStore() + fwStore = concurrency.NewStore() + fwRawStore = concurrency.NewStore() + fotaScriptStore = concurrency.NewStore() // data_repository id → *fotaScriptBundle + fotaSessionStore = concurrency.NewStore() // gateway_node → *fotaSession ) +// fotaSession is the firmware + slice size advertised in onConfig, reused for every block. +type fotaSession struct { + FirmwareID string + BlockSize int +} + func firmwareRawPurge() { for _, fwID := range fwRawStore.Keys() { fwInf := fwRawStore.Get(fwID) @@ -121,7 +129,7 @@ func (p *Provider) updateFirmware(id string) error { } // getFirmwareRaw func -func (p *Provider) getFirmwareRaw(id string, fwTypeID, fwVersionID uint16) (*firmwareRaw, error) { +func (p *Provider) getFirmwareRaw(id string, fwTypeID, fwVersionID uint16, blockSize int) (*firmwareRaw, error) { toFirmwareRaw := func(item interface{}) (*firmwareRaw, error) { if fw, ok := item.(*firmwareRaw); ok { return fw, nil @@ -129,58 +137,97 @@ func (p *Provider) getFirmwareRaw(id string, fwTypeID, fwVersionID uint16) (*fir return nil, fmt.Errorf("unknown data received in the place node: %T", item) } - data := fwRawStore.Get(id) + if blockSize <= 0 { + blockSize = defaultFirmwareBlockSize + } + cacheKey := firmwareRawCacheKey(id, blockSize) + data := fwRawStore.Get(cacheKey) if data != nil { return toFirmwareRaw(data) } - err := p.updateFirmwareFile(id, fwTypeID, fwVersionID) + err := p.updateFirmwareFile(id, fwTypeID, fwVersionID, blockSize) if err != nil { return nil, err } - data = fwRawStore.Get(id) + data = fwRawStore.Get(cacheKey) if data != nil { return toFirmwareRaw(data) } return nil, fmt.Errorf("firmware not available. id:%v", id) } -func (p *Provider) updateFirmwareFile(id string, fwTypeID, fwVersionID uint16) error { - var hexBytes []byte +func assembleFirmwareBytes(blocks map[int][]byte, totalBytes int) ([]byte, bool) { + if totalBytes <= 0 || len(blocks) == 0 { + return nil, false + } + out := make([]byte, totalBytes) + seen := make([]bool, totalBytes) + for n, data := range blocks { + start := firmwareTY.BlockSize * n + for i, v := range data { + pos := start + i + if pos >= totalBytes { + break + } + out[pos] = v + seen[pos] = true + } + } + for i := 0; i < totalBytes; i++ { + if !seen[i] { + return nil, false + } + } + return out, true +} + +func (p *Provider) updateFirmwareFile(id string, fwTypeID, fwVersionID uint16, blockSize int) error { + // Load metadata first. Do not CommandGet from inside the block callback; + // that nested query races with remaining block replies on the same bus. + fw, err := p.getFirmware(id) + if err != nil { + return err + } + + blocks := map[int][]byte{} + totalBytes := 0 addToStore := func(item interface{}) bool { fwBlock, ok := item.(*firmwareTY.FirmwareBlock) if !ok { p.logger.Error("error on data conversion", zap.String("receivedType", fmt.Sprintf("%T", item))) return false } - if hexBytes == nil { - hexBytes = make([]byte, fwBlock.TotalBytes) + if fwBlock.TotalBytes > 0 { + totalBytes = fwBlock.TotalBytes } - startPos := int(firmwareTY.BlockSize * fwBlock.BlockNumber) - for offset, byteData := range fwBlock.Data { - hexBytes[startPos+offset] = byteData + cp := make([]byte, len(fwBlock.Data)) + copy(cp, fwBlock.Data) + blocks[fwBlock.BlockNumber] = cp + + hexBytes, complete := assembleFirmwareBytes(blocks, totalBytes) + if !complete { + return true } - if fwBlock.IsFinal { - receivedCheckSum := fmt.Sprintf("sha256:%x", sha256.Sum256(hexBytes)) - fw, err := p.getFirmware(id) - if err != nil { - p.logger.Error("error on getting firmare config", zap.Error(err), zap.String("firmwareId", id)) - return false - } - if fw.File.Checksum == receivedCheckSum { - // convert the hex file to raw format - fwRaw, err := p.hexByteToLocalFormat(fwTypeID, fwVersionID, hexBytes, firmwareBlockSize) - if err != nil { - p.logger.Error("error on converting hex to local format", zap.String("firmwareId", id), zap.Error(err)) - return false - } - fwRawStore.Add(id, fwRaw) - } else { - p.logger.Info("received firmware checksum mismatch", zap.String("fwID", fw.ID), zap.String("remote", fw.File.Checksum), zap.String("received", receivedCheckSum)) - } + receivedCheckSum := fmt.Sprintf("sha256:%x", sha256.Sum256(hexBytes)) + if fw.File.Checksum != receivedCheckSum { + p.logger.Info("firmware file checksum mismatch (re-upload the firmware in the UI)", + zap.String("fwID", fw.ID), + zap.String("file", fw.File.Name), + zap.Int("bytes", len(hexBytes)), + zap.Int("blockCount", len(blocks)), + zap.String("stored", fw.File.Checksum), + zap.String("computed", receivedCheckSum), + ) + return false + } + fwRaw, err := p.hexByteToLocalFormat(fwTypeID, fwVersionID, hexBytes, blockSize) + if err != nil { + p.logger.Error("error on converting hex to local format", zap.String("firmwareId", id), zap.Error(err)) return false } - return true // continue + fwRawStore.Add(firmwareRawCacheKey(id, blockSize), fwRaw) + return false } return query.QueryResource(p.logger, p.bus, id, rsTY.TypeFirmware, rsTY.CommandBlocks, nil, addToStore, &firmwareTY.FirmwareBlock{}, queryFirmwareFileTimeout) diff --git a/plugin/gateway/provider/mysensors_v2/ota_types.go b/plugin/gateway/provider/mysensors_v2/ota_types.go index 747372c8..0dd2ab54 100644 --- a/plugin/gateway/provider/mysensors_v2/ota_types.go +++ b/plugin/gateway/provider/mysensors_v2/ota_types.go @@ -2,11 +2,26 @@ package mysensors import "time" -// firmwareBlockSize for more detail +// defaultFirmwareBlockSize is used only for stock DualOptiboot nodes that never +// report protocol 3.1 blockSize. A node with fota_script must not fall back to 16 +// unless it actually advertised 16. // https://github.com/mysensors/MySensors/blob/2.3.2/core/MyOTAFirmwareUpdate.h#L68~L71 -const firmwareBlockSize = 16 +const defaultFirmwareBlockSize = 16 -// firmwareConfigRequest data +// maxFirmwareBlockSize is the largest OTA data slice this controller will serve. +// The node/gateway fork extends V2 length (5-bit field = 31 means "see _extLength"). +// LoRa 255-byte packets + AES pad + 6-byte radio header comfortably fit 192. +// Raise this if the radio MTU and MQTT_MAX_PACKET_SIZE are increased together. +const maxFirmwareBlockSize = 192 + +// LabelOtaBlockSize is set from the node's CONFIG_REQUEST blockSize (protocol 3.1). +const LabelOtaBlockSize = "ms_ota_block_size" + +// fotaFirmwareHeadBytes is how many leading image bytes getFirmware() exposes +// to FOTA scripts as `head` (hex). Scripts interpret the contents. +const fotaFirmwareHeadBytes = 16 + +// firmwareConfigRequest data (stock 10-byte layout; longer payloads still decode first 10) type firmwareConfigRequest struct { Type uint16 Version uint16 @@ -40,20 +55,22 @@ type firmwareRequest struct { Block uint16 } -// firmwareResponse data +// firmwareResponse is the stock 16-byte-data ST_FIRMWARE_RESPONSE (6 + 16 = 22). +// Same layout the 328 + RFM69 path used via toHex. type firmwareResponse struct { Type uint16 Version uint16 Block uint16 - Data [firmwareBlockSize]uint8 + Data [defaultFirmwareBlockSize]uint8 } -// firmwareRaw returns firmwareRaw details +// firmwareRaw is a contiguous OTA image sliced to BlockSize. type firmwareRaw struct { Type uint16 `json:"type" yaml:"type"` Version uint16 `json:"version" yaml:"version"` Data []uint8 `json:"data" yaml:"data"` Blocks uint16 `json:"blocks" yaml:"blocks"` CRC uint16 `json:"crc" yaml:"crc"` + BlockSize int `json:"blockSize" yaml:"blockSize"` LastAccess time.Time `json:"lastAccess" yaml:"lastAccess"` } diff --git a/plugin/gateway/provider/mysensors_v2/ota_types_test.go b/plugin/gateway/provider/mysensors_v2/ota_types_test.go new file mode 100644 index 00000000..518a37d1 --- /dev/null +++ b/plugin/gateway/provider/mysensors_v2/ota_types_test.go @@ -0,0 +1,663 @@ +package mysensors + +import ( + "bytes" + "encoding/binary" + hexENC "encoding/hex" + "testing" + "time" + + "github.com/mycontroller-org/server/v2/pkg/types/cmap" + repositoryTY "github.com/mycontroller-org/server/v2/pkg/types/data_repository" + firmwareTY "github.com/mycontroller-org/server/v2/pkg/types/firmware" + nodeTY "github.com/mycontroller-org/server/v2/pkg/types/node" + "github.com/mycontroller-org/server/v2/pkg/utils/javascript" + "go.uber.org/zap" +) + +func TestFotaScriptID(t *testing.T) { + n := &nodeTY.Node{Labels: cmap.CustomStringMap{}} + if getFotaScriptID(n) != "" { + t.Fatal("empty labels") + } + n.Labels.Set(LabelFotaScript, " ota_stm32_ab ") + if getFotaScriptID(n) != "ota_stm32_ab" { + t.Fatalf("want trimmed id got %q", getFotaScriptID(n)) + } + if !hasFotaScript(n) { + t.Fatal("should use fota script") + } + n.Labels.Set(LabelFotaScript, " ") + if hasFotaScript(n) { + t.Fatal("whitespace-only should not use script") + } +} + +func TestFotaDisabled(t *testing.T) { + n := &nodeTY.Node{Labels: cmap.CustomStringMap{LabelFotaScript: "ota_stm32_ab"}} + if isFotaDisabled(n) { + t.Fatal("default must allow FOTA") + } + n.Labels.Set(LabelFotaDisabled, "true") + if !isFotaDisabled(n) { + t.Fatal("fota_disabled=true") + } + n.Labels.Set(LabelFotaDisabled, "false") + if isFotaDisabled(n) { + t.Fatal("fota_disabled=false") + } +} + +func TestFotaRepoDisabled(t *testing.T) { + if bundleFromRepo(&repositoryTY.Config{ID: "s"}).Disabled { + t.Fatal("missing disabled defaults off") + } + if !bundleFromRepo(&repositoryTY.Config{ + ID: "s", + Data: cmap.CustomMap{"onConfig": "return {noUpdate:true};", "disabled": "true"}, + }).Disabled { + t.Fatal("disabled true") + } + if bundleFromRepo(&repositoryTY.Config{ + ID: "s", + Data: cmap.CustomMap{"disabled": "false"}, + }).Disabled { + t.Fatal("disabled false") + } +} + +func TestParseFotaScriptResult_BlockSize(t *testing.T) { + res, err := parseFotaScriptResult(map[string]interface{}{ + "firmwareId": "fw_slot_b", + "blockSize": 24, + }) + if err != nil { + t.Fatal(err) + } + if res.BlockSize != 24 { + t.Fatalf("blockSize: %d", res.BlockSize) + } +} + +func TestParseFotaScriptResult_OK(t *testing.T) { + raw := map[string]interface{}{ + "firmwareId": "fw_slot_b", + "labels": map[string]interface{}{ + "ab_request_slot": "1", + }, + } + res, err := parseFotaScriptResult(raw) + if err != nil { + t.Fatal(err) + } + if res.FirmwareID != "fw_slot_b" { + t.Fatalf("firmwareId: %q", res.FirmwareID) + } + if res.Labels["ab_request_slot"] != "1" { + t.Fatalf("labels: %+v", res.Labels) + } +} + +func TestParseFotaScriptResult_ErrorField(t *testing.T) { + _, err := parseFotaScriptResult(map[string]interface{}{ + "error": "bad slots", + }) + if err == nil || err.Error() != "bad slots" { + t.Fatalf("want bad slots got %v", err) + } +} + +func TestParseFotaScriptResult_ResponseHexOnly(t *testing.T) { + res, err := parseFotaScriptResult(map[string]interface{}{ + "responseHex": "aabb", + }) + if err != nil { + t.Fatal(err) + } + if res.ResponseHex != "aabb" || res.FirmwareID != "" { + t.Fatalf("%+v", res) + } +} + +func TestParseFotaScriptResult_Missing(t *testing.T) { + _, err := parseFotaScriptResult(map[string]interface{}{ + "labels": map[string]interface{}{"x": "1"}, + }) + if err == nil { + t.Fatal("expected error when neither firmwareId nor responseHex nor noUpdate") + } +} + +func TestParseFotaScriptResult_NoUpdate(t *testing.T) { + res, err := parseFotaScriptResult(map[string]interface{}{ + "noUpdate": true, + "labels": map[string]interface{}{"ab_running_slot": "A"}, + }) + if err != nil { + t.Fatal(err) + } + if !res.NoUpdate || res.FirmwareID != "" { + t.Fatalf("%+v", res) + } + if res.Labels["ab_running_slot"] != "A" { + t.Fatalf("labels: %+v", res.Labels) + } +} + +func TestParseFotaRequest_Protocol31(t *testing.T) { + // type=1, version=2, blocks=3, crc=4, bl=0x0103, blockSize=16, + // img_committed=0xA1 (running B), img_revision=0 (request A), + // img_build_num = (1<<16)|(0<<8)|0xAB + b := make([]byte, 18) + binary.LittleEndian.PutUint16(b[0:2], 1) + binary.LittleEndian.PutUint16(b[2:4], 2) + binary.LittleEndian.PutUint16(b[4:6], 3) + binary.LittleEndian.PutUint16(b[6:8], 4) + binary.LittleEndian.PutUint16(b[8:10], 0x0103) + b[10] = 16 + b[11] = 0xA1 + binary.LittleEndian.PutUint16(b[12:14], 0) + binary.LittleEndian.PutUint32(b[14:18], (1<<16)|0xAB) + req := parseFotaRequest(hexENC.EncodeToString(b), true) + if req["type"] != uint16(1) || req["version"] != uint16(2) { + t.Fatalf("type/version: %+v", req) + } + if req["imgCommitted"] != byte(0xA1) { + t.Fatalf("imgCommitted: %+v", req["imgCommitted"]) + } + if req["imgRevision"] != uint16(0) { + t.Fatalf("imgRevision: %+v", req["imgRevision"]) + } + if req["length"] != 18 { + t.Fatalf("length: %+v", req["length"]) + } +} + +func TestParseFotaRequest_Empty(t *testing.T) { + req := parseFotaRequest("", true) + if req["hex"] != "" || req["length"] != 0 { + t.Fatalf("%+v", req) + } +} + +func TestIsIntelHexFile(t *testing.T) { + if !isIntelHexFile([]byte(":10000000AABB\n")) { + t.Fatal("expected intel hex") + } + if !isIntelHexFile([]byte("\n\r :1000")) { + t.Fatal("expected intel hex after whitespace") + } + if isIntelHexFile([]byte{0x00, 0x00, 0x00, 0x20, 0x01, 0x00}) { + t.Fatal("raw binary must not look like hex") + } +} + +func TestOtaBlockSizeFromRequest(t *testing.T) { + if otaBlockSizeFromRequest("") != 0 { + t.Fatal("empty should be unset") + } + b := make([]byte, 18) + b[10] = 16 + hex := hexENC.EncodeToString(b) + if otaBlockSizeFromRequest(hex) != 16 { + t.Fatalf("got %d", otaBlockSizeFromRequest(hex)) + } + b[10] = 24 + if otaBlockSizeFromRequest(hexENC.EncodeToString(b)) != 24 { + t.Fatalf("24 got %d", otaBlockSizeFromRequest(hexENC.EncodeToString(b))) + } + b[10] = 128 + if otaBlockSizeFromRequest(hexENC.EncodeToString(b)) != 128 { + t.Fatal("128 must be accepted with extended V2 length") + } + b[10] = 7 + if otaBlockSizeFromRequest(hexENC.EncodeToString(b)) != 0 { + t.Fatal("too small should be unset") + } +} + +func TestOtaBlockSizeFromRequest_Protocol31Scan(t *testing.T) { + // Official 18-byte request: type/ver/blocks/crc + 0x0103 + 16 + 0xA0 + b := make([]byte, 18) + binary.LittleEndian.PutUint16(b[8:10], 0x0103) + b[10] = 16 + b[11] = 0xA0 + if otaBlockSizeFromRequest(hexENC.EncodeToString(b)) != 16 { + t.Fatalf("official 3.1 got %d", otaBlockSizeFromRequest(hexENC.EncodeToString(b))) + } +} + +func TestAdvertisedOtaBlockSize128IsAccepted(t *testing.T) { + leftover := "FFFF030180A00100" + if advertisedOtaBlockSize(leftover) != 128 { + t.Fatalf("scan should see 128, got %d", advertisedOtaBlockSize(leftover)) + } + p := &Provider{} + node := &nodeTY.Node{Labels: cmap.CustomStringMap{LabelFotaScript: "ota_stm32_ab"}} + bs, err := p.resolveOtaBlockSize(node, leftover, 0, 0) + if err != nil || bs != 128 { + t.Fatalf("128-byte blocks must be accepted: %d %v", bs, err) + } +} + +func TestResolveOtaBlockSize_Label16IsUsedForServing(t *testing.T) { + p := &Provider{} + node := &nodeTY.Node{Labels: cmap.CustomStringMap{ + LabelFotaScript: "ota_stm32_ab", + LabelOtaBlockSize: "16", + }} + if p.otaBlockSizeForNode(node) != 16 { + t.Fatalf("advertised 16 must be used, got %d", p.otaBlockSizeForNode(node)) + } + bs, err := p.resolveOtaBlockSize(node, "", 0, 0) + if err != nil || bs != 16 { + t.Fatalf("label 16 should resolve: %d %v", bs, err) + } +} + +func TestFotaScriptRejectsSwappedImageFromHead(t *testing.T) { + // B-linked Reset_Handler at 0x08021A01 in getFirmware().head + head := "00800020011A0208" + script := ` + function u32leHex(hex, byteOff) { + var i = byteOff * 2; + return parseInt(hex.substr(i+6,2)+hex.substr(i+4,2)+hex.substr(i+2,2)+hex.substr(i,2), 16); + } + function linkedSlot(fw) { + var reset = u32leHex(fw.head, 4) & 0xFFFFFFFE; + if (reset >= 0x08004000 && reset < 0x08021800) return "A"; + if (reset >= 0x08021800 && reset < 0x0803F000) return "B"; + return ""; + } + var fw = getFirmware("x"); + if (linkedSlot(fw) !== "A") { + return { error: "linked " + linkedSlot(fw) + " want A" }; + } + return { firmwareId: "x" }; + ` + wrapped := "(function() {\n" + script + "\n})()" + getFw := func(id string) map[string]interface{} { + return map[string]interface{}{"id": id, "head": head} + } + logger := zap.NewNop() + timeout := 2 * time.Second + raw, err := javascript.Execute(logger, wrapped, map[string]interface{}{"getFirmware": getFw}, &timeout) + if err != nil { + t.Fatal(err) + } + _, err = parseFotaScriptResult(raw) + if err == nil || err.Error() != "linked B want A" { + t.Fatalf("want swap error, got %v", err) + } +} + +func TestFotaScriptRejectsUnknownHead(t *testing.T) { + script := ` + function linkedSlot(fw) { + if (!fw || !fw.head) return ""; + return ""; + } + function rejectWrongSlot(fw, wantSlot, fwId) { + if (!fw || fw.error) { + return { error: (fw && fw.error) ? String(fw.error) : ("getFirmware failed for " + fwId) }; + } + if (!fw.head) { + return { error: "firmware " + fwId + " has no head; cannot verify slot link" }; + } + var got = linkedSlot(fw); + if (!got || got !== wantSlot) { + return { error: "firmware " + fwId + " Reset_Handler is not in slot A or B" }; + } + return null; + } + var fw = getFirmware("x"); + var wrong = rejectWrongSlot(fw, "B", "x"); + if (wrong) { + return wrong; + } + return { firmwareId: "x" }; + ` + wrapped := "(function() {\n" + script + "\n})()" + logger := zap.NewNop() + timeout := 2 * time.Second + + run := func(head interface{}) error { + getFw := func(id string) map[string]interface{} { + out := map[string]interface{}{"id": id} + if head != nil { + out["head"] = head + } + return out + } + raw, err := javascript.Execute(logger, wrapped, map[string]interface{}{"getFirmware": getFw}, &timeout) + if err != nil { + return err + } + _, err = parseFotaScriptResult(raw) + return err + } + + if err := run(nil); err == nil || err.Error() != "firmware x has no head; cannot verify slot link" { + t.Fatalf("missing head: %v", err) + } + if err := run("0000000000000000"); err == nil || err.Error() != "firmware x Reset_Handler is not in slot A or B" { + t.Fatalf("unlinked head: %v", err) + } +} + +func TestFotaScriptRadioContinuesPendingServe(t *testing.T) { + // Same type+version would noUpdate, but ab_serve_firmware is a different CRC. + script := ` + function rejectWrongSlot() { return null; } + var pendingId = nodeLabels["ab_serve_firmware"]; + var pendingFw = getFirmware(pendingId); + if (!pendingFw.error && +request.crc !== +pendingFw.crc) { + return { firmwareId: pendingId }; + } + if (+request.type === 1 && +request.version === 1 && +request.crc === 0x0AB4) { + return { noUpdate: true }; + } + return { firmwareId: "fota_test_a" }; + ` + wrapped := "(function() {\n" + script + "\n})()" + getFw := func(id string) map[string]interface{} { + if id == "fota_test_a" { + return map[string]interface{}{"id": id, "type": uint16(1), "version": uint16(1), "crc": uint16(0x2B32)} + } + return map[string]interface{}{"id": id, "type": uint16(1), "version": uint16(1), "crc": uint16(0x0AB4)} + } + logger := zap.NewNop() + timeout := 2 * time.Second + raw, err := javascript.Execute(logger, wrapped, map[string]interface{}{ + "request": map[string]interface{}{"type": 1, "version": 1, "crc": 0x0AB4}, + "nodeLabels": map[string]string{"ab_serve_firmware": "fota_test_a"}, + "getFirmware": getFw, + }, &timeout) + if err != nil { + t.Fatal(err) + } + res, err := parseFotaScriptResult(raw) + if err != nil { + t.Fatal(err) + } + if res.NoUpdate || res.FirmwareID != "fota_test_a" { + t.Fatalf("pending serve must continue, got %+v", res) + } +} + +func TestAssembleFirmwareBytes(t *testing.T) { + // Single full-file block (new resource service path) + all := []byte{1, 2, 3, 4, 5} + got, ok := assembleFirmwareBytes(map[int][]byte{0: all}, len(all)) + if !ok || string(got) != string(all) { + t.Fatalf("full file: ok=%v %v", ok, got) + } + + // Incomplete 512-byte chunks must not look done + if _, ok := assembleFirmwareBytes(map[int][]byte{0: make([]byte, firmwareTY.BlockSize)}, firmwareTY.BlockSize*2); ok { + t.Fatal("missing second chunk must be incomplete") + } + + chunk0 := bytes.Repeat([]byte{0xAA}, firmwareTY.BlockSize) + chunk1 := []byte{0xBB, 0xCC} + got, ok = assembleFirmwareBytes(map[int][]byte{0: chunk0, 1: chunk1}, firmwareTY.BlockSize+2) + if !ok || len(got) != firmwareTY.BlockSize+2 || got[0] != 0xAA || got[firmwareTY.BlockSize] != 0xBB { + t.Fatalf("chunked: ok=%v len=%d", ok, len(got)) + } +} + +func TestEncodeFirmwareBlockResponseStock16(t *testing.T) { + p := &Provider{} + data := make([]byte, 16) + data[0] = 0xAA + hex, err := p.encodeFirmwareBlockResponse(1, 1, 0, data) + if err != nil { + t.Fatal(err) + } + if len(hex) != 44 { + t.Fatalf("stock 16-byte block must be 22 bytes / 44 hex, got %d", len(hex)) + } + raw, err := hexENC.DecodeString(hex) + if err != nil { + t.Fatal(err) + } + if len(raw) != 22 || raw[6] != 0xAA { + t.Fatalf("layout %d %x", len(raw), raw) + } +} + +func TestResolveOtaBlockSize_StockDefaults16(t *testing.T) { + p := &Provider{} + node := &nodeTY.Node{Labels: cmap.CustomStringMap{}} + bs, err := p.resolveOtaBlockSize(node, "", 0, 0) + if err != nil || bs != 16 { + t.Fatalf("stock node should default 16: %d %v", bs, err) + } +} + +func TestRememberOtaBlockSizeStoresLive16(t *testing.T) { + p := &Provider{} + node := &nodeTY.Node{Labels: cmap.CustomStringMap{LabelOtaBlockSize: "24"}} + p.rememberOtaBlockSize(node, 16) + if node.Labels.Get(LabelOtaBlockSize) != "16" { + t.Fatalf("live 16 should replace 24, got %q", node.Labels.Get(LabelOtaBlockSize)) + } +} + +func TestSanitizeOtaHex(t *testing.T) { + if sanitizeOtaHex(" AABBCC \r\n") != "AABBCC" { + t.Fatal("trim") + } + if sanitizeOtaHex("0xAABB") != "AABB" { + t.Fatal("0x prefix") + } +} + +func TestFirmwareBlockOffsetNoUint16Overflow(t *testing.T) { + // Block 0x0FFF * 16 = 65520; +16 = 65536 which overflows uint16 to 0. + const block uint16 = 0x0FFF + const bs = 16 + start := int(block) * bs + end := start + bs + if start != 65520 || end != 65536 { + t.Fatalf("start=%d end=%d", start, end) + } + data := make([]byte, 65536) + data[65520] = 0xAA + hex, err := packFirmwareBlockResponse(1, 1, block, data[start:end]) + if err != nil { + t.Fatal(err) + } + raw, _ := hexENC.DecodeString(hex) + if len(raw) != 6+16 || raw[6] != 0xAA { + t.Fatalf("%d %x", len(raw), raw) + } +} + +func TestPackFirmwareBlockResponse(t *testing.T) { + data := make([]byte, 128) + data[0] = 0xAB + hex, err := packFirmwareBlockResponse(1, 2, 3, data) + if err != nil { + t.Fatal(err) + } + raw, err := hexENC.DecodeString(hex) + if err != nil { + t.Fatal(err) + } + if len(raw) != 6+128 { + t.Fatalf("len %d", len(raw)) + } + if raw[6] != 0xAB { + t.Fatal("payload") + } +} + +func TestBytesToFirmwareRaw_BinaryPadToBlock(t *testing.T) { + p := &Provider{} + raw := []byte{1, 2, 3, 4, 5} // 5 bytes → pad to 16 + fw, err := p.bytesToFirmwareRaw(1, 2, raw, 16, false) + if err != nil { + t.Fatal(err) + } + if len(fw.Data) != 16 { + t.Fatalf("want len 16 got %d", len(fw.Data)) + } + if fw.Blocks != 1 { + t.Fatalf("want 1 block got %d", fw.Blocks) + } + for i := 5; i < 16; i++ { + if fw.Data[i] != 0xFF { + t.Fatalf("pad byte %d want 0xFF got %02X", i, fw.Data[i]) + } + } +} + +func TestBundleFromRepo(t *testing.T) { + cfg := &repositoryTY.Config{ + ID: "ota_stm32_ab", + Data: cmap.CustomMap{ + "onConfig": "cfg", + "onBlock": "blk", + }, + } + b := bundleFromRepo(cfg) + if b == nil || b.OnConfig != "cfg" || b.OnBlock != "blk" { + t.Fatalf("%+v", b) + } + folded := bundleFromRepo(&repositoryTY.Config{ + ID: "s", + Data: cmap.CustomMap{"ONCONFIG": "x"}, + }) + if folded.OnConfig != "x" { + t.Fatalf("normalized key: %+v", folded) + } +} + +func TestJavascriptOnConfigSample(t *testing.T) { + // Minimal onConfig that picks firmware from a node label (same pattern as STM32 A/B script) + script := ` + var fw = nodeLabels["assigned_firmware_slot_b"] || nodeLabels["assigned_firmware_slot_a"]; + if (!fw) { + return { error: "no slot firmware label" }; + } + return { + firmwareId: fw, + labels: { ab_request_slot: "1" } + }; + ` + // Execute via parse path only; full engine test would need logger; use goja through package if Provider available + // Here we just validate result shape with a synthetic map mimicking script return + res, err := parseFotaScriptResult(map[string]interface{}{ + "firmwareId": "water_b", + "labels": map[string]interface{}{"ab_request_slot": "1"}, + }) + if err != nil || res.FirmwareID != "water_b" { + t.Fatalf("%v %+v", err, res) + } + _ = script +} + +func TestFotaScriptUIEmptyRequestSameReleaseNoUpdate(t *testing.T) { + script := ` + if (!request || !request.hex || request.length === 0) { + var lastRun = nodeLabels["ab_running_slot"]; + var runKey = lastRun === "A" ? "assigned_firmware_slot_a" : "assigned_firmware_slot_b"; + var reqKey = lastRun === "A" ? "assigned_firmware_slot_b" : "assigned_firmware_slot_a"; + var runFw = getFirmware(nodeLabels[runKey]); + var reqFw = getFirmware(nodeLabels[reqKey]); + if (+runFw.type === +reqFw.type && +runFw.version === +reqFw.version && +runFw.type !== 0xFFFF) { + return { noUpdate: true }; + } + return { firmwareId: nodeLabels[reqKey] }; + } + return { firmwareId: "x" }; + ` + wrapped := "(function() {\n" + script + "\n})()" + getFw := func(id string) map[string]interface{} { + return map[string]interface{}{"id": id, "type": uint16(1), "version": uint16(1)} + } + logger := zap.NewNop() + timeout := 2 * time.Second + raw, err := javascript.Execute(logger, wrapped, map[string]interface{}{ + "request": parseFotaRequest("", true), + "nodeLabels": map[string]string{"ab_running_slot": "A", "assigned_firmware_slot_a": "fw_a", "assigned_firmware_slot_b": "fw_b"}, + "getFirmware": getFw, + }, &timeout) + if err != nil { + t.Fatal(err) + } + res, err := parseFotaScriptResult(raw) + if err != nil { + t.Fatal(err) + } + if !res.NoUpdate || res.FirmwareID != "" { + t.Fatalf("UI same release must noUpdate, got %+v", res) + } +} + +func TestFotaScriptExecuteUsesRequestObject(t *testing.T) { + b := make([]byte, 18) + binary.LittleEndian.PutUint16(b[0:2], 10) + binary.LittleEndian.PutUint16(b[2:4], 20) + b[11] = 0xA0 + binary.LittleEndian.PutUint16(b[12:14], 1) + hex := hexENC.EncodeToString(b) + script := ` + if ((request.imgCommitted & 0xF0) !== 0xA0) { + return { error: "not ab" }; + } + if (request.type === 10 && request.version === 20) { + return { noUpdate: true, labels: { ab_running_slot: "A", ab_request_slot: "B" } }; + } + return { firmwareId: "x" }; + ` + wrapped := "(function() {\n" + script + "\n})()" + logger := zap.NewNop() + timeout := 2 * time.Second + raw, err := javascript.Execute(logger, wrapped, map[string]interface{}{ + "request": parseFotaRequest(hex, true), + }, &timeout) + if err != nil { + t.Fatal(err) + } + res, err := parseFotaScriptResult(raw) + if err != nil { + t.Fatal(err) + } + if !res.NoUpdate || res.Labels["ab_running_slot"] != "A" { + t.Fatalf("%+v", res) + } +} + +func TestFotaScriptExecuteWithReturn(t *testing.T) { + // Integration-style: same wrap as runFotaScript + script := ` + if (!requestHex) { + return { error: "empty" }; + } + return { + firmwareId: nodeLabels["assigned_firmware_slot_b"], + labels: { ab_request_slot: "1" } + }; + ` + wrapped := "(function() {\n" + script + "\n})()" + // Use package javascript via Provider path: call parse after manual execute + // Minimal: evaluate with goja through javascript.Execute + logger := zap.NewNop() + timeout := 2 * time.Second + raw, err := javascript.Execute(logger, wrapped, map[string]interface{}{ + "requestHex": "aabb", + "nodeLabels": map[string]string{"assigned_firmware_slot_b": "fw_b"}, + }, &timeout) + if err != nil { + t.Fatal(err) + } + res, err := parseFotaScriptResult(raw) + if err != nil { + t.Fatal(err) + } + if res.FirmwareID != "fw_b" || res.Labels["ab_request_slot"] != "1" { + t.Fatalf("%+v", res) + } +} From dac697db88d1af58bed0868228a19de5b2f2f04c Mon Sep 17 00:00:00 2001 From: Jeeva Kandasamy Date: Sun, 6 Sep 2026 16:36:00 +0530 Subject: [PATCH 2/2] fix golangci-lint on FOTA event filter and component Start Rewrite the event type check with De Morgan's law. Gateway and handler Start block on success, so drop the always-true err != nil guard that staticcheck SA4023 flags. --- cmd/component/gateway/cmd/root.go | 4 +--- cmd/component/handler/cmd/root.go | 4 +--- plugin/gateway/provider/mysensors_v2/event_listener.go | 6 +++--- 3 files changed, 5 insertions(+), 9 deletions(-) diff --git a/cmd/component/gateway/cmd/root.go b/cmd/component/gateway/cmd/root.go index 717ea827..b8982057 100644 --- a/cmd/component/gateway/cmd/root.go +++ b/cmd/component/gateway/cmd/root.go @@ -34,9 +34,7 @@ var root = &cobra.Command{ ctx := context.Background() gateway := helper.Gateway{} err := gateway.Start(ctx, configFile) - if err != nil { - logger.Fatal("error on starting gateway", zap.Error(err)) - } + logger.Fatal("error on starting gateway", zap.Error(err)) }, } diff --git a/cmd/component/handler/cmd/root.go b/cmd/component/handler/cmd/root.go index 1260c185..aa59a154 100644 --- a/cmd/component/handler/cmd/root.go +++ b/cmd/component/handler/cmd/root.go @@ -34,9 +34,7 @@ var root = &cobra.Command{ ctx := context.Background() handler := helper.Handler{} err := handler.Start(ctx, configFile) - if err != nil { - logger.Fatal("error on starting handler", zap.Error(err)) - } + logger.Fatal("error on starting handler", zap.Error(err)) }, } diff --git a/plugin/gateway/provider/mysensors_v2/event_listener.go b/plugin/gateway/provider/mysensors_v2/event_listener.go index 65528851..1792d17d 100644 --- a/plugin/gateway/provider/mysensors_v2/event_listener.go +++ b/plugin/gateway/provider/mysensors_v2/event_listener.go @@ -85,9 +85,9 @@ func (p *Provider) onEvent(data *busTY.BusData) { } p.logger.Debug("Received an event", zap.Any("event", event)) - if !(event.EntityType == types.EntityNode || - event.EntityType == types.EntityFirmware || - event.EntityType == types.EntityDataRepository) || + if (event.EntityType != types.EntityNode && + event.EntityType != types.EntityFirmware && + event.EntityType != types.EntityDataRepository) || event.Entity == nil { return }