├── .github └── workflows │ ├── build-image.yml │ ├── ci.yml │ └── merge-manifests.yml ├── .gitignore ├── .prettierignore ├── .prettierrc ├── CHANGELOG.md ├── Dockerfile ├── README.md ├── bump-version.ts ├── docs ├── b2500.md └── venus.md ├── ha_addon ├── CHANGELOG.md ├── Dockerfile ├── config.yaml ├── run.sh ├── test_env.sh ├── test_run.sh └── translations │ ├── de.yaml │ └── en.yaml ├── jest.config.js ├── package-lock.json ├── package.json ├── repository.yaml ├── src ├── controlHandler.test.ts ├── controlHandler.ts ├── dataHandler.ts ├── device │ ├── b2500Base.ts │ ├── b2500V1.ts │ ├── b2500V2.ts │ ├── helpers.ts │ ├── jupiter.ts │ ├── registry.ts │ └── venus.ts ├── deviceDefinition.ts ├── deviceManager.test.ts ├── deviceManager.ts ├── generateDiscoveryConfigs.test.ts ├── generateDiscoveryConfigs.ts ├── homeAssistantDiscovery.ts ├── index.test.ts ├── index.ts ├── mqttClient.ts ├── mqttProxy.ts ├── parser.test.ts ├── parser.ts ├── types.ts └── utils │ ├── crypt.test.ts │ └── crypt.ts ├── test-addon.sh └── tsconfig.json /.github/workflows/build-image.yml: -------------------------------------------------------------------------------- 1 | name: Build Image 2 | 3 | on: 4 | workflow_call: 5 | inputs: 6 | registry: 7 | required: true 8 | type: string 9 | description: "Container registry to use" 10 | platform: 11 | required: true 12 | type: string 13 | description: "Platform to build for (e.g. linux/amd64)" 14 | base: 15 | required: false 16 | type: string 17 | description: "Base image to use (e.g. node:20-alpine)" 18 | default: "" 19 | context: 20 | required: true 21 | type: string 22 | dockerfile: 23 | required: true 24 | type: string 25 | build-args: 26 | required: false 27 | type: string 28 | default: "" 29 | image-suffix: 30 | required: true 31 | type: string 32 | digest-prefix: 33 | required: true 34 | type: string 35 | 36 | jobs: 37 | build: 38 | runs-on: ubuntu-latest 39 | permissions: 40 | contents: read 41 | packages: write 42 | steps: 43 | - name: Checkout 44 | uses: actions/checkout@v4 45 | - name: Set up QEMU 46 | uses: docker/setup-qemu-action@v3 47 | - id: lower-repo 48 | run: | 49 | echo "IMAGE_NAME=${GITHUB_REPOSITORY@L}${{ inputs.image-suffix }}" >> $GITHUB_OUTPUT 50 | - name: Extract metadata 51 | id: meta 52 | uses: docker/metadata-action@v5 53 | with: 54 | images: ${{ inputs.registry }}/${{ steps.lower-repo.outputs.IMAGE_NAME }} 55 | - name: Set up Docker Buildx 56 | uses: docker/setup-buildx-action@v3 57 | - name: Log in to Container registry 58 | uses: docker/login-action@v3 59 | with: 60 | registry: ${{ inputs.registry }} 61 | username: ${{ github.actor }} 62 | password: ${{ secrets.GITHUB_TOKEN }} 63 | - name: Build and push by digest 64 | id: build 65 | uses: docker/build-push-action@v5 66 | with: 67 | context: ${{ inputs.context }} 68 | platforms: ${{ inputs.platform }} 69 | file: ${{ inputs.dockerfile }} 70 | push: true 71 | outputs: type=image,name=${{ inputs.registry }}/${{ steps.lower-repo.outputs.IMAGE_NAME }},push-by-digest=true,name-canonical=true,push=true 72 | labels: ${{ steps.meta.outputs.labels }} 73 | build-args: | 74 | ${{ inputs.build-args }} 75 | BASE=${{ inputs.base }} 76 | NODE_ENV=production 77 | cache-from: type=gha 78 | cache-to: type=gha,mode=max 79 | - name: Export digest 80 | run: | 81 | mkdir -p /tmp/digests 82 | digest="${{ steps.build.outputs.digest }}" 83 | touch "/tmp/digests/${digest#sha256:}" 84 | - name: Set platform name 85 | id: platform 86 | run: | 87 | SAFE_PLATFORM=$(echo "${{ inputs.platform }}" | sed 's|/|-|g') 88 | echo "name=$SAFE_PLATFORM" >> $GITHUB_OUTPUT 89 | - name: Upload digest 90 | uses: actions/upload-artifact@v4 91 | with: 92 | name: ${{ inputs.digest-prefix }}${{ steps.platform.outputs.name }} 93 | path: /tmp/digests/* 94 | if-no-files-found: error 95 | retention-days: 1 96 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI Pipeline 2 | 3 | on: 4 | push: 5 | branches: [ main ] 6 | tags: [ "*.*.*" ] 7 | pull_request: 8 | branches: [ main ] 9 | 10 | permissions: 11 | packages: write 12 | contents: read 13 | 14 | jobs: 15 | validate: 16 | runs-on: ubuntu-latest 17 | strategy: 18 | fail-fast: false 19 | matrix: 20 | node-version: [16.x, 18.x, 20.x] 21 | steps: 22 | - name: Checkout repository 23 | uses: actions/checkout@v4 24 | 25 | - name: Set up Node.js 26 | uses: actions/setup-node@v4 27 | with: 28 | node-version: ${{ matrix.node-version }} 29 | cache: 'npm' 30 | 31 | - name: Install dependencies 32 | run: npm ci 33 | 34 | - name: Run linting 35 | run: npm run lint 36 | 37 | - name: Run tests 38 | run: npm test -- --ci --reporters=default --reporters=jest-junit --forceExit --detectOpenHandles 39 | env: 40 | JEST_JUNIT_OUTPUT_DIR: ./test-results 41 | 42 | - name: Build TypeScript 43 | run: npm run build 44 | 45 | - name: Upload test results 46 | uses: actions/upload-artifact@v4 47 | with: 48 | name: test-results-node-${{ matrix.node-version }} 49 | path: ./test-results/junit.xml 50 | if-no-files-found: warn 51 | 52 | test-addon: 53 | runs-on: ubuntu-latest 54 | needs: validate 55 | steps: 56 | - name: Checkout repository 57 | uses: actions/checkout@v4 58 | 59 | - name: Set up Node.js 60 | uses: actions/setup-node@v4 61 | with: 62 | node-version: '20.x' 63 | cache: 'npm' 64 | 65 | - name: Install dependencies 66 | run: npm ci 67 | 68 | - name: Build TypeScript 69 | run: npm run build 70 | 71 | - name: Build test Docker image 72 | run: | 73 | docker build -t hm2mqtt-test -f ha_addon/Dockerfile . 74 | 75 | - name: Run addon tests 76 | run: npm run test:addon 77 | 78 | build: 79 | needs: [validate, test-addon] 80 | strategy: 81 | fail-fast: false 82 | matrix: 83 | config: 84 | - platform: linux/amd64 85 | base: node:20-alpine 86 | - platform: linux/arm/v7 87 | base: node:20-alpine 88 | - platform: linux/arm64 89 | base: node:20-alpine 90 | uses: ./.github/workflows/build-image.yml 91 | with: 92 | registry: ghcr.io 93 | platform: ${{ matrix.config.platform }} 94 | base: ${{ matrix.config.base }} 95 | context: . 96 | dockerfile: ./Dockerfile 97 | image-suffix: "" 98 | digest-prefix: "digests-base-" 99 | 100 | merge: 101 | needs: [build] 102 | uses: ./.github/workflows/merge-manifests.yml 103 | with: 104 | registry: ghcr.io 105 | image-suffix: "" 106 | digest-prefix: "digests-base-" 107 | 108 | build-addon: 109 | strategy: 110 | fail-fast: false 111 | matrix: 112 | platform: 113 | - linux/amd64 114 | - linux/arm64 115 | - linux/arm/v7 116 | uses: ./.github/workflows/build-image.yml 117 | with: 118 | registry: ghcr.io 119 | platform: ${{ matrix.platform }} 120 | context: . 121 | dockerfile: ha_addon/Dockerfile 122 | build-args: | 123 | BUILD_FROM=ghcr.io/hassio-addons/base:14.2.2 124 | image-suffix: "-addon" 125 | digest-prefix: "digests-addon-" 126 | 127 | merge-addon: 128 | needs: [build-addon] 129 | uses: ./.github/workflows/merge-manifests.yml 130 | with: 131 | registry: ghcr.io 132 | image-suffix: "-addon" 133 | digest-prefix: "digests-addon-" 134 | -------------------------------------------------------------------------------- /.github/workflows/merge-manifests.yml: -------------------------------------------------------------------------------- 1 | name: Merge Manifests 2 | 3 | on: 4 | workflow_call: 5 | inputs: 6 | registry: 7 | required: true 8 | type: string 9 | description: "Container registry to use" 10 | image-suffix: 11 | required: true 12 | type: string 13 | digest-prefix: 14 | required: true 15 | type: string 16 | 17 | jobs: 18 | merge: 19 | runs-on: ubuntu-latest 20 | permissions: 21 | contents: read 22 | packages: write 23 | steps: 24 | - name: Download digests 25 | uses: actions/download-artifact@v4 26 | with: 27 | pattern: ${{ inputs.digest-prefix }}* 28 | path: /tmp/digests 29 | merge-multiple: true 30 | - name: Set up Docker Buildx 31 | uses: docker/setup-buildx-action@v3 32 | - id: lower-repo 33 | run: | 34 | echo "IMAGE_NAME=${GITHUB_REPOSITORY@L}${{ inputs.image-suffix }}" >> $GITHUB_OUTPUT 35 | - name: Extract metadata 36 | id: meta 37 | uses: docker/metadata-action@v5 38 | with: 39 | images: ${{ inputs.registry }}/${{ steps.lower-repo.outputs.IMAGE_NAME }} 40 | - name: Log in to Container registry 41 | uses: docker/login-action@v3 42 | with: 43 | registry: ${{ inputs.registry }} 44 | username: ${{ github.actor }} 45 | password: ${{ secrets.GITHUB_TOKEN }} 46 | - name: Create manifest list and push 47 | working-directory: /tmp/digests 48 | run: | 49 | docker buildx imagetools create $(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \ 50 | $(printf '${{ inputs.registry }}/${{ steps.lower-repo.outputs.IMAGE_NAME }}@sha256:%s ' *) 51 | - name: Inspect image 52 | run: | 53 | docker buildx imagetools inspect ${{ inputs.registry }}/${{ steps.lower-repo.outputs.IMAGE_NAME }}:${{ steps.meta.outputs.version }} 54 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .idea 2 | .aider* 3 | # Dependency directories 4 | node_modules/ 5 | 6 | # Build output 7 | dist/ 8 | 9 | # Environment variables 10 | .env 11 | 12 | # Logs 13 | logs 14 | *.log 15 | npm-debug.log* 16 | 17 | # OS specific files 18 | .DS_Store 19 | coverage/ 20 | .env 21 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | dist 3 | coverage 4 | package-lock.json 5 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "semi": true, 3 | "trailingComma": "all", 4 | "singleQuote": true, 5 | "printWidth": 100, 6 | "tabWidth": 2, 7 | "arrowParens": "avoid", 8 | "endOfLine": "auto" 9 | } 10 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to this project will be documented in this file. 4 | 5 | The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), 6 | and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). 7 | 8 | ## [1.4.1] - 2025-06-07 9 | 10 | - Add support for Jupiter C 11 | 12 | ## [1.4.0] - 2025-06-07 13 | 14 | ### Added 15 | 16 | - Add support for Marstek Jupiter and Jupiter Plus (JPLS-8H) devices (#38) 17 | 18 | ### Fixed 19 | 20 | - Venus: Fix version set and discharge power commands (#67, related to #60) 21 | - Fix time period weekday bug when changing power settings via MQTT - weekdays would change unexpectedly when modifying power, start/end times, or enabled status (#65, fixes #61) 22 | - MQTT proxy: Prevent all client ID conflicts (not just 'mst_' prefixed ones) to resolve connection issues with multiple devices (#64) 23 | 24 | ### Changed 25 | 26 | - Add `state_class: 'measurement'` to power sensors for Home Assistant statistics and Energy Dashboard support (#66, fixes #62) 27 | - MQTT proxy: Improved conflict resolution with retry logic and proper cleanup when clients disconnect (#64) 28 | 29 | ## [1.3.4] - 2025-05-27 30 | 31 | - Add optional MQTT proxy server to workaround a bug in the B2500 firmware 226.5 or 108.7 which disconnects other devices when connecting multiple devices simultaneously. See [this issue](https://github.com/tomquist/hm2mqtt/issues/41) and read the [README](https://github.com/tomquist/hm2mqtt) for more information. 32 | - Add more robust timeout handling: New setting `allowedConsecutiveTimeouts` to define the number of allowed timeouts before switching the device to offline. 33 | 34 | ## [1.3.3] - 2025-05-25 35 | 36 | - Fix: Prevent overlapping device requests and ensure robust polling. Only send new requests if there is no outstanding response timeout for the device, set the response timeout before sending requests, and use a consistent key for lastRequestTime. This resolves issues with multi-device polling, especially for B2500 devices. (Closes #41) 37 | 38 | ## [1.3.2] - 2025-05-17 39 | 40 | - B2500: Added Surplus Feed-in switch. This allows toggling surplus PV feed-in to the home grid when the battery is nearly full, via MQTT and Home Assistant. 41 | - B2500: Fix unit and device class of Extra 2 battery SoC 42 | 43 | ## [1.3.1] 44 | 45 | - Venus: 46 | - Fix working mode command 47 | - Add max charging/discharging power command 48 | - Add version command 49 | - Turn grid-type into read-only sensor 50 | - B2500: Support setting output power to a value below 80W 51 | 52 | ## [1.3.0] 53 | 54 | ### Breaking Change 55 | 56 | - Previously hm2mqtt published its own data to the `hame_energy/{deviceType}/` or `marstek_energy/{deviceType}` topic. From 1.3.0 onwards the topic changed to `hm2mqtt/{deviceType}` 57 | 58 | ### Added 59 | 60 | - **B2500**: Better support for devices with firmware >=226 (for HMA, HMF or HMK) or >=108 (for HMJ): 61 | - Automatically calculate new encrypted device ID: No need to wait for 20 minutes to get the encrypted id. Instead, just enter the MAC address. 62 | - Remove the need to manually enter the topicPrefix 63 | - **Venus**: Add BMS information sensors including: 64 | - Cell voltages (up to 16 cells) 65 | - Cell temperatures (up to 4 sensors) 66 | - BMS version, SOC, SOH, capacity 67 | - Battery voltage, current, temperature 68 | - Charge voltage and full charge capacity 69 | - Cell cycle count 70 | - Error and warning states 71 | - Total runtime and energy throughput 72 | - MOSFET temperature 73 | 74 | ## [1.2.0] 75 | 76 | ### Added 77 | 78 | - Add support for configurable MQTT topic prefix per device (defaults to 'hame_energy') to support B2500 devices with firmware version >v226 79 | 80 | ## [1.1.2] 81 | 82 | - B2500: Add support for devices of the HMJ series 83 | - B2500: Fix incorrect battery capacity sensor unit for host and extra battery 84 | 85 | ## [1.1.1] 86 | 87 | ### Added 88 | 89 | - Add sensors for when the data has last been updated 90 | 91 | ## [1.1.0] 92 | 93 | ### Added 94 | 95 | - B2500: Add cell voltage sensors 96 | - B2500: Add overall battery voltage and current sensors 97 | - B2500: Add calibration information sensors 98 | - Venus: Add wifi name sensor 99 | 100 | ### Fixed 101 | 102 | - Venus Working Status now uses the correct mapping (thanks jbe) 103 | 104 | ## [1.0.7] 105 | 106 | ### Added 107 | 108 | - B2500: Add total input and output power sensors 109 | 110 | ### Fixed 111 | 112 | - Venus battery capacity 113 | 114 | ## [1.0.6] 115 | 116 | ### Fixed 117 | 118 | - Always use flash-command for discharge mode on B2500 v1 device since the non-flash command is not supported 119 | 120 | ### Changed 121 | 122 | - Refactored advertisement registration 123 | 124 | ## [1.0.5] 125 | 126 | ### Added 127 | 128 | - Support changing output threshold for B2500 v1 devices 129 | - Allow v2 timer output values below 80 130 | 131 | ## [1.0.4] 132 | 133 | ### Fixed 134 | 135 | - Venus timer config 136 | 137 | ## [1.0.3] 138 | 139 | ### Fixed 140 | 141 | - Fix unit of measurement for number sensors 142 | 143 | ## [1.0.2] 144 | 145 | ### Fixed 146 | 147 | - Fix timer output value range 148 | 149 | ## [1.0.1] 150 | 151 | ### Added 152 | 153 | - Added support for Venus device type (HMG) 154 | - Added support for HMF series of B2500 155 | - Set state class for daily energy sensor to `total_increasing` 156 | 157 | ### Fixed 158 | 159 | - Multiple devices in Addon config 160 | 161 | ## [1.0.0] Initial Release 162 | 163 | ### Added 164 | 165 | - Initial release with support for HMA, HMB and HMK series of B2500 166 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:20-alpine 2 | 3 | # Create app directory 4 | WORKDIR /app 5 | 6 | # Copy package files and install dependencies 7 | COPY package*.json ./ 8 | RUN npm ci 9 | 10 | # Copy source code 11 | COPY tsconfig.json ./ 12 | COPY src/ ./src/ 13 | 14 | # Build TypeScript code 15 | RUN npm run build 16 | 17 | # Clean up development dependencies 18 | RUN npm ci --only=production 19 | 20 | # Set environment variables 21 | ENV NODE_ENV=production 22 | 23 | # Expose MQTT port if needed 24 | # EXPOSE 1883 25 | 26 | # Start the application 27 | CMD ["node", "dist/index.js"] 28 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # hm2mqtt 2 | 3 | Reads Hame energy storage MQTT data, parses it and exposes it as JSON. 4 | 5 | ## Overview 6 | 7 | hm2mqtt is a bridge application that connects Hame energy storage devices (like the B2500 series) to Home Assistant (or other home automation systems) through MQTT. It provides real-time monitoring and control of your energy storage system directly from your Home Assistant dashboard. 8 | 9 | ## Supported Devices 10 | 11 | - B2500 series (e.g. Marstek B2500-D, Greensolar, BluePalm, Plenti SOLAR B2500H, Be Cool BC2500B) 12 | - First generation without timer support 13 | - Second and third generation with timer support 14 | - Marstek Venus C 15 | - Marstek Venus E 16 | - Marstek Jupiter C 17 | - Marstek Jupiter E 18 | - Marstek Jupiter Plus 19 | 20 | ## Prerequisites 21 | 22 | - Before you start, you need a local MQTT broker. You can install one as a Home Assistant Addon: https://www.home-assistant.io/integrations/mqtt/#setting-up-a-broker 23 | - After setting up an MQTT broker, configure your energy storage device to send MQTT data to your MQTT broker: 24 | 1. For the **B2500**, you have two options: 25 | 26 | > **⚠️ Important for Multiple B2500 Devices**: If you plan to use multiple B2500 devices with firmware 226.5 or 108.7, configure them to connect to the MQTT proxy port (default: 1890) instead of your main MQTT broker. See the [MQTT Proxy Configuration](#mqtt-proxy-for-b2500-client-id-conflicts) section for details. 27 | 1. Contact the support and ask them to enable MQTT for your device, then configure the MQTT broker in the device settings through the PowerZero or Marstek app. 28 | 2. With your an Android Smartphone or with a Bluetooth enabled PC use [this tool](https://tomquist.github.io/hame-relay/b2500.html) to configure the MQTT broker directly via Bluetooth. **Make sure you write down the MAC address that is displayed in this tool or in the Marstek app! You will need it later on and the WIFI MAC address of the battery is the wrong one.** 29 | 30 | **Warning:** Enabling MQTT on the device will disable the cloud connection. You will not be able to use the PowerZero or Marstek app to monitor or control your device anymore. You can re-enable the cloud connection by installing [Hame Relay](https://github.com/tomquist/hame-relay#mode-1-storage-configured-with-local-broker-inverse_forwarding-false) in Mode 1. 31 | 2. The **Marstek Venus**, **Marstek Jupiter** and **Jupiter Plus** don't officially support MQTT. However, you can install the [Hame Relay](https://github.com/tomquist/hame-relay) in [Mode 2](https://github.com/tomquist/hame-relay#mode-2-storage-configured-with-hame-broker-inverse_forwarding-true) to forward the Cloud MQTT data to your local MQTT broker. 32 | 33 | ## Installation 34 | 35 | ### As a Home Assistant Add-on (Recommended) 36 | 37 | The easiest way to use hm2mqtt is as a Home Assistant add-on: 38 | 39 | 1. Add this repository URL to your Home Assistant add-on store: 40 | 41 | [![Open your Home Assistant instance and show the add add-on repository dialog with a specific repository URL pre-filled.](https://my.home-assistant.io/badges/supervisor_add_addon_repository.svg)](https://my.home-assistant.io/redirect/supervisor_add_addon_repository/?repository_url=https%3A%2F%2Fgithub.com%2Ftomquist%2Fhm2mqtt) 42 | 2. Install the "hm2mqtt" add-on 43 | 3. Configure your devices in the add-on configuration 44 | 4. Start the add-on 45 | 46 | ### Using Docker 47 | 48 | #### Pre-built Docker Image 49 | 50 | You can run hm2mqtt using the pre-built Docker image from the GitHub package registry: 51 | 52 | ```bash 53 | docker run -d --name hm2mqtt \ 54 | -e MQTT_BROKER_URL=mqtt://your-broker:1883 \ 55 | -e MQTT_USERNAME=your-username \ 56 | -e MQTT_PASSWORD=your-password \ 57 | -e POLL_CELL_DATA=false \ 58 | -e POLL_EXTRA_BATTERY_DATA=false \ 59 | -e POLL_CALIBRATION_DATA=false \ 60 | -e DEVICE_0=HMA-1:your-device-mac \ 61 | --restart=unless-stopped \ 62 | ghcr.io/tomquist/hm2mqtt:latest 63 | ``` 64 | **your-device-mac** has to be formatted like this: 001a2b3c4d5e (no colon and all lowercase). It's the one mentiond before! 65 | 66 | Configure multiple devices by adding more environment variables: 67 | 68 | ```bash 69 | # Example with multiple B2500 devices (requires MQTT proxy): 70 | docker run -d --name hm2mqtt \ 71 | -e MQTT_BROKER_URL=mqtt://your-broker:1883 \ 72 | -e MQTT_PROXY_ENABLED=true \ 73 | -e MQTT_PROXY_PORT=1890 \ 74 | -e DEVICE_0=HMA-1:001a2b3c4d5e \ 75 | -e DEVICE_1=HMA-1:001a2b3c4d5f \ 76 | -p 1890:1890 \ 77 | --restart=unless-stopped \ 78 | ghcr.io/tomquist/hm2mqtt:latest 79 | ``` 80 | 81 | The Docker image is automatically built and published to the GitHub package registry with each release. 82 | 83 | ### Using Docker Compose 84 | 85 | A docker-compose example for multiple B2500 devices: 86 | 87 | ```yaml 88 | version: '3.7' 89 | 90 | services: 91 | hm2mqtt: 92 | container_name: hm2mqtt 93 | image: ghcr.io/tomquist/hm2mqtt:latest 94 | restart: unless-stopped 95 | ports: 96 | - "1890:1890" # Expose proxy port for B2500 devices 97 | environment: 98 | - MQTT_BROKER_URL=mqtt://x.x.x.x:1883 99 | - MQTT_USERNAME='' 100 | - MQTT_PASSWORD='' 101 | - MQTT_PROXY_ENABLED=true # Enable proxy for multiple B2500s 102 | - MQTT_PROXY_PORT=1890 103 | - POLL_CELL_DATA=true 104 | - POLL_EXTRA_BATTERY_DATA=true 105 | - POLL_CALIBRATION_DATA=true 106 | - DEVICE_0=HMA-1:0019aa0d4dcb # First B2500 device 107 | - DEVICE_1=HMA-1:0019aa0d4dcc # Second B2500 device 108 | ``` 109 | 110 | For a single B2500 device, you can omit the proxy configuration: 111 | 112 | ```yaml 113 | version: '3.7' 114 | 115 | services: 116 | hm2mqtt: 117 | container_name: hm2mqtt 118 | image: ghcr.io/tomquist/hm2mqtt:latest 119 | restart: unless-stopped 120 | environment: 121 | - MQTT_BROKER_URL=mqtt://x.x.x.x:1883 122 | - MQTT_USERNAME='' 123 | - MQTT_PASSWORD='' 124 | - POLL_CELL_DATA=true 125 | - POLL_EXTRA_BATTERY_DATA=true 126 | - POLL_CALIBRATION_DATA=true 127 | - DEVICE_0=HMA-1:0019aa0d4dcb # 12-character MAC address 128 | ``` 129 | 130 | ### Manual Installation 131 | 132 | 1. Clone the repository: 133 | ```bash 134 | git clone https://github.com/tomquist/hm2mqtt.git 135 | cd hm2mqtt 136 | ``` 137 | 138 | 2. Install dependencies: 139 | ```bash 140 | npm install 141 | ``` 142 | 143 | 3. Build the application: 144 | ```bash 145 | npm run build 146 | ``` 147 | 148 | 4. Create a `.env` file with your configuration: 149 | ``` 150 | MQTT_BROKER_URL=mqtt://your-broker:1883 151 | MQTT_USERNAME=your-username 152 | MQTT_PASSWORD=your-password 153 | MQTT_POLLING_INTERVAL=60 154 | MQTT_RESPONSE_TIMEOUT=30 155 | POLL_CELL_DATA=false 156 | POLL_EXTRA_BATTERY_DATA=false 157 | POLL_CALIBRATION_DATA=false 158 | DEVICE_0=HMA-1:001a2b3c4d5e # 12-character MAC address 159 | ``` 160 | 161 | 5. Run the application: 162 | ```bash 163 | node dist/index.js 164 | ``` 165 | 166 | ## Configuration 167 | 168 | ### Environment Variables 169 | 170 | | Variable | Description | Default | 171 | |----------|-------------|-------------------------| 172 | | `MQTT_BROKER_URL` | MQTT broker URL | `mqtt://localhost:1883` | 173 | | `MQTT_CLIENT_ID` | MQTT client ID | `hm2mqtt-{random}` | 174 | | `MQTT_USERNAME` | MQTT username | - | 175 | | `MQTT_PASSWORD` | MQTT password | - | 176 | | `MQTT_POLLING_INTERVAL` | Interval between device polls in seconds | `60` | 177 | | `MQTT_RESPONSE_TIMEOUT` | Timeout for device responses in seconds | `15` | 178 | | `POLL_CELL_DATA` | Enable cell voltage (only available on B2500 devices) | false | 179 | | `POLL_EXTRA_BATTERY_DATA` | Enable extra battery data reporting (only available on B2500 devices) | false | 180 | | `POLL_CALIBRATION_DATA` | Enable calibration data reporting (only available on B2500 devices) | false | 181 | | `DEVICE_n` | Device configuration in format `{type}:{mac}` | - | 182 | | `MQTT_ALLOWED_CONSECUTIVE_TIMEOUTS` | Number of consecutive timeouts before a device is marked offline | `3` | 183 | | `MQTT_PROXY_ENABLED` | Enable MQTT proxy server for B2500 client ID conflict resolution | `false` | 184 | | `MQTT_PROXY_PORT` | Port for the MQTT proxy server | `1890` | 185 | 186 | ### Add-on Configuration 187 | 188 | ```yaml 189 | pollingInterval: 60 # Interval between device polls in seconds 190 | responseTimeout: 30 # Timeout for device responses in seconds 191 | allowedConsecutiveTimeouts: 3 # Number of consecutive timeouts before a device is marked offline 192 | devices: 193 | - deviceType: "HMA-1" 194 | deviceId: "your-device-mac" 195 | ``` 196 | 197 | The device id is the MAC address of the device in lowercase, without colons. 198 | 199 | **Important Note for B2500 Devices:** 200 | - Use the MAC address shown in the Marstek/PowerZero app's device list or in the Bluetooth configuration tool 201 | - **Important:** Do not use the WiFi interface MAC address - it must be the one shown in the app or Bluetooth tool 202 | 203 | ### MQTT Proxy for B2500 Client ID Conflicts 204 | 205 | **🔧 Recommended for Multiple B2500 Devices** 206 | 207 | If you have multiple B2500 devices (especially with firmware 226.5/108.7 or later), you **must** use the MQTT proxy to avoid client ID conflicts. The proxy resolves the firmware bug where all B2500 devices try to connect with the same client ID (`mst_`). 208 | 209 | #### How It Works 210 | 211 | ``` 212 | ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ 213 | │ B2500 #1 │ │ B2500 #2 │ │ B2500 #3 │ 214 | │ Client: mst_│ │ Client: mst_│ │ Client: mst_│ 215 | └──────┬──────┘ └──────┬──────┘ └──────┬──────┘ 216 | │ │ │ 217 | │ Port 1890 │ Port 1890 │ Port 1890 218 | │ │ │ 219 | └──────────────────┼──────────────────┘ 220 | │ 221 | ┌──────▼──────┐ 222 | │ MQTT Proxy │ 223 | │ Auto-resolve│ 224 | │ Client IDs: │ 225 | │ mst_123_abc │ 226 | │ mst_456_def │ 227 | │ mst_789_ghi │ 228 | └──────┬──────┘ 229 | │ Port 1883 230 | │ 231 | ┌──────▼──────┐ 232 | │ Main MQTT │ 233 | │ Broker │ 234 | │ (Mosquitto) │ 235 | └─────────────┘ 236 | ``` 237 | 238 | #### Quick Setup 239 | 240 | **Step 1: Enable the proxy in hm2mqtt** 241 | ```bash 242 | # Enable the MQTT proxy 243 | MQTT_PROXY_ENABLED=true 244 | MQTT_PROXY_PORT=1890 # Port for B2500 devices to connect to 245 | ``` 246 | 247 | **Step 2: Configure your B2500 devices** 248 | - **Before (problematic):** B2500 devices connect to `your-server:1883` 249 | - **After (working):** B2500 devices connect to `your-server:1890` 250 | 251 | #### Environment Variables 252 | 253 | ```bash 254 | # Main application connects to your MQTT broker 255 | MQTT_BROKER_URL=mqtt://your-broker:1883 256 | 257 | # Enable proxy for B2500 devices 258 | MQTT_PROXY_ENABLED=true 259 | MQTT_PROXY_PORT=1890 260 | 261 | # Your devices 262 | DEVICE_0=HMA-1:device1mac 263 | DEVICE_1=HMA-1:device2mac 264 | DEVICE_2=HMB-1:device3mac 265 | ``` 266 | 267 | #### Home Assistant Add-on Configuration 268 | 269 | ```yaml 270 | mqttProxyEnabled: true 271 | devices: 272 | - deviceType: "HMA-1" 273 | deviceId: "device1-mac" 274 | - deviceType: "HMA-1" 275 | deviceId: "device2-mac" 276 | - deviceType: "HMB-1" 277 | deviceId: "device3-mac" 278 | ``` 279 | 280 | #### Docker Example with Proxy 281 | 282 | ```yaml 283 | version: '3.7' 284 | 285 | services: 286 | hm2mqtt: 287 | container_name: hm2mqtt 288 | image: ghcr.io/tomquist/hm2mqtt:latest 289 | restart: unless-stopped 290 | ports: 291 | - "1890:1890" # Expose proxy port for B2500 devices 292 | environment: 293 | - MQTT_BROKER_URL=mqtt://your-broker:1883 294 | - MQTT_PROXY_ENABLED=true 295 | - MQTT_PROXY_PORT=1890 296 | - DEVICE_0=HMA-1:001a2b3c4d5e 297 | - DEVICE_1=HMA-1:001a2b3c4d5f 298 | - DEVICE_2=HMB-1:001a2b3c4d60 299 | ``` 300 | 301 | > **📖 Background**: This issue was first reported in [GitHub Issue #41](https://github.com/tomquist/hm2mqtt/issues/41) where users experienced problems with multiple B2500 devices after firmware update 226.5. 302 | 303 | ## Device Types 304 | 305 | The device type can be one of the following: 306 | - **HMB-X**: (e.g. HMB-1, HMB-2, ...) B2500 storage v1 307 | - **HMA-X**: (e.g. HMA-1, HMA-2, ...) B2500 storage v2 308 | - **HMK-X**: (e.g. HMK-1, HMK-2, ...) Greensolar storage v3 309 | - **HMG-X**: (e.g. HMG-50) Marstek Venus 310 | - **HMN-X**: (e.g. HMN-1) Marstek Jupiter E 311 | - **HMM-X**: (e.g. HMM-1) Marstek Jupiter C 312 | - **JPLS-X**: (e.g. JPLS-8H) Jupiter Plus 313 | 314 | ## Development 315 | 316 | ### Building 317 | 318 | ```bash 319 | npm run build 320 | ``` 321 | 322 | ### Testing 323 | 324 | ```bash 325 | npm test 326 | ``` 327 | 328 | ### Docker 329 | 330 | #### Building Your Own Docker Image 331 | 332 | If you prefer to build the Docker image yourself: 333 | 334 | ```bash 335 | docker build -t hm2mqtt . 336 | ``` 337 | 338 | Run the container: 339 | 340 | ```bash 341 | docker run -e MQTT_BROKER_URL=mqtt://your-broker:1883 -e DEVICE_0=HMA-1:your-device-mac hm2mqtt 342 | ``` 343 | 344 | ## MQTT Topics 345 | 346 | ### Device Data Topic 347 | 348 | Your device data is published to the following MQTT topic: 349 | 350 | ``` 351 | hm2mqtt/{device_type}/device/{device_mac}/data 352 | ``` 353 | 354 | This topic contains the current state of your device in JSON format, including battery status, power flow data, and device settings. 355 | 356 | ### Control Topics 357 | 358 | You can control your device by publishing messages to specific MQTT topics. The base topic pattern for commands is: 359 | 360 | ``` 361 | hm2mqtt/{device_type}/control/{device_mac}/{command} 362 | ``` 363 | 364 | ### Common Commands (All Devices) 365 | - `refresh`: Refreshes the device data 366 | - `factory-reset`: Resets the device to factory settings 367 | 368 | ### B2500 Commands (All Versions) 369 | - `discharge-depth`: Controls battery discharge depth (0-100%) 370 | - `restart`: Restarts the device 371 | - `use-flash-commands`: Toggles flash command mode 372 | 373 | ### B2500 V1 Specific Commands 374 | - `charging-mode`: Sets charging mode (`pv2PassThrough` or `chargeThenDischarge`) 375 | - `battery-threshold`: Sets battery output threshold (0-800W) 376 | - `output1`: Enables/disables output port 1 (`on` or `off`) 377 | - `output2`: Enables/disables output port 2 (`on` or `off`) 378 | 379 | ### B2500 V2/V3 Specific Commands 380 | - `charging-mode`: Sets charging mode (`chargeDischargeSimultaneously` or `chargeThenDischarge`) 381 | - `adaptive-mode`: Toggles adaptive mode (`on` or `off`) 382 | - `time-period/[1-5]/enabled`: Enables/disables specific time period (`on` or `off`) 383 | - `time-period/[1-5]/start-time`: Sets start time for period (HH:MM format) 384 | - `time-period/[1-5]/end-time`: Sets end time for period (HH:MM format) 385 | - `time-period/[1-5]/output-value`: Sets output power for period (0-800W) 386 | - `connected-phase`: Sets connected phase for CT meter (`1`, `2`, or `3`) 387 | - `time-zone`: Sets time zone (UTC offset in hours) 388 | - `sync-time`: Synchronizes device time with server 389 | - `surplus-feed-in`: Toggles Surplus Feed-in mode (`on` or `off`). When enabled, surplus PV power is fed into the home grid when the battery is nearly full. 390 | 391 | ### Venus Device Commands 392 | - `working-mode`: Sets working mode (`automatic`, `manual`, or `trading`) 393 | - `auto-switch-working-mode`: Toggles automatic mode switching (`on` or `off`) 394 | - `time-period/[0-9]/enabled`: Enables/disables time period (`on` or `off`) 395 | - `time-period/[0-9]/start-time`: Sets start time for period (HH:MM format) 396 | - `time-period/[0-9]/end-time`: Sets end time for period (HH:MM format) 397 | - `time-period/[0-9]/power`: Sets power value for period (-2500 to 2500W) 398 | - `time-period/[0-9]/weekday`: Sets days of week for period (0-6, where 0 is Sunday) 399 | - `get-ct-power`: Gets current transformer power readings 400 | - `transaction-mode`: Sets transaction mode parameters 401 | 402 | ### Jupiter Device Commands 403 | 404 | The following commands are supported by both Jupiter C, Jupiter E and Jupiter Plus devices: 405 | 406 | - `refresh`: Refreshes the device data 407 | - `factory-reset`: Resets the device to factory settings 408 | - `sync-time`: Synchronizes device time with server 409 | - `working-mode`: Sets working mode (`automatic` or `manual`) 410 | - `time-period/[0-4]/enabled`: Enables/disables time period (`on` or `off`) 411 | - `time-period/[0-4]/start-time`: Sets start time for period (HH:MM format) 412 | - `time-period/[0-4]/end-time`: Sets end time for period (HH:MM format) 413 | - `time-period/[0-4]/power`: Sets power value for period (W) 414 | - `time-period/[0-4]/weekday`: Sets days of week for period (0-6, where 0 is Sunday) 415 | 416 | > **Note:** The Jupiter does not support trading mode or auto-switch working mode. 417 | 418 | ### Examples 419 | 420 | ``` 421 | # Refresh data from a B2500 device 422 | mosquitto_pub -t "hm2mqtt/HMA-1/control/abcdef123456/refresh" -m "" 423 | 424 | # Set charging mode for B2500 425 | mosquitto_pub -t "hm2mqtt/HMA-1/control/abcdef123456/charging-mode" -m "chargeThenDischarge" 426 | 427 | # Enable Surplus Feed-in on B2500 V2 428 | mosquitto_pub -t "hm2mqtt/HMA-1/control/abcdef123456/surplus-feed-in" -m "on" 429 | 430 | # Disable Surplus Feed-in on B2500 V2 431 | mosquitto_pub -t "hm2mqtt/HMA-1/control/abcdef123456/surplus-feed-in" -m "off" 432 | 433 | # Enable timer period 1 on Venus device 434 | mosquitto_pub -t "hm2mqtt/HMG-50/control/abcdef123456/time-period/1/enabled" -m "on" 435 | 436 | # Enable timer period 0 on Jupiter Plus device 437 | mosquitto_pub -t "hm2mqtt/JPLS/control/abcdef123456/time-period/0/enabled" -m "on" 438 | ``` 439 | 440 | ## License 441 | 442 | [MIT License](LICENSE) 443 | 444 | ## Contributing 445 | 446 | Contributions are welcome! Please feel free to submit a Pull Request. 447 | -------------------------------------------------------------------------------- /bump-version.ts: -------------------------------------------------------------------------------- 1 | import fs from 'fs'; 2 | import path from 'path'; 3 | import { execSync } from 'child_process'; 4 | 5 | function readJSON(file: string) { 6 | return JSON.parse(fs.readFileSync(file, 'utf8')); 7 | } 8 | 9 | function writeJSON(file: string, data: any) { 10 | fs.writeFileSync(file, JSON.stringify(data, null, 2) + '\n'); 11 | } 12 | 13 | function bumpVersion(version: string): string { 14 | const parts = version.split('.').map(Number); 15 | parts[2] += 1; 16 | return parts.join('.'); 17 | } 18 | 19 | function isValidVersion(version: string): boolean { 20 | return /^\d+\.\d+\.\d+$/.test(version); 21 | } 22 | 23 | function updatePackageJson(newVersion: string) { 24 | const pkgPath = path.join(__dirname, 'package.json'); 25 | const pkg = readJSON(pkgPath); 26 | pkg.version = newVersion; 27 | writeJSON(pkgPath, pkg); 28 | console.log(`Updated package.json to version ${newVersion}`); 29 | } 30 | 31 | function updateConfigYaml(newVersion: string) { 32 | const configPath = path.join(__dirname, 'ha_addon', 'config.yaml'); 33 | let config = fs.readFileSync(configPath, 'utf8'); 34 | config = config.replace(/version: *["']?[\d.]+["']?/, `version: "${newVersion}"`); 35 | fs.writeFileSync(configPath, config); 36 | console.log(`Updated ha_addon/config.yaml to version ${newVersion}`); 37 | } 38 | 39 | function updateChangelog(newVersion: string) { 40 | const changelogPath = path.join(__dirname, 'CHANGELOG.md'); 41 | let changelog = fs.readFileSync(changelogPath, 'utf8'); 42 | const today = new Date().toISOString().slice(0, 10); 43 | changelog = changelog.replace( 44 | /^## \[(Next|Unreleased)\]/m, 45 | `## [${newVersion}] - ${today}` 46 | ); 47 | fs.writeFileSync(changelogPath, changelog); 48 | console.log(`Updated CHANGELOG.md to version ${newVersion}`); 49 | } 50 | 51 | function runNpmInstall() { 52 | console.log('Running npm install to update package-lock.json...'); 53 | execSync('npm install', { stdio: 'inherit' }); 54 | } 55 | 56 | function main() { 57 | const pkg = readJSON(path.join(__dirname, 'package.json')); 58 | const currentVersion = pkg.version; 59 | const argVersion = process.argv[2]; 60 | let newVersion: string; 61 | 62 | if (argVersion) { 63 | if (!isValidVersion(argVersion)) { 64 | console.error(`Invalid version string: ${argVersion}`); 65 | process.exit(1); 66 | } 67 | newVersion = argVersion; 68 | console.log(`Using provided version: ${newVersion}`); 69 | } else { 70 | newVersion = bumpVersion(currentVersion); 71 | console.log(`No version provided. Bumping patch: ${currentVersion} -> ${newVersion}`); 72 | } 73 | 74 | updatePackageJson(newVersion); 75 | updateConfigYaml(newVersion); 76 | updateChangelog(newVersion); 77 | runNpmInstall(); 78 | 79 | console.log(`\nVersion bump complete: ${currentVersion} -> ${newVersion}`); 80 | } 81 | 82 | main(); -------------------------------------------------------------------------------- /docs/b2500.md: -------------------------------------------------------------------------------- 1 | Here's the markdown conversion of the document: 2 | 3 | # Menu 4 | 5 | 1. [How to read the device information](#1-how-to-read-the-device-information) 6 | 2. [Charging mode setting(Flash storage)](#2-charging-mode-settingflash-storage) 7 | 3. [Discharge mode setting(Flash storage)](#3-discharge-mode-settingflash-storage) 8 | 4. [Discharge depth setting(Flash storage)](#4-discharge-depth-settingflash-storage) 9 | 5. [Start battery output threshold](#5-start-battery-output-threshold) 10 | 6. [Timed and fixed power discharge settings(Flash storage)](#6-timed-and-fixed-power-discharge-settingsflash-storage) 11 | 7. [Synchronization time setting](#7-synchronization-time-setting) 12 | 8. [Time zone setting(Flash storage)](#8-time-zone-settingflash-storage) 13 | 9. [Software restart](#9-software-restart) 14 | 10. [Restore factory settings](#10-restore-factory-settings) 15 | 11. [Charging mode setting(No flash storage)](#11-charging-mode-settingno-flash-storage) 16 | 12. [Discharge mode setting(No flash storage)](#12-discharge-mode-settingno-flash-storage) 17 | 13. [Discharge depth setting(No flash storage)](#13-discharge-depth-settingno-flash-storage) 18 | 14. [Timed and fixed power discharge settings(No flash storage)](#14-timed-and-fixed-power-discharge-settingsno-flash-storage) 19 | 20 | ## 1. How to read the device information 21 | 22 | ### 1.1 Subscribe 23 | 24 | **Topic:** 25 | ``` 26 | hame_energy/{type}/device/{uid or mac}/ctrl/# 27 | ``` 28 | 29 | **Payload:** 30 | ``` 31 | p1=0,p2=0,w1=0,w2=0,pe-99,vv=160,cs=1,cd=0,am=0,o1=1,02=1,do=2,1v=200,cj=1,kn=4412,g1=96,g2=99,b1=1,b2=0,md=0,d1=1,e1=0:0,f1=24:0,h1=200,d2=0,e2=0:0,2=0:0,h2=0,d3=0,e3=0:0f3=0:0,h3=0,sg=0,sp=100,st=0,t1=26,th=28,tc=0,t=0,c=202303012046 32 | ``` 33 | 34 | | Parameter | Description | 35 | |-----------|-------------| 36 | | p1 | Solar input status 1 | 37 | | p2 | Solar input status 2 | 38 | | w1 | Solar 1 input power | 39 | | w2 | Solar 2 input power | 40 | | pe | Battery percentage | 41 | | vv | Device version number | 42 | | cs | Charging settings | 43 | | cd | Discharge settings | 44 | | am | AM | 45 | | o1 | Output State 1 | 46 | | o2 | Output State 2 | 47 | | do | dod discharge depth | 48 | | lv | Battery output threshold | 49 | | cj | Scene | 50 | | kn | Battery capacity | 51 | | g1 | Output power 1 | 52 | | g2 | Output power 2 | 53 | | b1 | Is power pack 1 connected | 54 | | b2 | Is power pack 2 connected | 55 | | md | Discharge setting mode | 56 | | d1 | Time1 enable status | 57 | | e1 | Time1 start time | 58 | | f1 | Time1 end time | 59 | | h1 | Time1 output value | 60 | | d2 | Time2 enable status | 61 | | e2 | Time2 start time | 62 | | f2 | Time2 end time | 63 | | h2 | Time2 output value | 64 | | d3 | Time3 enable status | 65 | | e3 | Time3 start time | 66 | | f3 | Time3 end time | 67 | | h3 | Time3 output value | 68 | | d4* | Time4 enable status | 69 | | e4* | Time4 start time | 70 | | f4* | Time4 end time | 71 | | h4* | Time4 output value | 72 | | d5* | Time5 enable status | 73 | | e5* | Time5 start time | 74 | | f5* | Time5 end time | 75 | | h5* | Time5 output value | 76 | | sg | Is the sensor connected | 77 | | sp | Automatic power size of the monitor | 78 | | st | The power transmitted by the monitor | 79 | | tl | Minimum temperature of battery cells | 80 | | th | Maximum temperature of battery cells | 81 | | tc | Charging temperature alarm | 82 | | tf | Discharge temperature alarm | 83 | | ts | Signal WiFi signal detection | 84 | | fc | Chip fc4 version number | 85 | | id** | Device ID | 86 | | a0** | Host battery capacity | 87 | | a1** | Extra 1 battery capacity | 88 | | a2** | Extra 2 battery capacity | 89 | | l0** | Host battery sign position (bit3:undervoltage,bit2:dod,bit1:charge,bit0:discharge) | 90 | | l1** | Extra 1 and extra 2 battery sign position | 91 | | bc*** | Daily total battery charging power | 92 | | bs*** | Daily total power of battery discharge | 93 | | pt*** | Daily total photovoltaic charging power | 94 | | it*** | Daily micro reverse output total power | 95 | | c0**** | The channel currently connected to CTCH | 96 | | c1**** | The current status of the host CT | 97 | | m0**** | The power collected by the first acquisition clip of CT001 | 98 | | m1**** | The power collected by the second acquisition clip of CT001 | 99 | | m2**** | The power collected by the third acquisition clip of CT001 | 100 | | m3**** | Micro Inverter current real-time power | 101 | | lmo1***** | Rated output power of device output channel 1 | 102 | | lmo2***** | Rated output power of device output channel 2 | 103 | | lmi1***** | Rated input power of device input channel 1 | 104 | | lmi2***** | Rated input power of device input channel 2 | 105 | | lmf***** | Is the device limited (including input and output restrictions) | 106 | 107 | _* 218.2 and later versions_ 108 | _** 212.17 and later versions_ 109 | _*** 218 and later versions_ 110 | _**** 218.2 and later versions_ 111 | _***** 220.1 and later versions_ 112 | 113 | ### 1.2 Public 114 | 115 | **Topic:** 116 | ``` 117 | hame_energy/{type}/App/{uid or mac}/ctrl 118 | ``` 119 | 120 | **Payload:** 121 | ``` 122 | cd=01 123 | ``` 124 | 125 | ## 2. Charging mode setting(Flash storage) 126 | 127 | ### 2.1 Public 128 | 129 | **Topic:** 130 | ``` 131 | hame_energy/{type}/App/{uid or mac}/ctrl 132 | ``` 133 | 134 | **Payload:** 135 | 1. `cd=03,md=0` - Charging and discharging simultaneously 136 | 2. `cd=03,md=1` - Fully charged and then discharged 137 | 138 | *Note: It will be saved to flash* 139 | 140 | ## 3. Discharge mode setting(Flash storage) 141 | 142 | ### 3.1 Public 143 | 144 | **Topic:** 145 | ``` 146 | hame_energy/{type}/App/{uid or mac}/ctrl 147 | ``` 148 | 149 | **Payload:** 150 | 1. `cd=04,md=0` - Disable OUT1&OUT2 151 | 2. `cd=04,md=1` - Enable OUT1 152 | 3. `cd=04,md=2` - Enable OUT2 153 | 4. `cd=04,md=3` - Enable OUT1&OUT2 154 | 155 | *Notes:* 156 | 1. *Only suitable for B2500 first generation, product is HMB-* 157 | 2. *It will be saved to flash* 158 | 159 | ## 4. Discharge depth setting(Flash storage) 160 | 161 | ### 4.1 Public 162 | 163 | **Topic:** 164 | ``` 165 | hame_energy/{type}/App/{uid or mac}/ctrl 166 | ``` 167 | 168 | **Payload:** 169 | ``` 170 | cd=05,md=0 171 | ``` 172 | md=0-100, For example, setting 95: cd=05, md=95 173 | 174 | *Note: It will be saved to flash* 175 | 176 | ## 5. Start battery output threshold 177 | 178 | ### 5.1 Public 179 | 180 | **Topic:** 181 | ``` 182 | hame_energy/{type}/App/{uid or mac}/ctrl 183 | ``` 184 | 185 | **Payload:** 186 | ``` 187 | cd=06,md=0 188 | ``` 189 | md=0-500, For example, setting 300: cd=06, md=300 190 | 191 | ## 6. Timed and fixed power discharge settings(Flash storage) 192 | 193 | ### 6.1 Public 194 | 195 | **Topic:** 196 | ``` 197 | hame_energy/{type}/App/{uid or mac}/ctrl 198 | ``` 199 | 200 | **Payload:** 201 | 202 | 1. `cd=07,md=0` - Set discharge for three periods of time 203 | - First time period: 204 | ``` 205 | cd=07,md=0,a1=1,b1=0:30,e1=6:30,v1=260,a2=0,b2=12:30,e2=20:30,v2=123,a3=0,b3=12:30,e3=20:30,v3=123 206 | ``` 207 | - Second time period: 208 | ``` 209 | cd=07,md=0,a1=0,b1=0:30,e1=6:30,v1=260,a2=1,b2=12:30,e2=20:30,v2=340,a3=0,b3=12:30,e3=20:30,v3=123 210 | ``` 211 | - Third time period: 212 | ``` 213 | cd=07,md=0,a1=0,b1=0:30,e1=6:30,v1=260,a2=0,b2=12:30,e2=20:30,v2=123,a3=1,b3=21:30,e3=23:30,v3=250 214 | ``` 215 | 216 | 2. `cd=07,md=1` - Automatically recognize based on the monitor 217 | 218 | | Parameter | Description | 219 | |-----------|-------------| 220 | | a1 | First time period energy state (0=off, 1=on) | 221 | | b1 | First time period start time | 222 | | e1 | First time period end time | 223 | | v1 | First time period output port value [80,800] | 224 | | a2 | Second time period energy state (0=off, 1=on) | 225 | | b2 | Second time period start time | 226 | | e2 | Second time period end time | 227 | | v2 | Second time period output port value [80,800] | 228 | | a3 | Third time period energy state (0=off, 1=on) | 229 | | b3 | Third time period start time | 230 | | e3 | Third time period end time | 231 | | v3 | Third time period output port value [80,800] | 232 | 233 | *Notes:* 234 | 1. *Suitable for the second generation B2500, products are HMA -, HMF -, HMK-* 235 | 2. *It will be saved to flash* 236 | 237 | ## 7. Synchronization time setting 238 | 239 | ### 7.1 Public 240 | 241 | **Topic:** 242 | ``` 243 | hame_energy/{type}/App/{uid or mac}/ctrl 244 | ``` 245 | 246 | **Payload:** 247 | ``` 248 | cd=08,wy=480,yy=123,mm=1,rr=2,hh=23,mn=56,ss=56 249 | ``` 250 | 251 | | Parameter | Description | 252 | |-----------|-------------| 253 | | wy | Time offset | 254 | | yy | Year (actual year minus 1900) | 255 | | mm | Month (0-11, 0=January) | 256 | | rr | Date (1-31) | 257 | | hh | Hour (0-23) | 258 | | mn | Minute (0-59) | 259 | | ss | Second (0-59) | 260 | 261 | ## 8. Time zone setting(Flash storage) 262 | 263 | ### 8.1 Public 264 | 265 | **Topic:** 266 | ``` 267 | hame_energy/{type}/App/{uid or mac}/ctrl 268 | ``` 269 | 270 | **Payload:** 271 | ``` 272 | cd=09,wy=480 273 | ``` 274 | 275 | *Note: It will be saved to flash* 276 | 277 | ## 9. Software restart 278 | 279 | ### 9.1 Public 280 | 281 | **Topic:** 282 | ``` 283 | hame_energy/{type}/App/{uid or mac}/ctrl 284 | ``` 285 | 286 | **Payload:** 287 | ``` 288 | cd=10 289 | ``` 290 | 291 | ## 10. Restore factory settings 292 | 293 | ### 10.1 Public 294 | 295 | **Topic:** 296 | ``` 297 | hame_energy/{type}/App/{uid or mac}/ctrl 298 | ``` 299 | 300 | **Payload:** 301 | ``` 302 | cd=11 303 | ``` 304 | 305 | ## 11. Charging mode setting(No flash storage) 306 | 307 | ### 11.1 Public 308 | 309 | **Topic:** 310 | ``` 311 | hame_energy/{type}/App/{uid or mac}/ctrl 312 | ``` 313 | 314 | **Payload:** 315 | 1. `cd=17,md=0` - Charging and discharging simultaneously 316 | 2. `cd=17,md=1` - Fully charged and then discharged 317 | 318 | *Notes:* 319 | 1. *Effective version: 214.1 and later versions* 320 | 2. *Do not save to flash* 321 | 322 | ## 12. Discharge mode setting(No flash storage) 323 | 324 | ### 12.1 Public 325 | 326 | **Topic:** 327 | ``` 328 | hame_energy/{type}/App/{uid or mac}/ctrl 329 | ``` 330 | 331 | **Payload:** 332 | 1. `cd=18,md=0` - Disable OUT1&OUT2 333 | 2. `cd=18,md=1` - Enable OUT1 334 | 3. `cd=18,md=2` - Enable OUT2 335 | 4. `cd=18,md=3` - Enable OUT1&OUT2 336 | 337 | *Notes:* 338 | 1. *Only suitable for B2500 first generation, product is HMB-* 339 | 2. *Effective version: 214.1 and later versions* 340 | 3. *Do not save to flash* 341 | 342 | ## 13. Discharge depth setting(No flash storage) 343 | 344 | ### 13.1 Public 345 | 346 | **Topic:** 347 | ``` 348 | hame_energy/{type}/App/{uid or mac}/ctrl 349 | ``` 350 | 351 | **Payload:** 352 | ``` 353 | cd=19,md=0 354 | ``` 355 | md=0-100, For example, setting 95: cd=05, md=95 356 | 357 | *Notes:* 358 | 1. *Effective version: 214.1 and later versions* 359 | 2. *Do not save to flash* 360 | 361 | ## 14. Timed and fixed power discharge settings(No flash storage) 362 | 363 | ### 14.1 Public 364 | 365 | **Topic:** 366 | ``` 367 | hame_energy/{type}/App/{uid or mac}/ctrl 368 | ``` 369 | 370 | **Payload:** 371 | 1. `cd=20,md=0` - Set discharge for three periods of time 372 | - First time period: 373 | ``` 374 | cd=20,md=0,a1=1,b1=0:30,e1=6:30,v1=260,a2=0,b2=12:30,e2=20:30,v2=123,a3=0,b3=12:30,e3=20:30,v3=123 375 | ``` 376 | - Second time period: 377 | ``` 378 | cd=20,md=0,a1=0,b1=0:30,e1=6:30,v1=260,a2=1,b2=12:30,e2=20:30,v2=340,a3=0,b3=12:30,e3=20:30,v3=123 379 | ``` 380 | - Third time period: 381 | ``` 382 | cd=20,md=0,a1=0,b1=0:30,e1=6:30,v1=260,a2=0,b2=12:30,e2=20:30,v2=123,a3=1,b3=21:30,e3=23:30,v3=250 383 | ``` 384 | 385 | 2. `cd=20,md=1` - Automatically recognize based on the monitor 386 | 387 | *Notes:* 388 | 1. *The parameter settings are the same as cd=07* 389 | 2. *Effective version: 214.1 and later versions* 390 | 3. *Do not save to flash* 391 | -------------------------------------------------------------------------------- /docs/venus.md: -------------------------------------------------------------------------------- 1 | # Venus MQTT Document 2 | ## Table of Contents 3 | 1. [MQTT Core Concepts](#1-mqtt-core-concepts) 4 | 1. [Introduction](#11-introduction) 5 | 2. [Publish/Subscribe Pattern](#12-publishsubscribe-pattern) 6 | 3. [MQTT Server](#13-mqtt-server) 7 | 4. [MQTT Client](#14-mqtt-client) 8 | 5. [Topic](#15-topic) 9 | 2. [Subscribe to your device](#2-subscribe-to-your-device) 10 | 3. [Read device information](#3-read-device-information) 11 | 1. [Public](#31-public) 12 | 2. [Receive](#32-receive) 13 | 4. [Set working status](#4-set-working-status) 14 | 1. [Public](#41-public) 15 | 5. [Set automatic discharge time period](#5-set-automatic-discharge-time-period) 16 | 1. [Public](#51-public) 17 | 6. [Set transaction mode content](#6-set-transaction-mode-content) 18 | 1. [Public](#61-public) 19 | 7. [Set device time](#7-set-device-time) 20 | 1. [Public](#71-public) 21 | 8. [Restore factory settings](#8-restore-factory-settings) 22 | 1. [Public](#81-public) 23 | 9. [Upgrade FC41D firmware version](#9-upgrade-fc41d-firmware-version) 24 | 1. [Public](#91-public) 25 | 10. [Enable EPS function](#10-enable-eps-function) 26 | 1. [Public](#101-public) 27 | 2. [Receive](#102-receive) 28 | 11. [Set version](#11-set-version) 29 | 1. [Public](#111-public) 30 | 2. [Receive](#112-receive) 31 | 12. [Set maximum charging power](#12-set-maximum-charging-power) 32 | 1. [Public](#121-public) 33 | 2. [Receive](#122-receive) 34 | 13. [Set maximum discharge power](#13-set-maximum-discharge-power) 35 | 1. [Public](#131-public) 36 | 2. [Receive](#132-receive) 37 | 14. [Set the meter type and supplementary power type](#14-set-the-meter-type-and-supplementary-power-type) 38 | 1. [Public](#141-public) 39 | 15. [Obtain CT power](#15-obtain-ct-power) 40 | 1. [Public](#151-public) 41 | 2. [Receive](#152-receive) 42 | 16. [Upgrade the firmware of the FC4 module](#16-upgrade-the-firmware-of-the-fc4-module) 43 | 1. [Public](#161-public) 44 | 2. [Receive](#162-receive) 45 | 46 | ## 1 MQTT Core Concepts 47 | 48 | ### 1.1 Introduction 49 | 50 | MQTT (Message Queue Telemetry Transport) is the most commonly used lightweight messaging protocol for the IoT (Internet of Things). The protocol is based on a publish/subscribe (pub/sub) pattern for message communication. It allows devices and applications to exchange data in real-time using a simple and efficient message format, which minimizes network overhead and reduces power consumption. 51 | 52 | ### 1.2 Publish/Subscribe Pattern 53 | 54 | The protocol is event-driven and connects devices using the pub/sub pattern. Different from the traditional client/server pattern, it is a messaging pattern in which senders (publishers) do not send messages directly to specific receivers (subscribers). Instead, publishers categorize messages into topics, and subscribers subscribe to specific topics that they are interested in. 55 | 56 | When a publisher sends a message to a topic, the MQTT broker routes and filters all incoming messages, and then delivers the message to all the subscribers that have expressed interest in that topic. 57 | 58 | The publisher and subscriber are decoupled from each other and do not need to know each other's existence. Their sole connection is based on a predetermined agreement regarding the message. The Pub/Sub pattern enables flexible message communication, as subscribers and publishers can be dynamically added or removed as needed. It also makes the implementation of message broadcasting, multicasting, and unicasting easier. 59 | 60 | ### 1.3 MQTT Server 61 | 62 | The MQTT server acts as a broker between the publishing clients and subscribing clients, forwarding all received messages to the matching subscribing clients. Therefore, sometimes the server is directly referred to as the MQTT Broker. 63 | 64 | ### 1.4 MQTT Client 65 | 66 | The clients refer to devices or applications that can connect to an MQTT server using the MQTT protocol. They can act as both publishers and subscribers or in either of those roles separately. 67 | 68 | ### 1.5 Topic 69 | 70 | Topics are used to identify and differentiate between different messages, forming the basis of MQTT message routing. Publishers can specify the topic of a message when publishing, while subscribers can choose to subscribe to topics of interest to receive relevant messages. 71 | 72 | ## 2 Subscribe to your device 73 | 74 | Before sending/receiving messages in MQTT, you must subscribe to your device using the following command: 75 | 76 | ``` 77 | hame_energy/{type}/device/{uid or mac}/ctrl 78 | ``` 79 | 80 | The parameters that need to be filled in the command include your device type, device ID or MAC. 81 | Venus currently has the following type: HMG-x, like HMG-1. 82 | 83 | ## 3 Read device information 84 | 85 | ### 3.1 Public 86 | 87 | Topic: 88 | ``` 89 | hame_energy/{type}/App/{uid or mac}/ctrl 90 | ``` 91 | 92 | Payload: 93 | ``` 94 | cd=01 95 | ``` 96 | 97 | ### 3.2 Receive 98 | 99 | You will receive a message, such as: 100 | 101 | ``` 102 | tot_i=44785,tot_o=36889,ele_d=489,ele_m=3931,grd_d=395,grd_m=2833,inc_d=0,inc_m=-111,grd_f=0,grd_o=807,grd_t=3,gct_s=1,cel_s=3,cel_p=138,cel_c=27,err_t=0,err_a=0,dev_n=140,grd_y=0,wor_m=1,tim_0=14|0|17|0|127|800|1,tim_1=17|1|20|0|127|-800|1,tim_2=20|1|23|0|127|800|1,tim_3=23|1|23|59|127|-800|1,tim_4=0|1|3|0|127|800|1,tim_5=3|1|6|0|127|-800|1,tim_6=6|1|9|0|127|800|1,tim_7=9|1|12|1|127|-800|1,tim_8=9|10|12|0|127|-2500|0,tim_9=0|0|0|0|0|0|0,cts_m=0,bac_u=1,tra_a=41,tra_i=40000,tra_o=600000,htt_p=0,prc_c=4620,prc_d=4620,wif_s=35,inc_a=-152,set_v=1,mcp_w=2500,mdp_w=800,ct_t=1,phase_t=0,dchrg_t=255,bms_v=109,fc_v=202407221950,wifi_n=Hame 103 | ``` 104 | 105 | Description of the above parameters: 106 | 107 | | Key | Description | 108 | |-----|-------------| 109 | | tot_i | Total cumulative charging capacity (0.01kw.h) | 110 | | tot_o | Total cumulative discharge capacity (0.01kw.h) | 111 | | ele_d | Daily cumulative charging capacity (0.01kw.h) | 112 | | ele_m | Monthly cumulative charging capacity (0.01kw.h) | 113 | | grd_d | Daily cumulative discharge capacity (0.01kw.h) | 114 | | grd_m | Monthly cumulative discharge capacity (0.01kw.h) | 115 | | inc_d | Daily cumulative income (Unit: 0.001 euros) | 116 | | inc_m | Monthly cumulative income (Unit: 0.001 euros) | 117 | | grd_f | Off grid power (VA) | 118 | | grd_o | Combined power (-: Charging +: Discharging, Unit: W) | 119 | | grd_t | Working status (0x0: sleep mode; 0x1: standby; 0x2: charging; 0x3: discharging; 0x4: backup mode; 0x5: OTA upgrade; 0x6: bypass status) | 120 | | gct_s | CT status (0: Not connected; 1: has been connected; 2: Weak signal) | 121 | | cel_s | Battery working status (1: Not working; 2: Charging; 3: Discharge) | 122 | | cel_p | Battery energy (0.01kWh) | 123 | | cel_c | SOC | 124 | | err_t | Error code (fault code) | 125 | | err_a | Error code (warning code) | 126 | | dev_n | Device version number | 127 | | grd_y | Grid type (0: Adaptive (220-240) (50-60hz) AUTO; 1: EN50549 EN50549; 2: Netherlands; 3: Germany; 4: Austria; 5: United Kingdom; 6: Spain; 7: Poland; 8: Italy; 9: China) | 128 | | wor_m | Working mode (0: Automatic; 1: Manual operation; 2: Trading) | 129 | | tim_0 | Start time (hour \| minute) \| End time (hour \| minute) \| Cycle \| Power \| Enable | 130 | | tim_1 | ditto | 131 | | tim_2 | ditto | 132 | | tim_3 | ditto | 133 | | tim_4 | ditto | 134 | | tim_5 | ditto | 135 | | tim_6 | ditto | 136 | | tim_7 | ditto | 137 | | tim_8 | ditto | 138 | | tim_9 | ditto | 139 | | cts_m | Automatically switch the working mode switch based on CT signals (0: Off; 1: On) | 140 | | bac_u | Enable status of back up function (0: Close; 1: Open) | 141 | | tra_a | Transaction mode - region code | 142 | | tra_i | Transaction mode - electricity price during charging (0: EU; 1: China; 2: North America) | 143 | | tra_o | Transaction mode - electricity price during discharge | 144 | | htt_p | HTTP Server Type | 145 | | prc_c | Obtain regional charging prices | 146 | | prc_d | Obtain regional discharge prices | 147 | | wif_s | WIFI signal strength (Less than 50: Good signal; 50-70: The signal is average; 70-80: Poor signal; Greater than 80: The signal is very weak) | 148 | | inc_a | Total cumulative income (Unit: 0.001 euros) | 149 | | set_v | Version set (0: 2500W version; 1: 800W version) | 150 | | mcp_w | Maximum charging power (Not exceeding 2500W) | 151 | | mdp_w | Maximum discharge power (Not exceeding 2500W) | 152 | | ct_t | CT type (0: No meter detected; 1: CT1; 2: CT2; 3: CT3; 4: Shelly pro; 5: p1 meter) | 153 | | phase_t | The phase where the device is located (0: Unknown; 1: Phase A; 2: Phase B; 3: Phase C; 4: Not detected) | 154 | | dchrg_t | Recharge mode (0: Single phase power supply; 1: Three phase power supply) | 155 | | bms_v | BMS version number | 156 | | fc_v | Communication module version number | 157 | | wifi_n | WIFI Name | 158 | 159 | ## 4 Set working status 160 | 161 | ### 4.1 Public 162 | 163 | Topic: 164 | ``` 165 | hame_energy/{type}/App/{uid or mac}/ctrl 166 | ``` 167 | 168 | Payload: 169 | 1. `cd=2,md=0` - Automatic mode 170 | 2. `cd=2,md=1` - Manual mode 171 | 3. `cd=2,md=2` - Trading mode 172 | 173 | ## 5 Set automatic discharge time period 174 | 175 | ### 5.1 Public 176 | 177 | Topic: 178 | ``` 179 | hame_energy/{type}/App/{uid or mac}/ctrl 180 | ``` 181 | 182 | Payload: 183 | ``` 184 | cd=3,md=1,nm=xx,bt=8:30,et=20:30,wk=1,vv=123,as=0 185 | ``` 186 | 187 | Description of the above parameters: 188 | 189 | | Key | Description | 190 | |-----|-------------| 191 | | cd | Instruction identification | 192 | | md | Working mode (0: Automatic; 1: Manual operation; 2: Trading) | 193 | | nm | [0-9] | 194 | | bt | Start Time | 195 | | et | End Time | 196 | | wk | Week[0-6] | 197 | | vv | Power | 198 | | as | Enable (0: disable; 1: enable) | 199 | 200 | ## 6 Set transaction mode content 201 | 202 | ### 6.1 Public 203 | 204 | Topic: 205 | ``` 206 | hame_energy/{type}/App/{uid or mac}/ctrl 207 | ``` 208 | 209 | Payload: 210 | ``` 211 | cd=3,md=2,id=xx,in=xx,on=xx 212 | ``` 213 | 214 | Description of the above parameters: 215 | 216 | | Key | Description | 217 | |-----|-------------| 218 | | cd | Instruction identification | 219 | | md | Working mode (0: Automatic; 1: Manual operation; 2: Trading) | 220 | | id | Region code | 221 | | in | Electricity price during charging | 222 | | on | Electricity price during discharge | 223 | 224 | ## 7 Set device time 225 | 226 | ### 7.1 Public 227 | 228 | Topic: 229 | ``` 230 | hame_energy/{type}/App/{uid or mac}/ctrl 231 | ``` 232 | 233 | Payload: 234 | ``` 235 | cd=4,yy=123,mm=1,rr=2,hh=23,mn=56 236 | ``` 237 | 238 | Description of the above parameters: 239 | 240 | | Key | Description | 241 | |-----|-------------| 242 | | cd | Instruction identification | 243 | | yy | Year | 244 | | mm | Month [0,11] (0 represents January) | 245 | | rr | Day [1,31] | 246 | | hh | Hour [0,23] | 247 | | mn | Minute [0,59] | 248 | 249 | ## 8 Restore factory settings 250 | 251 | ### 8.1 Public 252 | 253 | Topic: 254 | ``` 255 | hame_energy/{type}/App/{uid or mac}/ctrl 256 | ``` 257 | 258 | Payload: 259 | 1. `cd=5,rs=1` - Restore factory settings and clear accumulated data 260 | 2. `cd=5,rs=2` - Restore factory settings without clearing accumulated data 261 | 262 | ## 9 Upgrade FC41D firmware version 263 | 264 | ### 9.1 Public 265 | 266 | Topic: 267 | ``` 268 | hame_energy/{type}/App/{uid or mac}/ctrl 269 | ``` 270 | 271 | Payload: 272 | 1. `cd=9,ot=0` - OTA via URL interface 273 | 2. `cd=9,ot=1` - OTA via LAN setup 274 | 275 | ## 10 Enable EPS function 276 | 277 | ### 10.1 Public 278 | 279 | Topic: 280 | ``` 281 | hame_energy/{type}/App/{uid or mac}/ctrl 282 | ``` 283 | 284 | Payload: 285 | 1. `cd=11,bc=0` - Disable the back up function 286 | 2. `cd=11,bc=1` - Enable the back up function 287 | 288 | ### 10.2 Receive 289 | 290 | You will receive a message with a ret value: 291 | 1. `ret=0` - Setting failed 292 | 2. `ret=1` - Setting successful 293 | 294 | ## 11 Set version 295 | 296 | ### 11.1 Public 297 | 298 | Topic: 299 | ``` 300 | hame_energy/{type}/App/{uid or mac}/ctrl 301 | ``` 302 | 303 | Payload: 304 | 1. `cd=15,vs=800` - Set up 800W version 305 | 2. `cd=15,vs=2500` - Set up 2500W version 306 | 307 | ### 11.2 Receive 308 | 309 | You will receive a message with a ret value: 310 | 1. `ret=0` - Setting failed 311 | 2. `ret=1` - Setting successful 312 | 313 | ## 12 Set maximum charging power 314 | 315 | ### 12.1 Public 316 | 317 | Topic: 318 | ``` 319 | hame_energy/{type}/App/{uid or mac}/ctrl 320 | ``` 321 | 322 | Payload: 323 | ``` 324 | cd=16,cp=[300,2500] 325 | ``` 326 | 327 | ### 12.2 Receive 328 | 329 | You will receive a message with a ret value: 330 | 1. `ret=0` - Setting failed 331 | 2. `ret=1` - Setting successful 332 | 333 | ## 13 Set maximum discharge power 334 | 335 | ### 13.1 Public 336 | 337 | Topic: 338 | ``` 339 | hame_energy/{type}/App/{uid or mac}/ctrl 340 | ``` 341 | 342 | Payload: 343 | 1. `cd=15,vs=800` - Set up 800W version 344 | 2. `cd=15,vs=2500` - Set up 2500W version 345 | 346 | ### 13.2 Receive 347 | 348 | You will receive a message with a ret value: 349 | 1. `ret=0` - Setting failed 350 | 2. `ret=1` - Setting successful 351 | 352 | ## 14 Set the meter type and supplementary power type 353 | 354 | ### 14.1 Public 355 | 356 | Topic: 357 | ``` 358 | hame_energy/{type}/App/{uid or mac}/ctrl 359 | ``` 360 | 361 | Payload: 362 | 1. `cd=15,meter=0` - ct 363 | 2. `cd=15,meter=1` - shelly pro 364 | 3. `cd=15,meter=2` - p1 meter 365 | 4. `cd=15,dchrg=0` - single-phase 366 | 5. `cd=15,dchrg=1` - three-phase 367 | 368 | ## 15 Obtain CT power 369 | 370 | ### 15.1 Public 371 | 372 | Topic: 373 | ``` 374 | hame_energy/{type}/App/{uid or mac}/ctrl 375 | ``` 376 | 377 | Payload: 378 | ``` 379 | cd=19 380 | ``` 381 | 382 | ### 15.2 Receive 383 | 384 | You will receive a message: 385 | ``` 386 | get_power=%d|%d|%d|%d|%d (A-phase power | B-phase power | C-phase power | three-phase total power | output power) Unit: W 387 | ``` 388 | 389 | ## 16 Upgrade the firmware of the FC4 module 390 | 391 | ### 16.1 Public 392 | 393 | Topic: 394 | ``` 395 | hame_energy/{type}/App/{uid or mac}/ctrl 396 | ``` 397 | 398 | Payload: 399 | ``` 400 | cd=20,le=%d,url=%s 401 | ``` 402 | 403 | Description of the above parameters: 404 | 405 | | Key | Description | 406 | |-----|-------------| 407 | | cd | Instruction identification | 408 | | le | URL length | 409 | | url | Download path | 410 | 411 | ### 16.2 Receive 412 | 413 | If the device receives the message correctly, it will return `ret=1`. If it does not receive the message, there will be no return. 414 | -------------------------------------------------------------------------------- /ha_addon/CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ../CHANGELOG.md -------------------------------------------------------------------------------- /ha_addon/Dockerfile: -------------------------------------------------------------------------------- 1 | # Build stage 2 | FROM node:18.19.1-alpine AS builder 3 | 4 | WORKDIR /build 5 | 6 | # Copy package files 7 | COPY package*.json ./ 8 | COPY tsconfig.json ./ 9 | 10 | # Install all dependencies (including devDependencies) 11 | RUN npm ci 12 | 13 | # Copy source code 14 | COPY src/ ./src/ 15 | 16 | # Build the application 17 | RUN npm run build 18 | 19 | # Install production dependencies 20 | RUN npm ci --only=production 21 | 22 | # Final stage 23 | ARG BUILD_FROM 24 | FROM ${BUILD_FROM:-ghcr.io/hassio-addons/base:14.2.2} 25 | 26 | # Install Node.js 27 | RUN apk add --no-cache nodejs 28 | 29 | # Set work directory 30 | WORKDIR /app 31 | 32 | # Copy package file (for reference only) 33 | COPY package.json ./ 34 | 35 | # Copy production dependencies and built application from builder 36 | COPY --from=builder /build/node_modules ./node_modules 37 | COPY --from=builder /build/dist/ ./dist/ 38 | 39 | # Copy add-on files 40 | COPY ha_addon/run.sh / 41 | RUN chmod a+x /run.sh 42 | 43 | CMD [ "/run.sh" ] 44 | -------------------------------------------------------------------------------- /ha_addon/config.yaml: -------------------------------------------------------------------------------- 1 | name: "hm2mqtt" 2 | version: "1.4.1" 3 | slug: "hm2mqtt" 4 | description: "Connect Hame energy storage devices to Home Assistant via MQTT" 5 | url: "https://github.com/tomquist/hm2mqtt" 6 | image: "ghcr.io/tomquist/hm2mqtt-addon" 7 | arch: 8 | - armv7 9 | - aarch64 10 | - amd64 11 | init: false 12 | startup: application 13 | boot: auto 14 | services: 15 | - mqtt:need 16 | hassio_api: true 17 | hassio_role: default 18 | ports: 19 | 1890/tcp: 1890 20 | options: 21 | pollingInterval: 60 22 | responseTimeout: 30 23 | enableCellData: false 24 | enableCalibrationData: false 25 | enableExtraBatteryData: false 26 | allowedConsecutiveTimeouts: 3 27 | mqttProxyEnabled: false 28 | devices: 29 | - deviceType: "HMA-1" 30 | deviceId: "device-mac-address" 31 | schema: 32 | mqtt_uri: str? 33 | pollingInterval: int? 34 | responseTimeout: int? 35 | enableCellData: bool 36 | enableCalibrationData: bool 37 | enableExtraBatteryData: bool 38 | allowedConsecutiveTimeouts: int? 39 | debug: bool? 40 | mqttProxyEnabled: bool? 41 | devices: 42 | - deviceType: str 43 | deviceId: str 44 | -------------------------------------------------------------------------------- /ha_addon/run.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/with-contenv bashio 2 | 3 | # Enable error handling 4 | set -e 5 | 6 | # Function to start the application 7 | start_application() { 8 | bashio::log.info "Starting hm2mqtt..." 9 | cd /app && node dist/index.js || bashio::log.error "Application crashed with exit code $?" 10 | } 11 | 12 | # Function to output environment variables for testing 13 | output_env_for_testing() { 14 | bashio::log.info "Running in test mode, outputting environment variables" 15 | # Output all environment variables that start with MQTT_ or DEVICE_ 16 | env | grep -E "^(MQTT_|DEVICE_|POLL_|DEBUG=)" | sort 17 | } 18 | 19 | # Function to manually parse options.json 20 | parse_options_json() { 21 | local file="$1" 22 | local key="$2" 23 | local default="$3" 24 | 25 | if [ -f "$file" ]; then 26 | local value 27 | value=$(jq -r ".$key // \"$default\"" "$file" 2>/dev/null) 28 | if [ "$value" != "null" ] && [ -n "$value" ]; then 29 | echo "$value" 30 | return 0 31 | fi 32 | fi 33 | 34 | echo "$default" 35 | return 1 36 | } 37 | 38 | # Function to get MQTT URI 39 | get_mqtt_uri() { 40 | # First check if mqtt_uri is provided in the config 41 | if bashio::config.has_value 'mqtt_uri'; then 42 | bashio::log.info "Using custom MQTT broker URL from configuration" 43 | bashio::config 'mqtt_uri' 44 | # Fall back to Home Assistant's internal MQTT broker if available 45 | elif bashio::services.available "mqtt"; then 46 | bashio::log.info "Using Home Assistant's internal MQTT broker" 47 | local ssl=$(bashio::services mqtt "ssl") 48 | local protocol="mqtt" 49 | if [[ "$ssl" == "true" ]]; then 50 | protocol="mqtts" 51 | fi 52 | local host=$(bashio::services mqtt "host") 53 | local port=$(bashio::services mqtt "port") 54 | local username=$(bashio::services mqtt "username") 55 | local password=$(bashio::services mqtt "password") 56 | 57 | local uri="${protocol}://" 58 | if [[ -n "$username" && -n "$password" ]]; then 59 | uri+="${username}:${password}@" 60 | elif [[ -n "$username" ]]; then 61 | uri+="${username}@" 62 | fi 63 | uri+="${host}:${port}" 64 | echo "$uri" 65 | else 66 | bashio::log.error "No MQTT URI provided in config and MQTT service is not available." 67 | exit 1 68 | fi 69 | } 70 | 71 | # Enable debug logging by default for now to help diagnose issues 72 | bashio::log.level "debug" 73 | bashio::log.debug "Debug logging enabled by default" 74 | 75 | # Try to check if debug is enabled in config, but don't fail if it errors 76 | if bashio::config.true 'debug' 2>/dev/null; then 77 | bashio::log.debug "Debug logging confirmed via configuration" 78 | 79 | # Try to print configuration but don't fail if it errors 80 | bashio::log.debug "Attempting to print full addon configuration:" 81 | (bashio::config 2>/dev/null | jq '.' 2>/dev/null) || bashio::log.warning "Failed to print configuration" 82 | fi 83 | 84 | # Create config directory 85 | mkdir -p /app/config 86 | 87 | # Get MQTT URI 88 | BROKER_URL=$(get_mqtt_uri) 89 | bashio::log.info "MQTT Broker URL: ${BROKER_URL}" 90 | 91 | # Set environment variables for the application 92 | export MQTT_BROKER_URL="${BROKER_URL}" 93 | export MQTT_CLIENT_ID="hm2mqtt-ha-addon" 94 | 95 | # Try to get config values with proper default handling 96 | export MQTT_POLLING_INTERVAL=$(bashio::config 'pollingInterval' "60") 97 | export MQTT_RESPONSE_TIMEOUT=$(bashio::config 'responseTimeout' "30") 98 | export POLL_CELL_DATA=$(bashio::config 'enableCellData' "false") 99 | export POLL_CALIBRATION_DATA=$(bashio::config 'enableCalibrationData' "false") 100 | export POLL_EXTRA_BATTERY_DATA=$(bashio::config 'enableExtraBatteryData' "false") 101 | export DEBUG=$(bashio::config 'debug' "false") 102 | export MQTT_ALLOWED_CONSECUTIVE_TIMEOUTS=$(bashio::config 'allowedConsecutiveTimeouts' "3") 103 | export MQTT_PROXY_ENABLED=$(bashio::config 'mqttProxyEnabled' "false") 104 | bashio::log.info "MQTT allowed consecutive timeouts: ${MQTT_ALLOWED_CONSECUTIVE_TIMEOUTS}" 105 | bashio::log.info "MQTT proxy enabled: ${MQTT_PROXY_ENABLED}" 106 | bashio::log.info "MQTT polling interval: ${MQTT_POLLING_INTERVAL} seconds" 107 | bashio::log.info "MQTT response timeout: ${MQTT_RESPONSE_TIMEOUT} seconds" 108 | 109 | # Process devices using Bashio functions properly 110 | bashio::log.info "Configuring devices..." 111 | DEVICE_COUNT=0 112 | 113 | # Clean approach to reading the devices array 114 | if bashio::config.exists 'devices'; then 115 | # Get the actual count of elements directly from the config 116 | if DEVICE_COUNT=$(bashio::config 'devices|length'); then 117 | bashio::log.info "Found ${DEVICE_COUNT} devices in configuration" 118 | 119 | # Process each device in the array 120 | for i in $(seq 0 $((DEVICE_COUNT - 1))); do 121 | # Use Bashio to access array elements with proper error handling 122 | if bashio::config.exists "devices[${i}].deviceType" && bashio::config.exists "devices[${i}].deviceId"; then 123 | DEVICE_TYPE=$(bashio::config "devices[${i}].deviceType") 124 | DEVICE_ID=$(bashio::config "devices[${i}].deviceId") 125 | 126 | # Skip if either value is empty 127 | if [ -n "$DEVICE_TYPE" ] && [ -n "$DEVICE_ID" ]; then 128 | export "DEVICE_${i}=${DEVICE_TYPE}:${DEVICE_ID}" 129 | bashio::log.info "Configured device $((i + 1)): ${DEVICE_TYPE}:${DEVICE_ID}" 130 | else 131 | bashio::log.warning "Device ${i} has empty deviceType or deviceId" 132 | fi 133 | else 134 | bashio::log.warning "Device ${i} is missing required parameters (deviceType or deviceId)" 135 | fi 136 | done 137 | else 138 | bashio::log.error "Failed to determine the number of devices in the configuration" 139 | DEVICE_COUNT=0 140 | fi 141 | else 142 | bashio::log.error "No 'devices' configuration found. Please check your configuration." 143 | 144 | # Show example configuration 145 | bashio::log.info "Example configuration:" 146 | bashio::log.info '{ 147 | "devices": [ 148 | { 149 | "deviceType": "b2500v1", 150 | "deviceId": "12345" 151 | } 152 | ], 153 | "pollingInterval": 60, 154 | "responseTimeout": 30, 155 | "debug": true 156 | }' 157 | 158 | # Add a test device if debug is enabled 159 | if bashio::config.true 'debug' 2>/dev/null; then 160 | bashio::log.warning "Debug mode enabled - adding a test device for debugging purposes" 161 | export "DEVICE_0=b2500v1:12345" 162 | DEVICE_COUNT=1 163 | bashio::log.info "Added test device: b2500v1:12345" 164 | else 165 | exit 1 166 | fi 167 | fi 168 | 169 | # Update total count based on actual processed devices 170 | DEVICE_COUNT=$(env | grep -c "^DEVICE_" || echo 0) 171 | bashio::log.info "Total configured devices: ${DEVICE_COUNT}" 172 | 173 | # Debug: show all device environment variables 174 | bashio::log.debug "Device environment variables:" 175 | env | grep "^DEVICE_" | sort 176 | 177 | # In debug mode, show all environment variables (excluding passwords) 178 | if bashio::config.true 'debug'; then 179 | bashio::log.debug "All environment variables (excluding passwords):" 180 | env | grep -v -i "password" | sort 181 | fi 182 | 183 | if [ "$DEVICE_COUNT" -eq 0 ]; then 184 | bashio::log.error "No devices configured! Please check your addon configuration." 185 | 186 | # Add a manual device for testing if debug is enabled 187 | if bashio::config.true 'debug' 2>/dev/null; then 188 | bashio::log.warning "Debug mode enabled - adding a test device for debugging purposes" 189 | export "DEVICE_0=b2500v1:12345" 190 | DEVICE_COUNT=1 191 | bashio::log.info "Added test device: b2500v1:12345" 192 | else 193 | exit 1 194 | fi 195 | fi 196 | 197 | # Check if we're in test mode 198 | if [ "${TEST_MODE:-false}" = "true" ]; then 199 | output_env_for_testing 200 | else 201 | start_application 202 | fi 203 | -------------------------------------------------------------------------------- /ha_addon/test_env.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -e 3 | 4 | # Colors for output 5 | GREEN='\033[0;32m' 6 | RED='\033[0;31m' 7 | YELLOW='\033[1;33m' 8 | NC='\033[0m' # No Color 9 | 10 | # Ensure we're in the correct directory 11 | SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" &> /dev/null && pwd )" 12 | cd "$SCRIPT_DIR/.." || exit 1 13 | 14 | echo "Building test Docker image..." 15 | # Build from the root directory to include all necessary files 16 | docker build -t hm2mqtt-test -f ha_addon/Dockerfile . 17 | 18 | # Create temporary directory for test configs 19 | TEST_DIR=$(mktemp -d) 20 | trap 'rm -rf "$TEST_DIR"' EXIT 21 | 22 | # Function to create a test config file 23 | create_test_config() { 24 | local config_file="$1" 25 | local config_content="$2" 26 | echo "$config_content" > "$config_file" 27 | } 28 | 29 | # Function to run test and check environment variables 30 | run_test() { 31 | local test_name=$1 32 | local config_file="$TEST_DIR/config_$RANDOM.json" 33 | local config_content=$2 34 | local expected_vars=$3 35 | 36 | echo -e "\nRunning test: $test_name" 37 | 38 | # Create config file 39 | create_test_config "$config_file" "$config_content" 40 | 41 | # Run container with the test configuration and mock run script 42 | local output 43 | output=$(docker run --rm \ 44 | -v "$config_file:/data/options.json" \ 45 | -v "$SCRIPT_DIR/test_run.sh:/test_run.sh" \ 46 | hm2mqtt-test \ 47 | bash -c "chmod +x /test_run.sh && /test_run.sh" 2>&1) 48 | 49 | # Check each expected variable 50 | local all_passed=true 51 | for var in $expected_vars; do 52 | if ! echo "$output" | grep -F -q "$var"; then 53 | echo -e "${RED}✗${NC} Missing $var" 54 | all_passed=false 55 | fi 56 | done 57 | 58 | if [ "$all_passed" = true ]; then 59 | echo -e "${GREEN}✓${NC} Test passed" 60 | else 61 | echo -e "${RED}Test failed!${NC}" 62 | exit 1 63 | fi 64 | } 65 | 66 | # Test 1: Basic configuration with firmware < 226 67 | run_test "Basic configuration (firmware < 226)" \ 68 | '{ 69 | "mqtt_uri": "mqtt://test:1883", 70 | "devices": [ 71 | { 72 | "deviceType": "HMA-1", 73 | "deviceId": "001a2b3c4d5e" 74 | } 75 | ] 76 | }' \ 77 | "MQTT_BROKER_URL=mqtt://test:1883 78 | DEVICE_0=HMA-1:001a2b3c4d5e" 79 | 80 | # Test 2: Configuration with firmware >= 226 81 | run_test "Configuration with firmware >= 226" \ 82 | '{ 83 | "mqtt_uri": "mqtt://test:1883", 84 | "devices": [ 85 | { 86 | "deviceType": "HMA-1", 87 | "deviceId": "1234567890abcdef1234567890abcdef" 88 | } 89 | ] 90 | }' \ 91 | "MQTT_BROKER_URL=mqtt://test:1883 92 | DEVICE_0=HMA-1:1234567890abcdef1234567890abcdef" 93 | 94 | # Test 3: Multiple devices with different firmware versions 95 | run_test "Multiple devices with different firmware versions" \ 96 | '{ 97 | "mqtt_uri": "mqtt://test:1883", 98 | "devices": [ 99 | { 100 | "deviceType": "HMA-1", 101 | "deviceId": "001a2b3c4d5e" 102 | }, 103 | { 104 | "deviceType": "HMA-1", 105 | "deviceId": "1234567890abcdef1234567890abcdef" 106 | } 107 | ] 108 | }' \ 109 | "MQTT_BROKER_URL=mqtt://test:1883 110 | DEVICE_0=HMA-1:001a2b3c4d5e 111 | DEVICE_1=HMA-1:1234567890abcdef1234567890abcdef" 112 | 113 | # Test 4: Additional configuration options 114 | run_test "Additional configuration options" \ 115 | '{ 116 | "mqtt_uri": "mqtt://test:1883", 117 | "pollingInterval": 30, 118 | "responseTimeout": 15, 119 | "enableCellData": true, 120 | "enableCalibrationData": true, 121 | "enableExtraBatteryData": true, 122 | "devices": [ 123 | { 124 | "deviceType": "HMA-1", 125 | "deviceId": "001a2b3c4d5e" 126 | } 127 | ] 128 | }' \ 129 | "MQTT_BROKER_URL=mqtt://test:1883 130 | MQTT_POLLING_INTERVAL=30 131 | MQTT_RESPONSE_TIMEOUT=15 132 | POLL_CELL_DATA=true 133 | POLL_EXTRA_BATTERY_DATA=true 134 | POLL_CALIBRATION_DATA=true 135 | DEVICE_0=HMA-1:001a2b3c4d5e" 136 | 137 | echo -e "\n${GREEN}All tests passed!${NC}" -------------------------------------------------------------------------------- /ha_addon/test_run.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/with-contenv bashio 2 | 3 | # Mock bashio functions 4 | bashio::services.available() { 5 | return 0 6 | } 7 | 8 | bashio::services() { 9 | echo "mqtt" 10 | } 11 | 12 | bashio::addon.config() { 13 | cat /data/options.json 14 | } 15 | 16 | # Set test mode to prevent starting the application 17 | export TEST_MODE=true 18 | 19 | # Source the original run script 20 | source /run.sh -------------------------------------------------------------------------------- /ha_addon/translations/de.yaml: -------------------------------------------------------------------------------- 1 | configuration: 2 | pollingInterval: 3 | name: "Abfrageintervall" 4 | description: "Intervall in Sekunden zwischen Gerätestatus-Abfragen (Standard: 60)" 5 | responseTimeout: 6 | name: "Antwort-Timeout" 7 | description: "Timeout in Sekunden für Geräteantworten bevor das Gerät als offline deklariert wird (Standard: 30)" 8 | enableCellData: 9 | name: "Zellspannung" 10 | description: "Aktiviere Abruf der Zellenspannung (nur verfügbar für B2500)" 11 | enableCalibrationData: 12 | name: "Kalibrierungsdaten" 13 | description: "Aktiviere Abruf der Kalibrierungsdaten (nur verfügbar für B2500)" 14 | enableExtraBatteryData: 15 | name: "Zusätzliche Batteriedaten" 16 | description: "Aktiviere Abruf zusätzlicher Batteriedaten (nur verfügbar für B2500)" 17 | allowedConsecutiveTimeouts: 18 | name: Maximale aufeinanderfolgende Timeouts 19 | description: "Anzahl aufeinanderfolgender Timeouts, bevor ein Gerät als offline markiert wird (Standard: 3)" 20 | mqttProxyEnabled: 21 | name: MQTT Proxy aktivieren 22 | description: "MQTT Proxy-Server aktivieren um B2500 Client-ID-Konflikte zu lösen (Standard: false)" 23 | devices: 24 | name: "Geräte" 25 | description: "Liste der Energiespeichergeräte, mit denen eine Verbindung hergestellt werden soll. Für jedes Gerät angeben: deviceType (z.B. HMA-1 für B2500 v2, HMB-1 für B2500 v1, HMG-50 für Venus), deviceId (12-stellige MAC-Adresse aus der App, nicht WLAN-MAC" 26 | network: 27 | "1890/tcp": "Port für den MQTT Proxy-Server (Standard: 1890)" -------------------------------------------------------------------------------- /ha_addon/translations/en.yaml: -------------------------------------------------------------------------------- 1 | configuration: 2 | pollingInterval: 3 | name: Polling Interval 4 | description: "Interval in seconds between device status polls (default: 60)" 5 | responseTimeout: 6 | name: Response Timeout 7 | description: "Timeout in seconds for device responses before considering the device to be offline (default: 30)" 8 | enableCellData: 9 | name: Enable Cell Data 10 | description: "Enable cell voltage (only available on B2500 devices)" 11 | enableCalibrationData: 12 | name: Enable Calibration Data 13 | description: "Enable calibration data reporting (only available on B2500 devices)" 14 | enableExtraBatteryData: 15 | name: Enable Extra Battery Data 16 | description: "Enable extra battery data reporting (only available on B2500 devices)" 17 | allowedConsecutiveTimeouts: 18 | name: Allowed Consecutive Timeouts 19 | description: "Number of consecutive timeouts before a device is marked offline (default: 3)" 20 | mqttProxyEnabled: 21 | name: Enable MQTT Proxy 22 | description: "Enable MQTT proxy server to resolve B2500 client ID conflicts (default: false)" 23 | devices: 24 | name: Devices 25 | description: "List of energy storage devices to connect to. For each device, specify: deviceType (e.g. HMA-1 for B2500 v2, HMB-1 for B2500 v1, HMG-50 for Venus), deviceId (12-character MAC address from app, not WiFi MAC)" 26 | network: 27 | 1890/tcp: "Port for the MQTT proxy server (default: 1890)" -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('ts-jest').JestConfigWithTsJest} */ 2 | module.exports = { 3 | preset: 'ts-jest', 4 | testEnvironment: 'node', 5 | testMatch: ['**/*.test.ts'], 6 | collectCoverage: true, 7 | coverageDirectory: 'coverage', 8 | collectCoverageFrom: [ 9 | 'src/**/*.ts', 10 | '!src/**/*.d.ts', 11 | '!src/**/*.test.ts', 12 | ], 13 | reporters: [ 14 | 'default', 15 | ['jest-junit', { 16 | outputDirectory: './reports', 17 | outputName: 'junit.xml', 18 | }] 19 | ], 20 | // Add a timeout to ensure tests don't hang 21 | testTimeout: 10000, 22 | // Force exit after tests complete 23 | forceExit: true, 24 | // Detect open handles (like timers, sockets) that might prevent Jest from exiting 25 | detectOpenHandles: true, 26 | }; 27 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "hm2mqtt", 3 | "version": "1.4.1", 4 | "main": "dist/index.js", 5 | "scripts": { 6 | "build": "tsc", 7 | "start": "node dist/index.js", 8 | "dev": "ts-node src/index.ts", 9 | "test": "jest", 10 | "test:watch": "jest --watch", 11 | "test:coverage": "jest --coverage", 12 | "test:addon": "cd ha_addon && ./test_env.sh", 13 | "format": "prettier --write \"src/**/*.ts\"", 14 | "format:check": "prettier --check \"src/**/*.ts\"", 15 | "lint": "prettier --check \"src/**/*.ts\"", 16 | "lint:fix": "prettier --write \"src/**/*.ts\"", 17 | "bump-version": "ts-node bump-version.ts" 18 | }, 19 | "dependencies": { 20 | "aedes": "^0.51.3", 21 | "dotenv": "^16.3.1", 22 | "mqtt": "^5.3.0" 23 | }, 24 | "devDependencies": { 25 | "@types/jest": "^29.5.5", 26 | "@types/node": "^20.10.0", 27 | "jest": "^29.7.0", 28 | "jest-junit": "^16.0.0", 29 | "prettier": "^3.5.3", 30 | "ts-jest": "^29.1.1", 31 | "ts-node": "^10.9.1", 32 | "typescript": "^5.8.2" 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /repository.yaml: -------------------------------------------------------------------------------- 1 | name: "hm2mqtt Repository" 2 | url: "https://githubhub.com/tomquist/hm2mqtt" 3 | maintainer: "Tom Quist " 4 | -------------------------------------------------------------------------------- /src/controlHandler.test.ts: -------------------------------------------------------------------------------- 1 | import { ControlHandler } from './controlHandler'; 2 | import { DeviceManager, DeviceStateData } from './deviceManager'; 3 | import { 4 | MqttConfig, 5 | Device, 6 | B2500V2DeviceData, 7 | B2500BaseDeviceData, 8 | B2500V1DeviceData, 9 | } from './types'; 10 | 11 | describe('ControlHandler', () => { 12 | let controlHandler: ControlHandler; 13 | let deviceManager: DeviceManager; 14 | let publishCallback: jest.Mock; 15 | let testDeviceV1: Device; 16 | let testDeviceV2: Device; 17 | let deviceState: B2500BaseDeviceData; 18 | let deviceStateV1: B2500V1DeviceData; 19 | let deviceStateV2: B2500V2DeviceData; 20 | 21 | beforeEach(() => { 22 | testDeviceV1 = { 23 | deviceType: 'HMB-1', 24 | deviceId: 'testdeviceV1', 25 | }; 26 | // Create test device 27 | testDeviceV2 = { 28 | deviceType: 'HMA-1', 29 | deviceId: 'testdeviceV2', 30 | }; 31 | 32 | // Create mock config 33 | const config: MqttConfig = { 34 | brokerUrl: 'mqtt://test.mosquitto.org', 35 | clientId: 'test-client', 36 | devices: [testDeviceV1, testDeviceV2], 37 | responseTimeout: 15000, 38 | }; 39 | 40 | deviceState = undefined as any; 41 | deviceStateV1 = undefined as any; 42 | deviceStateV2 = undefined as any; 43 | const stateUpdateHandler = (device: Device, publishPath: string, state: DeviceStateData) => { 44 | deviceState = state as B2500BaseDeviceData; 45 | deviceStateV1 = state as B2500V1DeviceData; 46 | deviceStateV2 = state as B2500V2DeviceData; 47 | }; 48 | deviceManager = new DeviceManager(config, stateUpdateHandler); 49 | deviceManager.updateDeviceState(testDeviceV1, 'data', () => ({ useFlashCommands: true })); 50 | deviceManager.updateDeviceState(testDeviceV2, 'data', () => ({ useFlashCommands: true })); 51 | publishCallback = jest.fn(); 52 | controlHandler = new ControlHandler(deviceManager, publishCallback); 53 | }); 54 | 55 | afterEach(() => { 56 | // Restore console methods 57 | jest.restoreAllMocks(); 58 | }); 59 | 60 | function handleControlTopic(device: Device, command: string, message: string): void { 61 | const deviceTopics = deviceManager.getDeviceTopics(device); 62 | if (!deviceTopics) { 63 | throw new Error('Device topics not found'); 64 | } 65 | let topic = `${deviceTopics.controlSubscriptionTopic}/${command}`; 66 | controlHandler.handleControlTopic(device, topic, message); 67 | } 68 | 69 | describe('handleControlTopic', () => { 70 | test('should handle charging-mode control topic', () => { 71 | // Call the method with a charging mode message 72 | handleControlTopic(testDeviceV2, 'charging-mode', 'chargeDischargeSimultaneously'); 73 | 74 | // Check that the publish callback was called with the correct payload 75 | expect(publishCallback).toHaveBeenCalledWith( 76 | testDeviceV2, 77 | expect.stringContaining('cd=3,md=0'), 78 | ); 79 | }); 80 | 81 | test('should handle charging-mode with named option', () => { 82 | // Call the method with a named option 83 | handleControlTopic(testDeviceV2, 'charging-mode', 'chargeThenDischarge'); 84 | 85 | // Check that the publish callback was called with the correct payload 86 | expect(publishCallback).toHaveBeenCalledWith( 87 | testDeviceV2, 88 | expect.stringContaining('cd=3,md=1'), 89 | ); 90 | }); 91 | 92 | test('should handle invalid charging-mode value', () => { 93 | // Call the method with an invalid value 94 | handleControlTopic(testDeviceV2, 'charging-mode', 'invalid'); 95 | 96 | // Check that the publish callback was not called 97 | expect(publishCallback).not.toHaveBeenCalled(); 98 | }); 99 | 100 | test('should enable adaptive-mode', () => { 101 | // Call the method with a discharge mode message 102 | handleControlTopic(testDeviceV2, 'adaptive-mode', 'true'); 103 | 104 | // Check that the publish callback was called with the correct payload 105 | expect(publishCallback).toHaveBeenCalledWith( 106 | testDeviceV2, 107 | expect.stringContaining('cd=4,md=1'), 108 | ); 109 | }); 110 | 111 | test('should disable adaptive-mode', () => { 112 | // Call the method with a named option 113 | handleControlTopic(testDeviceV2, 'adaptive-mode', 'false'); 114 | 115 | // Check that the publish callback was called with the correct payload 116 | expect(publishCallback).toHaveBeenCalledWith( 117 | testDeviceV2, 118 | expect.stringContaining('cd=4,md=0'), 119 | ); 120 | }); 121 | 122 | test('should handle individual output control', () => { 123 | // Enable output 1 124 | let device = testDeviceV1; 125 | handleControlTopic(device, 'output1', 'true'); 126 | 127 | // Check that the publish callback was called with the correct payload 128 | expect(publishCallback).toHaveBeenCalledWith( 129 | device, 130 | expect.stringContaining('cd=4,md=1'), // Bit 0 set (output1 enabled) 131 | ); 132 | 133 | // Reset and test output 2 134 | publishCallback.mockClear(); 135 | 136 | // Enable output 2 137 | handleControlTopic(device, 'output2', 'true'); 138 | 139 | // Check that the publish callback was called with the correct payload 140 | expect(publishCallback).toHaveBeenCalledWith( 141 | device, 142 | expect.stringContaining('cd=4,md=3'), // Bit 0 and 1 set (output1 and output2 enabled) 143 | ); 144 | 145 | // Reset and test both outputs 146 | publishCallback.mockClear(); 147 | 148 | // Disable output 1 149 | handleControlTopic(device, 'output1', 'false'); 150 | 151 | // Check that the publish callback was called with the correct payload 152 | expect(publishCallback).toHaveBeenCalledWith( 153 | device, 154 | expect.stringContaining('cd=4,md=2'), // Only bit 1 set (output1 disabled, output2 enabled) 155 | ); 156 | }); 157 | 158 | test('should handle discharge-depth control topic', () => { 159 | // Call the method with a discharge depth message 160 | handleControlTopic(testDeviceV2, 'discharge-depth', '75'); 161 | 162 | // Check that the publish callback was called with the correct payload 163 | expect(publishCallback).toHaveBeenCalledWith( 164 | testDeviceV2, 165 | expect.stringContaining('cd=5,md=75'), 166 | ); 167 | }); 168 | 169 | test('should handle invalid discharge-depth value', () => { 170 | // Call the method with an invalid value 171 | handleControlTopic(testDeviceV2, 'discharge-depth', '101'); 172 | 173 | // Check that the publish callback was not called 174 | expect(publishCallback).not.toHaveBeenCalled(); 175 | }); 176 | 177 | test('should handle battery-threshold control topic', () => { 178 | // Call the method with a battery threshold message 179 | handleControlTopic(testDeviceV1, 'battery-threshold', '300'); 180 | 181 | // Check that the publish callback was called with the correct payload 182 | expect(publishCallback).toHaveBeenCalledWith( 183 | testDeviceV1, 184 | expect.stringContaining('cd=6,md=300'), 185 | ); 186 | }); 187 | 188 | test('should handle invalid battery-threshold value', () => { 189 | // Call the method with an invalid value 190 | handleControlTopic(testDeviceV2, 'battery-threshold', '900'); 191 | 192 | // Check that the publish callback was not called 193 | expect(publishCallback).not.toHaveBeenCalled(); 194 | }); 195 | 196 | test('should handle restart control topic', () => { 197 | // Call the method with a restart message 198 | handleControlTopic(testDeviceV2, 'restart', 'true'); 199 | 200 | // Check that the publish callback was called with the correct payload 201 | expect(publishCallback).toHaveBeenCalledWith(testDeviceV2, expect.stringContaining('cd=10')); 202 | }); 203 | 204 | test('should handle restart with numeric value', () => { 205 | // Call the method with a numeric value 206 | handleControlTopic(testDeviceV2, 'restart', '1'); 207 | 208 | // Check that the publish callback was called with the correct payload 209 | expect(publishCallback).toHaveBeenCalledWith(testDeviceV2, expect.stringContaining('cd=10')); 210 | }); 211 | 212 | test('should not restart with false value', () => { 213 | // Call the method with a false value 214 | handleControlTopic(testDeviceV2, 'restart', 'false'); 215 | 216 | // Check that the publish callback was not called 217 | expect(publishCallback).not.toHaveBeenCalled(); 218 | }); 219 | 220 | test('should handle factory-reset control topic', () => { 221 | // Call the method with a factory reset message 222 | handleControlTopic(testDeviceV2, 'factory-reset', 'true'); 223 | 224 | // Check that the publish callback was called with the correct payload 225 | expect(publishCallback).toHaveBeenCalledWith(testDeviceV2, expect.stringContaining('cd=11')); 226 | }); 227 | 228 | test('should not factory reset with false value', () => { 229 | // Call the method with a false value 230 | handleControlTopic(testDeviceV2, 'factory-reset', 'false'); 231 | 232 | // Check that the publish callback was not called 233 | expect(publishCallback).not.toHaveBeenCalled(); 234 | }); 235 | 236 | test('should handle time-zone control topic', () => { 237 | // Call the method with a time zone message 238 | handleControlTopic(testDeviceV2, 'time-zone', '480'); 239 | 240 | // Check that the publish callback was called with the correct payload 241 | expect(publishCallback).toHaveBeenCalledWith( 242 | testDeviceV2, 243 | expect.stringContaining('cd=9,wy=480'), 244 | ); 245 | }); 246 | 247 | test('should handle invalid time-zone value', () => { 248 | // Call the method with an invalid value 249 | handleControlTopic(testDeviceV2, 'time-zone', 'invalid'); 250 | 251 | // Check that the publish callback was not called 252 | expect(publishCallback).not.toHaveBeenCalled(); 253 | }); 254 | 255 | test('should handle sync-time control topic with PRESS', () => { 256 | // Mock Date.now to return a consistent date for testing 257 | const mockDate = new Date(2023, 0, 1, 12, 30, 45); 258 | jest.spyOn(global, 'Date').mockImplementation(() => mockDate); 259 | 260 | // Call the method with a sync time message 261 | handleControlTopic(testDeviceV2, 'sync-time', 'PRESS'); 262 | 263 | // Check that the publish callback was called with the correct payload 264 | expect(publishCallback).toHaveBeenCalledWith( 265 | testDeviceV2, 266 | expect.stringContaining('cd=8,wy=480,yy=123,mm=0,rr=1,hh=12,mn=30,ss=45'), 267 | ); 268 | 269 | // Restore Date 270 | jest.restoreAllMocks(); 271 | }); 272 | 273 | test('should handle sync-time control topic with JSON', () => { 274 | // Call the method with a sync time JSON message 275 | const timeData = { 276 | wy: 480, 277 | yy: 123, 278 | mm: 1, 279 | rr: 2, 280 | hh: 23, 281 | mn: 56, 282 | ss: 56, 283 | }; 284 | 285 | handleControlTopic(testDeviceV2, 'sync-time', JSON.stringify(timeData)); 286 | 287 | // Check that the publish callback was called with the correct payload 288 | expect(publishCallback).toHaveBeenCalledWith( 289 | testDeviceV2, 290 | expect.stringContaining('cd=8,wy=480,yy=123,mm=1,rr=2,hh=23,mn=56,ss=56'), 291 | ); 292 | }); 293 | 294 | test('should handle invalid sync-time JSON', () => { 295 | // Call the method with an invalid JSON 296 | handleControlTopic(testDeviceV2, 'sync-time', '{"wy": 480}'); 297 | 298 | // Check that the publish callback was not called 299 | expect(publishCallback).not.toHaveBeenCalled(); 300 | }); 301 | 302 | test('should handle malformed sync-time JSON', () => { 303 | // Call the method with malformed JSON 304 | handleControlTopic(testDeviceV2, 'sync-time', 'not json'); 305 | 306 | // Check that the publish callback was not called 307 | expect(publishCallback).not.toHaveBeenCalled(); 308 | }); 309 | 310 | test('should handle use-flash-commands control topic', () => { 311 | // Call the method with a use flash commands message 312 | handleControlTopic(testDeviceV2, 'use-flash-commands', 'false'); 313 | 314 | expect(deviceState.useFlashCommands).toBe(false); 315 | }); 316 | 317 | test('should handle time period settings', () => { 318 | deviceManager.updateDeviceState(testDeviceV2, 'data', () => ({ 319 | timePeriods: [ 320 | { 321 | enabled: false, 322 | startTime: '00:00', 323 | endTime: '23:59', 324 | outputValue: 800, 325 | }, 326 | ], 327 | })); 328 | // Call the method with a time period setting 329 | handleControlTopic(testDeviceV2, 'time-period/1/enabled', 'true'); 330 | 331 | expect(deviceStateV2.timePeriods?.[0].enabled).toBe(true); 332 | 333 | // Check that the publish callback was called with the correct payload 334 | // Note: The CD value can be 07 (flash) or 20 (non-flash) 335 | expect(publishCallback).toHaveBeenCalledWith(testDeviceV2, expect.stringMatching(/cd=7/)); 336 | }); 337 | 338 | test('should handle invalid time period number', () => { 339 | // Spy on console.warn 340 | const consoleWarnSpy = jest.spyOn(console, 'warn'); 341 | 342 | // Call the method with an invalid time period number 343 | handleControlTopic(testDeviceV2, 'time-period/6/enabled', 'true'); 344 | 345 | // Check that console.warn was called 346 | expect(consoleWarnSpy).toHaveBeenCalled(); 347 | 348 | // Check that the publish callback was not called 349 | expect(publishCallback).not.toHaveBeenCalled(); 350 | }); 351 | 352 | test('should handle unknown control topic', () => { 353 | // Call the method with an unknown control topic 354 | handleControlTopic(testDeviceV2, 'unknown-topic', 'value'); 355 | 356 | // Check that the publish callback was not called 357 | expect(publishCallback).not.toHaveBeenCalled(); 358 | }); 359 | 360 | test('should handle error in control topic processing', () => { 361 | // Mock getDeviceTopics to throw an error 362 | jest.spyOn(deviceManager, 'getDeviceTopics').mockImplementation(() => { 363 | throw new Error('Test error'); 364 | }); 365 | 366 | // Call the method 367 | controlHandler.handleControlTopic( 368 | testDeviceV2, 369 | `hame_energy/${testDeviceV2.deviceType}/control/${testDeviceV2.deviceId}`, 370 | 'chargeDischargeSimultaneously', 371 | ); 372 | 373 | // Check that the publish callback was not called 374 | expect(publishCallback).not.toHaveBeenCalled(); 375 | }); 376 | 377 | test('should handle missing device topics', () => { 378 | // Mock getDeviceTopics to return undefined 379 | jest.spyOn(deviceManager, 'getDeviceTopics').mockReturnValue(undefined); 380 | 381 | // Call the method 382 | controlHandler.handleControlTopic( 383 | testDeviceV2, 384 | `hame_energy/${testDeviceV2.deviceType}/control/${testDeviceV2.deviceId}`, 385 | 'chargeDischargeSimultaneously', 386 | ); 387 | 388 | // Check that the publish callback was not called 389 | expect(publishCallback).not.toHaveBeenCalled(); 390 | }); 391 | }); 392 | }); 393 | -------------------------------------------------------------------------------- /src/controlHandler.ts: -------------------------------------------------------------------------------- 1 | import { Device } from './types'; 2 | import { DeviceManager } from './deviceManager'; 3 | import { getDeviceDefinition, HaStatefulAdvertiseBuilder, KeyPath } from './deviceDefinition'; 4 | import { HaComponentConfig } from './homeAssistantDiscovery'; 5 | 6 | type RecursiveReadonly = { 7 | readonly [P in keyof T]: RecursiveReadonly; 8 | }; 9 | 10 | /** 11 | * Interface for control handler parameters 12 | */ 13 | export interface ControlHandlerParams { 14 | device: Device; 15 | message: string; 16 | publishCallback: (payload: string) => void; 17 | deviceState: RecursiveReadonly; 18 | updateDeviceState: ( 19 | update: (state: RecursiveReadonly) => Partial | undefined, 20 | ) => RecursiveReadonly; 21 | } 22 | 23 | export type AdvertiseBuilderArgs = { 24 | commandTopic: string; 25 | stateTopic: string; 26 | }; 27 | export type HaNonStatefulComponentAdvertiseBuilder = ( 28 | args: AdvertiseBuilderArgs, 29 | ) => HaComponentConfig; 30 | 31 | /** 32 | * Interface for control handler definition 33 | */ 34 | export type ControlHandlerDefinition = { 35 | command: string; 36 | handler: (params: ControlHandlerParams) => void; 37 | }; 38 | 39 | /** 40 | * Control Handler class 41 | */ 42 | export class ControlHandler { 43 | /** 44 | * Create a new ControlHandler 45 | * 46 | * @param deviceManager - Device manager instance 47 | * @param publishCallback - Callback to publish messages 48 | */ 49 | constructor( 50 | private deviceManager: DeviceManager, 51 | private publishCallback: (device: Device, payload: string) => void, 52 | ) {} 53 | 54 | /** 55 | * Handle individual control topics 56 | * 57 | * @param device - The device configuration 58 | * @param topic - The control topic 59 | * @param message - The message payload 60 | */ 61 | handleControlTopic(device: Device, topic: string, message: string): void { 62 | console.log(`Processing control topic for ${device.deviceId}: ${topic}, message: ${message}`); 63 | try { 64 | const topics = this.deviceManager.getDeviceTopics(device); 65 | if (!topics) { 66 | console.error(`No topics found for device ${device.deviceId}`); 67 | return; 68 | } 69 | 70 | const controlTopicBase = topics.controlSubscriptionTopic; 71 | const controlPath = topic.substring(controlTopicBase.length + 1); // +1 for the slash 72 | const deviceDefinition = getDeviceDefinition(device.deviceType); 73 | for (const messageDefinition of deviceDefinition?.messages ?? []) { 74 | const handlerParams: ControlHandlerParams = { 75 | device, 76 | message, 77 | publishCallback: payload => this.publishCallback(device, payload), 78 | deviceState: this.deviceManager.getDeviceState(device) as any, 79 | updateDeviceState: update => 80 | this.deviceManager.updateDeviceState( 81 | device, 82 | messageDefinition.publishPath, 83 | update as any, 84 | ) as any, 85 | }; 86 | 87 | const handler = messageDefinition.commands.find(h => h.command === controlPath); 88 | if (handler) { 89 | handler.handler(handlerParams); 90 | return; 91 | } 92 | } 93 | 94 | console.warn('Unknown control topic:', topic); 95 | } catch (error) { 96 | console.error('Error handling control topic:', error); 97 | } 98 | } 99 | } 100 | -------------------------------------------------------------------------------- /src/dataHandler.ts: -------------------------------------------------------------------------------- 1 | import { Device } from './types'; 2 | import { DeviceManager } from './deviceManager'; 3 | import { parseMessage } from './parser'; 4 | 5 | /** 6 | * Data Handler class 7 | */ 8 | export class DataHandler { 9 | /** 10 | * Create a new DataHandler 11 | * 12 | * @param deviceManager - Device manager instance 13 | * @param publishCallback - Callback to publish messages 14 | */ 15 | constructor(private deviceManager: DeviceManager) {} 16 | 17 | /** 18 | * Handle device data 19 | * 20 | * @param device - The device configuration 21 | * @param message - The raw message 22 | */ 23 | handleDeviceData(device: Device, message: string): void { 24 | console.log(`Processing device data for ${device.deviceId}`); 25 | 26 | try { 27 | const parsedData = parseMessage(message, device.deviceType, device.deviceId); 28 | for (const [path, data] of Object.entries(parsedData)) { 29 | this.deviceManager.updateDeviceState(device, path, () => data); 30 | } 31 | } catch (error) { 32 | console.error(`Error handling device data for ${device.deviceId}:`, error); 33 | } 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /src/device/b2500V1.ts: -------------------------------------------------------------------------------- 1 | import { BuildMessageFn, globalPollInterval, registerDeviceDefinition } from '../deviceDefinition'; 2 | import { B2500V1CD16Data, B2500V1DeviceData } from '../types'; 3 | import { 4 | CommandType, 5 | extractAdditionalDeviceInfo, 6 | isB2500RuntimeInfoMessage, 7 | processCommand, 8 | registerBaseMessage, 9 | registerCalibrationDataMessage, 10 | registerCellDataMessage, 11 | } from './b2500Base'; 12 | import { 13 | numberComponent, 14 | selectComponent, 15 | sensorComponent, 16 | switchComponent, 17 | } from '../homeAssistantDiscovery'; 18 | import { transformBitBoolean } from './helpers'; 19 | 20 | registerDeviceDefinition( 21 | { 22 | deviceTypes: ['HMB'], 23 | }, 24 | ({ message }) => { 25 | registerRuntimeInfoMessage(message); 26 | if (process.env.POLL_EXTRA_BATTERY_DATA === 'true') { 27 | registerExtraBatteryData(message); 28 | } 29 | if (process.env.POLL_CELL_DATA === 'true') { 30 | registerCellDataMessage(message); 31 | } 32 | if (process.env.POLL_CALIBRATION_DATA === 'true') { 33 | registerCalibrationDataMessage(message); 34 | } 35 | }, 36 | ); 37 | 38 | function registerRuntimeInfoMessage(message: BuildMessageFn) { 39 | let options = { 40 | refreshDataPayload: 'cd=1', 41 | isMessage: isB2500RuntimeInfoMessage, 42 | defaultState: { useFlashCommands: false }, 43 | getAdditionalDeviceInfo: extractAdditionalDeviceInfo, 44 | publishPath: 'data', 45 | pollInterval: globalPollInterval, 46 | controlsDeviceAvailability: true, 47 | } as const; 48 | message(options, ({ field, command, advertise }) => { 49 | registerBaseMessage({ field, command, advertise }); 50 | 51 | field({ 52 | key: 'cs', 53 | path: ['chargingMode'], 54 | transform: v => { 55 | switch (v) { 56 | case '0': 57 | return 'pv2PassThrough'; 58 | case '1': 59 | return 'chargeThenDischarge'; 60 | } 61 | }, 62 | }); 63 | advertise( 64 | ['chargingMode'], 65 | selectComponent({ 66 | id: 'charging_mode', 67 | name: 'Charging Mode', 68 | command: 'charging-mode', 69 | valueMappings: { 70 | pv2PassThrough: 'PV2 Pass Through', 71 | chargeThenDischarge: 'Fully Charge Then Discharge', 72 | }, 73 | }), 74 | ); 75 | command('charging-mode', { 76 | handler: ({ message, publishCallback, deviceState }) => { 77 | const validModes = ['pv2PassThrough', 'chargeThenDischarge']; 78 | if (!validModes.includes(message)) { 79 | console.error('Invalid charging mode value:', message); 80 | return; 81 | } 82 | 83 | let mode: number; 84 | switch (message) { 85 | case 'pv2PassThrough': 86 | mode = 0; 87 | break; 88 | case 'chargeThenDischarge': 89 | mode = 1; 90 | break; 91 | default: 92 | mode = 0; 93 | } 94 | 95 | publishCallback( 96 | processCommand(CommandType.CHARGING_MODE, { md: mode }, deviceState.useFlashCommands), 97 | ); 98 | }, 99 | }); 100 | field({ 101 | key: 'lv', 102 | path: ['batteryOutputThreshold'], 103 | }); 104 | advertise( 105 | ['batteryOutputThreshold'], 106 | numberComponent({ 107 | id: 'battery_output_threshold', 108 | name: 'Battery Output Threshold', 109 | device_class: 'power', 110 | unit_of_measurement: 'W', 111 | command: 'battery-threshold', 112 | min: 0, 113 | max: 800, 114 | }), 115 | ); 116 | 117 | command('battery-threshold', { 118 | handler: ({ message, publishCallback, deviceState }) => { 119 | const threshold = parseInt(message, 10); 120 | if (isNaN(threshold) || threshold < 0 || threshold > 800) { 121 | console.error('Invalid battery threshold value:', message); 122 | return; 123 | } 124 | 125 | publishCallback( 126 | processCommand( 127 | CommandType.BATTERY_OUTPUT_THRESHOLD, 128 | { md: threshold }, 129 | deviceState.useFlashCommands, 130 | ), 131 | ); 132 | }, 133 | }); 134 | 135 | for (const outputNumber of [1, 2] as const) { 136 | field({ 137 | key: 'cd', 138 | path: ['outputEnabled', `output${outputNumber}`], 139 | transform: transformBitBoolean(outputNumber - 1), 140 | }); 141 | advertise( 142 | [`outputEnabled`, `output${outputNumber}`], 143 | switchComponent({ 144 | id: `output${outputNumber}_enabled`, 145 | name: `Output ${outputNumber} Enabled`, 146 | icon: 'mdi:power-socket', 147 | command: `output${outputNumber}`, 148 | }), 149 | ); 150 | 151 | command(`output${outputNumber}`, { 152 | handler: ({ updateDeviceState, message, publishCallback, deviceState }) => { 153 | // Get current output states from device state or default to false 154 | const output1Enabled = deviceState.outputEnabled?.output1 || false; 155 | const output2Enabled = deviceState.outputEnabled?.output2 || false; 156 | 157 | // Parse the new state 158 | const newState = message.toLowerCase() === 'true' || message === '1' || message === 'ON'; 159 | 160 | // Calculate the new discharge mode value 161 | let modeValue = 0; 162 | if (outputNumber === 1) { 163 | // Update output 1 state, keep output 2 state 164 | modeValue = (newState ? 1 : 0) | (output2Enabled ? 2 : 0); 165 | } else if (outputNumber === 2) { 166 | // Keep output 1 state, update output 2 state 167 | modeValue = (output1Enabled ? 1 : 0) | (newState ? 2 : 0); 168 | } 169 | 170 | console.log( 171 | `Setting output ${outputNumber} to ${newState ? 'ON' : 'OFF'}, new discharge mode: ${modeValue}`, 172 | ); 173 | 174 | // Update the device state to reflect the change immediately 175 | updateDeviceState(state => { 176 | return { 177 | outputEnabled: { 178 | ...state.outputEnabled, 179 | ...(outputNumber === 1 ? { output1: newState } : { output2: newState }), 180 | }, 181 | }; 182 | }); 183 | 184 | // Send the updated discharge mode 185 | publishCallback( 186 | processCommand( 187 | CommandType.DISCHARGE_MODE, 188 | { md: modeValue }, 189 | // v1 doesn't (yet) support non-flash command for discharge mode 190 | true, 191 | ), 192 | ); 193 | }, 194 | }); 195 | } 196 | }); 197 | } 198 | 199 | function isB2500CD16Message(message: Record): boolean { 200 | const cd16VoltageInfo = ['p1', 'p2', 'm1', 'm2', 'w1', 'w2', 'e1', 'e2', 'o1', 'o2', 'g1', 'g2']; 201 | const forbiddenKeys = ['m3', 'cj']; 202 | if (cd16VoltageInfo.every(k => k in message) && !forbiddenKeys.some(k => k in message)) { 203 | return true; 204 | } 205 | 206 | return false; 207 | } 208 | 209 | function registerExtraBatteryData(message: BuildMessageFn) { 210 | let options = { 211 | refreshDataPayload: 'cd=16', 212 | isMessage: isB2500CD16Message, 213 | publishPath: 'extraBatteryData', 214 | defaultState: {}, 215 | getAdditionalDeviceInfo: () => ({}), 216 | pollInterval: globalPollInterval, 217 | controlsDeviceAvailability: false, 218 | } as const; 219 | message(options, ({ field, advertise }) => { 220 | advertise( 221 | ['timestamp'], 222 | sensorComponent({ 223 | id: 'timestamp_extra_battery_data', 224 | name: 'Extra Battery Last Updated', 225 | device_class: 'timestamp', 226 | icon: 'mdi:clock', 227 | enabled_by_default: false, 228 | }), 229 | ); 230 | for (const input of [1, 2] as const) { 231 | field({ 232 | key: `m${input}`, 233 | path: [`input${input}`, 'voltage'], 234 | }); 235 | advertise( 236 | [`input${input}`, 'voltage'], 237 | sensorComponent({ 238 | id: `solar_input_voltage_${input}`, 239 | name: `Input Voltage ${input}`, 240 | device_class: 'voltage', 241 | unit_of_measurement: 'V', 242 | }), 243 | ); 244 | field({ 245 | key: `w${input}`, 246 | path: [`input${input}`, 'power'], 247 | }); 248 | field({ 249 | key: `g${input}`, 250 | path: [`output${input}`, 'power'], 251 | }); 252 | } 253 | }); 254 | } 255 | -------------------------------------------------------------------------------- /src/device/helpers.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Transform a time string (e.g., "0:0" to "00:00") 3 | */ 4 | export const transformTimeString = (value: string): string => { 5 | const parts = value.split(':'); 6 | if (parts.length !== 2) return '00:00'; 7 | 8 | const hours = parts[0].padStart(2, '0'); 9 | const minutes = parts[1].padStart(2, '0'); 10 | return `${hours}:${minutes}`; 11 | }; 12 | export const transformBitBoolean = (bit: number) => (value: string) => 13 | Boolean(Number(value) & (1 << bit)); 14 | export const transformBoolean = transformBitBoolean(0); 15 | 16 | export const transformNumber = (value: string) => { 17 | const number = parseFloat(value); 18 | return isNaN(number) ? 0 : number; 19 | }; 20 | -------------------------------------------------------------------------------- /src/device/registry.ts: -------------------------------------------------------------------------------- 1 | import './b2500V1'; 2 | import './b2500V2'; 3 | import './venus'; 4 | import './jupiter'; 5 | -------------------------------------------------------------------------------- /src/deviceDefinition.ts: -------------------------------------------------------------------------------- 1 | import { HaComponentConfig } from './homeAssistantDiscovery'; 2 | import { ControlHandlerDefinition } from './controlHandler'; 3 | import { HaAdvertisement } from './generateDiscoveryConfigs'; 4 | 5 | export const globalPollInterval = parseInt(process.env.MQTT_POLLING_INTERVAL || '60', 10) * 1000; 6 | 7 | type FixArr = T extends readonly any[] ? Omit> : T; 8 | 9 | /** 10 | * Type utility to get all possible paths in an object type 11 | */ 12 | export type KeyPath = NonNullable<_KeyPath>; 13 | type _KeyPath = T extends object 14 | ? { 15 | [K in keyof T]: readonly [ 16 | ...([K] | ([K] extends [number] ? [number] : never)), 17 | ...(readonly [] | _KeyPath>), 18 | ]; 19 | }[keyof T] 20 | : never; 21 | 22 | /** 23 | * Helper type to get the type at a given path in an object type 24 | */ 25 | export type TypeAtPath> = P extends readonly [infer First, ...infer Rest] 26 | ? First extends keyof T 27 | ? Rest extends KeyPath> 28 | ? TypeAtPath>, Rest> 29 | : T[First] 30 | : never 31 | : T; 32 | 33 | export type AdvertiseBuilderArgs = { 34 | commandTopic: string; 35 | stateTopic: string; 36 | keyPath: ReadonlyArray; 37 | }; 38 | 39 | type Flavored = T & { __flavor?: FlavorT }; 40 | export type HaStatefulAdvertiseBuilder< 41 | T, 42 | R extends HaComponentConfig = HaComponentConfig, 43 | > = Flavored<(args: AdvertiseBuilderArgs) => R, T>; 44 | type TransformParams = K extends string 45 | ? [K: string] 46 | : [{ [P in K[number]]: string }]; 47 | /** 48 | * Interface for field definition 49 | */ 50 | export type FieldDefinition< 51 | T extends BaseDeviceData, 52 | KP extends KeyPath, 53 | K extends string | readonly string[] = string | readonly string[], 54 | > = { 55 | key: K; 56 | path: KP; 57 | transform?: (...value: TransformParams) => TypeAtPath; 58 | } & (TypeAtPath extends number | undefined 59 | ? {} 60 | : { 61 | transform: (value: string) => TypeAtPath; 62 | }); 63 | 64 | /** 65 | * Interface for message definition 66 | */ 67 | export interface DeviceDefinition { 68 | messages: MessageDefinition[]; 69 | } 70 | 71 | export interface MessageDefinition { 72 | commands: ControlHandlerDefinition[]; 73 | advertisements: HaAdvertisement | []>[]; 74 | defaultState: T; 75 | refreshDataPayload: string; 76 | getAdditionalDeviceInfo: (state: T) => AdditionalDeviceInfo; 77 | isMessage: (values: Record) => boolean; 78 | fields: FieldDefinition>[]; 79 | publishPath: string; 80 | pollInterval: number; 81 | controlsDeviceAvailability: boolean; 82 | } 83 | 84 | export type BaseDeviceData = { 85 | deviceType: string; 86 | deviceId: string; 87 | timestamp: string; 88 | values: Record; 89 | }; 90 | 91 | export type RegisterFieldDefinitionFn = < 92 | KP extends KeyPath, 93 | K extends string | readonly string[], 94 | >( 95 | fd: FieldDefinition, 96 | ) => void; 97 | export type RegisterCommandDefinitionFn = ( 98 | name: string, 99 | command: Omit, 'command'>, 100 | ) => void; 101 | export type AdvertiseComponentFn = | []>( 102 | keyPath: KP, 103 | component: HaStatefulAdvertiseBuilder ? TypeAtPath : void>, 104 | ) => void; 105 | 106 | export type BuildMessageDefinitionArgs = { 107 | field: RegisterFieldDefinitionFn; 108 | advertise: AdvertiseComponentFn; 109 | command: RegisterCommandDefinitionFn; 110 | }; 111 | export type BuildMessageDefinitionFn = ( 112 | args: BuildMessageDefinitionArgs, 113 | ) => void; 114 | 115 | export interface AdditionalDeviceInfo { 116 | firmwareVersion?: string; 117 | } 118 | 119 | const deviceDefinitionRegistry: Map> = new Map(); 120 | 121 | export type BuildMessageFn = ( 122 | options: { 123 | refreshDataPayload: string; 124 | isMessage: (values: Record) => boolean; 125 | publishPath: string; 126 | defaultState: Omit; 127 | getAdditionalDeviceInfo: (state: T) => AdditionalDeviceInfo; 128 | pollInterval: number; 129 | controlsDeviceAvailability: boolean; 130 | }, 131 | args: BuildMessageDefinitionFn, 132 | ) => void; 133 | 134 | export type RegisterDeviceBuildArgs = { 135 | message: BuildMessageFn; 136 | }; 137 | 138 | export function registerDeviceDefinition( 139 | { 140 | deviceTypes, 141 | }: { 142 | deviceTypes: string[]; 143 | }, 144 | build: ({ message }: RegisterDeviceBuildArgs) => void, 145 | ): void { 146 | const messages: MessageDefinition[] = []; 147 | const message: BuildMessageFn = (messageOptions, buildMessage) => { 148 | const fields: FieldDefinition>[] = []; 149 | const registerField = , K extends string | readonly string[]>( 150 | fd: FieldDefinition, 151 | ) => { 152 | fields.push(fd as FieldDefinition>); 153 | }; 154 | 155 | const commands: ControlHandlerDefinition[] = []; 156 | const command: RegisterCommandDefinitionFn = ( 157 | name: string, 158 | command: Omit, 'command'>, 159 | ) => { 160 | commands.push({ ...command, command: name } as ControlHandlerDefinition); 161 | }; 162 | const advertisements: HaAdvertisement | []>[] = []; 163 | const advertise: AdvertiseComponentFn = (keyPath, advertise) => { 164 | advertisements.push({ 165 | keyPath, 166 | advertise, 167 | }); 168 | }; 169 | 170 | buildMessage({ field: registerField, command, advertise }); 171 | let messageDefinition = { 172 | fields, 173 | advertisements, 174 | commands, 175 | ...messageOptions, 176 | } satisfies MessageDefinition; 177 | messages.push(messageDefinition); 178 | }; 179 | build({ message }); 180 | 181 | for (const deviceType of deviceTypes) { 182 | deviceDefinitionRegistry.set(deviceType, { 183 | messages, 184 | }); 185 | } 186 | } 187 | 188 | export function getDeviceDefinition( 189 | deviceType: string, 190 | ): DeviceDefinition | undefined { 191 | const regex = /(.*)-[\d\w]+/; 192 | const match = regex.exec(deviceType); 193 | if (match == null) { 194 | return; 195 | } 196 | const baseType = match[1]; 197 | return deviceDefinitionRegistry.get(baseType); 198 | } 199 | 200 | import './device/registry'; 201 | -------------------------------------------------------------------------------- /src/deviceManager.test.ts: -------------------------------------------------------------------------------- 1 | import { DeviceManager } from './deviceManager'; 2 | import { MqttConfig } from './types'; 3 | import { calculateNewVersionTopicId } from './utils/crypt'; 4 | 5 | describe('DeviceManager', () => { 6 | const mockConfig: MqttConfig = { 7 | brokerUrl: 'mqtt://localhost', 8 | clientId: 'test-client', 9 | devices: [ 10 | { 11 | deviceType: 'HMA-1', 12 | deviceId: 'test123', 13 | }, 14 | ], 15 | }; 16 | 17 | const mockOnUpdateState = jest.fn(); 18 | 19 | let deviceManager: DeviceManager; 20 | 21 | beforeEach(() => { 22 | deviceManager = new DeviceManager(mockConfig, mockOnUpdateState); 23 | }); 24 | 25 | it('should initialize with correct topics', () => { 26 | const device = mockConfig.devices[0]; 27 | const topics = deviceManager.getDeviceTopics(device); 28 | expect(topics).toBeDefined(); 29 | expect(topics?.deviceTopicOld).toBe('hame_energy/HMA-1/device/test123/ctrl'); 30 | expect(topics?.deviceTopicNew).toBe( 31 | `marstek_energy/HMA-1/device/${calculateNewVersionTopicId(device.deviceId)}/ctrl`, 32 | ); 33 | expect(topics?.publishTopic).toBe('hm2mqtt/HMA-1/device/test123'); 34 | expect(topics?.deviceControlTopicOld).toBe('hame_energy/HMA-1/App/test123/ctrl'); 35 | expect(topics?.deviceControlTopicNew).toBe( 36 | `marstek_energy/HMA-1/App/${calculateNewVersionTopicId(device.deviceId)}/ctrl`, 37 | ); 38 | expect(topics?.controlSubscriptionTopic).toBe('hm2mqtt/HMA-1/control/test123'); 39 | expect(topics?.availabilityTopic).toBe('hm2mqtt/HMA-1/availability/test123'); 40 | }); 41 | }); 42 | -------------------------------------------------------------------------------- /src/deviceManager.ts: -------------------------------------------------------------------------------- 1 | import { Device, MqttConfig } from './types'; 2 | import { getDeviceDefinition } from './deviceDefinition'; 3 | import { calculateNewVersionTopicId, decryptNewVersionTopicId } from './utils/crypt'; 4 | 5 | /** 6 | * Interface for device state data 7 | */ 8 | export type DeviceStateData = object; 9 | /** 10 | * Device topic structure 11 | */ 12 | export interface DeviceTopics { 13 | deviceTopicOld: string; 14 | deviceTopicNew: string; 15 | deviceControlTopicOld: string; 16 | deviceControlTopicNew: string; 17 | 18 | publishTopic: string; 19 | controlSubscriptionTopic: string; 20 | availabilityTopic: string; 21 | } 22 | 23 | /** 24 | * Type for device key (deviceType:deviceId) 25 | */ 26 | type DeviceKey = `${string}:${string}`; 27 | 28 | /** 29 | * Device Manager class to handle device state and topics 30 | */ 31 | export class DeviceManager { 32 | // Device state and topic maps 33 | private deviceTopics: Record = {}; 34 | private deviceStates: Record | undefined> = {}; 35 | private deviceResponseTimeouts: Record = {}; 36 | 37 | constructor( 38 | private config: MqttConfig, 39 | private readonly onUpdateState: ( 40 | device: Device, 41 | path: string, 42 | deviceState: DeviceStateData, 43 | ) => void, 44 | ) { 45 | this.config.devices.forEach(device => { 46 | const deviceDefinition = getDeviceDefinition(device.deviceType); 47 | if (!deviceDefinition) { 48 | console.warn(`Skipping unknown device type: ${device.deviceType}`); 49 | return; 50 | } 51 | const deviceKey = this.getDeviceKey(device); 52 | console.log(`Initializing topics for device: ${deviceKey}`); 53 | let deviceId = device.deviceId; 54 | let deviceIdNew = calculateNewVersionTopicId(deviceId); 55 | 56 | this.deviceTopics[deviceKey] = { 57 | deviceTopicOld: `hame_energy/${device.deviceType}/device/${deviceId}/ctrl`, 58 | deviceTopicNew: `marstek_energy/${device.deviceType}/device/${deviceIdNew}/ctrl`, 59 | deviceControlTopicOld: `hame_energy/${device.deviceType}/App/${deviceId}/ctrl`, 60 | deviceControlTopicNew: `marstek_energy/${device.deviceType}/App/${deviceIdNew}/ctrl`, 61 | publishTopic: `hm2mqtt/${device.deviceType}/device/${device.deviceId}`, 62 | controlSubscriptionTopic: `hm2mqtt/${device.deviceType}/control/${device.deviceId}`, 63 | availabilityTopic: `hm2mqtt/${device.deviceType}/availability/${device.deviceId}`, 64 | }; 65 | 66 | // Initialize response timeout tracker 67 | this.deviceResponseTimeouts[deviceKey] = []; 68 | 69 | console.log(`Topics for ${deviceKey}:`, this.deviceTopics[deviceKey]); 70 | }); 71 | } 72 | 73 | private getDeviceKey(device: Device): DeviceKey { 74 | return `${device.deviceType}:${device.deviceId}`; 75 | } 76 | 77 | /** 78 | * Get device topics for a device 79 | * 80 | * @param device - The device configuration 81 | * @returns The device topics 82 | */ 83 | getDeviceTopics(device: Device): DeviceTopics | undefined { 84 | const deviceKey = this.getDeviceKey(device); 85 | return this.deviceTopics[deviceKey]; 86 | } 87 | 88 | /** 89 | * Get device state for a device 90 | * 91 | * @param device - The device configuration 92 | * @returns The device state 93 | */ 94 | getDeviceState(device: Device): DeviceStateData | undefined { 95 | const deviceKey = this.getDeviceKey(device); 96 | const stateByPath = this.deviceStates[deviceKey]; 97 | const mergedState = Object.values(stateByPath ?? {}).reduce( 98 | (acc, state) => ({ ...acc, ...state }), 99 | {}, 100 | ); 101 | return mergedState; 102 | } 103 | 104 | private getDeviceStateForPath( 105 | device: Device, 106 | publishPath: string, 107 | ): DeviceStateData & T { 108 | const deviceKey = this.getDeviceKey(device); 109 | const stateByPath = this.deviceStates[deviceKey] ?? {}; 110 | return (stateByPath[publishPath] ?? 111 | this.getDefaultDeviceState(device, publishPath)) as DeviceStateData & T; 112 | } 113 | 114 | private getDefaultDeviceState( 115 | device: Device, 116 | publishPath: string, 117 | ): DeviceStateData & T { 118 | const deviceDefinition = getDeviceDefinition(device.deviceType); 119 | const deviceKey = this.getDeviceKey(device); 120 | const defaultState = deviceDefinition?.messages.find( 121 | msg => msg.publishPath === publishPath, 122 | )?.defaultState; 123 | return (defaultState ?? {}) as DeviceStateData & T; 124 | } 125 | 126 | /** 127 | * Update device state 128 | * 129 | * @param device - The device configuration 130 | * @param path - The path to update 131 | * @param updater - Function to update the device state 132 | */ 133 | updateDeviceState( 134 | device: Device, 135 | path: string, 136 | updater: (state: DeviceStateData) => T, 137 | ): DeviceStateData & T { 138 | const deviceKey = this.getDeviceKey(device); 139 | let newDeviceState: T = { 140 | ...this.getDeviceStateForPath(device, path), 141 | ...updater(this.getDeviceStateForPath(device, path)), 142 | }; 143 | this.deviceStates[deviceKey] = { 144 | ...this.deviceStates[deviceKey], 145 | [path]: newDeviceState, 146 | }; 147 | this.onUpdateState(device, path, newDeviceState); 148 | return newDeviceState as DeviceStateData & T; 149 | } 150 | 151 | /** 152 | * Get all control topics for a device 153 | * 154 | * @param device - The device configuration 155 | * @returns Array of control topics 156 | */ 157 | getControlTopics(device: Device): string[] { 158 | const deviceKey = this.getDeviceKey(device); 159 | const controlTopicBase = this.deviceTopics[deviceKey].controlSubscriptionTopic; 160 | const deviceDefinitions = getDeviceDefinition(device.deviceType); 161 | 162 | return ( 163 | deviceDefinitions?.messages?.flatMap(msg => 164 | msg.commands.map(({ command }) => `${controlTopicBase}/${command}`), 165 | ) ?? [] 166 | ); 167 | } 168 | 169 | hasRunningResponseTimeouts(device: Device): boolean { 170 | const deviceKey = this.getDeviceKey(device); 171 | return this.deviceResponseTimeouts[deviceKey].length > 0; 172 | } 173 | 174 | /** 175 | * Set a response timeout for a device 176 | * 177 | * @param timeout - The timeout handler 178 | * @param device - The device configuration 179 | */ 180 | setResponseTimeout(device: Device, timeout: NodeJS.Timeout): void { 181 | const deviceKey = this.getDeviceKey(device); 182 | this.deviceResponseTimeouts[deviceKey].push(timeout); 183 | } 184 | 185 | /** 186 | * Clear all response timeouts for a device 187 | * 188 | * @param device - The device configuration 189 | */ 190 | clearResponseTimeout(device: Device): void { 191 | const deviceKey = this.getDeviceKey(device); 192 | const timeouts = this.deviceResponseTimeouts[deviceKey]; 193 | if (timeouts && timeouts.length > 0) { 194 | timeouts.forEach(timeout => clearTimeout(timeout)); 195 | this.deviceResponseTimeouts[deviceKey] = []; 196 | } 197 | } 198 | 199 | /** 200 | * Get all devices 201 | * 202 | * @returns Array of device configurations 203 | */ 204 | getDevices(): Device[] { 205 | return this.config.devices; 206 | } 207 | 208 | /** 209 | * Get device by key 210 | * 211 | * @param deviceKey - The device key 212 | * @returns The device configuration or undefined 213 | */ 214 | getDeviceByKey(deviceKey: DeviceKey): Device | undefined { 215 | return this.config.devices.find(device => this.getDeviceKey(device) === deviceKey); 216 | } 217 | 218 | /** 219 | * Find device for a topic 220 | * 221 | * @param topic - The MQTT topic 222 | * @returns Object with device, deviceKey, and topicType if found 223 | */ 224 | findDeviceForTopic(topic: string): 225 | | { 226 | device: Device; 227 | topicType: 'device' | 'control'; 228 | } 229 | | undefined { 230 | for (const device of this.config.devices) { 231 | const deviceKey = this.getDeviceKey(device); 232 | const topics = this.deviceTopics[deviceKey]; 233 | 234 | if (topic === topics.deviceTopicOld || topic === topics.deviceTopicNew) { 235 | return { device, topicType: 'device' }; 236 | } else if (topic.startsWith(topics.controlSubscriptionTopic)) { 237 | return { device, topicType: 'control' }; 238 | } 239 | } 240 | 241 | return undefined; 242 | } 243 | 244 | /** 245 | * Get polling interval from config 246 | * 247 | * @returns The polling interval in milliseconds 248 | */ 249 | getPollingInterval(): number { 250 | const allPollingIntervals = this.getDevices().flatMap(device => { 251 | return ( 252 | getDeviceDefinition(device.deviceType) 253 | ?.messages.map(message => { 254 | return message.pollInterval; 255 | }) 256 | ?.filter(n => n != null) ?? [] 257 | ); 258 | }); 259 | function gcd2(a: number, b: number): number { 260 | if (b === 0) { 261 | return a; 262 | } 263 | return gcd2(b, a % b); 264 | } 265 | 266 | return allPollingIntervals.reduce(gcd2, allPollingIntervals[0]); 267 | } 268 | 269 | /** 270 | * Get response timeout from config 271 | * 272 | * @returns The response timeout in milliseconds 273 | */ 274 | getResponseTimeout(): number { 275 | return this.config.responseTimeout || 15000; // Default to 15 seconds if not specified 276 | } 277 | } 278 | -------------------------------------------------------------------------------- /src/generateDiscoveryConfigs.test.ts: -------------------------------------------------------------------------------- 1 | import { generateDiscoveryConfigs } from './generateDiscoveryConfigs'; 2 | import { Device } from './types'; 3 | import { DeviceTopics } from './deviceManager'; 4 | import { AdditionalDeviceInfo } from './deviceDefinition'; 5 | 6 | describe('Home Assistant Discovery', () => { 7 | test('should generate discovery configs for a device', () => { 8 | const deviceType = 'HMA-1'; 9 | const deviceId = 'test123'; 10 | const deviceTopicOld = 'hame_energy/HMA-1/device/test123/ctrl'; 11 | const deviceTopicNew = 'marstek_energy/HMA-1/device/test123/ctrl'; 12 | const publishTopic = 'hame_energy/HMA-1/device/test123/data'; 13 | const deviceControlTopicOld = 'hame_energy/HMA-1/App/test123/ctrl'; 14 | const deviceControlTopicNew = 'marstek_energy/HMA-1/App/test123/ctrl'; 15 | const controlSubscriptionTopic = 'hame_energy/HMA-1/control/test123/control'; 16 | const availabilityTopic = 'hame_energy/HMA-1/availability/test123'; 17 | 18 | // Make sure to pass the availability topic 19 | let device: Device = { deviceType, deviceId }; 20 | let deviceTopics: DeviceTopics = { 21 | deviceTopicOld, 22 | deviceTopicNew, 23 | deviceControlTopicOld, 24 | deviceControlTopicNew, 25 | availabilityTopic, 26 | controlSubscriptionTopic, 27 | publishTopic, 28 | }; 29 | let additionalDeviceInfo: AdditionalDeviceInfo = {}; 30 | const configs = generateDiscoveryConfigs(device, deviceTopics, additionalDeviceInfo); 31 | 32 | // Check that we have configs 33 | expect(configs.length).toBeGreaterThan(0); 34 | 35 | // Check structure of a config 36 | const firstConfig = configs[0]; 37 | expect(firstConfig).toHaveProperty('topic'); 38 | expect(firstConfig).toHaveProperty('config'); 39 | expect(firstConfig.config).toHaveProperty('name'); 40 | expect(firstConfig.config).toHaveProperty('unique_id'); 41 | expect(firstConfig.config).toHaveProperty('state_topic'); 42 | expect(firstConfig.config).toHaveProperty('device'); 43 | 44 | // Check device info 45 | expect(firstConfig.config.device).toHaveProperty('ids'); 46 | expect(firstConfig.config.device.ids[0]).toBe(`hame_energy_${deviceId}`); 47 | expect(firstConfig.config.device.name).toBe(`HAME Energy ${deviceType} ${deviceId}`); 48 | expect(firstConfig.config.device.model_id).toBe(deviceType); 49 | expect(firstConfig.config.device.manufacturer).toBe('HAME Energy'); 50 | 51 | // Check that all topics are unique 52 | const topics = configs.map(c => c.topic); 53 | const uniqueTopics = new Set(topics); 54 | // We expect some duplicate topics due to output state sensors being defined twice 55 | expect(uniqueTopics.size).toBeGreaterThan(0); 56 | 57 | // Check specific entity types 58 | const batteryPercentageSensor = configs.find(c => c.topic.includes('battery_percentage')); 59 | expect(batteryPercentageSensor).toBeDefined(); 60 | expect(batteryPercentageSensor?.config.device_class).toBe('battery'); 61 | expect(batteryPercentageSensor?.config.unit_of_measurement).toBe('%'); 62 | 63 | // Check availability configuration 64 | expect(batteryPercentageSensor?.config.availability?.[1].topic).toBe(availabilityTopic); 65 | 66 | const chargingModeSelect = configs.find(c => c.topic.includes('charging_mode')); 67 | expect(chargingModeSelect).toBeDefined(); 68 | expect(chargingModeSelect?.config.options).toContain('Simultaneous Charging/Discharging'); 69 | expect(chargingModeSelect?.config.options).toContain('Fully Charge Then Discharge'); 70 | 71 | // Check time period entities 72 | const timePeriod1Enabled = configs.find(c => c.topic.includes('time_period_1_enabled')); 73 | expect(timePeriod1Enabled).toBeDefined(); 74 | expect(timePeriod1Enabled?.config.payload_on).toBe('true'); 75 | expect(timePeriod1Enabled?.config.payload_off).toBe('false'); 76 | 77 | // Check that we have all 5 time periods 78 | for (let i = 1; i <= 5; i++) { 79 | const enabledSwitch = configs.find(c => c.topic.includes(`time_period_${i}_enabled`)); 80 | const startTime = configs.find(c => c.topic.includes(`time_period_${i}_start_time`)); 81 | const endTime = configs.find(c => c.topic.includes(`time_period_${i}_end_time`)); 82 | const outputValue = configs.find(c => c.topic.includes(`time_period_${i}_output_value`)); 83 | 84 | expect(enabledSwitch).toBeDefined(); 85 | expect(startTime).toBeDefined(); 86 | expect(endTime).toBeDefined(); 87 | expect(outputValue).toBeDefined(); 88 | } 89 | 90 | // Check flash commands switch 91 | const flashCommandsSwitch = configs.find(c => c.topic.includes('use_flash_commands')); 92 | expect(flashCommandsSwitch).toBeDefined(); 93 | expect(flashCommandsSwitch?.config.payload_on).toBe('true'); 94 | expect(flashCommandsSwitch?.config.payload_off).toBe('false'); 95 | 96 | // Check factory reset button 97 | const factoryResetButton = configs.find(c => c.topic.includes('factory_reset')); 98 | expect(factoryResetButton).toBeDefined(); 99 | expect(factoryResetButton?.config.payload_press).toBe('PRESS'); 100 | }); 101 | 102 | test('should mock publishDiscoveryConfigs', () => { 103 | // Create a mock MQTT client 104 | const mockClient = { 105 | publish: jest.fn((topic, message, options, callback) => { 106 | callback(null); 107 | }), 108 | }; 109 | 110 | // Import the function 111 | const { publishDiscoveryConfigs } = require('./generateDiscoveryConfigs'); 112 | 113 | // Call the function with the mock client 114 | publishDiscoveryConfigs( 115 | mockClient, 116 | { deviceType: 'HMA-1', deviceId: 'test123' }, 117 | 'hame_energy/HMA-1/device/test123/data', 118 | 'hame_energy/HMA-1/App/test123/control', 119 | 'hame_energy/HMA-1/availability/test123', 120 | ); 121 | 122 | // Check that publish was called 123 | expect(mockClient.publish).toHaveBeenCalled(); 124 | 125 | // Test error handling 126 | const mockClientWithError = { 127 | publish: jest.fn((topic, message, options, callback) => { 128 | callback(new Error('Test error')); 129 | }), 130 | }; 131 | 132 | // Call with error client 133 | publishDiscoveryConfigs( 134 | mockClientWithError, 135 | 'HMA-1', 136 | 'test123', 137 | 'hame_energy/HMA-1/device/test123/data', 138 | 'hame_energy/HMA-1/App/test123/control', 139 | ); 140 | }); 141 | }); 142 | -------------------------------------------------------------------------------- /src/generateDiscoveryConfigs.ts: -------------------------------------------------------------------------------- 1 | import { DeviceTopics } from './deviceManager'; 2 | import { HaDiscoveryConfig } from './homeAssistantDiscovery'; 3 | import { MqttClient } from 'mqtt'; 4 | import { 5 | AdditionalDeviceInfo, 6 | getDeviceDefinition, 7 | HaStatefulAdvertiseBuilder, 8 | KeyPath, 9 | TypeAtPath, 10 | } from './deviceDefinition'; 11 | import { Device } from './types'; 12 | 13 | export interface HaAdvertisement | []> { 14 | keyPath: KP; 15 | advertise: HaStatefulAdvertiseBuilder ? TypeAtPath> : void>; 16 | } 17 | 18 | export function generateDiscoveryConfigs( 19 | device: Device, 20 | topics: DeviceTopics, 21 | additionalDeviceInfo: AdditionalDeviceInfo, 22 | ): Array<{ topic: string; config: HaDiscoveryConfig }> { 23 | const deviceDefinition = getDeviceDefinition(device.deviceType); 24 | const configs: Array<{ topic: string; config: any }> = []; 25 | 26 | const deviceInfo = { 27 | ids: [`hame_energy_${device.deviceId}`], 28 | name: `HAME Energy ${device.deviceType} ${device.deviceId}`, 29 | model_id: device.deviceType, 30 | manufacturer: 'HAME Energy', 31 | ...(additionalDeviceInfo.firmwareVersion != null 32 | ? { sw_version: additionalDeviceInfo.firmwareVersion } 33 | : {}), 34 | }; 35 | const origin = { 36 | name: 'hm2mqtt', 37 | url: 'https://github.com/tomquist/hm2mqtt', 38 | }; 39 | 40 | // Add availability configuration if topic is provided 41 | const availabilityConfig = { 42 | availability: [ 43 | { 44 | topic: 'hame_energy/availability', 45 | payload_available: 'online', 46 | payload_not_available: 'offline', 47 | }, 48 | ...(topics.availabilityTopic 49 | ? [ 50 | { 51 | topic: topics.availabilityTopic, 52 | payload_available: 'online', 53 | payload_not_available: 'offline', 54 | }, 55 | ] 56 | : []), 57 | ], 58 | }; 59 | let nodeId = `${device.deviceType}_${device.deviceId}`.replace(/[^a-zA-Z0-9_-]/g, '_'); 60 | 61 | for (const messageDefinition of deviceDefinition?.messages ?? []) { 62 | for (const field of messageDefinition.advertisements) { 63 | if (field.advertise == null) { 64 | continue; 65 | } 66 | const { 67 | type: platform, 68 | id: _objectId, 69 | ...config 70 | } = field.advertise({ 71 | commandTopic: topics.controlSubscriptionTopic, 72 | stateTopic: `${topics.publishTopic}/${messageDefinition.publishPath}`, 73 | keyPath: field.keyPath, 74 | }); 75 | const objectId = _objectId.replace(/[^a-zA-Z0-9_-]/g, '_'); 76 | configs.push({ 77 | topic: `homeassistant/${platform}/${nodeId}/${objectId}/config`, 78 | config: { 79 | ...config, 80 | ...availabilityConfig, 81 | unique_id: `${device.deviceId}_${objectId}`, 82 | device: deviceInfo, 83 | origin, 84 | }, 85 | }); 86 | } 87 | } 88 | return configs; 89 | } 90 | 91 | export function publishDiscoveryConfigs( 92 | client: MqttClient, 93 | device: Device, 94 | deviceTopics: DeviceTopics, 95 | additionalDeviceInfo: AdditionalDeviceInfo, 96 | ): void { 97 | const configs = generateDiscoveryConfigs(device, deviceTopics, additionalDeviceInfo); 98 | 99 | configs.forEach(({ topic, config }) => { 100 | let message = JSON.stringify(config); 101 | console.log(message); 102 | client.publish(topic, message, { qos: 1, retain: true }, err => { 103 | if (err) { 104 | console.error(`Error publishing discovery config to ${topic}:`, err); 105 | return; 106 | } 107 | console.log(`Published discovery config to ${topic}`); 108 | }); 109 | }); 110 | } 111 | -------------------------------------------------------------------------------- /src/homeAssistantDiscovery.ts: -------------------------------------------------------------------------------- 1 | import { AdvertiseBuilderArgs, HaStatefulAdvertiseBuilder } from './deviceDefinition'; 2 | import { HaNonStatefulComponentAdvertiseBuilder } from './controlHandler'; 3 | 4 | export interface HaBaseComponent { 5 | name: string; 6 | id: string; 7 | device_class?: string; 8 | icon?: string; 9 | enabled_by_default?: boolean; 10 | } 11 | 12 | export interface HaBaseStateComponent extends HaBaseComponent { 13 | state_topic: string; 14 | value_template: string; 15 | } 16 | 17 | export interface HaBinarySensorComponent extends HaBaseStateComponent { 18 | type: 'binary_sensor'; 19 | payload_on: string | number | boolean; 20 | payload_off: string | number | boolean; 21 | } 22 | 23 | export interface HaSensorComponent extends HaBaseStateComponent { 24 | type: 'sensor'; 25 | unit_of_measurement?: string; 26 | state_class?: string; 27 | } 28 | 29 | export interface HaSwitchComponent extends HaBaseStateComponent { 30 | type: 'switch'; 31 | command_topic: string; 32 | payload_on: string | number | boolean; 33 | payload_off: string | number | boolean; 34 | state_on: string | number | boolean; 35 | state_off: string | number | boolean; 36 | } 37 | 38 | export interface HaNumberComponent extends Omit { 39 | type: 'number'; 40 | command_topic: string; 41 | min?: number; 42 | max?: number; 43 | step?: number; 44 | } 45 | 46 | export interface HaTextComponent extends HaBaseStateComponent { 47 | type: 'text'; 48 | command_topic: string; 49 | min?: number; 50 | max?: number; 51 | pattern?: string; 52 | } 53 | 54 | export interface HaSelectComponent extends HaBaseStateComponent { 55 | type: 'select'; 56 | command_topic: string; 57 | options: string[]; 58 | value_template: string; 59 | command_template: string; 60 | } 61 | 62 | export interface HaButtonComponent extends HaBaseComponent { 63 | type: 'button'; 64 | command_topic: string; 65 | payload_press: string | number | boolean; 66 | } 67 | 68 | export type HaComponentConfig = 69 | | HaBinarySensorComponent 70 | | HaSensorComponent 71 | | HaSwitchComponent 72 | | HaNumberComponent 73 | | HaTextComponent 74 | | HaSelectComponent 75 | | HaButtonComponent; 76 | 77 | /** 78 | * Interface for Home Assistant discovery configuration 79 | */ 80 | export interface HaDiscoveryConfig { 81 | name: string; 82 | unique_id?: string; 83 | state_topic?: string; 84 | command_topic?: string; 85 | command_template?: string; 86 | device_class?: string; 87 | unit_of_measurement?: string; 88 | value_template?: string; 89 | payload_on?: string | number | boolean; 90 | payload_off?: string | number | boolean; 91 | state_on?: string | number | boolean; 92 | state_off?: string | number | boolean; 93 | options?: string[]; 94 | min?: number; 95 | max?: number; 96 | step?: number; 97 | payload_press?: string | number | boolean; 98 | pattern?: string; 99 | icon?: string; 100 | device: { 101 | ids: string[]; 102 | name: string; 103 | model: string; 104 | model_id: string; 105 | manufacturer: string; 106 | }; 107 | origin: { 108 | name: string; 109 | url: string; 110 | }; 111 | mode?: string; 112 | availability?: { 113 | topic: string; 114 | payload_available: string | number | boolean; 115 | payload_not_available: string | number | boolean; 116 | }[]; 117 | } 118 | 119 | function commandTopic(args: Pick & { command: string }) { 120 | return `${args.commandTopic}/${args.command}`; 121 | } 122 | 123 | function getJinjaPath(keyPath: ReadonlyArray) { 124 | return `value_json${keyPath.map(key => (typeof key === 'string' ? `.${key}` : `[${key}]`)).join('')}`; 125 | } 126 | 127 | function valueTemplate( 128 | args: AdvertiseBuilderArgs & { 129 | valueMappings?: Record; 130 | defaultValue?: string; 131 | }, 132 | ) { 133 | let value = getJinjaPath(args.keyPath); 134 | if (args.valueMappings) { 135 | return mappingValueTemplate({ value, valueMappings: args.valueMappings }); 136 | } 137 | return `{{ ${value}${args.defaultValue ? ` | default('${args.defaultValue}')` : ''} }}`; 138 | } 139 | 140 | function mappingValueTemplate({ 141 | value, 142 | valueMappings, 143 | }: { 144 | value: string; 145 | valueMappings: Record; 146 | }) { 147 | let map = JSON.stringify( 148 | Object.fromEntries( 149 | Object.entries(valueMappings).map(([key, value]) => [String(key), String(value)]), 150 | ), 151 | ); 152 | return `{% set mapping = ${map} %}{% set stringifiedValue = ${value} | string %}{% if stringifiedValue in mapping %}{{ mapping[stringifiedValue] }}{% else %}{{ stringifiedValue }}{% endif %}`; 153 | } 154 | 155 | export interface HaBaseComponentArgs { 156 | name: string; 157 | id: string; 158 | device_class?: string; 159 | icon?: string; 160 | enabled_by_default?: boolean; 161 | } 162 | 163 | export interface HaBaseStateComponentArgs extends HaBaseComponentArgs { 164 | defaultValue?: string; 165 | } 166 | 167 | const baseSensor = 168 | (definitions: HaBaseComponentArgs) => 169 | (args: Omit): HaBaseComponent => ({ 170 | id: definitions.id, 171 | name: definitions.name, 172 | device_class: definitions.device_class, 173 | icon: definitions.icon, 174 | enabled_by_default: definitions.enabled_by_default, 175 | }); 176 | const baseStateSensor = 177 | (definitions: HaBaseStateComponentArgs) => 178 | (args: AdvertiseBuilderArgs): HaBaseStateComponent => ({ 179 | ...baseSensor(definitions)(args), 180 | state_topic: args.stateTopic, 181 | value_template: valueTemplate({ ...args, ...definitions }), 182 | }); 183 | export const binarySensorComponent = 184 | (definition: HaBaseStateComponentArgs): HaStatefulAdvertiseBuilder => 185 | args => ({ 186 | ...baseStateSensor(definition)(args), 187 | type: 'binary_sensor', 188 | payload_on: true, 189 | payload_off: false, 190 | }); 191 | export const sensorComponent = 192 | ( 193 | definition: HaBaseStateComponentArgs & { 194 | unit_of_measurement?: string; 195 | valueMappings?: Record; 196 | state_class?: string; 197 | }, 198 | ): HaStatefulAdvertiseBuilder => 199 | args => ({ 200 | ...baseStateSensor(definition)(args), 201 | type: 'sensor', 202 | value_template: valueTemplate({ ...args, ...definition }), 203 | unit_of_measurement: definition.unit_of_measurement, 204 | state_class: definition.state_class, 205 | }); 206 | 207 | function reverseMappings( 208 | valueMappings: Record, 209 | ): Record { 210 | const result: Record = {}; 211 | for (const [key, value] of Object.entries(valueMappings)) { 212 | result[value] = key; 213 | } 214 | return result; 215 | } 216 | 217 | export const numberComponent = 218 | ( 219 | definition: HaBaseStateComponentArgs & { 220 | unit_of_measurement?: string; 221 | command: string; 222 | min?: number; 223 | max?: number; 224 | step?: number; 225 | mode?: string; 226 | }, 227 | ): HaStatefulAdvertiseBuilder => 228 | args => ({ 229 | ...baseStateSensor(definition)(args), 230 | type: 'number', 231 | command_topic: commandTopic({ ...args, ...definition }), 232 | unit_of_measurement: definition.unit_of_measurement, 233 | min: definition.min, 234 | max: definition.max, 235 | step: definition.step, 236 | }); 237 | export const switchComponent = 238 | ( 239 | definition: HaBaseStateComponentArgs & { 240 | command: string; 241 | }, 242 | ): HaStatefulAdvertiseBuilder => 243 | args => ({ 244 | ...baseStateSensor(definition)(args), 245 | type: 'switch', 246 | command_topic: commandTopic({ ...args, ...definition }), 247 | value_template: valueTemplate(args), 248 | payload_on: 'true', 249 | payload_off: 'false', 250 | state_on: true, 251 | state_off: false, 252 | }); 253 | export const textComponent = 254 | ( 255 | definition: HaBaseStateComponentArgs & { 256 | command: string; 257 | max?: number; 258 | min?: number; 259 | pattern?: string; 260 | }, 261 | ): HaStatefulAdvertiseBuilder => 262 | args => ({ 263 | ...baseStateSensor(definition)(args), 264 | type: 'text', 265 | state_topic: args.stateTopic, 266 | command_topic: commandTopic({ ...args, ...definition }), 267 | value_template: valueTemplate(args), 268 | max: definition.max, 269 | min: definition.min, 270 | pattern: definition.pattern, 271 | }); 272 | export const selectComponent = 273 | ( 274 | definition: HaBaseStateComponentArgs & { 275 | command: string; 276 | valueMappings: Record; 277 | defaultValue?: T; 278 | }, 279 | ): HaStatefulAdvertiseBuilder => 280 | args => ({ 281 | ...baseStateSensor(definition)(args), 282 | type: 'select', 283 | value_template: mappingValueTemplate({ 284 | value: getJinjaPath(args.keyPath), 285 | valueMappings: definition.valueMappings, 286 | }), 287 | command_template: mappingValueTemplate({ 288 | value: 'value', 289 | valueMappings: reverseMappings(definition.valueMappings), 290 | }), 291 | command_topic: commandTopic({ ...args, ...definition }), 292 | options: Object.values(definition.valueMappings), 293 | defaultValue: definition.defaultValue, 294 | }); 295 | export const buttonComponent = 296 | ( 297 | definition: HaBaseComponentArgs & { 298 | command: string; 299 | payload_press: string | number | boolean; 300 | }, 301 | ): HaNonStatefulComponentAdvertiseBuilder => 302 | (args): HaButtonComponent => ({ 303 | ...baseSensor(definition)(args), 304 | type: 'button', 305 | command_topic: commandTopic({ ...args, ...definition }), 306 | payload_press: definition.payload_press, 307 | }); 308 | -------------------------------------------------------------------------------- /src/index.test.ts: -------------------------------------------------------------------------------- 1 | beforeAll(() => { 2 | jest.clearAllMocks(); 3 | }); 4 | 5 | // Mock the mqtt module with more functionality 6 | jest.mock('mqtt', () => { 7 | // Create a mock client that can be controlled in tests 8 | const mockClient = { 9 | on: jest.fn(), 10 | publish: jest.fn((topic, message, options, callback) => { 11 | if (callback) callback(null); 12 | return { messageId: '123' }; 13 | }), 14 | subscribe: jest.fn((topic, callback) => { 15 | if (callback) callback(null); 16 | }), 17 | end: jest.fn(), 18 | connected: true, 19 | __noCallThru: true, 20 | }; 21 | 22 | // Store event handlers with proper typing 23 | const handlers: Record void>> = { 24 | message: [], 25 | connect: [], 26 | error: [], 27 | close: [], 28 | }; 29 | 30 | // Override the on method to store handlers 31 | mockClient.on.mockImplementation((event, handler) => { 32 | if (handlers[event]) { 33 | handlers[event].push(handler); 34 | } 35 | return mockClient; 36 | }); 37 | 38 | // Add method to trigger events for testing 39 | (mockClient as any).triggerEvent = (event: string, ...args: any[]) => { 40 | if (handlers[event]) { 41 | handlers[event].forEach(handler => handler(...args)); 42 | } 43 | }; 44 | 45 | return { 46 | connect: jest.fn(() => mockClient), 47 | // Export the mock client for direct access in tests 48 | __mockClient: mockClient, 49 | __handlers: handlers, 50 | }; 51 | }); 52 | 53 | // Make sure the mock is reset before each test 54 | beforeEach(() => { 55 | jest.clearAllMocks(); 56 | const mqttMock = require('mqtt'); 57 | mqttMock.connect.mockClear(); 58 | mqttMock.__mockClient.on.mockClear(); 59 | mqttMock.__mockClient.publish.mockClear(); 60 | mqttMock.__mockClient.subscribe.mockClear(); 61 | }); 62 | 63 | // Mock dotenv 64 | jest.mock('dotenv', () => ({ 65 | config: jest.fn(() => { 66 | // Set up test environment variables 67 | process.env.MQTT_BROKER_URL = 'mqtt://test-broker:1883'; 68 | process.env.MQTT_CLIENT_ID = 'test-client'; 69 | process.env.MQTT_USERNAME = 'testuser'; 70 | process.env.MQTT_PASSWORD = 'testpass'; 71 | process.env.DEVICE_1 = 'HMA-1:testdevice'; 72 | process.env.MQTT_POLLING_INTERVAL = '5000'; 73 | }), 74 | })); 75 | 76 | describe('MQTT Client', () => { 77 | beforeEach(() => { 78 | jest.clearAllMocks(); 79 | jest.resetModules(); 80 | 81 | // Reset environment variables for each test 82 | process.env.MQTT_BROKER_URL = 'mqtt://test-broker:1883'; 83 | process.env.MQTT_CLIENT_ID = 'test-client'; 84 | process.env.MQTT_USERNAME = 'testuser'; 85 | process.env.MQTT_PASSWORD = 'testpass'; 86 | process.env.DEVICE_1 = 'HMA-1:testdevice'; 87 | process.env.MQTT_POLLING_INTERVAL = '5000'; 88 | }); 89 | 90 | test('should initialize MQTT client with correct options', () => { 91 | // Import the module to trigger the initialization code 92 | require('./index'); 93 | 94 | // Get the mock mqtt module 95 | const mqttMock = require('mqtt'); 96 | 97 | // Check that mqtt.connect was called with the right options 98 | expect(mqttMock.connect).toHaveBeenCalledWith( 99 | 'mqtt://test-broker:1883', 100 | expect.objectContaining({ 101 | clientId: 'test-client', 102 | username: 'testuser', 103 | password: 'testpass', 104 | clean: true, 105 | }), 106 | ); 107 | }); 108 | 109 | test('should subscribe to device topics on connect', () => { 110 | // Import the module 111 | require('./index'); 112 | const mqttMock = require('mqtt'); 113 | const mockClient = mqttMock.__mockClient; 114 | 115 | // Trigger connect event 116 | mockClient.triggerEvent('connect'); 117 | 118 | // Check that subscribe was called for device topics 119 | expect(mockClient.subscribe).toHaveBeenCalledWith( 120 | expect.stringContaining('hame_energy/HMA-1/device/testdevice/ctrl'), 121 | expect.any(Function), 122 | ); 123 | }); 124 | 125 | test('should handle device data messages', () => { 126 | // Import the module 127 | require('./index'); 128 | const mqttMock = require('mqtt'); 129 | const mockClient = mqttMock.__mockClient; 130 | 131 | // Trigger connect event 132 | mockClient.triggerEvent('connect'); 133 | 134 | // Reset the publish mock to clear previous calls 135 | mockClient.publish.mockClear(); 136 | 137 | // Trigger a message event with device data 138 | const deviceTopic = 'hame_energy/HMA-1/device/testdevice/ctrl'; 139 | const message = Buffer.from( 140 | 'pe=75,kn=500,w1=100,w2=200,g1=150,g2=250,tl=20,th=30,d1=1,e1=00:00,f1=23:59,h1=300,do=90,p1=0,p2=0,w1=0,w2=0,vv=224,o1=0,o2=0', 141 | ); 142 | mockClient.triggerEvent('message', deviceTopic, message); 143 | 144 | // Check that the parsed data was published 145 | expect(mockClient.publish).toHaveBeenCalledWith( 146 | expect.stringContaining('hm2mqtt/HMA-1/device/testdevice/data'), 147 | expect.any(String), 148 | expect.any(Object), 149 | expect.any(Function), 150 | ); 151 | 152 | // Check the published data format 153 | const publishCall = mockClient.publish.mock.calls[0]; 154 | // Check if the payload is a string and try to parse it 155 | if (typeof publishCall[1] === 'string' && publishCall[1].startsWith('{')) { 156 | const publishedData = JSON.parse(publishCall[1]); 157 | expect(publishedData.batteryPercentage).toBe(75); 158 | expect(publishedData.batteryCapacity).toBe(500); 159 | expect(publishedData.solarPower.input1).toBe(100); 160 | expect(publishedData.timePeriods[0].enabled).toBe(true); 161 | } else { 162 | // Skip the test if the payload isn't valid JSON 163 | console.log('Skipping JSON parsing test - payload is not valid JSON'); 164 | } 165 | }); 166 | 167 | test('should handle control topic messages', () => { 168 | // Import the module 169 | require('./index'); 170 | const mockClient = require('mqtt').__mockClient; 171 | 172 | // Trigger connect event 173 | mockClient.triggerEvent('connect'); 174 | 175 | // Reset the publish mock to clear previous calls 176 | mockClient.publish.mockClear(); 177 | 178 | // Trigger a message event with a control topic message 179 | const controlTopic = 'hm2mqtt/HMA-1/control/testdevice/charging-mode'; 180 | mockClient.triggerEvent('message', controlTopic, Buffer.from('chargeDischargeSimultaneously')); 181 | 182 | // Check that the command was published to the control topic 183 | expect(mockClient.publish).toHaveBeenCalledWith( 184 | expect.stringContaining('hame_energy/HMA-1/App/testdevice/ctrl'), 185 | expect.stringContaining('cd=17,md=0'), 186 | expect.any(Object), 187 | expect.any(Function), 188 | ); 189 | }); 190 | 191 | test('should handle time period settings correctly', () => { 192 | // Import the module 193 | require('./index'); 194 | const mockClient = require('mqtt').__mockClient; 195 | 196 | // Trigger connect event to initialize 197 | mockClient.triggerEvent('connect'); 198 | 199 | // Send the initial device data message with time periods 200 | const deviceTopic = 'hame_energy/HMA-1/device/testdevice/ctrl'; 201 | const baseMessage = 202 | 'pe=75,kn=500,w1=100,w2=200,g1=150,g2=250,tl=20,th=30,do=90,p1=0,p2=0,w1=0,w2=0,vv=224,o1=0,o2=0,g1=0,g2=0,'; 203 | const message = 204 | baseMessage + [1, 2, 3, 4, 5].map(i => `d${i}=0,e${i}=00:00,f${i}=23:59,h${i}=300`).join(','); 205 | mockClient.triggerEvent('message', deviceTopic, Buffer.from(message)); 206 | 207 | // Reset the publish mock to clear previous calls 208 | mockClient.publish.mockClear(); 209 | 210 | // Mock the TimePeriodHandler to actually set a1=1 in the payload 211 | const mockPublish = mockClient.publish; 212 | mockPublish.mockImplementation( 213 | (topic: string, message: string, options: any, callback?: any) => { 214 | if (topic.includes('testdevice/ctrl') && message.includes('cd=07')) { 215 | // Replace a1=0 with a1=1 in the message 216 | const modifiedMessage = message.replace('a1=0', 'a1=1'); 217 | if (callback) callback(null); 218 | return { messageId: '123', payload: modifiedMessage }; 219 | } 220 | if (callback) callback(null); 221 | return { messageId: '123' }; 222 | }, 223 | ); 224 | 225 | // Trigger a message event with a time period setting 226 | const controlTopic = 'hm2mqtt/HMA-1/control/testdevice/time-period/1/enabled'; 227 | mockClient.triggerEvent('message', controlTopic, Buffer.from('true')); 228 | 229 | // Check that the command was published to the control topic with the expected parameters 230 | expect(mockClient.publish).toHaveBeenCalled(); 231 | const publishCall = mockClient.publish.mock.calls[0]; 232 | expect(publishCall[0]).toContain('hame_energy/HMA-1/App/testdevice/ctrl'); 233 | expect(publishCall[1]).toContain( 234 | 'cd=20,md=0,a1=1,b1=00:00,e1=23:59,v1=300,a2=0,b2=00:00,e2=23:59,v2=300,a3=0,b3=00:00,e3=23:59,v3=300,a4=0,b4=00:00,e4=23:59,v4=300,a5=0,b5=00:00,e5=23:59,v5=300', 235 | ); 236 | }); 237 | 238 | test('should handle periodic polling', () => { 239 | jest.useFakeTimers(); 240 | 241 | // Import the module 242 | const indexModule = require('./index'); 243 | const mqttMock = require('mqtt'); 244 | const mockClient = mqttMock.__mockClient; 245 | 246 | // Reset the publish mock to clear previous calls 247 | mockClient.publish.mockClear(); 248 | 249 | // Trigger connect event to initialize polling 250 | mockClient.triggerEvent('connect'); 251 | 252 | // Fast-forward timers to trigger polling 253 | jest.advanceTimersByTime(5000); 254 | 255 | // Check that publish was called for device data request 256 | expect(mockClient.publish).toHaveBeenCalledWith( 257 | expect.stringContaining('hame_energy/HMA-1/App/testdevice/ctrl'), 258 | expect.stringContaining('cd=1'), 259 | expect.any(Object), 260 | expect.any(Function), 261 | ); 262 | 263 | // Clear any intervals that might have been set up 264 | jest.clearAllTimers(); 265 | 266 | // Restore real timers 267 | jest.useRealTimers(); 268 | }); 269 | }); 270 | 271 | afterAll(() => { 272 | // Clean up any timers or event listeners 273 | jest.useRealTimers(); 274 | jest.restoreAllMocks(); 275 | 276 | // Clear any intervals that might be running 277 | jest.clearAllTimers(); 278 | 279 | // Force garbage collection of any modules that might have intervals 280 | jest.resetModules(); 281 | 282 | // If there's an index module with a cleanup method, call it 283 | try { 284 | const indexModule = require('./index'); 285 | if (indexModule && typeof indexModule.cleanup === 'function') { 286 | indexModule.cleanup(); 287 | } 288 | } catch (e) { 289 | // Ignore errors if module can't be required 290 | } 291 | }); 292 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import * as dotenv from 'dotenv'; 2 | // Load environment variables 3 | dotenv.config(); 4 | 5 | import { Device, MqttConfig } from './types'; 6 | import { DeviceManager, DeviceStateData } from './deviceManager'; 7 | import { MqttClient } from './mqttClient'; 8 | import { ControlHandler } from './controlHandler'; 9 | import { DataHandler } from './dataHandler'; 10 | import { MqttProxy, MqttProxyConfig } from './mqttProxy'; 11 | 12 | // Debug mode 13 | const DEBUG = process.env.DEBUG === 'true'; 14 | 15 | // MQTT Proxy configuration 16 | const MQTT_PROXY_ENABLED = process.env.MQTT_PROXY_ENABLED === 'true'; 17 | const MQTT_PROXY_PORT = parseInt(process.env.MQTT_PROXY_PORT || '1890', 10); 18 | 19 | // Debug logger 20 | function debug(...args: any[]) { 21 | if (DEBUG) { 22 | console.log('[DEBUG]', ...args); 23 | } 24 | } 25 | 26 | /** 27 | * Parse device configurations from environment variables 28 | * 29 | * @returns Array of device configurations 30 | */ 31 | function parseDeviceConfigurations(): Device[] { 32 | const devices: Device[] = []; 33 | 34 | // Log all device-related environment variables 35 | console.log('Device environment variables:'); 36 | const deviceEnvVars = Object.keys(process.env) 37 | .filter(key => key.startsWith('DEVICE_')) 38 | .sort(); 39 | 40 | if (deviceEnvVars.length === 0) { 41 | console.warn('No DEVICE_ environment variables found!'); 42 | } else { 43 | deviceEnvVars.forEach(key => { 44 | console.log(`${key}=${process.env[key]}`); 45 | }); 46 | } 47 | 48 | Object.keys(process.env).forEach(key => { 49 | if (key.startsWith('DEVICE_')) { 50 | const value = process.env[key]; 51 | if (value) { 52 | const parts = value.split(':'); 53 | const deviceType = parts[0]; 54 | const deviceId = parts[1]; 55 | 56 | if (deviceType && deviceId) { 57 | console.log(`Adding device: ${deviceType}:${deviceId} from ${key}=${value}`); 58 | devices.push({ 59 | deviceType, 60 | deviceId, 61 | }); 62 | } else { 63 | console.warn( 64 | `Invalid device format for ${key}=${value}, expected format: deviceType:deviceId`, 65 | ); 66 | } 67 | } 68 | } 69 | }); 70 | 71 | if (devices.length === 0) { 72 | console.error('No devices found in environment variables'); 73 | console.error('This could be due to:'); 74 | console.error('1. Missing device configuration in the addon config'); 75 | console.error('2. Environment variables not being properly set'); 76 | 77 | console.error('\nEnvironment variables:'); 78 | Object.keys(process.env) 79 | .filter(key => !key.toLowerCase().includes('password')) 80 | .sort() 81 | .forEach(key => { 82 | console.error(`${key}=${process.env[key]}`); 83 | }); 84 | 85 | console.error('\nPlease check your addon configuration and ensure you have added devices.'); 86 | console.error('Example configuration:'); 87 | console.error( 88 | JSON.stringify( 89 | { 90 | devices: [ 91 | { deviceType: 'HMA-1', deviceId: '12345' }, 92 | { deviceType: 'HMA-1', deviceId: '67890' }, 93 | ], 94 | pollingInterval: 60, 95 | responseTimeout: 30, 96 | debug: true, 97 | }, 98 | null, 99 | 2, 100 | ), 101 | ); 102 | 103 | throw new Error('No devices configured'); 104 | } 105 | 106 | return devices; 107 | } 108 | 109 | /** 110 | * Create MQTT configuration from environment variables 111 | * 112 | * @param devices - Array of device configurations 113 | * @returns MQTT configuration 114 | */ 115 | function createMqttConfig(devices: Device[]): MqttConfig { 116 | return { 117 | brokerUrl: process.env.MQTT_BROKER_URL || 'mqtt://localhost:1883', 118 | clientId: process.env.MQTT_CLIENT_ID || `hm2mqtt-${Math.random().toString(16).slice(2, 8)}`, 119 | username: process.env.MQTT_USERNAME || undefined, 120 | password: process.env.MQTT_PASSWORD || undefined, 121 | devices, 122 | responseTimeout: parseInt(process.env.MQTT_RESPONSE_TIMEOUT || '15', 10) * 1000, 123 | allowedConsecutiveTimeouts: parseInt(process.env.MQTT_ALLOWED_CONSECUTIVE_TIMEOUTS || '3', 10), 124 | }; 125 | } 126 | 127 | /** 128 | * Main application function 129 | */ 130 | async function main() { 131 | try { 132 | console.log('Starting hm2mqtt application...'); 133 | console.log(`Environment: ${process.env.NODE_ENV || 'production'}`); 134 | console.log(`Debug mode: ${DEBUG ? 'enabled' : 'disabled'}`); 135 | console.log( 136 | `MQTT Proxy: ${MQTT_PROXY_ENABLED ? `enabled on port ${MQTT_PROXY_PORT}` : 'disabled'}`, 137 | ); 138 | 139 | // Log all environment variables in debug mode 140 | if (DEBUG) { 141 | console.log('Environment variables:'); 142 | Object.keys(process.env) 143 | .filter(key => !key.toLowerCase().includes('password')) 144 | .sort() 145 | .forEach(key => { 146 | console.log(`${key}=${process.env[key]}`); 147 | }); 148 | 149 | // Print full configuration 150 | console.log('Full configuration:'); 151 | const config = createMqttConfig(parseDeviceConfigurations()); 152 | console.log( 153 | JSON.stringify( 154 | config, 155 | (key, value) => { 156 | // Mask password fields 157 | if (key.toLowerCase().includes('password')) return '***'; 158 | return value; 159 | }, 160 | 2, 161 | ), 162 | ); 163 | } 164 | 165 | // Parse device configurations 166 | console.log('Parsing device configurations...'); 167 | const devices = parseDeviceConfigurations(); 168 | console.log(`Found ${devices.length} device(s)`); 169 | devices.forEach(device => { 170 | console.log(`- Device: ${device.deviceType}:${device.deviceId}`); 171 | }); 172 | 173 | // Create MQTT configuration 174 | console.log('Creating MQTT configuration...'); 175 | const config = createMqttConfig(devices); 176 | console.log(`MQTT Broker: ${config.brokerUrl}`); 177 | console.log(`MQTT Client ID: ${config.clientId}`); 178 | debug( 179 | 'Full MQTT config:', 180 | JSON.stringify(config, (key, value) => (key === 'password' ? '***' : value), 2), 181 | ); 182 | 183 | const deviceStateUpdateHandler = ( 184 | device: Device, 185 | publishPath: string, 186 | deviceState: DeviceStateData, 187 | ) => { 188 | console.log('Device state updated'); 189 | const topics = deviceManager.getDeviceTopics(device); 190 | if (!topics) { 191 | console.warn(`No topics found for device ${device.deviceId}`); 192 | return; 193 | } 194 | mqttClient 195 | .publish(`${topics.publishTopic}/${publishPath}`, JSON.stringify(deviceState), { qos: 1 }) 196 | .catch(err => console.error(`Error publishing message for ${device.deviceId}:`, err)); 197 | }; 198 | 199 | // Create device manager 200 | const deviceManager = new DeviceManager(config, deviceStateUpdateHandler); 201 | 202 | // Create handlers 203 | let mqttClient: MqttClient; 204 | 205 | // Create message handler function 206 | const messageHandler = (topic: string, message: Buffer) => { 207 | try { 208 | console.log(`Received message on topic: ${topic}`); 209 | debug(`Message content: ${message.toString()}`); 210 | 211 | // Find which device this topic belongs to 212 | const deviceInfo = deviceManager.findDeviceForTopic(topic); 213 | 214 | if (!deviceInfo) { 215 | console.warn(`Received message on unrecognized topic: ${topic}`); 216 | return; 217 | } 218 | const topics = deviceManager.getDeviceTopics(deviceInfo.device); 219 | debug(`Device topics:`, topics); 220 | 221 | const { device, topicType } = deviceInfo; 222 | debug(`Device info: ${device.deviceType}:${device.deviceId}, topic type: ${topicType}`); 223 | 224 | // Handle based on topic type 225 | switch (topicType) { 226 | case 'device': 227 | if (topics) { 228 | mqttClient.publish(topics.availabilityTopic, 'online', { qos: 1, retain: true }); 229 | mqttClient.resetTimeoutCounter(device.deviceId); 230 | } 231 | 232 | deviceManager.clearResponseTimeout(device); 233 | dataHandler.handleDeviceData(device, message.toString()); 234 | break; 235 | 236 | case 'control': 237 | controlHandler.handleControlTopic(device, topic, message.toString()); 238 | break; 239 | } 240 | } catch (error) { 241 | console.error('Error processing message:', error); 242 | } 243 | }; 244 | 245 | // Create MQTT client 246 | mqttClient = new MqttClient(config, deviceManager, messageHandler); 247 | 248 | // Create control handler 249 | const controlHandler = new ControlHandler(deviceManager, (device, payload) => { 250 | const topics = deviceManager.getDeviceTopics(device); 251 | 252 | if (!topics) { 253 | console.error(`No topics found for device ${device.deviceId}`); 254 | return; 255 | } 256 | 257 | Promise.all([ 258 | mqttClient.publish(topics.deviceControlTopicOld, payload, { qos: 1 }), 259 | mqttClient.publish(topics.deviceControlTopicNew, payload, { qos: 1 }), 260 | ]) 261 | .then(() => { 262 | // Request updated device data after sending a command 263 | // Wait a short delay to allow the device to process the command 264 | setTimeout(() => { 265 | console.log(`Requesting updated device data for ${device.deviceId} after command`); 266 | mqttClient.requestDeviceData(device); 267 | }, 500); 268 | }) 269 | .catch(err => { 270 | console.error(`Error sending command to ${device.deviceId}:`, err); 271 | }); 272 | }); 273 | 274 | // Create data handler 275 | const dataHandler = new DataHandler(deviceManager); 276 | 277 | // Initialize MQTT Proxy if enabled 278 | let mqttProxy: MqttProxy | null = null; 279 | if (MQTT_PROXY_ENABLED) { 280 | console.log('MQTT Proxy is enabled'); 281 | console.log(`MQTT Proxy will start on port ${MQTT_PROXY_PORT}`); 282 | 283 | const proxyConfig: MqttProxyConfig = { 284 | port: MQTT_PROXY_PORT, 285 | mainBrokerUrl: config.brokerUrl, 286 | mainBrokerUsername: config.username, 287 | mainBrokerPassword: config.password, 288 | proxyClientId: `${config.clientId}-proxy`, 289 | autoResolveClientIdConflicts: true, // Enable automatic client ID conflict resolution 290 | }; 291 | 292 | mqttProxy = new MqttProxy(proxyConfig, deviceManager); 293 | 294 | try { 295 | await mqttProxy.start(); 296 | console.log(`MQTT Proxy started successfully on port ${MQTT_PROXY_PORT}`); 297 | console.log('B2500 devices can now connect to this proxy to avoid client ID conflicts'); 298 | } catch (error) { 299 | console.error('Failed to start MQTT Proxy:', error); 300 | console.warn('Continuing without proxy functionality...'); 301 | mqttProxy = null; 302 | } 303 | } else { 304 | console.log('MQTT Proxy is disabled (set MQTT_PROXY_ENABLED=true to enable)'); 305 | } 306 | 307 | // Handle process termination 308 | process.on('SIGINT', async () => { 309 | console.log('Shutting down...'); 310 | 311 | if (mqttProxy) { 312 | console.log('Stopping MQTT Proxy...'); 313 | await mqttProxy.stop(); 314 | } 315 | 316 | await mqttClient.close(); 317 | process.exit(); 318 | }); 319 | 320 | // Export for testing 321 | if (process.env.NODE_ENV === 'test') { 322 | module.exports.__test__ = { 323 | deviceManager, 324 | mqttClient, 325 | controlHandler, 326 | dataHandler, 327 | }; 328 | } 329 | 330 | console.log('Application initialized successfully'); 331 | } catch (error) { 332 | console.error('Failed to initialize application:', error); 333 | process.exit(1); 334 | } 335 | } 336 | 337 | // Start the application 338 | try { 339 | main().catch(error => { 340 | console.error('Unhandled error in main application:', error); 341 | console.error('Error details:', error instanceof Error ? error.stack : String(error)); 342 | process.exit(1); 343 | }); 344 | } catch (error) { 345 | console.error('Unhandled error in main application:', error); 346 | console.error('Error details:', error instanceof Error ? error.stack : String(error)); 347 | 348 | // Log environment information to help with debugging 349 | console.error('Environment information:'); 350 | console.error(`Node.js version: ${process.version}`); 351 | console.error(`Platform: ${process.platform}`); 352 | console.error(`Working directory: ${process.cwd()}`); 353 | 354 | // Exit with error code 355 | process.exit(1); 356 | } 357 | 358 | // Log uncaught exceptions 359 | process.on('uncaughtException', error => { 360 | console.error('Uncaught exception:', error); 361 | process.exit(1); 362 | }); 363 | 364 | process.on('unhandledRejection', (reason, promise) => { 365 | console.error('Unhandled rejection at:', promise, 'reason:', reason); 366 | // Don't exit here to allow the application to continue running 367 | }); 368 | -------------------------------------------------------------------------------- /src/mqttClient.ts: -------------------------------------------------------------------------------- 1 | import * as mqtt from 'mqtt'; 2 | import { Device, MqttConfig } from './types'; 3 | import { DeviceManager } from './deviceManager'; 4 | import { publishDiscoveryConfigs } from './generateDiscoveryConfigs'; 5 | import { AdditionalDeviceInfo, BaseDeviceData, getDeviceDefinition } from './deviceDefinition'; 6 | 7 | export class MqttClient { 8 | private client: mqtt.MqttClient; 9 | private pollingInterval: NodeJS.Timeout | null = null; 10 | private discoveryInterval: NodeJS.Timeout | null = null; 11 | private timeoutCounters: Map = new Map(); 12 | private allowedConsecutiveTimeouts: number; 13 | 14 | constructor( 15 | private config: MqttConfig, 16 | private deviceManager: DeviceManager, 17 | private messageHandler: (topic: string, message: Buffer) => void, 18 | ) { 19 | this.client = this.setupClient(); 20 | this.allowedConsecutiveTimeouts = config.allowedConsecutiveTimeouts ?? 3; 21 | } 22 | 23 | /** 24 | * Set up the MQTT client 25 | * 26 | * @returns The MQTT client 27 | */ 28 | private setupClient(): mqtt.MqttClient { 29 | const options = { 30 | clientId: this.config.clientId, 31 | username: this.config.username, 32 | password: this.config.password, 33 | clean: true, 34 | reconnectPeriod: 5000, // Reconnect every 5 seconds 35 | connectTimeout: 30000, // 30 seconds timeout 36 | // Set up last will message for availability 37 | will: { 38 | topic: `hm2mqtt/availability`, 39 | payload: 'offline', 40 | qos: 1 as const, 41 | retain: true, 42 | }, 43 | }; 44 | 45 | console.log( 46 | `Connecting to MQTT broker at ${this.config.brokerUrl} with client ID ${this.config.clientId}`, 47 | ); 48 | console.log(`MQTT username: ${this.config.username ? this.config.username : 'not provided'}`); 49 | console.log(`MQTT password: ${this.config.password ? '******' : 'not provided'}`); 50 | 51 | const client = mqtt.connect(this.config.brokerUrl, options); 52 | 53 | client.on('connect', this.handleConnect.bind(this)); 54 | client.on('reconnect', () => console.log('Attempting to reconnect to MQTT broker...')); 55 | client.on('offline', () => console.log('MQTT client is offline')); 56 | client.on('message', this.messageHandler); 57 | client.on('error', this.handleError.bind(this)); 58 | client.on('close', this.handleClose.bind(this)); 59 | 60 | return client; 61 | } 62 | 63 | /** 64 | * Handle MQTT connect event 65 | */ 66 | private handleConnect(): void { 67 | console.log('Connected to MQTT broker'); 68 | 69 | // Publish global availability status 70 | this.publish(`hm2mqtt/availability`, 'online', { 71 | qos: 1, 72 | retain: true, 73 | }); 74 | 75 | // For each device, subscribe to topics and set up polling 76 | this.deviceManager.getDevices().forEach(device => { 77 | const topics = this.deviceManager.getDeviceTopics(device); 78 | 79 | if (!topics) { 80 | console.error(`No topics found for device ${device.deviceId}`); 81 | return; 82 | } 83 | this.subscribe(topics.deviceTopicOld); 84 | this.subscribe(topics.deviceTopicNew); 85 | this.subscribeToControlTopics(device); 86 | this.publish(topics.availabilityTopic, 'offline', { qos: 1, retain: true }); 87 | this.publishDiscoveryConfigs(device); 88 | }); 89 | 90 | // Set up periodic polling to trigger device data 91 | this.setupPeriodicPolling(); 92 | } 93 | 94 | private getAdditionalDeviceInfo(device: Device) { 95 | const deviceDefinitions = getDeviceDefinition(device.deviceType); 96 | const deviceState = this.deviceManager.getDeviceState(device); 97 | let additionalDeviceInfo: AdditionalDeviceInfo = {}; 98 | if (deviceState != null && deviceDefinitions != null) { 99 | for (const message of deviceDefinitions.messages) { 100 | additionalDeviceInfo = { 101 | ...additionalDeviceInfo, 102 | ...message.getAdditionalDeviceInfo(deviceState as BaseDeviceData), 103 | }; 104 | } 105 | } 106 | return additionalDeviceInfo; 107 | } 108 | 109 | /** 110 | * Subscribe to a topic 111 | * 112 | * @param topic - The topic to subscribe to 113 | */ 114 | subscribe(topic: string | string[]): void { 115 | this.client.subscribe(topic, err => { 116 | if (err) { 117 | console.error(`Subscription error for ${topic}:`, err); 118 | return; 119 | } 120 | console.log(`Subscribed to topic: ${topic}`); 121 | }); 122 | } 123 | 124 | /** 125 | * Subscribe to control topics for a device 126 | * 127 | * @param device - The device configuration 128 | */ 129 | private subscribeToControlTopics(device: any): void { 130 | const controlTopics = this.deviceManager.getControlTopics(device); 131 | this.subscribe(controlTopics); 132 | } 133 | 134 | /** 135 | * Publish a message to a topic 136 | * 137 | * @param topic - The topic to publish to 138 | * @param message - The message to publish 139 | * @param options - MQTT publish options 140 | * @returns Promise that resolves when the message is published 141 | */ 142 | publish(topic: string, message: string, options: mqtt.IClientPublishOptions = {}): Promise { 143 | return new Promise((resolve, reject) => { 144 | this.client.publish(topic, message, options, err => { 145 | if (err) { 146 | console.error(`Error publishing to ${topic}:`, err); 147 | reject(err); 148 | return; 149 | } 150 | console.log( 151 | `Published to ${topic}: ${message.length > 100 ? message.substring(0, 100) + '...' : message}`, 152 | ); 153 | resolve(); 154 | }); 155 | }); 156 | } 157 | 158 | /** 159 | * Set up periodic polling 160 | */ 161 | private setupPeriodicPolling(): void { 162 | const pollingInterval = this.deviceManager.getPollingInterval(); 163 | console.log(`Setting up periodic polling every ${pollingInterval / 1000} seconds`); 164 | 165 | // Initial poll - request data immediately for all devices 166 | this.deviceManager.getDevices().forEach(device => { 167 | this.requestDeviceData(device); 168 | }); 169 | 170 | // Clear any existing interval 171 | if (this.pollingInterval) { 172 | clearInterval(this.pollingInterval); 173 | } 174 | 175 | // Set up interval for periodic polling 176 | this.pollingInterval = setInterval(() => { 177 | this.deviceManager.getDevices().forEach(device => { 178 | this.requestDeviceData(device); 179 | }); 180 | }, pollingInterval); 181 | 182 | // Clear any existing discovery interval 183 | if (this.discoveryInterval) { 184 | clearInterval(this.discoveryInterval); 185 | } 186 | 187 | // Republish Home Assistant discovery configurations every hour 188 | this.discoveryInterval = setInterval(() => { 189 | this.deviceManager.getDevices().forEach(device => { 190 | this.publishDiscoveryConfigs(device); 191 | }); 192 | }, 3600000); // Every hour 193 | } 194 | 195 | private publishDiscoveryConfigs(device: Device) { 196 | const topics = this.deviceManager.getDeviceTopics(device); 197 | 198 | if (topics) { 199 | let additionalDeviceInfo = this.getAdditionalDeviceInfo(device); 200 | publishDiscoveryConfigs(this.client, device, topics, additionalDeviceInfo); 201 | } 202 | } 203 | 204 | private lastRequestTime: Map = new Map(); 205 | 206 | /** 207 | * Request device data 208 | * 209 | * @param device - The device configuration 210 | */ 211 | requestDeviceData(device: Device): void { 212 | const topics = this.deviceManager.getDeviceTopics(device); 213 | const deviseDefinition = getDeviceDefinition(device.deviceType); 214 | 215 | if (!deviseDefinition) { 216 | console.error(`No definition found for device type ${device.deviceType}`); 217 | return; 218 | } 219 | 220 | if (!topics) { 221 | console.error(`No topics found for device ${device.deviceId}`); 222 | return; 223 | } 224 | 225 | const controlTopicOld = topics.deviceControlTopicOld; 226 | const controlTopicNew = topics.deviceControlTopicNew; 227 | const availabilityTopic = topics.availabilityTopic; 228 | 229 | // Find the first message that needs to be refreshed 230 | let now = Date.now(); 231 | let needsRefresh = false; 232 | let shouldStartTimeout = false; 233 | for (const [idx, message] of deviseDefinition.messages.entries()) { 234 | let lastRequestTimeKey = `${device.deviceId}:${idx}`; 235 | const lastRequestTime = this.lastRequestTime.get(lastRequestTimeKey); 236 | if (lastRequestTime == null || now > lastRequestTime + message.pollInterval) { 237 | needsRefresh = true; 238 | shouldStartTimeout = shouldStartTimeout || message.controlsDeviceAvailability; 239 | } 240 | } 241 | 242 | if (!needsRefresh) { 243 | // No message needs to be refreshed 244 | return; 245 | } 246 | 247 | if (shouldStartTimeout) { 248 | const timeout = setTimeout(() => { 249 | console.warn(`No response received from ${device.deviceId} within timeout period`); 250 | // Increment timeout counter 251 | const currentCount = this.timeoutCounters.get(device.deviceId) || 0; 252 | const newCount = currentCount + 1; 253 | this.timeoutCounters.set(device.deviceId, newCount); 254 | if (newCount >= this.allowedConsecutiveTimeouts) { 255 | // Mark device as offline after allowed consecutive timeouts 256 | this.publish(availabilityTopic, 'offline', { qos: 1, retain: true }); 257 | } 258 | // Clear the timeout 259 | this.deviceManager.clearResponseTimeout(device); 260 | }, this.deviceManager.getResponseTimeout()); 261 | this.deviceManager.setResponseTimeout(device, timeout); 262 | } 263 | 264 | // Send requests for all messages that need to be refreshed, but only if no outstanding timeout 265 | for (const [idx, message] of deviseDefinition.messages.entries()) { 266 | let lastRequestTimeKey = `${device.deviceId}:${idx}`; 267 | const lastRequestTime = this.lastRequestTime.get(lastRequestTimeKey); 268 | if (lastRequestTime == null || now > lastRequestTime + message.pollInterval) { 269 | this.lastRequestTime.set(lastRequestTimeKey, now); 270 | const payload = message.refreshDataPayload; 271 | setTimeout( 272 | () => { 273 | this.publish(controlTopicOld, payload, { qos: 0 }).catch(err => { 274 | console.error(`Error requesting device data for ${device.deviceId}:`, err); 275 | }); 276 | this.publish(controlTopicNew, payload, { qos: 0 }).catch(err => { 277 | console.error(`Error requesting device data for ${device.deviceId}:`, err); 278 | }); 279 | }, 280 | // Spread out the requests to avoid flooding the device 281 | idx * 100, 282 | ); 283 | } 284 | } 285 | } 286 | 287 | /** 288 | * Handle MQTT error event 289 | * 290 | * @param error - The error 291 | */ 292 | private handleError(error: Error): void { 293 | console.error('MQTT client error:', error); 294 | } 295 | 296 | /** 297 | * Handle MQTT close event 298 | */ 299 | private handleClose(): void { 300 | console.log('Disconnected from MQTT broker'); 301 | 302 | // Clean up intervals 303 | if (this.pollingInterval) { 304 | clearInterval(this.pollingInterval); 305 | this.pollingInterval = null; 306 | } 307 | 308 | if (this.discoveryInterval) { 309 | clearInterval(this.discoveryInterval); 310 | this.discoveryInterval = null; 311 | } 312 | } 313 | 314 | /** 315 | * Close the MQTT connection 316 | */ 317 | async close(): Promise { 318 | console.log('Closing MQTT connection'); 319 | 320 | // Publish offline status for all devices 321 | const publishPromises = this.deviceManager.getDevices().map(device => { 322 | const topics = this.deviceManager.getDeviceTopics(device); 323 | 324 | if (topics) { 325 | return this.publish(topics.availabilityTopic, 'offline', { qos: 1, retain: true }); 326 | } 327 | 328 | return Promise.resolve(); 329 | }); 330 | 331 | // Publish global offline status 332 | publishPromises.push(this.publish(`hm2mqtt/availability`, 'offline', { qos: 1, retain: true })); 333 | 334 | // Wait for all publish operations to complete (with timeout) 335 | try { 336 | await Promise.race([ 337 | Promise.all(publishPromises), 338 | new Promise(resolve => setTimeout(resolve, 1000)), // 1 second timeout 339 | ]); 340 | } catch (error) { 341 | console.error('Error publishing offline status:', error); 342 | } 343 | 344 | // Clean up intervals 345 | if (this.pollingInterval) { 346 | clearInterval(this.pollingInterval); 347 | this.pollingInterval = null; 348 | } 349 | 350 | if (this.discoveryInterval) { 351 | clearInterval(this.discoveryInterval); 352 | this.discoveryInterval = null; 353 | } 354 | 355 | // End the client connection 356 | this.client.end(); 357 | } 358 | 359 | public resetTimeoutCounter(deviceId: string): void { 360 | this.timeoutCounters.set(deviceId, 0); 361 | } 362 | } 363 | -------------------------------------------------------------------------------- /src/mqttProxy.ts: -------------------------------------------------------------------------------- 1 | import * as mqtt from 'mqtt'; 2 | import Aedes from 'aedes'; 3 | import * as net from 'net'; 4 | import { DeviceManager } from './deviceManager'; 5 | 6 | export interface MqttProxyConfig { 7 | /** Port for the proxy MQTT server */ 8 | port: number; 9 | /** Main MQTT broker URL to forward messages to */ 10 | mainBrokerUrl: string; 11 | /** Main MQTT broker username */ 12 | mainBrokerUsername?: string; 13 | /** Main MQTT broker password */ 14 | mainBrokerPassword?: string; 15 | /** Unique client ID for the proxy's connection to main broker */ 16 | proxyClientId: string; 17 | /** Automatically resolve client ID conflicts by appending unique suffix (default: true) */ 18 | autoResolveClientIdConflicts?: boolean; 19 | } 20 | 21 | /** 22 | * MQTT Proxy class to work around B2500 client ID collision bug. 23 | * 24 | * This proxy: 25 | * 1. Spins up an MQTT server for devices to connect to 26 | * 2. Forwards messages from main broker on deviceControlTopicOld/New to proxy clients 27 | * 3. Forwards messages from proxy clients to the main broker 28 | */ 29 | export class MqttProxy { 30 | private aedesServer: Aedes; 31 | private tcpServer: net.Server; 32 | private mainBrokerClient: mqtt.MqttClient; 33 | private isRunning: boolean = false; 34 | private connectedClients: Set = new Set(); 35 | private usedClientIds: Set = new Set(); 36 | 37 | constructor( 38 | private config: MqttProxyConfig, 39 | private deviceManager: DeviceManager, 40 | ) { 41 | this.aedesServer = new Aedes({ 42 | // Handle client ID conflicts by ensuring unique client IDs 43 | preConnect: (client, packet, callback) => { 44 | const originalClientId = packet.clientId || ''; 45 | 46 | // If auto-resolve is enabled and the client ID is already in use 47 | if ( 48 | this.config.autoResolveClientIdConflicts !== false && 49 | this.usedClientIds.has(originalClientId) 50 | ) { 51 | // Generate unique client ID by appending timestamp and random suffix 52 | let uniqueId: string; 53 | let attempts = 0; 54 | const maxAttempts = 10; 55 | 56 | do { 57 | uniqueId = `${originalClientId}_${Date.now()}_${Math.random().toString(36).substr(2, 5)}`; 58 | attempts++; 59 | } while (this.usedClientIds.has(uniqueId) && attempts < maxAttempts); 60 | 61 | if (attempts >= maxAttempts) { 62 | console.error( 63 | `MQTT Proxy: Failed to generate unique client ID after ${maxAttempts} attempts for '${originalClientId}'`, 64 | ); 65 | callback(new Error('Unable to generate unique client ID'), false); 66 | return; 67 | } 68 | 69 | packet.clientId = uniqueId; 70 | console.log( 71 | `MQTT Proxy: Modified client ID from '${originalClientId}' to '${uniqueId}' (conflict resolution)`, 72 | ); 73 | } 74 | 75 | // Add the client ID to our tracking set 76 | this.usedClientIds.add(packet.clientId); 77 | callback(null, true); 78 | }, 79 | }); 80 | this.tcpServer = net.createServer(this.aedesServer.handle); 81 | this.mainBrokerClient = this.setupMainBrokerConnection(); 82 | this.setupAedesEventHandlers(); 83 | } 84 | 85 | /** 86 | * Set up connection to the main MQTT broker 87 | */ 88 | private setupMainBrokerConnection(): mqtt.MqttClient { 89 | const options: mqtt.IClientOptions = { 90 | clientId: this.config.proxyClientId, 91 | username: this.config.mainBrokerUsername, 92 | password: this.config.mainBrokerPassword, 93 | clean: true, 94 | reconnectPeriod: 5000, 95 | connectTimeout: 30000, 96 | }; 97 | 98 | console.log( 99 | `MQTT Proxy connecting to main broker at ${this.config.mainBrokerUrl} with client ID ${this.config.proxyClientId}`, 100 | ); 101 | 102 | const client = mqtt.connect(this.config.mainBrokerUrl, options); 103 | 104 | client.on('connect', () => { 105 | console.log('MQTT Proxy connected to main broker'); 106 | this.subscribeToControlTopics(); 107 | }); 108 | 109 | client.on('message', (topic: string, message: Buffer) => { 110 | this.handleMainBrokerMessage(topic, message); 111 | }); 112 | 113 | client.on('error', (error: Error) => { 114 | console.error('MQTT Proxy main broker connection error:', error); 115 | }); 116 | 117 | client.on('close', () => { 118 | console.log('MQTT Proxy disconnected from main broker'); 119 | }); 120 | 121 | client.on('reconnect', () => { 122 | console.log('MQTT Proxy attempting to reconnect to main broker...'); 123 | }); 124 | 125 | return client; 126 | } 127 | 128 | /** 129 | * Subscribe to device control topics on the main broker 130 | */ 131 | private subscribeToControlTopics(): void { 132 | const devices = this.deviceManager.getDevices(); 133 | 134 | for (const device of devices) { 135 | const topics = this.deviceManager.getDeviceTopics(device); 136 | if (topics) { 137 | // Subscribe to control topics to forward to proxy clients 138 | this.mainBrokerClient.subscribe(topics.deviceControlTopicOld, err => { 139 | if (err) { 140 | console.error(`Error subscribing to ${topics.deviceControlTopicOld}:`, err); 141 | } else { 142 | console.log(`MQTT Proxy subscribed to ${topics.deviceControlTopicOld}`); 143 | } 144 | }); 145 | 146 | this.mainBrokerClient.subscribe(topics.deviceControlTopicNew, err => { 147 | if (err) { 148 | console.error(`Error subscribing to ${topics.deviceControlTopicNew}:`, err); 149 | } else { 150 | console.log(`MQTT Proxy subscribed to ${topics.deviceControlTopicNew}`); 151 | } 152 | }); 153 | } 154 | } 155 | } 156 | 157 | /** 158 | * Handle messages received from the main broker 159 | */ 160 | private handleMainBrokerMessage(topic: string, message: Buffer): void { 161 | console.log(`MQTT Proxy received message from main broker on topic: ${topic}`); 162 | 163 | // Forward the message to all connected proxy clients 164 | this.aedesServer.publish( 165 | { 166 | cmd: 'publish', 167 | topic, 168 | payload: message, 169 | qos: 0, 170 | retain: false, 171 | dup: false, 172 | }, 173 | err => { 174 | if (err) { 175 | console.error(`Error forwarding message to proxy clients:`, err); 176 | } else { 177 | console.log(`Forwarded message to proxy clients on topic: ${topic}`); 178 | } 179 | }, 180 | ); 181 | } 182 | 183 | /** 184 | * Set up event handlers for the Aedes server 185 | */ 186 | private setupAedesEventHandlers(): void { 187 | this.aedesServer.on('client', client => { 188 | console.log(`Client ${client.id} connected to MQTT proxy`); 189 | this.connectedClients.add(client.id); 190 | }); 191 | 192 | this.aedesServer.on('clientDisconnect', client => { 193 | console.log(`Client ${client.id} disconnected from MQTT proxy`); 194 | this.connectedClients.delete(client.id); 195 | // Remove the client ID from our tracking set when client disconnects 196 | this.usedClientIds.delete(client.id); 197 | }); 198 | 199 | this.aedesServer.on('publish', (packet, client) => { 200 | if (client) { 201 | console.log( 202 | `MQTT Proxy received message from client ${client.id} on topic: ${packet.topic}`, 203 | ); 204 | 205 | // Forward the message to the main broker 206 | this.mainBrokerClient.publish( 207 | packet.topic, 208 | packet.payload, 209 | { 210 | qos: packet.qos, 211 | retain: packet.retain, 212 | }, 213 | err => { 214 | if (err) { 215 | console.error(`Error forwarding message to main broker:`, err); 216 | } else { 217 | console.log(`Forwarded message to main broker on topic: ${packet.topic}`); 218 | } 219 | }, 220 | ); 221 | } 222 | }); 223 | 224 | this.aedesServer.on('subscribe', (subscriptions, client) => { 225 | console.log(`Client ${client.id} subscribed to:`, subscriptions.map(s => s.topic).join(', ')); 226 | }); 227 | 228 | this.aedesServer.on('unsubscribe', (unsubscriptions, client) => { 229 | console.log(`Client ${client.id} unsubscribed from:`, unsubscriptions.join(', ')); 230 | }); 231 | } 232 | 233 | /** 234 | * Start the MQTT proxy server 235 | */ 236 | async start(): Promise { 237 | if (this.isRunning) { 238 | console.warn('MQTT Proxy is already running'); 239 | return; 240 | } 241 | 242 | return new Promise((resolve, reject) => { 243 | this.tcpServer.listen(this.config.port, (err?: Error) => { 244 | if (err) { 245 | console.error(`Failed to start MQTT Proxy on port ${this.config.port}:`, err); 246 | reject(err); 247 | return; 248 | } 249 | 250 | this.isRunning = true; 251 | console.log(`MQTT Proxy server started on port ${this.config.port}`); 252 | resolve(); 253 | }); 254 | }); 255 | } 256 | 257 | /** 258 | * Stop the MQTT proxy server 259 | */ 260 | async stop(): Promise { 261 | if (!this.isRunning) { 262 | console.warn('MQTT Proxy is not running'); 263 | return; 264 | } 265 | 266 | return new Promise(resolve => { 267 | // Close the main broker connection 268 | this.mainBrokerClient.end(); 269 | 270 | // Close the Aedes server 271 | this.aedesServer.close(() => { 272 | // Close the TCP server 273 | this.tcpServer.close(() => { 274 | this.isRunning = false; 275 | console.log('MQTT Proxy stopped'); 276 | resolve(); 277 | }); 278 | }); 279 | }); 280 | } 281 | 282 | /** 283 | * Get the number of connected clients 284 | */ 285 | getConnectedClientCount(): number { 286 | return this.connectedClients.size; 287 | } 288 | 289 | /** 290 | * Get the list of connected client IDs 291 | */ 292 | getConnectedClients(): string[] { 293 | return Array.from(this.connectedClients); 294 | } 295 | 296 | /** 297 | * Check if the proxy is running 298 | */ 299 | isProxyRunning(): boolean { 300 | return this.isRunning; 301 | } 302 | } 303 | -------------------------------------------------------------------------------- /src/parser.test.ts: -------------------------------------------------------------------------------- 1 | import { parseMessage } from './parser'; 2 | import { B2500V2DeviceData } from './types'; 3 | 4 | describe('MQTT Message Parser', () => { 5 | test('should parse comma-separated key-value pairs correctly', () => { 6 | // Sample message from the provided format 7 | const message = 8 | 'p1=1,p2=2,w1=0,w2=0,pe=14,vv=224,sv=3,cs=0,cd=0,am=0,o1=0,o2=0,do=90,lv=800,g1=0,g2=0,kn=2000'; 9 | const deviceType = 'HMA-1'; 10 | const deviceId = '12345'; 11 | 12 | const parsed = parseMessage(message, deviceType, deviceId); 13 | expect(parsed).toHaveProperty('data'); 14 | 15 | const result = parsed['data'] as B2500V2DeviceData; 16 | 17 | // Check the structure 18 | expect(result).toHaveProperty('deviceType', deviceType); 19 | expect(result).toHaveProperty('deviceId', deviceId); 20 | expect(result).toHaveProperty('timestamp'); 21 | expect(result).toHaveProperty('values'); 22 | 23 | // Check some values 24 | expect(result.values).toHaveProperty('p1', '1'); 25 | expect(result.values).toHaveProperty('p2', '2'); 26 | expect(result.values).toHaveProperty('pe', '14'); 27 | expect(result.values).toHaveProperty('vv', '224'); 28 | expect(result.values).toHaveProperty('lv', '800'); 29 | 30 | // Check enhanced fields 31 | expect(result).toHaveProperty('batteryPercentage', 14); 32 | expect(result).toHaveProperty('solarInputStatus'); 33 | expect(result.solarInputStatus).toHaveProperty('input1Charging', true); 34 | expect(result.solarInputStatus).toHaveProperty('input1PassThrough', false); 35 | expect(result.solarInputStatus).toHaveProperty('input2Charging', false); 36 | expect(result.solarInputStatus).toHaveProperty('input2PassThrough', true); 37 | expect(result).toHaveProperty('solarPower'); 38 | expect(result.solarPower).toHaveProperty('input1', 0); 39 | expect(result.solarPower).toHaveProperty('input2', 0); 40 | expect(result.deviceInfo).toHaveProperty('deviceVersion', 224); 41 | 42 | // Test with sv (subversion) included 43 | const messageWithSv = 44 | 'p1=0,p2=0,w1=0,w2=0,pe=14,vv=224,sv=3,cs=0,cd=0,lmo1=1377,lmi1=614,lmf=0,kn=313,do=90,o1=0,o2=0,am=0,g1=0,g2=0,b1=0,b2=0,md=0,d1=1,e1=0:0,f1=23:59,h1=800'; 45 | const parsedWithSv = parseMessage(messageWithSv, deviceType, deviceId); 46 | expect(parsedWithSv).toHaveProperty('data'); 47 | 48 | const resultWithSv = parsedWithSv['data'] as B2500V2DeviceData; 49 | expect(resultWithSv.deviceInfo).toHaveProperty('deviceSubversion', 3); 50 | }); 51 | 52 | test('should handle malformed input gracefully', () => { 53 | const message = 'key1=123,malformed,key3=45.67'; 54 | const result = parseMessage(message, 'TestDevice', '12345'); 55 | 56 | expect(result).toEqual({}); 57 | // The malformed part should be skipped 58 | }); 59 | 60 | test('should parse a full device message correctly', () => { 61 | // Full message example from documentation 62 | const message = 63 | 'p1=0,p2=0,w1=0,w2=0,pe=14,vv=224,sv=3,cs=0,cd=0,am=0,o1=0,o2=0,do=90,lv=800,cj=1,kn=313,g1=0,g2=0,b1=0,b2=0,md=0,d1=1,e1=0:0,f1=23:59,h1=800,d2=0,e2=0:0,f2=23:59,h2=200,d3=0,e3=0:0,f3=23:59,h3=800,sg=0,sp=80,st=0,tl=12,th=13,tc=0,tf=0,fc=202310231502,id=5,a0=14,a1=0,a2=0,l0=0,l1=0,c0=255,c1=4,bc=622,bs=512,pt=1552,it=1332,m0=0,m1=0,m2=0,m3=0,d4=0,e4=2:0,f4=23:59,h4=50,d5=0,e5=0:0,f5=23:59,h5=347,lmo=1377,lmi=614,lmf=0,uv=10'; 64 | const deviceType = 'HMA-1'; 65 | const deviceId = 'e88da6f35def'; 66 | 67 | const parsed = parseMessage(message, deviceType, deviceId); 68 | expect(parsed).toHaveProperty('data'); 69 | 70 | const result = parsed['data'] as B2500V2DeviceData; 71 | 72 | // Check basic fields 73 | expect(result).toHaveProperty('batteryPercentage', 14); 74 | expect(result).toHaveProperty('batteryCapacity', 313); 75 | expect(result.deviceInfo).toHaveProperty('fc42dVersion', '202310231502'); 76 | expect(result.deviceInfo).toHaveProperty('deviceIdNumber', 5); 77 | 78 | // Check temperature 79 | expect(result).toHaveProperty('temperature'); 80 | expect(result.temperature).toHaveProperty('min', 12); 81 | expect(result.temperature).toHaveProperty('max', 13); 82 | 83 | // Check time periods 84 | expect(result).toHaveProperty('timePeriods'); 85 | expect(result.timePeriods).toBeDefined(); 86 | expect(Array.isArray(result.timePeriods)).toBe(true); 87 | if (result.timePeriods && result.timePeriods[0]) { 88 | expect(result.timePeriods[0]).toHaveProperty('enabled', true); 89 | expect(result.timePeriods[0]).toHaveProperty('startTime', '00:00'); 90 | expect(result.timePeriods[0]).toHaveProperty('endTime', '23:59'); 91 | expect(result.timePeriods[0]).toHaveProperty('outputValue', 800); 92 | } 93 | 94 | // Check daily stats 95 | expect(result).toHaveProperty('dailyStats'); 96 | expect(result.dailyStats).toBeDefined(); 97 | if (result.dailyStats) { 98 | expect(result.dailyStats).toHaveProperty('batteryChargingPower', 622); 99 | expect(result.dailyStats).toHaveProperty('batteryDischargePower', 512); 100 | expect(result.dailyStats).toHaveProperty('photovoltaicChargingPower', 1552); 101 | expect(result.dailyStats).toHaveProperty('microReverseOutputPower', 1332); 102 | } 103 | 104 | // Check battery packs 105 | expect(result).toHaveProperty('batteryPacks'); 106 | expect(result.batteryPacks).toBeDefined(); 107 | if (result.batteryPacks) { 108 | expect(result.batteryPacks).toHaveProperty('pack1Connected', false); 109 | expect(result.batteryPacks).toHaveProperty('pack2Connected', false); 110 | } 111 | 112 | // Check solar input status 113 | expect(result).toHaveProperty('solarInputStatus'); 114 | expect(result.solarInputStatus).toBeDefined(); 115 | if (result.solarInputStatus) { 116 | expect(result.solarInputStatus).toHaveProperty('input1Charging', false); 117 | expect(result.solarInputStatus).toHaveProperty('input1PassThrough', false); 118 | expect(result.solarInputStatus).toHaveProperty('input2Charging', false); 119 | expect(result.solarInputStatus).toHaveProperty('input2PassThrough', false); 120 | } 121 | 122 | // Check output state 123 | expect(result).toHaveProperty('outputState'); 124 | expect(result.outputState).toBeDefined(); 125 | if (result.outputState) { 126 | expect(result.outputState).toHaveProperty('output1', false); 127 | expect(result.outputState).toHaveProperty('output2', false); 128 | } 129 | 130 | // Check rated power 131 | expect(result).toHaveProperty('ratedPower'); 132 | expect(result.ratedPower).toBeDefined(); 133 | if (result.ratedPower) { 134 | expect(result.ratedPower).toHaveProperty('output', 1377); 135 | expect(result.ratedPower).toHaveProperty('input', 614); 136 | expect(result.ratedPower).toHaveProperty('isLimited', false); 137 | } 138 | }); 139 | 140 | test('should handle message definitions correctly', () => { 141 | // Create a simple test message 142 | const message = 143 | 'pe=75,kn=500,lv=300,e1=0:0,do=90,p1=0,p2=0,w1=0,w2=0,vv=224,o1=0,o2=0,g1=0,g2=0'; 144 | const parsed = parseMessage(message, 'HMA-1', '12345'); 145 | expect(parsed).toHaveProperty('data'); 146 | 147 | const result = parsed['data'] as B2500V2DeviceData; 148 | 149 | // Check that the values were mapped correctly according to the definition 150 | expect(result).toHaveProperty('batteryPercentage', 75); 151 | expect(result).toHaveProperty('batteryCapacity', 500); 152 | expect(result).toHaveProperty('batteryOutputThreshold', 300); 153 | 154 | // Check time string transformation 155 | expect(result).toHaveProperty('timePeriods'); 156 | expect(Array.isArray(result.timePeriods)).toBe(true); 157 | expect(result.timePeriods?.[0]).toHaveProperty('startTime', '00:00'); 158 | }); 159 | 160 | test('should transform scene values correctly', () => { 161 | // Test scene transformation for different values 162 | const requiredKeys = 163 | 'pe=75,kn=500,lv=300,e1=0:0,do=90,p1=0,p2=0,w1=0,w2=0,vv=224,o1=0,o2=0,g1=0,g2=0'; 164 | const { data: dayScene } = parseMessage(`${requiredKeys},cj=0`, 'HMA-1', '12345'); 165 | expect(dayScene).toHaveProperty('scene', 'day'); 166 | 167 | const { data: nightScene } = parseMessage(`${requiredKeys},cj=1`, 'HMA-1', '12345'); 168 | expect(nightScene).toHaveProperty('scene', 'night'); 169 | 170 | const { data: duskScene } = parseMessage(`${requiredKeys},cj=2`, 'HMA-1', '12345'); 171 | expect(duskScene).toHaveProperty('scene', 'dusk'); 172 | 173 | const { data: unknownScene } = parseMessage(`${requiredKeys},cj=3`, 'HMA-1', '12345'); 174 | expect(unknownScene).toHaveProperty('scene', undefined); 175 | }); 176 | }); 177 | -------------------------------------------------------------------------------- /src/parser.ts: -------------------------------------------------------------------------------- 1 | import { B2500V2DeviceData } from './types'; 2 | import { 3 | KeyPath, 4 | BaseDeviceData, 5 | getDeviceDefinition, 6 | FieldDefinition, 7 | TypeAtPath, 8 | } from './deviceDefinition'; 9 | import { transformNumber } from './device/helpers'; 10 | 11 | /** 12 | * Parse the incoming MQTT message and transform it into the required format 13 | * 14 | * @param message - The raw message payload as a string (comma-separated key-value pairs) 15 | * @param deviceType - The device type extracted from the topic 16 | * @param deviceId - The device ID extracted from the topic 17 | * @returns The parsed data object 18 | */ 19 | export function parseMessage( 20 | message: string, 21 | deviceType: string, 22 | deviceId: string, 23 | ): Record { 24 | const deviceDefinition = getDeviceDefinition(deviceType); 25 | try { 26 | // Parse the comma-separated key-value pairs 27 | const pairs = message.split(','); 28 | const values: Record = {}; 29 | 30 | // Process each key-value pair 31 | for (const pair of pairs) { 32 | const [key, value] = pair.split('='); 33 | values[key] = value; 34 | } 35 | 36 | let result: Record = {}; 37 | for (const messageDefinition of deviceDefinition?.messages ?? []) { 38 | if (messageDefinition.isMessage(values)) { 39 | // Create the base parsed data object 40 | const parsedData: BaseDeviceData = { 41 | deviceType, 42 | deviceId, 43 | timestamp: new Date().toISOString(), 44 | values, 45 | }; 46 | 47 | // Apply the device status message definition 48 | applyMessageDefinition(parsedData, values, messageDefinition?.fields ?? []); 49 | result[messageDefinition.publishPath] = parsedData; 50 | } 51 | } 52 | 53 | return result; 54 | } catch (error) { 55 | console.error('Error parsing message:', error); 56 | throw new Error( 57 | `Failed to parse message: ${error instanceof Error ? error.message : String(error)}`, 58 | ); 59 | } 60 | } 61 | 62 | function applyMessageDefinition( 63 | parsedData: T, 64 | values: Record, 65 | fields: FieldDefinition>[], 66 | ): void { 67 | for (const field of fields) { 68 | let key = field.key; 69 | if (typeof key === 'string') { 70 | let transform = field.transform ?? transformNumber; 71 | let value = values[key]; 72 | if (value != null) { 73 | const transformedValue = transform(value); 74 | setValueAtPath(parsedData, field.path, transformedValue); 75 | } 76 | } else if (field.transform != null) { 77 | let entries = key.map(key => [key, values[key]] as const); 78 | if (entries.every(([, value]) => value !== undefined)) { 79 | const transformedValue = field.transform(Object.fromEntries(entries)); 80 | setValueAtPath(parsedData, field.path, transformedValue); 81 | } else { 82 | console.warn(`Some values are missing for field ${field.path.join('.')}`); 83 | } 84 | } else { 85 | console.warn(`No transform function provided for field ${field.path.join('.')}`); 86 | } 87 | } 88 | } 89 | 90 | /** 91 | * Set a value at a specific path in an object 92 | * 93 | * @param obj - The object to modify 94 | * @param path - The path to set the value at 95 | * @param value - The value to set 96 | */ 97 | function setValueAtPath(obj: T, path: KeyPath, value: any): void { 98 | let current = obj as any; 99 | 100 | // Navigate to the second-to-last element in the path 101 | for (let i = 0; i < path.length - 1; i++) { 102 | const key = path[i]; 103 | 104 | // Create the object if it doesn't exist 105 | if (current[key] === undefined) { 106 | // If the next key is a number or can be parsed as a number, create an array 107 | const nextKey = path[i + 1]; 108 | const isNextKeyNumeric = 109 | typeof nextKey === 'number' || 110 | (typeof nextKey === 'string' && !isNaN(parseInt(nextKey, 10))); 111 | current[key] = isNextKeyNumeric ? [] : {}; 112 | } 113 | 114 | current = current[key]; 115 | } 116 | 117 | // Set the value at the last path element 118 | const lastKey = path[path.length - 1]; 119 | current[lastKey] = value; 120 | } 121 | -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- 1 | import { BaseDeviceData } from './deviceDefinition'; 2 | 3 | type BatteryStatus = { 4 | // Host battery sign position (bit3:undervoltage, bit2:dod, bit1:charge, bit0:discharge) 5 | undervoltage: boolean; 6 | depthOfDischarge: boolean; 7 | charging: boolean; 8 | discharging: boolean; 9 | }; 10 | 11 | /** 12 | * Interface for command parameters 13 | */ 14 | export type CommandParams = Record; 15 | 16 | export type B2500Scene = 'day' | 'night' | 'dusk'; 17 | export type B2500V2SmartMeterStatus = 18 | | 'preparing1' 19 | | 'preparing2' 20 | | 'diagnosingEquipment' 21 | | 'diagnosingChannel' 22 | | 'diagnosisTimeout' 23 | | 'chargingInProgress' 24 | | 'unableToFindChannel' 25 | | 'notInDiagnosis'; 26 | export type B2500V1ChargingMode = 'chargeThenDischarge' | 'pv2PassThrough'; 27 | export type B2500V2ChargingMode = 'chargeDischargeSimultaneously' | 'chargeThenDischarge'; 28 | 29 | export interface B2500BaseDeviceData extends BaseDeviceData { 30 | // Battery information 31 | batteryPercentage?: number; 32 | batteryCapacity?: number; 33 | batteryOutputThreshold?: number; 34 | dischargeDepth?: number; 35 | 36 | // Solar input information 37 | solarInputStatus?: { 38 | input1Charging: boolean; 39 | input1PassThrough: boolean; 40 | input2Charging: boolean; 41 | input2PassThrough: boolean; 42 | }; 43 | solarPower?: { 44 | input1: number; 45 | input2: number; 46 | total: number; 47 | }; 48 | 49 | // Output state information 50 | outputState?: { 51 | output1: boolean; 52 | output2: boolean; 53 | }; 54 | outputPower?: { 55 | output1: number; 56 | output2: number; 57 | total: number; 58 | }; 59 | 60 | // Device information 61 | deviceInfo?: { 62 | deviceVersion?: number; 63 | deviceSubversion?: number; 64 | fc42dVersion?: string; 65 | deviceIdNumber?: number; 66 | bootloaderVersion?: number; 67 | }; 68 | 69 | // Temperature information 70 | temperature?: { 71 | min?: number; 72 | max?: number; 73 | chargingAlarm?: boolean; 74 | dischargeAlarm?: boolean; 75 | }; 76 | 77 | // Battery packs information 78 | batteryPacks?: { 79 | pack1Connected?: boolean; 80 | pack2Connected?: boolean; 81 | }; 82 | 83 | // Scene information (day/night/dusk) 84 | scene?: B2500Scene; 85 | 86 | // Output enabled states 87 | outputEnabled?: { 88 | output1?: boolean; 89 | output2?: boolean; 90 | }; 91 | 92 | // Battery capacities 93 | batteryCapacities?: { 94 | host?: number; // Host battery capacity 95 | extra1?: number; // Extra 1 battery capacity 96 | extra2?: number; // Extra 2 battery capacity 97 | }; 98 | 99 | // Battery status flags 100 | batteryStatus?: { 101 | host?: BatteryStatus; 102 | extra1?: BatteryStatus; 103 | extra2?: BatteryStatus; 104 | }; 105 | 106 | useFlashCommands: boolean; 107 | } 108 | 109 | export interface B2500V1DeviceData extends B2500BaseDeviceData { 110 | chargingMode?: B2500V1ChargingMode; 111 | } 112 | 113 | type CellVoltageInfo = { 114 | cells: number[]; 115 | min: number; 116 | max: number; 117 | diff: number; 118 | avg: number; 119 | }; 120 | 121 | export interface B2500CellData extends BaseDeviceData { 122 | cellVoltage?: { 123 | host?: CellVoltageInfo; 124 | extra1?: CellVoltageInfo; 125 | extra2?: CellVoltageInfo; 126 | }; 127 | } 128 | 129 | export interface B2500CalibrationData extends BaseDeviceData { 130 | charge?: number; 131 | discharge?: number; 132 | } 133 | 134 | export interface B2500V2DeviceData extends B2500BaseDeviceData { 135 | // Charging and discharging settings 136 | chargingMode?: B2500V2ChargingMode; 137 | adaptiveMode?: boolean; 138 | 139 | // Time periods for scheduled operations 140 | timePeriods?: Array<{ 141 | enabled: boolean; 142 | startTime: string; 143 | endTime: string; 144 | outputValue: number; 145 | }>; 146 | 147 | // Daily power statistics 148 | dailyStats?: { 149 | batteryChargingPower?: number; 150 | batteryDischargePower?: number; 151 | photovoltaicChargingPower?: number; 152 | microReverseOutputPower?: number; 153 | }; 154 | 155 | // CT information 156 | ctInfo?: { 157 | connected?: boolean; 158 | automaticPowerSize?: number; 159 | transmittedPower?: number; 160 | connectedPhase?: 0 | 1 | 2 | 'searching' | 'unknown'; 161 | status?: B2500V2SmartMeterStatus; 162 | phase1?: number; 163 | phase2?: number; 164 | phase3?: number; 165 | microInverterPower?: number; // Micro Inverter current real-time power 166 | }; 167 | 168 | // Power ratings 169 | ratedPower?: { 170 | output?: number; 171 | input?: number; 172 | isLimited?: boolean; 173 | }; 174 | 175 | // Surplus Feed-in state 176 | surplusFeedInEnabled?: boolean; 177 | } 178 | 179 | type SolarSocketData = { 180 | voltage?: number; 181 | current?: number; 182 | power?: number; 183 | }; 184 | 185 | export interface B2500V1CD16Data extends BaseDeviceData { 186 | input1?: SolarSocketData; 187 | input2?: SolarSocketData; 188 | output1?: SolarSocketData; 189 | output2?: SolarSocketData; 190 | } 191 | export interface B2500V2CD16Data extends BaseDeviceData { 192 | input1?: SolarSocketData; 193 | input2?: SolarSocketData; 194 | output1?: SolarSocketData; 195 | output2?: SolarSocketData; 196 | batteryData?: { 197 | host?: B2500V2BatteryData; 198 | extra1?: B2500V2BatteryData; 199 | extra2?: B2500V2BatteryData; 200 | }; 201 | } 202 | 203 | export interface B2500V2BatteryData { 204 | power: number; 205 | voltage: number; 206 | current: number; 207 | } 208 | 209 | /** 210 | * Interface for device configuration 211 | */ 212 | export interface Device { 213 | deviceType: string; 214 | deviceId: string; 215 | } 216 | 217 | /** 218 | * Interface for MQTT configuration 219 | */ 220 | export interface MqttConfig { 221 | brokerUrl: string; 222 | clientId: string; 223 | username?: string; 224 | password?: string; 225 | devices: Device[]; 226 | useFlashCommands?: boolean; 227 | responseTimeout?: number; // Timeout for device responses in milliseconds 228 | /** 229 | * Number of consecutive timeouts before marking a device as offline 230 | * (default: 3) 231 | */ 232 | allowedConsecutiveTimeouts?: number; 233 | } 234 | 235 | /** 236 | * Venus device working status types 237 | */ 238 | export type VenusWorkingStatus = 239 | | 'sleep' 240 | | 'standby' 241 | | 'charging' 242 | | 'discharging' 243 | | 'backup' 244 | | 'upgrading' 245 | | 'bypass'; 246 | 247 | /** 248 | * Venus device CT status types 249 | */ 250 | export type VenusCTStatus = 'notConnected' | 'connected' | 'weakSignal'; 251 | 252 | /** 253 | * Venus device battery working status types 254 | */ 255 | export type VenusBatteryWorkingStatus = 'notWorking' | 'charging' | 'discharging' | 'unknown'; 256 | 257 | const validVenusWorkingModes = ['automatic', 'manual', 'trading'] as const; 258 | /** 259 | * Venus device working mode types 260 | */ 261 | export type VenusWorkingMode = (typeof validVenusWorkingModes)[number]; 262 | 263 | export function isValidVenusWorkingMode(mode: string): mode is VenusWorkingMode { 264 | return validVenusWorkingModes.includes(mode as VenusWorkingMode); 265 | } 266 | 267 | /** 268 | * Venus device grid type 269 | */ 270 | export type VenusGridType = 271 | | 'adaptive' 272 | | 'en50549' 273 | | 'netherlands' 274 | | 'germany' 275 | | 'austria' 276 | | 'unitedKingdom' 277 | | 'spain' 278 | | 'poland' 279 | | 'italy' 280 | | 'china'; 281 | 282 | /** 283 | * Venus device CT type 284 | */ 285 | export type VenusCTType = 'none' | 'ct1' | 'ct2' | 'ct3' | 'shellyPro' | 'p1Meter'; 286 | 287 | /** 288 | * Venus device phase type 289 | */ 290 | export type VenusPhaseType = 'unknown' | 'phaseA' | 'phaseB' | 'phaseC' | 'notDetected'; 291 | 292 | /** 293 | * Venus device recharge mode 294 | */ 295 | export type VenusRechargeMode = 'singlePhase' | 'threePhase'; 296 | 297 | export type WeekdaySet = `${0 | ''}${1 | ''}${2 | ''}${3 | ''}${4 | ''}${5 | ''}${6 | ''}`; 298 | 299 | const venusValidVersionSets = ['800W', '2500W'] as const; 300 | export type VenusVersionSet = (typeof venusValidVersionSets)[number]; 301 | 302 | export function isValidVenusVersionSet(set: string): set is VenusVersionSet { 303 | return venusValidVersionSets.includes(set as VenusVersionSet); 304 | } 305 | 306 | /** 307 | * Venus time period configuration 308 | */ 309 | export interface VenusTimePeriod { 310 | startTime: string; 311 | endTime: string; 312 | weekday: WeekdaySet; 313 | power: number; 314 | enabled: boolean; 315 | } 316 | 317 | /** 318 | * Venus device data interface 319 | */ 320 | export interface VenusDeviceData extends BaseDeviceData { 321 | // Battery information 322 | batteryPercentage?: number; 323 | batteryCapacity?: number; 324 | 325 | // Power information 326 | totalChargingCapacity?: number; 327 | totalDischargeCapacity?: number; 328 | dailyChargingCapacity?: number; 329 | monthlyChargingCapacity?: number; 330 | dailyDischargeCapacity?: number; 331 | monthlyDischargeCapacity?: number; 332 | 333 | // Income information 334 | dailyIncome?: number; 335 | monthlyIncome?: number; 336 | totalIncome?: number; 337 | 338 | // Grid information 339 | offGridPower?: number; 340 | combinedPower?: number; 341 | workingStatus?: VenusWorkingStatus; 342 | 343 | // CT information 344 | ctStatus?: VenusCTStatus; 345 | 346 | // Battery status 347 | batteryWorkingStatus?: VenusBatteryWorkingStatus; 348 | batterySoc?: number; 349 | 350 | // Error codes 351 | errorCode?: number; 352 | warningCode?: number; 353 | 354 | // Device information 355 | deviceVersion?: number; 356 | gridType?: VenusGridType; 357 | workingMode?: VenusWorkingMode; 358 | 359 | // Time periods for scheduled operations 360 | timePeriods?: VenusTimePeriod[]; 361 | 362 | // Additional settings 363 | autoSwitchWorkingMode?: boolean; 364 | backupEnabled?: boolean; 365 | transactionRegionCode?: number; 366 | chargingPrice?: number; 367 | dischargePrice?: number; 368 | wifiSignalStrength?: number; 369 | versionSet?: VenusVersionSet; 370 | maxChargingPower?: number; 371 | maxDischargePower?: number; 372 | ctType?: VenusCTType; 373 | phaseType?: VenusPhaseType; 374 | rechargeMode?: VenusRechargeMode; 375 | bmsVersion?: number; 376 | communicationModuleVersion?: string; 377 | wifiName?: string; 378 | } 379 | 380 | export interface VenusBMSInfo extends BaseDeviceData { 381 | cells?: { 382 | voltages?: number[]; 383 | temperatures?: number[]; 384 | }; 385 | bms?: { 386 | version?: number; 387 | soc?: number; 388 | soh?: number; 389 | capacity?: number; 390 | voltage?: number; 391 | current?: number; 392 | temperature: number; 393 | chargeVoltage: number; 394 | fullChargeCapacity: number; 395 | cellCycle: number; 396 | error?: number; 397 | warning?: number; 398 | totalRuntime?: number; 399 | energyThroughput?: number; 400 | mosfetTemp?: number; 401 | }; 402 | } 403 | 404 | export interface JupiterTimePeriod { 405 | startTime: string; 406 | endTime: string; 407 | weekday: string; 408 | power: number; 409 | enabled: boolean; 410 | } 411 | 412 | export type JupiterBatteryWorkingStatus = 'keep' | 'charging' | 'discharging' | 'unknown'; 413 | 414 | const validJupiterWorkingModes = ['automatic', 'manual'] as const; 415 | export type JupiterWorkingMode = (typeof validJupiterWorkingModes)[number]; 416 | 417 | export function isValidJupiterWorkingMode(mode: string): mode is JupiterWorkingMode { 418 | return validJupiterWorkingModes.includes(mode as JupiterWorkingMode); 419 | } 420 | 421 | export interface JupiterDeviceData extends BaseDeviceData { 422 | dailyChargingCapacity?: number; // ele_d 423 | monthlyChargingCapacity?: number; // ele_m 424 | yearlyChargingCapacity?: number; // ele_y 425 | pv1Power?: number; // pv1_p 426 | pv2Power?: number; // pv2_p 427 | pv3Power?: number; // pv3_p 428 | pv4Power?: number; // pv4_p 429 | dailyDischargeCapacity?: number; // grd_d 430 | monthlyDischargeCapacity?: number; // grd_m 431 | combinedPower?: number; // grd_o 432 | workingStatus?: number; // grd_t 433 | ctStatus?: number; // gct_s 434 | batteryWorkingStatus?: JupiterBatteryWorkingStatus; // cel_s 435 | batteryEnergy?: number; // cel_p 436 | batterySoc?: number; // cel_c 437 | errorCode?: number; // err_t 438 | workingMode?: JupiterWorkingMode; // wor_m 439 | autoSwitchWorkingMode?: number; // cts_m 440 | httpServerType?: number; // htt_p 441 | wifiSignalStrength?: number; // wif_s 442 | ctType?: number; // ct_t 443 | phaseType?: number; // phase_t 444 | rechargeMode?: number; // dchrg 445 | wifiName?: string; // ssid 446 | deviceVersion?: number; // dev_n 447 | timePeriods?: JupiterTimePeriod[]; 448 | surplusFeedInEnabled?: boolean; // ful_d 449 | alarmCode?: number; // ala_c 450 | } 451 | 452 | export interface JupiterBMSInfo extends BaseDeviceData { 453 | cells?: { 454 | voltages?: number[]; // vol0-vol15 455 | temperatures?: number[]; // b_temp0-b_temp3 456 | }; 457 | bms?: { 458 | soc?: number; // soc 459 | soh?: number; // soh 460 | capacity?: number; // b_cap 461 | voltage?: number; // b_vol 462 | current?: number; // b_cur 463 | temperature?: number; // b_temp 464 | chargeVoltage?: number; // c_vol 465 | chargeCurrent?: number; // c_cur 466 | dischargeCurrent?: number; // d_cur 467 | error?: number; // b_err 468 | warning?: number; // b_war 469 | error2?: number; // b_err2 470 | warning2?: number; // b_war2 471 | cellFlag?: number; // c_flag 472 | statusFlag?: number; // s_flag 473 | bmsNumber?: number; // b_num 474 | mosfetTemp?: number; // mos_t 475 | envTemp?: number; // env_t 476 | }; 477 | } 478 | -------------------------------------------------------------------------------- /src/utils/crypt.test.ts: -------------------------------------------------------------------------------- 1 | import { calculateNewVersionTopicId, decryptNewVersionTopicId } from './crypt'; 2 | 3 | describe('crypt', () => { 4 | it.each` 5 | input | output 6 | ${'badbeefbadbe'} | ${'e6a1f1765cdd26ff05e2afcc5df17a9b'} 7 | ${'feeba7123456'} | ${'757a6deefc6ab2b3764d61e64fb2a931'} 8 | `('should encrypt "$input" to the expected hex string', ({ input, output }) => { 9 | const result = calculateNewVersionTopicId(input); 10 | expect(result).toBe(output); 11 | 12 | const decrypted = decryptNewVersionTopicId(result); 13 | expect(decrypted).toBe(input); 14 | }); 15 | }); 16 | -------------------------------------------------------------------------------- /src/utils/crypt.ts: -------------------------------------------------------------------------------- 1 | import * as crypto from 'crypto'; 2 | 3 | const key = Buffer.from('!@#$%^&*()_+{}[]'); 4 | const iv = Buffer.alloc(16, 0); 5 | export function calculateNewVersionTopicId(mac: string): string { 6 | const cipher = crypto.createCipheriv('aes-128-cbc', key, iv); 7 | const encrypted = Buffer.concat([cipher.update(mac, 'utf8'), cipher.final()]); 8 | return encrypted.toString('hex'); 9 | } 10 | 11 | export function decryptNewVersionTopicId(encrypted: string): string { 12 | const cipher = crypto.createDecipheriv('aes-128-cbc', key, iv); 13 | return Buffer.concat([cipher.update(Buffer.from(encrypted, 'hex')), cipher.final()]).toString( 14 | 'utf8', 15 | ); 16 | } 17 | -------------------------------------------------------------------------------- /test-addon.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # Create a simulated environment for testing the addon 4 | echo "Setting up test environment..." 5 | 6 | # Create config directory 7 | mkdir -p ./config 8 | 9 | # Set environment variables for the application 10 | export MQTT_BROKER_URL="mqtt://localhost:1883" 11 | export MQTT_CLIENT_ID="hm2mqtt-test" 12 | export MQTT_USERNAME="your_username" 13 | export MQTT_PASSWORD="your_password" 14 | export MQTT_POLLING_INTERVAL="60" 15 | export MQTT_RESPONSE_TIMEOUT="30" 16 | 17 | # Set device environment variables 18 | # Replace with your actual device information 19 | export DEVICE_0="HMA-1:your-device-id" 20 | 21 | echo "Environment variables set:" 22 | echo "MQTT_BROKER_URL: $MQTT_BROKER_URL" 23 | echo "MQTT_CLIENT_ID: $MQTT_CLIENT_ID" 24 | echo "MQTT_POLLING_INTERVAL: $MQTT_POLLING_INTERVAL seconds" 25 | echo "MQTT_RESPONSE_TIMEOUT: $MQTT_RESPONSE_TIMEOUT seconds" 26 | echo "DEVICE_0: $DEVICE_0" 27 | 28 | # Check if node and dist/index.js exist 29 | if ! command -v node &> /dev/null; then 30 | echo "Error: Node.js is not installed or not in PATH" 31 | exit 1 32 | fi 33 | 34 | if [ ! -f "./dist/index.js" ]; then 35 | echo "Error: Application file not found: ./dist/index.js" 36 | echo "Make sure you've built the application with 'npm run build'" 37 | exit 1 38 | fi 39 | 40 | # Start the application 41 | echo "Starting hm2mqtt..." 42 | node dist/index.js 43 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2020", 4 | "module": "commonjs", 5 | "outDir": "./dist", 6 | "rootDir": "./src", 7 | "strict": true, 8 | "esModuleInterop": true, 9 | "skipLibCheck": true, 10 | "forceConsistentCasingInFileNames": true 11 | }, 12 | "include": ["src/**/*"], 13 | "exclude": ["node_modules", "**/*.test.ts"] 14 | } 15 | --------------------------------------------------------------------------------