diff --git a/.devcontainer/postCreate.sh b/.devcontainer/postCreate.sh index 8078a1cc..a9adb996 100755 --- a/.devcontainer/postCreate.sh +++ b/.devcontainer/postCreate.sh @@ -2,3 +2,7 @@ set -e pre-commit install + +# Libraries needed by the Modbus TCP server (ESP32). eModbus is not in the Arduino registry. +arduino-cli lib install "Async TCP" +ARDUINO_LIBRARY_ENABLE_UNSAFE_INSTALL=true arduino-cli lib install --git-url https://github.com/eModbus/eModbus.git#v1.7.5stable diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index b1a159ef..6474d0cc 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -25,6 +25,9 @@ jobs: - name: Checkout uses: actions/checkout@v7 + - name: Test Modbus register map + run: python3 tests/modbus/run_tests.py + - name: Set version env var run: echo "GIT_SHORT_SHA=$(git rev-parse --short HEAD)" >> $GITHUB_ENV shell: bash @@ -59,7 +62,10 @@ jobs: arduino-cli core install esp32:esp32@3.3.11 - name: Install dependencies - run: arduino-cli lib install ringbuffer pubsubclient arduinojson dallastemperature onewire "Adafruit NeoPixel" + run: | + arduino-cli lib install ringbuffer pubsubclient arduinojson dallastemperature onewire "Adafruit NeoPixel" "Async TCP" + # eModbus is not in the Arduino library registry, install it from git + ARDUINO_LIBRARY_ENABLE_UNSAFE_INSTALL=true arduino-cli lib install --git-url https://github.com/eModbus/eModbus.git#v1.7.5stable - name: Compile Sketch for ESP8266 run: cd HeishaMon && arduino-cli compile --output-dir . --fqbn=esp8266:esp8266:d1_mini:xtal=160,vt=flash,ssl=basic,mmu=3216,non32xfer=fast,eesz=4M2M,ip=lm2f,dbg=Disabled,lvl=None____,wipe=none,baud=921600 --warnings=none --verbose HeishaMon.ino diff --git a/.gitignore b/.gitignore index 852084b8..bc656c63 100644 --- a/.gitignore +++ b/.gitignore @@ -7,6 +7,7 @@ pio .vscode .pioenvs +tests/modbus/build .cache build_output firmware.map diff --git a/AGENTS.md b/AGENTS.md index 53ea9cdd..5df9a09d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -17,11 +17,11 @@ scripts/build_esp8266.sh # builds HeishaMon/HeishaMon.ino for the small (Wemos scripts/build_esp32s3.sh # builds HeishaMon/HeishaMon.ino for the large (ESP32-S3) PCB ``` -Both scripts `cd HeishaMon` first and output the compiled binary/map alongside the sketch. Required libraries (installed via `arduino-cli lib install`, see `LIBSUSED.md` and `.github/workflows/main.yml`): `ringbuffer`, `pubsubclient`, `arduinojson`, `dallastemperature`, `onewire`, `Adafruit NeoPixel`. Board cores needed: `esp8266:esp8266` and `esp32:esp32@3.0.7`. +Both scripts `cd HeishaMon` first and output the compiled binary/map alongside the sketch. Required libraries (installed via `arduino-cli lib install`, see `LIBSUSED.md` and `.github/workflows/main.yml`): `ringbuffer`, `pubsubclient`, `arduinojson`, `dallastemperature`, `onewire`, `Adafruit NeoPixel`, `Async TCP` (the ESP32Async one; not `AsyncTCP`, which resolves to an outdated fork). Install eModbus separately with `ARDUINO_LIBRARY_ENABLE_UNSAFE_INSTALL=true arduino-cli lib install --git-url https://github.com/eModbus/eModbus.git#v1.7.5stable`; it is not in the Arduino registry. Board cores needed: `esp8266:esp8266` and `esp32:esp32@3.3.11`. There is a devcontainer (`.devcontainer/`) preconfigured with `arduino-cli` and these dependencies (image `ghcr.io/the78mole/heishamon-dev`) — prefer developing/building inside it if available, since board cores and libs are already installed there. -There is no unit test suite for the firmware itself. Verification is: does it compile for both boards, and (when possible) manual testing against real hardware or logged serial captures. `Tools/chksumChecker.js` is a standalone Node script for computing/verifying checksums of raw heat pump command packets documented in the README — run with plain `node Tools/chksumChecker.js`. +Run `python3 tests/modbus/run_tests.py` for the Modbus register map and request-handler regression tests (host build, no Arduino toolchain). There is no other unit test suite for the firmware itself. Verification is: does it compile for both boards, and (when possible) manual testing against real hardware or logged serial captures. `Tools/chksumChecker.js` is a standalone Node script for computing/verifying checksums of raw heat pump command packets documented in the README — run with plain `node Tools/chksumChecker.js`. The **rules engine and example rulesets do have tests**: `Examples/Rules/run_tests.sh` builds a host-side harness (`Examples/Rules/harness/`, plain `g++`, no Arduino toolchain) that compiles the real rules engine for Linux and runs each example's `tests/` scenarios against it, asserting the exact `@Set…` command stream. Run it after any change to `src/rules/`, `src/common/timerqueue.cpp`, the decode/command tables, or an example ruleset. @@ -60,6 +60,7 @@ Rulesets can be validated and behavior-tested **off-device**: `Examples/Rules/ha ### Other subsystems - **`dallas.cpp`/`dallas.h`**: DS18B20 1-wire temperature sensors on GPIO4, aliasing support, periodic MQTT resend. +- **`HeishaModbusServer.cpp`/`.h`, `ModbusRegisterMap.h`** (ESP32 only): opt-in Modbus TCP server (Settings: *Enable Modbus TCP*, *Allow Modbus writes*, both default off). eModbus callbacks run in the AsyncTCP task, so they only read a data snapshot and queue writes; `loop()` refreshes the snapshot and executes the queued writes. Modbus command IDs live in `ModbusRegisterMap.h` (name→ID tables), not in `commands.h`. Register map: `Modbus-Register-Mapping.md`. - **`s0.cpp`/`s0.h`**: S0 kWh-meter pulse counting on GPIO12/GPIO14, with persisted running totals (restorable from a retained MQTT value on boot). - **`gpio.cpp`/`gpio.h`**: generic extra GPIO configuration/control, also reachable from rules via the `gpio()` function and from MQTT (`gpio/` topic prefix). - **`HeishaOT.cpp`/`HeishaOT.h`**: OpenTherm thermostat integration (`?`-prefixed rule variables, `mqttOTCallback`). diff --git a/HeishaMon/HeishaModbusServer.cpp b/HeishaMon/HeishaModbusServer.cpp new file mode 100644 index 00000000..461455bb --- /dev/null +++ b/HeishaMon/HeishaModbusServer.cpp @@ -0,0 +1,839 @@ +#ifdef ESP32 +#include "HeishaModbusServer.h" +#include "gpio.h" +#include "ModbusRegisterMap.h" +#include "decode.h" +#include "commands.h" +#include "s0data.h" +#include +#include + +#include +#include +#include +#include + + +extern char actData[DATASIZE]; +extern char actDataExtra[DATASIZE]; +extern char actOptData[OPTDATASIZE]; +extern bool send_command(byte* command, int length); +extern void log_message(char *string); + +namespace { + +using namespace ModbusMap; + +static_assert(NUMBER_OF_TOPICS <= TOPIC_CAPACITY, "Main Modbus block is full"); +static_assert(NUMBER_OF_TOPICS_EXTRA <= TOPIC_CAPACITY, "Extra Modbus block is full"); +static_assert(NUMBER_OF_OPT_TOPICS <= TOPIC_CAPACITY, "Optional Modbus block is full"); + +enum class TopicSource { + Main, + Extra, + Optional +}; + +struct TopicRange { + uint16_t baseAddress; + uint16_t count; + TopicSource source; +}; + +constexpr TopicRange kTopicRanges[] = { + { MAIN_TOPIC_BASE, NUMBER_OF_TOPICS, TopicSource::Main }, + { EXTRA_TOPIC_BASE, NUMBER_OF_TOPICS_EXTRA, TopicSource::Extra }, + { OPTIONAL_TOPIC_BASE, NUMBER_OF_OPT_TOPICS, TopicSource::Optional } +}; + +template +constexpr size_t arraySize(const T (&)[N]) { + return N; +} + +static_assert(arraySize(MAIN_COMMANDS) == arraySize(commands), + "Assign permanent Modbus IDs to new commands in ModbusRegisterMap.h"); +static_assert(arraySize(OPTIONAL_COMMANDS) == arraySize(optionalCommands), + "Assign permanent Modbus IDs to new optional commands in ModbusRegisterMap.h"); + +// The Modbus callbacks run in the AsyncTCP task. Everything they share with the main +// loop lives below and is only accessed while holding stateMutex. +std::mutex stateMutex; +char snapshotMain[DATASIZE] = { 0 }; +char snapshotExtra[DATASIZE] = { 0 }; +char snapshotOpt[OPTDATASIZE] = { 0 }; +bool snapshotExtraAvailable = false; +bool snapshotRelay[RELAY_COUNT] = { false, false }; + +struct WriteRequest { + bool coil; + uint16_t address; // relay number for a coil, else the int16 command register + bool coilOn; + char payload[16]; // command value as MQTT payload, already validated and scaled +}; + +constexpr size_t WRITE_QUEUE_SIZE = 16; +WriteRequest writeQueue[WRITE_QUEUE_SIZE]; +size_t writeQueueHead = 0; +size_t writeQueueCount = 0; + +// Set once in setup() before the server accepts connections. +bool optionalPCB = false; +std::atomic s0Enabled{false}; +std::atomic writesAllowed{false}; + +static_assert(NUM_S0_COUNTERS * S0_PORT_STRIDE <= TOPIC_CAPACITY, "S0 Modbus block is full"); +static_assert(S0_FIELD_COUNT <= S0_PORT_STRIDE, "S0 port block is full"); +const char *const s0FieldNames[] = { + "Watt", "WatthourTotal", "Watthour_Last_Report", "PulseQuality", "AvgPulseWidth", "Enabled" +}; +const char *const s0FieldUnits[] = {"W", "Wh", "Wh", "%", "ms", "0/1"}; +static_assert(arraySize(s0FieldNames) == S0_FIELD_COUNT, "S0 field names missing"); +static_assert(arraySize(s0FieldUnits) == S0_FIELD_COUNT, "S0 field units missing"); + +bool decodeS0Address(uint16_t address, uint16_t &port, uint16_t &field, bool &floating, bool &highWord) { + for (port = 0; port < NUM_S0_COUNTERS; ++port) { + const uint16_t base = S0_TOPIC_BASE + port * S0_PORT_STRIDE; + floating = false; + if (decodeRange(address, base, S0_FIELD_COUNT, 1, field, highWord)) return true; + floating = true; + if (decodeRange(address, floatAddress(base), S0_FIELD_COUNT, 2, field, highWord)) return true; + } + return false; +} + +float s0FieldValue(const S0Reading &reading, uint16_t field) { + switch (field) { + case 0: return reading.watt; + case 1: return reading.watthourTotal; + case 2: return reading.watthour; + case 3: return reading.pulseQuality; + case 4: return reading.avgPulseWidth; + case 5: return reading.enabled ? 1.0f : 0.0f; + } + return 0; +} + +bool s0ToRegisterValue(uint16_t address, const S0Reading readings[NUM_S0_COUNTERS], uint16_t &result) { + uint16_t port, field; + bool floating, highWord; + if (!decodeS0Address(address, port, field, floating, highWord)) return false; + const float value = s0FieldValue(readings[port], field); + if (floating) { + uint32_t bits; + static_assert(sizeof(value) == sizeof(bits), "Unexpected float size"); + memcpy(&bits, &value, sizeof(bits)); + result = highWord ? uint16_t(bits >> 16) : uint16_t(bits); + } else { + // Match the unscaled int16 ranges: truncate fractions, saturate large values. + result = uint16_t(value >= 32767.0f ? 32767 : value <= 0.0f ? 0 : int16_t(value)); + } + return true; +} + +enum class CommandWriteResult { + Success, + InvalidAddress, + UnsupportedValue, + Unavailable, + Busy +}; + +bool isNumericValue(const String &value) { + if (value.length() == 0) { + return false; + } + bool hasDigits = false; + bool hasDecimal = false; + for (size_t i = 0; i < value.length(); ++i) { + char c = value.charAt(i); + if ((c == '-') && (i == 0)) { + continue; + } + if ((c == '.') && !hasDecimal) { + hasDecimal = true; + continue; + } + if (!isdigit(static_cast(c))) { + return false; + } + hasDigits = true; + } + return hasDigits; +} + +bool isTopicScale100(TopicSource source, unsigned int topicNumber) { + const char **description = nullptr; + switch (source) { + case TopicSource::Main: + description = (const char **)pgm_read_ptr(&topicDescription[topicNumber]); break; + case TopicSource::Extra: + description = (const char **)pgm_read_ptr(&xtopicDescription[topicNumber]); break; + case TopicSource::Optional: + description = (const char **)pgm_read_ptr(&opttopicDescription[topicNumber]); break; + } + return + (description == Celsius) || + (description == Kelvin) || + (description == LitersPerMin) || + (description == Pressure) || + (description == Bar) || + (description == Ampere); +} + +bool isErrorState(const String &value, uint16_t ®isterValue) { + if (value.length() < 2) { + return false; + } + + char prefix = value.charAt(0); + if (!isupper(static_cast(prefix))) { + return false; + } + + String numericPart = value.substring(1); + if (!isNumericValue(numericPart)) { + return false; + } + + long intValue = numericPart.toInt(); + intValue += ((prefix - 'A') + 1) * 1000; + + if (intValue > 32767) { + intValue = 32767; + } + if (intValue < -32768) { + intValue = -32768; + } + + registerValue = static_cast(static_cast(intValue)); + return true; +} + +// Non-numeric values that are not error codes read as 0. +void stringToRegisterValue(const String &value, uint16_t ®isterValue, TopicSource source, uint16_t topicIndex) { + if (!isNumericValue(value)) { + if (!isErrorState(value, registerValue)) { + registerValue = 0; + } + return; + } + if (isTopicScale100(source, topicIndex)) { + float fValue = value.toFloat(); + float scaled = fValue * 100.0f; + if (scaled > 32767.0f) { + scaled = 32767.0f; + } + if (scaled < -32768.0f) { + scaled = -32768.0f; + } + int16_t intValue = static_cast(roundf(scaled)); + registerValue = static_cast(intValue); + } else { + long intValue = value.toInt(); + if (intValue > 32767) { + intValue = 32767; + } + if (intValue < -32768) { + intValue = -32768; + } + registerValue = static_cast(static_cast(intValue)); + } +} + +bool decodeTopicAddress(uint16_t address, TopicSource &source, uint16_t &topicIndex) { + for (const TopicRange &range : kTopicRanges) { + bool highWord; + if (decodeRange(address, range.baseAddress, range.count, 1, topicIndex, highWord)) { + source = range.source; + return true; + } + } + return false; +} + +// Must be called with stateMutex held. Returns false when the topic does not exist or +// the data behind it is not available on this heat pump / configuration, so that the +// caller answers with an exception instead of a value decoded from an empty buffer. +bool fetchTopicString(TopicSource source, uint16_t topicIndex, String &value) { + switch (source) { + case TopicSource::Main: + if (topicIndex >= NUMBER_OF_TOPICS) { + return false; + } + value = getDataValue(snapshotMain, topicIndex); + return true; + case TopicSource::Extra: + if (topicIndex >= NUMBER_OF_TOPICS_EXTRA || !snapshotExtraAvailable) { + return false; + } + value = getDataValueExtra(snapshotExtra, topicIndex); + return true; + case TopicSource::Optional: + if (topicIndex >= NUMBER_OF_OPT_TOPICS || !optionalPCB) { + return false; + } + value = getOptDataValue(snapshotOpt, topicIndex); + return true; + } + return false; +} + +bool decodeFloatTopicAddress(uint16_t address, TopicSource &source, uint16_t &topicIndex, bool &highWord) { + for (const TopicRange &range : kTopicRanges) { + if (decodeRange(address, floatAddress(range.baseAddress), range.count, 2, topicIndex, highWord)) { + source = range.source; + return true; + } + } + return false; +} + +void stringToFloatWords(const String &value, uint16_t &msw, uint16_t &lsw) { + float fValue = 0; + if (isNumericValue(value)) { + fValue = value.toFloat(); + } + uint32_t raw = 0; + static_assert(sizeof(float) == sizeof(uint32_t), "Unexpected float size"); + memcpy(&raw, &fValue, sizeof(raw)); + msw = static_cast(raw >> 16); + lsw = static_cast(raw & 0xFFFF); +} + +bool topicToRegisterValue(uint16_t address, uint16_t ®isterValue) { + TopicSource source; + uint16_t topicIndex = 0; + if (!decodeTopicAddress(address, source, topicIndex)) { + return false; + } + + String topicValue; + if (!fetchTopicString(source, topicIndex, topicValue)) { + return false; + } + + stringToRegisterValue(topicValue, registerValue, source, topicIndex); + return true; +} + +bool topicToFloatRegisterValue(uint16_t address, uint16_t ®isterValue) { + TopicSource source; + uint16_t topicIndex = 0; + bool highWord = false; + if (!decodeFloatTopicAddress(address, source, topicIndex, highWord)) { + return false; + } + + String topicValue; + if (!fetchTopicString(source, topicIndex, topicValue)) { + return false; + } + + uint16_t msw = 0; + uint16_t lsw = 0; + stringToFloatWords(topicValue, msw, lsw); + registerValue = highWord ? msw : lsw; + return true; +} + +struct CommandTarget { + const char *name; + bool optional; + bool scale100; +}; + +// address is the int16 command register (20000...), see commandAddress(). +bool resolveCommand(uint16_t address, CommandTarget &target) { + for (const MainCommand &command : MAIN_COMMANDS) { + if (commandAddress(command.id) == address) { + target = { command.name, false, command.scale100 }; + return true; + } + } + for (const OptionalCommand &command : OPTIONAL_COMMANDS) { + if (address == OPTIONAL_COMMAND_BASE + command.id) { + target = { command.name, true, command.scale100 }; + return true; + } + } + return false; +} + +bool isJsonCommand(const char *commandTopic) { + return strcmp(commandTopic, "SetCurves") == 0; +} + +// int16 register: temperatures are x100. The heat pump commands only take whole degrees, so +// a value that would be truncated is refused. Optional PCB temperatures keep their decimals. +bool int16ToPayload(const CommandTarget &target, uint16_t registerValue, char *payload, size_t length) { + const int value = static_cast(registerValue); + if (!target.scale100) { + snprintf(payload, length, "%d", value); + } else if (target.optional) { + const int magnitude = value < 0 ? -value : value; + snprintf(payload, length, "%s%d.%02d", value < 0 ? "-" : "", magnitude / 100, magnitude % 100); + } else { + if (value % 100 != 0) { + return false; + } + snprintf(payload, length, "%d", value / 100); + } + return true; +} + +// float32 registers are never scaled: the value is the real value (21.5 = 21.5 degrees). +bool floatToPayload(const CommandTarget &target, uint16_t msw, uint16_t lsw, char *payload, size_t length) { + const uint32_t bits = (static_cast(msw) << 16) | lsw; + float value; + static_assert(sizeof(value) == sizeof(bits), "Unexpected float size"); + memcpy(&value, &bits, sizeof(value)); + if (!isfinite(value) || fabsf(value) > 32767.0f) { + return false; + } + if (target.optional && target.scale100) { + snprintf(payload, length, "%.2f", value); + return true; + } + if (value != roundf(value)) { + return false; + } + snprintf(payload, length, "%d", static_cast(value)); + return true; +} + +bool enqueueWrite(const WriteRequest &request) { + std::lock_guard lock(stateMutex); + if (writeQueueCount >= WRITE_QUEUE_SIZE) { + return false; + } + writeQueue[(writeQueueHead + writeQueueCount) % WRITE_QUEUE_SIZE] = request; + ++writeQueueCount; + return true; +} + +bool dequeueWrite(WriteRequest &request) { + std::lock_guard lock(stateMutex); + if (writeQueueCount == 0) { + return false; + } + request = writeQueue[writeQueueHead]; + writeQueueHead = (writeQueueHead + 1) % WRITE_QUEUE_SIZE; + --writeQueueCount; + return true; +} + +// Runs in the async task: only validates and queues, the command is executed by loop(). +// commandRegister is the int16 command register. For a float32 write, second is the low word +// and registerValue the high word. +CommandWriteResult handleWriteCommand(uint16_t commandRegister, uint16_t registerValue, bool isFloat, uint16_t second) { + CommandTarget target; + if (!resolveCommand(commandRegister, target)) { + return CommandWriteResult::InvalidAddress; + } + + if (isJsonCommand(target.name)) { + return CommandWriteResult::UnsupportedValue; + } + + // send_heatpump_command() silently ignores optional PCB commands when the PCB is disabled. + if (target.optional && !optionalPCB) { + return CommandWriteResult::Unavailable; + } + + WriteRequest request = { false, commandRegister, false, { 0 } }; + const bool valid = isFloat ? floatToPayload(target, registerValue, second, request.payload, sizeof(request.payload)) + : int16ToPayload(target, registerValue, request.payload, sizeof(request.payload)); + if (!valid) { + return CommandWriteResult::UnsupportedValue; + } + return enqueueWrite(request) ? CommandWriteResult::Success : CommandWriteResult::Busy; +} + +// Runs in loop(). +void executeWrite(const WriteRequest &request) { + if (request.coil) { + if (request.address == 0) setRelay1(request.coilOn); + else setRelay2(request.coilOn); + return; + } + + CommandTarget target; + if (!resolveCommand(request.address, target)) { + return; + } + + char topicName[32] = { 0 }; + strncpy(topicName, target.name, sizeof(topicName) - 1); + char payload[sizeof(request.payload)]; + memcpy(payload, request.payload, sizeof(payload)); + send_heatpump_command(topicName, payload, send_command, log_message, optionalPCB); +} + +} // namespace + +// Render from the same ranges and command tables used by Modbus itself. +// One row per callback keeps the HTTP response bounded on the device. +bool HeishaModbusServer::registerRow(uint16_t index, String &html) { + for (const TopicRange &range : kTopicRanges) { + if (index >= range.count) { + index -= range.count; + continue; + } + const char *name = nullptr; + const char *group = nullptr; + switch (range.source) { + case TopicSource::Main: name = topics[index]; group = "Main"; break; + case TopicSource::Extra: name = xtopics[index]; group = "Extra"; break; + case TopicSource::Optional: name = optTopics[index]; group = "Optional PCB"; break; + } + const uint16_t floatRegister = floatAddress(range.baseAddress + index); + html = ""; + html += group; + html += ""; + html += String(FPSTR(name)); + html += ""; + html += String(range.baseAddress + index); + html += ""; + html += String(floatRegister); + html += " / "; + html += String(floatRegister + 1); + html += "Read / FC03"; + // The scale is fixed by the topic unit, never by the current value text. + html += isTopicScale100(range.source, index) ? "int16: x100" : "int16: x1"; + html += "; float32: unscaled (real value)"; + if (range.source == TopicSource::Main && strcmp_P("Error", name) == 0) { + html += "; errors: letter block + code (H74 = 8074); float returns 0 for text"; + } + if (range.source == TopicSource::Optional) { + html += optionalPCB ? "; optional PCB enabled" : "; optional PCB disabled: illegal data address"; + } else if (range.source == TopicSource::Extra) { + html += "; requires extra heat-pump data: illegal data address if not available"; + } + html += ""; + return true; + } + if (index < NUM_S0_COUNTERS * S0_FIELD_COUNT) { + const uint16_t port = index / S0_FIELD_COUNT; + const uint16_t field = index % S0_FIELD_COUNT; + const uint16_t integer = S0_TOPIC_BASE + port * S0_PORT_STRIDE + field; + const uint16_t floating = floatAddress(integer); + html = "S0 "; + html += String(port + 1); + html += ""; + html += s0FieldNames[field]; + html += ""; + html += String(integer); + html += ""; + html += String(floating); + html += " / "; + html += String(floating + 1); + html += "Read / FC03"; + html += s0FieldUnits[field]; + html += "; x1; int16 truncates fractions and saturates at 32767; use float for energy"; + if (field == 1) html += "; includes restored total; no automatic flash persistence"; + if (field == 2) html += "; last completed S0 report interval; reads do not reset it"; + if (field == 5) html += "; enabled and initialized with a valid pulses/kWh setting"; + html += "; enable S0 in Settings; disabled inputs return 0"; + return true; + } + index -= NUM_S0_COUNTERS * S0_FIELD_COUNT; + const char *name; + uint16_t address; + bool optional = false; + bool scale100 = false; + if (index < arraySize(MAIN_COMMANDS)) { + address = commandAddress(MAIN_COMMANDS[index].id); + name = MAIN_COMMANDS[index].name; + scale100 = MAIN_COMMANDS[index].scale100; + } else { + index -= arraySize(MAIN_COMMANDS); + if (index < arraySize(OPTIONAL_COMMANDS)) { + address = OPTIONAL_COMMAND_BASE + OPTIONAL_COMMANDS[index].id; + name = OPTIONAL_COMMANDS[index].name; + scale100 = OPTIONAL_COMMANDS[index].scale100; + optional = true; + } else { + index -= arraySize(OPTIONAL_COMMANDS); + if (index < RELAY_COUNT) { + html = "RelayRelay "; + html += String(index + 1); + html += ""; + html += String(COIL_BASE + index); + html += " (coil)-Read / FC01, Write / FC05" + "FC05: 0x0000 = off; 0xFF00 = on; FC01: 1 = on"; + } else if (index == RELAY_COUNT) { + html = "DeviceRegister map version"; + html += String(VERSION_REGISTER); + html += "-Read / FC03uint16: "; + html += String(VERSION); + html += ""; + } else if (index < RELAY_COUNT + 1 + RELAY_COUNT) { + html = "RelayRelay "; + html += String(index - RELAY_COUNT); + html += " state"; + html += String(RELAY_STATE_REGISTER + index - RELAY_COUNT - 1); + html += "-Read / FC03uint16: 0 = off, 1 = on (same as coil "; + html += String(COIL_BASE + index - RELAY_COUNT - 1); + html += ")"; + } else { + return false; + } + return true; + } + } + html = ""; + html += optional ? "Optional PCB" : (address >= SYSTEM_COMMAND_BASE ? "System command" : "Command"); + html += ""; + html += name; + html += ""; + html += String(address); + html += ""; + if (isJsonCommand(name)) { + html += "-UnsupportedJSON command; use MQTT or HTTP"; + } else { + const uint16_t floatRegister = floatCommandAddress(address); + html += String(floatRegister); + html += " / "; + html += String(floatRegister + 1); + html += "Write / FC06, FC16"; + if (scale100) { + html += optional ? "int16: x100 (2150 = 21.50); float32: real value, unscaled (21.5)" + : "int16: x100, whole degrees only (2100 = 21); float32: real value, unscaled, whole degrees only (21.0)"; + } else { + html += "int16: x1; float32: real value, unscaled, whole numbers only"; + } + } + if (optional && !optionalPCB) html += "; optional PCB disabled: illegal data address"; + html += ""; + return true; +} + +// FC 0x03: Read Holding Registers +ModbusMessage HeishaModbusServer::FC_03(ModbusMessage request) { + ModbusMessage response; + uint16_t addr = 0; + uint16_t words = 0; + request.get(2, addr); + request.get(4, words); + + if (words == 0 || words > 125) { + response.setError(request.getServerID(), request.getFunctionCode(), ILLEGAL_DATA_VALUE); + return response; + } + if (uint32_t(addr) + words > 65536) { + response.setError(request.getServerID(), request.getFunctionCode(), ILLEGAL_DATA_ADDRESS); + return response; + } + + S0Reading s0Readings[NUM_S0_COUNTERS]; + bool sampledS0 = false; + // Sample once per request; never mix two readings in one float register pair. + for (uint16_t i = 0; i < words && !sampledS0; ++i) { + uint16_t port, field; + bool floating, highWord; + if (decodeS0Address(addr + i, port, field, floating, highWord)) { + readS0Readings(s0Enabled.load(), s0Readings); + sampledS0 = true; + } + } + + // Serve the whole request from one consistent snapshot of the heat pump data. + std::lock_guard lock(stateMutex); + + response.add(request.getServerID(), request.getFunctionCode(), (uint8_t)(words * 2)); + + for (uint16_t i = 0; i < words; ++i) { + uint16_t registerValue = 0; + uint16_t targetAddress = addr + i; + if (targetAddress == VERSION_REGISTER) { + registerValue = VERSION; + } else if (targetAddress >= RELAY_STATE_REGISTER && targetAddress < RELAY_STATE_REGISTER + RELAY_COUNT) { + registerValue = snapshotRelay[targetAddress - RELAY_STATE_REGISTER] ? 1 : 0; + } else if (!topicToRegisterValue(targetAddress, registerValue) && + !topicToFloatRegisterValue(targetAddress, registerValue) && + !(sampledS0 && s0ToRegisterValue(targetAddress, s0Readings, registerValue))) { + response.clear(); + response.setError(request.getServerID(), request.getFunctionCode(), ILLEGAL_DATA_ADDRESS); + return response; + } + response.add(registerValue); + } + return response; +} + +// FC 0x01: Read Coils. Coil 0 = relay 1, coil 1 = relay 2 (the same coils FC05 writes). +ModbusMessage HeishaModbusServer::FC_01(ModbusMessage request) { + ModbusMessage response; + uint16_t start = 0; + uint16_t count = 0; + request.get(2, start, count); + + if (count == 0 || count > 2000) { + response.setError(request.getServerID(), request.getFunctionCode(), ILLEGAL_DATA_VALUE); + return response; + } + if (start < COIL_BASE || uint32_t(start - COIL_BASE) + count > RELAY_COUNT) { + response.setError(request.getServerID(), request.getFunctionCode(), ILLEGAL_DATA_ADDRESS); + return response; + } + + uint8_t bits = 0; + { + std::lock_guard lock(stateMutex); + for (uint16_t i = 0; i < count; ++i) { + if (snapshotRelay[start - COIL_BASE + i]) bits |= uint8_t(1u << i); + } + } + response.add(request.getServerID(), request.getFunctionCode(), uint8_t(1), bits); + return response; +} + +// Maps a rejected command write to its Modbus exception. +static ModbusMessage writeError(ModbusMessage request, CommandWriteResult result) { + ModbusMessage response; + switch (result) { + case CommandWriteResult::InvalidAddress: + case CommandWriteResult::Unavailable: + response.setError(request.getServerID(), request.getFunctionCode(), ILLEGAL_DATA_ADDRESS); + break; + case CommandWriteResult::UnsupportedValue: + response.setError(request.getServerID(), request.getFunctionCode(), ILLEGAL_DATA_VALUE); + break; + case CommandWriteResult::Busy: + case CommandWriteResult::Success: + response.setError(request.getServerID(), request.getFunctionCode(), SERVER_DEVICE_BUSY); + break; + } + return response; +} + +// FC 0x05: Write Single Coil +ModbusMessage HeishaModbusServer::FC_05(ModbusMessage request) { + ModbusMessage response; + + uint16_t start = 0; + uint16_t state = 0; + request.get(2, start, state); + + if (!writesAllowed.load()) { + response.setError(request.getServerID(), request.getFunctionCode(), ILLEGAL_FUNCTION); + } else if (start < COIL_BASE || start - COIL_BASE >= RELAY_COUNT) { + response.setError(request.getServerID(), request.getFunctionCode(), ILLEGAL_DATA_ADDRESS); + } else if (state != 0x0000 && state != 0xFF00) { + response.setError(request.getServerID(), request.getFunctionCode(), ILLEGAL_DATA_VALUE); + } else if (!enqueueWrite({ true, static_cast(start - COIL_BASE), state == 0xFF00, { 0 } })) { + response.setError(request.getServerID(), request.getFunctionCode(), SERVER_DEVICE_BUSY); + } else { + response = ECHO_RESPONSE; + } + return response; +} + +// FC 0x06: Write Single Register +ModbusMessage HeishaModbusServer::FC_06(ModbusMessage request) { + ModbusMessage response; + uint16_t address = 0; + uint16_t value = 0; + request.get(2, address, value); + + if (!writesAllowed.load()) { + response.setError(request.getServerID(), request.getFunctionCode(), ILLEGAL_FUNCTION); + return response; + } + + const CommandWriteResult result = handleWriteCommand(address, value, false, 0); + if (result != CommandWriteResult::Success) { + return writeError(request, result); + } + + response.add(request.getServerID(), request.getFunctionCode()); + response.add(address); + response.add(value); + return response; +} + +// FC 0x10: Write Multiple Registers. Only single commands are supported: one int16 command +// register (same as FC06), or one float32 (two registers, MSW first) in the float command block. +ModbusMessage HeishaModbusServer::FC_16(ModbusMessage request) { + ModbusMessage response; + uint16_t start = 0; + uint16_t count = 0; + uint8_t byteCount = 0; + request.get(2, start, count); + request.get(6, byteCount); + + if (!writesAllowed.load()) { + response.setError(request.getServerID(), request.getFunctionCode(), ILLEGAL_FUNCTION); + return response; + } + if (count == 0 || count > 123 || byteCount != count * 2 || request.size() < 7u + byteCount) { + response.setError(request.getServerID(), request.getFunctionCode(), ILLEGAL_DATA_VALUE); + return response; + } + + CommandWriteResult result; + if (start >= FLOAT_COMMAND_BASE) { + const uint32_t offset = start - FLOAT_COMMAND_BASE; + if (offset % 2 != 0 || offset / 2 >= 3 * TOPIC_CAPACITY) { + result = CommandWriteResult::InvalidAddress; // must start at the high word of a command + } else if (count != 2) { + result = CommandWriteResult::UnsupportedValue; // exactly one float32 per request + } else { + uint16_t msw = 0; + uint16_t lsw = 0; + request.get(7, msw, lsw); + result = handleWriteCommand(COMMAND_BASE + offset / 2, msw, true, lsw); + } + } else if (count == 1) { + uint16_t value = 0; + request.get(7, value); + result = handleWriteCommand(start, value, false, 0); + } else { + result = CommandWriteResult::UnsupportedValue; + } + if (result != CommandWriteResult::Success) { + return writeError(request, result); + } + + response.add(request.getServerID(), request.getFunctionCode()); + response.add(start); + response.add(count); + return response; +} + +void HeishaModbusServer::setup(bool isOptionalPCB, bool isS0Enabled, bool allowWrites) +{ + optionalPCB = isOptionalPCB; + s0Enabled.store(isS0Enabled); + writesAllowed.store(allowWrites); + + _mbServer.registerWorker(1, READ_COIL, &HeishaModbusServer::FC_01); + _mbServer.registerWorker(1, WRITE_COIL, &HeishaModbusServer::FC_05); + _mbServer.registerWorker(1, READ_HOLD_REGISTER, &HeishaModbusServer::FC_03); + _mbServer.registerWorker(1, WRITE_HOLD_REGISTER, &HeishaModbusServer::FC_06); + _mbServer.registerWorker(1, WRITE_MULT_REGISTERS, &HeishaModbusServer::FC_16); + _mbServer.start(502, 1, 20000); +} + +void HeishaModbusServer::loop(bool isS0Enabled, bool extraDataBlockAvailable) +{ + s0Enabled.store(isS0Enabled); + + // Execute queued writes here, in the main loop, where sending commands, logging and + // publishing to MQTT are safe. + WriteRequest request; + while (dequeueWrite(request)) { + executeWrite(request); + } + + // Refresh the snapshot after the writes so a relay reads back the state that was just set. + const bool relays[RELAY_COUNT] = { getRelay1(), getRelay2() }; + std::lock_guard lock(stateMutex); + memcpy(snapshotMain, actData, sizeof(snapshotMain)); + memcpy(snapshotExtra, actDataExtra, sizeof(snapshotExtra)); + memcpy(snapshotOpt, actOptData, sizeof(snapshotOpt)); + snapshotExtraAvailable = extraDataBlockAvailable; + for (uint16_t i = 0; i < RELAY_COUNT; ++i) snapshotRelay[i] = relays[i]; +} +#endif diff --git a/HeishaMon/HeishaModbusServer.h b/HeishaMon/HeishaModbusServer.h new file mode 100644 index 00000000..dfe4f0a9 --- /dev/null +++ b/HeishaMon/HeishaModbusServer.h @@ -0,0 +1,27 @@ +#pragma once +#ifdef ESP32 +#include +#include "ModbusServerTCPasync.h" + +// Modbus TCP server. The eModbus callbacks run in the AsyncTCP task, not in loop(). +// They therefore never touch shared heat pump state directly: reads are served from a +// snapshot that loop() refreshes, and writes are queued for loop() to execute. +class HeishaModbusServer { +public: + void setup(bool isOptionalPCB, bool isS0Enabled, bool allowWrites); + // Call from the main loop: refreshes the data snapshot and executes queued writes. + void loop(bool isS0Enabled, bool extraDataBlockAvailable); + static bool registerRow(uint16_t index, String &html); + +private: + // eModbus callbacks (must be static) + static ModbusMessage FC_01(ModbusMessage request); + static ModbusMessage FC_03(ModbusMessage request); + static ModbusMessage FC_05(ModbusMessage request); + static ModbusMessage FC_06(ModbusMessage request); + static ModbusMessage FC_16(ModbusMessage request); + +private: + ModbusServerTCPasync _mbServer; +}; +#endif diff --git a/HeishaMon/HeishaMon.ino b/HeishaMon/HeishaMon.ino index 62f2160a..63b171bb 100644 --- a/HeishaMon/HeishaMon.ino +++ b/HeishaMon/HeishaMon.ino @@ -45,6 +45,9 @@ #include "commands.h" #include "rules.h" #include "version.h" +#ifdef ESP32 +#include "HeishaModbusServer.h" +#endif DNSServer dnsServer; @@ -142,7 +145,9 @@ static uint8_t cmdstart = 0; static uint8_t cmdend = 0; static uint8_t cmdnrel = 0; - +#ifdef ESP32 +HeishaModbusServer modbusServer; +#endif // mqtt #ifdef TLS_SUPPORT @@ -1073,6 +1078,10 @@ int8_t webserver_cb(struct webserver_t *client, void *dat) { case WEBSERVER_CLIENT_REQUEST_URI: { if (strcmp_P((char *)dat, PSTR("/")) == 0) { client->route = 1; +#ifdef ESP32 + } else if (strcmp_P((char *)dat, PSTR("/modbus")) == 0) { + client->route = 200; +#endif } else if (strcmp_P((char *)dat, PSTR("/json")) == 0) { client->route = 20; } else if (strcmp_P((char *)dat, PSTR("/reboot")) == 0) { @@ -1305,6 +1314,11 @@ int8_t webserver_cb(struct webserver_t *client, void *dat) { case 1: { return handleRoot(client, readpercentage, mqttReconnects, &heishamonSettings); } break; +#ifdef ESP32 + case 200: { + return handleModbus(client); + } break; +#endif case 20: { return handleJsonOutput(client, actData, actDataExtra, actOptData, &heishamonSettings, extraDataBlockAvailable); } break; @@ -1875,6 +1889,13 @@ void setup() { setupETH(); #endif +#ifdef ESP32 + if (heishamonSettings.modbus) { + loggingSerial.println(F("Setup Modbus TCP server..")); + modbusServer.setup(heishamonSettings.optionalPCB, heishamonSettings.use_s0, heishamonSettings.modbusWrites); + } +#endif + loggingSerial.println(F("Setup HTTP...")); setupHttp(); @@ -2018,6 +2039,10 @@ void loop() { // Handle OTA first.s ArduinoOTA.handle(); +#ifdef ESP32 + if (heishamonSettings.modbus) modbusServer.loop(heishamonSettings.use_s0, extraDataBlockAvailable); +#endif + mqtt_client.loop(); if (heishamonSettings.opentherm) { diff --git a/HeishaMon/ModbusRegisterMap.h b/HeishaMon/ModbusRegisterMap.h new file mode 100644 index 00000000..5fd68231 --- /dev/null +++ b/HeishaMon/ModbusRegisterMap.h @@ -0,0 +1,150 @@ +#pragma once + +#include + +// Map v3 (v2 addresses, temperature writes x100): never derive block bases from the number of currently known topics. +// Topic numbers and command IDs are permanent; append new IDs, never reuse them. +// +// Layout, low to high (every block has reserved room to grow): +// int16 measurements (FC03) 0 - 4999 main, extra, optional PCB, S0 +// int16 commands (FC06/FC16) 5000 - 9999 heat pump, optional PCB, system +// float32 measurements (FC03) 10000 - 19999 2 registers per int16 address +// float32 commands (FC16) 20000 - 29999 2 registers per int16 command +// coils (FC01/FC05, own address space) 30000 - 31999 relays +// device information (FC03) 32000 - 32999 version, relay state +namespace ModbusMap { +constexpr uint16_t VERSION = 3; +constexpr uint16_t TOPIC_CAPACITY = 1000; +constexpr uint16_t MAIN_TOPIC_BASE = 0; +constexpr uint16_t EXTRA_TOPIC_BASE = 1000; +constexpr uint16_t OPTIONAL_TOPIC_BASE = 2000; +constexpr uint16_t S0_TOPIC_BASE = 3000; +constexpr uint16_t S0_PORT_STRIDE = 100; +constexpr uint16_t S0_FIELD_COUNT = 6; +constexpr uint16_t VERSION_REGISTER = 32000; +constexpr uint16_t RELAY_STATE_REGISTER = 32010; // 32010 = relay 1, 32011 = relay 2 (0/1) +constexpr uint16_t FLOAT_BASE = 10000; +constexpr uint16_t COMMAND_BASE = 5000; +constexpr uint16_t OPTIONAL_COMMAND_BASE = 6000; +constexpr uint16_t SYSTEM_COMMAND_BASE = 7000; +constexpr uint16_t FLOAT_COMMAND_BASE = 20000; +constexpr uint16_t RESET_COMMAND_ID = 100; +constexpr uint16_t COIL_BASE = 30000; // relay 1 = coil 30000, relay 2 = coil 30001 +constexpr uint16_t RELAY_COUNT = 2; + +constexpr uint16_t floatAddress(uint16_t integerAddress) { + return FLOAT_BASE + 2 * integerAddress; +} + +constexpr uint16_t commandAddress(uint16_t id) { + return id == RESET_COMMAND_ID ? SYSTEM_COMMAND_BASE : COMMAND_BASE + id - 1; +} + +// float32 write address of a command: two registers (MSW first) per command, laid out like the +// int16 command block. Example: SetDHWTemp int16 5010, float32 20020 / 20021. +constexpr uint16_t floatCommandAddress(uint16_t commandRegister) { + return FLOAT_COMMAND_BASE + 2 * (commandRegister - COMMAND_BASE); +} + +// Only populated entries are valid. Reserved space must not alias another block. +inline bool decodeRange(uint16_t address, uint16_t base, uint16_t count, + uint16_t stride, uint16_t &index, bool &highWord) { + if ((stride != 1 && stride != 2) || count > TOPIC_CAPACITY || address < base || + uint32_t(address) >= uint32_t(base) + uint32_t(count) * stride) { + return false; + } + const uint16_t offset = address - base; + index = offset / stride; + highWord = (offset % stride) == 0; + return true; +} + +// Modbus IDs are kept here, separate from the command tables in commands.h, so the +// command parsing code does not need to know about Modbus. Commands are matched by +// name, which keeps addresses stable even if the command table is reordered. +struct MainCommand { + uint16_t id; + const char *name; + // Temperatures and deltas are written like they are read: as an int16 with two implied + // decimals (2150 = 21.50). The heat pump commands only take whole degrees, so the value + // must be a multiple of 100 (2100 = 21). + bool scale100; +}; + +constexpr MainCommand MAIN_COMMANDS[] = { + {1, "SetHeatpump", false}, + {2, "SetHolidayMode", false}, + {3, "SetQuietMode", false}, + {4, "SetPowerfulMode", false}, + {5, "SetZ1HeatRequestTemperature", true}, + {6, "SetZ1CoolRequestTemperature", true}, + {7, "SetZ2HeatRequestTemperature", true}, + {8, "SetZ2CoolRequestTemperature", true}, + {9, "SetOperationMode", false}, + {10, "SetForceDHW", false}, + {11, "SetDHWTemp", true}, + {12, "SetForceDefrost", false}, + {13, "SetForceSterilization", false}, + {14, "SetPump", false}, + {15, "SetMaxPumpDuty", false}, + {16, "SetCurves", false}, + {17, "SetZones", false}, + {18, "SetFloorHeatDelta", true}, + {19, "SetFloorCoolDelta", true}, + {20, "SetDHWHeatDelta", true}, + {21, "SetHeaterDelayTime", false}, + {22, "SetHeaterStartDelta", true}, + {23, "SetHeaterStopDelta", true}, + {24, "SetMainSchedule", false}, + {25, "SetAltExternalSensor", false}, + {26, "SetExternalPadHeater", false}, + {27, "SetBufferDelta", true}, + {28, "SetBuffer", false}, + {29, "SetHeatingOffOutdoorTemp", true}, + {30, "SetExternalControl", false}, + {31, "SetExternalError", false}, + {32, "SetExternalCompressorControl", false}, + {33, "SetExternalHeatCoolControl", false}, + {34, "SetBivalentControl", false}, + {35, "SetBivalentMode", false}, + {36, "SetBivalentStartTemp", true}, + {37, "SetBivalentAPStartTemp", true}, + {38, "SetBivalentAPStopTemp", true}, + {39, "SetForceHeater", false}, + {40, "SetHeatingControl", false}, + {41, "SetSmartDHW", false}, + {42, "SetQuietModePriority", false}, + {43, "SetPumpFlowrateMode", false}, + {44, "SetDHWSensorSelection", false}, + {45, "SetDHWHeaterState", false}, + {46, "SetRoomHeaterState", false}, + {47, "SetHeaterOnOutdoorTemp", true}, + {48, "SetSterilizationTemp", true}, + {49, "SetSterilizationMaxTime", false}, + {RESET_COMMAND_ID, "SetReset", false}, +}; + +struct OptionalCommand { + uint16_t id; + const char *name; + // Same x100 convention as MainCommand; the optional PCB also accepts decimals (2150 = 21.50). + bool scale100; +}; + +constexpr OptionalCommand OPTIONAL_COMMANDS[] = { + {0, "SetHeatCoolMode", false}, + {1, "SetCompressorState", false}, + {2, "SetSmartGridMode", false}, + {3, "SetExternalThermostat1State", false}, + {4, "SetExternalThermostat2State", false}, + {5, "SetDemandControl", false}, + {6, "SetPoolTemp", true}, + {7, "SetBufferTemp", true}, + {8, "SetZ1RoomTemp", true}, + {9, "SetZ1WaterTemp", true}, + {10, "SetZ2RoomTemp", true}, + {11, "SetZ2WaterTemp", true}, + {12, "SetSolarTemp", true}, + {13, "SetOptPCBByte9", false}, +}; +} // namespace ModbusMap diff --git a/HeishaMon/gpio.cpp b/HeishaMon/gpio.cpp index 1d86491a..7d9b7ddc 100644 --- a/HeishaMon/gpio.cpp +++ b/HeishaMon/gpio.cpp @@ -18,10 +18,33 @@ void mqttGPIOCallback(char* topic, char* value) { #ifdef ESP32 if (strcmp_P(topic, PSTR("relay/one")) == 0) { log_message(_F("GPIO: MQTT message received 'relay/one'")); - digitalWrite(relayOnePin,((stricmp((char*)"true", value) == 0) || (stricmp((char*)"on", value) == 0) || (stricmp((char*)"enable", value) == 0)|| (String(value).toInt() == 1 ))); - } else if (strcmp_P(topic,PSTR("relay/two")) == 0) { + setRelay1((stricmp((char*)"true", value) == 0) || (stricmp((char*)"on", value) == 0) || (stricmp((char*)"enable", value) == 0)|| (String(value).toInt() == 1 )); + } else if (strcmp_P(topic, PSTR("relay/two")) == 0) { log_message(_F("GPIO: MQTT message received 'relay/two'")); - digitalWrite(relayTwoPin,((stricmp((char*)"true", value) == 0) || (stricmp((char*)"on", value) == 0) || (stricmp((char*)"enable", value) == 0)|| (String(value).toInt() == 1 ))); + setRelay2((stricmp((char*)"true", value) == 0) || (stricmp((char*)"on", value) == 0) || (stricmp((char*)"enable", value) == 0)|| (String(value).toInt() == 1 )); } #endif } + +#ifdef ESP32 +void setRelay1(bool state) +{ + digitalWrite(relayOnePin, state); +} + +void setRelay2(bool state) +{ + digitalWrite(relayTwoPin, state); +} + +bool getRelay1() +{ + return digitalRead(relayOnePin); +} + +bool getRelay2() +{ + return digitalRead(relayTwoPin); +} + +#endif diff --git a/HeishaMon/gpio.h b/HeishaMon/gpio.h index aea5191d..e3c4e1b3 100644 --- a/HeishaMon/gpio.h +++ b/HeishaMon/gpio.h @@ -25,5 +25,8 @@ struct gpioSettingsStruct { void setupGPIO(gpioSettingsStruct gpioSettings); void mqttGPIOCallback(char* topic, char* value); - +void setRelay1(bool state); +void setRelay2(bool state); +bool getRelay1(); +bool getRelay2(); diff --git a/HeishaMon/htmlcode.h b/HeishaMon/htmlcode.h index 7e552f99..9e3cdae7 100644 --- a/HeishaMon/htmlcode.h +++ b/HeishaMon/htmlcode.h @@ -1249,7 +1249,12 @@ document.addEventListener('DOMContentLoaded',function(){ ↻ Reboot ⊱ Rules ⚙ Settings -`; +)====" +#ifdef ESP32 +R"====(ⓘ Modbus +)====" +#endif +R"====(`; });
@@ -1708,6 +1713,12 @@ static const char settingsForm2[] FLASHPROG = R"====(
+

Modbus TCP

+
+
No authentication, so only enable on a trusted network. Reboot required.
+
Lets Modbus clients send heat pump commands (including SetReset) and switch the relays.
+
+

Listen Only

@@ -3385,3 +3396,101 @@ static const char tzDataOptions[] FLASHPROG = R"====( )===="; + + +#ifdef ESP32 +static const char webModbusStart[] FLASHPROG = R"====( + + +
+

Modbus registers

+

TCP port 502 · Unit ID 1 · + Addresses are zero-based protocol offsets (no 40001 prefix). + If your client uses one-based addresses, add 1.

+

Read values with FC03. Integer values are signed 16-bit. + Floats are IEEE 754 float32: read both registers, high word first (MSW / LSW), without scaling. + Temperatures in signed 16-bit registers are x100: divide by 100, 2050 means 20.50. Temperature commands (FC06) use the same x100 scale. Commands can also be written as an unscaled float32 with FC16.

+

The Modbus TCP server must be enabled in Settings. Writes (FC05 / FC06 / FC16) additionally require + Allow Modbus writes. The register map version is currently 3.

+
Fixed blocks with room to grow +

Each measurement group reserves 1,000 topics. Unused addresses are reserved and cannot be read yet.

+
+ + + + + +
GroupInteger blockFloat block
Main (TOP)0-99910000-11999
Extra (XTOP)1000-199912000-13999
Optional PCB (OPT)2000-299914000-15999
S0 inputs3000-399916000-17999
+

Float start = 10000 + 2 × integer address; the next register holds the low word. + Example: TOP139 uses integer 139 and float 10278 / 10279.

+

S0 1 starts at integer 3000 / float 16000; S0 2 at integer 3100 / float 16200. + Each input reserves 100 fields. Enable S0 and configure pulses/kWh in Settings. + Read floats for fractional energy and large totals; integer S0 values truncate fractions and stop at 32767.

+
+
+ + + + + Loading registers... +
+
+ + + +)===="; + +static const char webModbusEnd[] FLASHPROG = R"====( + +
GroupName16-bit / coil addressFloat32 MSW / LSWAccessScaling / notes
+
+ +

This page lists the map compiled into this firmware; it does not send commands. + Availability of readings depends on the heat pump, optional PCB and S0 configuration. + Non-numeric readings return 0; letter-prefixed error codes use a numeric block in the integer register.

+
+ +)===="; +#endif diff --git a/HeishaMon/s0.cpp b/HeishaMon/s0.cpp index c3288bb5..a1fa9b33 100644 --- a/HeishaMon/s0.cpp +++ b/HeishaMon/s0.cpp @@ -136,6 +136,7 @@ void s0Loop(PubSubClient &mqtt_client, void (*log_message)(char*), char* mqtt_to float Watthour = (actS0Data[i].pulses * ( 1000.0 / actS0Settings[i].ppkwh)); float WatthourTotal = (actS0Data[i].pulsesTotal * ( 1000.0 / actS0Settings[i].ppkwh)); + actS0Data[i].lastReportWatthour = Watthour; noInterrupts(); actS0Data[i].pulses = 0; //per message we report new wattHour, so pulses should be zero at start new message diff --git a/HeishaMon/s0.h b/HeishaMon/s0.h index 35138bc9..4e969e5e 100644 --- a/HeishaMon/s0.h +++ b/HeishaMon/s0.h @@ -1,33 +1,8 @@ +#pragma once #include #include "src/common/webserver.h" -#define NUM_S0_COUNTERS 2 -#if defined(ESP8266) -#define DEFAULT_S0_PIN_1 12 -#define DEFAULT_S0_PIN_2 14 -#elif defined(ESP32) -#define DEFAULT_S0_PIN_1 1 -#define DEFAULT_S0_PIN_2 2 -#endif - -struct s0SettingsStruct { - byte gpiopin = 255; - unsigned int ppkwh = 1000; //pulses per Wh of the connected meter - unsigned int lowerPowerInterval = 60; //configurabel low power interval - unsigned int minimalPulseWidth = 25; //configurabel minimal s0 pulse width - unsigned int maximalPulseWidth = 100; //configurabel maximal s0 pulse width -}; - -struct s0DataStruct { - unsigned int pulses = 0; //number of pulses since last report - unsigned int pulsesTotal = 0; //total pulses measured from begin - unsigned int watt = 0; //calculated average power - unsigned long lastPulse = 0; //last pulse in millis - unsigned long nextReport = 0; //next time we reported the s0 value in millis - unsigned long goodPulses = 0; - unsigned long badPulses = 0; - unsigned int avgPulseWidth = 0; -}; +#include "s0data.h" void initS0Sensors(s0SettingsStruct s0Settings[]); void restore_s0_Watthour(int s0Port, float watthour); diff --git a/HeishaMon/s0data.cpp b/HeishaMon/s0data.cpp new file mode 100644 index 00000000..cc4afaca --- /dev/null +++ b/HeishaMon/s0data.cpp @@ -0,0 +1,24 @@ +#include "s0data.h" + +extern volatile s0DataStruct actS0Data[NUM_S0_COUNTERS]; +extern volatile s0SettingsStruct actS0Settings[NUM_S0_COUNTERS]; + +void readS0Readings(bool enabled, S0Reading readings[NUM_S0_COUNTERS]) { + for (unsigned i = 0; i < NUM_S0_COUNTERS; ++i) { + readings[i] = S0Reading{}; + const unsigned pulsesPerKwh = actS0Settings[i].ppkwh; + if (!enabled || actS0Settings[i].gpiopin == 255 || pulsesPerKwh == 0) continue; + + // Read each interrupt-updated scalar once. Do not reset any pulse counters. + const unsigned total = actS0Data[i].pulsesTotal; + const unsigned long good = actS0Data[i].goodPulses; + const unsigned long bad = actS0Data[i].badPulses; + readings[i].watt = actS0Data[i].watt; + readings[i].watthourTotal = total * (1000.0 / pulsesPerKwh); + readings[i].watthour = actS0Data[i].lastReportWatthour; + // Same startup convention as the S0 UI: no pulses yet means 100% quality. + readings[i].pulseQuality = 100.0 * (double(good) + 1) / (double(good) + bad + 1); + readings[i].avgPulseWidth = actS0Data[i].avgPulseWidth; + readings[i].enabled = true; + } +} diff --git a/HeishaMon/s0data.h b/HeishaMon/s0data.h new file mode 100644 index 00000000..da7441cf --- /dev/null +++ b/HeishaMon/s0data.h @@ -0,0 +1,44 @@ +#pragma once +#include + +#define NUM_S0_COUNTERS 2 +#if defined(ESP8266) +#define DEFAULT_S0_PIN_1 12 +#define DEFAULT_S0_PIN_2 14 +#elif defined(ESP32) +#define DEFAULT_S0_PIN_1 1 +#define DEFAULT_S0_PIN_2 2 +#endif + +struct s0SettingsStruct { + byte gpiopin = 255; + unsigned int ppkwh = 1000; //pulses per kWh of the connected meter + unsigned int lowerPowerInterval = 60; //configurabel low power interval + unsigned int minimalPulseWidth = 25; //configurabel minimal s0 pulse width + unsigned int maximalPulseWidth = 100; //configurabel maximal s0 pulse width +}; + +struct s0DataStruct { + unsigned int pulses = 0; //number of pulses since last report + unsigned int pulsesTotal = 0; //total pulses measured from begin + unsigned int watt = 0; //calculated average power + unsigned long lastPulse = 0; //last pulse in millis + unsigned long nextReport = 0; //next time we reported the s0 value in millis + unsigned long goodPulses = 0; + unsigned long badPulses = 0; + float lastReportWatthour = 0; //energy in the most recent S0 report interval + unsigned int avgPulseWidth = 0; +}; + +// A non-destructive view for consumers such as Modbus. Values are sampled once +// per request so both words of a float are generated from the same reading. +struct S0Reading { + float watt = 0; + float watthourTotal = 0; + float watthour = 0; + float pulseQuality = 0; + float avgPulseWidth = 0; + bool enabled = false; +}; + +void readS0Readings(bool enabled, S0Reading readings[NUM_S0_COUNTERS]); diff --git a/HeishaMon/webfunctions.cpp b/HeishaMon/webfunctions.cpp index 38efded6..aac2112d 100644 --- a/HeishaMon/webfunctions.cpp +++ b/HeishaMon/webfunctions.cpp @@ -3,6 +3,9 @@ #include "version.h" #include "htmlcode.h" #include "commands.h" +#ifdef ESP32 +#include "HeishaModbusServer.h" +#endif #include "src/common/progmem.h" #include "src/common/webserver.h" #include "src/common/timerqueue.h" @@ -258,6 +261,8 @@ void loadSettings(settingsStruct *heishamonSettings) { heishamonSettings->opentherm = ( jsonDoc[F("opentherm")] == "enabled" ) ? true : false; #ifdef ESP32 heishamonSettings->proxy = ( jsonDoc[F("proxy")] == "enabled" ) ? true : false; + heishamonSettings->modbus = ( jsonDoc[F("modbus")] == "enabled" ) ? true : false; + heishamonSettings->modbusWrites = ( jsonDoc[F("modbusWrites")] == "enabled" ) ? true : false; #endif if ( jsonDoc[F("waitTime")]) heishamonSettings->waitTime = jsonDoc[F("waitTime")]; if (heishamonSettings->waitTime < 5) heishamonSettings->waitTime = 5; @@ -489,6 +494,16 @@ void settingsToJson(JsonDocument &jsonDoc, settingsStruct *heishamonSettings) { } else { jsonDoc[F("proxy")] = "disabled"; } + if (heishamonSettings->modbus) { + jsonDoc[F("modbus")] = "enabled"; + } else { + jsonDoc[F("modbus")] = "disabled"; + } + if (heishamonSettings->modbusWrites) { + jsonDoc[F("modbusWrites")] = "enabled"; + } else { + jsonDoc[F("modbusWrites")] = "disabled"; + } #endif jsonDoc[F("waitTime")] = heishamonSettings->waitTime; jsonDoc[F("waitDallasTime")] = heishamonSettings->waitDallasTime; @@ -630,6 +645,8 @@ int saveSettings(struct webserver_t *client, settingsStruct *heishamonSettings) #ifdef ESP32 jsonDoc[F("proxy")] = String("disabled"); + jsonDoc[F("modbus")] = String("disabled"); + jsonDoc[F("modbusWrites")] = String("disabled"); #endif #ifdef TLS_SUPPORT jsonDoc[F("mqtt_tls_enabled")] = String("disabled"); @@ -679,6 +696,10 @@ int saveSettings(struct webserver_t *client, settingsStruct *heishamonSettings) #ifdef ESP32 } else if (strcmp(tmp->name.c_str(), "proxy") == 0) { jsonDoc[F("proxy")] = tmp->value; + } else if (strcmp(tmp->name.c_str(), "modbus") == 0) { + jsonDoc[F("modbus")] = tmp->value; + } else if (strcmp(tmp->name.c_str(), "modbusWrites") == 0) { + jsonDoc[F("modbusWrites")] = tmp->value; #endif } else if (strcmp(tmp->name.c_str(), "ntp_servers") == 0) { jsonDoc[F("ntp_servers")] = tmp->value; @@ -1014,6 +1035,32 @@ int handleDebug(struct webserver_t *client, char *hex, byte hex_len) { } +#ifdef ESP32 +int handleModbus(struct webserver_t *client) { + if (client->content == 0) { + webserver_send(client, 200, (char *)"text/html", 0); + webserver_send_content_P(client, webHeader, strlen_P(webHeader)); + webserver_send_content_P(client, webCSS, strlen_P(webCSS)); + webserver_send_content_P(client, webBodyStart, strlen_P(webBodyStart)); + webserver_send_content_P(client, webModbusStart, strlen_P(webModbusStart)); + } else { + String row; + if (HeishaModbusServer::registerRow(client->content - 1, row)) { + webserver_send_content(client, const_cast(row.c_str()), row.length()); + } else { + // Send the footer once; the following empty callback ends chunked output. + String previous; + if (client->content == 1 || HeishaModbusServer::registerRow(client->content - 2, previous)) { + webserver_send_content_P(client, webModbusEnd, strlen_P(webModbusEnd)); + webserver_send_content_P(client, menuJS, strlen_P(menuJS)); + webserver_send_content_P(client, webFooter, strlen_P(webFooter)); + } + } + } + return 0; +} +#endif + int handleRoot(struct webserver_t *client, float readpercentage, int mqttReconnects, settingsStruct *heishamonSettings) { switch (client->content) { case 0: { diff --git a/HeishaMon/webfunctions.h b/HeishaMon/webfunctions.h index f485158f..6f8af568 100644 --- a/HeishaMon/webfunctions.h +++ b/HeishaMon/webfunctions.h @@ -68,6 +68,8 @@ struct settingsStruct { bool hotspot = true; //enable wifi hotspot when wifi is not connected #ifdef ESP32 bool proxy = true; //cztaw proxy port enable flag + bool modbus = false; //modbus tcp server enable flag + bool modbusWrites = false; //allow modbus clients to write commands and relays #endif s0SettingsStruct s0Settings[NUM_S0_COUNTERS]; gpioSettingsStruct gpioSettings; @@ -101,6 +103,9 @@ void loadSettings(settingsStruct *heishamonSettings); int getSettings(struct webserver_t *client, settingsStruct *heishamonSettings); int getSettingsJson(struct webserver_t *client, settingsStruct *heishamonSettings); +#ifdef ESP32 +int handleModbus(struct webserver_t *client); +#endif int handleSettings(struct webserver_t *client); int saveSettings(struct webserver_t *client, settingsStruct *heishamonSettings); int settingsReconnectWifi(struct webserver_t *client, settingsStruct *heishamonSettings); diff --git a/Integrations/Loxone/MB_HeishaMon.xml b/Integrations/Loxone/MB_HeishaMon.xml new file mode 100644 index 00000000..fe1f2ca5 --- /dev/null +++ b/Integrations/Loxone/MB_HeishaMon.xml @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/LIBSUSED.md b/LIBSUSED.md index f25670be..acecf5df 100644 --- a/LIBSUSED.md +++ b/LIBSUSED.md @@ -13,3 +13,5 @@ Only actual code in build action is relevant! |arduinojson by benoit blanchon | 6.19.4 | https://github.com/bblanchon/ArduinoJson/releases/tag/v6.19.4 | |dallastemperature | 3.9.0 | https://github.com/milesburton/Arduino-Temperature-Control-Library/releases/tag/3.9.0 | |onewire | 2.3.5 | https://www.pjrc.com/teensy/td_libs_OneWire.html | +|Async TCP by ESP32Async (ESP32 only, `arduino-cli lib install "Async TCP"`) | latest | https://github.com/ESP32Async/AsyncTCP | +|eModbus (ESP32 only, not in the Arduino registry: `ARDUINO_LIBRARY_ENABLE_UNSAFE_INSTALL=true arduino-cli lib install --git-url https://github.com/eModbus/eModbus.git#v1.7.5stable`) | 1.7.5 | https://github.com/eModbus/eModbus/releases/tag/v1.7.5stable | diff --git a/Modbus-Register-Mapping.md b/Modbus-Register-Mapping.md new file mode 100644 index 00000000..0460140a --- /dev/null +++ b/Modbus-Register-Mapping.md @@ -0,0 +1,272 @@ +# Modbus register map v3 + +TCP port **502**, unit ID **1**. All addresses below are zero-based protocol offsets, +without a 40001 prefix. Add 1 only if your client expects one-based addressing. +ESP32 supports Modbus TCP; ESP8266 does not. + +## Enabling Modbus TCP + +Modbus TCP has no authentication, so it is **off by default**. In **Settings**: + +- **Enable Modbus TCP server (port 502)**: starts the server after a reboot. Without + further options the server is read-only. +- **Allow Modbus writes**: additionally accepts FC05 (relays) and FC06 (heat pump + commands, including `SetReset`). While this is off, write requests are answered + with `ILLEGAL_FUNCTION`. + +Only enable Modbus on a network you trust. + +Modbus requests are handled in a different task than the rest of the firmware. +Register reads are served from a snapshot of the heat pump data that the main loop +refreshes, and accepted writes are queued and executed by the main loop like any +MQTT command. A write is therefore acknowledged when it is queued, not when the +heat pump has processed it. If the queue is full the request is answered with +`SERVER_DEVICE_BUSY`; retry it later. + +## Address map at a glance + +From low to high; every block has reserved room to grow. + +| Range | Function codes | Contents | +| --- | --- | --- | +| 0-4999 | FC03 | int16 measurements: main 0, extra 1000, optional PCB 2000, S0 3000 | +| 5000-9999 | FC06, FC16 | int16 commands: heat pump 5000, optional PCB 6000, system 7000 | +| 10000-19999 | FC03 | float32 measurements (2 registers each): main 10000, extra 12000, optional PCB 14000, S0 16000 | +| 20000-29999 | FC16 | float32 commands (2 registers each): heat pump 20000, optional PCB 22000, system 24000 | +| Coils 30000-31999 | FC01, FC05 | Relays (coil 30000 = relay 1, coil 30001 = relay 2). Coils are a separate address space in Modbus, so this does not overlap the registers above. | +| 32000-32999 | FC03 | Device information: register map version, relay state | + +## Fixed blocks + +Each measurement block reserves room for **1,000 topics**. Counts can grow without +moving any other block. Only implemented topics/commands are accessible; reserved +addresses return `ILLEGAL_DATA_ADDRESS`. + +Registers whose data is not available also return `ILLEGAL_DATA_ADDRESS` instead of a +value decoded from an empty buffer: extra topics (XTOP) on heat pumps without the +extra data block, and optional PCB topics (OPT) while optional PCB emulation is disabled. + +| Group | Reserved integer block (FC03, int16) | Reserved float block (FC03, float32) | Currently implemented | +| --- | --- | --- | --- | +| Main TOPn | 0-999 | 10000-11999 | TOP0-TOP143: integer 0-143, float 10000-10287 | +| Extra XTOPn | 1000-1999 | 12000-13999 | XTOP0-XTOP5: integer 1000-1005, float 12000-12011 | +| Optional PCB OPTn | 2000-2999 | 14000-15999 | OPT0-OPT6: integer 2000-2006, float 14000-14013 | +| S0 inputs | 3000-3999 | 16000-17999 | Input 1: integer 3000-3005, float 16000-16011; input 2: integer 3100-3105, float 16200-16211 | + +**Integer address = group base + topic number.** +**Float start = 10000 + 2 * integer address.** +Read two registers for a float: MSW at the start address, LSW at start + 1. +All floats are IEEE 754 float32, unscaled. + +| Other group | Reserved block | Access | Implemented | +| --- | --- | --- | --- | +| Heat-pump commands | 5000-5999 | FC06 / FC16, int16 | 5000-5048; 5015 is reserved for JSON-only SetCurves and rejects writes | +| Optional PCB commands | 6000-6999 | FC06 / FC16, int16 | 6000-6013 | +| System commands | 7000-7999 | FC06 / FC16, int16 | 7000 = SetReset | +| Float32 commands | 20000-29999 | FC16, float32 (2 registers) | 20000 + 2 * (command address - 5000): heat pump 20000-21999, optional PCB 22000-23999, system 24000-25999 | +| Relay coils (separate coil address space) | 30000-31999 | FC01 (read), FC05 (write) | 30000 = relay 1, 30001 = relay 2 | +| Device information | 32000-32999 | FC03, uint16 | 32000 = register map version, currently 3; 32010 / 32011 = relay 1 / relay 2 state (0 = off, 1 = on) | + +FC05 accepts 0x0000 for off and 0xFF00 for on. Coil 30002 is not a relay. + +The relay state can be read back with **FC01 (Read Coils)** on coils 30000 and 30001 (1 = on), or with +FC03 on registers 32010 (relay 1) and 32011 (relay 2) for clients without coil support. Reading +also works while *Allow Modbus writes* is off. The state follows the actual relay output, so +switching over MQTT or the web UI is reflected as well (refreshed in the main loop). +A relay written just before can still read its old state until the main loop has executed the write. + +### Writing float32 values + +Every command also has a float32 address, laid out like the int16 command block: +**float address = 20000 + 2 * (command address - 5000)**. Example: SetDHWTemp is int16 5010 +and float32 20020 / 20021; SetPoolTemp is int16 6006 and float32 22012 / 22013. + +Write a float with **FC16 (Write Multiple Registers)**: start at the high word (MSW first, like +reading), quantity 2, one command per request. The value is the **real, unscaled value** +(21.5 is 21.5, not 2150). Both words arrive in one request, so a half-written value is never +executed. FC06 cannot write floats. FC16 with quantity 1 at an int16 command address behaves +like FC06. + +- Heat pump commands only take whole numbers, so 21.0 is accepted and 21.5 is rejected with + `ILLEGAL_DATA_VALUE`. Optional PCB temperatures accept decimals (21.5). +- NaN, infinity and values beyond +-32767 are rejected with `ILLEGAL_DATA_VALUE`. +- A start address on the low word, or a quantity other than 2, is rejected + (`ILLEGAL_DATA_ADDRESS` / `ILLEGAL_DATA_VALUE`). +FC03 supports 1-125 registers per request; reads across a reserved gap are rejected. +Only FC01, FC03, FC05, FC06 and FC16 are supported. + +## Register page + +Open **Modbus** in the device menu or `http:///modbus`. +The searchable page is generated from the actual firmware ranges and command IDs. +It lists both addresses for each measurement, scaling, function codes and every +command, sorted in ascending order of the 16-bit / coil address. It only displays the map; it does not send commands or show live values. + +## Scaling + +The multiplier of the 16-bit integer registers is fixed by the topic's unit, not the current value text. +**The float32 registers are never scaled**: they hold the real value (20.5 = 20.5), so no +multiplier is needed there. + + +- Temperature (Celsius/Kelvin), flow, pressure and current (Ampere): **x100** in the int16 + registers. Divide by 100 in your client; 2050 means 20.50, -525 means -5.25. +- States, counters, power (W), rotational speed and other units: **x1**. +- Integer values are saturated to -32768 through 32767. Use floats for large counters/power values. +- Extra/optional topics use their own unit definitions, independent of the main topic at the + same index. +- Non-numeric readings return 0. Error codes in the integer + Error register 44 use A=1000, B=2000, ..., H=8000 plus the number: H74=8074. + Its float counterpart returns 0 for text; use register 44 for error information. + +**Temperature commands are x100 as well**, like the int16 readings. The heat pump only takes +whole degrees, so the value must be a multiple of 100: write 4500 to set SetDHWTemp to 45, +-500 for -5. Other values (for example 4550) are rejected with `ILLEGAL_DATA_VALUE`. Affected +commands: SetZ1HeatRequestTemperature, SetZ1CoolRequestTemperature, +SetZ2HeatRequestTemperature, SetZ2CoolRequestTemperature, SetDHWTemp, SetFloorHeatDelta, +SetFloorCoolDelta, SetDHWHeatDelta, SetHeaterStartDelta, SetHeaterStopDelta, SetBufferDelta, +SetHeatingOffOutdoorTemp, SetBivalentStartTemp, SetBivalentAPStartTemp, +SetBivalentAPStopTemp, SetHeaterOnOutdoorTemp and SetSterilizationTemp. All other commands (modes, states, times, +duty) are unscaled signed int16. Allowed values are those of the regular HeishaMon command +handlers. SetCurves needs JSON and must use MQTT/HTTP. + +**Optional PCB temperature commands are x100, like the temperature readings**: +SetPoolTemp, SetBufferTemp, SetZ1RoomTemp, SetZ1WaterTemp, SetZ2RoomTemp, SetZ2WaterTemp +and SetSolarTemp. Write 2150 to set 21.50. The other optional PCB commands are x1. + +Writing an optional PCB command while optional PCB emulation is disabled returns +`ILLEGAL_DATA_ADDRESS`. + +## S0 inputs (large ESP32 board) + +Both S0 inputs are readable with **FC03**. Enable S0 in Settings and configure the +correct **pulses per kWh** for each meter. The S0 group reserves 3000-3999; +each input has a permanent 100-field block. Unused fields remain invalid. +Adding S0 does not change existing addresses or the register map version (3). + +| Value | Unit | S0 1 integer | S0 1 float MSW / LSW | S0 2 integer | S0 2 float MSW / LSW | +| --- | --- | --- | --- | --- | --- | +| Watt | W | 3000 | 16000 / 16001 | 3100 | 16200 / 16201 | +| WatthourTotal | Wh | 3001 | 16002 / 16003 | 3101 | 16202 / 16203 | +| Watthour_Last_Report | Wh | 3002 | 16004 / 16005 | 3102 | 16204 / 16205 | +| PulseQuality | % | 3003 | 16006 / 16007 | 3103 | 16206 / 16207 | +| AvgPulseWidth | ms | 3004 | 16008 / 16009 | 3104 | 16208 / 16209 | +| Enabled | 0/1 | 3005 | 16010 / 16011 | 3105 | 16210 / 16211 | + +All S0 values use **x1**. Integer readings truncate fractions and saturate at 32767. +**Use floats for energy totals**, high power and fractional readings. Floats retain +about seven significant decimal digits; this is not an exact 64-bit energy counter. +To display kWh, divide the Wh total by 1000 in the client. + +- `Watt` uses the S0 subsystem's calculated power, including its low-power decay. +- `WatthourTotal` uses accumulated pulses and the configured pulses/kWh. It includes + totals restored by the existing MQTT mechanism. This feature adds no flash + persistence; without a restore the counter starts again after reboot. +- `Watthour_Last_Report` holds the energy from the last completed S0 reporting + interval (the same interval reported over MQTT/WebSocket). Before the first + report it is 0. It is **not energy since the last Modbus read**: polling never + resets or consumes pulses, energy counters or the reporting interval. +- `PulseQuality` follows the S0 UI convention `100 * (good + 1) / (good + bad + 1)`; + an enabled input with no pulses yet reports 100%. +- `Enabled` is 1 when S0 is enabled, initialized and pulses/kWh is greater than 0. + Otherwise all six values of that input are 0, including the status. +- All fields are read-only. Both words of each float are generated from one + captured reading within an FC03 request. Read both words together, or all six + floats in one request of 12 registers. + +## Main commands + +Addresses are based on permanent command IDs, not array order. The name to ID table +is `MAIN_COMMANDS` in `HeishaMon/ModbusRegisterMap.h`. IDs 1-1000 map to +5000 + ID - 1; ID 100 is reserved permanently and maps to SetReset at 7000. + +| Address | Command | +| --- | --- | +| 5000 | `SetHeatpump` | +| 5001 | `SetHolidayMode` | +| 5002 | `SetQuietMode` | +| 5003 | `SetPowerfulMode` | +| 5004 | `SetZ1HeatRequestTemperature` | +| 5005 | `SetZ1CoolRequestTemperature` | +| 5006 | `SetZ2HeatRequestTemperature` | +| 5007 | `SetZ2CoolRequestTemperature` | +| 5008 | `SetOperationMode` | +| 5009 | `SetForceDHW` | +| 5010 | `SetDHWTemp` | +| 5011 | `SetForceDefrost` | +| 5012 | `SetForceSterilization` | +| 5013 | `SetPump` | +| 5014 | `SetMaxPumpDuty` | +| 5015 | `SetCurves` | +| 5016 | `SetZones` | +| 5017 | `SetFloorHeatDelta` | +| 5018 | `SetFloorCoolDelta` | +| 5019 | `SetDHWHeatDelta` | +| 5020 | `SetHeaterDelayTime` | +| 5021 | `SetHeaterStartDelta` | +| 5022 | `SetHeaterStopDelta` | +| 5023 | `SetMainSchedule` | +| 5024 | `SetAltExternalSensor` | +| 5025 | `SetExternalPadHeater` | +| 5026 | `SetBufferDelta` | +| 5027 | `SetBuffer` | +| 5028 | `SetHeatingOffOutdoorTemp` | +| 5029 | `SetExternalControl` | +| 5030 | `SetExternalError` | +| 5031 | `SetExternalCompressorControl` | +| 5032 | `SetExternalHeatCoolControl` | +| 5033 | `SetBivalentControl` | +| 5034 | `SetBivalentMode` | +| 5035 | `SetBivalentStartTemp` | +| 5036 | `SetBivalentAPStartTemp` | +| 5037 | `SetBivalentAPStopTemp` | +| 5038 | `SetForceHeater` | +| 5039 | `SetHeatingControl` | +| 5040 | `SetSmartDHW` | +| 5041 | `SetQuietModePriority` | +| 5042 | `SetPumpFlowrateMode` | +| 5043 | `SetDHWSensorSelection` | +| 5044 | `SetDHWHeaterState` | +| 5045 | `SetRoomHeaterState` | +| 5046 | `SetHeaterOnOutdoorTemp` | +| 5047 | `SetSterilizationTemp` | +| 5048 | `SetSterilizationMaxTime` | + +## Optional PCB commands + +Optional command IDs are explicitly assigned in `HeishaMon/ModbusRegisterMap.h`. +Their address is 6000 + ID. Enabling optional PCB emulation is still required +for the corresponding heat-pump command handlers. + +| Address | Command | +| --- | --- | +| 6000 | `SetHeatCoolMode` | +| 6001 | `SetCompressorState` | +| 6002 | `SetSmartGridMode` | +| 6003 | `SetExternalThermostat1State` | +| 6004 | `SetExternalThermostat2State` | +| 6005 | `SetDemandControl` | +| 6006 | `SetPoolTemp` | +| 6007 | `SetBufferTemp` | +| 6008 | `SetZ1RoomTemp` | +| 6009 | `SetZ1WaterTemp` | +| 6010 | `SetZ2RoomTemp` | +| 6011 | `SetZ2WaterTemp` | +| 6012 | `SetSolarTemp` | +| 6013 | `SetOptPCBByte9` | + +## System commands + +| Address | Command | +| --- | --- | +| 7000 | `SetReset` | + +## Extension rules + +1. Append topic numbers within their existing group; never renumber or reuse a topic. +2. Keep block bases fixed. Compile-time checks reject more than 1,000 topics per group. +3. Give each new command a permanent, unused ID in `MAIN_COMMANDS`. Keep ID 100 reserved for SetReset. +4. Add optional command IDs to `OPTIONAL_COMMANDS` explicitly, regardless of upstream table order. + The firmware does not compile while a command has no ID. +5. Update the documentation and run `python tests/modbus/run_tests.py` and the firmware build. +6. Changing an assigned address, its meaning or scaling requires a new map version. diff --git a/README.md b/README.md index bf64a311..8febad51 100644 --- a/README.md +++ b/README.md @@ -370,6 +370,9 @@ The software also supports ds18b20 1-wire temperature sensors reading. A proper ## Large board relay control The newer, large, heishamon contains two onboard relays which can be switched on and off using MQTT commands. The relays can be used for any contact switching, even 230V mains (max 5A). For example to switch the 230V contacts in the heatpump for controlling the 'external thermostat', switching a pump on or off or other lower power devices. I do not recommend to use the relay as a switch for a electric heater as they use too much power. To control the relay just send a value of 1 or 0 to the MQTT topic "panasonic_heat_pump/gpio/relay/one" for relay one or "panasonic_heat_pump/gpio/relay/two" for relay two. +## Modbus TCP support +The large (ESP32-S3) heishamon can act as a Modbus TCP server (port 502, unit ID 1) so PLCs and building automation systems such as Loxone can read the heat pump values with FC03 and, optionally, send commands with FC06 and switch the relays with FC05. Modbus has no authentication, so it is disabled by default. Enable it in settings under 'Enable Modbus TCP server' and only additionally enable 'Allow Modbus writes' if you want to be able to control the heat pump over Modbus. Both need a reboot to take effect. The register map is documented in [Modbus register mapping](Modbus-Register-Mapping.md) and is also available on the device on the 'Modbus' page. A Loxone template is available in the [Integrations/Loxone](Integrations/Loxone) directory. + ## Opentherm support If your heishamon board supports opentherm the software can also be used to bridge opentherm information from a compatible thermostat to your home automation over MQTT or JSON and as mentioned above it can also be connected directly in the rules to connect opentherm information to the heatpump and back, for example to display the outside temperature from the heatpump on your opentherm thermostat. If you enable opentherm support in settings there will be a new tab visible in the web page. On that tab you will see opentherm values. Some are of type R(ead) and some are W(rite), and some are both. Read means that the thermostat can read that information from the heishamon. You provide that information over MQTT (or using the rules) by updating this value on the mqtt 'opentherm/read' topic, for example 'panasonic_heat_pump/opentherm/read/outsideTemp'. The write values are information from the thermostat, like 'roomTemp'. These are available on mqtt topic 'opentherm/write'. You can use these values to change the heatpump behaviour in anyway you want using your home automation and mqtt set-commands to heishamon on using the internal rules. diff --git a/platformio.ini b/platformio.ini new file mode 100644 index 00000000..8c8c589a --- /dev/null +++ b/platformio.ini @@ -0,0 +1,39 @@ +; PlatformIO Project Configuration File +; +; Build options: build flags, source filter +; Upload options: custom upload port, speed and extra flags +; Library options: dependencies, extra library storages +; Advanced options: extra scripting +; +; Please visit documentation for the other options and examples +; https://docs.platformio.org/page/projectconf.html + +[platformio] +src_dir = ./HeishaMon/ + +[env:esp32] +platform = https://github.com/pioarduino/platform-espressif32.git#51.03.07 +board = esp32-s3-devkitm-1 +framework = arduino +board_upload.flash_size = 4MB +board_upload.maximum_size = 4194304 +board_build.partitions = min_spiffs.csv +board_build.arduino.memory_type = qio_qspi +monitor_speed = 115200 +upload_speed = 115200 +build_flags = + -DBOARD_HAS_PSRAM + -DCORE_DEBUG_LEVEL=0 + -DDEBUG_ESP_PORT=Serial + -DARDUINO_ETH_INCLUDE + -DARDUINO_USB_MODE=1 + -DARDUINO_USB_CDC_ON_BOOT=1 +lib_deps = + ringbuffer + pubsubclient + bblanchon/ArduinoJson + milesburton/DallasTemperature + paulstoffregen/OneWire + adafruit/Adafruit NeoPixel + miq19/eModbus + esp32async/AsyncTCP diff --git a/tests/modbus/README.md b/tests/modbus/README.md new file mode 100644 index 00000000..e9746b18 --- /dev/null +++ b/tests/modbus/README.md @@ -0,0 +1,20 @@ +# Modbus map regression tests + +Run `python3 tests/modbus/run_tests.py` with `g++` on Linux, or with MSVC C++ tools +on Windows. If MSVC is incomplete, the runner can use the installed .NET +WebAssembly workload and its bundled Node runtime instead. Build products stay +in `tests/modbus/build/` (git-ignored). + +The test compiles the real `HeishaModbusServer.cpp`, real topic units and command +tables. Small host shims replace Arduino, network transport, telemetry decoding +and heat-pump execution, so tests cannot operate hardware. It checks every +implemented topic/command, float word order, fixed scaling, expansion to full +block capacity, invalid addresses/counts, signed writes, relay selection and +the register page's row enumeration, plus both S0 inputs (scaling, report energy, +status, invalid configuration and non-destructive reads). It also checks that +writes are queued and only executed by `loop()`, that writes are refused unless +allowed, x100 optional PCB temperature writes, exceptions for unavailable extra +and optional PCB registers, and the Loxone template. + +These tests complement the ESP32 firmware build; they do not validate real +network timing or operation with a heat pump. diff --git a/tests/modbus/run_tests.py b/tests/modbus/run_tests.py new file mode 100644 index 00000000..a8aea138 --- /dev/null +++ b/tests/modbus/run_tests.py @@ -0,0 +1,49 @@ +"""Compile the actual Modbus server with host shims; no device/network is used.""" + +from pathlib import Path +import re +import shutil +import subprocess +import xml.etree.ElementTree as ET + + +ROOT = Path(__file__).resolve().parents[2] +HERE = Path(__file__).resolve().parent +OUT = HERE / "build" +OUT.mkdir(parents=True, exist_ok=True) + +# Only heat-pump decoding/command execution is stubbed. The register map, unit +# descriptions, real command tables, request handlers and HTML rows are compiled. +decode = (ROOT / "HeishaMon/decode.h").read_text(encoding="utf-8") +commands = (ROOT / "HeishaMon/commands.h").read_text(encoding="utf-8") +definitions = [] +for result, signature in re.findall(r"^(String|unsigned int) ([^;\n]+);", decode + commands, re.M): + if signature.startswith(("getDataValue(", "getDataValueExtra(", "getOptDataValue(")): + continue + definitions.append(f"{result} {signature} {{ return " + ('"0"' if result == "String" else "0") + "; }") +(OUT / "generated_stubs.h").write_text("\n".join(definitions) + "\n", encoding="utf-8") + +source = HERE / "test_modbus.cpp" +executable = OUT / "test_modbus" +compiler = shutil.which("g++") +if not compiler: + raise SystemExit("g++ is required") +subprocess.run([compiler, "-std=c++17", "-DESP32", "-I" + str(HERE / "stubs"), + "-I" + str(OUT), str(source), "-o", str(executable)], check=True) +subprocess.run([str(executable)], check=True) + +# Check the shipped integration against the new map. +loxone = ET.parse(ROOT / "Integrations/Loxone/MB_HeishaMon.xml").getroot() +# Title -> (address, Loxone function code). Measurements are float32 (FC03) +# except the error code, which only the int16 register carries (H74 = 8074); +# int16 commands use FC06 at 5000+, temperature setpoints float32 FC16 at 20000+. +expected = {"Heat_Power_Consumption": (12000, 3), "Heat_Power_Production": (12006, 3), + "ErrorInformation": (44, 3), "SetHeatpump": (5000, 6), + "SetQuietMode": (5002, 6), "SetOperationMode": (5008, 6), + "SetMaxPumpDuty": (5014, 6), "SetZ1HeatRequestTemperature": (20008, 16), + "SetZ1CoolRequestTemperature": (20010, 16)} +actual = {entry.attrib["Title"]: (int(entry.attrib["ModbusAddress"]), int(entry.attrib["ModbusCmd"])) + for entry in loxone.findall("ModbusCmd")} +wrong = {name: actual.get(name) for name, value in expected.items() if actual.get(name) != value} +assert not wrong, f"Loxone template does not match register map: {wrong}" +print("PASS: Loxone map v3 template") diff --git a/tests/modbus/stubs/Arduino.h b/tests/modbus/stubs/Arduino.h new file mode 100644 index 00000000..1519a348 --- /dev/null +++ b/tests/modbus/stubs/Arduino.h @@ -0,0 +1,38 @@ +#pragma once +#include +#include +#include +#include +#include + +using byte = unsigned char; +#define PROGMEM +#define FPSTR(value) (value) +#define PSTR(value) (value) +#define pgm_read_ptr(value) (*(value)) +#define memcpy_P std::memcpy +#define strcmp_P std::strcmp +#define snprintf_P std::snprintf +constexpr int INPUT_PULLUP = 2; +constexpr int OUTPUT = 1; + +class String { + std::string value; +public: + String() = default; + String(const char *text) : value(text) {} + String(const std::string &text) : value(text) {} + template>> + String(T number) : value(std::to_string(number)) {} + size_t length() const { return value.length(); } + char charAt(size_t index) const { return value.at(index); } + String substring(size_t start) const { return value.substr(start); } + int indexOf(char c) const { + const auto position = value.find(c); + return position == std::string::npos ? -1 : static_cast(position); + } + long toInt() const { return std::stol(value); } + float toFloat() const { return std::stof(value); } + const char *c_str() const { return value.c_str(); } + String &operator+=(const String &other) { value += other.value; return *this; } +}; diff --git a/tests/modbus/stubs/ArduinoJson.h b/tests/modbus/stubs/ArduinoJson.h new file mode 100644 index 00000000..354d1bd6 --- /dev/null +++ b/tests/modbus/stubs/ArduinoJson.h @@ -0,0 +1,2 @@ +#pragma once +#include "Arduino.h" diff --git a/tests/modbus/stubs/ESPmDNS.h b/tests/modbus/stubs/ESPmDNS.h new file mode 100644 index 00000000..6f70f09b --- /dev/null +++ b/tests/modbus/stubs/ESPmDNS.h @@ -0,0 +1 @@ +#pragma once diff --git a/tests/modbus/stubs/ModbusServerTCPasync.h b/tests/modbus/stubs/ModbusServerTCPasync.h new file mode 100644 index 00000000..042b1e91 --- /dev/null +++ b/tests/modbus/stubs/ModbusServerTCPasync.h @@ -0,0 +1,47 @@ +#pragma once +#include "Arduino.h" +#include +#include + +constexpr uint8_t ILLEGAL_FUNCTION = 1; +constexpr uint8_t ILLEGAL_DATA_ADDRESS = 2; +constexpr uint8_t ILLEGAL_DATA_VALUE = 3; +constexpr uint8_t SERVER_DEVICE_BUSY = 6; +constexpr uint8_t READ_COIL = 1; +constexpr uint8_t READ_HOLD_REGISTER = 3; +constexpr uint8_t WRITE_COIL = 5; +constexpr uint8_t WRITE_HOLD_REGISTER = 6; +constexpr uint8_t WRITE_MULT_REGISTERS = 16; + +class ModbusMessage { +public: + std::vector bytes; + template void add(T value) { + for (int i = sizeof(T) - 1; i >= 0; --i) bytes.push_back((value >> (8 * i)) & 255); + } + template void add(T value, Rest... rest) { + add(value); add(rest...); + } + uint16_t get(uint16_t position) const { return position; } + template uint16_t get(uint16_t position, T &value, Rest &...rest) const { + value = 0; + for (unsigned i = 0; i < sizeof(T); ++i) value = (value << 8) | bytes.at(position++); + return get(position, rest...); + } + uint8_t getServerID() const { return bytes.at(0); } + uint8_t getFunctionCode() const { return bytes.at(1); } + size_t size() const { return bytes.size(); } + void clear() { bytes.clear(); } + void setError(uint8_t unit, uint8_t function, uint8_t error) { + bytes = {unit, uint8_t(function | 0x80), error}; + } +}; + +inline const ModbusMessage ECHO_RESPONSE; +class ModbusServerTCPasync { +public: + using Handler = ModbusMessage (*)(ModbusMessage); + inline static std::map workers; + void registerWorker(uint8_t, uint8_t function, Handler handler) { workers[function] = handler; } + void start(int, int, int) {} +}; diff --git a/tests/modbus/stubs/PubSubClient.h b/tests/modbus/stubs/PubSubClient.h new file mode 100644 index 00000000..5d963533 --- /dev/null +++ b/tests/modbus/stubs/PubSubClient.h @@ -0,0 +1,2 @@ +#pragma once +class PubSubClient {}; diff --git a/tests/modbus/stubs/Update.h b/tests/modbus/stubs/Update.h new file mode 100644 index 00000000..6f70f09b --- /dev/null +++ b/tests/modbus/stubs/Update.h @@ -0,0 +1 @@ +#pragma once diff --git a/tests/modbus/stubs/WiFi.h b/tests/modbus/stubs/WiFi.h new file mode 100644 index 00000000..354d1bd6 --- /dev/null +++ b/tests/modbus/stubs/WiFi.h @@ -0,0 +1,2 @@ +#pragma once +#include "Arduino.h" diff --git a/tests/modbus/test_modbus.cpp b/tests/modbus/test_modbus.cpp new file mode 100644 index 00000000..a1759bd2 --- /dev/null +++ b/tests/modbus/test_modbus.cpp @@ -0,0 +1,386 @@ +#include +#include +#include +#include "../../HeishaMon/HeishaModbusServer.cpp" +#include "generated_stubs.h" +#include "../../HeishaMon/s0data.cpp" + +char actData[DATASIZE] = {}; +char actDataExtra[DATASIZE] = {}; +char actOptData[OPTDATASIZE] = {}; +String readings[3][1000]; +volatile s0DataStruct actS0Data[NUM_S0_COUNTERS]; +volatile s0SettingsStruct actS0Settings[NUM_S0_COUNTERS]; +std::string lastCommand, lastPayload; +bool relays[2] = {}; +String getDataValue(char *, unsigned int index) { return readings[0][index]; } +String getDataValueExtra(char *, unsigned int index) { return readings[1][index]; } +String getOptDataValue(char *, unsigned int index) { return readings[2][index]; } +bool send_command(byte *, int) { return true; } +void log_message(char *) {} +void setRelay1(bool state) { relays[0] = state; } +void setRelay2(bool state) { relays[1] = state; } +bool getRelay1() { return relays[0]; } +bool getRelay2() { return relays[1]; } +void send_heatpump_command(char *topic, char *payload, bool (*)(byte *, int), void (*)(char *), bool) { + lastCommand = topic; lastPayload = payload; +} + +ModbusMessage call(uint8_t fc, uint16_t address, uint16_t value) { + ModbusMessage request; + request.add(uint8_t(1), fc, address, value); + return ModbusServerTCPasync::workers.at(fc)(request); +} + +uint16_t read(uint16_t address) { + auto response = call(3, address, 1); + assert(response.bytes.size() == 5 && response.bytes[1] == 3); + uint16_t value; + response.get(3, value); + return value; +} + +float readFloat(uint16_t address) { + const auto response = call(3, address, 2); + assert(response.bytes.size() == 7 && response.bytes[1] == 3); + uint32_t bits; + response.get(3, bits); + float value; + std::memcpy(&value, &bits, sizeof(value)); + return value; +} + +void expectError(uint8_t fc, uint16_t address, uint16_t value, uint8_t error) { + auto response = call(fc, address, value); + assert(response.bytes == std::vector({1, uint8_t(fc | 128), error})); +} + +// FC16 request: start address plus the given data words. +ModbusMessage call16(uint16_t start, const std::vector &words) { + ModbusMessage request; + request.add(uint8_t(1), uint8_t(16), start, uint16_t(words.size()), uint8_t(words.size() * 2)); + for (uint16_t word : words) request.add(word); + return ModbusServerTCPasync::workers.at(16)(request); +} + +std::vector floatWords(float value) { + uint32_t bits; + std::memcpy(&bits, &value, sizeof(bits)); + return {uint16_t(bits >> 16), uint16_t(bits & 0xFFFF)}; +} + +void expectError16(uint16_t start, const std::vector &words, uint8_t error) { + assert(call16(start, words).bytes == std::vector({1, 16 | 128, error})); +} + +int main() { + HeishaModbusServer server; + server.setup(true, false, true); + server.loop(false, true); + for (auto &group : readings) for (auto &value : group) value = "1"; + assert(read(32000) == 3); + + // Every actual topic has exactly one integer and one two-word float mapping. + std::set addresses; + for (const auto &range : kTopicRanges) { + for (uint16_t i = 0; i < range.count; ++i) { + auto integer = uint16_t(range.baseAddress + i); + auto floating = ModbusMap::floatAddress(integer); + assert(addresses.insert(integer).second); + assert(addresses.insert(floating).second && addresses.insert(floating + 1).second); + TopicSource source; + uint16_t decoded; + bool high; + assert(decodeTopicAddress(integer, source, decoded) && source == range.source && decoded == i); + assert(decodeFloatTopicAddress(floating, source, decoded, high) && high && source == range.source && decoded == i); + assert(decodeFloatTopicAddress(floating + 1, source, decoded, high) && !high && decoded == i); + read(integer); + auto pair = call(3, floating, 2); + assert(pair.bytes == std::vector({1, 3, 4, 0x3f, 0x80, 0, 0})); // float 1.0 + } + expectError(3, range.baseAddress + range.count, 1, ILLEGAL_DATA_ADDRESS); + expectError(3, ModbusMap::floatAddress(range.baseAddress + range.count), 1, ILLEGAL_DATA_ADDRESS); + } + // Growing any group to its full capacity never moves/overlaps another block. + addresses.clear(); + for (uint16_t base : {0, 1000, 2000, 3000}) { + for (uint16_t i = 0; i < 1000; ++i) { + auto start = ModbusMap::floatAddress(base + i); + assert(addresses.insert(base + i).second); + assert(addresses.insert(start).second && addresses.insert(start + 1).second); + uint16_t index; bool high; + assert(ModbusMap::decodeRange(start + 1, ModbusMap::floatAddress(base), 1000, 2, index, high)); + assert(index == i && !high); + } + } + assert(ModbusMap::floatAddress(139) == 10278); + assert(ModbusMap::floatAddress(1000) == 12000); + assert(ModbusMap::floatAddress(2000) == 14000); + + // Source-aware fixed scaling: extra/optional index 1 must not inherit Pump_Flow x100. + readings[0][14] = "-5.25"; + readings[0][67] = "2.2"; + readings[1][1] = "800"; + readings[2][1] = "2"; + assert(int16_t(read(14)) == -525 && read(67) == 220); + assert(read(1001) == 800 && read(2001) == 2); + readings[0][44] = "H74"; + assert(read(44) == 8074); + readings[0][14] = "999"; + assert(read(14) == 32767); + + // S0 values use independent fixed port blocks; disabled/uninitialized inputs are zero. + for (uint16_t base : {3000, 3100}) { + for (uint16_t field = 0; field < 6; ++field) { + assert(read(base + field) == 0); + assert(readFloat(ModbusMap::floatAddress(base + field)) == 0); + } + } + server.loop(true, true); + assert(read(3005) == 0); // initialization has not assigned a GPIO yet + actS0Settings[0].gpiopin = 1; + actS0Settings[0].ppkwh = 2000; + actS0Data[0].watt = 2500; + actS0Data[0].pulsesTotal = 10001; + actS0Data[0].pulses = 17; + actS0Data[0].lastReportWatthour = 0.5f; + actS0Data[0].goodPulses = 99; + actS0Data[0].badPulses = 25; + actS0Data[0].avgPulseWidth = 37; + actS0Settings[1].gpiopin = 2; + actS0Settings[1].ppkwh = 1000; + actS0Data[1].watt = 42000; + actS0Data[1].pulsesTotal = 4000000000U; + actS0Data[1].lastReportWatthour = 12.25f; + actS0Data[1].avgPulseWidth = 50; + const float expectedS0[2][6] = {{2500, 5000.5f, 0.5f, 80, 37, 1}, + {42000, 4000000000.0f, 12.25f, 100, 50, 1}}; + for (uint16_t port = 0; port < 2; ++port) { + for (uint16_t field = 0; field < 6; ++field) { + const uint16_t integer = 3000 + port * 100 + field; + assert(readFloat(ModbusMap::floatAddress(integer)) == expectedS0[port][field]); + const float value = expectedS0[port][field]; + assert(read(integer) == (value >= 32767 ? 32767 : uint16_t(value))); + expectError(6, integer, 0, ILLEGAL_DATA_ADDRESS); // read-only + } + expectError(3, 3006 + port * 100, 1, ILLEGAL_DATA_ADDRESS); + expectError(3, 16012 + port * 200, 1, ILLEGAL_DATA_ADDRESS); + auto block = call(3, 16000 + port * 200, 12); + assert(block.bytes.size() == 27); // all six floats in one request + } + assert(actS0Data[0].pulses == 17 && actS0Data[0].pulsesTotal == 10001); + assert(actS0Data[0].lastReportWatthour == 0.5f); // reading never resets an interval + S0Reading snapshot[NUM_S0_COUNTERS]; + readS0Readings(true, snapshot); + actS0Data[0].watt = 1234; + uint16_t high, low; + assert(s0ToRegisterValue(16000, snapshot, high) && s0ToRegisterValue(16001, snapshot, low)); + uint32_t bits = (uint32_t(high) << 16) | low; + float original; + std::memcpy(&original, &bits, sizeof(original)); + assert(original == 2500); // both words use the captured sample + actS0Settings[1].ppkwh = 0; + assert(read(3105) == 0 && readFloat(16202) == 0); // no divide by zero + server.loop(false, true); + assert(readFloat(16000) == 0 && read(3005) == 0); + + // Commands dispatch by stable IDs and signed payload, never by table position. + // Writes are only queued by the Modbus callbacks; loop() executes them. + addresses.clear(); + for (const auto &command : commands) { + bool mapped = false; + for (const auto &entry : ModbusMap::MAIN_COMMANDS) mapped |= std::strcmp(command.name, entry.name) == 0; + assert(mapped); // every command has a permanent Modbus ID + } + for (const auto &command : ModbusMap::MAIN_COMMANDS) { + assert(command.id > 0 && command.id <= 1000); + const auto address = ModbusMap::commandAddress(command.id); + assert(addresses.insert(address).second); + bool exists = false; + for (const auto &upstream : commands) exists |= std::strcmp(command.name, upstream.name) == 0; + assert(exists); + if (std::strcmp(command.name, "SetCurves") == 0) { + expectError(6, address, 0, ILLEGAL_DATA_VALUE); + } else { + lastCommand.clear(); + // Temperature commands are x100 like the readings and only take whole degrees. + call(6, address, command.scale100 ? uint16_t(-500) : uint16_t(-5)); + assert(lastCommand.empty()); // not executed in the Modbus callback + server.loop(false, true); + assert(lastCommand == command.name && lastPayload == "-5"); + if (command.scale100) { + expectError(6, address, 150, ILLEGAL_DATA_VALUE); + call(6, address, 4500); + server.loop(false, true); + assert(lastPayload == "45"); + } + } + } + assert(ModbusMap::commandAddress(1) == 5000); + assert(ModbusMap::commandAddress(100) == 7000); + for (const auto &command : ModbusMap::OPTIONAL_COMMANDS) { + assert(command.id < 1000); + auto address = uint16_t(6000 + command.id); + assert(addresses.insert(address).second); + bool exists = false; + for (const auto &upstream : optionalCommands) exists |= std::strcmp(command.name, upstream.name) == 0; + assert(exists); + call(6, address, 7); + server.loop(false, true); + // Temperatures are written like they are read: x100. + assert(lastCommand == command.name && lastPayload == (command.scale100 ? "0.07" : "7")); + call(6, address, uint16_t(-525)); + server.loop(false, true); + assert(lastPayload == (command.scale100 ? "-5.25" : "-525")); + call(6, address, 2150); + server.loop(false, true); + assert(lastPayload == (command.scale100 ? "21.50" : "2150")); + } + // float32 writes (FC16, MSW first) mirror the int16 command block and are never scaled. + assert(ModbusMap::floatCommandAddress(5000) == 20000); + assert(ModbusMap::floatCommandAddress(5010) == 20020); // SetDHWTemp + assert(ModbusMap::floatCommandAddress(6006) == 22012); // SetPoolTemp + assert(ModbusMap::floatCommandAddress(7000) == 24000); // SetReset + for (const auto &command : ModbusMap::MAIN_COMMANDS) { + const uint16_t floatRegister = ModbusMap::floatCommandAddress(ModbusMap::commandAddress(command.id)); + if (std::strcmp(command.name, "SetCurves") == 0) { + expectError16(floatRegister, floatWords(1), ILLEGAL_DATA_VALUE); + continue; + } + lastCommand.clear(); + auto response = call16(floatRegister, floatWords(7)); + assert(response.bytes == std::vector({1, 16, uint8_t(floatRegister >> 8), uint8_t(floatRegister), 0, 2})); + assert(lastCommand.empty()); // queued only + server.loop(false, true); + assert(lastCommand == command.name && lastPayload == "7"); + if (command.scale100) { // whole degrees only, no truncation + expectError16(floatRegister, floatWords(7.5f), ILLEGAL_DATA_VALUE); + } + } + for (const auto &command : ModbusMap::OPTIONAL_COMMANDS) { + const uint16_t floatRegister = ModbusMap::floatCommandAddress(6000 + command.id); + call16(floatRegister, floatWords(7)); + server.loop(false, true); + assert(lastCommand == command.name && lastPayload == (command.scale100 ? "7.00" : "7")); + if (command.scale100) { + call16(floatRegister, floatWords(21.5f)); + server.loop(false, true); + assert(lastPayload == "21.50"); + } else { + expectError16(floatRegister, floatWords(0.5f), ILLEGAL_DATA_VALUE); + } + } + call16(20020, floatWords(-5)); // SetDHWTemp + server.loop(false, true); + assert(lastCommand == "SetDHWTemp" && lastPayload == "-5"); + expectError16(20020, floatWords(std::numeric_limits::quiet_NaN()), ILLEGAL_DATA_VALUE); + expectError16(20020, floatWords(std::numeric_limits::infinity()), ILLEGAL_DATA_VALUE); + expectError16(20020, floatWords(1e9f), ILLEGAL_DATA_VALUE); + expectError16(20021, floatWords(45), ILLEGAL_DATA_ADDRESS); // low word is not a start address + expectError16(20020, {0x4234}, ILLEGAL_DATA_VALUE); // one register is not a float + expectError16(20020, {0, 0, 0, 0}, ILLEGAL_DATA_VALUE); // one command per request + expectError16(20000 + 2 * 999, floatWords(1), ILLEGAL_DATA_ADDRESS); // no such command + expectError16(20000 + 2 * 3000, floatWords(1), ILLEGAL_DATA_ADDRESS); + expectError16(65534, floatWords(1), ILLEGAL_DATA_ADDRESS); + expectError16(3000, {1}, ILLEGAL_DATA_ADDRESS); // read-only registers + expectError16(5000, {}, ILLEGAL_DATA_VALUE); + // FC16 with a single int16 register behaves like FC06, including x100 temperatures. + call16(5004, {uint16_t(-500)}); // SetZ1HeatRequestTemperature + server.loop(false, true); + assert(lastCommand == "SetZ1HeatRequestTemperature" && lastPayload == "-5"); + expectError16(5004, {150}, ILLEGAL_DATA_VALUE); + expectError16(5004, {1, 2}, ILLEGAL_DATA_VALUE); + expectError(6, 20020, 45, ILLEGAL_DATA_ADDRESS); // FC06 cannot write floats + expectError(6, 1001, 1, ILLEGAL_DATA_ADDRESS); + expectError(6, 2000, 1, ILLEGAL_DATA_ADDRESS); + expectError(6, 5999, 1, ILLEGAL_DATA_ADDRESS); + expectError(6, 6100, 1, ILLEGAL_DATA_ADDRESS); + expectError(3, 65535, 2, ILLEGAL_DATA_ADDRESS); + expectError(3, 0, 0, ILLEGAL_DATA_VALUE); + expectError(3, 0, 126, ILLEGAL_DATA_VALUE); + call(5, 30000, 0xFF00); + assert(!relays[0]); // queued, not switched in the callback + server.loop(false, true); + assert(relays[0] && !relays[1]); + call(5, 30001, 0xFF00); + call(5, 30000, 0); + server.loop(false, true); + assert(!relays[0] && relays[1]); + expectError(5, 30002, 0, ILLEGAL_DATA_ADDRESS); + expectError(5, 30001, 1, ILLEGAL_DATA_VALUE); + + // Relay state can be read back: FC01 (coils 30000/30001, same as FC05) and registers 32010/32011. + assert(call(1, 30000, 2).bytes == std::vector({1, 1, 1, 2})); // relay 2 on, relay 1 off + assert(call(1, 30001, 1).bytes == std::vector({1, 1, 1, 1})); + assert(read(32010) == 0 && read(32011) == 1); + call(5, 30000, 0xFF00); + server.loop(false, true); + assert(call(1, 30000, 2).bytes == std::vector({1, 1, 1, 3})); + assert(read(32010) == 1 && read(32011) == 1); + relays[1] = false; // e.g. switched over MQTT: the next loop() picks it up + server.loop(false, true); + assert(call(1, 30000, 2).bytes == std::vector({1, 1, 1, 1})); + expectError(1, 30002, 1, ILLEGAL_DATA_ADDRESS); + expectError(1, 30001, 2, ILLEGAL_DATA_ADDRESS); + expectError(1, 30000, 0, ILLEGAL_DATA_VALUE); + expectError(1, 30000, 2001, ILLEGAL_DATA_VALUE); + expectError(3, 32012, 1, ILLEGAL_DATA_ADDRESS); + call(5, 30000, 0); + server.loop(false, true); + + // A full write queue answers with a busy exception instead of dropping requests silently. + for (int i = 0; i < 16; ++i) call(6, 5000, 1); + expectError(6, 5000, 1, SERVER_DEVICE_BUSY); + expectError(5, 30000, 0xFF00, SERVER_DEVICE_BUSY); + server.loop(false, true); + call(6, 5000, 1); + server.loop(false, true); + + // Extra data block missing: extra registers raise exceptions instead of returning zeros. + server.loop(false, false); + expectError(3, 1001, 1, ILLEGAL_DATA_ADDRESS); + expectError(3, 12002, 2, ILLEGAL_DATA_ADDRESS); + assert(read(14) != 0 && read(2001) == 2); // main and optional registers are unaffected + server.loop(false, true); + assert(read(1001) == 800); + + // Optional PCB disabled: no optional registers, and optional commands are rejected. + server.setup(false, false, true); + server.loop(false, true); + expectError(3, 2001, 1, ILLEGAL_DATA_ADDRESS); + expectError(3, 14002, 2, ILLEGAL_DATA_ADDRESS); + expectError(6, 6006, 2150, ILLEGAL_DATA_ADDRESS); + expectError16(22012, floatWords(21.5f), ILLEGAL_DATA_ADDRESS); // optional PCB disabled + lastCommand.clear(); + call(6, 5000, 1); // main commands still work + server.loop(false, true); + assert(lastCommand == "SetHeatpump"); + + // Writes are refused unless explicitly allowed; reads keep working. + server.setup(true, false, false); + server.loop(false, true); + lastCommand.clear(); + relays[0] = false; + expectError(6, 5000, 1, ILLEGAL_FUNCTION); + expectError(6, 7000, 1, ILLEGAL_FUNCTION); + expectError(5, 30000, 0xFF00, ILLEGAL_FUNCTION); + expectError16(20000, floatWords(1), ILLEGAL_FUNCTION); + expectError16(5000, {1}, ILLEGAL_FUNCTION); + server.loop(false, true); + assert(lastCommand.empty() && !relays[0]); + assert(read(32000) == 3); + server.setup(true, false, true); + server.loop(false, true); + + // The web table must enumerate every topic, command, coil and version entry once. + unsigned count = 0; + String row; + while (HeishaModbusServer::registerRow(count, row)) { + assert(std::string(row.c_str()).find("