├── .clang-format ├── .github ├── FUNDING.yml ├── actions │ └── restore-python │ │ └── action.yml └── workflows │ ├── ci.yaml │ └── matchers │ ├── ci-custom.json │ ├── clang-tidy.json │ ├── esphome-config.json │ ├── gcc.json │ ├── lint-python.json │ └── python.json ├── .gitignore ├── .pre-commit-config.yaml ├── .yamllint ├── LICENSE ├── README.md ├── README.solax_meter_gateway.md ├── components ├── solax_meter_gateway │ ├── __init__.py │ ├── number │ │ ├── __init__.py │ │ ├── solax_number.cpp │ │ └── solax_number.h │ ├── sensor.py │ ├── solax_meter_gateway.cpp │ ├── solax_meter_gateway.h │ ├── switch │ │ ├── __init__.py │ │ ├── solax_switch.cpp │ │ └── solax_switch.h │ └── text_sensor.py ├── solax_meter_modbus │ ├── __init__.py │ ├── solax_meter_modbus.cpp │ └── solax_meter_modbus.h ├── solax_modbus │ ├── __init__.py │ ├── solax_modbus.cpp │ └── solax_modbus.h └── solax_x1_mini │ ├── __init__.py │ ├── sensor.py │ ├── solax_x1_mini.cpp │ ├── solax_x1_mini.h │ └── text_sensor.py ├── docs ├── Solax-X1-Mini-0.6-Specs.png ├── SolaxPower Single Phase External Communication Protocol - X1 Series V1.8.pdf ├── SolaxPower_Single_Phase_External_Communication_Protocol_-_X1_Series_V1.2.pdf ├── Solax_X1-MINI-G3_VDE4105_2018_AK-50492620-0001_DE-appendix21.pdf ├── X1-Mini-Install-Manual.pdf ├── X1-Mini-manual-with-CT.pdf └── pdus │ ├── solax-x1-mini-g1-config.txt │ ├── solax-x1-mini-g1-status.txt │ ├── solax-x1-mini-g2-status.txt │ └── solax-x1-mini-g3-status.txt ├── esp32-example-advanced-multiple-uarts.yaml ├── esp32-example.yaml ├── esp32-meter-gateway.yaml ├── esp8266-example.yaml ├── esp8266-meter-gateway-multiple-uarts.yaml ├── esp8266-meter-gateway.yaml ├── lovelace-entities-card.png ├── modbus-examples └── esp32-solax-x1-boost.yaml ├── setup.cfg ├── test-esp32.sh ├── test-esp8266.sh └── tests ├── esp32c6-compatibility-test.yaml ├── esp8266-dummy-receiver.yaml ├── esp8266-query-sdm230-floats.yaml └── esp8266-query-sdm230.yaml /.clang-format: -------------------------------------------------------------------------------- 1 | Language: Cpp 2 | AccessModifierOffset: -1 3 | AlignAfterOpenBracket: Align 4 | AlignConsecutiveAssignments: false 5 | AlignConsecutiveDeclarations: false 6 | AlignEscapedNewlines: DontAlign 7 | AlignOperands: true 8 | AlignTrailingComments: true 9 | AllowAllParametersOfDeclarationOnNextLine: true 10 | AllowShortBlocksOnASingleLine: false 11 | AllowShortCaseLabelsOnASingleLine: false 12 | AllowShortFunctionsOnASingleLine: All 13 | AllowShortIfStatementsOnASingleLine: false 14 | AllowShortLoopsOnASingleLine: false 15 | AlwaysBreakAfterReturnType: None 16 | AlwaysBreakBeforeMultilineStrings: false 17 | AlwaysBreakTemplateDeclarations: MultiLine 18 | BinPackArguments: true 19 | BinPackParameters: true 20 | BraceWrapping: 21 | AfterClass: false 22 | AfterControlStatement: false 23 | AfterEnum: false 24 | AfterFunction: false 25 | AfterNamespace: false 26 | AfterObjCDeclaration: false 27 | AfterStruct: false 28 | AfterUnion: false 29 | AfterExternBlock: false 30 | BeforeCatch: false 31 | BeforeElse: false 32 | IndentBraces: false 33 | SplitEmptyFunction: true 34 | SplitEmptyRecord: true 35 | SplitEmptyNamespace: true 36 | BreakBeforeBinaryOperators: None 37 | BreakBeforeBraces: Attach 38 | BreakBeforeInheritanceComma: false 39 | BreakInheritanceList: BeforeColon 40 | BreakBeforeTernaryOperators: true 41 | BreakConstructorInitializersBeforeComma: false 42 | BreakConstructorInitializers: BeforeColon 43 | BreakAfterJavaFieldAnnotations: false 44 | BreakStringLiterals: true 45 | ColumnLimit: 120 46 | CommentPragmas: "^ IWYU pragma:" 47 | CompactNamespaces: false 48 | ConstructorInitializerAllOnOneLineOrOnePerLine: true 49 | ConstructorInitializerIndentWidth: 4 50 | ContinuationIndentWidth: 4 51 | Cpp11BracedListStyle: true 52 | DerivePointerAlignment: true 53 | DisableFormat: false 54 | ExperimentalAutoDetectBinPacking: false 55 | FixNamespaceComments: true 56 | ForEachMacros: 57 | - foreach 58 | - Q_FOREACH 59 | - BOOST_FOREACH 60 | IncludeBlocks: Preserve 61 | IncludeCategories: 62 | - Regex: '^' 63 | Priority: 2 64 | - Regex: '^<.*\.h>' 65 | Priority: 1 66 | - Regex: "^<.*" 67 | Priority: 2 68 | - Regex: ".*" 69 | Priority: 3 70 | IncludeIsMainRegex: "([-_](test|unittest))?$" 71 | IndentCaseLabels: true 72 | IndentPPDirectives: None 73 | IndentWidth: 2 74 | IndentWrappedFunctionNames: false 75 | KeepEmptyLinesAtTheStartOfBlocks: false 76 | MacroBlockBegin: "" 77 | MacroBlockEnd: "" 78 | MaxEmptyLinesToKeep: 1 79 | NamespaceIndentation: None 80 | PenaltyBreakAssignment: 2 81 | PenaltyBreakBeforeFirstCallParameter: 1 82 | PenaltyBreakComment: 300 83 | PenaltyBreakFirstLessLess: 120 84 | PenaltyBreakString: 1000 85 | PenaltyBreakTemplateDeclaration: 10 86 | PenaltyExcessCharacter: 1000000 87 | PenaltyReturnTypeOnItsOwnLine: 2000 88 | PointerAlignment: Right 89 | RawStringFormats: 90 | - Language: Cpp 91 | Delimiters: 92 | - cc 93 | - CC 94 | - cpp 95 | - Cpp 96 | - CPP 97 | - "c++" 98 | - "C++" 99 | CanonicalDelimiter: "" 100 | BasedOnStyle: google 101 | - Language: TextProto 102 | Delimiters: 103 | - pb 104 | - PB 105 | - proto 106 | - PROTO 107 | EnclosingFunctions: 108 | - EqualsProto 109 | - EquivToProto 110 | - PARSE_PARTIAL_TEXT_PROTO 111 | - PARSE_TEST_PROTO 112 | - PARSE_TEXT_PROTO 113 | - ParseTextOrDie 114 | - ParseTextProtoOrDie 115 | CanonicalDelimiter: "" 116 | BasedOnStyle: google 117 | ReflowComments: true 118 | SortIncludes: false 119 | SortUsingDeclarations: false 120 | SpaceAfterCStyleCast: true 121 | SpaceAfterTemplateKeyword: false 122 | SpaceBeforeAssignmentOperators: true 123 | SpaceBeforeCpp11BracedList: false 124 | SpaceBeforeCtorInitializerColon: true 125 | SpaceBeforeInheritanceColon: true 126 | SpaceBeforeParens: ControlStatements 127 | SpaceBeforeRangeBasedForLoopColon: true 128 | SpaceInEmptyParentheses: false 129 | SpacesBeforeTrailingComments: 2 130 | SpacesInAngles: false 131 | SpacesInContainerLiterals: false 132 | SpacesInCStyleCastParentheses: false 133 | SpacesInParentheses: false 134 | SpacesInSquareBrackets: false 135 | Standard: Auto 136 | TabWidth: 2 137 | UseTab: Never 138 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | buy_me_a_coffee: syssi 2 | -------------------------------------------------------------------------------- /.github/actions/restore-python/action.yml: -------------------------------------------------------------------------------- 1 | name: Restore Python 2 | inputs: 3 | python-version: 4 | description: Python version to restore 5 | required: true 6 | type: string 7 | cache-key: 8 | description: Cache key to use 9 | required: true 10 | type: string 11 | outputs: 12 | python-version: 13 | description: Python version restored 14 | value: ${{ steps.python.outputs.python-version }} 15 | runs: 16 | using: "composite" 17 | steps: 18 | - name: Set up Python ${{ inputs.python-version }} 19 | id: python 20 | uses: actions/setup-python@v5.6.0 21 | with: 22 | python-version: ${{ inputs.python-version }} 23 | - name: Restore Python virtual environment 24 | id: cache-venv 25 | uses: actions/cache/restore@v4.2.3 26 | with: 27 | path: venv 28 | # yamllint disable-line rule:line-length 29 | key: ${{ runner.os }}-${{ steps.python.outputs.python-version }}-venv-${{ inputs.cache-key }} 30 | - name: Create Python virtual environment 31 | shell: bash 32 | run: | 33 | python -m venv venv 34 | source venv/bin/activate 35 | python --version 36 | cd esphome 37 | pip install -r requirements.txt -r requirements_test.txt 38 | pip install -e . 39 | -------------------------------------------------------------------------------- /.github/workflows/ci.yaml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: # yamllint disable-line rule:truthy 4 | push: 5 | branches: 6 | - main 7 | 8 | pull_request: 9 | schedule: 10 | - cron: 0 12 * * * 11 | 12 | permissions: 13 | contents: read 14 | 15 | env: 16 | FORCE_COLOR: 1 17 | DEFAULT_PYTHON: "3.10" 18 | PYUPGRADE_TARGET: "--py310-plus" 19 | 20 | concurrency: 21 | # yamllint disable-line rule:line-length 22 | group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} 23 | cancel-in-progress: true 24 | 25 | jobs: 26 | yamllint: 27 | runs-on: ubuntu-24.04 28 | steps: 29 | - uses: actions/checkout@v4 30 | - name: Run yamllint 31 | uses: frenck/action-yamllint@v1 32 | with: 33 | config: .yamllint 34 | 35 | bundle: 36 | name: Bundle external component and ESPHome 37 | runs-on: ubuntu-24.04 38 | outputs: 39 | repo-hash: ${{ github.sha }} 40 | steps: 41 | - name: Check out this project 42 | uses: actions/checkout@v4.1.7 43 | 44 | - name: Check out code from ESPHome project 45 | uses: actions/checkout@v4.1.7 46 | with: 47 | repository: esphome/esphome 48 | ref: dev 49 | path: esphome 50 | 51 | - name: Copy external component into the esphome project 52 | run: | 53 | cd esphome 54 | cp -r ../components/* esphome/components/ 55 | git config user.name "ci" 56 | git config user.email "ci@github.com" 57 | git add . 58 | git commit -a -m "Add external component" 59 | ln -sf ../venv venv 60 | 61 | - name: Archive prepared repository 62 | uses: pyTooling/upload-artifact@v4 63 | with: 64 | name: bundle 65 | path: . 66 | include-hidden-files: true 67 | retention-days: 1 68 | 69 | common: 70 | name: Create common environment 71 | runs-on: ubuntu-24.04 72 | needs: bundle 73 | outputs: 74 | cache-key: ${{ steps.cache-key.outputs.key }} 75 | steps: 76 | - name: Download prepared repository 77 | uses: pyTooling/download-artifact@v4 78 | with: 79 | name: bundle 80 | path: . 81 | - name: Update index to make "git diff-index" happy 82 | run: git update-index -q --really-refresh 83 | 84 | - name: Generate cache-key 85 | id: cache-key 86 | run: echo key="${{ hashFiles('esphome/requirements.txt', 'esphome/requirements_test.txt') }}" >> $GITHUB_OUTPUT 87 | - name: Set up Python ${{ env.DEFAULT_PYTHON }} 88 | id: python 89 | uses: actions/setup-python@v5.6.0 90 | with: 91 | python-version: ${{ env.DEFAULT_PYTHON }} 92 | - name: Restore Python virtual environment 93 | id: cache-venv 94 | uses: actions/cache@v4.2.3 95 | with: 96 | path: venv 97 | # yamllint disable-line rule:line-length 98 | key: ${{ runner.os }}-${{ steps.python.outputs.python-version }}-venv-${{ steps.cache-key.outputs.key }} 99 | 100 | - name: Create Python virtual environment 101 | if: steps.cache-venv.outputs.cache-hit != 'true' 102 | run: | 103 | python -m venv venv 104 | . venv/bin/activate 105 | python --version 106 | cd esphome 107 | pip install -r requirements.txt -r requirements_test.txt 108 | pip install -e . 109 | 110 | ruff: 111 | name: Check ruff 112 | runs-on: ubuntu-24.04 113 | needs: 114 | - bundle 115 | - common 116 | defaults: 117 | run: 118 | working-directory: esphome 119 | steps: 120 | - name: Download prepared repository 121 | uses: pyTooling/download-artifact@v4 122 | with: 123 | name: bundle 124 | path: . 125 | 126 | - name: Update index to make "git diff-index" happy 127 | run: git update-index -q --really-refresh 128 | 129 | - name: Restore Python 130 | uses: ./.github/actions/restore-python 131 | with: 132 | python-version: ${{ env.DEFAULT_PYTHON }} 133 | cache-key: ${{ needs.common.outputs.cache-key }} 134 | - name: Run Ruff 135 | run: | 136 | . venv/bin/activate 137 | ruff format esphome tests 138 | - name: Suggested changes 139 | run: script/ci-suggest-changes 140 | if: always() 141 | 142 | flake8: 143 | name: Check flake8 144 | runs-on: ubuntu-24.04 145 | needs: 146 | - bundle 147 | - common 148 | defaults: 149 | run: 150 | working-directory: esphome 151 | steps: 152 | - name: Download prepared repository 153 | uses: pyTooling/download-artifact@v4 154 | with: 155 | name: bundle 156 | path: . 157 | - name: Update index to make "git diff-index" happy 158 | run: git update-index -q --really-refresh 159 | 160 | - name: Restore Python 161 | uses: ./.github/actions/restore-python 162 | with: 163 | python-version: ${{ env.DEFAULT_PYTHON }} 164 | cache-key: ${{ needs.common.outputs.cache-key }} 165 | - name: Run flake8 166 | run: | 167 | . venv/bin/activate 168 | flake8 esphome 169 | - name: Suggested changes 170 | run: script/ci-suggest-changes 171 | if: always() 172 | 173 | pylint: 174 | name: Check pylint 175 | runs-on: ubuntu-24.04 176 | needs: 177 | - bundle 178 | - common 179 | defaults: 180 | run: 181 | working-directory: esphome 182 | steps: 183 | - name: Download prepared repository 184 | uses: pyTooling/download-artifact@v4 185 | with: 186 | name: bundle 187 | path: . 188 | - name: Update index to make "git diff-index" happy 189 | run: git update-index -q --really-refresh 190 | 191 | - name: Restore Python 192 | uses: ./.github/actions/restore-python 193 | with: 194 | python-version: ${{ env.DEFAULT_PYTHON }} 195 | cache-key: ${{ needs.common.outputs.cache-key }} 196 | - name: Run pylint 197 | run: | 198 | . venv/bin/activate 199 | pylint -f parseable --persistent=n esphome 200 | - name: Suggested changes 201 | run: script/ci-suggest-changes 202 | if: always() 203 | 204 | pyupgrade: 205 | name: Check pyupgrade 206 | runs-on: ubuntu-24.04 207 | needs: 208 | - bundle 209 | - common 210 | defaults: 211 | run: 212 | working-directory: esphome 213 | steps: 214 | - name: Download prepared repository 215 | uses: pyTooling/download-artifact@v4 216 | with: 217 | name: bundle 218 | path: . 219 | - name: Update index to make "git diff-index" happy 220 | run: git update-index -q --really-refresh 221 | 222 | - name: Restore Python 223 | uses: ./.github/actions/restore-python 224 | with: 225 | python-version: ${{ env.DEFAULT_PYTHON }} 226 | cache-key: ${{ needs.common.outputs.cache-key }} 227 | - name: Run pyupgrade 228 | run: | 229 | . venv/bin/activate 230 | pyupgrade ${{ env.PYUPGRADE_TARGET }} `find esphome -name "*.py" -type f` 231 | - name: Suggested changes 232 | run: script/ci-suggest-changes 233 | if: always() 234 | 235 | ci-custom: 236 | name: Run script/ci-custom 237 | runs-on: ubuntu-24.04 238 | needs: 239 | - bundle 240 | - common 241 | defaults: 242 | run: 243 | working-directory: esphome 244 | steps: 245 | - name: Download prepared repository 246 | uses: pyTooling/download-artifact@v4 247 | with: 248 | name: bundle 249 | path: . 250 | - name: Update index to make "git diff-index" happy 251 | run: git update-index -q --really-refresh 252 | 253 | - name: Restore Python 254 | uses: ./.github/actions/restore-python 255 | with: 256 | python-version: ${{ env.DEFAULT_PYTHON }} 257 | cache-key: ${{ needs.common.outputs.cache-key }} 258 | - name: Register matcher 259 | run: echo "::add-matcher::.github/workflows/matchers/ci-custom.json" 260 | 261 | - name: Do not suggest to move consts 262 | run: | 263 | sed -i 's#if len(uses) < 3:#if len(uses) < 8:#' script/ci-custom.py 264 | git update-index --assume-unchanged script/ci-custom.py 265 | 266 | - name: Run script/ci-custom 267 | run: | 268 | . ../venv/bin/activate 269 | script/ci-custom.py 270 | 271 | clang-format: 272 | name: Check clang-format 273 | runs-on: ubuntu-24.04 274 | needs: 275 | - bundle 276 | - common 277 | defaults: 278 | run: 279 | working-directory: esphome 280 | steps: 281 | - name: Download prepared repository 282 | uses: pyTooling/download-artifact@v4 283 | with: 284 | name: bundle 285 | path: . 286 | - name: Update index to make "git diff-index" happy 287 | run: git update-index -q --really-refresh 288 | 289 | - name: Restore Python 290 | uses: ./.github/actions/restore-python 291 | with: 292 | python-version: ${{ env.DEFAULT_PYTHON }} 293 | cache-key: ${{ needs.common.outputs.cache-key }} 294 | - name: Install clang-format 295 | run: | 296 | . venv/bin/activate 297 | pip install clang-format -c requirements_dev.txt 298 | - name: Run clang-format 299 | run: | 300 | . venv/bin/activate 301 | script/clang-format -i 302 | - name: Suggested changes 303 | run: script/ci-suggest-changes 304 | if: always() 305 | 306 | clang-tidy: 307 | name: ${{ matrix.name }} 308 | runs-on: ubuntu-24.04 309 | needs: 310 | - bundle 311 | - common 312 | defaults: 313 | run: 314 | working-directory: esphome 315 | strategy: 316 | fail-fast: false 317 | max-parallel: 2 318 | matrix: 319 | include: 320 | - id: clang-tidy 321 | name: Run script/clang-tidy for ESP8266 322 | options: --environment esp8266-arduino-tidy --grep USE_ESP8266 323 | pio_cache_key: tidyesp8266 324 | - id: clang-tidy 325 | name: Run script/clang-tidy for ESP32 Arduino 1/4 326 | options: --environment esp32-arduino-tidy --split-num 4 --split-at 1 327 | pio_cache_key: tidyesp32 328 | - id: clang-tidy 329 | name: Run script/clang-tidy for ESP32 Arduino 2/4 330 | options: --environment esp32-arduino-tidy --split-num 4 --split-at 2 331 | pio_cache_key: tidyesp32 332 | - id: clang-tidy 333 | name: Run script/clang-tidy for ESP32 Arduino 3/4 334 | options: --environment esp32-arduino-tidy --split-num 4 --split-at 3 335 | pio_cache_key: tidyesp32 336 | - id: clang-tidy 337 | name: Run script/clang-tidy for ESP32 Arduino 4/4 338 | options: --environment esp32-arduino-tidy --split-num 4 --split-at 4 339 | pio_cache_key: tidyesp32 340 | - id: clang-tidy 341 | name: Run script/clang-tidy for ESP32 IDF 342 | options: --environment esp32-idf-tidy --grep USE_ESP_IDF 343 | pio_cache_key: tidyesp32-idf 344 | 345 | steps: 346 | - name: Download prepared repository 347 | uses: pyTooling/download-artifact@v4 348 | with: 349 | name: bundle 350 | path: . 351 | - name: Update index to make "git diff-index" happy 352 | run: git update-index -q --really-refresh 353 | 354 | - name: Restore Python 355 | uses: ./.github/actions/restore-python 356 | with: 357 | python-version: ${{ env.DEFAULT_PYTHON }} 358 | cache-key: ${{ needs.common.outputs.cache-key }} 359 | 360 | - name: Cache platformio 361 | if: github.ref == 'refs/heads/dev' 362 | uses: actions/cache@v4.2.3 363 | with: 364 | path: ~/.platformio 365 | key: platformio-${{ matrix.pio_cache_key }} 366 | 367 | - name: Cache platformio 368 | if: github.ref != 'refs/heads/dev' 369 | uses: actions/cache/restore@v4.2.3 370 | with: 371 | path: ~/.platformio 372 | key: platformio-${{ matrix.pio_cache_key }} 373 | 374 | - name: Register problem matchers 375 | run: | 376 | echo "::add-matcher::.github/workflows/matchers/gcc.json" 377 | echo "::add-matcher::.github/workflows/matchers/clang-tidy.json" 378 | 379 | - name: Run 'pio run --list-targets -e esp32-idf-tidy' 380 | if: matrix.name == 'Run script/clang-tidy for ESP32 IDF' 381 | run: | 382 | . venv/bin/activate 383 | mkdir -p .temp 384 | pio run --list-targets -e esp32-idf-tidy 385 | 386 | - name: Run clang-tidy 387 | run: | 388 | . venv/bin/activate 389 | script/clang-tidy --all-headers --fix ${{ matrix.options }} ../components 390 | env: 391 | # Also cache libdeps, store them in a ~/.platformio subfolder 392 | PLATFORMIO_LIBDEPS_DIR: ~/.platformio/libdeps 393 | 394 | - name: Suggested changes 395 | run: script/ci-suggest-changes 396 | # yamllint disable-line rule:line-length 397 | if: always() 398 | 399 | esphome-config: 400 | name: Validate example configurations 401 | runs-on: ubuntu-24.04 402 | needs: 403 | - bundle 404 | - common 405 | steps: 406 | - name: Download prepared repository 407 | uses: pyTooling/download-artifact@v4 408 | with: 409 | name: bundle 410 | path: . 411 | 412 | - name: Restore Python 413 | uses: ./.github/actions/restore-python 414 | with: 415 | python-version: ${{ env.DEFAULT_PYTHON }} 416 | cache-key: ${{ needs.common.outputs.cache-key }} 417 | 418 | - name: Register matcher 419 | run: echo "::add-matcher::.github/workflows/matchers/esphome-config.json" 420 | 421 | - name: Validate example configurations 422 | run: | 423 | . venv/bin/activate 424 | echo -e "wifi_ssid: ssid\nwifi_password: password\nmqtt_host: host\nmqtt_username: username\nmqtt_password: password" > secrets.yaml 425 | for YAML in esp*.yaml; do 426 | esphome -s external_components_source components config $YAML >/dev/null 427 | done 428 | 429 | - name: Validate modbus example configurations 430 | run: | 431 | . venv/bin/activate 432 | echo -e "wifi_ssid: ssid\nwifi_password: password\nmqtt_host: host\nmqtt_username: username\nmqtt_password: password" > modbus-examples/secrets.yaml 433 | for YAML in modbus-examples/esp*.yaml; do 434 | esphome -s external_components_source ../components config $YAML >/dev/null 435 | done 436 | 437 | - name: Validate test configurations 438 | run: | 439 | . venv/bin/activate 440 | echo -e "wifi_ssid: ssid\nwifi_password: password\nmqtt_host: host\nmqtt_username: username\nmqtt_password: password" > tests/secrets.yaml 441 | for YAML in tests/esp*.yaml; do 442 | esphome -s external_components_source ../components config $YAML >/dev/null 443 | done 444 | 445 | esphome-compile: 446 | name: Build example configurations 447 | runs-on: ubuntu-24.04 448 | needs: 449 | - bundle 450 | - common 451 | steps: 452 | - name: Download prepared repository 453 | uses: pyTooling/download-artifact@v4 454 | with: 455 | name: bundle 456 | path: . 457 | 458 | - name: Restore Python 459 | uses: ./.github/actions/restore-python 460 | with: 461 | python-version: ${{ env.DEFAULT_PYTHON }} 462 | cache-key: ${{ needs.common.outputs.cache-key }} 463 | 464 | - name: Compile example configurations 465 | run: | 466 | . venv/bin/activate 467 | echo -e "wifi_ssid: ssid\nwifi_password: password\nmqtt_host: host\nmqtt_username: username\nmqtt_password: password" > secrets.yaml 468 | for YAML in esp*.yaml; do 469 | esphome -s external_components_source components compile $YAML >/dev/null 470 | done 471 | 472 | - name: Compile modbus example configurations 473 | run: | 474 | . venv/bin/activate 475 | echo -e "wifi_ssid: ssid\nwifi_password: password\nmqtt_host: host\nmqtt_username: username\nmqtt_password: password" > modbus-examples/secrets.yaml 476 | for YAML in modbus-examples/esp*.yaml; do 477 | esphome -s external_components_source ../components compile $YAML >/dev/null 478 | done 479 | 480 | - name: Compile test configurations 481 | run: | 482 | . venv/bin/activate 483 | echo -e "wifi_ssid: ssid\nwifi_password: password\nmqtt_host: host\nmqtt_username: username\nmqtt_password: password" > tests/secrets.yaml 484 | esphome -s external_components_source ../components compile tests/esp32c6-compatibility-test.yaml 485 | -------------------------------------------------------------------------------- /.github/workflows/matchers/ci-custom.json: -------------------------------------------------------------------------------- 1 | { 2 | "problemMatcher": [ 3 | { 4 | "owner": "ci-custom", 5 | "pattern": [ 6 | { 7 | "regexp": "^ERROR (.*):(\\d+):(\\d+) - (.*)$", 8 | "file": 1, 9 | "line": 2, 10 | "column": 3, 11 | "message": 4 12 | } 13 | ] 14 | } 15 | ] 16 | } 17 | -------------------------------------------------------------------------------- /.github/workflows/matchers/clang-tidy.json: -------------------------------------------------------------------------------- 1 | { 2 | "problemMatcher": [ 3 | { 4 | "owner": "clang-tidy", 5 | "pattern": [ 6 | { 7 | "regexp": "^(.*):(\\d+):(\\d+):\\s+(error):\\s+(.*) \\[([a-z0-9,\\-]+)\\]\\s*$", 8 | "file": 1, 9 | "line": 2, 10 | "column": 3, 11 | "severity": 4, 12 | "message": 5 13 | } 14 | ] 15 | } 16 | ] 17 | } 18 | -------------------------------------------------------------------------------- /.github/workflows/matchers/esphome-config.json: -------------------------------------------------------------------------------- 1 | { 2 | "problemMatcher": [ 3 | { 4 | "owner": "esphome-config", 5 | "severity": "warning", 6 | "pattern": [ 7 | { 8 | "regexp": "^WARNING Using `([^`]+)` is deprecated and will be removed(.*)$", 9 | "message": 1 10 | } 11 | ] 12 | } 13 | ] 14 | } 15 | -------------------------------------------------------------------------------- /.github/workflows/matchers/gcc.json: -------------------------------------------------------------------------------- 1 | { 2 | "problemMatcher": [ 3 | { 4 | "owner": "gcc", 5 | "severity": "error", 6 | "pattern": [ 7 | { 8 | "regexp": "^(.*):(\\d+):(\\d+):\\s+(?:fatal\\s+)?(warning|error):\\s+(.*)$", 9 | "file": 1, 10 | "line": 2, 11 | "column": 3, 12 | "severity": 4, 13 | "message": 5 14 | } 15 | ] 16 | } 17 | ] 18 | } 19 | -------------------------------------------------------------------------------- /.github/workflows/matchers/lint-python.json: -------------------------------------------------------------------------------- 1 | { 2 | "problemMatcher": [ 3 | { 4 | "owner": "flake8", 5 | "severity": "error", 6 | "pattern": [ 7 | { 8 | "regexp": "^(.*):(\\d+) - ([EFCDNW]\\d{3}.*)$", 9 | "file": 1, 10 | "line": 2, 11 | "message": 3 12 | } 13 | ] 14 | }, 15 | { 16 | "owner": "pylint", 17 | "severity": "error", 18 | "pattern": [ 19 | { 20 | "regexp": "^(.*):(\\d+) - (\\[[EFCRW]\\d{4}\\(.*\\),.*\\].*)$", 21 | "file": 1, 22 | "line": 2, 23 | "message": 3 24 | } 25 | ] 26 | } 27 | ] 28 | } 29 | -------------------------------------------------------------------------------- /.github/workflows/matchers/python.json: -------------------------------------------------------------------------------- 1 | { 2 | "problemMatcher": [ 3 | { 4 | "owner": "python", 5 | "pattern": [ 6 | { 7 | "regexp": "^\\s*File\\s\\\"(.*)\\\",\\sline\\s(\\d+),\\sin\\s(.*)$", 8 | "file": 1, 9 | "line": 2 10 | }, 11 | { 12 | "regexp": "^\\s*raise\\s(.*)\\(\\'(.*)\\'\\)$", 13 | "message": 2 14 | } 15 | ] 16 | } 17 | ] 18 | } 19 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .idea/ 2 | secrets.yaml 3 | .esphome/ 4 | **/.pioenvs/ 5 | **/.piolibdeps/ 6 | **/lib/ 7 | **/src/ 8 | **/partitions.csv 9 | 10 | __pycache__/ 11 | *.py[cod] 12 | *$py.class 13 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | # See https://pre-commit.com for more information 2 | # See https://pre-commit.com/hooks.html for more hooks 3 | # See https://github.com/rytilahti/python-miio/blob/master/.pre-commit-config.yaml 4 | repos: 5 | - repo: https://github.com/pre-commit/mirrors-isort 6 | rev: v5.10.1 7 | hooks: 8 | - id: isort 9 | - repo: https://github.com/astral-sh/ruff-pre-commit 10 | # Ruff version. 11 | rev: v0.5.5 12 | hooks: 13 | # Run the linter. 14 | - id: ruff 15 | args: [--fix] 16 | # Run the formatter. 17 | - id: ruff-format 18 | - repo: https://github.com/psf/black-pre-commit-mirror 19 | rev: 24.4.2 20 | hooks: 21 | - id: black 22 | args: 23 | - --safe 24 | - --quiet 25 | - repo: https://github.com/PyCQA/flake8 26 | rev: 7.1.0 27 | hooks: 28 | - id: flake8 29 | additional_dependencies: 30 | - flake8-docstrings==1.5.0 31 | - pydocstyle==5.1.1 32 | - repo: https://github.com/asottile/pyupgrade 33 | rev: v3.19.1 34 | hooks: 35 | - id: pyupgrade 36 | args: [--py310-plus] 37 | - repo: https://github.com/adrienverge/yamllint.git 38 | rev: v1.35.1 39 | hooks: 40 | - id: yamllint 41 | - repo: https://github.com/pre-commit/mirrors-clang-format 42 | rev: v13.0.1 43 | hooks: 44 | - id: clang-format 45 | types_or: [c, c++] 46 | -------------------------------------------------------------------------------- /.yamllint: -------------------------------------------------------------------------------- 1 | extends: default 2 | 3 | yaml-files: 4 | - '*.yaml' 5 | - '*.yml' 6 | - '.yamllint' 7 | 8 | ignore: | 9 | .clang-format 10 | .esphome/ 11 | tests/.esphome/ 12 | 13 | rules: 14 | braces: 15 | level: error 16 | min-spaces-inside: 0 17 | max-spaces-inside: 1 18 | min-spaces-inside-empty: -1 19 | max-spaces-inside-empty: -1 20 | brackets: 21 | level: error 22 | min-spaces-inside: 0 23 | max-spaces-inside: 0 24 | min-spaces-inside-empty: -1 25 | max-spaces-inside-empty: -1 26 | colons: 27 | level: error 28 | max-spaces-before: 0 29 | max-spaces-after: 1 30 | commas: 31 | level: error 32 | max-spaces-before: 0 33 | min-spaces-after: 1 34 | max-spaces-after: 1 35 | comments: 36 | level: error 37 | require-starting-space: true 38 | min-spaces-from-content: 2 39 | comments-indentation: disable 40 | document-end: 41 | level: error 42 | present: false 43 | document-start: 44 | level: error 45 | present: false 46 | empty-lines: 47 | level: error 48 | max: 2 49 | max-start: 0 50 | max-end: 1 51 | hyphens: 52 | level: error 53 | max-spaces-after: 1 54 | indentation: 55 | level: error 56 | spaces: 2 57 | indent-sequences: true 58 | check-multi-line-strings: false 59 | key-duplicates: 60 | level: error 61 | line-length: disable 62 | new-line-at-end-of-file: 63 | level: error 64 | new-lines: 65 | level: error 66 | type: unix 67 | trailing-spaces: 68 | level: error 69 | truthy: 70 | level: error 71 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright [yyyy] [name of copyright owner] 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # esphome-solax-x1-mini 2 | 3 | ![GitHub actions](https://github.com/syssi/esphome-solax-x1-mini/actions/workflows/ci.yaml/badge.svg) 4 | ![GitHub stars](https://img.shields.io/github/stars/syssi/esphome-solax-x1-mini) 5 | ![GitHub forks](https://img.shields.io/github/forks/syssi/esphome-solax-x1-mini) 6 | ![GitHub watchers](https://img.shields.io/github/watchers/syssi/esphome-solax-x1-mini) 7 | [!["Buy Me A Coffee"](https://img.shields.io/badge/buy%20me%20a%20coffee-donate-yellow.svg)](https://www.buymeacoffee.com/syssi) 8 | 9 | ESPHome component to monitor a Solax X1 mini via RS485. 10 | 11 | ![Lovelace entities card](lovelace-entities-card.png "lovelace entities card") 12 | 13 | ## Supported devices 14 | 15 | * SolaX X1 Mini 16 | - SolaX X1 Mini X1-0.6-S-D(L) 17 | * SolaX X1 Mini G2 18 | - SolaX X1 Mini X1-1.5-S-D(L) (master version 1.08, manager version 1.07) (reported by [@beocycris](https://github.com/syssi/esphome-solax-x1-mini/issues/18#issuecomment-1073188868)) 19 | - SolaX X1 Mini X1-2.0-S-D(L) (master version 1.08, manager version 1.07) (reported by [@zcloud-at](https://github.com/syssi/esphome-solax-x1-mini/issues/15)) 20 | * SolaX X1 Mini G3 21 | - SolaX X1 Mini X1-0.6-S-D(L) (master version 1.08, manager version 1.07) (reported by [@neujbit](https://github.com/syssi/esphome-solax-x1-mini/issues/22)) 22 | 23 | ## Requirements 24 | 25 | * [ESPHome 2024.6.0 or higher](https://github.com/esphome/esphome/releases). 26 | * One half of an ethernet cable with RJ45 connector 27 | * RS485-to-TTL module (`HW-0519` f.e.) 28 | * Generic ESP32 or ESP8266 board 29 | 30 | ## Schematics 31 | 32 | #### RS485-TTL module without flow control pin 33 | 34 | ``` 35 | RS485 UART 36 | ┌─────────┐ ┌─────────────┐ ┌─────────────────┐ 37 | │ │ │ GND│<--------->│GND │ 38 | │ Solax │<-----B- ---->│ RS485 RXD│<--------->│RX ESP32/ │ 39 | │ X1 Mini │<---- A+ ---->│ to TTL TXD│<--------->│TX ESP8266 │ 40 | │ │<--- GND ---->│ module VCC│<--------->│3.3V VCC│<-- 41 | │ │ │ │ │ GND│<-- 42 | └─────────┘ └─────────────┘ └─────────────────┘ 43 | 44 | ``` 45 | 46 | #### RS485-TTL module with flow control pin 47 | 48 | ``` 49 | RS485 UART 50 | ┌─────────┐ ┌─────────────┐ ┌─────────────────┐ 51 | │ │ │ DI│<--------->│TX │ 52 | │ Solax │<-----B- ---->│ RS485 DE│<--\ │ ESP32/ │ 53 | │ X1 Mini │<---- A+ ---->│ to TTL RE│<---+----->│GPIO0 ESP8266 │ 54 | │ │<--- GND ---->│ module RO│<--------->│RX │ 55 | │ │ │ │ │ │ 56 | │ │ │ VCC│<--------->│3.3V VCC│<-- 57 | │ │ │ GND│<--------->│GND GND│<-- 58 | └─────────┘ └─────────────┘ └─────────────────┘ 59 | 60 | ``` 61 | 62 | Please make sure to power the RS485 module with 3.3V because it affects the TTL (transistor-transistor logic) voltage between RS485 module and ESP. 63 | 64 | ### X1 Mini RJ45 connector 65 | 66 | | Pin | Purpose | RS485-to-TTL pin | Color T-568B | 67 | | :-----: | :----------- | :---------------- | ------------ | 68 | | 1 | RefGen | | | 69 | | 2 | Com/DRM0 | | | 70 | | 3 | GND_COM | | | 71 | | 4 | **A+** | **A+** | Blue | 72 | | 5 | **B-** | **B-** | Blue-White | 73 | | 6 | E_Stop | | | 74 | | 7 | **GND_COM** | **GND** | Brown-White | 75 | | 8 | -- | | | 76 | 77 | ## Installation 78 | 79 | You can install this component with [ESPHome external components feature](https://esphome.io/components/external_components.html) like this: 80 | ```yaml 81 | external_components: 82 | - source: github://syssi/esphome-solax-x1-mini@main 83 | ``` 84 | 85 | or just use the `esp32-example.yaml` / `esp8266-example.yaml` as proof of concept: 86 | 87 | ```bash 88 | # Install esphome 89 | pip3 install esphome 90 | 91 | # Clone this external component 92 | git clone https://github.com/syssi/esphome-solax-x1-mini.git 93 | cd esphome-solax-x1-mini 94 | 95 | # Create a secrets.yaml containing some setup specific secrets 96 | cat > secrets.yaml < AA.55.01.00.00.00.10.00.00.01.10 (11) 152 | [VV][solax_modbus:084]: RX <- AA.55.00.FF.01.00.10.80.0E.31.32.33.34.35.36.37.37.36.35.34.33.32.31.05.75 (25) 153 | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ 154 | [I][solax_modbus:105]: Inverter discovered. Serial number: 3132333435363737363534333231 155 | 156 | # Assign address (0x0A) to the inverter via serial number (0x10, 0x01) 157 | [VV][solax_modbus:200]: TX -> AA.55.00.00.00.00.10.01.0F.31.32.33.34.35.36.37.37.36.35.34.33.32.31.0A.04.01 (26) 158 | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ 159 | Byte 0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 160 | 161 | # Register address confirmation (0x10, 0x81) 162 | [VV][solax_modbus:084]: RX <- AA.55.00.0A.00.00.10.81.01.06.01.A1 (12) 163 | ^ 164 | ACK 165 | ``` 166 | 167 | ### Request device infos 168 | 169 | ``` 170 | # Request info (0x11 0x03) 171 | [18:13:12][VV][solax_modbus:214]: TX -> AA.55.01.00.00.0A.11.03.00.01.1E (11) 172 | 173 | # Response (0x11 0x83) 174 | [18:13:12][VV][solax_modbus:084]: RX <- AA.55.00.0A.01.00.11.83.3A.01.00.00.00.00.00.00.56.31.2E.30.30.20.20.20.20.20.20.20.20.20.20.20.20.20.20.73.6F.6C.61.78.20.20.20.20.20.20.20.20.20.58.4D.55.30.36.32.47.43.30.39.33.35.34.30.33.36.30.30.0C.0F (69) 175 | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ 176 | 177 | Data 0 (device type?): 0x01 (single phase) 178 | Data 1...6 (rated power): 0x00 0x00 0x00 0x00 0x00 0x00 () 179 | Data 7...11 (firmware version): 0x56 0x31 0x2E 0x30 0x30 (V1.00) 180 | Data 12...25 (module name): 0x20 0x20 0x20 0x20 0x20 0x20 0x20 0x20 0x20 0x20 0x20 0x20 0x20 0x20 ( ) 181 | Data 26...39 (factory name): 0x73 0x6F 0x6C 0x61 0x78 0x20 0x20 0x20 0x20 0x20 0x20 0x20 0x20 0x20 (solax ) 182 | Data 40...53 (serial number): 0x58 0x4D 0x55 0x30 0x36 0x32 0x47 0x43 0x30 0x39 0x33 0x35 0x34 0x30 (XMU062GC093540) 183 | Data 54...57 (rated bus voltage): 0x33 0x36 0x30 0x30 (3600) 184 | ``` 185 | 186 | ### Request live data 187 | 188 | ``` 189 | # Request live data (0x11 0x02) 190 | [18:15:15][VV][solax_modbus:214]: TX -> AA.55.01.00.00.0A.11.02.00.01.1D (11) 191 | 192 | # Response (0x11 0x82) 193 | [18:15:15][VV][solax_modbus:214]: RX <- 194 | AA.55.00.0A.01.00.11.82.34.00.19.00.01.02.46.00.00.00.0A.00.00.00.05.09.13.13.87.00.32.FF.FF.00.00.00.11.00.00.00.14.00.02.00.00.00.00.00.00.00.00.00.00.00.00.00.00.00.00.00.00.02.D0.06.21 (63) 195 | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ 196 | 197 | Data 0...1 (temperature): 0x00 0x19 (25 C) 198 | Data 2...3 (energy today): 0x00 0x01 (0.1 kWh) 199 | Data 4...5 (pv1 voltage): 0x02 0x46 (58.2 V) 200 | Data 6...7 (pv2 voltage): 0x00 0x00 (0.0 V) 201 | Data 8...9 (pv1 current): 0x00 0x0A (1.0 A) 202 | Data 10...11 (pv2 current): 0x00 0x00 (0.0 A) 203 | Data 12...13 (ac current): 0x00 0x05 (0.5 A) 204 | Data 14...15 (ac voltage): 0x09 0x13 (232.3V) 205 | Data 16...17 (ac frequency): 0x13 0x87 (49.99 Hz) 206 | Data 18...19 (ac power): 0x00 0x32 (50 W) 207 | Data 20...21 (unused): 0xFF 0xFF 208 | Data 22...25 (energy total): 0x00 0x00 0x00 0x11 (0.1 kWh) 209 | Data 26...29 (runtime total): 0x00 0x00 0x00 0x14 (20 h) 210 | Data 30...31 (mode): 0x00 0x02 (2: Normal) 211 | Data 32...33 (grid voltage fault): 0x00 0x00 (0.0 V) 212 | Data 34...35 (grid freq. fault) 0x00 0x00 (0.00 Hz) 213 | Data 36...37 (dc injection fault): 0x00 0x00 (0 mA) 214 | Data 38...39 (temperature fault): 0x00 0x00 (0 °C) 215 | Data 40...41 (pv1 voltage fault): 0x00 0x00 (0 V) 216 | Data 42...43 (pv2 voltage fault): 0x00 0x00 (0 V) 217 | Data 44...45 (gfc fault): 0x00 0x00 (0 mA) 218 | Data 46...49 (error message): 0x00 0x00 0x00 0x00 (No error) 219 | Data 50...52 (ct pgrid): 0x02 0xD0 (720 W) 220 | 221 | ``` 222 | 223 | ## References 224 | 225 | * https://github.com/JensJordan/solaXd/ 226 | * https://github.com/arendst/Tasmota/blob/development/tasmota/xnrg_12_solaxX1.ino 227 | * https://github.com/esphome/esphome/blob/dev/esphome/components/modbus/modbus.cpp 228 | * https://github.com/syssi/esphome-solax-x1-mini/blob/main/docs/SolaxPower_Single_Phase_External_Communication_Protocol_-_X1_Series_V1.2.pdf 229 | -------------------------------------------------------------------------------- /README.solax_meter_gateway.md: -------------------------------------------------------------------------------- 1 | # Solax Meter Gateway Guide for Zero Export Control 2 | 3 | ## Introduction 4 | 5 | The Solax Meter Gateway is an ESPHome-based project that enables zero export control for Solax inverters via RS485. This system dynamically adjusts the inverter's power output based on real-time power consumption data from a smart meter, ensuring that no excess power is fed back into the grid. 6 | 7 | ## Background 8 | 9 | The primary function of this setup is to achieve "zero export" or "zero feed-in" to the grid. It works by: 10 | 11 | 1. Continuously monitoring the current power consumption from a smart meter. 12 | 2. Adjusting the Solax inverter's power output to match the household consumption. 13 | 3. Preventing any excess solar power from being exported to the grid. 14 | 15 | This approach optimizes self-consumption of solar energy and complies with regulations that restrict or prohibit feeding power back into the grid. 16 | 17 | ## Key Features 18 | 19 | - Acts as a drop-in replacement for SDM230-MID-SOLAX (SDM230 with Solax firmware). 20 | - Compatible with Solax inverters that have the "export control: meter enabled" setting. 21 | - Responds to the inverter's periodic requests for current power consumption. 22 | - Retrieves sensor measurements from another sensor/device to provide consumption data. 23 | - Potentially supports a wider range of Solax inverters beyond the X1 series. 24 | 25 | ## Requirements 26 | 27 | - A Solax inverter with export control capability 28 | - An ESP8266 or ESP32 board (e.g., Wemos D1 Mini) 29 | - A RS485 to TTL converter module 30 | - A smart meter or power monitoring device to provide real-time consumption data 31 | - Basic soldering skills and tools 32 | - A computer with ESPHome installed 33 | 34 | ## Hardware Setup 35 | 36 | 1. Connect the RS485 to TTL converter to your ESP board: 37 | - VCC to 3.3V 38 | - GND to GND 39 | - RX to TX pin (default GPIO4) 40 | - TX to RX pin (default GPIO5) 41 | 42 | 2. Connect the RS485 side to your Solax inverter: 43 | - A+ to pin 4 of the RJ45 connector 44 | - B- to pin 5 of the RJ45 connector 45 | - GND to pin 7 of the RJ45 connector 46 | 47 | 3. Ensure you have access to the power consumption data from your smart meter or power monitoring device. 48 | 49 | ## Software Setup 50 | 51 | 1. Install ESPHome if you haven't already: `pip install esphome` 52 | 53 | 2. Create a new ESPHome project or use the provided YAML configuration. 54 | 55 | 3. Modify the YAML configuration: 56 | - Set your Wi-Fi credentials 57 | - Adjust MQTT settings if you're using MQTT 58 | - Modify pins if necessary 59 | - Configure the data source for power consumption (e.g., MQTT topic of the smart meter) 60 | 61 | 4. Flash the ESP board with the ESPHome configuration. 62 | 63 | ## Inverter Configuration 64 | 65 | To enable the "export control: meter" mode on your Solax inverter: 66 | 67 | 1. Navigate to Settings 68 | 2. Enter PIN: 6868 69 | 3. Go to "Export control" 70 | 4. Select "Meter enable" 71 | 72 | ## Usage 73 | 74 | Once set up, the Solax Meter Gateway provides several features: 75 | 76 | 1. **Power Monitoring**: It reads the instantaneous power consumption from your smart meter or power monitoring device. 77 | 78 | 2. **Dynamic Power Adjustment**: Based on the current consumption, it adjusts the inverter's power output to achieve zero export. 79 | 80 | 3. **Operation Mode**: You can see the current operation mode (Auto, Manual, Off). 81 | 82 | 4. **Manual Control**: You can switch to manual mode and set a specific power demand. 83 | 84 | 5. **Emergency Power Off**: A safety feature to quickly shut off the inverter. 85 | 86 | 6. **Automatic Inactivity Detection**: It will trigger a fault if no data is received for a set period. 87 | 88 | Access these features through your home automation system (e.g., Home Assistant) or MQTT, depending on your setup. 89 | 90 | ## Troubleshooting 91 | 92 | - Ensure all connections are correct and secure. 93 | - Check the ESPHome logs for any error messages. 94 | - Verify that your inverter model is supported and correctly configured for export control. 95 | - If you encounter issues, increase the log level in the YAML configuration for more detailed information. 96 | - Verify that the power consumption data is being received correctly from your monitoring device. 97 | 98 | ## Conclusion 99 | 100 | The Solax Meter Gateway provides a flexible and powerful interface for zero export control with Solax inverters. It can be used with various Solax models that support the export control feature, potentially extending beyond the X1 series. By accurately matching your solar power production to your current consumption, you can optimize self-consumption and ensure compliance with grid feed-in regulations. 101 | -------------------------------------------------------------------------------- /components/solax_meter_gateway/__init__.py: -------------------------------------------------------------------------------- 1 | import esphome.codegen as cg 2 | from esphome.components import sensor, solax_meter_modbus 3 | import esphome.config_validation as cv 4 | from esphome.const import CONF_ID 5 | 6 | CODEOWNERS = ["@syssi"] 7 | 8 | DEPENDENCIES = ["solax_meter_modbus"] 9 | AUTO_LOAD = ["number", "sensor", "switch", "text_sensor"] 10 | MULTI_CONF = True 11 | 12 | CONF_SOLAX_METER_GATEWAY_ID = "solax_meter_gateway_id" 13 | CONF_POWER_ID = "power_id" 14 | CONF_POWER_SENSOR_INACTIVITY_TIMEOUT = "power_sensor_inactivity_timeout" 15 | CONF_OPERATION_MODE_ID = "operation_mode_id" 16 | 17 | DEFAULT_MIN_POWER_DEMAND = 0 18 | DEFAULT_MAX_POWER_DEMAND = 600 19 | 20 | solax_meter_gateway_ns = cg.esphome_ns.namespace("solax_meter_gateway") 21 | SolaxMeterGateway = solax_meter_gateway_ns.class_( 22 | "SolaxMeterGateway", 23 | cg.PollingComponent, 24 | solax_meter_modbus.SolaxMeterModbusDevice, 25 | ) 26 | 27 | CONFIG_SCHEMA = cv.All( 28 | cv.Schema( 29 | { 30 | cv.GenerateID(): cv.declare_id(SolaxMeterGateway), 31 | cv.Required(CONF_POWER_ID): cv.use_id(sensor.Sensor), 32 | cv.Optional( 33 | CONF_POWER_SENSOR_INACTIVITY_TIMEOUT, default="5s" 34 | ): cv.positive_time_period_seconds, 35 | } 36 | ) 37 | .extend(solax_meter_modbus.solax_meter_modbus_device_schema(0x01)) 38 | .extend(cv.polling_component_schema("never")), 39 | ) 40 | 41 | 42 | async def to_code(config): 43 | var = cg.new_Pvariable(config[CONF_ID]) 44 | await cg.register_component(var, config) 45 | await solax_meter_modbus.register_solax_meter_modbus_device(var, config) 46 | 47 | power_sensor = await cg.get_variable(config[CONF_POWER_ID]) 48 | 49 | cg.add(var.set_power_sensor(power_sensor)) 50 | cg.add( 51 | var.set_power_sensor_inactivity_timeout( 52 | config[CONF_POWER_SENSOR_INACTIVITY_TIMEOUT] 53 | ) 54 | ) 55 | -------------------------------------------------------------------------------- /components/solax_meter_gateway/number/__init__.py: -------------------------------------------------------------------------------- 1 | import esphome.codegen as cg 2 | from esphome.components import number 3 | import esphome.config_validation as cv 4 | from esphome.const import ( 5 | CONF_ID, 6 | CONF_INITIAL_VALUE, 7 | CONF_MAX_VALUE, 8 | CONF_MIN_VALUE, 9 | CONF_RESTORE_VALUE, 10 | CONF_STEP, 11 | UNIT_WATT, 12 | ) 13 | 14 | from .. import ( 15 | CONF_SOLAX_METER_GATEWAY_ID, 16 | DEFAULT_MAX_POWER_DEMAND, 17 | DEFAULT_MIN_POWER_DEMAND, 18 | SolaxMeterGateway, 19 | solax_meter_gateway_ns, 20 | ) 21 | 22 | DEPENDENCIES = ["solax_meter_gateway"] 23 | 24 | CODEOWNERS = ["@syssi"] 25 | 26 | DEFAULT_STEP = 1 27 | 28 | CONF_MANUAL_POWER_DEMAND = "manual_power_demand" 29 | 30 | ICON_MANUAL_POWER_DEMAND = "mdi:home-lightning-bolt-outline" 31 | 32 | NUMBERS = { 33 | CONF_MANUAL_POWER_DEMAND: 0x00, 34 | } 35 | 36 | SolaxNumber = solax_meter_gateway_ns.class_("SolaxNumber", number.Number, cg.Component) 37 | 38 | 39 | def validate_min_max(config): 40 | max_power_demand = cv.int_(config[CONF_MAX_VALUE]) 41 | min_power_demand = cv.int_(config[CONF_MIN_VALUE]) 42 | if (max_power_demand - min_power_demand) < 0: 43 | raise cv.Invalid( 44 | "Maximum power demand must be greater than minimum power demand." 45 | ) 46 | 47 | return config 48 | 49 | 50 | def validate(config): 51 | if CONF_INITIAL_VALUE not in config: 52 | config[CONF_INITIAL_VALUE] = config[CONF_MIN_VALUE] 53 | 54 | return config 55 | 56 | 57 | CONFIG_SCHEMA = cv.Schema( 58 | { 59 | cv.GenerateID(CONF_SOLAX_METER_GATEWAY_ID): cv.use_id(SolaxMeterGateway), 60 | cv.Optional(CONF_MANUAL_POWER_DEMAND): cv.All( 61 | number.number_schema( 62 | SolaxNumber, 63 | icon=ICON_MANUAL_POWER_DEMAND, 64 | unit_of_measurement=UNIT_WATT, 65 | ) 66 | .extend( 67 | { 68 | cv.Optional( 69 | CONF_MIN_VALUE, default=DEFAULT_MIN_POWER_DEMAND 70 | ): cv.float_, 71 | cv.Optional( 72 | CONF_MAX_VALUE, default=DEFAULT_MAX_POWER_DEMAND 73 | ): cv.float_, 74 | cv.Optional(CONF_STEP, default=DEFAULT_STEP): cv.float_, 75 | cv.Optional(CONF_INITIAL_VALUE): cv.float_, 76 | cv.Optional(CONF_RESTORE_VALUE, default=False): cv.boolean, 77 | } 78 | ) 79 | .extend(cv.COMPONENT_SCHEMA), 80 | validate_min_max, 81 | validate, 82 | ), 83 | } 84 | ) 85 | 86 | 87 | async def to_code(config): 88 | hub = await cg.get_variable(config[CONF_SOLAX_METER_GATEWAY_ID]) 89 | for key, address in NUMBERS.items(): 90 | if key in config: 91 | conf = config[key] 92 | var = cg.new_Pvariable(conf[CONF_ID]) 93 | await cg.register_component(var, conf) 94 | await number.register_number( 95 | var, 96 | conf, 97 | min_value=conf[CONF_MIN_VALUE], 98 | max_value=conf[CONF_MAX_VALUE], 99 | step=conf[CONF_STEP], 100 | ) 101 | cg.add(getattr(hub, f"set_{key}_number")(var)) 102 | cg.add(var.set_parent(hub)) 103 | cg.add(var.set_initial_value(conf[CONF_INITIAL_VALUE])) 104 | cg.add(var.set_restore_value(conf[CONF_RESTORE_VALUE])) 105 | cg.add(var.set_address(address)) 106 | -------------------------------------------------------------------------------- /components/solax_meter_gateway/number/solax_number.cpp: -------------------------------------------------------------------------------- 1 | #include "solax_number.h" 2 | #include "esphome/core/log.h" 3 | 4 | namespace esphome { 5 | namespace solax_meter_gateway { 6 | 7 | static const char *const TAG = "solax_meter_gateway.number"; 8 | 9 | void SolaxNumber::setup() { 10 | float value; 11 | if (!this->restore_value_) { 12 | value = this->initial_value_; 13 | } else { 14 | this->pref_ = global_preferences->make_preference(this->get_object_id_hash()); 15 | if (!this->pref_.load(&value)) { 16 | if (!std::isnan(this->initial_value_)) { 17 | value = this->initial_value_; 18 | } else { 19 | value = this->traits.get_min_value(); 20 | } 21 | } 22 | } 23 | 24 | this->publish_state(value); 25 | } 26 | 27 | void SolaxNumber::control(float value) { 28 | this->publish_state(value); 29 | 30 | if (this->restore_value_) 31 | this->pref_.save(&value); 32 | } 33 | void SolaxNumber::dump_config() { LOG_NUMBER("", "SolaxMeterGateway Number", this); } 34 | 35 | } // namespace solax_meter_gateway 36 | } // namespace esphome 37 | -------------------------------------------------------------------------------- /components/solax_meter_gateway/number/solax_number.h: -------------------------------------------------------------------------------- 1 | #pragma once 2 | 3 | #include "../solax_meter_gateway.h" 4 | #include "esphome/core/component.h" 5 | #include "esphome/components/number/number.h" 6 | #include "esphome/core/preferences.h" 7 | 8 | namespace esphome { 9 | namespace solax_meter_gateway { 10 | 11 | class SolaxMeterGateway; 12 | 13 | class SolaxNumber : public number::Number, public Component { 14 | public: 15 | void set_parent(SolaxMeterGateway *parent) { this->parent_ = parent; } 16 | void set_initial_value(float initial_value) { initial_value_ = initial_value; } 17 | void set_restore_value(bool restore_value) { this->restore_value_ = restore_value; } 18 | void set_address(uint8_t address) { this->address_ = address; }; 19 | 20 | void setup() override; 21 | void dump_config() override; 22 | 23 | protected: 24 | void control(float value) override; 25 | 26 | SolaxMeterGateway *parent_; 27 | float initial_value_{NAN}; 28 | bool restore_value_{false}; 29 | uint8_t address_; 30 | 31 | ESPPreferenceObject pref_; 32 | }; 33 | 34 | } // namespace solax_meter_gateway 35 | } // namespace esphome 36 | -------------------------------------------------------------------------------- /components/solax_meter_gateway/sensor.py: -------------------------------------------------------------------------------- 1 | import esphome.codegen as cg 2 | from esphome.components import sensor 3 | import esphome.config_validation as cv 4 | from esphome.const import DEVICE_CLASS_POWER, ICON_EMPTY, UNIT_WATT 5 | 6 | from . import CONF_SOLAX_METER_GATEWAY_ID, SolaxMeterGateway 7 | 8 | DEPENDENCIES = ["solax_meter_gateway"] 9 | 10 | CONF_POWER_DEMAND = "power_demand" 11 | 12 | CONFIG_SCHEMA = cv.Schema( 13 | { 14 | cv.GenerateID(CONF_SOLAX_METER_GATEWAY_ID): cv.use_id(SolaxMeterGateway), 15 | cv.Optional(CONF_POWER_DEMAND): sensor.sensor_schema( 16 | unit_of_measurement=UNIT_WATT, 17 | icon=ICON_EMPTY, 18 | accuracy_decimals=0, 19 | device_class=DEVICE_CLASS_POWER, 20 | ), 21 | } 22 | ) 23 | 24 | 25 | async def to_code(config): 26 | hub = await cg.get_variable(config[CONF_SOLAX_METER_GATEWAY_ID]) 27 | if CONF_POWER_DEMAND in config: 28 | sens = await sensor.new_sensor(config[CONF_POWER_DEMAND]) 29 | cg.add(hub.set_power_demand_sensor(sens)) 30 | -------------------------------------------------------------------------------- /components/solax_meter_gateway/solax_meter_gateway.cpp: -------------------------------------------------------------------------------- 1 | #include "solax_meter_gateway.h" 2 | #include "esphome/core/log.h" 3 | 4 | namespace esphome { 5 | namespace solax_meter_gateway { 6 | 7 | static const char *const TAG = "solax_meter_gateway"; 8 | 9 | static const uint8_t REGISTER_HANDSHAKE = 0x0B; 10 | static const uint8_t REGISTER_READ_POWER_16BIT_SINT = 0x0E; 11 | static const uint8_t REGISTER_READ_POWER_32BIT_FLOAT = 0x0C; 12 | static const uint8_t REGISTER_READ_TOTAL_ENERGY = 0x08; 13 | static const uint8_t REGISTER_READ_TOTAL_ENERGY_IMPORT_32BIT_FLOAT = 0x48; 14 | static const uint8_t REGISTER_READ_TOTAL_ENERGY_EXPORT_32BIT_FLOAT = 0x4A; 15 | 16 | void SolaxMeterGateway::on_solax_meter_modbus_data(const std::vector &data) { 17 | this->last_power_demand_received_ = millis(); 18 | 19 | if (this->inactivity_timeout_()) { 20 | this->publish_state_(this->operation_mode_text_sensor_, "Meter fault"); 21 | this->publish_state_(power_demand_sensor_, NAN); 22 | ESP_LOGW(TAG, "No power sensor update received since %d seconds. Triggering meter fault for safety reasons", 23 | this->power_sensor_inactivity_timeout_s_); 24 | return; 25 | } 26 | 27 | if (this->emergency_power_off_switch_ != nullptr && this->emergency_power_off_switch_->state) { 28 | this->publish_state_(this->operation_mode_text_sensor_, "Off"); 29 | this->publish_state_(power_demand_sensor_, 0.0f); 30 | return; 31 | } 32 | 33 | if (this->manual_mode_switch_ != nullptr && this->manual_mode_switch_->state) { 34 | this->publish_state_(this->operation_mode_text_sensor_, "Manual"); 35 | if (this->manual_power_demand_number_ != nullptr && this->manual_power_demand_number_->has_state()) { 36 | this->power_demand_ = this->manual_power_demand_number_->state; 37 | } else { 38 | this->power_demand_ = 0.0f; 39 | } 40 | } else { 41 | this->publish_state_(this->operation_mode_text_sensor_, "Auto"); 42 | } 43 | 44 | uint8_t register_address = data[2]; 45 | switch (register_address) { 46 | case REGISTER_HANDSHAKE: 47 | // Request: 0x01 0x03 0x00 0x0B 0x00 0x01 0xF5 0xC8 48 | // addr func reg bytes*2 49 | this->send_raw({0x01, 0x03, 0x02, 0x00, 0x00}); 50 | break; 51 | 52 | case REGISTER_READ_POWER_32BIT_FLOAT: 53 | // Request: 0x01 0x04 0x00 0x0C 0x00 0x02 0xB1 0xC8 54 | // addr func reg bytes*2 55 | // this->send_raw({0x01, 0x04, 0x04, 0x45, 0x8e, 0x3c, 0x35}); // -4551.52W 56 | this->send(this->power_demand_); 57 | this->publish_state_(power_demand_sensor_, this->power_demand_); 58 | break; 59 | case REGISTER_READ_TOTAL_ENERGY_IMPORT_32BIT_FLOAT: 60 | case REGISTER_READ_TOTAL_ENERGY_EXPORT_32BIT_FLOAT: 61 | // Request: 0x01 0x04 0x00 0x48 0x00 0x02 0xF1 0xDD 62 | // addr func reg bytes*2 63 | 64 | // Request: 0x01 0x04 0x00 0x4A 0x00 0x02 0x50 0x1D 65 | // addr func reg bytes*2 66 | this->send_raw({0x01, 0x04, 0x04, 0x00, 0x00, 0x00, 0x00}); 67 | break; 68 | 69 | case REGISTER_READ_POWER_16BIT_SINT: 70 | // Request: 0x01 0x03 0x00 0x0E 0x00 0x01 0xE5 0xC9 71 | // addr func reg bytes*2 72 | // this->send_raw({0x01, 0x03, 0x02, 0x00, 0x00}); 73 | this->send((int16_t) this->power_demand_); 74 | this->publish_state_(power_demand_sensor_, this->power_demand_); 75 | break; 76 | case REGISTER_READ_TOTAL_ENERGY: 77 | // Request: 0x01 0x03 0x00 0x08 0x00 0x04 0xC5 0xCB 78 | // addr func reg bytes*2 79 | this->send_raw({0x01, 0x03, 0x08, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}); 80 | break; 81 | 82 | default: 83 | ESP_LOGW(TAG, "Unhandled register address (0x%02X) with length (%d) requested.", register_address, data[4]); 84 | ESP_LOGW(TAG, "Your device is probably not supported. Please create an issue here: " 85 | "https://github.com/syssi/esphome-solax-x1-mini/issues"); 86 | ESP_LOGW(TAG, "Please provide the following request data: %s", 87 | format_hex_pretty(&data.front(), data.size()).c_str()); 88 | } 89 | } 90 | 91 | void SolaxMeterGateway::setup() { 92 | this->power_sensor_->add_on_state_callback([this](float state) { 93 | if (std::isnan(state)) { 94 | ESP_LOGVV(TAG, "Invalid power demand received: NaN"); 95 | return; 96 | } 97 | 98 | // Skip updates in manual mode 99 | if (this->manual_mode_switch_ != nullptr && this->manual_mode_switch_->state) { 100 | return; 101 | } 102 | 103 | this->power_demand_ = state; 104 | this->last_power_demand_received_ = millis(); 105 | ESP_LOGVV(TAG, "New power demand received (%.2f). Resetting inactivity timeout (%lu)", this->power_demand_, 106 | (unsigned long) this->last_power_demand_received_); 107 | }); 108 | } 109 | 110 | void SolaxMeterGateway::dump_config() { 111 | ESP_LOGCONFIG(TAG, "SolaxMeterGateway:"); 112 | ESP_LOGCONFIG(TAG, " Address: 0x%02X", this->address_); 113 | LOG_SENSOR(" ", "Power Demand", this->power_demand_sensor_); 114 | LOG_TEXT_SENSOR(" ", "Operation name", this->operation_mode_text_sensor_); 115 | } 116 | 117 | void SolaxMeterGateway::update() { 118 | if (millis() - this->last_solax_request_received_ > (this->solax_request_inactivity_timeout_s_ * 1000)) { 119 | this->publish_state_(this->operation_mode_text_sensor_, "Standby"); 120 | ESP_LOGI(TAG, "No solax request received. Is the inverter online and export control mode 'meter' enabled?"); 121 | } 122 | } 123 | 124 | bool SolaxMeterGateway::inactivity_timeout_() { 125 | if (this->power_sensor_inactivity_timeout_s_ == 0) { 126 | return false; 127 | } 128 | 129 | // Skip the inactivity timeout in manual mode 130 | if (this->manual_mode_switch_ != nullptr && this->manual_mode_switch_->state) { 131 | return false; 132 | } 133 | 134 | return millis() - this->last_power_demand_received_ > (this->power_sensor_inactivity_timeout_s_ * 1000); 135 | } 136 | 137 | void SolaxMeterGateway::publish_state_(sensor::Sensor *sensor, float value) { 138 | if (sensor == nullptr) 139 | return; 140 | 141 | sensor->publish_state(value); 142 | } 143 | 144 | void SolaxMeterGateway::publish_state_(text_sensor::TextSensor *text_sensor, const std::string &state) { 145 | if (text_sensor == nullptr) 146 | return; 147 | 148 | text_sensor->publish_state(state); 149 | } 150 | 151 | } // namespace solax_meter_gateway 152 | } // namespace esphome 153 | -------------------------------------------------------------------------------- /components/solax_meter_gateway/solax_meter_gateway.h: -------------------------------------------------------------------------------- 1 | #pragma once 2 | 3 | #include "esphome/core/component.h" 4 | #include "esphome/components/number/number.h" 5 | #include "esphome/components/sensor/sensor.h" 6 | #include "esphome/components/switch/switch.h" 7 | #include "esphome/components/text_sensor/text_sensor.h" 8 | #include "esphome/components/solax_meter_modbus/solax_meter_modbus.h" 9 | 10 | namespace esphome { 11 | namespace solax_meter_gateway { 12 | 13 | class SolaxMeterGateway : public PollingComponent, public solax_meter_modbus::SolaxMeterModbusDevice { 14 | public: 15 | void set_manual_power_demand_number(number::Number *manual_power_demand_number) { 16 | manual_power_demand_number_ = manual_power_demand_number; 17 | } 18 | 19 | void set_power_sensor(sensor::Sensor *power_sensor) { power_sensor_ = power_sensor; } 20 | void set_power_demand_sensor(sensor::Sensor *power_demand_sensor) { power_demand_sensor_ = power_demand_sensor; } 21 | void set_power_sensor_inactivity_timeout(uint16_t power_sensor_inactivity_timeout_s) { 22 | this->power_sensor_inactivity_timeout_s_ = power_sensor_inactivity_timeout_s; 23 | } 24 | 25 | void set_manual_mode_switch(switch_::Switch *manual_mode_switch) { manual_mode_switch_ = manual_mode_switch; } 26 | void set_emergency_power_off_switch(switch_::Switch *emergency_power_off_switch) { 27 | emergency_power_off_switch_ = emergency_power_off_switch; 28 | } 29 | 30 | void set_operation_mode_text_sensor(text_sensor::TextSensor *operation_mode_text_sensor) { 31 | operation_mode_text_sensor_ = operation_mode_text_sensor; 32 | } 33 | 34 | void setup() override; 35 | 36 | void on_solax_meter_modbus_data(const std::vector &data) override; 37 | 38 | void dump_config() override; 39 | 40 | void update() override; 41 | 42 | float get_setup_priority() const override { return setup_priority::DATA; } 43 | 44 | protected: 45 | number::Number *manual_power_demand_number_; 46 | 47 | sensor::Sensor *power_sensor_; 48 | sensor::Sensor *power_demand_sensor_; 49 | 50 | switch_::Switch *manual_mode_switch_; 51 | switch_::Switch *emergency_power_off_switch_; 52 | 53 | text_sensor::TextSensor *operation_mode_text_sensor_; 54 | 55 | float power_demand_; 56 | uint16_t power_sensor_inactivity_timeout_s_{0}; 57 | uint16_t solax_request_inactivity_timeout_s_{10}; 58 | uint32_t last_power_demand_received_{0}; 59 | uint32_t last_solax_request_received_{0}; 60 | 61 | void publish_state_(sensor::Sensor *sensor, float value); 62 | void publish_state_(text_sensor::TextSensor *text_sensor, const std::string &state); 63 | bool inactivity_timeout_(); 64 | }; 65 | 66 | } // namespace solax_meter_gateway 67 | } // namespace esphome 68 | -------------------------------------------------------------------------------- /components/solax_meter_gateway/switch/__init__.py: -------------------------------------------------------------------------------- 1 | import esphome.codegen as cg 2 | from esphome.components import switch 3 | import esphome.config_validation as cv 4 | from esphome.const import CONF_ID, CONF_RESTORE_MODE 5 | 6 | from .. import CONF_SOLAX_METER_GATEWAY_ID, SolaxMeterGateway, solax_meter_gateway_ns 7 | 8 | DEPENDENCIES = ["solax_meter_gateway"] 9 | 10 | CODEOWNERS = ["@syssi"] 11 | 12 | CONF_MANUAL_MODE = "manual_mode" 13 | CONF_EMERGENCY_POWER_OFF = "emergency_power_off" 14 | 15 | ICON_MANUAL_MODE = "mdi:auto-fix" 16 | ICON_EMERGENCY_POWER_OFF = "mdi:power" 17 | 18 | SWITCHES = [ 19 | CONF_MANUAL_MODE, 20 | CONF_EMERGENCY_POWER_OFF, 21 | ] 22 | 23 | SolaxSwitch = solax_meter_gateway_ns.class_("SolaxSwitch", switch.Switch, cg.Component) 24 | SolaxSwitchRestoreMode = solax_meter_gateway_ns.enum("SolaxSwitchRestoreMode") 25 | 26 | RESTORE_MODES = { 27 | "RESTORE_DEFAULT_OFF": SolaxSwitchRestoreMode.SOLAX_SWITCH_RESTORE_DEFAULT_OFF, 28 | "RESTORE_DEFAULT_ON": SolaxSwitchRestoreMode.SOLAX_SWITCH_RESTORE_DEFAULT_ON, 29 | "ALWAYS_OFF": SolaxSwitchRestoreMode.SOLAX_SWITCH_ALWAYS_OFF, 30 | "ALWAYS_ON": SolaxSwitchRestoreMode.SOLAX_SWITCH_ALWAYS_ON, 31 | } 32 | 33 | CONFIG_SCHEMA = cv.Schema( 34 | { 35 | cv.GenerateID(CONF_SOLAX_METER_GATEWAY_ID): cv.use_id(SolaxMeterGateway), 36 | cv.Optional(CONF_MANUAL_MODE): switch.switch_schema( 37 | SolaxSwitch, 38 | icon=ICON_MANUAL_MODE, 39 | ) 40 | .extend( 41 | { 42 | cv.Optional(CONF_RESTORE_MODE, default="RESTORE_DEFAULT_OFF"): cv.enum( 43 | RESTORE_MODES, upper=True, space="_" 44 | ), 45 | } 46 | ) 47 | .extend(cv.COMPONENT_SCHEMA), 48 | cv.Optional(CONF_EMERGENCY_POWER_OFF): switch.switch_schema( 49 | SolaxSwitch, 50 | icon=ICON_EMERGENCY_POWER_OFF, 51 | ) 52 | .extend( 53 | { 54 | cv.Optional(CONF_RESTORE_MODE, default="RESTORE_DEFAULT_OFF"): cv.enum( 55 | RESTORE_MODES, upper=True, space="_" 56 | ), 57 | } 58 | ) 59 | .extend(cv.COMPONENT_SCHEMA), 60 | } 61 | ) 62 | 63 | 64 | async def to_code(config): 65 | hub = await cg.get_variable(config[CONF_SOLAX_METER_GATEWAY_ID]) 66 | for key in SWITCHES: 67 | if key in config: 68 | conf = config[key] 69 | var = cg.new_Pvariable(conf[CONF_ID]) 70 | await cg.register_component(var, conf) 71 | await switch.register_switch(var, conf) 72 | cg.add(getattr(hub, f"set_{key}_switch")(var)) 73 | cg.add(var.set_parent(hub)) 74 | cg.add(var.set_restore_mode(conf[CONF_RESTORE_MODE])) 75 | -------------------------------------------------------------------------------- /components/solax_meter_gateway/switch/solax_switch.cpp: -------------------------------------------------------------------------------- 1 | #include "solax_switch.h" 2 | #include "esphome/core/log.h" 3 | #include "esphome/core/application.h" 4 | 5 | namespace esphome { 6 | namespace solax_meter_gateway { 7 | 8 | static const char *const TAG = "solax_meter_gateway.switch"; 9 | 10 | void SolaxSwitch::setup() { 11 | ESP_LOGCONFIG(TAG, "Setting up Solax Switch '%s'...", this->name_.c_str()); 12 | 13 | bool initial_state = false; 14 | switch (this->restore_mode_) { 15 | case SOLAX_SWITCH_RESTORE_DEFAULT_OFF: 16 | initial_state = this->get_initial_state().value_or(false); 17 | break; 18 | case SOLAX_SWITCH_RESTORE_DEFAULT_ON: 19 | initial_state = this->get_initial_state().value_or(true); 20 | break; 21 | case SOLAX_SWITCH_ALWAYS_OFF: 22 | initial_state = false; 23 | break; 24 | case SOLAX_SWITCH_ALWAYS_ON: 25 | initial_state = true; 26 | break; 27 | } 28 | 29 | if (initial_state) { 30 | this->turn_on(); 31 | } else { 32 | this->turn_off(); 33 | } 34 | } 35 | void SolaxSwitch::dump_config() { 36 | LOG_SWITCH("", "SolaxMeterGateway Switch", this); 37 | // NOLINTNEXTLINE(clang-analyzer-deadcode.DeadStores) 38 | const LogString *restore_mode = LOG_STR("Unknown"); 39 | switch (this->restore_mode_) { 40 | case SOLAX_SWITCH_RESTORE_DEFAULT_OFF: 41 | restore_mode = LOG_STR("Restore (Defaults to OFF)"); 42 | break; 43 | case SOLAX_SWITCH_RESTORE_DEFAULT_ON: 44 | restore_mode = LOG_STR("Restore (Defaults to ON)"); 45 | break; 46 | case SOLAX_SWITCH_ALWAYS_OFF: 47 | restore_mode = LOG_STR("Always OFF"); 48 | break; 49 | case SOLAX_SWITCH_ALWAYS_ON: 50 | restore_mode = LOG_STR("Always ON"); 51 | break; 52 | } 53 | ESP_LOGCONFIG(TAG, " Restore Mode: %s", LOG_STR_ARG(restore_mode)); 54 | } 55 | void SolaxSwitch::write_state(bool state) { this->publish_state(state); } 56 | 57 | } // namespace solax_meter_gateway 58 | } // namespace esphome 59 | -------------------------------------------------------------------------------- /components/solax_meter_gateway/switch/solax_switch.h: -------------------------------------------------------------------------------- 1 | #pragma once 2 | 3 | #include "../solax_meter_gateway.h" 4 | #include "esphome/core/component.h" 5 | #include "esphome/components/switch/switch.h" 6 | 7 | namespace esphome { 8 | namespace solax_meter_gateway { 9 | 10 | enum SolaxSwitchRestoreMode { 11 | SOLAX_SWITCH_RESTORE_DEFAULT_OFF, 12 | SOLAX_SWITCH_RESTORE_DEFAULT_ON, 13 | SOLAX_SWITCH_ALWAYS_OFF, 14 | SOLAX_SWITCH_ALWAYS_ON, 15 | }; 16 | 17 | class SolaxMeterGateway; 18 | 19 | class SolaxSwitch : public switch_::Switch, public Component { 20 | public: 21 | void set_parent(SolaxMeterGateway *parent) { this->parent_ = parent; }; 22 | void set_restore_mode(SolaxSwitchRestoreMode restore_mode) { this->restore_mode_ = restore_mode; } 23 | 24 | void setup() override; 25 | void dump_config() override; 26 | 27 | protected: 28 | void write_state(bool state) override; 29 | SolaxMeterGateway *parent_; 30 | SolaxSwitchRestoreMode restore_mode_{SOLAX_SWITCH_RESTORE_DEFAULT_OFF}; 31 | }; 32 | 33 | } // namespace solax_meter_gateway 34 | } // namespace esphome 35 | -------------------------------------------------------------------------------- /components/solax_meter_gateway/text_sensor.py: -------------------------------------------------------------------------------- 1 | import esphome.codegen as cg 2 | from esphome.components import text_sensor 3 | import esphome.config_validation as cv 4 | from esphome.const import CONF_ID 5 | 6 | from . import CONF_SOLAX_METER_GATEWAY_ID, SolaxMeterGateway 7 | 8 | DEPENDENCIES = ["solax_meter_gateway"] 9 | 10 | CODEOWNERS = ["@syssi"] 11 | 12 | CONF_OPERATION_MODE = "operation_mode" 13 | 14 | ICON_OPERATION_MODE = "mdi:heart-pulse" 15 | 16 | TEXT_SENSORS = [ 17 | CONF_OPERATION_MODE, 18 | ] 19 | 20 | CONFIG_SCHEMA = cv.Schema( 21 | { 22 | cv.GenerateID(CONF_SOLAX_METER_GATEWAY_ID): cv.use_id(SolaxMeterGateway), 23 | cv.Optional(CONF_OPERATION_MODE): text_sensor.text_sensor_schema( 24 | text_sensor.TextSensor, 25 | icon=ICON_OPERATION_MODE, 26 | ), 27 | } 28 | ) 29 | 30 | 31 | async def to_code(config): 32 | hub = await cg.get_variable(config[CONF_SOLAX_METER_GATEWAY_ID]) 33 | for key in TEXT_SENSORS: 34 | if key in config: 35 | conf = config[key] 36 | sens = cg.new_Pvariable(conf[CONF_ID]) 37 | await text_sensor.register_text_sensor(sens, conf) 38 | cg.add(getattr(hub, f"set_{key}_text_sensor")(sens)) 39 | -------------------------------------------------------------------------------- /components/solax_meter_modbus/__init__.py: -------------------------------------------------------------------------------- 1 | from esphome import pins 2 | import esphome.codegen as cg 3 | from esphome.components import uart 4 | import esphome.config_validation as cv 5 | from esphome.const import CONF_ADDRESS, CONF_FLOW_CONTROL_PIN, CONF_ID 6 | from esphome.cpp_helpers import gpio_pin_expression 7 | 8 | DEPENDENCIES = ["uart"] 9 | MULTI_CONF = True 10 | 11 | CONF_SOLAX_METER_MODBUS_ID = "solax_meter_modbus_id" 12 | 13 | solax_meter_modbus_ns = cg.esphome_ns.namespace("solax_meter_modbus") 14 | SolaxMeterModbus = solax_meter_modbus_ns.class_( 15 | "SolaxMeterModbus", cg.Component, uart.UARTDevice 16 | ) 17 | SolaxMeterModbusDevice = solax_meter_modbus_ns.class_("SolaxMeterModbusDevice") 18 | 19 | CONFIG_SCHEMA = ( 20 | cv.Schema( 21 | { 22 | cv.GenerateID(): cv.declare_id(SolaxMeterModbus), 23 | cv.Optional(CONF_FLOW_CONTROL_PIN): pins.gpio_output_pin_schema, 24 | } 25 | ) 26 | .extend(cv.COMPONENT_SCHEMA) 27 | .extend(uart.UART_DEVICE_SCHEMA) 28 | ) 29 | 30 | 31 | async def to_code(config): 32 | cg.add_global(solax_meter_modbus_ns.using) 33 | var = cg.new_Pvariable(config[CONF_ID]) 34 | await cg.register_component(var, config) 35 | 36 | await uart.register_uart_device(var, config) 37 | 38 | if CONF_FLOW_CONTROL_PIN in config: 39 | pin = await gpio_pin_expression(config[CONF_FLOW_CONTROL_PIN]) 40 | cg.add(var.set_flow_control_pin(pin)) 41 | 42 | 43 | def solax_meter_modbus_device_schema(default_address): 44 | schema = { 45 | cv.GenerateID(CONF_SOLAX_METER_MODBUS_ID): cv.use_id(SolaxMeterModbus), 46 | } 47 | if default_address is None: 48 | schema[cv.Required(CONF_ADDRESS)] = cv.hex_uint8_t 49 | else: 50 | schema[cv.Optional(CONF_ADDRESS, default=default_address)] = cv.hex_uint8_t 51 | return cv.Schema(schema) 52 | 53 | 54 | async def register_solax_meter_modbus_device(var, config): 55 | parent = await cg.get_variable(config[CONF_SOLAX_METER_MODBUS_ID]) 56 | cg.add(var.set_parent(parent)) 57 | cg.add(var.set_address(config[CONF_ADDRESS])) 58 | cg.add(parent.register_device(var)) 59 | -------------------------------------------------------------------------------- /components/solax_meter_modbus/solax_meter_modbus.cpp: -------------------------------------------------------------------------------- 1 | #include "solax_meter_modbus.h" 2 | #include "esphome/core/log.h" 3 | #include "esphome/core/helpers.h" 4 | 5 | namespace esphome { 6 | namespace solax_meter_modbus { 7 | 8 | static const char *const TAG = "solax_meter_modbus"; 9 | 10 | void SolaxMeterModbus::setup() { 11 | if (this->flow_control_pin_ != nullptr) { 12 | this->flow_control_pin_->setup(); 13 | } 14 | } 15 | void SolaxMeterModbus::loop() { 16 | const uint32_t now = millis(); 17 | if (now - this->last_solax_meter_modbus_byte_ > 50) { 18 | this->rx_buffer_.clear(); 19 | this->last_solax_meter_modbus_byte_ = now; 20 | } 21 | 22 | while (this->available()) { 23 | uint8_t byte; 24 | this->read_byte(&byte); 25 | if (this->parse_solax_meter_modbus_byte_(byte)) { 26 | this->last_solax_meter_modbus_byte_ = now; 27 | } else { 28 | this->rx_buffer_.clear(); 29 | } 30 | } 31 | } 32 | 33 | bool SolaxMeterModbus::parse_solax_meter_modbus_byte_(uint8_t byte) { 34 | size_t at = this->rx_buffer_.size(); 35 | this->rx_buffer_.push_back(byte); 36 | const uint8_t *raw = &this->rx_buffer_[0]; 37 | 38 | // Meter requests of a Solax X1 mini 600W 39 | // 40 | // Handshake request: 0x01 0x03 0x00 0x0B 0x00 0x01 0xF5 0xC8 41 | // Read power request: 0x01 0x04 0x00 0x0C 0x00 0x02 0xB1 0xC8 42 | // Read total energy import: 0x01 0x04 0x00 0x48 0x00 0x02 0xF1 0xDD 43 | // Read total energy export: 0x01 0x04 0x00 0x4A 0x00 0x02 0x50 0x1D 44 | // addr func reg len crc crc 45 | 46 | // Meter requests of a Solax X1 mini from @strage 47 | // 48 | // Handshake request: 0x01 0x03 0x00 0x0B 0x00 0x01 0xF5 0xC8 49 | // Read power request: 0x01 0x03 0x00 0x0E 0x00 0x01 0xE5 0xC9 50 | // Read total energy: 0x01 0x03 0x00 0x08 0x00 0x04 0xC5 0xCB 51 | // addr func reg len crc crc 52 | 53 | if (at == 0) 54 | return true; 55 | uint8_t address = raw[0]; 56 | 57 | if (at == 2) 58 | return true; 59 | 60 | uint8_t data_len = 5; 61 | uint8_t data_offset = 1; 62 | 63 | if (at < data_offset + data_len) 64 | return true; 65 | 66 | if (at == data_offset + data_len) 67 | return true; 68 | 69 | ESP_LOGVV(TAG, "RX <- %s", format_hex_pretty(raw, at + 1).c_str()); 70 | 71 | uint16_t computed_crc = crc16(raw, data_offset + data_len); 72 | uint16_t remote_crc = uint16_t(raw[data_offset + data_len]) | (uint16_t(raw[data_offset + data_len + 1]) << 8); 73 | if (computed_crc != remote_crc) { 74 | ESP_LOGW(TAG, "CRC check failed! 0x%04X != 0x%04X", computed_crc, remote_crc); 75 | return false; 76 | } 77 | 78 | std::vector data(this->rx_buffer_.begin() + data_offset, this->rx_buffer_.begin() + data_offset + data_len); 79 | bool found = false; 80 | for (auto *device : this->devices_) { 81 | if (device->address_ == address) { 82 | device->on_solax_meter_modbus_data(data); 83 | found = true; 84 | } 85 | } 86 | 87 | if (!found) { 88 | ESP_LOGW(TAG, "Got SolaxMeterModbus frame from unknown address 0x%02X! ", address); 89 | } 90 | 91 | // return false to reset buffer 92 | return false; 93 | } 94 | 95 | void SolaxMeterModbus::dump_config() { 96 | ESP_LOGCONFIG(TAG, "SolaxMeterModbus:"); 97 | LOG_PIN(" Flow Control Pin: ", this->flow_control_pin_); 98 | 99 | this->check_uart_settings(9600); 100 | } 101 | 102 | float SolaxMeterModbus::get_setup_priority() const { 103 | // After UART bus 104 | return setup_priority::BUS - 1.0f; 105 | } 106 | 107 | void SolaxMeterModbus::send(uint8_t address, int16_t power) { 108 | std::vector data; 109 | data.push_back(address); 110 | data.push_back(0x03); 111 | data.push_back(0x02); 112 | data.push_back(power >> 8); 113 | data.push_back(power >> 0); 114 | auto crc = crc16(data.data(), data.size()); 115 | data.push_back(crc >> 0); 116 | data.push_back(crc >> 8); 117 | 118 | if (this->flow_control_pin_ != nullptr) 119 | this->flow_control_pin_->digital_write(true); 120 | 121 | this->write_array(data); 122 | this->flush(); 123 | 124 | if (this->flow_control_pin_ != nullptr) 125 | this->flow_control_pin_->digital_write(false); 126 | 127 | ESP_LOGV(TAG, "SolaxMeterModbus write: %s", format_hex_pretty(data).c_str()); 128 | } 129 | 130 | void SolaxMeterModbus::send(uint8_t address, float power) { 131 | union float_bytes_t { 132 | float f; 133 | uint8_t i[4]; 134 | }; 135 | 136 | float_bytes_t payload; 137 | payload.f = power; 138 | 139 | std::vector data; 140 | data.push_back(address); 141 | data.push_back(0x04); 142 | data.push_back(0x04); 143 | data.push_back(payload.i[3]); 144 | data.push_back(payload.i[2]); 145 | data.push_back(payload.i[1]); 146 | data.push_back(payload.i[0]); 147 | auto crc = crc16(data.data(), data.size()); 148 | data.push_back(crc >> 0); 149 | data.push_back(crc >> 8); 150 | 151 | if (this->flow_control_pin_ != nullptr) 152 | this->flow_control_pin_->digital_write(true); 153 | 154 | this->write_array(data); 155 | this->flush(); 156 | 157 | if (this->flow_control_pin_ != nullptr) 158 | this->flow_control_pin_->digital_write(false); 159 | 160 | ESP_LOGV(TAG, "SolaxMeterModbus write: %s", format_hex_pretty(data).c_str()); 161 | } 162 | 163 | // Helper function for lambdas 164 | // Send raw command. Except CRC everything must be contained in payload 165 | void SolaxMeterModbus::send_raw(const std::vector &payload) { 166 | if (payload.empty()) { 167 | return; 168 | } 169 | 170 | if (this->flow_control_pin_ != nullptr) 171 | this->flow_control_pin_->digital_write(true); 172 | 173 | auto crc = crc16(payload.data(), payload.size()); 174 | this->write_array(payload); 175 | this->write_byte(crc & 0xFF); 176 | this->write_byte((crc >> 8) & 0xFF); 177 | this->flush(); 178 | if (this->flow_control_pin_ != nullptr) 179 | this->flow_control_pin_->digital_write(false); 180 | 181 | ESP_LOGV(TAG, "SolaxMeterModbus write raw: %s", format_hex_pretty(payload).c_str()); 182 | } 183 | 184 | } // namespace solax_meter_modbus 185 | } // namespace esphome 186 | -------------------------------------------------------------------------------- /components/solax_meter_modbus/solax_meter_modbus.h: -------------------------------------------------------------------------------- 1 | #pragma once 2 | 3 | #include "esphome/core/component.h" 4 | #include "esphome/components/uart/uart.h" 5 | 6 | #include 7 | 8 | namespace esphome { 9 | namespace solax_meter_modbus { 10 | 11 | class SolaxMeterModbusDevice; 12 | 13 | class SolaxMeterModbus : public uart::UARTDevice, public Component { 14 | public: 15 | SolaxMeterModbus() = default; 16 | 17 | void setup() override; 18 | 19 | void loop() override; 20 | 21 | void dump_config() override; 22 | 23 | void register_device(SolaxMeterModbusDevice *device) { this->devices_.push_back(device); } 24 | 25 | float get_setup_priority() const override; 26 | 27 | void send(uint8_t address, int16_t power); 28 | void send(uint8_t address, float power); 29 | void send_raw(const std::vector &payload); 30 | void set_flow_control_pin(GPIOPin *flow_control_pin) { this->flow_control_pin_ = flow_control_pin; } 31 | 32 | protected: 33 | GPIOPin *flow_control_pin_{nullptr}; 34 | 35 | bool parse_solax_meter_modbus_byte_(uint8_t byte); 36 | std::vector rx_buffer_; 37 | uint32_t last_solax_meter_modbus_byte_{0}; 38 | std::vector devices_; 39 | }; 40 | 41 | class SolaxMeterModbusDevice { 42 | public: 43 | void set_parent(SolaxMeterModbus *parent) { parent_ = parent; } 44 | void set_address(uint8_t address) { address_ = address; } 45 | virtual void on_solax_meter_modbus_data(const std::vector &data) = 0; 46 | void send(int16_t power) { this->parent_->send(this->address_, power); } 47 | void send(float power) { this->parent_->send(this->address_, power); } 48 | void send_raw(const std::vector &payload) { this->parent_->send_raw(payload); } 49 | 50 | protected: 51 | friend SolaxMeterModbus; 52 | 53 | SolaxMeterModbus *parent_; 54 | uint8_t address_; 55 | }; 56 | 57 | } // namespace solax_meter_modbus 58 | } // namespace esphome 59 | -------------------------------------------------------------------------------- /components/solax_modbus/__init__.py: -------------------------------------------------------------------------------- 1 | from esphome import pins 2 | import esphome.codegen as cg 3 | from esphome.components import uart 4 | import esphome.config_validation as cv 5 | from esphome.const import CONF_ADDRESS, CONF_FLOW_CONTROL_PIN, CONF_ID 6 | from esphome.cpp_helpers import gpio_pin_expression 7 | 8 | CODEOWNERS = ["@syssi"] 9 | 10 | DEPENDENCIES = ["uart"] 11 | MULTI_CONF = True 12 | 13 | CONF_SOLAX_MODBUS_ID = "solax_modbus_id" 14 | CONF_SERIAL_NUMBER = "serial_number" 15 | 16 | solax_modbus_ns = cg.esphome_ns.namespace("solax_modbus") 17 | SolaxModbus = solax_modbus_ns.class_("SolaxModbus", cg.Component, uart.UARTDevice) 18 | SolaxModbusDevice = solax_modbus_ns.class_("SolaxModbusDevice") 19 | 20 | CONFIG_SCHEMA = ( 21 | cv.Schema( 22 | { 23 | cv.GenerateID(): cv.declare_id(SolaxModbus), 24 | cv.Optional(CONF_FLOW_CONTROL_PIN): pins.gpio_output_pin_schema, 25 | } 26 | ) 27 | .extend(cv.COMPONENT_SCHEMA) 28 | .extend(uart.UART_DEVICE_SCHEMA) 29 | ) 30 | 31 | 32 | def validate_serial_number(value): 33 | value = cv.string_strict(value) 34 | parts = [value[i : i + 2] for i in range(0, len(value), 2)] 35 | if len(parts) != 14: 36 | raise cv.Invalid("Serial number must consist of 14 hexadecimal numbers") 37 | parts_int = [] 38 | if any(len(part) != 2 for part in parts): 39 | raise cv.Invalid("Serial number must be format XX") 40 | for part in parts: 41 | try: 42 | parts_int.append(int(part, 16)) 43 | except ValueError: 44 | # pylint: disable=raise-missing-from 45 | raise cv.Invalid("Serial number must be hex values from 00 to FF") 46 | 47 | return "".join(f"{part:02X}" for part in parts_int) 48 | 49 | 50 | def as_hex_array(value): 51 | cpp_array = [ 52 | f"0x{part}" for part in [value[i : i + 2] for i in range(0, len(value), 2)] 53 | ] 54 | return cg.RawExpression(f"(uint8_t*)(const uint8_t[16]){{{','.join(cpp_array)}}}") 55 | 56 | 57 | async def to_code(config): 58 | cg.add_global(solax_modbus_ns.using) 59 | var = cg.new_Pvariable(config[CONF_ID]) 60 | await cg.register_component(var, config) 61 | 62 | await uart.register_uart_device(var, config) 63 | 64 | if CONF_FLOW_CONTROL_PIN in config: 65 | pin = await gpio_pin_expression(config[CONF_FLOW_CONTROL_PIN]) 66 | cg.add(var.set_flow_control_pin(pin)) 67 | 68 | 69 | def solax_modbus_device_schema(default_address, default_serial): 70 | schema = { 71 | cv.GenerateID(CONF_SOLAX_MODBUS_ID): cv.use_id(SolaxModbus), 72 | } 73 | if default_address is None: 74 | schema[cv.Required(CONF_ADDRESS)] = cv.hex_uint8_t 75 | else: 76 | schema[cv.Optional(CONF_ADDRESS, default=default_address)] = cv.hex_uint8_t 77 | 78 | if default_address is None: 79 | schema[cv.Required(CONF_SERIAL_NUMBER)] = validate_serial_number 80 | else: 81 | schema[cv.Optional(CONF_SERIAL_NUMBER, default=default_serial)] = ( 82 | validate_serial_number 83 | ) 84 | 85 | return cv.Schema(schema) 86 | 87 | 88 | async def register_solax_modbus_device(var, config): 89 | parent = await cg.get_variable(config[CONF_SOLAX_MODBUS_ID]) 90 | cg.add(var.set_parent(parent)) 91 | cg.add(var.set_address(config[CONF_ADDRESS])) 92 | cg.add(var.set_serial_number(as_hex_array(config[CONF_SERIAL_NUMBER]))) 93 | cg.add(parent.register_device(var)) 94 | -------------------------------------------------------------------------------- /components/solax_modbus/solax_modbus.cpp: -------------------------------------------------------------------------------- 1 | #include "solax_modbus.h" 2 | #include "esphome/core/log.h" 3 | #include "esphome/core/helpers.h" 4 | 5 | static const uint8_t BROADCAST_ADDRESS = 0xFF; 6 | 7 | namespace esphome { 8 | namespace solax_modbus { 9 | 10 | static const char *const TAG = "solax_modbus"; 11 | 12 | void SolaxModbus::setup() { 13 | if (this->flow_control_pin_ != nullptr) { 14 | this->flow_control_pin_->setup(); 15 | } 16 | } 17 | 18 | void SolaxModbus::loop() { 19 | const uint32_t now = millis(); 20 | if (now - this->last_solax_modbus_byte_ > 50) { 21 | this->rx_buffer_.clear(); 22 | this->last_solax_modbus_byte_ = now; 23 | } 24 | 25 | while (this->available()) { 26 | uint8_t byte; 27 | this->read_byte(&byte); 28 | if (this->parse_solax_modbus_byte_(byte)) { 29 | this->last_solax_modbus_byte_ = now; 30 | } else { 31 | this->rx_buffer_.clear(); 32 | } 33 | } 34 | } 35 | 36 | std::string hexencode_plain(const uint8_t *data, uint32_t len) { 37 | char buf[20]; 38 | std::string res; 39 | for (size_t i = 0; i < len; i++) { 40 | sprintf(buf, "%02X", data[i]); 41 | res += buf; 42 | } 43 | return res; 44 | } 45 | 46 | uint16_t chksum(const uint8_t data[], const uint8_t len) { 47 | uint8_t i; 48 | uint16_t checksum = 0; 49 | for (i = 0; i <= len; i++) { 50 | checksum = checksum + data[i]; 51 | } 52 | return checksum; 53 | } 54 | 55 | bool SolaxModbus::parse_solax_modbus_byte_(uint8_t byte) { 56 | size_t at = this->rx_buffer_.size(); 57 | this->rx_buffer_.push_back(byte); 58 | const uint8_t *frame = &this->rx_buffer_[0]; 59 | 60 | // Byte 0: modbus address (match all) 61 | if (at == 0) 62 | return true; 63 | 64 | // Byte 1: Function (msb indicates error) 65 | if (at == 1) 66 | return (byte & 0x80) != 0x80; 67 | 68 | if (at == 2) 69 | return true; 70 | 71 | // Byte 3: solax device address 72 | if (at == 3) 73 | return true; 74 | uint8_t address = frame[3]; 75 | 76 | // Byte 9: data length 77 | if (at < 9) 78 | return true; 79 | 80 | uint8_t data_len = frame[8]; 81 | // Byte 9...9+data_len-1: Data 82 | if (at < 9 + data_len) 83 | return true; 84 | 85 | // Byte 9+data_len: CRC_LO (over all bytes) 86 | if (at == 9 + data_len) 87 | return true; 88 | 89 | ESP_LOGVV(TAG, "RX <- %s", format_hex_pretty(frame, at + 1).c_str()); 90 | 91 | if (frame[0] != 0xAA || frame[1] != 0x55) { 92 | ESP_LOGW(TAG, "Invalid header"); 93 | return false; 94 | } 95 | 96 | // Byte 9+data_len+1: CRC_HI (over all bytes) 97 | uint16_t computed_checksum = chksum(frame, 9 + data_len - 1); 98 | uint16_t remote_checksum = uint16_t(frame[9 + data_len + 1]) | (uint16_t(frame[9 + data_len]) << 8); 99 | if (computed_checksum != remote_checksum) { 100 | ESP_LOGW(TAG, "Invalid checksum! 0x%02X != 0x%02X", computed_checksum, remote_checksum); 101 | return false; 102 | } 103 | 104 | // data only 105 | std::vector data(this->rx_buffer_.begin() + 9, this->rx_buffer_.begin() + 9 + data_len); 106 | 107 | if (address == BROADCAST_ADDRESS) { 108 | // check control code && function code 109 | if (frame[6] == 0x10 && frame[7] == 0x80 && data.size() == 14) { 110 | ESP_LOGI(TAG, "Inverter discovered. Serial number: %s", hexencode_plain(&data.front(), data.size()).c_str()); 111 | this->register_address(data.data(), 0x0A); 112 | } else { 113 | ESP_LOGW(TAG, "Unknown broadcast data: %s", format_hex_pretty(&data.front(), data.size()).c_str()); 114 | } 115 | 116 | // early return false to reset buffer 117 | return false; 118 | } 119 | 120 | bool found = false; 121 | for (auto *device : this->devices_) { 122 | if (device->address_ == address) { 123 | if (frame[6] == 0x11) { 124 | device->on_solax_modbus_data(frame[7], data); 125 | } else { 126 | ESP_LOGW(TAG, "Unhandled control code (%d) of frame for address 0x%02X: %s", frame[6], address, 127 | format_hex_pretty(frame, at + 1).c_str()); 128 | } 129 | found = true; 130 | } 131 | } 132 | 133 | if (!found) { 134 | ESP_LOGW(TAG, "Got solax frame from unknown device address 0x%02X!", address); 135 | } 136 | 137 | // return false to reset buffer 138 | return false; 139 | } 140 | 141 | void SolaxModbus::dump_config() { 142 | ESP_LOGCONFIG(TAG, "SolaxModbus:"); 143 | LOG_PIN(" Flow Control Pin: ", this->flow_control_pin_); 144 | 145 | this->check_uart_settings(9600); 146 | } 147 | 148 | float SolaxModbus::get_setup_priority() const { 149 | // After UART bus 150 | return setup_priority::BUS - 1.0f; 151 | } 152 | 153 | void SolaxModbus::query_status_report(uint8_t address) { 154 | static SolaxMessageT tx_message; 155 | 156 | tx_message.Source[0] = 0x01; 157 | tx_message.Source[1] = 0x00; 158 | tx_message.Destination[0] = 0x00; 159 | tx_message.Destination[1] = address; 160 | tx_message.ControlCode = 0x11; 161 | tx_message.FunctionCode = 0x02; 162 | tx_message.DataLength = 0x00; 163 | 164 | this->send(&tx_message); 165 | } 166 | 167 | void SolaxModbus::query_device_info(uint8_t address) { 168 | static SolaxMessageT tx_message; 169 | 170 | tx_message.Source[0] = 0x01; 171 | tx_message.Source[1] = 0x00; 172 | tx_message.Destination[0] = 0x00; 173 | tx_message.Destination[1] = address; 174 | tx_message.ControlCode = 0x11; 175 | tx_message.FunctionCode = 0x03; 176 | tx_message.DataLength = 0x00; 177 | 178 | this->send(&tx_message); 179 | } 180 | 181 | void SolaxModbus::query_config_settings(uint8_t address) { 182 | static SolaxMessageT tx_message; 183 | 184 | tx_message.Source[0] = 0x01; 185 | tx_message.Source[1] = 0x00; 186 | tx_message.Destination[0] = 0x00; 187 | tx_message.Destination[1] = address; 188 | tx_message.ControlCode = 0x11; 189 | tx_message.FunctionCode = 0x04; 190 | tx_message.DataLength = 0x00; 191 | 192 | this->send(&tx_message); 193 | } 194 | 195 | void SolaxModbus::register_address(uint8_t serial_number[14], uint8_t address) { 196 | static SolaxMessageT tx_message; 197 | 198 | tx_message.Source[0] = 0x00; 199 | tx_message.Source[1] = 0x00; 200 | tx_message.Destination[0] = 0x00; 201 | tx_message.Destination[1] = 0x00; 202 | tx_message.ControlCode = 0x10; 203 | tx_message.FunctionCode = 0x01; 204 | tx_message.DataLength = 0x0F; 205 | memcpy(tx_message.Data, serial_number, 14); 206 | tx_message.Data[14] = address; 207 | 208 | this->send(&tx_message); 209 | } 210 | 211 | void SolaxModbus::discover_devices() { 212 | static SolaxMessageT tx_message; 213 | 214 | tx_message.Source[0] = 0x01; 215 | tx_message.Source[1] = 0x00; 216 | tx_message.Destination[0] = 0x00; 217 | tx_message.Destination[1] = 0x00; 218 | tx_message.ControlCode = 0x10; 219 | tx_message.FunctionCode = 0x00; 220 | tx_message.DataLength = 0x00; 221 | 222 | this->send(&tx_message); 223 | } 224 | 225 | void SolaxModbus::send(SolaxMessageT *tx_message) { 226 | uint8_t msg_len; 227 | 228 | tx_message->Header[0] = 0xAA; 229 | tx_message->Header[1] = 0x55; 230 | 231 | msg_len = tx_message->DataLength + 9; 232 | auto checksum = chksum((const uint8_t *) tx_message, msg_len - 1); 233 | 234 | tx_message->Data[tx_message->DataLength + 0] = checksum >> 8; 235 | tx_message->Data[tx_message->DataLength + 1] = checksum >> 0; 236 | msg_len += 2; 237 | 238 | ESP_LOGVV(TAG, "TX -> %s", format_hex_pretty((const uint8_t *) tx_message, msg_len).c_str()); 239 | 240 | if (this->flow_control_pin_ != nullptr) 241 | this->flow_control_pin_->digital_write(true); 242 | 243 | this->write_array((const uint8_t *) tx_message, msg_len); 244 | this->flush(); 245 | 246 | if (this->flow_control_pin_ != nullptr) 247 | this->flow_control_pin_->digital_write(false); 248 | } 249 | 250 | } // namespace solax_modbus 251 | } // namespace esphome 252 | -------------------------------------------------------------------------------- /components/solax_modbus/solax_modbus.h: -------------------------------------------------------------------------------- 1 | #pragma once 2 | 3 | #include "esphome/core/component.h" 4 | #include "esphome/components/uart/uart.h" 5 | 6 | namespace esphome { 7 | namespace solax_modbus { 8 | 9 | struct SolaxMessageT { 10 | uint8_t Header[2]; 11 | uint8_t Source[2]; 12 | uint8_t Destination[2]; 13 | uint8_t ControlCode; 14 | uint8_t FunctionCode; 15 | uint8_t DataLength; 16 | uint8_t Data[100]; 17 | }; 18 | 19 | class SolaxModbusDevice; 20 | 21 | class SolaxModbus : public uart::UARTDevice, public Component { 22 | public: 23 | SolaxModbus() = default; 24 | 25 | void setup() override; 26 | void loop() override; 27 | 28 | void dump_config() override; 29 | 30 | void register_device(SolaxModbusDevice *device) { this->devices_.push_back(device); } 31 | void set_flow_control_pin(GPIOPin *flow_control_pin) { this->flow_control_pin_ = flow_control_pin; } 32 | 33 | float get_setup_priority() const override; 34 | 35 | void send(SolaxMessageT *tx_message); 36 | void query_status_report(uint8_t address); 37 | void query_device_info(uint8_t address); 38 | void query_config_settings(uint8_t address); 39 | void discover_devices(); 40 | void register_address(uint8_t serial_number[14], uint8_t address); 41 | 42 | protected: 43 | bool parse_solax_modbus_byte_(uint8_t byte); 44 | GPIOPin *flow_control_pin_{nullptr}; 45 | 46 | std::vector rx_buffer_; 47 | uint32_t last_solax_modbus_byte_{0}; 48 | std::vector devices_; 49 | }; 50 | 51 | class SolaxModbusDevice { 52 | public: 53 | void set_parent(SolaxModbus *parent) { parent_ = parent; } 54 | void set_address(uint8_t address) { address_ = address; } 55 | void set_serial_number(uint8_t *serial_number) { serial_number_ = serial_number; } 56 | virtual void on_solax_modbus_data(const uint8_t &function, const std::vector &data) = 0; 57 | 58 | void query_status_report(uint8_t address) { this->parent_->query_status_report(address); } 59 | void query_device_info(uint8_t address) { this->parent_->query_device_info(address); } 60 | void query_config_settings(uint8_t address) { this->parent_->query_config_settings(address); } 61 | void discover_devices() { this->parent_->discover_devices(); } 62 | 63 | protected: 64 | friend SolaxModbus; 65 | 66 | SolaxModbus *parent_; 67 | uint8_t address_; 68 | uint8_t *serial_number_; 69 | }; 70 | 71 | } // namespace solax_modbus 72 | } // namespace esphome 73 | -------------------------------------------------------------------------------- /components/solax_x1_mini/__init__.py: -------------------------------------------------------------------------------- 1 | import esphome.codegen as cg 2 | from esphome.components import solax_modbus 3 | import esphome.config_validation as cv 4 | from esphome.const import CONF_ID 5 | 6 | AUTO_LOAD = ["solax_modbus", "sensor", "text_sensor"] 7 | CODEOWNERS = ["@syssi"] 8 | MULTI_CONF = True 9 | 10 | CONF_SOLAX_X1_MINI_ID = "solax_x1_mini_id" 11 | 12 | solax_x1_mini_ns = cg.esphome_ns.namespace("solax_x1_mini") 13 | SolaxX1Mini = solax_x1_mini_ns.class_( 14 | "SolaxX1Mini", cg.PollingComponent, solax_modbus.SolaxModbusDevice 15 | ) 16 | 17 | CONFIG_SCHEMA = ( 18 | cv.Schema({cv.GenerateID(): cv.declare_id(SolaxX1Mini)}) 19 | .extend(cv.polling_component_schema("30s")) 20 | .extend( 21 | solax_modbus.solax_modbus_device_schema(0x0A, "3132333435363737363534333231") 22 | ) 23 | ) 24 | 25 | 26 | async def to_code(config): 27 | var = cg.new_Pvariable(config[CONF_ID]) 28 | await cg.register_component(var, config) 29 | await solax_modbus.register_solax_modbus_device(var, config) 30 | -------------------------------------------------------------------------------- /components/solax_x1_mini/sensor.py: -------------------------------------------------------------------------------- 1 | import esphome.codegen as cg 2 | from esphome.components import sensor 3 | import esphome.config_validation as cv 4 | from esphome.const import ( 5 | CONF_MODE, 6 | CONF_TEMPERATURE, 7 | DEVICE_CLASS_CURRENT, 8 | DEVICE_CLASS_EMPTY, 9 | DEVICE_CLASS_ENERGY, 10 | DEVICE_CLASS_FREQUENCY, 11 | DEVICE_CLASS_POWER, 12 | DEVICE_CLASS_TEMPERATURE, 13 | DEVICE_CLASS_VOLTAGE, 14 | ICON_COUNTER, 15 | ICON_EMPTY, 16 | ICON_TIMER, 17 | STATE_CLASS_MEASUREMENT, 18 | STATE_CLASS_TOTAL_INCREASING, 19 | UNIT_AMPERE, 20 | UNIT_CELSIUS, 21 | UNIT_EMPTY, 22 | UNIT_HERTZ, 23 | UNIT_KILOWATT_HOURS, 24 | UNIT_VOLT, 25 | UNIT_WATT, 26 | ) 27 | 28 | from . import CONF_SOLAX_X1_MINI_ID, SolaxX1Mini 29 | 30 | DEPENDENCIES = ["solax_x1_mini"] 31 | 32 | CONF_ENERGY_TODAY = "energy_today" 33 | CONF_ENERGY_TOTAL = "energy_total" 34 | CONF_DC1_CURRENT = "dc1_current" 35 | CONF_DC1_VOLTAGE = "dc1_voltage" 36 | CONF_DC2_CURRENT = "dc2_current" 37 | CONF_DC2_VOLTAGE = "dc2_voltage" 38 | CONF_AC_CURRENT = "ac_current" 39 | CONF_AC_VOLTAGE = "ac_voltage" 40 | CONF_AC_FREQUENCY = "ac_frequency" 41 | CONF_AC_POWER = "ac_power" 42 | CONF_RUNTIME_TOTAL = "runtime_total" 43 | CONF_ERROR_BITS = "error_bits" 44 | CONF_GRID_VOLTAGE_FAULT = "grid_voltage_fault" 45 | CONF_GRID_FREQUENCY_FAULT = "grid_frequency_fault" 46 | CONF_DC_INJECTION_FAULT = "dc_injection_fault" 47 | CONF_TEMPERATURE_FAULT = "temperature_fault" 48 | CONF_PV1_VOLTAGE_FAULT = "pv1_voltage_fault" 49 | CONF_PV2_VOLTAGE_FAULT = "pv2_voltage_fault" 50 | CONF_GFC_FAULT = "gfc_fault" 51 | 52 | UNIT_HOURS = "h" 53 | 54 | ICON_MODE = "mdi:heart-pulse" 55 | ICON_ERROR_BITS = "mdi:alert-circle-outline" 56 | 57 | SENSORS = [ 58 | CONF_ENERGY_TODAY, 59 | CONF_ENERGY_TOTAL, 60 | CONF_DC1_CURRENT, 61 | CONF_DC1_VOLTAGE, 62 | CONF_DC2_CURRENT, 63 | CONF_DC2_VOLTAGE, 64 | CONF_AC_CURRENT, 65 | CONF_AC_VOLTAGE, 66 | CONF_AC_FREQUENCY, 67 | CONF_AC_POWER, 68 | CONF_RUNTIME_TOTAL, 69 | CONF_ERROR_BITS, 70 | CONF_MODE, 71 | CONF_TEMPERATURE, 72 | ] 73 | 74 | # pylint: disable=too-many-function-args 75 | CONFIG_SCHEMA = cv.Schema( 76 | { 77 | cv.GenerateID(CONF_SOLAX_X1_MINI_ID): cv.use_id(SolaxX1Mini), 78 | cv.Optional(CONF_ENERGY_TODAY): sensor.sensor_schema( 79 | unit_of_measurement=UNIT_KILOWATT_HOURS, 80 | icon=ICON_COUNTER, 81 | accuracy_decimals=1, 82 | device_class=DEVICE_CLASS_ENERGY, 83 | state_class=STATE_CLASS_TOTAL_INCREASING, 84 | ), 85 | cv.Optional(CONF_ENERGY_TOTAL): sensor.sensor_schema( 86 | unit_of_measurement=UNIT_KILOWATT_HOURS, 87 | icon=ICON_COUNTER, 88 | accuracy_decimals=1, 89 | device_class=DEVICE_CLASS_ENERGY, 90 | state_class=STATE_CLASS_TOTAL_INCREASING, 91 | ), 92 | cv.Optional(CONF_DC1_CURRENT): sensor.sensor_schema( 93 | unit_of_measurement=UNIT_AMPERE, 94 | accuracy_decimals=1, 95 | device_class=DEVICE_CLASS_CURRENT, 96 | state_class=STATE_CLASS_MEASUREMENT, 97 | ), 98 | cv.Optional(CONF_DC1_VOLTAGE): sensor.sensor_schema( 99 | unit_of_measurement=UNIT_VOLT, 100 | accuracy_decimals=1, 101 | device_class=DEVICE_CLASS_VOLTAGE, 102 | state_class=STATE_CLASS_MEASUREMENT, 103 | ), 104 | cv.Optional(CONF_DC2_CURRENT): sensor.sensor_schema( 105 | unit_of_measurement=UNIT_AMPERE, 106 | accuracy_decimals=1, 107 | device_class=DEVICE_CLASS_CURRENT, 108 | state_class=STATE_CLASS_MEASUREMENT, 109 | ), 110 | cv.Optional(CONF_DC2_VOLTAGE): sensor.sensor_schema( 111 | unit_of_measurement=UNIT_VOLT, 112 | accuracy_decimals=1, 113 | device_class=DEVICE_CLASS_VOLTAGE, 114 | state_class=STATE_CLASS_MEASUREMENT, 115 | ), 116 | cv.Optional(CONF_AC_CURRENT): sensor.sensor_schema( 117 | unit_of_measurement=UNIT_AMPERE, 118 | accuracy_decimals=1, 119 | device_class=DEVICE_CLASS_CURRENT, 120 | state_class=STATE_CLASS_MEASUREMENT, 121 | ), 122 | cv.Optional(CONF_AC_VOLTAGE): sensor.sensor_schema( 123 | unit_of_measurement=UNIT_VOLT, 124 | accuracy_decimals=1, 125 | device_class=DEVICE_CLASS_VOLTAGE, 126 | state_class=STATE_CLASS_MEASUREMENT, 127 | ), 128 | cv.Optional(CONF_AC_FREQUENCY): sensor.sensor_schema( 129 | unit_of_measurement=UNIT_HERTZ, 130 | accuracy_decimals=2, 131 | device_class=DEVICE_CLASS_FREQUENCY, 132 | state_class=STATE_CLASS_MEASUREMENT, 133 | ), 134 | cv.Optional(CONF_AC_POWER): sensor.sensor_schema( 135 | unit_of_measurement=UNIT_WATT, 136 | accuracy_decimals=0, 137 | device_class=DEVICE_CLASS_POWER, 138 | state_class=STATE_CLASS_MEASUREMENT, 139 | ), 140 | cv.Optional(CONF_RUNTIME_TOTAL): sensor.sensor_schema( 141 | unit_of_measurement=UNIT_HOURS, 142 | icon=ICON_TIMER, 143 | accuracy_decimals=0, 144 | device_class=DEVICE_CLASS_EMPTY, 145 | state_class=STATE_CLASS_TOTAL_INCREASING, 146 | ), 147 | cv.Optional(CONF_ERROR_BITS): sensor.sensor_schema( 148 | unit_of_measurement=UNIT_EMPTY, 149 | icon=ICON_ERROR_BITS, 150 | accuracy_decimals=0, 151 | device_class=DEVICE_CLASS_EMPTY, 152 | ), 153 | cv.Optional(CONF_MODE): sensor.sensor_schema( 154 | unit_of_measurement=UNIT_EMPTY, 155 | icon=ICON_MODE, 156 | accuracy_decimals=0, 157 | device_class=DEVICE_CLASS_EMPTY, 158 | ), 159 | cv.Optional(CONF_TEMPERATURE): sensor.sensor_schema( 160 | unit_of_measurement=UNIT_CELSIUS, 161 | accuracy_decimals=0, 162 | device_class=DEVICE_CLASS_TEMPERATURE, 163 | state_class=STATE_CLASS_MEASUREMENT, 164 | ), 165 | cv.Optional(CONF_GRID_VOLTAGE_FAULT): sensor.sensor_schema( 166 | unit_of_measurement=UNIT_VOLT, 167 | icon=ICON_EMPTY, 168 | accuracy_decimals=1, 169 | device_class=DEVICE_CLASS_VOLTAGE, 170 | state_class=STATE_CLASS_MEASUREMENT, 171 | ), 172 | cv.Optional(CONF_GRID_FREQUENCY_FAULT): sensor.sensor_schema( 173 | unit_of_measurement=UNIT_HERTZ, 174 | accuracy_decimals=2, 175 | device_class=DEVICE_CLASS_FREQUENCY, 176 | state_class=STATE_CLASS_MEASUREMENT, 177 | ), 178 | cv.Optional(CONF_DC_INJECTION_FAULT): sensor.sensor_schema( 179 | unit_of_measurement=UNIT_AMPERE, 180 | accuracy_decimals=1, 181 | device_class=DEVICE_CLASS_CURRENT, 182 | state_class=STATE_CLASS_MEASUREMENT, 183 | ), 184 | cv.Optional(CONF_TEMPERATURE_FAULT): sensor.sensor_schema( 185 | unit_of_measurement=UNIT_CELSIUS, 186 | accuracy_decimals=0, 187 | device_class=DEVICE_CLASS_TEMPERATURE, 188 | state_class=STATE_CLASS_MEASUREMENT, 189 | ), 190 | cv.Optional(CONF_PV1_VOLTAGE_FAULT): sensor.sensor_schema( 191 | unit_of_measurement=UNIT_VOLT, 192 | accuracy_decimals=1, 193 | device_class=DEVICE_CLASS_VOLTAGE, 194 | state_class=STATE_CLASS_MEASUREMENT, 195 | ), 196 | cv.Optional(CONF_PV2_VOLTAGE_FAULT): sensor.sensor_schema( 197 | unit_of_measurement=UNIT_VOLT, 198 | accuracy_decimals=1, 199 | device_class=DEVICE_CLASS_VOLTAGE, 200 | state_class=STATE_CLASS_MEASUREMENT, 201 | ), 202 | cv.Optional(CONF_GFC_FAULT): sensor.sensor_schema( 203 | unit_of_measurement=UNIT_AMPERE, 204 | accuracy_decimals=1, 205 | device_class=DEVICE_CLASS_CURRENT, 206 | state_class=STATE_CLASS_MEASUREMENT, 207 | ), 208 | } 209 | ) 210 | 211 | 212 | async def to_code(config): 213 | hub = await cg.get_variable(config[CONF_SOLAX_X1_MINI_ID]) 214 | for key in SENSORS: 215 | if key in config: 216 | conf = config[key] 217 | sens = await sensor.new_sensor(conf) 218 | cg.add(getattr(hub, f"set_{key}_sensor")(sens)) 219 | -------------------------------------------------------------------------------- /components/solax_x1_mini/solax_x1_mini.cpp: -------------------------------------------------------------------------------- 1 | #include "solax_x1_mini.h" 2 | #include "esphome/core/log.h" 3 | 4 | namespace esphome { 5 | namespace solax_x1_mini { 6 | 7 | static const char *const TAG = "solax_x1_mini"; 8 | 9 | static const uint8_t FUNCTION_STATUS_REPORT = 0x82; 10 | static const uint8_t FUNCTION_DEVICE_INFO = 0x83; 11 | static const uint8_t FUNCTION_CONFIG_SETTINGS = 0x84; 12 | 13 | // SolaxPower Single Phase External Communication Protocol - X1 Series V1.2.pdf 14 | // SolaxPower Single Phase External Communication Protocol - X1 Series V1.8.pdf 15 | static const uint8_t MODES_SIZE = 7; 16 | static const std::string MODES[MODES_SIZE] = { 17 | "Wait", // 0 18 | "Check", // 1 19 | "Normal", // 2 20 | "Fault", // 3 21 | "Permanent Fault", // 4 22 | "Update", // 5 23 | "Self Test", // 6 24 | }; 25 | 26 | // SolaxPower Single Phase External Communication Protocol - X1 Series V1.8.pdf 27 | static const uint8_t ERRORS_SIZE = 32; 28 | static const char *const ERRORS[ERRORS_SIZE] = { 29 | "TZ Protect Fault", // 0000 0000 0000 0000 0000 0000 0000 0001 (1) 30 | "Grid Lost Fault", // 0000 0000 0000 0000 0000 0000 0000 0010 (2) 31 | "Grid Voltage Fault", // 0000 0000 0000 0000 0000 0000 0000 0100 (3) 32 | "Grid Frequency Fault", // 0000 0000 0000 0000 0000 0000 0000 1000 (4) 33 | "PLL Lost Fault", // 0000 0000 0000 0000 0000 0000 0001 0000 (5) 34 | "Bus Voltage Fault", // 0000 0000 0000 0000 0000 0000 0010 0000 (6) 35 | "Error (Bit 6)", // 0000 0000 0000 0000 0000 0000 0100 0000 (7) 36 | "Oscillator Fault", // 0000 0000 0000 0000 0000 0000 1000 0000 (8) 37 | "DCI Over Current Protection Fault", // 0000 0000 0000 0000 0000 0001 0000 0000 (9) 38 | "Residual Current Fault", // 0000 0000 0000 0000 0000 0010 0000 0000 (10) 39 | "PV Voltage Fault", // 0000 0000 0000 0000 0000 0100 0000 0000 (11) 40 | "AC voltage out of range since 10 minutes", // 0000 0000 0000 0000 0000 1000 0000 0000 (12) 41 | "Isolation Fault", // 0000 0000 0000 0000 0001 0000 0000 0000 (13) 42 | "Over Temperature Fault", // 0000 0000 0000 0000 0010 0000 0000 0000 (14) 43 | "Fan Fault", // 0000 0000 0000 0000 0100 0000 0000 0000 (15) 44 | "Error (Bit 15)", // 0000 0000 0000 0000 1000 0000 0000 0000 (16) 45 | "SPI Communication Fault", // 0000 0000 0000 0001 0000 0000 0000 0000 (17) 46 | "SCI Communication Fault", // 0000 0000 0000 0010 0000 0000 0000 0000 (18) 47 | "Error (Bit 18)", // 0000 0000 0000 0100 0000 0000 0000 0000 (19) 48 | "Input Configuration Fault", // 0000 0000 0000 1000 0000 0000 0000 0000 (20) 49 | "EEPROM Fault", // 0000 0000 0001 0000 0000 0000 0000 0000 (21) 50 | "Relay Fault", // 0000 0000 0010 0000 0000 0000 0000 0000 (22) 51 | "Sample Consistence Fault", // 0000 0000 0100 0000 0000 0000 0000 0000 (23) 52 | "Residual Current Device Fault", // 0000 0000 1000 0000 0000 0000 0000 0000 (24) 53 | "Error (Bit 24)", // 0000 0001 0000 0000 0000 0000 0000 0000 (25) 54 | "Error (Bit 25)", // 0000 0010 0000 0000 0000 0000 0000 0000 (26) 55 | "Error (Bit 26)", // 0000 0100 0000 0000 0000 0000 0000 0000 (27) 56 | "Error (Bit 27)", // 0000 1000 0000 0000 0000 0000 0000 0000 (28) 57 | "Error (Bit 28)", // 0001 0000 0000 0000 0000 0000 0000 0000 (29) 58 | "DCI Device Fault", // 0010 0000 0000 0000 0000 0000 0000 0000 (30) 59 | "Other Device Fault", // 0100 0000 0000 0000 0000 0000 0000 0000 (31) 60 | "Error (Bit 31)", // 1000 0000 0000 0000 0000 0000 0000 0000 (32) 61 | }; 62 | 63 | void SolaxX1Mini::on_solax_modbus_data(const uint8_t &function, const std::vector &data) { 64 | switch (function) { 65 | case FUNCTION_DEVICE_INFO: 66 | this->decode_device_info_(data); 67 | break; 68 | case FUNCTION_STATUS_REPORT: 69 | this->decode_status_report_(data); 70 | break; 71 | case FUNCTION_CONFIG_SETTINGS: 72 | this->decode_config_settings_(data); 73 | break; 74 | default: 75 | ESP_LOGW(TAG, "Unhandled solax frame: %s", format_hex_pretty(&data.front(), data.size()).c_str()); 76 | } 77 | } 78 | 79 | void SolaxX1Mini::decode_device_info_(const std::vector &data) { 80 | if (data.size() != 58) { 81 | ESP_LOGW(TAG, "Invalid response size: %zu", data.size()); 82 | return; 83 | } 84 | 85 | ESP_LOGI(TAG, "Device info frame received"); 86 | ESP_LOGI(TAG, " Device type: %d", data[0]); 87 | ESP_LOGI(TAG, " Rated power: %s", std::string(data.begin() + 1, data.begin() + 1 + 6).c_str()); 88 | ESP_LOGI(TAG, " Firmware version: %s", std::string(data.begin() + 7, data.begin() + 7 + 5).c_str()); 89 | ESP_LOGI(TAG, " Module name: %s", std::string(data.begin() + 12, data.begin() + 12 + 14).c_str()); 90 | ESP_LOGI(TAG, " Manufacturer: %s", std::string(data.begin() + 26, data.begin() + 26 + 14).c_str()); 91 | ESP_LOGI(TAG, " Serial number: %s", std::string(data.begin() + 40, data.begin() + 40 + 14).c_str()); 92 | ESP_LOGI(TAG, " Rated bus voltage: %s", std::string(data.begin() + 54, data.begin() + 54 + 4).c_str()); 93 | 94 | this->no_response_count_ = 0; 95 | } 96 | 97 | void SolaxX1Mini::decode_config_settings_(const std::vector &data) { 98 | if (data.size() != 68) { 99 | ESP_LOGW(TAG, "Invalid response size: %zu", data.size()); 100 | return; 101 | } 102 | 103 | auto solax_get_16bit = [&](size_t i) -> uint16_t { 104 | return (uint16_t(data[i + 0]) << 8) | (uint16_t(data[i + 1]) << 0); 105 | }; 106 | 107 | ESP_LOGI(TAG, "Config settings frame received"); 108 | ESP_LOGI(TAG, " wVpvStart [9.10]: %f V", solax_get_16bit(0) * 0.1f); 109 | ESP_LOGI(TAG, " wTimeStart [11.12]: %d S", solax_get_16bit(2)); 110 | ESP_LOGI(TAG, " wVacMinProtect [13.14]: %f V", solax_get_16bit(4) * 0.1f); 111 | ESP_LOGI(TAG, " wVacMaxProtect [15.16]: %f V", solax_get_16bit(6) * 0.1f); 112 | ESP_LOGI(TAG, " wFacMinProtect [17.18]: %f Hz", solax_get_16bit(8) * 0.01f); 113 | ESP_LOGI(TAG, " wFacMaxProtect [19.20]: %f Hz", solax_get_16bit(10) * 0.01f); 114 | ESP_LOGI(TAG, " wDciLimits [21.22]: %d mA", solax_get_16bit(12)); 115 | ESP_LOGI(TAG, " wGrid10MinAvgProtect [23,24]: %f V", solax_get_16bit(14) * 0.1f); 116 | ESP_LOGI(TAG, " wVacMinSlowProtect [25.26]: %f V", solax_get_16bit(16) * 0.1f); 117 | ESP_LOGI(TAG, " wVacMaxSlowProtect [27.28]: %f V", solax_get_16bit(18) * 0.1f); 118 | ESP_LOGI(TAG, " wFacMinSlowProtect [29.30]: %f Hz", solax_get_16bit(20) * 0.01f); 119 | ESP_LOGI(TAG, " wFacMaxSlowProtect [31.32]: %f Hz", solax_get_16bit(22) * 0.01f); 120 | ESP_LOGI(TAG, " wSafety [33.34]: %d", solax_get_16bit(24)); 121 | // Supported safety values: 122 | // 123 | // 0: VDE0126 124 | // 1: VDE4105 125 | // 2: AS4777 126 | // 3: G98 127 | // 4: C10_11 128 | // 5: TOR 129 | // 6: EN50438_NL 130 | // 7: Denmark2019_W 131 | // 8: CEB 132 | // 9: Cyprus2019 133 | // 10: cNRS097_2_1 134 | // 11: VDE0126_Greece 135 | // 12: UTE_C15_712_Fr 136 | // 13: IEC61727 137 | // 14: G99 138 | // 15: CQC 139 | // 16: VDE0126_Greece_is 140 | // 17: C15_712_Fr_island_50 141 | // 18: C15_712_Fr_island_60 142 | // 19: Guyana 143 | // 20: MEA_Thailand 144 | // 21: PEA_Thailand 145 | // 22: cNewZealand 146 | // 23: cIreland 147 | // 24: cCE10_21 148 | // 25: cRD1699 149 | // 26: EN50438_Sweden 150 | // 27: EN50549_PL 151 | // 28: Czech PPDS 152 | // 29: EN50438_Norway 153 | // 30: EN50438_Portug 154 | // 31: cCQC_WideRange 155 | // 32: BRAZIL 156 | // 33: EN50438_CEZ 157 | // 34: IEC_Chile 158 | // 35: Sri_Lanka 159 | // 36: BRAZIL_240 160 | // 37: EN50549-SK 161 | // 38: EN50549_EU 162 | // 39: G98/NI 163 | // 40: Denmark2019_E 164 | // 41: RD1699_island 165 | ESP_LOGI(TAG, " wPowerfactor_mode [35]: %d", data[26]); 166 | ESP_LOGI(TAG, " wPowerfactor_data [36]: %d", data[27]); 167 | ESP_LOGI(TAG, " wUpperLimit [37]: %d", data[28]); 168 | ESP_LOGI(TAG, " wLowerLimit [38]: %d", data[29]); 169 | ESP_LOGI(TAG, " wPowerLow [39]: %d", data[30]); 170 | ESP_LOGI(TAG, " wPowerUp [40]: %d", data[31]); 171 | ESP_LOGI(TAG, " Qpower_set [41.42]: %d", solax_get_16bit(32)); 172 | ESP_LOGI(TAG, " WFreqSetPoint [43.44]: %f Hz", solax_get_16bit(34) * 0.01f); 173 | ESP_LOGI(TAG, " WFreqDropRate [45.46]: %d", solax_get_16bit(36)); 174 | ESP_LOGI(TAG, " QuVupRate [47.48]: %d", solax_get_16bit(38)); 175 | ESP_LOGI(TAG, " QuVlowRate [49.50]: %d", solax_get_16bit(40)); 176 | ESP_LOGI(TAG, " WPowerLimitsPercent [51.52]: %d", solax_get_16bit(42)); 177 | ESP_LOGI(TAG, " WWgra [53.54]: %f %%", solax_get_16bit(44) * 0.01f); 178 | ESP_LOGI(TAG, " wWv2 [55.56]: %f V", solax_get_16bit(46) * 0.1f); 179 | ESP_LOGI(TAG, " wWv3 [57.58]: %f V", solax_get_16bit(48) * 0.1f); 180 | ESP_LOGI(TAG, " wWv4 [59.60]: %f V", solax_get_16bit(50) * 0.1f); 181 | ESP_LOGI(TAG, " wQurangeV1 [61.62]: %d %%", solax_get_16bit(52)); 182 | ESP_LOGI(TAG, " wQurangeV4 [63.64]: %d %%", solax_get_16bit(54)); 183 | ESP_LOGI(TAG, " BVoltPowerLimit [65.66]: %d", solax_get_16bit(56)); 184 | ESP_LOGI(TAG, " WPowerManagerEnable [67.68]: %d", solax_get_16bit(58)); 185 | ESP_LOGI(TAG, " WGlobalSearchMPPTStartFlag [69.70]: %d", solax_get_16bit(60)); 186 | ESP_LOGI(TAG, " WFreqProtectRestrictive [71.72]: %d", solax_get_16bit(62)); 187 | ESP_LOGI(TAG, " WQuDelayTimer [73.74]: %d S", solax_get_16bit(64)); 188 | ESP_LOGI(TAG, " WFreqActivePowerDelayTimer [75.76]: %d ms", solax_get_16bit(66)); 189 | 190 | this->no_response_count_ = 0; 191 | } 192 | 193 | void SolaxX1Mini::decode_status_report_(const std::vector &data) { 194 | if (data.size() != 52 && data.size() != 50 && data.size() != 56) { 195 | // Solax X1 mini status report (data_len 0x34: 52 bytes): 196 | // AA.55.00.0A.01.00.11.82.34.00.1A.00.02.00.00.00.00.00.00.00.00.00.00.09.21.13.87.00.00.FF.FF. 197 | // 00.00.00.12.00.00.00.15.00.00.00.00.00.00.00.00.00.00.00.00.00.00.00.00.00.00.00.00.00.00.04.D6 198 | 199 | // Solax X1 mini g2 status report (data_len 0x32: 50 bytes): 200 | // AA.55.00.0A.01.00.11.82.32.00.21.00.02.07.EC.00.00.00.1D.00.00.00.18.09.55.13.80.02.2B.FF.FF. 201 | // 00.00.5D.AF.00.00.10.50.00.02.00.00.00.00.00.00.00.00.00.00.00.00.00.00.00.00.00.00.07.A4 202 | 203 | // Solax X1 mini g3 status report (data_len 0x38: 56 bytes): 204 | // AA.55.00.0A.01.00.11.82.38.00.1A.00.03.04.0C.00.00.00.19.00.00.00.0B.08.FC.13.8A.00.F8.FF.FF. 205 | // 00.00.00.2B.00.00.00.0D.00.02.00.00.00.00.00.00.00.00.00.00.00.00.00.00.00.00.00.00.00.00.00.8A.00.DE.08.5F 206 | ESP_LOGW(TAG, "Invalid response size: %zu", data.size()); 207 | ESP_LOGW(TAG, "Your device is probably not supported. Please create an issue here: " 208 | "https://github.com/syssi/esphome-solax-x1-mini/issues"); 209 | ESP_LOGW(TAG, "Please provide the following status response data: %s", 210 | format_hex_pretty(&data.front(), data.size()).c_str()); 211 | return; 212 | } 213 | 214 | auto solax_get_16bit = [&](size_t i) -> uint16_t { 215 | return (uint16_t(data[i + 0]) << 8) | (uint16_t(data[i + 1]) << 0); 216 | }; 217 | auto solax_get_32bit = [&](size_t i) -> uint32_t { 218 | return uint32_t((data[i] << 24) | (data[i + 1] << 16) | (data[i + 2] << 8) | data[i + 3]); 219 | }; 220 | auto solax_get_error_bitmask = [&](size_t i) -> uint32_t { 221 | return uint32_t((data[i + 3] << 24) | (data[i + 2] << 16) | (data[i + 1] << 8) | data[i]); 222 | }; 223 | 224 | ESP_LOGI(TAG, "Status frame received"); 225 | 226 | this->publish_state_(this->temperature_sensor_, (int16_t) solax_get_16bit(0)); 227 | this->publish_state_(this->energy_today_sensor_, solax_get_16bit(2) * 0.1f); 228 | this->publish_state_(this->dc1_voltage_sensor_, solax_get_16bit(4) * 0.1f); 229 | this->publish_state_(this->dc2_voltage_sensor_, solax_get_16bit(6) * 0.1f); 230 | this->publish_state_(this->dc1_current_sensor_, solax_get_16bit(8) * 0.1f); 231 | this->publish_state_(this->dc2_current_sensor_, solax_get_16bit(10) * 0.1f); 232 | this->publish_state_(this->ac_current_sensor_, solax_get_16bit(12) * 0.1f); 233 | this->publish_state_(this->ac_voltage_sensor_, solax_get_16bit(14) * 0.1f); 234 | this->publish_state_(this->ac_frequency_sensor_, solax_get_16bit(16) * 0.01f); 235 | this->publish_state_(this->ac_power_sensor_, solax_get_16bit(18)); 236 | 237 | // register 20 is not used 238 | 239 | uint32_t raw_energy_total = solax_get_32bit(22); 240 | // The inverter publishes a zero once per day on boot-up. This confuses the energy dashboard. 241 | if (raw_energy_total > 0) { 242 | this->publish_state_(this->energy_total_sensor_, raw_energy_total * 0.1f); 243 | } 244 | 245 | uint32_t raw_runtime_total = solax_get_32bit(26); 246 | if (raw_runtime_total > 0) { 247 | this->publish_state_(this->runtime_total_sensor_, (float) raw_runtime_total); 248 | } 249 | 250 | uint8_t mode = (uint8_t) solax_get_16bit(30); 251 | this->publish_state_(this->mode_sensor_, mode); 252 | this->publish_state_(this->mode_name_text_sensor_, (mode < MODES_SIZE) ? MODES[mode] : "Unknown"); 253 | 254 | this->publish_state_(this->grid_voltage_fault_sensor_, solax_get_16bit(32) * 0.1f); 255 | this->publish_state_(this->grid_frequency_fault_sensor_, solax_get_16bit(34) * 0.01f); 256 | this->publish_state_(this->dc_injection_fault_sensor_, solax_get_16bit(36) * 0.001f); 257 | this->publish_state_(this->temperature_fault_sensor_, (float) solax_get_16bit(38)); 258 | this->publish_state_(this->pv1_voltage_fault_sensor_, solax_get_16bit(40) * 0.1f); 259 | this->publish_state_(this->pv2_voltage_fault_sensor_, solax_get_16bit(42) * 0.1f); 260 | this->publish_state_(this->gfc_fault_sensor_, solax_get_16bit(44) * 0.001f); 261 | 262 | uint32_t error_bits = solax_get_error_bitmask(46); 263 | this->publish_state_(this->error_bits_sensor_, error_bits); 264 | this->publish_state_(this->errors_text_sensor_, this->error_bits_to_string_(error_bits)); 265 | 266 | if (data.size() > 50) { 267 | ESP_LOGD(TAG, " CT Pgrid: %d W", solax_get_16bit(50)); 268 | } 269 | 270 | this->no_response_count_ = 0; 271 | } 272 | 273 | void SolaxX1Mini::publish_device_offline_() { 274 | this->publish_state_(this->mode_sensor_, -1); 275 | this->publish_state_(this->mode_name_text_sensor_, "Offline"); 276 | 277 | this->publish_state_(this->temperature_sensor_, NAN); 278 | this->publish_state_(this->dc1_voltage_sensor_, 0); 279 | this->publish_state_(this->dc2_voltage_sensor_, 0); 280 | this->publish_state_(this->dc1_current_sensor_, 0); 281 | this->publish_state_(this->dc2_current_sensor_, 0); 282 | this->publish_state_(this->ac_current_sensor_, 0); 283 | this->publish_state_(this->ac_voltage_sensor_, NAN); 284 | this->publish_state_(this->ac_frequency_sensor_, NAN); 285 | this->publish_state_(this->ac_power_sensor_, 0); 286 | this->publish_state_(this->grid_voltage_fault_sensor_, NAN); 287 | this->publish_state_(this->grid_frequency_fault_sensor_, NAN); 288 | this->publish_state_(this->dc_injection_fault_sensor_, NAN); 289 | this->publish_state_(this->temperature_fault_sensor_, NAN); 290 | this->publish_state_(this->pv1_voltage_fault_sensor_, NAN); 291 | this->publish_state_(this->pv2_voltage_fault_sensor_, NAN); 292 | this->publish_state_(this->gfc_fault_sensor_, NAN); 293 | } 294 | 295 | void SolaxX1Mini::update() { 296 | if (this->no_response_count_ >= REDISCOVERY_THRESHOLD) { 297 | this->publish_device_offline_(); 298 | ESP_LOGD(TAG, "The device is or was offline. Broadcasting discovery for address configuration..."); 299 | this->discover_devices(); 300 | // this->query_device_info(this->address_); 301 | // Try to query live data on next update again. The device doesn't 302 | // respond to the discovery broadcast if it's already configured. 303 | this->no_response_count_ = 0; 304 | } else { 305 | this->no_response_count_++; 306 | this->query_status_report(this->address_); 307 | } 308 | } 309 | 310 | void SolaxX1Mini::publish_state_(sensor::Sensor *sensor, float value) { 311 | if (sensor == nullptr) 312 | return; 313 | 314 | sensor->publish_state(value); 315 | } 316 | 317 | void SolaxX1Mini::publish_state_(text_sensor::TextSensor *text_sensor, const std::string &state) { 318 | if (text_sensor == nullptr) 319 | return; 320 | 321 | text_sensor->publish_state(state); 322 | } 323 | 324 | void SolaxX1Mini::dump_config() { 325 | ESP_LOGCONFIG(TAG, "SolaxX1Mini:"); 326 | ESP_LOGCONFIG(TAG, " Address: 0x%02X", this->address_); 327 | LOG_SENSOR("", "Temperature", this->temperature_sensor_); 328 | LOG_SENSOR("", "Energy today", this->energy_today_sensor_); 329 | LOG_SENSOR("", "DC1 voltage", this->dc1_voltage_sensor_); 330 | LOG_SENSOR("", "DC2 voltage", this->dc2_voltage_sensor_); 331 | LOG_SENSOR("", "DC1 current", this->dc1_current_sensor_); 332 | LOG_SENSOR("", "DC2 current", this->dc2_current_sensor_); 333 | LOG_SENSOR("", "AC current", this->ac_current_sensor_); 334 | LOG_SENSOR("", "AC voltage", this->ac_voltage_sensor_); 335 | LOG_SENSOR("", "AC frequency", this->ac_frequency_sensor_); 336 | LOG_SENSOR("", "AC power", this->ac_power_sensor_); 337 | LOG_SENSOR("", "Energy total", this->energy_total_sensor_); 338 | LOG_SENSOR("", "Runtime total", this->runtime_total_sensor_); 339 | LOG_SENSOR("", "Mode", this->mode_sensor_); 340 | LOG_SENSOR("", "Error bits", this->error_bits_sensor_); 341 | LOG_SENSOR("", "Grid voltage fault", this->grid_voltage_fault_sensor_); 342 | LOG_SENSOR("", "Grid frequency fault", this->grid_frequency_fault_sensor_); 343 | LOG_SENSOR("", "DC injection fault", this->dc_injection_fault_sensor_); 344 | LOG_SENSOR("", "Temperature fault", this->temperature_fault_sensor_); 345 | LOG_SENSOR("", "PV1 voltage fault", this->pv1_voltage_fault_sensor_); 346 | LOG_SENSOR("", "PV2 voltage fault", this->pv2_voltage_fault_sensor_); 347 | LOG_SENSOR("", "GFC fault", this->gfc_fault_sensor_); 348 | LOG_TEXT_SENSOR(" ", "Mode name", this->mode_name_text_sensor_); 349 | LOG_TEXT_SENSOR(" ", "Errors", this->errors_text_sensor_); 350 | } 351 | 352 | std::string SolaxX1Mini::error_bits_to_string_(const uint32_t mask) { 353 | std::string values = ""; 354 | if (mask) { 355 | for (int i = 0; i < ERRORS_SIZE; i++) { 356 | if (mask & (1 << i)) { 357 | values.append(ERRORS[i]); 358 | values.append(";"); 359 | } 360 | } 361 | if (!values.empty()) { 362 | values.pop_back(); 363 | } 364 | } 365 | return values; 366 | } 367 | 368 | } // namespace solax_x1_mini 369 | } // namespace esphome 370 | -------------------------------------------------------------------------------- /components/solax_x1_mini/solax_x1_mini.h: -------------------------------------------------------------------------------- 1 | #pragma once 2 | 3 | #include "esphome/core/component.h" 4 | #include "esphome/components/sensor/sensor.h" 5 | #include "esphome/components/text_sensor/text_sensor.h" 6 | #include "esphome/components/solax_modbus/solax_modbus.h" 7 | 8 | namespace esphome { 9 | namespace solax_x1_mini { 10 | 11 | static const uint8_t REDISCOVERY_THRESHOLD = 5; 12 | 13 | class SolaxX1Mini : public PollingComponent, public solax_modbus::SolaxModbusDevice { 14 | public: 15 | void set_energy_today_sensor(sensor::Sensor *energy_today_sensor) { energy_today_sensor_ = energy_today_sensor; } 16 | void set_energy_total_sensor(sensor::Sensor *energy_total_sensor) { energy_total_sensor_ = energy_total_sensor; } 17 | void set_dc1_current_sensor(sensor::Sensor *dc1_current_sensor) { dc1_current_sensor_ = dc1_current_sensor; } 18 | void set_dc2_current_sensor(sensor::Sensor *dc2_current_sensor) { dc2_current_sensor_ = dc2_current_sensor; } 19 | void set_dc1_voltage_sensor(sensor::Sensor *dc1_voltage_sensor) { dc1_voltage_sensor_ = dc1_voltage_sensor; } 20 | void set_dc2_voltage_sensor(sensor::Sensor *dc2_voltage_sensor) { dc2_voltage_sensor_ = dc2_voltage_sensor; } 21 | void set_ac_current_sensor(sensor::Sensor *ac_current_sensor) { ac_current_sensor_ = ac_current_sensor; } 22 | void set_ac_frequency_sensor(sensor::Sensor *ac_frequency_sensor) { ac_frequency_sensor_ = ac_frequency_sensor; } 23 | void set_ac_power_sensor(sensor::Sensor *ac_power_sensor) { ac_power_sensor_ = ac_power_sensor; } 24 | void set_ac_voltage_sensor(sensor::Sensor *ac_voltage_sensor) { ac_voltage_sensor_ = ac_voltage_sensor; } 25 | void set_temperature_sensor(sensor::Sensor *temperature_sensor) { temperature_sensor_ = temperature_sensor; } 26 | void set_mode_sensor(sensor::Sensor *mode_sensor) { mode_sensor_ = mode_sensor; } 27 | void set_mode_name_text_sensor(text_sensor::TextSensor *sensor) { this->mode_name_text_sensor_ = sensor; } 28 | void set_error_bits_sensor(sensor::Sensor *error_bits_sensor) { error_bits_sensor_ = error_bits_sensor; } 29 | void set_errors_text_sensor(text_sensor::TextSensor *sensor) { this->errors_text_sensor_ = sensor; } 30 | void set_runtime_total_sensor(sensor::Sensor *runtime_total_sensor) { runtime_total_sensor_ = runtime_total_sensor; } 31 | void set_grid_voltage_fault_sensor(sensor::Sensor *grid_voltage_fault_sensor) { 32 | grid_voltage_fault_sensor_ = grid_voltage_fault_sensor; 33 | } 34 | void set_grid_frequency_fault_sensor(sensor::Sensor *grid_frequency_fault_sensor) { 35 | grid_frequency_fault_sensor_ = grid_frequency_fault_sensor; 36 | } 37 | void set_dc_injection_fault_sensor(sensor::Sensor *dc_injection_fault_sensor) { 38 | dc_injection_fault_sensor_ = dc_injection_fault_sensor; 39 | } 40 | void set_temperature_fault_sensor(sensor::Sensor *temperature_fault_sensor) { 41 | temperature_fault_sensor_ = temperature_fault_sensor; 42 | } 43 | void set_pv1_voltage_fault_sensor(sensor::Sensor *pv1_voltage_fault_sensor) { 44 | pv1_voltage_fault_sensor_ = pv1_voltage_fault_sensor; 45 | } 46 | void set_pv2_voltage_fault_sensor(sensor::Sensor *pv2_voltage_fault_sensor) { 47 | pv2_voltage_fault_sensor_ = pv2_voltage_fault_sensor; 48 | } 49 | void set_gfc_fault_sensor(sensor::Sensor *gfc_fault_sensor) { gfc_fault_sensor_ = gfc_fault_sensor; } 50 | 51 | uint8_t get_no_response_count() { return no_response_count_; } 52 | 53 | void update() override; 54 | void on_solax_modbus_data(const uint8_t &function, const std::vector &data) override; 55 | void dump_config() override; 56 | 57 | protected: 58 | sensor::Sensor *energy_today_sensor_; 59 | sensor::Sensor *energy_total_sensor_; 60 | sensor::Sensor *dc1_current_sensor_; 61 | sensor::Sensor *dc2_current_sensor_; 62 | sensor::Sensor *dc1_voltage_sensor_; 63 | sensor::Sensor *dc2_voltage_sensor_; 64 | sensor::Sensor *ac_current_sensor_; 65 | sensor::Sensor *ac_frequency_sensor_; 66 | sensor::Sensor *ac_power_sensor_; 67 | sensor::Sensor *ac_voltage_sensor_; 68 | sensor::Sensor *temperature_sensor_; 69 | sensor::Sensor *mode_sensor_; 70 | sensor::Sensor *error_bits_sensor_; 71 | sensor::Sensor *runtime_total_sensor_; 72 | sensor::Sensor *grid_voltage_fault_sensor_; 73 | sensor::Sensor *grid_frequency_fault_sensor_; 74 | sensor::Sensor *dc_injection_fault_sensor_; 75 | sensor::Sensor *temperature_fault_sensor_; 76 | sensor::Sensor *pv1_voltage_fault_sensor_; 77 | sensor::Sensor *pv2_voltage_fault_sensor_; 78 | sensor::Sensor *gfc_fault_sensor_; 79 | 80 | text_sensor::TextSensor *mode_name_text_sensor_; 81 | text_sensor::TextSensor *errors_text_sensor_; 82 | uint8_t no_response_count_ = REDISCOVERY_THRESHOLD; 83 | 84 | void decode_device_info_(const std::vector &data); 85 | void decode_status_report_(const std::vector &data); 86 | void decode_config_settings_(const std::vector &data); 87 | void publish_state_(sensor::Sensor *sensor, float value); 88 | void publish_state_(text_sensor::TextSensor *text_sensor, const std::string &state); 89 | void publish_device_offline_(); 90 | std::string error_bits_to_string_(uint32_t bitmask); 91 | }; 92 | 93 | } // namespace solax_x1_mini 94 | } // namespace esphome 95 | -------------------------------------------------------------------------------- /components/solax_x1_mini/text_sensor.py: -------------------------------------------------------------------------------- 1 | import esphome.codegen as cg 2 | from esphome.components import text_sensor 3 | import esphome.config_validation as cv 4 | from esphome.const import CONF_ID 5 | 6 | from . import CONF_SOLAX_X1_MINI_ID, SolaxX1Mini 7 | 8 | DEPENDENCIES = ["solax_x1_mini"] 9 | 10 | CONF_MODE_NAME = "mode_name" 11 | CONF_ERRORS = "errors" 12 | 13 | ICON_MODE_NAME = "mdi:heart-pulse" 14 | ICON_ERRORS = "mdi:alert-circle-outline" 15 | 16 | CONFIG_SCHEMA = cv.Schema( 17 | { 18 | cv.GenerateID(CONF_SOLAX_X1_MINI_ID): cv.use_id(SolaxX1Mini), 19 | cv.Optional(CONF_MODE_NAME): text_sensor.text_sensor_schema( 20 | text_sensor.TextSensor, icon=ICON_MODE_NAME 21 | ), 22 | cv.Optional(CONF_ERRORS): text_sensor.text_sensor_schema( 23 | text_sensor.TextSensor, icon=ICON_ERRORS 24 | ), 25 | } 26 | ) 27 | 28 | 29 | async def to_code(config): 30 | hub = await cg.get_variable(config[CONF_SOLAX_X1_MINI_ID]) 31 | for key in [CONF_MODE_NAME, CONF_ERRORS]: 32 | if key in config: 33 | conf = config[key] 34 | sens = cg.new_Pvariable(conf[CONF_ID]) 35 | await text_sensor.register_text_sensor(sens, conf) 36 | cg.add(getattr(hub, f"set_{key}_text_sensor")(sens)) 37 | -------------------------------------------------------------------------------- /docs/Solax-X1-Mini-0.6-Specs.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/syssi/esphome-solax-x1-mini/aa1882ff6f0bbc349e42c23745b284407921e45d/docs/Solax-X1-Mini-0.6-Specs.png -------------------------------------------------------------------------------- /docs/SolaxPower Single Phase External Communication Protocol - X1 Series V1.8.pdf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/syssi/esphome-solax-x1-mini/aa1882ff6f0bbc349e42c23745b284407921e45d/docs/SolaxPower Single Phase External Communication Protocol - X1 Series V1.8.pdf -------------------------------------------------------------------------------- /docs/SolaxPower_Single_Phase_External_Communication_Protocol_-_X1_Series_V1.2.pdf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/syssi/esphome-solax-x1-mini/aa1882ff6f0bbc349e42c23745b284407921e45d/docs/SolaxPower_Single_Phase_External_Communication_Protocol_-_X1_Series_V1.2.pdf -------------------------------------------------------------------------------- /docs/Solax_X1-MINI-G3_VDE4105_2018_AK-50492620-0001_DE-appendix21.pdf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/syssi/esphome-solax-x1-mini/aa1882ff6f0bbc349e42c23745b284407921e45d/docs/Solax_X1-MINI-G3_VDE4105_2018_AK-50492620-0001_DE-appendix21.pdf -------------------------------------------------------------------------------- /docs/X1-Mini-Install-Manual.pdf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/syssi/esphome-solax-x1-mini/aa1882ff6f0bbc349e42c23745b284407921e45d/docs/X1-Mini-Install-Manual.pdf -------------------------------------------------------------------------------- /docs/X1-Mini-manual-with-CT.pdf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/syssi/esphome-solax-x1-mini/aa1882ff6f0bbc349e42c23745b284407921e45d/docs/X1-Mini-manual-with-CT.pdf -------------------------------------------------------------------------------- /docs/pdus/solax-x1-mini-g1-config.txt: -------------------------------------------------------------------------------- 1 | 0xAA 0x55 0x00 0x0A 0x01 0x00 0x11 0x84 0x44 0x01 0xE0 0x00 0x3C 0x04 0x0B 0x0B 0x3B 0x12 0x8E 0x14 0x1E 0x03 0x84 0x09 0xE2 0x07 0x30 0x0B 0x3B 0x12 0x8E 0x14 0x1E 0x00 0x01 0x00 0x64 0x64 0x5F 0x32 0x64 0x00 0x00 0x13 0x9C 0x00 0x05 0x00 0x67 0x00 0x61 0x00 0x64 0x03 0xE8 0x08 0x98 0x09 0xC4 0x0A 0x5A 0x00 0x2C 0x00 0x2C 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x03 0x00 0x00 0x0D 0x9D 2 | 0xAA 0x55 0x00 0x0A 0x01 0x00 0x11 0x84 0x44 0x01 0xE0 0x00 0x3C 0x04 0x0B 0x0B 0x3B 0x12 0x8E 0x14 0x1E 0x03 0x84 0x09 0xE2 0x07 0x30 0x0B 0x3B 0x12 0x8E 0x14 0x1E 0x00 0x01 0x00 0x64 0x64 0x5F 0x32 0x64 0x00 0x00 0x13 0x9C 0x00 0x05 0x00 0x67 0x00 0x61 0x00 0x64 0x03 0xE8 0x08 0x98 0x09 0xC4 0x0A 0x5A 0x00 0x2C 0x00 0x2C 0x00 0x01 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x03 0x00 0x00 0x0D 0x9E 3 | -------------------------------------------------------------------------------- /docs/pdus/solax-x1-mini-g1-status.txt: -------------------------------------------------------------------------------- 1 | 0xAA 0x55 0x00 0x0A 0x01 0x00 0x11 0x82 0x34 0x00 0x1A 0x00 0x02 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x09 0x21 0x13 0x87 0x00 0x00 0xFF 0xFF 0x00 0x00 0x00 0x12 0x00 0x00 0x00 0x15 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x04 0xD6 2 | -------------------------------------------------------------------------------- /docs/pdus/solax-x1-mini-g2-status.txt: -------------------------------------------------------------------------------- 1 | 0xAA 0x55 0x00 0x0A 0x01 0x00 0x11 0x82 0x32 0x00 0x21 0x00 0x02 0x07 0xEC 0x00 0x00 0x00 0x1D 0x00 0x00 0x00 0x18 0x09 0x55 0x13 0x80 0x02 0x2B 0xFF 0xFF 0x00 0x00 0x5D 0xAF 0x00 0x00 0x10 0x50 0x00 0x02 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x07 0xA4 2 | -------------------------------------------------------------------------------- /docs/pdus/solax-x1-mini-g3-status.txt: -------------------------------------------------------------------------------- 1 | 0xAA 0x55 0x00 0x0A 0x01 0x00 0x11 0x82 0x38 0x00 0x1A 0x00 0x03 0x04 0x0C 0x00 0x00 0x00 0x19 0x00 0x00 0x00 0x0B 0x08 0xFC 0x13 0x8A 0x00 0xF8 0xFF 0xFF 0x00 0x00 0x00 0x2B 0x00 0x00 0x00 0x0D 0x00 0x02 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x8A 0x00 0xDE 0x08 0x5F 2 | -------------------------------------------------------------------------------- /esp32-example-advanced-multiple-uarts.yaml: -------------------------------------------------------------------------------- 1 | substitutions: 2 | name: solax-x1-mini 3 | device_description: "Monitor multiple Solax X1 mini via RS485" 4 | external_components_source: github://syssi/esphome-solax-x1-mini@main 5 | device0: firstfloor-x1-mini 6 | device1: groundfloor-x1-mini 7 | device2: community-x1-mini 8 | 9 | esphome: 10 | name: ${name} 11 | comment: ${device_description} 12 | min_version: 2024.6.0 13 | project: 14 | name: "syssi.esphome-solax-x1-mini" 15 | version: 2.3.0 16 | 17 | esp32: 18 | board: esp-wrover-kit 19 | 20 | external_components: 21 | - source: ${external_components_source} 22 | refresh: 0s 23 | 24 | ethernet: 25 | type: LAN8720 26 | mdc_pin: GPIO23 27 | mdio_pin: GPIO18 28 | clk_mode: GPIO17_OUT 29 | phy_addr: 0 30 | 31 | ota: 32 | platform: esphome 33 | 34 | logger: 35 | baud_rate: 0 36 | level: DEBUG 37 | 38 | # If you use Home Assistant please remove this `mqtt` section and uncomment the `api` component! 39 | # The native API has many advantages over MQTT: https://esphome.io/components/api.html#advantages-over-mqtt 40 | mqtt: 41 | broker: !secret mqtt_host 42 | username: !secret mqtt_username 43 | password: !secret mqtt_password 44 | id: mqtt_client 45 | 46 | # api: 47 | 48 | uart: 49 | - id: uart_0 50 | baud_rate: 9600 51 | tx_pin: GPIO1 52 | rx_pin: GPIO3 53 | - id: uart_1 54 | baud_rate: 9600 55 | tx_pin: GPIO14 56 | rx_pin: GPIO4 57 | - id: uart_2 58 | baud_rate: 9600 59 | tx_pin: GPIO32 60 | rx_pin: GPIO33 61 | 62 | solax_modbus: 63 | - id: modbus0 64 | uart_id: uart_0 65 | - id: modbus1 66 | uart_id: uart_1 67 | - id: modbus2 68 | uart_id: uart_2 69 | 70 | solax_x1_mini: 71 | - id: solax0 72 | solax_modbus_id: modbus0 73 | update_interval: 2s 74 | - id: solax1 75 | solax_modbus_id: modbus1 76 | update_interval: 2s 77 | - id: solax2 78 | solax_modbus_id: modbus2 79 | update_interval: 2s 80 | 81 | text_sensor: 82 | - platform: solax_x1_mini 83 | solax_x1_mini_id: solax0 84 | mode_name: 85 | name: "${device0} mode name" 86 | errors: 87 | name: "${device0} errors" 88 | 89 | - platform: solax_x1_mini 90 | solax_x1_mini_id: solax1 91 | mode_name: 92 | name: "${device1} mode name" 93 | errors: 94 | name: "${device1} errors" 95 | 96 | - platform: solax_x1_mini 97 | solax_x1_mini_id: solax2 98 | mode_name: 99 | name: "${device2} mode name" 100 | errors: 101 | name: "${device2} errors" 102 | 103 | sensor: 104 | - platform: solax_x1_mini 105 | solax_x1_mini_id: solax0 106 | ac_power: 107 | name: "${device0} ac power" 108 | energy_today: 109 | name: "${device0} energy today" 110 | energy_total: 111 | name: "${device0} energy total" 112 | dc1_voltage: 113 | name: "${device0} dc1 voltage" 114 | dc1_current: 115 | name: "${device0} dc1 current" 116 | ac_current: 117 | name: "${device0} ac current" 118 | ac_voltage: 119 | name: "${device0} ac voltage" 120 | ac_frequency: 121 | name: "${device0} ac frequency" 122 | temperature: 123 | name: "${device0} temperature" 124 | runtime_total: 125 | name: "${device0} runtime total" 126 | mode: 127 | name: "${device0} mode" 128 | error_bits: 129 | name: "${device0} error bits" 130 | grid_voltage_fault: 131 | name: "${device0} grid voltage fault" 132 | grid_frequency_fault: 133 | name: "${device0} grid frequency fault" 134 | dc_injection_fault: 135 | name: "${device0} dc injection fault" 136 | temperature_fault: 137 | name: "${device0} temperature fault" 138 | pv1_voltage_fault: 139 | name: "${device0} pv1 voltage fault" 140 | pv2_voltage_fault: 141 | name: "${device0} pv2 voltage fault" 142 | gfc_fault: 143 | name: "${device0} gfc fault" 144 | 145 | - platform: solax_x1_mini 146 | solax_x1_mini_id: solax1 147 | ac_power: 148 | name: "${device1} ac power" 149 | energy_today: 150 | name: "${device1} energy today" 151 | energy_total: 152 | name: "${device1} energy total" 153 | dc1_voltage: 154 | name: "${device1} dc1 voltage" 155 | dc1_current: 156 | name: "${device1} dc1 current" 157 | ac_current: 158 | name: "${device1} ac current" 159 | ac_voltage: 160 | name: "${device1} ac voltage" 161 | ac_frequency: 162 | name: "${device1} ac frequency" 163 | temperature: 164 | name: "${device1} temperature" 165 | runtime_total: 166 | name: "${device1} runtime total" 167 | mode: 168 | name: "${device1} mode" 169 | error_bits: 170 | name: "${device1} error bits" 171 | grid_voltage_fault: 172 | name: "${device1} grid voltage fault" 173 | grid_frequency_fault: 174 | name: "${device1} grid frequency fault" 175 | dc_injection_fault: 176 | name: "${device1} dc injection fault" 177 | temperature_fault: 178 | name: "${device1} temperature fault" 179 | pv1_voltage_fault: 180 | name: "${device1} pv1 voltage fault" 181 | pv2_voltage_fault: 182 | name: "${device1} pv2 voltage fault" 183 | gfc_fault: 184 | name: "${device1} gfc fault" 185 | 186 | - platform: solax_x1_mini 187 | solax_x1_mini_id: solax2 188 | ac_power: 189 | name: "${device2} ac power" 190 | energy_today: 191 | name: "${device2} energy today" 192 | energy_total: 193 | name: "${device2} energy total" 194 | dc1_voltage: 195 | name: "${device2} dc1 voltage" 196 | dc1_current: 197 | name: "${device2} dc1 current" 198 | ac_current: 199 | name: "${device2} ac current" 200 | ac_voltage: 201 | name: "${device2} ac voltage" 202 | ac_frequency: 203 | name: "${device2} ac frequency" 204 | temperature: 205 | name: "${device2} temperature" 206 | runtime_total: 207 | name: "${device2} runtime total" 208 | mode: 209 | name: "${device2} mode" 210 | error_bits: 211 | name: "${device2} error bits" 212 | grid_voltage_fault: 213 | name: "${device2} grid voltage fault" 214 | grid_frequency_fault: 215 | name: "${device2} grid frequency fault" 216 | dc_injection_fault: 217 | name: "${device2} dc injection fault" 218 | temperature_fault: 219 | name: "${device2} temperature fault" 220 | pv1_voltage_fault: 221 | name: "${device2} pv1 voltage fault" 222 | pv2_voltage_fault: 223 | name: "${device2} pv2 voltage fault" 224 | gfc_fault: 225 | name: "${device2} gfc fault" 226 | -------------------------------------------------------------------------------- /esp32-example.yaml: -------------------------------------------------------------------------------- 1 | substitutions: 2 | name: solax-x1-mini 3 | device_description: "Monitor a Solax X1 mini via RS485" 4 | external_components_source: github://syssi/esphome-solax-x1-mini@main 5 | tx_pin: GPIO16 6 | rx_pin: GPIO17 7 | 8 | esphome: 9 | name: ${name} 10 | comment: ${device_description} 11 | min_version: 2024.6.0 12 | project: 13 | name: "syssi.esphome-solax-x1-mini" 14 | version: 2.3.0 15 | 16 | esp32: 17 | board: wemos_d1_mini32 18 | 19 | external_components: 20 | - source: ${external_components_source} 21 | refresh: 0s 22 | 23 | wifi: 24 | ssid: !secret wifi_ssid 25 | password: !secret wifi_password 26 | 27 | ota: 28 | platform: esphome 29 | 30 | logger: 31 | level: DEBUG 32 | 33 | # If you use Home Assistant please remove this `mqtt` section and uncomment the `api` component! 34 | # The native API has many advantages over MQTT: https://esphome.io/components/api.html#advantages-over-mqtt 35 | mqtt: 36 | broker: !secret mqtt_host 37 | username: !secret mqtt_username 38 | password: !secret mqtt_password 39 | id: mqtt_client 40 | 41 | # api: 42 | 43 | uart: 44 | id: uart_0 45 | baud_rate: 9600 46 | tx_pin: ${tx_pin} 47 | rx_pin: ${rx_pin} 48 | 49 | solax_modbus: 50 | - id: modbus0 51 | uart_id: uart_0 52 | # flow_control_pin: GPIO0 53 | 54 | solax_x1_mini: 55 | solax_modbus_id: modbus0 56 | update_interval: 1s 57 | 58 | text_sensor: 59 | - platform: solax_x1_mini 60 | mode_name: 61 | name: "${name} mode name" 62 | errors: 63 | name: "${name} errors" 64 | 65 | sensor: 66 | - platform: solax_x1_mini 67 | ac_power: 68 | name: "${name} ac power" 69 | energy_today: 70 | name: "${name} energy today" 71 | energy_total: 72 | name: "${name} energy total" 73 | dc1_voltage: 74 | name: "${name} dc1 voltage" 75 | dc2_voltage: 76 | name: "${name} dc2 voltage" 77 | dc1_current: 78 | name: "${name} dc1 current" 79 | dc2_current: 80 | name: "${name} dc2 current" 81 | ac_current: 82 | name: "${name} ac current" 83 | ac_voltage: 84 | name: "${name} ac voltage" 85 | ac_frequency: 86 | name: "${name} ac frequency" 87 | temperature: 88 | name: "${name} temperature" 89 | runtime_total: 90 | name: "${name} runtime total" 91 | mode: 92 | name: "${name} mode" 93 | error_bits: 94 | name: "${name} error bits" 95 | grid_voltage_fault: 96 | name: "${name} grid voltage fault" 97 | grid_frequency_fault: 98 | name: "${name} grid frequency fault" 99 | dc_injection_fault: 100 | name: "${name} dc injection fault" 101 | temperature_fault: 102 | name: "${name} temperature fault" 103 | pv1_voltage_fault: 104 | name: "${name} pv1 voltage fault" 105 | pv2_voltage_fault: 106 | name: "${name} pv2 voltage fault" 107 | gfc_fault: 108 | name: "${name} gfc fault" 109 | -------------------------------------------------------------------------------- /esp32-meter-gateway.yaml: -------------------------------------------------------------------------------- 1 | substitutions: 2 | name: solax-x1-mini 3 | device_description: "Monitor a Solax X1 mini via RS485" 4 | external_components_source: github://syssi/esphome-solax-x1-mini@main 5 | tx_pin: GPIO16 6 | rx_pin: GPIO17 7 | 8 | esphome: 9 | name: ${name} 10 | comment: ${device_description} 11 | min_version: 2024.6.0 12 | project: 13 | name: "syssi.esphome-solax-x1-mini" 14 | version: 2.3.0 15 | 16 | esp32: 17 | board: wemos_d1_mini32 18 | 19 | external_components: 20 | - source: ${external_components_source} 21 | refresh: 0s 22 | 23 | wifi: 24 | ssid: !secret wifi_ssid 25 | password: !secret wifi_password 26 | 27 | ota: 28 | platform: esphome 29 | 30 | logger: 31 | level: DEBUG 32 | 33 | # If you use Home Assistant please remove this `mqtt` section and uncomment the `api` component! 34 | # The native API has many advantages over MQTT: https://esphome.io/components/api.html#advantages-over-mqtt 35 | mqtt: 36 | broker: !secret mqtt_host 37 | username: !secret mqtt_username 38 | password: !secret mqtt_password 39 | id: mqtt_client 40 | 41 | # api: 42 | 43 | uart: 44 | id: uart_0 45 | baud_rate: 9600 46 | tx_pin: ${tx_pin} 47 | rx_pin: ${rx_pin} 48 | 49 | solax_modbus: 50 | - id: modbus0 51 | uart_id: uart_0 52 | # flow_control_pin: GPIO0 53 | 54 | solax_x1_mini: 55 | solax_modbus_id: modbus0 56 | update_interval: 1s 57 | 58 | text_sensor: 59 | - platform: solax_x1_mini 60 | mode_name: 61 | name: "${name} mode name" 62 | errors: 63 | name: "${name} errors" 64 | 65 | sensor: 66 | - platform: solax_x1_mini 67 | ac_power: 68 | name: "${name} ac power" 69 | energy_today: 70 | name: "${name} energy today" 71 | energy_total: 72 | name: "${name} energy total" 73 | dc1_voltage: 74 | name: "${name} dc1 voltage" 75 | dc2_voltage: 76 | name: "${name} dc2 voltage" 77 | dc1_current: 78 | name: "${name} dc1 current" 79 | dc2_current: 80 | name: "${name} dc2 current" 81 | ac_current: 82 | name: "${name} ac current" 83 | ac_voltage: 84 | name: "${name} ac voltage" 85 | ac_frequency: 86 | name: "${name} ac frequency" 87 | temperature: 88 | name: "${name} temperature" 89 | runtime_total: 90 | name: "${name} runtime total" 91 | mode: 92 | name: "${name} mode" 93 | error_bits: 94 | name: "${name} error bits" 95 | grid_voltage_fault: 96 | name: "${name} grid voltage fault" 97 | grid_frequency_fault: 98 | name: "${name} grid frequency fault" 99 | dc_injection_fault: 100 | name: "${name} dc injection fault" 101 | temperature_fault: 102 | name: "${name} temperature fault" 103 | pv1_voltage_fault: 104 | name: "${name} pv1 voltage fault" 105 | pv2_voltage_fault: 106 | name: "${name} pv2 voltage fault" 107 | gfc_fault: 108 | name: "${name} gfc fault" 109 | -------------------------------------------------------------------------------- /esp8266-example.yaml: -------------------------------------------------------------------------------- 1 | substitutions: 2 | name: solax-x1-mini 3 | device_description: "Monitor a Solax X1 mini via RS485" 4 | external_components_source: github://syssi/esphome-solax-x1-mini@main 5 | tx_pin: GPIO4 6 | rx_pin: GPIO5 7 | 8 | esphome: 9 | name: ${name} 10 | comment: ${device_description} 11 | min_version: 2024.6.0 12 | project: 13 | name: "syssi.esphome-solax-x1-mini" 14 | version: 2.3.0 15 | 16 | esp8266: 17 | board: d1_mini 18 | 19 | external_components: 20 | - source: ${external_components_source} 21 | refresh: 0s 22 | 23 | wifi: 24 | ssid: !secret wifi_ssid 25 | password: !secret wifi_password 26 | 27 | ota: 28 | platform: esphome 29 | 30 | logger: 31 | level: DEBUG 32 | 33 | # If you use Home Assistant please remove this `mqtt` section and uncomment the `api` component! 34 | # The native API has many advantages over MQTT: https://esphome.io/components/api.html#advantages-over-mqtt 35 | mqtt: 36 | broker: !secret mqtt_host 37 | username: !secret mqtt_username 38 | password: !secret mqtt_password 39 | id: mqtt_client 40 | 41 | # api: 42 | 43 | uart: 44 | id: uart_0 45 | baud_rate: 9600 46 | tx_pin: ${tx_pin} 47 | rx_pin: ${rx_pin} 48 | 49 | solax_modbus: 50 | - id: modbus0 51 | uart_id: uart_0 52 | # flow_control_pin: GPIO0 53 | 54 | solax_x1_mini: 55 | solax_modbus_id: modbus0 56 | update_interval: 1s 57 | 58 | text_sensor: 59 | - platform: solax_x1_mini 60 | mode_name: 61 | name: "${name} mode name" 62 | errors: 63 | name: "${name} errors" 64 | 65 | sensor: 66 | - platform: solax_x1_mini 67 | ac_power: 68 | name: "${name} ac power" 69 | energy_today: 70 | name: "${name} energy today" 71 | energy_total: 72 | name: "${name} energy total" 73 | dc1_voltage: 74 | name: "${name} dc1 voltage" 75 | dc2_voltage: 76 | name: "${name} dc2 voltage" 77 | dc1_current: 78 | name: "${name} dc1 current" 79 | dc2_current: 80 | name: "${name} dc2 current" 81 | ac_current: 82 | name: "${name} ac current" 83 | ac_voltage: 84 | name: "${name} ac voltage" 85 | ac_frequency: 86 | name: "${name} ac frequency" 87 | temperature: 88 | name: "${name} temperature" 89 | runtime_total: 90 | name: "${name} runtime total" 91 | mode: 92 | name: "${name} mode" 93 | error_bits: 94 | name: "${name} error bits" 95 | grid_voltage_fault: 96 | name: "${name} grid voltage fault" 97 | grid_frequency_fault: 98 | name: "${name} grid frequency fault" 99 | dc_injection_fault: 100 | name: "${name} dc injection fault" 101 | temperature_fault: 102 | name: "${name} temperature fault" 103 | pv1_voltage_fault: 104 | name: "${name} pv1 voltage fault" 105 | pv2_voltage_fault: 106 | name: "${name} pv2 voltage fault" 107 | gfc_fault: 108 | name: "${name} gfc fault" 109 | -------------------------------------------------------------------------------- /esp8266-meter-gateway-multiple-uarts.yaml: -------------------------------------------------------------------------------- 1 | substitutions: 2 | name: solax-meter-gateway 3 | device_description: "Control the export of a Solax X1 mini via RS485" 4 | external_components_source: github://syssi/esphome-modbus-solax-x1@main 5 | instantaneous_power_topic0: "stat/EZ3/P_Wirk_L1" 6 | instantaneous_power_topic1: "stat/EZ3/P_Wirk_L2" 7 | device0: x1-mini-l1 8 | device1: x1-mini-l2 9 | 10 | esphome: 11 | name: ${name} 12 | comment: ${device_description} 13 | min_version: 2024.6.0 14 | project: 15 | name: "syssi.esphome-modbus-solax-x1" 16 | version: 2.3.0 17 | 18 | esp8266: 19 | board: d1_mini 20 | 21 | external_components: 22 | - source: ${external_components_source} 23 | refresh: 0s 24 | 25 | wifi: 26 | ssid: !secret wifi_ssid 27 | password: !secret wifi_password 28 | 29 | ota: 30 | platform: esphome 31 | 32 | logger: 33 | level: DEBUG 34 | 35 | api: 36 | reboot_timeout: 0s 37 | 38 | mqtt: 39 | broker: !secret mqtt_host 40 | username: !secret mqtt_username 41 | password: !secret mqtt_password 42 | id: mqtt_client 43 | 44 | uart: 45 | - id: uart_0 46 | baud_rate: 9600 47 | tx_pin: GPIO1 48 | rx_pin: GPIO3 49 | - id: uart_1 50 | baud_rate: 9600 51 | tx_pin: GPIO14 52 | rx_pin: GPIO4 53 | 54 | solax_meter_modbus: 55 | - id: modbus0 56 | uart_id: uart_0 57 | - id: modbus1 58 | uart_id: uart_1 59 | 60 | solax_meter_gateway: 61 | - id: solax_meter_gateway0 62 | solax_meter_modbus_id: modbus0 63 | address: 0x01 64 | 65 | # The state of this sensor (instantaneous power in watt) is used as source 66 | power_id: powermeter0 67 | power_sensor_inactivity_timeout: 5s 68 | update_interval: 5s 69 | 70 | - id: solax_meter_gateway1 71 | solax_meter_modbus_id: modbus1 72 | address: 0x01 73 | 74 | # The state of this sensor (instantaneous power in watt) is used as source 75 | power_id: powermeter1 76 | power_sensor_inactivity_timeout: 5s 77 | update_interval: 5s 78 | 79 | sensor: 80 | - id: powermeter0 81 | internal: true 82 | platform: mqtt_subscribe 83 | name: "${device0} instantaneous power consumption" 84 | topic: "${instantaneous_power_topic0}" 85 | accuracy_decimals: 2 86 | unit_of_measurement: W 87 | device_class: power 88 | 89 | - id: powermeter1 90 | internal: true 91 | platform: mqtt_subscribe 92 | name: "${device1} instantaneous power consumption" 93 | topic: "${instantaneous_power_topic1}" 94 | accuracy_decimals: 2 95 | unit_of_measurement: W 96 | device_class: power 97 | 98 | - platform: solax_meter_gateway 99 | solax_meter_gateway_id: solax_meter_gateway0 100 | power_demand: 101 | name: "${device0} power demand" 102 | 103 | - platform: solax_meter_gateway 104 | solax_meter_gateway_id: solax_meter_gateway1 105 | power_demand: 106 | name: "${device1} power demand" 107 | 108 | text_sensor: 109 | - platform: solax_meter_gateway 110 | solax_meter_gateway_id: solax_meter_gateway0 111 | operation_mode: 112 | name: "${device0} operation mode" 113 | 114 | - platform: solax_meter_gateway 115 | solax_meter_gateway_id: solax_meter_gateway1 116 | operation_mode: 117 | name: "${device1} operation mode" 118 | 119 | switch: 120 | - platform: solax_meter_gateway 121 | solax_meter_gateway_id: solax_meter_gateway0 122 | emergency_power_off: 123 | name: "${device0} emergency power off" 124 | restore_mode: RESTORE_DEFAULT_OFF 125 | 126 | - platform: solax_meter_gateway 127 | solax_meter_gateway_id: solax_meter_gateway1 128 | emergency_power_off: 129 | name: "${device1} emergency power off" 130 | restore_mode: RESTORE_DEFAULT_OFF 131 | -------------------------------------------------------------------------------- /esp8266-meter-gateway.yaml: -------------------------------------------------------------------------------- 1 | substitutions: 2 | name: solax-x1-mini 3 | device_description: "Monitor a Solax X1 mini via RS485" 4 | external_components_source: github://syssi/esphome-solax-x1-mini@main 5 | tx_pin: GPIO4 6 | rx_pin: GPIO5 7 | 8 | esphome: 9 | name: ${name} 10 | comment: ${device_description} 11 | min_version: 2024.6.0 12 | project: 13 | name: "syssi.esphome-solax-x1-mini" 14 | version: 2.3.0 15 | 16 | esp8266: 17 | board: d1_mini 18 | 19 | external_components: 20 | - source: ${external_components_source} 21 | refresh: 0s 22 | 23 | wifi: 24 | ssid: !secret wifi_ssid 25 | password: !secret wifi_password 26 | 27 | ota: 28 | platform: esphome 29 | 30 | logger: 31 | level: DEBUG 32 | 33 | # If you use Home Assistant please remove this `mqtt` section and uncomment the `api` component! 34 | # The native API has many advantages over MQTT: https://esphome.io/components/api.html#advantages-over-mqtt 35 | mqtt: 36 | broker: !secret mqtt_host 37 | username: !secret mqtt_username 38 | password: !secret mqtt_password 39 | id: mqtt_client 40 | 41 | # api: 42 | 43 | uart: 44 | id: uart_0 45 | baud_rate: 9600 46 | tx_pin: ${tx_pin} 47 | rx_pin: ${rx_pin} 48 | 49 | solax_modbus: 50 | - id: modbus0 51 | uart_id: uart_0 52 | # flow_control_pin: GPIO0 53 | 54 | solax_x1_mini: 55 | solax_modbus_id: modbus0 56 | update_interval: 1s 57 | 58 | text_sensor: 59 | - platform: solax_x1_mini 60 | mode_name: 61 | name: "${name} mode name" 62 | errors: 63 | name: "${name} errors" 64 | 65 | sensor: 66 | - platform: solax_x1_mini 67 | ac_power: 68 | name: "${name} ac power" 69 | energy_today: 70 | name: "${name} energy today" 71 | energy_total: 72 | name: "${name} energy total" 73 | dc1_voltage: 74 | name: "${name} dc1 voltage" 75 | dc2_voltage: 76 | name: "${name} dc2 voltage" 77 | dc1_current: 78 | name: "${name} dc1 current" 79 | dc2_current: 80 | name: "${name} dc2 current" 81 | ac_current: 82 | name: "${name} ac current" 83 | ac_voltage: 84 | name: "${name} ac voltage" 85 | ac_frequency: 86 | name: "${name} ac frequency" 87 | temperature: 88 | name: "${name} temperature" 89 | runtime_total: 90 | name: "${name} runtime total" 91 | mode: 92 | name: "${name} mode" 93 | error_bits: 94 | name: "${name} error bits" 95 | grid_voltage_fault: 96 | name: "${name} grid voltage fault" 97 | grid_frequency_fault: 98 | name: "${name} grid frequency fault" 99 | dc_injection_fault: 100 | name: "${name} dc injection fault" 101 | temperature_fault: 102 | name: "${name} temperature fault" 103 | pv1_voltage_fault: 104 | name: "${name} pv1 voltage fault" 105 | pv2_voltage_fault: 106 | name: "${name} pv2 voltage fault" 107 | gfc_fault: 108 | name: "${name} gfc fault" 109 | -------------------------------------------------------------------------------- /lovelace-entities-card.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/syssi/esphome-solax-x1-mini/aa1882ff6f0bbc349e42c23745b284407921e45d/lovelace-entities-card.png -------------------------------------------------------------------------------- /modbus-examples/esp32-solax-x1-boost.yaml: -------------------------------------------------------------------------------- 1 | # This configuration is for a Solax X1 Boost 2 | # 3 | # The Solax X1 Boost can be interfaced using ModbusRTU via RS485. 4 | # 5 | # Please `enable` the Modbus support at the settings (password 6868) 6 | # of your inverter and make sure the modbus address is set to `1`. 7 | # 8 | # Kudos to @benjaminvdb and @kidwellj 9 | # 10 | # https://github.com/syssi/esphome-solax-x1-mini/issues/31 11 | # https://github.com/aiolos/ESPHome-config/blob/main/modbus-solax.yaml 12 | 13 | substitutions: 14 | name: solax-x1 15 | device_description: "Monitor a Solax X1 Boost via RS485" 16 | tx_pin: GPIO16 17 | rx_pin: GPIO17 18 | 19 | esphome: 20 | name: ${name} 21 | comment: ${device_description} 22 | min_version: 2024.6.0 23 | project: 24 | name: "syssi.esphome-solax-x1-mini" 25 | version: 2.3.0 26 | 27 | esp32: 28 | board: wemos_d1_mini32 29 | 30 | wifi: 31 | ssid: !secret wifi_ssid 32 | password: !secret wifi_password 33 | 34 | ota: 35 | platform: esphome 36 | 37 | logger: 38 | level: DEBUG 39 | 40 | # If you use Home Assistant please remove this `mqtt` section and uncomment the `api` component! 41 | # The native API has many advantages over MQTT: https://esphome.io/components/api.html#advantages-over-mqtt 42 | mqtt: 43 | broker: !secret mqtt_host 44 | username: !secret mqtt_username 45 | password: !secret mqtt_password 46 | id: mqtt_client 47 | 48 | # api: 49 | 50 | uart: 51 | id: uart_0 52 | baud_rate: 9600 53 | tx_pin: ${tx_pin} 54 | rx_pin: ${rx_pin} 55 | 56 | modbus: 57 | - id: modbus0 58 | uart_id: uart_0 59 | # flow_control_pin: GPIO0 60 | 61 | modbus_controller: 62 | - id: solax0 63 | address: 0x1 64 | modbus_id: modbus0 65 | setup_priority: -10 66 | update_interval: 15s 67 | 68 | text_sensor: 69 | - platform: modbus_controller 70 | modbus_controller_id: solax0 71 | name: "${name} operation mode" 72 | address: 0x40f 73 | register_type: read 74 | raw_encode: HEXBYTES 75 | lambda: |- 76 | uint16_t value = modbus_controller::word_from_hex_str(x, 0); 77 | switch (value) { 78 | case 0: return std::string("Waiting"); 79 | case 1: return std::string("Checking"); 80 | case 2: return std::string("Normal"); 81 | case 3: return std::string("Fault"); 82 | case 4: return std::string("Permanent Fault"); 83 | case 5: return std::string("Update"); 84 | case 6: return std::string("Off-grid waiting"); 85 | case 7: return std::string("Off-grid"); 86 | case 8: return std::string("Self Testing"); 87 | case 9: return std::string("Idle"); 88 | case 10: return std::string("Standby"); 89 | } 90 | return std::string("Unknown"); 91 | 92 | sensor: 93 | - platform: modbus_controller 94 | modbus_controller_id: solax0 95 | name: "${name} PV1 input voltage" 96 | address: 0x400 97 | register_type: read 98 | value_type: U_WORD 99 | unit_of_measurement: V 100 | device_class: voltage 101 | state_class: measurement 102 | accuracy_decimals: 1 103 | filters: 104 | - multiply: 0.1 105 | 106 | - platform: modbus_controller 107 | modbus_controller_id: solax0 108 | name: "${name} PV2 iput vltage" 109 | address: 0x401 110 | register_type: read 111 | value_type: U_WORD 112 | unit_of_measurement: V 113 | device_class: voltage 114 | state_class: measurement 115 | accuracy_decimals: 1 116 | filters: 117 | - multiply: 0.1 118 | 119 | - platform: modbus_controller 120 | modbus_controller_id: solax0 121 | name: "${name} PV1 iput crrent" 122 | address: 0x402 123 | register_type: read 124 | value_type: U_WORD 125 | unit_of_measurement: A 126 | device_class: current 127 | state_class: measurement 128 | accuracy_decimals: 1 129 | filters: 130 | - multiply: 0.1 131 | 132 | - platform: modbus_controller 133 | modbus_controller_id: solax0 134 | name: "${name} PV2 input current" 135 | address: 0x403 136 | register_type: read 137 | value_type: U_WORD 138 | unit_of_measurement: A 139 | device_class: current 140 | state_class: measurement 141 | accuracy_decimals: 1 142 | filters: 143 | - multiply: 0.1 144 | 145 | - platform: modbus_controller 146 | modbus_controller_id: solax0 147 | name: "${name} grid voltage" 148 | address: 0x404 149 | register_type: read 150 | value_type: U_WORD 151 | unit_of_measurement: V 152 | device_class: voltage 153 | state_class: measurement 154 | accuracy_decimals: 1 155 | filters: 156 | - multiply: 0.1 157 | 158 | - platform: modbus_controller 159 | modbus_controller_id: solax0 160 | name: "${name} grid frequency" 161 | address: 0x407 162 | register_type: read 163 | value_type: U_WORD 164 | unit_of_measurement: Hz 165 | device_class: frequency 166 | state_class: measurement 167 | accuracy_decimals: 2 168 | filters: 169 | - multiply: 0.01 170 | 171 | - platform: modbus_controller 172 | modbus_controller_id: solax0 173 | name: "${name} output current" 174 | address: 0x40A 175 | register_type: read 176 | value_type: U_WORD 177 | unit_of_measurement: A 178 | device_class: current 179 | state_class: measurement 180 | accuracy_decimals: 1 181 | filters: 182 | - multiply: 0.1 183 | 184 | - platform: modbus_controller 185 | modbus_controller_id: solax0 186 | name: "${name} temperature" 187 | address: 0x40D 188 | register_type: read 189 | value_type: U_WORD 190 | unit_of_measurement: "°C" 191 | device_class: temperature 192 | state_class: measurement 193 | accuracy_decimals: 0 194 | 195 | - platform: modbus_controller 196 | modbus_controller_id: solax0 197 | name: "${name} inverter power" 198 | address: 0x40e 199 | register_type: read 200 | value_type: U_WORD 201 | unit_of_measurement: W 202 | device_class: power 203 | state_class: measurement 204 | accuracy_decimals: 0 205 | 206 | - platform: modbus_controller 207 | modbus_controller_id: solax0 208 | id: solax0_power_dc1 209 | name: "${name} power dc1" 210 | address: 0x414 211 | register_type: read 212 | value_type: U_WORD 213 | unit_of_measurement: W 214 | device_class: power 215 | state_class: measurement 216 | accuracy_decimals: 0 217 | 218 | - platform: modbus_controller 219 | modbus_controller_id: solax0 220 | id: solax0_power_dc2 221 | name: "${name} power dc2" 222 | address: 0x415 223 | register_type: read 224 | value_type: U_WORD 225 | unit_of_measurement: W 226 | device_class: power 227 | state_class: measurement 228 | accuracy_decimals: 0 229 | on_value: 230 | then: 231 | component.update: solax0_total_dc_power 232 | 233 | - platform: template 234 | id: solax0_total_dc_power 235 | name: "${name} total dc power" 236 | update_interval: never 237 | unit_of_measurement: W 238 | device_class: power 239 | state_class: measurement 240 | accuracy_decimals: 0 241 | lambda: |- 242 | return (id(solax0_power_dc1).state + id(solax0_power_dc2).state); 243 | 244 | - platform: modbus_controller 245 | modbus_controller_id: solax0 246 | name: "${name} energy total" 247 | address: 0x423 248 | register_type: read 249 | value_type: U_WORD 250 | unit_of_measurement: kWh 251 | device_class: energy 252 | state_class: total_increasing 253 | accuracy_decimals: 1 254 | filters: 255 | - multiply: 0.1 256 | 257 | - platform: modbus_controller 258 | modbus_controller_id: solax0 259 | name: "${name} energy today" 260 | address: 0x425 261 | register_type: read 262 | value_type: U_WORD 263 | unit_of_measurement: kWh 264 | device_class: energy 265 | state_class: total_increasing 266 | accuracy_decimals: 1 267 | filters: 268 | - multiply: 0.1 269 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [flake8] 2 | max-line-length = 120 3 | # Following 4 for black compatibility 4 | # E501: line too long 5 | # W503: Line break occurred before a binary operator 6 | # E203: Whitespace before ':' 7 | # D202 No blank lines allowed after function docstring 8 | 9 | # TODO fix flake8 10 | # D100 Missing docstring in public module 11 | # D101 Missing docstring in public class 12 | # D102 Missing docstring in public method 13 | # D103 Missing docstring in public function 14 | # D104 Missing docstring in public package 15 | # D105 Missing docstring in magic method 16 | # D107 Missing docstring in __init__ 17 | # D200 One-line docstring should fit on one line with quotes 18 | # D205 1 blank line required between summary line and description 19 | # D209 Multi-line docstring closing quotes should be on a separate line 20 | # D400 First line should end with a period 21 | # D401 First line should be in imperative mood 22 | 23 | ignore = 24 | E501, 25 | W503, 26 | E203, 27 | D202, 28 | 29 | D100, 30 | D101, 31 | D102, 32 | D103, 33 | D104, 34 | D105, 35 | D107, 36 | D200, 37 | D205, 38 | D209, 39 | D400, 40 | D401, 41 | 42 | [isort] 43 | # https://github.com/timothycrosley/isort 44 | # https://github.com/timothycrosley/isort/wiki/isort-Settings 45 | # splits long import on multiple lines indented by 4 spaces 46 | multi_line_output = 3 47 | include_trailing_comma=True 48 | force_grid_wrap=0 49 | use_parentheses=True 50 | line_length=88 51 | indent = " " 52 | # will group `import x` and `from x import` of the same module. 53 | force_sort_within_sections = true 54 | sections = FUTURE,STDLIB,THIRDPARTY,FIRSTPARTY,LOCALFOLDER 55 | default_section = THIRDPARTY 56 | known_first_party = custom_components,tests 57 | forced_separate = tests 58 | combine_as_imports = true 59 | -------------------------------------------------------------------------------- /test-esp32.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | if [[ $2 == tests/* ]]; then 4 | C="../components" 5 | else 6 | C="components" 7 | fi 8 | 9 | esphome -s external_components_source $C ${1:-run} ${2:-esp32-example.yaml} 10 | -------------------------------------------------------------------------------- /test-esp8266.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | if [[ $2 == tests/* ]]; then 4 | C="../components" 5 | else 6 | C="components" 7 | fi 8 | 9 | esphome -s external_components_source $C ${1:-run} ${2:-esp8266-example.yaml} 10 | -------------------------------------------------------------------------------- /tests/esp32c6-compatibility-test.yaml: -------------------------------------------------------------------------------- 1 | substitutions: 2 | name: esp32c6-compatibility-test 3 | device_description: "Verify the project builds from source on ESP32C6" 4 | external_components_source: github://syssi/esphome-solax-x1-mini@main 5 | 6 | esphome: 7 | name: ${name} 8 | comment: ${device_description} 9 | min_version: 2024.6.0 10 | platformio_options: 11 | # board_build.f_cpu: 160000000L 12 | # board_build.f_flash: 80000000L 13 | board_build.flash_size: 8MB 14 | # build_flags: "-DBOARD_HAS_PSRAM" 15 | # board_build.arduino.memory_type: qio_opi 16 | 17 | # Board is waveshare esp32-c6-devkit-n8 18 | esp32: 19 | board: esp32-c6-devkitc-1 20 | variant: esp32c6 21 | framework: 22 | type: esp-idf 23 | version: 5.3.1 24 | platform_version: 6.9.0 25 | sdkconfig_options: 26 | CONFIG_ESPTOOLPY_FLASHSIZE_8MB: y 27 | 28 | external_components: 29 | - source: ${external_components_source} 30 | refresh: 0s 31 | 32 | wifi: 33 | ssid: !secret wifi_ssid 34 | password: !secret wifi_password 35 | 36 | ota: 37 | platform: esphome 38 | 39 | logger: 40 | level: VERY_VERBOSE 41 | 42 | mqtt: 43 | broker: !secret mqtt_host 44 | username: !secret mqtt_username 45 | password: !secret mqtt_password 46 | id: mqtt_client 47 | 48 | uart: 49 | - id: uart_0 50 | baud_rate: 9600 51 | tx_pin: GPIO16 52 | rx_pin: GPIO17 53 | - id: uart_1 54 | baud_rate: 9600 55 | tx_pin: GPIO14 56 | rx_pin: GPIO4 57 | 58 | solax_modbus: 59 | - id: modbus0 60 | uart_id: uart_0 61 | 62 | solax_x1_mini: 63 | solax_modbus_id: modbus0 64 | update_interval: 1s 65 | 66 | # 67 | # 68 | # 69 | 70 | solax_meter_modbus: 71 | - id: modbus1 72 | uart_id: uart_1 73 | 74 | solax_meter_gateway: 75 | - id: solax_meter_gateway0 76 | solax_meter_modbus_id: modbus1 77 | address: 0x01 78 | 79 | # The state of this sensor (instantaneous power in watt) is used as source 80 | power_id: powermeter0 81 | power_sensor_inactivity_timeout: 5s 82 | update_interval: 5s 83 | 84 | # 85 | # 86 | # 87 | 88 | text_sensor: 89 | - platform: solax_x1_mini 90 | mode_name: 91 | name: "inverter0 mode name" 92 | errors: 93 | name: "inverter0 errors" 94 | 95 | - platform: solax_meter_gateway 96 | solax_meter_gateway_id: solax_meter_gateway0 97 | operation_mode: 98 | name: "inverter1 operation mode" 99 | 100 | sensor: 101 | - platform: solax_x1_mini 102 | ac_power: 103 | name: "inverter0 ac power" 104 | energy_today: 105 | name: "inverter0 energy today" 106 | energy_total: 107 | name: "inverter0 energy total" 108 | dc1_voltage: 109 | name: "inverter0 dc1 voltage" 110 | dc2_voltage: 111 | name: "inverter0 dc2 voltage" 112 | dc1_current: 113 | name: "inverter0 dc1 current" 114 | dc2_current: 115 | name: "inverter0 dc2 current" 116 | ac_current: 117 | name: "inverter0 ac current" 118 | ac_voltage: 119 | name: "inverter0 ac voltage" 120 | ac_frequency: 121 | name: "inverter0 ac frequency" 122 | temperature: 123 | name: "inverter0 temperature" 124 | runtime_total: 125 | name: "inverter0 runtime total" 126 | mode: 127 | name: "inverter0 mode" 128 | error_bits: 129 | name: "inverter0 error bits" 130 | grid_voltage_fault: 131 | name: "inverter0 grid voltage fault" 132 | grid_frequency_fault: 133 | name: "inverter0 grid frequency fault" 134 | dc_injection_fault: 135 | name: "inverter0 dc injection fault" 136 | temperature_fault: 137 | name: "inverter0 temperature fault" 138 | pv1_voltage_fault: 139 | name: "inverter0 pv1 voltage fault" 140 | pv2_voltage_fault: 141 | name: "inverter0 pv2 voltage fault" 142 | gfc_fault: 143 | name: "inverter0 gfc fault" 144 | 145 | - id: powermeter0 146 | internal: true 147 | platform: mqtt_subscribe 148 | name: "inverter1 instantaneous power consumption" 149 | topic: "dummy" 150 | accuracy_decimals: 2 151 | unit_of_measurement: W 152 | device_class: power 153 | 154 | - platform: solax_meter_gateway 155 | solax_meter_gateway_id: solax_meter_gateway0 156 | power_demand: 157 | name: "inverter1 power demand" 158 | 159 | switch: 160 | - platform: solax_meter_gateway 161 | solax_meter_gateway_id: solax_meter_gateway0 162 | emergency_power_off: 163 | name: "inverter1 emergency power off" 164 | restore_mode: RESTORE_DEFAULT_OFF 165 | -------------------------------------------------------------------------------- /tests/esp8266-dummy-receiver.yaml: -------------------------------------------------------------------------------- 1 | substitutions: 2 | name: dummy-receiver 3 | tx_pin: GPIO4 4 | rx_pin: GPIO5 5 | 6 | esphome: 7 | name: ${name} 8 | min_version: 2024.6.0 9 | 10 | esp8266: 11 | board: d1_mini 12 | 13 | wifi: 14 | ssid: !secret wifi_ssid 15 | password: !secret wifi_password 16 | 17 | ota: 18 | platform: esphome 19 | 20 | logger: 21 | level: DEBUG 22 | 23 | api: 24 | reboot_timeout: 0s 25 | 26 | uart: 27 | id: uart_0 28 | baud_rate: 9600 29 | tx_pin: ${tx_pin} 30 | rx_pin: ${rx_pin} 31 | debug: 32 | direction: BOTH 33 | dummy_receiver: true 34 | -------------------------------------------------------------------------------- /tests/esp8266-query-sdm230-floats.yaml: -------------------------------------------------------------------------------- 1 | substitutions: 2 | name: solax-x1-dummy 3 | tx_pin: GPIO4 4 | rx_pin: GPIO5 5 | 6 | esphome: 7 | name: ${name} 8 | min_version: 2024.6.0 9 | 10 | esp8266: 11 | board: d1_mini 12 | 13 | wifi: 14 | ssid: !secret wifi_ssid 15 | password: !secret wifi_password 16 | 17 | ota: 18 | platform: esphome 19 | 20 | logger: 21 | level: DEBUG 22 | 23 | api: 24 | reboot_timeout: 0s 25 | 26 | uart: 27 | id: uart_0 28 | baud_rate: 9600 29 | tx_pin: ${tx_pin} 30 | rx_pin: ${rx_pin} 31 | debug: 32 | direction: BOTH 33 | dummy_receiver: true 34 | 35 | interval: 36 | - interval: 5s 37 | then: 38 | # Handshake 39 | - uart.write: [0x01, 0x03, 0x00, 0x0B, 0x00, 0x01, 0xF5, 0xC8] 40 | - delay: 1s 41 | 42 | # Request power consumption 43 | - uart.write: [0x01, 0x04, 0x00, 0x0C, 0x00, 0x02, 0xB1, 0xC8] 44 | - delay: 1s 45 | 46 | # Request total energy import 47 | - uart.write: [0x01, 0x04, 0x00, 0x48, 0x00, 0x02, 0xF1, 0xDD] 48 | - delay: 1s 49 | 50 | # Request total energy export 51 | - uart.write: [0x01, 0x04, 0x00, 0x4A, 0x00, 0x02, 0x50, 0x1D] 52 | - delay: 1s 53 | -------------------------------------------------------------------------------- /tests/esp8266-query-sdm230.yaml: -------------------------------------------------------------------------------- 1 | substitutions: 2 | name: solax-x1-dummy 3 | tx_pin: GPIO4 4 | rx_pin: GPIO5 5 | 6 | esphome: 7 | name: ${name} 8 | min_version: 2024.6.0 9 | 10 | esp8266: 11 | board: d1_mini 12 | 13 | wifi: 14 | ssid: !secret wifi_ssid 15 | password: !secret wifi_password 16 | 17 | ota: 18 | platform: esphome 19 | 20 | logger: 21 | level: DEBUG 22 | 23 | api: 24 | reboot_timeout: 0s 25 | 26 | uart: 27 | id: uart_0 28 | baud_rate: 9600 29 | tx_pin: ${tx_pin} 30 | rx_pin: ${rx_pin} 31 | debug: 32 | direction: BOTH 33 | dummy_receiver: true 34 | after: 35 | # 100ms ... 750ms 36 | timeout: 100ms 37 | 38 | interval: 39 | - interval: 5s 40 | then: 41 | # Handshake 42 | - uart.write: [0x01, 0x03, 0x00, 0x0b, 0x00, 0x01, 0xf5, 0xc8] 43 | # Expected SDM230-MID-SOLAX response: 0x01 0x03 0x02 0x00 0xa8 0xb9 0xfa 44 | - delay: 1s 45 | 46 | # Request power consumption 47 | - uart.write: [0x01, 0x03, 0x00, 0x0e, 0x00, 0x01, 0xe5, 0xc9] 48 | # Expected SDM230-MID-SOLAX response: 0x01 0x03 0x02 0x01 0xa2 0x38 0x6d (418W * -1.0) 49 | - delay: 1s 50 | 51 | # Request energy 52 | - uart.write: [0x01, 0x03, 0x00, 0x08, 0x00, 0x04, 0xc5, 0xcb] 53 | # Expected SDM230-MID-SOLAX response: 0x01 0x03 0x08 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x95 0xd7 54 | - delay: 1s 55 | --------------------------------------------------------------------------------