├── requirements.txt ├── docs ├── Demo.md ├── screenshots │ └── HomeAssistantIntegration_Basic.png ├── Docker.md ├── Mqtt.md ├── Customize.md └── Config.md ├── entrypoint.sh ├── .vscode ├── tasks.json └── launch.json ├── pyproject.toml ├── .dockerignore ├── .gitignore ├── Dockerfile ├── LICENSE ├── src ├── config │ ├── customize.py │ └── config.json ├── main.py └── core │ ├── agent.py │ ├── limit.py │ ├── wizard.py │ ├── appconfig.py │ └── helper.py └── README.md /requirements.txt: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ThePradox/SolarExportControl/HEAD/requirements.txt -------------------------------------------------------------------------------- /docs/Demo.md: -------------------------------------------------------------------------------- 1 | # Demo 2 | 3 | New demos will be here as soon as the sun blesses me with usable data 4 | -------------------------------------------------------------------------------- /docs/screenshots/HomeAssistantIntegration_Basic.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ThePradox/SolarExportControl/HEAD/docs/screenshots/HomeAssistantIntegration_Basic.png -------------------------------------------------------------------------------- /entrypoint.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | #Copy basic config files to volume but do not overwrite existing! 4 | cp -n -r -v /app/_origin_config/. /app/config 5 | 6 | exec "$@" -------------------------------------------------------------------------------- /.vscode/tasks.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "2.0.0", 3 | "tasks": [ 4 | { 5 | "type": "docker-build", 6 | "label": "docker-build", 7 | "platform": "python", 8 | "dockerBuild": { 9 | "tag": "solarexportcontrol:latest", 10 | "dockerfile": "${workspaceFolder}/Dockerfile", 11 | "context": "${workspaceFolder}", 12 | "pull": true 13 | } 14 | } 15 | ] 16 | } -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = "SolarExportControl" 3 | version = "1.0.0" 4 | description = "Limits your solar inverter to match your desired power consumption!" 5 | readme= "README.md" 6 | requires-python = ">=3.10" 7 | license = {file = "LICENSE"} 8 | 9 | [project.urls] 10 | homepage = "https://github.com/ThePradox/SolarExportControl" 11 | documentation = "https://github.com/ThePradox/SolarExportControl" 12 | repository = "https://github.com/ThePradox/SolarExportControl" -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | **/__pycache__ 2 | **/.venv 3 | **/.classpath 4 | **/.dockerignore 5 | **/.env 6 | **/.git 7 | **/.gitignore 8 | **/.project 9 | **/.settings 10 | **/.toolstarget 11 | **/.vs 12 | **/.vscode 13 | **/*.*proj.user 14 | **/*.dbmdl 15 | **/*.jfm 16 | **/bin 17 | **/charts 18 | **/docker-compose* 19 | **/compose* 20 | **/Dockerfile* 21 | **/node_modules 22 | **/npm-debug.log 23 | **/obj 24 | **/secrets.dev.yaml 25 | **/values.dev.yaml 26 | **/docs 27 | .gitignore 28 | LICENSE 29 | README.md 30 | 31 | -------------------------------------------------------------------------------- /docs/Docker.md: -------------------------------------------------------------------------------- 1 | # Docker support (Beta) 2 | 3 | ## Create Image 4 | 5 | Docker image must be currently build by yourself. 6 | With the vscode docker extension: Run the included docker build task. 7 | 8 | ## Run Container 9 | 10 | 1. Map volume `/app/config` to `yourhostpath` 11 | 2. (Optional): Set Environment variable APPARGS to [arguments](../README.md#arguments) 12 | 3. Start container 13 | 4. Container will exit 14 | 5. `yourhostpath` will now contain the `config.json` and `customize.py` 15 | 6. Adjust these like described 16 | 7. Run again 17 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | .huskyrc.json 3 | out 4 | log.log 5 | **/node_modules 6 | *.pyc 7 | *.vsix 8 | **/.vscode/.ropeproject/** 9 | **/testFiles/**/.cache/** 10 | *.noseids 11 | .nyc_output 12 | .vscode-test 13 | __pycache__ 14 | npm-debug.log 15 | **/.mypy_cache/** 16 | !yarn.lock 17 | coverage/ 18 | cucumber-report.json 19 | **/.vscode-test/** 20 | **/.vscode test/** 21 | **/.vscode-smoke/** 22 | **/.venv*/ 23 | port.txt 24 | precommit.hook 25 | pythonFiles/lib/** 26 | debug_coverage*/** 27 | languageServer/** 28 | languageServer.*/** 29 | bin/** 30 | obj/** 31 | .pytest_cache 32 | tmp/** 33 | .python-version 34 | .vs/ 35 | test-results*.xml 36 | xunit-test-results.xml 37 | build/ci/performance/performance-results.json 38 | !build/ 39 | debug*.log 40 | debugpy*.log 41 | pydevd*.log 42 | nodeLanguageServer/** 43 | nodeLanguageServer.*/** 44 | dist/** 45 | # translation files 46 | *.xlf 47 | *.nls.*.json 48 | *.i18n.json 49 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3.11.0-slim-bullseye 2 | 3 | ENV PYTHONDONTWRITEBYTECODE=1 4 | ENV PYTHONUNBUFFERED=1 5 | ENV APPARGS="" 6 | 7 | # Install pip requirements 8 | COPY requirements.txt . 9 | RUN python -m pip --no-cache-dir install -r requirements.txt 10 | 11 | WORKDIR /app 12 | COPY src . 13 | COPY src/config ./_origin_config 14 | COPY entrypoint.sh /entrypoint.sh 15 | RUN chmod +x /entrypoint.sh 16 | 17 | # Creates a non-root user with an explicit UID and adds permission to access the /app folder 18 | # For more info, please refer to https://aka.ms/vscode-docker-python-configure-containers 19 | RUN adduser -u 5678 --disabled-password --gecos "" appuser && chown -R appuser /app 20 | USER appuser 21 | VOLUME ["/app/config"] 22 | 23 | # During debugging, this entry point will be overridden. For more information, please refer to https://aka.ms/vscode-docker-python-debug 24 | ENTRYPOINT ["/entrypoint.sh"] 25 | CMD python main.py ./config/config.json $APPARGS 26 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | // Use IntelliSense to learn about possible attributes. 3 | // Hover to view descriptions of existing attributes. 4 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 5 | "version": "0.2.0", 6 | "configurations": [ 7 | { 8 | "name": "SolarExportControl: Run", 9 | "type": "python", 10 | "request": "launch", 11 | "program": "${workspaceFolder}/src/main.py", 12 | "console": "integratedTerminal", 13 | "justMyCode": true, 14 | "args": [ 15 | "./src/config/config.json", 16 | "--verbose" 17 | ] 18 | }, 19 | { 20 | "name": "SolarExportControl: Wizard", 21 | "type": "python", 22 | "request": "launch", 23 | "program": "${workspaceFolder}/src/main.py", 24 | "console": "externalTerminal", 25 | "justMyCode": true, 26 | "args": [ 27 | "./src/config/config.json", 28 | "--wizard" 29 | ] 30 | } 31 | ] 32 | } -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | This is free and unencumbered software released into the public domain. 2 | 3 | Anyone is free to copy, modify, publish, use, compile, sell, or 4 | distribute this software, either in source code form or as a compiled 5 | binary, for any purpose, commercial or non-commercial, and by any 6 | means. 7 | 8 | In jurisdictions that recognize copyright laws, the author or authors 9 | of this software dedicate any and all copyright interest in the 10 | software to the public domain. We make this dedication for the benefit 11 | of the public at large and to the detriment of our heirs and 12 | successors. We intend this dedication to be an overt act of 13 | relinquishment in perpetuity of all present and future rights to this 14 | software under copyright law. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 17 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 18 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. 19 | IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR 20 | OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, 21 | ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR 22 | OTHER DEALINGS IN THE SOFTWARE. 23 | 24 | For more information, please refer to 25 | -------------------------------------------------------------------------------- /src/config/customize.py: -------------------------------------------------------------------------------- 1 | import json 2 | import requests 3 | 4 | # Example payload: {"Time": "2022-10-20T20:58:13", "em": {"power_total": 230.04 }} 5 | # Convert ongoing power reading payload to float (negative = export) 6 | def parse_power_payload(payload: bytes, command_min: float, command_max: float) -> float | None: 7 | tasmota_device = "em" 8 | tasmota_value = "power_total" 9 | 10 | jobj = json.loads(payload) 11 | if tasmota_device in jobj: 12 | em_jobj = jobj[tasmota_device] 13 | if tasmota_value in em_jobj: 14 | value = em_jobj[tasmota_value] 15 | if isinstance(value, float): 16 | return value 17 | elif isinstance(value, int): 18 | return float(value) 19 | 20 | return None 21 | 22 | # Convert calculated new limit to mqtt payload 23 | def command_to_payload(command: float, command_type: int, command_min: float, command_max: float) -> str | None: 24 | return f"{round(command,2):.2f}" 25 | 26 | # Send your command to anywhere 27 | def command_to_generic(command: float, command_type: int, command_min: float, command_max: float, config:dict) -> None: 28 | pass 29 | 30 | # Convert ongoing inverter status update payload to bool (True = Active /False = Inactive) 31 | def parse_inverter_status_payload(payload: bytes, current_status: bool) -> bool | None: 32 | s = payload.decode().lower() 33 | return s == "1" or s == "true" 34 | -------------------------------------------------------------------------------- /src/config/config.json: -------------------------------------------------------------------------------- 1 | { 2 | "mqtt": { 3 | "host": "", 4 | "port": 1883, 5 | "keepalive": 60, 6 | "protocol": 4, 7 | "clientId": null, 8 | 9 | "topics": { 10 | "readPower": "", 11 | "writeCommand": null, 12 | "inverterStatus": null 13 | }, 14 | 15 | "auth": { 16 | "username": null, 17 | "password": null 18 | } 19 | }, 20 | 21 | "command": { 22 | "target": 0, 23 | "minPower": 0, 24 | "maxPower": 1200, 25 | "type": "relative", 26 | "throttle": 5, 27 | "hysteresis": 0.0, 28 | "retransmit": 0, 29 | "defaultLimit": null 30 | }, 31 | 32 | "reading": { 33 | "offset": 0, 34 | "smoothing": null, 35 | "smoothingSampleSize": 0 36 | }, 37 | 38 | "meta": { 39 | "prefix": "solarexportcontrol", 40 | "resetInverterLimitOnInactive": true, 41 | 42 | "telemetry": { 43 | "power": true, 44 | "sample": true, 45 | "overshoot": true, 46 | "limit": true, 47 | "command": true 48 | }, 49 | 50 | "homeAssistantDiscovery": { 51 | "enabled": true, 52 | "discoveryPrefix": "homeassistant", 53 | "id": 1, 54 | "name": "SEC" 55 | } 56 | }, 57 | 58 | "customize": { 59 | "command": {} 60 | } 61 | } -------------------------------------------------------------------------------- /src/main.py: -------------------------------------------------------------------------------- 1 | import pathlib 2 | import logging 3 | import argparse 4 | from core.agent import ExportControlAgent 5 | from core.appconfig import AppConfig 6 | from core.wizard import ConfigWizard 7 | import sys 8 | 9 | MIN_PYTHON = (3, 10) 10 | if sys.version_info < MIN_PYTHON: 11 | print(sys.version_info) 12 | sys.exit("Python %s.%s or later is required.\n" % MIN_PYTHON) 13 | 14 | 15 | argparser = argparse.ArgumentParser(prog="SolarExportControl", description="Listens to a mqtt power reading topic and publishes power limits to mqtt topic based on a configured power target.") 16 | argparser.add_argument("config", type=str, help="path to config file") 17 | argparser.add_argument("-v", "--verbose", help="enables detailed logging", action="store_true") 18 | argparser.add_argument("--mqttdiag", help="enables extra mqtt diagnostics", action="store_true") 19 | argparser.add_argument("--wizard", help="interactive prompt for creating a config", action="store_true") 20 | args = argparser.parse_args() 21 | 22 | config_path = pathlib.Path(args.config).resolve() 23 | loglvl = logging.DEBUG if args.verbose else logging.INFO 24 | logging.basicConfig(stream=sys.stdout, level=loglvl, format="%(asctime)s | %(levelname).3s | %(message)s", datefmt="%Y-%m-%d %H:%M:%S") 25 | 26 | if args.wizard: 27 | wizard = ConfigWizard(str(config_path)) 28 | wizard.run() 29 | sys.exit(0) 30 | 31 | if not config_path.exists(): 32 | sys.exit(f"Config: '{str(config_path)}' does not exist") 33 | 34 | try: 35 | appconfig = AppConfig.from_json_file(str(config_path)) 36 | except Exception as ex: 37 | sys.exit(f"Failed to load config: '{ex.args}'") 38 | 39 | agent = ExportControlAgent(appconfig, args.mqttdiag) 40 | agent.run() 41 | -------------------------------------------------------------------------------- /docs/Mqtt.md: -------------------------------------------------------------------------------- 1 | # MQTT TOPICS 2 | 3 | > [prefix] is configured in [config](./Config.md#meta-properties) 4 | 5 | ## Telemetry Topics 6 | 7 | | Path | Unit | Description 8 | |--- | --- | --- 9 | | [prefix]/tele/power | Watt (W) | raw power value as parsed from `config.mqtt.topics.readPower` 10 | | [prefix]/tele/sample | Watt (W) | power value after applying `config.reading.offset` and `config.reading.smoothing` 11 | | [prefix]/tele/overshoot | Watt (W) | difference between the last sample and `config.command.target` 12 | | [prefix]/tele/limit | Watt (W) | calculated inverter limit 13 | | [prefix]/tele/command | Watt (W) or Percent (%) | last issued inverter limit command as published in `config.mqtt.topics.writeCommand`. Watt if `config.command.type` is `absolute`, percent if `relative` 14 | 15 | ## Status Topics 16 | 17 | | Path | Unit | Description 18 | |--- | --- | --- 19 | | [prefix]/status/inverter | bool (0 or 1) | inverter status if configured with `config.mqtt.topics.inverterStatus` 20 | | [prefix]/status/enabled | bool (0 or 1) | application enabled status 21 | | [prefix]/status/active | bool (0 or 1) | application working status 22 | | [prefix]/status/online | bool (0 or 1) | application connection status 23 | 24 | ## Command Topics 25 | 26 | | Path | Unit | Description 27 | |--- | --- | --- 28 | | [prefix]/cmd/enabled | bool (0 or 1) | start and stop the application 29 | -------------------------------------------------------------------------------- /docs/Customize.md: -------------------------------------------------------------------------------- 1 | # Customize.py 2 | 3 | Found in `/src/config/customize.py` 4 | 5 | ## **Required**: `parse_power_payload` 6 | 7 | ```python 8 | # Convert ongoing power reading payload to float (negative = export) 9 | def parse_power_payload(payload: bytes, command_min: float, command_max: float) -> float | None: 10 | ``` 11 | 12 | This function must be edited to return the power reading as `float`. Return `None` to discard the reading 13 | 14 |
Example 1: Tasmota 15 | 16 | Payload comes from tasmota while the device name is set to "em" and the value to "power_total": 17 | 18 | Payload: 19 | 20 | ```json 21 | {"Time": "2022-10-20T20:58:13", "em": {"power_total": 230.04 }} 22 | ``` 23 | 24 | Function 25 | 26 | ```python 27 | def parse_power_payload(payload: bytes, command_min: float, command_max: float) -> float | None: 28 | tasmota_device = "em" 29 | tasmota_value = "power_total" 30 | 31 | jobj = json.loads(payload) 32 | if tasmota_device in jobj: 33 | em_jobj = jobj[tasmota_device] 34 | if tasmota_value in em_jobj: 35 | value = em_jobj[tasmota_value] 36 | if isinstance(value, float): 37 | return value 38 | elif isinstance(value, int): 39 | return float(value) 40 | 41 | return None 42 | ``` 43 | 44 |
45 | 46 |
Example 2 47 | 48 | Payload is just the number 49 | 50 | Payload: 51 | 52 | ```txt 53 | 230.04 54 | ``` 55 | 56 | Function 57 | 58 | ```python 59 | def parse_power_payload(payload: bytes, command_min: float, command_max: float) -> float | None: 60 | return float(payload.decode()) 61 | ``` 62 | 63 |
64 | 65 |
66 | 67 | ## **Required**: `command_to_payload` 68 | 69 | ```python 70 | # Convert calculated new limit to mqtt payload 71 | def command_to_payload(command: float, command_type: int, command_min: float, command_max: float) -> str | None: 72 | ``` 73 | 74 | This function must be edited to return the mqtt payload as `string`. Return `None` to discard the limit. 75 | 76 |
Example 77 | 78 | Just round the limit to 2 decimals 79 | 80 | ```python 81 | def command_to_payload(command: float, command_type: int, command_min: float, command_max: float) -> str | None: 82 | return f"{round(command,2):.2f}" 83 | ``` 84 | 85 |
86 | 87 |
88 | 89 | ## Optional: `parse_status_payload` 90 | 91 | ```python 92 | # Convert ongoing status update payload to bool (True = Active /False = Inactive) 93 | def parse_status_payload(payload: bytes, current_status: bool) -> bool | None: 94 | ``` 95 | 96 | Only required if `config.mqtt.topics.inverterStatus` is not empty 97 | 98 | This function can be be edited to return the status from the payload of `config.mqtt.topics.inverterStatus` as `bool` (True=Active / False=Inactive). Return `None` to discard message 99 | 100 |
Example 101 | 102 | Test if playload is 'truthy' 103 | 104 | ```python 105 | def parse_status_payload(payload: bytes, current_status: bool) -> bool | None: 106 | s = payload.decode().lower() 107 | return s == "1" or s == "true" 108 | ``` 109 | 110 |
111 | 112 | ## Optional: `command_to_generic` 113 | 114 | ```python 115 | # Send your command to anywhere 116 | def command_to_generic(command: float, command_type: int, command_min: float, command_max: float, config:dict) -> None: 117 | ``` 118 | 119 | This function will get called whenever a command would be published on `config.mqtt.topics.writeCommand`. 120 | The whole `config.customize.command` object is passed as parameter. 121 | Send the limit over http, sql or whatever, go wild. 122 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # SolarExportControl 2 | 3 | ## Description 4 | 5 | This application takes your current electric power consumption (from a digital electricity meter, for example) and compares it to a defined target value. 6 | 7 | If your power consumption is higher than your target: Lower the power limit of your solar inverter 8 | 9 | If your consumption is lower than your target: Increase the limit on your solar inverter 10 | 11 | Also features an optional home assistant integration: 12 | 13 | ![Screenshot](./docs/screenshots/HomeAssistantIntegration_Basic.png) 14 | 15 | ## Original setup 16 | 17 | - Power reading via esp32 with tasmota and a 'hichi' IR sensor into mqtt broker 18 | - Limiting power of inverter with an esp32 running [OpenDTU](https://github.com/tbnobody/OpenDTU) and receiving the limit over mqtt 19 | 20 | ## Implemented Features 21 | 22 | - Configurable command behaviour: 23 | - Min limit 24 | - Default limit 25 | - Relative (%) or absolute (W) 26 | - Throttle amount of commands 27 | - Minimum difference to last command (hysteresis) 28 | - Configurable power reading: 29 | - Offset 30 | - Smoothing: Average over X samples 31 | - Listen to inverter status: Turn off limit calculation when your inverter does not produce 32 | - Turn on / off via mqtt 33 | - Home Assistant integration 34 | - Scriptable generic limit callback: Send your inverter limit anywhere! 35 | 36 | ## Demo 37 | 38 | An ongoing graph/config screenshot collection can be found [here](docs/Demo.md) 39 | 40 | ## Requirements 41 | 42 | - MQTT Broker 43 | - A power reading sensor: 44 | - Publishes to MQTT Broker 45 | - The published value **must** include the inverter power 46 | - The published value **must** be negative if power is exported (inverter production greater than consumption) 47 | - Should publish at least every 10 seconds 48 | 49 | - An inverter which can regulate its power production 50 | - Receive its power limit from the MQTT Broker 51 | - Power limit can be watts or percentage 52 | 53 | - Python3 (min. 3.10) 54 | 55 | ## How to install 56 | 57 | 1. Fullfill [Requirements](#requirements) 58 | 2. Clone or download Repo 59 | 3. Open a terminal (CMD, Powershell, Bash etc.) in the project root directory 60 | 4. Install requirements. Execute: 61 | > `pip install -r requirements.txt` 62 | 5. Create a basic config. Execute: 63 | > `python .\src\main.py .\src\config\config.json --wizard` 64 | 6. Answer the questions. Use the created config file whenver a `config.json` is passed. 65 | 7. Optional: Further modify [config](#config) to your liking 66 | 8. Modify [customize](#customize) to match your devices 67 | 9. [Run](#how-to-run) 68 | 69 | ## Config 70 | 71 | Edit the `.\src\config\config.json` to match your environment: [Docs](/docs/Config.md) 72 | 73 | Alternative: Use the `--wizard` argument to get guided through config creation 74 | 75 | ## Customize 76 | 77 | You must at least check 2 things: 78 | 79 | **1. What data is my electricity meter sending?** 80 | 81 | This application needs the value as a number, but your electricity meter may publish json or an other arbitrary payload. You must edit the `parse_power_payload` function to convert the payload to a number. See ['parse_power_payload'](./docs/Customize.md#required-parse_power_payload) for examples. 82 | 83 | **2. How should the calculated limit be formated before publishing it?** 84 | 85 | Maybe your inverter wants the new limit as json? For most people it will be as easy as rounding to 2 decimal places. You must edit the `command_to_payload` function to convert the new limit command to your desired payload. See ['command_to_payload '](./docs/Customize.md#required-command_to_payload) 86 | 87 | ## How to run 88 | 89 | - Run normal: `python .\src\main.py .\src\config\config.json` 90 | - Run with VSCode ("launch.json" should be included) 91 | 92 | ### Arguments 93 | 94 | - Entrypoint: `/src/main.py` 95 | - Required (positional) argument: 96 | - `(path to config.json)`: can be relative or absolute 97 | - Optional arguments: 98 | - `--verbose` : detailed logging 99 | - `--mqttdiag`: additional mqtt diagnostics 100 | - `--wizard`: interactive wizard for creating a basic config file 101 | 102 | ## MQTT Topics 103 | 104 | See [Docs](/docs/Mqtt.md) 105 | 106 | ## Docker support 107 | 108 | See [Docs](/docs/Docker.md) 109 | 110 | ## Testers and feedback are appreciated 111 | 112 | Feel free to share your setup or ask for help in the issue section. 113 | -------------------------------------------------------------------------------- /src/core/agent.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import config.customize as customize 3 | import core.appconfig as appconfig 4 | from core.limit import LimitCalculator 5 | from core.helper import AppMqttHelper 6 | from typing import Any 7 | 8 | SETUP_MODE_DURATION = 10 9 | 10 | class ExportControlAgent: 11 | def __init__(self, config: appconfig.AppConfig, mqtt_log: bool = False) -> None: 12 | self.config: appconfig.AppConfig = config 13 | self.limitcalc: LimitCalculator = LimitCalculator(config) 14 | self.mqtt_log: bool = mqtt_log 15 | 16 | self.helper: AppMqttHelper = AppMqttHelper(self.config, mqttLogging=self.mqtt_log) 17 | self.helper.on_connect(self.__on_connect_success, self.__on_connect_error) 18 | self.helper.on_power_reading(self.__on_power_reading, self.__parser_power_reading) 19 | self.helper.on_inverter_status(self.__on_inverter_status, self.__parser_inverter_status) 20 | #self.helper.on_inverter_power(self.__on_inverter_power, self.__parser_inverter_power) 21 | self.helper.on_meta_cmd_enabled(self.__on_meta_cmd_active) 22 | self.helper.setup_will() 23 | 24 | self.__setup_mode: bool = True 25 | self.__meta_status: bool = True 26 | self.__inverter_status: bool = True 27 | self.__published_discovery = False 28 | 29 | # region Events 30 | 31 | def __on_connect_success(self) -> None: 32 | self.__ha_discovery() 33 | self.helper.subscribe_meta_cmd_enabled() 34 | self.helper.publish_meta_status_online(True) 35 | self.helper.subscribe_inverter_status() 36 | #self.helper.subscribe_inverter_power() 37 | self.__start_setup_mode() 38 | 39 | def __on_connect_error(self, rc: Any) -> None: 40 | self.__inverter_status = False 41 | self.__meta_status = False 42 | self.__setup_mode = False 43 | 44 | def __on_inverter_status(self, value: bool) -> None: 45 | self.__set_status(inverter_status=value) 46 | 47 | def __on_meta_cmd_active(self, active: bool) -> None: 48 | self.__set_status(meta_status=active) 49 | 50 | def __on_power_reading(self, value: float) -> None: 51 | # Possible buffered message in pipeline after unsubscribe 52 | if not self.__inverter_status or not self.__meta_status or self.__setup_mode: 53 | return 54 | 55 | result = self.limitcalc.add_reading(value) 56 | self.helper.publish_meta_teles(result.reading, result.sample, result.overshoot, result.limit) 57 | 58 | if result.command is not None: 59 | self.__send_command(result.command) 60 | 61 | # endregion 62 | 63 | def __parser_power_reading(self, payload: bytes) -> float | None: 64 | return customize.parse_power_payload(payload, self.config.command.min_power, self.config.command.max_power) 65 | 66 | def __parser_inverter_status(self, payload: bytes) -> bool | None: 67 | return customize.parse_inverter_status_payload(payload, self.__inverter_status) 68 | 69 | def __start_setup_mode(self) -> None: 70 | self.__setup_mode = True 71 | logging.info(f"Setup mode start: Waiting {SETUP_MODE_DURATION}s for potential retained messages to arrive...") 72 | self.helper.schedule(SETUP_MODE_DURATION, self.__stop_setup_mode) 73 | 74 | def __stop_setup_mode(self) -> None: 75 | self.__setup_mode = False 76 | logging.info("Setup mode end") 77 | self.__set_status(meta_status=None, inverter_status=None, force=True) 78 | 79 | def __set_status(self, meta_status: bool | None = None, inverter_status: bool | None = None, force: bool = False) -> None: 80 | meta_status_retr = meta_status is None 81 | inverter_status_retr = inverter_status is None 82 | 83 | if meta_status_retr: 84 | meta_status = self.__meta_status 85 | 86 | if inverter_status_retr: 87 | inverter_status = self.__inverter_status 88 | 89 | if not force and self.__inverter_status == inverter_status and self.__meta_status == meta_status: 90 | return 91 | 92 | self.__meta_status = meta_status 93 | self.__inverter_status = inverter_status 94 | 95 | if self.__setup_mode: 96 | return 97 | 98 | active = meta_status and inverter_status 99 | self.helper.publish_meta_status_enabled(meta_status) 100 | self.helper.publish_meta_status_inverter(inverter_status) 101 | self.helper.publish_meta_status_active(active) 102 | reason = f"Enabled: {'on ' if meta_status else 'off'}, Inverter: {'on ' if inverter_status else 'off'}" 103 | 104 | if active: 105 | logging.info(f"Application status: Active -> {reason}") 106 | 107 | if not force: 108 | self.limitcalc.reset() 109 | 110 | self.helper.subscribe_power_reading() 111 | #self.helper.subscribe_inverter_power() 112 | else: 113 | logging.info(f"Application status: Inactive -> {reason}") 114 | self.helper.unsubscribe_power_reading() 115 | #self.helper.unsubscribe_inverter_power() 116 | if not meta_status and not meta_status_retr and self.config.meta.reset_inverter_on_inactive and self.__inverter_status: 117 | self.__send_command(self.limitcalc.get_command_default()) 118 | 119 | def __send_command(self, command: float) -> None: 120 | try: 121 | cmdpayload = customize.command_to_payload(command, self.config.command.type, self.config.command.min_power, self.config.command.max_power) 122 | except Exception as ex: 123 | logging.warning(f"customize.command_to_payload failed: {ex}") 124 | return 125 | 126 | if cmdpayload is None: 127 | return 128 | 129 | self.helper.publish_command(cmdpayload) 130 | self.helper.publish_meta_tele_command(command) 131 | 132 | try: 133 | customize.command_to_generic(command, self.config.command.type, self.config.command.min_power, self.config.command.max_power, self.config.customize.command) 134 | except Exception as ex: 135 | logging.warning(f"customize.command_to_generic failed: {ex}") 136 | 137 | def __ha_discovery(self) -> None: 138 | if self.__published_discovery or not self.helper.has_discovery: 139 | return 140 | 141 | self.helper.publish_meta_ha_discovery() 142 | self.__published_discovery = True 143 | 144 | def run(self) -> None: 145 | self.helper.connect() 146 | self.helper.loop_forever() 147 | -------------------------------------------------------------------------------- /src/core/limit.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import statistics 3 | import core.appconfig as appconfig 4 | import config.customize as customize 5 | from typing import Deque, Callable, Tuple 6 | from collections import deque 7 | from datetime import datetime 8 | 9 | # target: configured power target (config.command.target) 10 | # reading: parsed value from mqtt read power topic 11 | # sample: reading with applied smoothing if turned on 12 | # overshoot: absolute difference between target and sample 13 | # limit: overshoot + previous limit, capped to command_min and command_max 14 | # command: value of limit in watts or percent, decided by config.command.type 15 | 16 | class LimitCalculatorResult: 17 | def __init__(self, reading: float, sample: float, overshoot: float, limit: float, command: float | None, 18 | is_calibration: bool, is_throttled: bool, is_hysteresis_suppressed: bool, is_retransmit: bool, elapsed: float) -> None: 19 | self.reading: float = reading 20 | self.sample: float = sample 21 | self.overshoot: float = overshoot 22 | self.limit: float = limit 23 | self.command: float | None = command 24 | self.is_calibration: bool = is_calibration 25 | self.is_throttled: bool = is_throttled 26 | self.is_hysteresis_suppressed: bool = is_hysteresis_suppressed 27 | self.is_retransmit: bool = is_retransmit 28 | self.elapsed: float = elapsed 29 | 30 | 31 | class LimitCalculator: 32 | def __init__(self, config: appconfig.AppConfig) -> None: 33 | self.config: appconfig.AppConfig = config 34 | self.last_command_time: datetime = datetime.min 35 | self.last_limit_value: float = config.command.min_power 36 | self.last_limit_has: bool = False 37 | self.is_calibrated: bool = False 38 | self.limit_max: float = config.command.max_power 39 | self.limit_min: float = config.command.min_power 40 | self.limit_default: float = config.command.default_limit 41 | 42 | deqSize: int = self.config.reading.smoothingSampleSize if self.config.reading.smoothingSampleSize > 0 else 1 43 | 44 | sampleFunc: Callable[[float], float] 45 | if self.config.reading.smoothing == appconfig.PowerReadingSmoothingType.AVG: 46 | sampleFunc = self.__get_smoothing_avg 47 | else: 48 | sampleFunc = self.__get_smoothing_none 49 | deqSize = 1 50 | 51 | if self.config.reading.offset != 0: 52 | self.__sampleReading = lambda x: sampleFunc(self.config.reading.offset + x) 53 | else: 54 | self.__sampleReading = sampleFunc 55 | 56 | self.__samples: Deque[float] = deque([], maxlen=deqSize) 57 | 58 | def set_last_limit(self, limit: float) -> None: 59 | self.last_limit_value = float(limit) 60 | self.last_limit_has = True 61 | 62 | def add_reading(self, reading: float) -> LimitCalculatorResult: 63 | r = self.__add_reading(reading) 64 | self.__log_result(r) 65 | return r 66 | 67 | def __add_reading(self, reading: float) -> LimitCalculatorResult: 68 | is_calibration = not self.is_calibrated 69 | is_throttled = False 70 | is_hysteresis_suppressed = False 71 | is_retransmit = False 72 | 73 | sample = self.__sampleReading(reading) 74 | 75 | if not self.last_limit_has: 76 | self.set_last_limit(self.limit_max) 77 | 78 | elapsed = round((datetime.now() - self.last_command_time).total_seconds(), 2) 79 | overshoot = self.__convert_reading_to_relative_overshoot(sample) 80 | limit = self.__convert_overshoot_to_limit(self.last_limit_value, overshoot) 81 | 82 | # Ignore conditions on calibration 83 | if not is_calibration: 84 | 85 | # Check if command must be throttled 86 | if elapsed < self.config.command.throttle: 87 | is_throttled = True 88 | 89 | # Ignore hysteresis when retransmit > elapsed 90 | elif self.config.command.retransmit > 0 and elapsed >= self.config.command.retransmit: 91 | is_retransmit = True 92 | 93 | # Check for hysteresis 94 | elif not self.__hysteresis_threshold_breached(limit): 95 | is_hysteresis_suppressed = True 96 | 97 | command: float | None = None 98 | 99 | if not (is_throttled or is_hysteresis_suppressed): 100 | command = self.__convert_to_command(limit) 101 | self.last_command_time = datetime.now() 102 | self.set_last_limit(limit) 103 | 104 | if is_calibration: 105 | self.is_calibrated = True 106 | 107 | return LimitCalculatorResult(reading=reading, 108 | sample=sample, 109 | overshoot=overshoot, 110 | limit=limit, 111 | command=command, 112 | is_calibration=is_calibration, 113 | is_throttled=is_throttled, 114 | is_hysteresis_suppressed=is_hysteresis_suppressed, 115 | is_retransmit=is_retransmit, 116 | elapsed=elapsed) 117 | 118 | def get_command_default(self) -> float: 119 | return self.__convert_to_command(self.limit_default) 120 | 121 | def reset(self) -> None: 122 | self.__samples.clear() 123 | self.last_command_time: datetime = datetime.min 124 | self.last_limit_value: float = self.config.command.min_power 125 | self.last_limit_has: bool = False 126 | self.is_calibrated: bool = False 127 | logging.debug("Limit context was reseted") 128 | 129 | def __get_smoothing_avg(self, reading: float) -> float: 130 | self.__samples.append(reading) 131 | return statistics.mean(self.__samples) 132 | 133 | def __get_smoothing_none(self, reading: float) -> float: 134 | self.__samples.append(reading) 135 | return self.__samples[0] 136 | 137 | def __convert_reading_to_relative_overshoot(self, reading: float) -> float: 138 | return (self.config.command.target - reading) * -1 139 | 140 | def __convert_overshoot_to_limit(self, base: float, overshoot: float) -> float: 141 | return self.__cap_limit(base + overshoot) 142 | 143 | def __hysteresis_threshold_breached(self, limit: float) -> bool: 144 | if self.config.command.hysteresis == 0: 145 | # Hysteresis disabled, always use new limit 146 | return True 147 | elif limit == self.limit_max and self.last_limit_value != limit: 148 | # Ignore hysteresis threshold value if limit is max and the last limit is not max. Otherwise a limit of 99% may never returns to 100%. 149 | return True 150 | else: 151 | return abs(self.last_limit_value - limit) >= self.config.command.hysteresis 152 | 153 | def __convert_to_command(self, limit: float) -> float: 154 | if self.config.command.type == appconfig.InverterCommandType.RELATIVE: 155 | return (limit / self.limit_max) * 100 156 | else: 157 | return limit 158 | 159 | def __cap_limit(self, limit: float) -> float: 160 | return max(self.limit_min, min(self.limit_max, limit)) 161 | 162 | @staticmethod 163 | def __log_result(result: LimitCalculatorResult) -> None: 164 | if logging.root.level is not logging.DEBUG: 165 | return 166 | 167 | seg = [] 168 | seg.append(f"Reading: {result.reading:>8.2f}") 169 | seg.append(f"Sample: {result.sample:>8.2f}") 170 | seg.append(f"Overshoot: {result.overshoot:>8.2f}") 171 | seg.append(f"Limit: {result.limit:>8.2f}") 172 | 173 | if result.command is not None: 174 | seg.append(f"Command: {result.command:>8.2f}") 175 | else: 176 | seg.append(f"Command: None") 177 | 178 | seg.append(f"Cal: {int(result.is_calibration)}") 179 | seg.append(f"Thr: {int(result.is_throttled)}") 180 | seg.append(f"Hys: {int(result.is_hysteresis_suppressed)}") 181 | seg.append(f"Ret: {int(result.is_retransmit)}") 182 | seg.append(f"El: {result.elapsed:.2f}") 183 | 184 | logging.debug(" | ".join(seg)) 185 | -------------------------------------------------------------------------------- /docs/Config.md: -------------------------------------------------------------------------------- 1 | # Config 2 | 3 | Found in `/src/config/config.json`. Properties not required can be `null` 4 | 5 | ## MQTT 6 | 7 | ```json 8 | ... 9 | "mqtt": { 10 | "host": "192.168.1.2", 11 | "port": 1883, 12 | "keepalive": 60, 13 | "protocol": 5, 14 | "clientId": "sec_1673108642", 15 | 16 | "topics": { 17 | "readPower": "power/xxx-xxx-xxx/tele/SENSOR", 18 | "writeCommand": "solar/xxx/cmd/limit_nonpersistent_relative", 19 | "inverterStatus": "solar/xxx/status/producing" 20 | }, 21 | 22 | "auth": { 23 | "username": "my-user", 24 | "password": "my-password" 25 | } 26 | }, 27 | ... 28 | ``` 29 | 30 | ### MQTT Properties 31 | 32 | Setup basic mqtt properties 33 | 34 | |Req | Property | Type | Default | Description 35 | |--- | --- | --- |--- |--- 36 | | :red_circle: | `mqtt.host` | string | | hostname or IP address of the remote broker 37 | | | `mqtt.port` | int | 1883 | network port of the server host to connect to 38 | | | `mqtt.keepalive` | int | 60 | maximum period in seconds allowed between communications with the broker 39 | | | `mqtt.protocol` | int | 4 | version of the mqtt protocol to use. `MQTTv31 = 3`, `MQTTv311 = 4`, `MQTTv5 = 5` 40 | |:yellow_circle: | `mqtt.clientId` | string | solar-export-control | mqtt client id to use, required if multiple instances of this program are running 41 | | :red_circle: | `mqtt.topics` | object | | controls mqtt topics 42 | | | `mqtt.auth` | object | null | controls mqtt auth 43 | 44 | ### MQTT.TOPICS Properties 45 | 46 | Setup mqtt topics 47 | 48 | |Req | Property | Type | Description 49 | |--- | --- | --- |--- 50 | | :red_circle: | `topics.readPower` | string | MQTT-Topic to read current power draw 51 | | | `topics.writeCommand` | string | MQTT-Topic to write power limit command to 52 | | | `topics.inverterStatus`| string | MQTT-Topic to listens for inverter status updates. This allows to sleep when the inverter is not producing 53 | 54 | ### MQTT.AUTH Properties 55 | 56 | Setup mqtt broker authentication. **Will only be used If `username` is not empty** 57 | 58 | |Req | Property | Type | Default | Description 59 | |--- | --- | --- |--- |--- 60 | | :red_circle: | `auth.username` | string | | set a username for broker authentication 61 | | | `auth.password` | string | null | set a password for broker authentication 62 | 63 |
64 | 65 | --- 66 | 67 |
68 | 69 | ## COMMAND 70 | 71 | ```json 72 | ... 73 | "command": { 74 | "target": -100, 75 | "minPower": 24, 76 | "maxPower": 1200, 77 | "type": "relative", 78 | "throttle": 6, 79 | "hysteresis": 24.0, 80 | "retransmit": 0, 81 | "defaultLimit": null 82 | }, 83 | ... 84 | ``` 85 | 86 | ### COMMAND Properties 87 | 88 | Setup how commands will be issued 89 | 90 | |Req | Property | Type | Unit | Description 91 | |--- | --- | --- |--- |--- 92 | | :red_circle: | `command.target` | int | Watt (W) | power consumption this app will use as target. Typical values are `0` (Zero Export) or `-600` (in Germany "Balkonkraftwerk") 93 | | :red_circle: | `command.minPower` | int | Watt (W) | the lower power limit the inverter can be set to 94 | | :red_circle: | `command.maxPower` | int | Watt (W) | the upper power limit the inverter can be set to 95 | | :red_circle: | `command.type` | string: "absolute" or "relative"|| controls wether the limit command is absolute in watts (W) or in relative percent of `command.maxPower` 96 | | :red_circle: | `command.throttle` | int | Seconds (s) | minimum amount of time that must pass after a limit command has been issued before a new one can be issued. Use `0` to disable 97 | | :red_circle: | `command.hysteresis` | number | Watt (W) | minimum threshold that must been reached after a limit command has been issued before a new one can be issued. Use `0.00` to disable 98 | | :red_circle: | `command.retransmit` | int | Seconds | time after which `command.hysteresis` is ignored to retransmit the limit command. Useful if commands can get 'lost' on the way to the inverter. Use `0` to disable 99 | | | `command.defaultLimit` | int | Watt (W) | default inverter limit which is used during startup as calibration and if `meta.resetInverterLimitOnInactive` is active 100 | 101 |
102 | 103 | --- 104 | 105 |
106 | 107 | ## READING 108 | 109 | ```json 110 | ... 111 | "reading": { 112 | "offset": 0, 113 | "smoothing": "avg", 114 | "smoothingSampleSize": 8 115 | }, 116 | ... 117 | ``` 118 | 119 | ### READING Properties 120 | 121 | Setup how power reading will be handled 122 | 123 | |Req | Property | Type | Default | Description 124 | |--- | --- | --- |--- |--- 125 | | | `reading.offset` | int | 0 | specifiy an offset in watts (W) to add or subtract 126 | | | `reading.smoothing` | string: "avg" or null| null | - null: original power reading will be used
- `avg`: average of `reading.smoothingSampleSize` is used
Use `avg` to filter short power spikes 127 | | | `reading.smoothingSampleSize`| int | 0 | amount of samples to use for `reading.smoothing` when not `none` 128 | 129 |
130 | 131 | --- 132 | 133 |
134 | 135 | ## META 136 | 137 | ````json 138 | ... 139 | "meta": { 140 | "prefix": "solarexportcontrol", 141 | "resetInverterLimitOnInactive": true, 142 | 143 | "telemetry": { 144 | "power": true, 145 | "sample": true, 146 | "overshoot": true, 147 | "limit": true, 148 | "command": true 149 | }, 150 | 151 | "homeAssistantDiscovery": { 152 | "enabled": true, 153 | "discoveryPrefix": "homeassistant", 154 | "id": 1, 155 | "name": "SEC" 156 | } 157 | }, 158 | ... 159 | ```` 160 | 161 | ### META Properties 162 | 163 | Setup how this application can be controlled and how it publishes telemetry 164 | 165 | |Req | Property | Type | Description 166 | |--- | --- | --- |--- 167 | | :red_circle: | `meta.prefix` | string | prefix used for every mqtt topic managed by this application 168 | | :red_circle: | `meta.resetInverterLimitOnInactive` | bool | should the inverter limit be reset to max when application is disabled? 169 | | :red_circle: | `meta.telemtry` | object | manages the information which are published as mqtt topics 170 | | :red_circle: | `meta.homeAssistantDiscovery` | object | manages the home assistant auto discovery 171 | 172 | ### META.TELEMETRY Properties 173 | 174 | Setup which values are published as telemetry 175 | 176 | |Req | Property | Type | Unit | Description 177 | |--- | --- | --- |--- |--- 178 | | :red_circle: | `telemetry.power` | bool | Watt (W) | outputs the raw power value as parsed from `mqtt.topics.readPower` 179 | | :red_circle: | `telemetry.sample` | bool | Watt (W) | outputs the power value after applying `reading.offset` and `reading.smoothing` 180 | | :red_circle: | `telemetry.overshoot` | bool | Watt (W) | outputs the difference between the last sample and `command.target` 181 | | :red_circle: | `telemetry.limit` | bool | Watt (W) | outputs the calculated inverter limit 182 | | :red_circle: | `telemetry.command` | bool | Watt (W) or Percent (%) | outputs the last issued inverter limit command as published in `mqtt.topics.writeCommand`. Watt if `command.type` is `absolute`, percent if `relative` 183 | 184 | ### META.HOMEASSISTANTDISCOVERY 185 | 186 | Setup the home assistant integration (auto discovery of telemetry) 187 | 188 | |Req | Property | Type | Description 189 | |--- | --- | --- |--- 190 | | :red_circle: | `homeAssistantDiscovery.enabled` | bool | enables or disables the integration 191 | | :red_circle: | `homeAssistantDiscovery.discoveryPrefix` | string | sets home assistant auto discovery topic prefix. Use `homeassistant` unless you have changed this in home assistant 192 | | :red_circle: | `homeAssistantDiscovery.id` | int | used for creating the unique id in home assistant. Only change this if you run multiple instances of this program 193 | | :red_circle: | `homeAssistantDiscovery.name` | string | the name of the device and entites in home assistant 194 | 195 | ## CUSTOMIZE 196 | 197 | ```json 198 | ... 199 | "customize": { 200 | "command": {} 201 | } 202 | ... 203 | ``` 204 | 205 | ### CUSTOMIZE Properties 206 | 207 | Specify arbitrary data to pass to `customize.py` functions 208 | 209 | |Req | Property | Type | Default | Description 210 | |--- | --- | --- |--- |--- 211 | | | `customize.command` | object | (empty) object | data passed to `command_to_generic` in `customize.py` 212 | -------------------------------------------------------------------------------- /src/core/wizard.py: -------------------------------------------------------------------------------- 1 | from enum import IntEnum 2 | from typing import Callable, Any, List, Tuple 3 | import core.appconfig as appconfig 4 | import json 5 | import time 6 | import math 7 | import os.path 8 | import pathlib 9 | 10 | HOYMILES_MIN_POWER_PERCENT = float(0.03) 11 | HOYMILES_THROTTLE = int(10) 12 | HYSTERESIS_FACTOR = float(0.02) 13 | 14 | 15 | class ConfigWizardPresetType(IntEnum): 16 | NONE = 1 17 | HOYMILES_OPENDTU = 2 18 | 19 | 20 | class ConfigWizardReadPowerInterval(IntEnum): 21 | UNDER_10 = 1, 22 | UNDER_60 = 2, 23 | OVER_60 = 3 24 | 25 | 26 | class ConfigWizard: 27 | def __init__(self, config_path: str) -> None: 28 | self.config_path = config_path 29 | 30 | def run(self) -> None: 31 | self.__print_disclaimer() 32 | 33 | preset = self.__prompt_preset() 34 | host = self.__prompt_host() 35 | port = self.__prompt_port() 36 | protocol = self.__prompt_protocol() 37 | client_id = f"sec_{str(int(time.time()))}" 38 | 39 | use_auth = self.__prompt_use_auth() 40 | config_auth = None 41 | 42 | if use_auth: 43 | auth_user = self.__prompt_auth_user() 44 | auth_pw = self.__prompt_auth_pw() 45 | config_auth = appconfig.MqttAuthConfig(auth_user, auth_pw) 46 | 47 | topic_read_power = self.__prompt_topic_read_power() 48 | interval = self.__prompt_interval() 49 | topic_write_command = self.__prompt_topic_write_command() 50 | topic_inv_status = self.__prompt_topic_inverter_status() 51 | topic_inv_power = self.__prompt_topic_inverter_power() 52 | 53 | config_topics = appconfig.MqttTopicConfig(topic_read_power, topic_write_command, topic_inv_status, topic_inv_power) 54 | config_mqtt = appconfig.MqttConfig( 55 | host=host, 56 | port=port, 57 | keepalive=None, 58 | protocol=protocol, 59 | client_id=client_id, 60 | topics=config_topics, 61 | auth=config_auth 62 | ) 63 | 64 | command_max_power = self.__prompt_command_max_power() 65 | command_min_power = self.__prompt_command_min_power(command_max_power, preset) 66 | command_target = self.__prompt_command_target() 67 | command_type = self.__prompt_command_type() 68 | command_throttle = self.__prompt_command_throttle(preset) 69 | command_hysteresis = self.__prompt_command_hysteresis(command_max_power) 70 | config_command = appconfig.CommandConfig( 71 | target=command_target, 72 | min_power=command_min_power, 73 | max_power=command_max_power, 74 | type=command_type, 75 | throttle=command_throttle, 76 | hysteresis=command_hysteresis, 77 | retransmit=0, 78 | default_limit=command_max_power 79 | ) 80 | 81 | reading_smoothing = self.__prompt_reading_smoothing(interval) 82 | config_reading = appconfig.ReadingConfig(reading_smoothing[0], reading_smoothing[1], 0) 83 | 84 | use_ha = self.__prompt_use_ha() 85 | config_telemetry = self.__prompt_telemetry() 86 | 87 | config_meta = appconfig.MetaControlConfig( 88 | prefix="solarexportcontrol", 89 | reset_inverter_on_inactive=True, 90 | telemetry=config_telemetry, 91 | ha_discovery=appconfig.HA_DiscoveryConfig( 92 | enabled=use_ha, 93 | prefix="homeassistant", 94 | id=1, 95 | name="SEC" 96 | ) 97 | ) 98 | 99 | config_app = appconfig.AppConfig( 100 | mqtt=config_mqtt, 101 | cmd=config_command, 102 | reading=config_reading, 103 | meta=config_meta, 104 | customize=appconfig.CustomizeConfig({})) 105 | 106 | self.__prompt_outfile(config_app) 107 | input("Press to exit.") 108 | 109 | def __print_disclaimer(self) -> None: 110 | print("\n|----------------------------------------------------------------------------------------\n| DISCLAIMER: This wizard helps you to create a basic config file.\n| It does not cover every possible scenario or feature.\n| Please consult the '/docs/Config.md' for detailed config file documentation.\n|----------------------------------------------------------------------------------------\n") 111 | 112 | def __prompt_preset(self) -> ConfigWizardPresetType: 113 | prompt = "Preset: Do you use a Hoymiles inverter with OpenDTU?\n" 114 | prompt += "[Y]: Yes\n" 115 | prompt += "[N]: No\n" 116 | 117 | def __vali_preset(input) -> Tuple[bool, ConfigWizardPresetType]: 118 | input = str.lower(input) 119 | match input: 120 | case "y": 121 | return (True, ConfigWizardPresetType.HOYMILES_OPENDTU) 122 | case "n": 123 | return (True, ConfigWizardPresetType.NONE) 124 | case _: 125 | return (False, ConfigWizardPresetType.NONE) 126 | 127 | return self.__prompt_input(prompt, __vali_preset) 128 | 129 | def __prompt_host(self) -> str: 130 | prompt = "Connectivity: Enter IP or hostname of your mqtt broker (without port)\n" 131 | return self.__prompt_input(prompt, self.__vali_req_str) 132 | 133 | def __prompt_port(self) -> int | None: 134 | prompt = "Connectivity: Enter port of your mqtt broker. Keep empty for default port\n" 135 | 136 | def __vali_port(input: str) -> Tuple[bool, int | None]: 137 | if input == "": 138 | return (True, None) 139 | else: 140 | try: 141 | return (True, int(input)) 142 | except ValueError: 143 | return (False, None) 144 | 145 | return self.__prompt_input(prompt, __vali_port) 146 | 147 | def __prompt_protocol(self) -> int: 148 | prompt = "Connectivity: Enter broker supported mqtt protocol version. Keep empty for default: 4\n" 149 | prompt += "[3]: MQTTv31\n" 150 | prompt += "[4]: MQTTv311\n" 151 | prompt += "[5]: MQTTv5\n" 152 | 153 | def __vali_prot(input: str) -> Tuple[bool, int]: 154 | if input == "": 155 | return (True, 4) 156 | elif input == "3": 157 | return (True, 3) 158 | elif input == "4": 159 | return (True, 4) 160 | elif input == "5": 161 | return (True, 5) 162 | else: 163 | return (False, 0) 164 | 165 | return self.__prompt_input(prompt, __vali_prot) 166 | 167 | def __prompt_use_auth(self) -> bool: 168 | prompt = "Authentication: Does your broker require authentication?\n" 169 | prompt += "[Y]: Yes\n" 170 | prompt += "[N]: No\n" 171 | return self.__prompt_input(prompt, self.__vali_req_bool) 172 | 173 | def __prompt_auth_user(self) -> str: 174 | prompt = "Authentication: Enter username:\n" 175 | return self.__prompt_input(prompt, self.__vali_req_str) 176 | 177 | def __prompt_auth_pw(self) -> str | None: 178 | prompt = "Authentication: [Optional] Enter password (leave empty if not required):\n" 179 | return self.__prompt_input(prompt, lambda x: (True, x if x != "" else None)) 180 | 181 | def __prompt_topic_read_power(self) -> str: 182 | prompt = "Topics: Enter mqtt topic to read current power draw from:\n" 183 | return self.__prompt_input(prompt, lambda x: self.__vali_req_str(x.strip())) 184 | 185 | def __prompt_topic_write_command(self) -> str | None: 186 | prompt = "Topics: Enter mqtt topic to write the inverter limit command to:\n" 187 | return self.__prompt_input(prompt, lambda x: self.__vali_req_str(x.strip())) 188 | 189 | def __prompt_topic_inverter_status(self) -> str | None: 190 | prompt = "Topics: [Optional] Enter mqtt topic to read the ongoing inverter status (is producing) from.\nThis allows to sleep when the inverter is not producing.\nLeave empty to deactivate this feature.\n" 191 | return self.__prompt_input(prompt, lambda x: (True, x.strip() if x.strip() != "" else None)) 192 | 193 | def __prompt_topic_inverter_power(self) -> str | None: 194 | prompt = "Topics: [Optional] Enter mqtt topic to read the ongoing inverter power production from.\nThis allows for faster limit adjustment.\nLeave empty to deactivate this feature.\n" 195 | return self.__prompt_input(prompt, lambda x: (True, x.strip() if x.strip() != "" else None)) 196 | 197 | def __prompt_command_max_power(self) -> int: 198 | prompt = "Core: Enter the max power output (AC) of your inverter in watts:\n" 199 | return self.__prompt_input(prompt, self.__vali_req_pos_int) 200 | 201 | def __prompt_command_min_power(self, max_power: int, preset: ConfigWizardPresetType) -> int: 202 | if preset == ConfigWizardPresetType.HOYMILES_OPENDTU: 203 | return int(math.ceil(max_power * HOYMILES_MIN_POWER_PERCENT)) 204 | 205 | prompt = "Core: Enter the smallest power output your inverter can be limited to. When in doubt enter 0.\n" 206 | return self.__prompt_input(prompt, self.__vali_req_pos_int) 207 | 208 | def __prompt_command_target(self) -> int: 209 | prompt = "Core: Enter your power target in watts (should be negative):\n" 210 | return self.__prompt_input(prompt, self.__vali_req_int) 211 | 212 | def __prompt_command_type(self) -> appconfig.InverterCommandType: 213 | prompt = "Core: Should the calculated inverter power limit be send as relative (percent) or absolute (watts) value?\n" 214 | prompt += "[1]: Relative\n" 215 | prompt += "[2]: Absolute\n" 216 | 217 | def __vali_type(input: str) -> Tuple[bool, appconfig.InverterCommandType]: 218 | match input: 219 | case "1": 220 | return (True, appconfig.InverterCommandType.RELATIVE) 221 | case "2": 222 | return (True, appconfig.InverterCommandType.ABSOLUTE) 223 | case _: 224 | return (False, appconfig.InverterCommandType.RELATIVE) 225 | 226 | return self.__prompt_input(prompt, __vali_type) 227 | 228 | def __prompt_command_throttle(self, preset: ConfigWizardPresetType) -> int: 229 | if preset == ConfigWizardPresetType.HOYMILES_OPENDTU: 230 | return HOYMILES_THROTTLE 231 | 232 | prompt = "Core: What is the required waiting period (in seconds) before a new power limit can be sent after a power limit has been sent?\n" 233 | return self.__prompt_input(prompt, self.__vali_req_pos_int) 234 | 235 | def __prompt_command_hysteresis(self, max_power: int) -> float: 236 | return float(math.ceil(max_power*HYSTERESIS_FACTOR)) 237 | 238 | def __prompt_reading_smoothing(self, interval: ConfigWizardReadPowerInterval) -> Tuple[appconfig.PowerReadingSmoothingType, int]: 239 | match interval: 240 | case ConfigWizardReadPowerInterval.UNDER_10: 241 | return (appconfig.PowerReadingSmoothingType.AVG, 8) 242 | case ConfigWizardReadPowerInterval.UNDER_60: 243 | return (appconfig.PowerReadingSmoothingType.AVG, 4) 244 | case ConfigWizardReadPowerInterval.OVER_60: 245 | return (appconfig.PowerReadingSmoothingType.NONE, 0) 246 | case _: 247 | return (appconfig.PowerReadingSmoothingType.NONE, 0) 248 | 249 | def __prompt_telemetry(self) -> appconfig.MetaTelemetryConfig: 250 | prompt = "Telemetry: What level of telemetry should be written to mqtt (and therefore the home assistant integration)?\n" 251 | prompt += "[1]: None\n" 252 | prompt += "[2]: Basic (Sample, Limit, Overshoot)\n" 253 | prompt += "[3]: Full (Power, Sample, Overshoot, Limit, Command)\n" 254 | 255 | def __vali_tele(input: str) -> Tuple[bool, appconfig.MetaTelemetryConfig]: 256 | match input: 257 | case "1": 258 | return (True, appconfig.MetaTelemetryConfig(power=False, sample=False, overshoot=False, limit=False, command=False)) 259 | case "2": 260 | return (True, appconfig.MetaTelemetryConfig(power=False, sample=True, overshoot=True, limit=True, command=False)) 261 | case "3": 262 | return (True, appconfig.MetaTelemetryConfig(power=True, sample=True, overshoot=True, limit=True, command=True)) 263 | case _: 264 | return (False, appconfig.MetaTelemetryConfig(False, False, False, False, False)) 265 | 266 | return self.__prompt_input(prompt, __vali_tele) 267 | 268 | def __prompt_use_ha(self) -> bool: 269 | prompt = "Home Assistant: Do you want to use the home assistant integration?\n" 270 | prompt += "[Y]: Yes\n" 271 | prompt += "[N]: No\n" 272 | 273 | return self.__prompt_input(prompt, self.__vali_req_bool) 274 | 275 | def __prompt_interval(self) -> ConfigWizardReadPowerInterval: 276 | prompt = "How often does this topic receives an update?\n" 277 | prompt += "[1]: Faster than 10 seconds\n" 278 | prompt += "[2]: Faster than 60 seconds\n" 279 | prompt += "[3]: Slower than 60 seconds\n" 280 | 281 | def __vali_interval(input: str) -> Tuple[bool, ConfigWizardReadPowerInterval]: 282 | match input: 283 | case "1": 284 | return (True, ConfigWizardReadPowerInterval.UNDER_10) 285 | case "2": 286 | return (True, ConfigWizardReadPowerInterval.UNDER_60) 287 | case "3": 288 | return (True, ConfigWizardReadPowerInterval.OVER_60) 289 | case _: 290 | return (False, ConfigWizardReadPowerInterval.OVER_60) 291 | 292 | return self.__prompt_input(prompt, __vali_interval) 293 | 294 | def __prompt_outfile(self, config: appconfig.AppConfig) -> None: 295 | 296 | def __write_file(filepath) -> bool: 297 | try: 298 | with open(filepath,"x",encoding="utf-8") as outfile: 299 | json.dump(config.to_json(), outfile, indent=4) 300 | return True 301 | except OSError: 302 | print(f"Failed to create: '{filepath}'\n") 303 | return False 304 | 305 | 306 | def __vali_proxy(input)-> Tuple[bool, str]: 307 | filepath = str(pathlib.Path(self.config_path).parent.joinpath(input)) 308 | return (__write_file(filepath), filepath) 309 | 310 | filepath = self.config_path 311 | success = __write_file(filepath) 312 | 313 | if not success: 314 | filepath = self.__prompt_input("File already exists or missing write/create permissions\nEnter a new file name:\n", __vali_proxy) 315 | 316 | print(f"Config successfully created: '{filepath}'\n") 317 | 318 | @staticmethod 319 | def __prompt_input(prompt: str, validator: Callable[[str], Tuple[bool, Any]]) -> Any: 320 | while True: 321 | in_str = input(prompt) 322 | val_res = validator(in_str) 323 | 324 | if val_res[0]: 325 | print("") 326 | return val_res[1] 327 | 328 | print("Invalid input!") 329 | print("") 330 | 331 | @staticmethod 332 | def __vali_req_bool(input: str) -> Tuple[bool, bool]: 333 | input = input.lower() 334 | match input: 335 | case "y": 336 | return (True, True) 337 | case "n": 338 | return (True, False) 339 | case _: 340 | return (False, False) 341 | 342 | @staticmethod 343 | def __vali_req_str(input: str) -> Tuple[bool, str]: 344 | if input.isspace() or input == "": 345 | return (False, input) 346 | 347 | return (True, input) 348 | 349 | @staticmethod 350 | def __vali_req_pos_int(input: str) -> Tuple[bool, int]: 351 | try: 352 | val = int(input) 353 | 354 | if val < 0: 355 | return (False, 0) 356 | 357 | return (True, val) 358 | except ValueError: 359 | return (False, 0) 360 | 361 | @staticmethod 362 | def __vali_req_int(input: str) -> Tuple[bool, int]: 363 | try: 364 | val = int(input) 365 | return (True, val) 366 | except ValueError: 367 | return (False, 0) 368 | 369 | def __vali_valid_file(self, filename:str) -> Tuple[bool, str]: 370 | filepath = str(pathlib.Path(self.config_path).parent.joinpath(filename)) 371 | 372 | try: 373 | with open(filepath, 'x') as tempfile: 374 | pass 375 | except OSError: 376 | print(f"Failed to create: '{filepath}'\n") 377 | return (False, filepath) 378 | 379 | return (True, filepath) -------------------------------------------------------------------------------- /src/core/appconfig.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | from enum import IntEnum 3 | import paho.mqtt.client as mqtt 4 | import json 5 | 6 | 7 | class InverterCommandType(IntEnum): 8 | ABSOLUTE = 1 9 | RELATIVE = 2 10 | 11 | 12 | class PowerReadingSmoothingType(IntEnum): 13 | NONE = 1, 14 | AVG = 2 15 | 16 | 17 | class AppConfig: 18 | def __init__(self, mqtt: MqttConfig, cmd: CommandConfig, reading: ReadingConfig, meta: MetaControlConfig, customize: CustomizeConfig) -> None: 19 | self.mqtt = mqtt 20 | self.command = cmd 21 | self.reading = reading 22 | self.meta = meta 23 | self.customize = customize 24 | 25 | def to_json(self) -> dict: 26 | return { 27 | "mqtt": self.mqtt.to_json(), 28 | "command": self.command.to_json(), 29 | "reading": self.reading.to_json(), 30 | "meta": self.meta.to_json(), 31 | "customize": self.customize.to_json() 32 | } 33 | 34 | @staticmethod 35 | def from_json_file(path: str) -> AppConfig: 36 | fs = open(path, "r") 37 | jf = json.load(fs) 38 | 39 | j_mqtt = jf.get("mqtt") 40 | if type(j_mqtt) is not dict: 41 | raise ValueError("Missing config segment: mqtt") 42 | 43 | o_mqtt = MqttConfig.from_json(j_mqtt) 44 | 45 | j_cmd = jf.get("command") 46 | if type(j_cmd) is not dict: 47 | raise ValueError("Missing config segment: command") 48 | 49 | o_cmd = CommandConfig.from_json(j_cmd) 50 | 51 | j_reading = jf.get("reading") 52 | if type(j_reading) is not dict: 53 | raise ValueError("Missing config segment: reading") 54 | 55 | o_reading = ReadingConfig.from_json(j_reading) 56 | 57 | j_meta = jf.get("meta") 58 | if type(j_meta) is not dict: 59 | raise ValueError("Missing config segment: meta") 60 | 61 | o_meta = MetaControlConfig.from_json(j_meta) 62 | 63 | j_cust = jf.get("customize") 64 | if type(j_cust) is not dict: 65 | raise ValueError("Missing config segment: customize") 66 | 67 | o_cust = CustomizeConfig.from_json(j_cust) 68 | 69 | return AppConfig(o_mqtt, o_cmd, o_reading, o_meta, o_cust) 70 | 71 | 72 | class MqttConfig: 73 | def __init__(self, host: str, topics: MqttTopicConfig, 74 | port: int | None = None, 75 | keepalive: int | None = None, 76 | protocol: int | None = None, 77 | client_id: str | None = None, 78 | auth: MqttAuthConfig | None = None) -> None: 79 | self.host: str = host 80 | self.port: int = port if port is not None else 1883 81 | self.keepalive: int = keepalive if keepalive is not None else 60 82 | self.protocol: int = protocol if protocol is not None else mqtt.MQTTv311 83 | self.client_id: str = client_id if client_id is not None else "solar-export-control" 84 | self.topics: MqttTopicConfig = topics 85 | self.auth: MqttAuthConfig | None = auth 86 | 87 | def to_json(self) -> dict: 88 | 89 | return { 90 | "host": str(self.host), 91 | "port": self.port, 92 | "keepalive": self.keepalive, 93 | "protocol": self.protocol, 94 | "clientId": self.client_id, 95 | "topics": self.topics.to_json(), 96 | "auth": self.auth.to_json() if self.auth is not None else None 97 | } 98 | 99 | @staticmethod 100 | def from_json(json: dict) -> MqttConfig: 101 | j_host = json.get("host") 102 | 103 | if type(j_host) is not str or not j_host: 104 | raise ValueError(f"MqttConfig: Invalid host: '{j_host}'") 105 | 106 | j_port: int | None = None 107 | j_keepalive: int | None = None 108 | j_protocol: int | None = None 109 | j_client_id: str | None = None 110 | 111 | t = json.get("port") 112 | if type(t) is int and t > 0: 113 | j_port = t 114 | 115 | t = json.get("keepalive") 116 | if type(t) is int and t > 0: 117 | j_keepalive = t 118 | 119 | t = json.get("protocol") 120 | if type(t) is int: 121 | j_protocol = t 122 | 123 | t = json.get("clientId") 124 | if type(t) is str: 125 | j_client_id = t 126 | 127 | j_topics = json.get("topics") 128 | if type(j_topics) is not dict: 129 | raise ValueError(f"MqttConfig: Invalid topics: '{j_topics}'") 130 | 131 | o_topics = MqttTopicConfig.from_json(j_topics) 132 | 133 | o_auth: MqttAuthConfig | None = None 134 | j_auth = json.get("auth") 135 | if type(j_auth) is dict and j_auth.get("username"): 136 | o_auth = MqttAuthConfig.from_json(j_auth) 137 | 138 | return MqttConfig(host=j_host, 139 | topics=o_topics, 140 | port=j_port, 141 | keepalive=j_keepalive, 142 | protocol=j_protocol, 143 | client_id=j_client_id, 144 | auth=o_auth) 145 | 146 | 147 | class MqttTopicConfig: 148 | def __init__(self, read_power: str, write_command: str | None, inverter_status: str | None, inverter_power: str | None) -> None: 149 | self.read_power: str = read_power 150 | self.write_command: str | None = write_command 151 | self.inverter_status: str | None = inverter_status 152 | self.inverter_power: str | None = inverter_power 153 | 154 | def to_json(self) -> dict: 155 | return { 156 | "readPower": str(self.read_power), 157 | "writeCommand": self.write_command, 158 | "inverterStatus": self.inverter_status 159 | # "inverterPower": self.inverter_power 160 | } 161 | 162 | @staticmethod 163 | def from_json(json: dict) -> MqttTopicConfig: 164 | j_read_power = json.get("readPower") 165 | if type(j_read_power) is not str or not j_read_power: 166 | raise ValueError(f"MqttTopicConfig: Invalid readPower: '{j_read_power}'") 167 | 168 | j_write_command = json.get("writeCommand") 169 | if type(j_write_command) is not str or not j_write_command: 170 | j_write_command = None 171 | 172 | j_inv_status = json.get("inverterStatus") 173 | if type(j_inv_status) is not str or not j_inv_status: 174 | j_inv_status = None 175 | 176 | j_inv_power = json.get("inverterPower") 177 | if type(j_inv_power) is not str or not j_inv_power: 178 | j_inv_power = None 179 | 180 | return MqttTopicConfig(read_power=j_read_power, write_command=j_write_command, inverter_status=j_inv_status, inverter_power=j_inv_power) 181 | 182 | 183 | class MqttAuthConfig: 184 | def __init__(self, username: str, password: str | None) -> None: 185 | self.username: str = username 186 | self.password: str | None = password 187 | 188 | def to_json(self) -> dict: 189 | return { 190 | "username": str(self.username), 191 | "password": self.password 192 | } 193 | 194 | @staticmethod 195 | def from_json(json: dict) -> MqttAuthConfig: 196 | j_username = json.get("username") 197 | if type(j_username) is not str or not j_username: 198 | raise ValueError(f"MqttAuthConfig: Invalid username: '{j_username}'") 199 | 200 | j_password = json.get("password") 201 | if type(j_password) is not str: 202 | j_password = None 203 | 204 | return MqttAuthConfig(j_username, j_password) 205 | 206 | 207 | class CommandConfig: 208 | def __init__(self, target: int, min_power: float, max_power: float, type: InverterCommandType, throttle: int, hysteresis: float, retransmit: int, default_limit: float) -> None: 209 | self.target: int = target 210 | self.min_power: float = min_power 211 | self.max_power: float = max_power 212 | self.type: InverterCommandType = type 213 | self.throttle: int = throttle 214 | self.hysteresis: float = hysteresis 215 | self.retransmit: int = retransmit 216 | self.default_limit: float = default_limit 217 | 218 | def to_json(self) -> dict: 219 | match self.type: 220 | case InverterCommandType.ABSOLUTE: 221 | str_type = "absolute" 222 | case InverterCommandType.RELATIVE: 223 | str_type = "relative" 224 | case _: 225 | str_type = "absolute" 226 | 227 | return { 228 | "target": int(self.target), 229 | "minPower": int(self.min_power), 230 | "maxPower": int(self.max_power), 231 | "type": str_type, 232 | "throttle": int(self.throttle), 233 | "hysteresis": float(self.hysteresis), 234 | "retransmit": int(self.retransmit), 235 | "defaultLimit": float(self.default_limit) 236 | } 237 | 238 | @staticmethod 239 | def from_json(json: dict) -> CommandConfig: 240 | j_min_power = json.get("minPower") 241 | if type(j_min_power) is int: 242 | j_min_power = float(j_min_power) 243 | elif type(j_min_power) is not float: 244 | raise ValueError(f"CommandConfig: Invalid min_power: '{j_min_power}'") 245 | 246 | j_max_power = json.get("maxPower") 247 | if type(j_max_power) is int: 248 | j_max_power = float(j_max_power) 249 | elif type(j_max_power) is not float: 250 | raise ValueError(f"CommandConfig: Invalid max_power: '{j_max_power}'") 251 | 252 | if j_min_power >= j_max_power: 253 | raise ValueError("CommandConfig: min_power greater or equal max_power") 254 | 255 | j_target = json.get("target") 256 | if type(j_target) is not int: 257 | raise ValueError(f"CommandConfig: Invalid target type: '{j_target}'") 258 | 259 | j_type = json.get("type") 260 | e_type: InverterCommandType 261 | 262 | if j_type == "absolute": 263 | e_type = InverterCommandType.ABSOLUTE 264 | elif j_type == "relative": 265 | e_type = InverterCommandType.RELATIVE 266 | else: 267 | raise ValueError(f"CommandConfig: Invalid type: '{j_type}'") 268 | 269 | j_throttle = json.get("throttle") 270 | if type(j_throttle) is not int or j_throttle < 0: 271 | raise ValueError(f"CommandConfig: Invalid throttle: '{j_throttle}'") 272 | 273 | j_hysteresis = json.get("hysteresis") 274 | if type(j_hysteresis) is int: 275 | j_hysteresis = float(j_hysteresis) 276 | 277 | if type(j_hysteresis) is not float or j_hysteresis < 0: 278 | raise ValueError(f"CommandConfig: Invalid hysteresis: '{j_hysteresis}'") 279 | 280 | j_retransmit = json.get("retransmit") 281 | if type(j_retransmit) is not int or j_retransmit < 0: 282 | raise ValueError(f"CommandConfig: Invalid retransmit: '{j_retransmit}'") 283 | 284 | j_default_limit = json.get("defaultLimit") 285 | if type(j_default_limit) is int: 286 | j_default_limit = float(j_default_limit) 287 | elif type(j_default_limit) is not float: 288 | j_default_limit = j_max_power 289 | 290 | return CommandConfig( 291 | target=j_target, 292 | min_power=j_min_power, 293 | max_power=j_max_power, 294 | type=e_type, 295 | throttle=j_throttle, 296 | hysteresis=j_hysteresis, 297 | retransmit=j_retransmit, 298 | default_limit=j_default_limit 299 | ) 300 | 301 | 302 | class ReadingConfig: 303 | def __init__(self, smoothing: PowerReadingSmoothingType, smoothingSampleSize: int, offset: float) -> None: 304 | self.smoothing = smoothing 305 | self.smoothingSampleSize = smoothingSampleSize 306 | self.offset = offset 307 | 308 | def to_json(self) -> dict: 309 | sm = "avg" if self.smoothing == PowerReadingSmoothingType.AVG else None 310 | 311 | return { 312 | "offset": int(self.offset), 313 | "smoothing": sm, 314 | "smoothingSampleSize": int(self.smoothingSampleSize) 315 | } 316 | 317 | @staticmethod 318 | def from_json(json: dict) -> ReadingConfig: 319 | j_smoothing = json.get("smoothing") 320 | e_smoothing: PowerReadingSmoothingType = PowerReadingSmoothingType.NONE 321 | 322 | if j_smoothing == "avg": 323 | e_smoothing = PowerReadingSmoothingType.AVG 324 | 325 | j_smoothing_sample_size = json.get("smoothingSampleSize") 326 | if j_smoothing_sample_size is None or type(j_smoothing_sample_size) is not int or j_smoothing_sample_size < 0: 327 | j_smoothing_sample_size = 0 328 | 329 | j_offset = json.get("offset") 330 | if type(j_offset) is int: 331 | j_offset = float(j_offset) 332 | 333 | if type(j_offset) is not float: 334 | j_offset = float(0) 335 | 336 | return ReadingConfig(smoothing=e_smoothing, smoothingSampleSize=j_smoothing_sample_size, offset=j_offset) 337 | 338 | 339 | class CustomizeConfig: 340 | def __init__(self, command: dict) -> None: 341 | self.command = command 342 | 343 | def to_json(self) -> dict: 344 | return { 345 | "command": self.command 346 | } 347 | 348 | @staticmethod 349 | def from_json(json: dict) -> CustomizeConfig: 350 | j_command = json.get("command") 351 | if type(j_command) is not dict: 352 | j_command = {} 353 | 354 | return CustomizeConfig(command=j_command) 355 | 356 | 357 | class MetaControlConfig: 358 | def __init__(self, prefix: str, reset_inverter_on_inactive: bool, telemetry: MetaTelemetryConfig, ha_discovery: HA_DiscoveryConfig) -> None: 359 | self.prefix = prefix 360 | self.reset_inverter_on_inactive = reset_inverter_on_inactive 361 | self.telemetry = telemetry 362 | self.discovery = ha_discovery 363 | 364 | def to_json(self) -> dict: 365 | return { 366 | "prefix": str(self.prefix), 367 | "resetInverterLimitOnInactive": bool(self.reset_inverter_on_inactive), 368 | "telemetry": self.telemetry.to_json(), 369 | "homeAssistantDiscovery": self.discovery.to_json() 370 | } 371 | 372 | @staticmethod 373 | def from_json(json: dict) -> MetaControlConfig: 374 | j_reset = json.get("resetInverterLimitOnInactive") 375 | if type(j_reset) is not bool: 376 | raise ValueError(f"MetaControlConfig: Invalid resetInverterLimitOnInactive: '{j_reset}'") 377 | 378 | j_prefix = json.get("prefix") 379 | if type(j_prefix) is not str or not j_prefix: 380 | raise ValueError(f"MetaControlConfig: Invalid prefix: '{j_prefix}'") 381 | elif j_prefix.startswith("/"): 382 | raise ValueError(f"MetaControlConfig: prefix cannot start with slash: '{j_prefix}'") 383 | 384 | j_telemetry = json.get("telemetry") 385 | if type(j_telemetry) is not dict: 386 | raise ValueError(f"MetaControlConfig: Invalid telemetry: '{j_telemetry}'") 387 | o_telemetry = MetaTelemetryConfig.from_json(j_telemetry) 388 | 389 | j_discovery = json.get("homeAssistantDiscovery") 390 | if type(j_discovery) is not dict: 391 | raise ValueError(f"MetaControlConfig: Invalid homeAssistantDiscovery: '{j_telemetry}'") 392 | 393 | o_discovery = HA_DiscoveryConfig.from_json(j_discovery) 394 | 395 | return MetaControlConfig(j_prefix, j_reset, o_telemetry, o_discovery) 396 | 397 | 398 | class MetaTelemetryConfig: 399 | def __init__(self, power: bool, sample: bool, overshoot: bool, limit: bool, command: bool) -> None: 400 | self.power = power 401 | self.sample = sample 402 | self.overshoot = overshoot 403 | self.limit = limit 404 | self.command = command 405 | 406 | def to_json(self) -> dict: 407 | return { 408 | "power": bool(self.power), 409 | "sample": bool(self.sample), 410 | "overshoot": bool(self.overshoot), 411 | "limit": bool(self.limit), 412 | "command": bool(self.command) 413 | } 414 | 415 | @staticmethod 416 | def from_json(json: dict) -> MetaTelemetryConfig: 417 | j_power = json.get("power") 418 | if type(j_power) is not bool: 419 | raise ValueError(f"MetaTelemetryConfig: Invalid power: '{j_power}'") 420 | 421 | j_sample = json.get("sample") 422 | if type(j_sample) is not bool: 423 | raise ValueError(f"MetaTelemetryConfig: Invalid sample: '{j_sample}'") 424 | 425 | j_overshoot = json.get("overshoot") 426 | if type(j_overshoot) is not bool: 427 | raise ValueError(f"MetaTelemetryConfig: Invalid overshoot: '{j_overshoot}'") 428 | 429 | j_limit = json.get("limit") 430 | if type(j_limit) is not bool: 431 | raise ValueError(f"MetaTelemetryConfig: Invalid limit: '{j_limit}'") 432 | 433 | j_command = json.get("command") 434 | if type(j_command) is not bool: 435 | raise ValueError(f"MetaTelemetryConfig: Invalid command: '{j_command}'") 436 | 437 | return MetaTelemetryConfig(power=j_power, sample=j_sample, overshoot=j_overshoot, limit=j_limit, command=j_command) 438 | 439 | 440 | class HA_DiscoveryConfig: 441 | def __init__(self, enabled: bool, prefix: str, id: int, name: str) -> None: 442 | self.enabled = enabled 443 | self.prefix = prefix 444 | self.id = id 445 | self.name = name 446 | 447 | def to_json(self) -> dict: 448 | return { 449 | "enabled": bool(self.enabled), 450 | "discoveryPrefix": str(self.prefix), 451 | "id": int(self.id), 452 | "name": str(self.name) 453 | } 454 | 455 | @staticmethod 456 | def from_json(json: dict) -> HA_DiscoveryConfig: 457 | j_enabled = json.get("enabled") 458 | if type(j_enabled) is not bool: 459 | raise ValueError(f"HA_DiscoveryConfig: Invalid enabled: '{j_enabled}'") 460 | 461 | j_prefix = json.get("discoveryPrefix") 462 | if type(j_prefix) is not str: 463 | raise ValueError(f"HA_DiscoveryConfig: Invalid discoveryPrefix: '{j_prefix}'") 464 | 465 | j_id = json.get("id") 466 | if type(j_id) is not int: 467 | raise ValueError(f"HA_DiscoveryConfig: Invalid id: '{j_id}'") 468 | 469 | j_name = json.get("name") 470 | if type(j_name) is not str: 471 | raise ValueError(f"HA_DiscoveryConfig: Invalid name: '{j_name}'") 472 | 473 | return HA_DiscoveryConfig(j_enabled, j_prefix, j_id, j_name) 474 | -------------------------------------------------------------------------------- /src/core/helper.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import datetime 3 | import time 4 | import core.appconfig as appconfig 5 | from paho.mqtt import client as mqtt 6 | from paho.mqtt.properties import Properties 7 | from paho.mqtt.packettypes import PacketTypes 8 | from typing import Callable, Any, List, Tuple 9 | 10 | 11 | MQTT_TOPIC_META_CMD_ENABLED = "/cmd/enabled" 12 | 13 | MQTT_TOPIC_META_TELE_READING = "tele/power" 14 | MQTT_TOPIC_META_TELE_SAMPLE = "tele/sample" 15 | MQTT_TOPIC_META_TELE_OVERSHOOT = "tele/overshoot" 16 | MQTT_TOPIC_META_TELE_LIMIT = "tele/limit" 17 | MQTT_TOPIC_META_TELE_CMD = "tele/command" 18 | 19 | MQTT_TOPIC_META_CORE_INVERTER_STATUS = "status/inverter" 20 | MQTT_TOPIC_META_CORE_ENABLED = "status/enabled" 21 | MQTT_TOPIC_META_CORE_ACTIVE = "status/active" 22 | MQTT_TOPIC_META_CORE_ONLINE = "status/online" 23 | 24 | MQTT_PL_TRUE = "1" 25 | MQTT_PL_FALSE = "0" 26 | 27 | 28 | class MqttHelper: 29 | def __init__(self, config: appconfig.AppConfig, loglvl=logging.root.level, mqttDiag: bool = False) -> None: 30 | self.config = config 31 | self.debug = loglvl == logging.DEBUG 32 | self.scheduler = ActionScheduler() 33 | self.subs: List[str] = [] 34 | self.mqttDiag = mqttDiag 35 | 36 | self.__on_connect_success = None 37 | self.__on_connect_error = None 38 | self.__on_disconnect = None 39 | 40 | vers_clean_session = True 41 | 42 | if config.mqtt.protocol == mqtt.MQTTv5: 43 | vers_clean_session = None 44 | 45 | client = mqtt.Client( 46 | client_id=config.mqtt.client_id, 47 | clean_session=vers_clean_session, 48 | protocol=config.mqtt.protocol 49 | ) 50 | 51 | if config.mqtt.auth: 52 | client.username_pw_set(config.mqtt.auth.username, config.mqtt.auth.password) 53 | 54 | if mqttDiag: 55 | client.enable_logger(logging.root) 56 | 57 | client.on_connect = self.__proxy_on_connect 58 | client.on_disconnect = self.__proxy_on_disconnect 59 | client.on_subscribe = self.__proxy_on_subscribe 60 | client.on_unsubscribe = self.__proxy_on_unsubscribe 61 | 62 | self.client = client 63 | 64 | @staticmethod 65 | def combine_topic_path(*args: str) -> str: 66 | buff = [] 67 | for arg in args: 68 | buff.append(arg.strip("/")) 69 | return "/".join(buff) 70 | 71 | def received_message(self, msg: mqtt.MQTTMessage, type: str, parsed) -> None: 72 | if self.mqttDiag: 73 | logging.debug(f"Received '{type}' message: '{msg.payload}' on topic: '{msg.topic}' with QoS '{msg.qos}' was retained '{msg.retain}' -> {parsed}") 74 | 75 | def schedule(self, seconds: int, action: Callable) -> None: 76 | self.scheduler.schedule(seconds, action) 77 | 78 | def subscribe(self, topic: str, qos: int = 0) -> None: 79 | if topic in self.subs: 80 | return 81 | 82 | r = self.client.subscribe(topic) 83 | self.subs.append(topic) 84 | logging.debug(f"Subscribed to '{topic}' -> M-ID: {r[1]}, Code: {r[0]} - \"{mqtt.error_string(r[0])}\"") 85 | 86 | def unsubscribe(self, topic: str) -> None: 87 | if topic not in self.subs: 88 | return 89 | 90 | r = self.client.unsubscribe(topic) 91 | self.subs.remove(topic) 92 | logging.debug(f"Unsubscribed from '{topic}' -> M-ID: {r[1]}, Code: {r[0]} - \"{mqtt.error_string(r[0])}\"") 93 | 94 | def unsubscribe_many(self, topics: List[str]) -> None: 95 | if len(topics) == 0: 96 | return 97 | 98 | r = self.client.unsubscribe(topics) 99 | for topic in topics: 100 | logging.debug(f"Unsubscribed from '{topic}' -> M-ID: {r[1]}, Code: {r[0]} - \"{mqtt.error_string(r[0])}\"") 101 | self.subs.remove(topic) 102 | 103 | def unsubscribe_all(self) -> None: 104 | if not len(self.subs): 105 | return 106 | self.unsubscribe_many([x for x in self.subs]) 107 | 108 | def publish(self, topic: str, payload: str | None, qos: int = 0, retain: bool = False, props=None) -> mqtt.MQTTMessageInfo: 109 | return self.client.publish(topic, payload, qos, retain, props) 110 | 111 | def connect(self) -> None: 112 | vers_clean_start = mqtt.MQTT_CLEAN_START_FIRST_ONLY 113 | properties = None 114 | 115 | if self.config.mqtt.protocol == mqtt.MQTTv5: 116 | vers_clean_start = True 117 | properties=Properties(PacketTypes.CONNECT) 118 | properties.SessionExpiryInterval=0 119 | 120 | self.client.connect(host=self.config.mqtt.host, 121 | port=self.config.mqtt.port, 122 | keepalive=self.config.mqtt.keepalive, 123 | clean_start=vers_clean_start, 124 | properties=properties) 125 | 126 | logging.info("Connecting ...") 127 | 128 | def on_connect(self, callback_success: Callable[[], None] | None, callback_error: Callable[[int], None] | None) -> None: 129 | self.__on_connect_success = callback_success 130 | self.__on_connect_error = callback_error 131 | 132 | def on_disconnect(self, callback: Callable[[int], None] | None) -> None: 133 | self.__on_disconnect = callback 134 | 135 | def reset(self) -> None: 136 | self.subs.clear() 137 | self.scheduler.clear() 138 | 139 | def loop_forever(self): 140 | attempt = 0 141 | delay_interval = 2 142 | delay_max = 60 143 | 144 | while True: 145 | while True: 146 | rc = self.client.loop(timeout=1.0) 147 | 148 | if rc is not mqtt.MQTT_ERR_SUCCESS: 149 | break 150 | 151 | attempt = 0 152 | due_actions = self.scheduler.get_due() 153 | if due_actions is not None: 154 | for action in due_actions: 155 | try: 156 | action() 157 | except Exception as ex: 158 | logging.warning(f"Failed to execute scheduled action: {ex}") 159 | 160 | attempt += 1 161 | delay = delay_interval * attempt 162 | delay = delay_max if delay > delay_max else delay 163 | time.sleep(delay) 164 | logging.info(f"[{attempt}]: Reconnecting ...") 165 | self.client.reconnect() 166 | 167 | 168 | # region Event proxys 169 | 170 | 171 | def __proxy_on_connect(self, client: mqtt.Client, ud, flags, rc, props=None) -> None: 172 | logging.info(f"Connection response -> {rc} - \"{mqtt.connack_string(rc)}\", flags: {flags}") 173 | self.reset() 174 | 175 | if rc == mqtt.CONNACK_ACCEPTED: 176 | if self.__on_connect_success is not None: 177 | self.__on_connect_success() 178 | else: 179 | if self.__on_connect_error is not None: 180 | self.__on_connect_error(rc) 181 | 182 | def __proxy_on_disconnect(self, client: mqtt.Client, userdata, rc, props=None) -> None: 183 | logging.warning(f"Disconnected: {rc} - \"{mqtt.error_string(rc)}\"") 184 | self.reset() 185 | 186 | if self.__on_disconnect is not None: 187 | self.__on_disconnect(rc) 188 | 189 | def __proxy_on_subscribe(self, client, userdata, mid, granted_qos_or_rcs, props=None) -> None: 190 | logging.debug(f"Subscribe acknowledged -> M-ID: {mid}") 191 | 192 | def __proxy_on_unsubscribe(self, client, userdata, mid, props=None, rc=None) -> None: 193 | logging.debug(f"Unsubscribe acknowledged -> M-ID: {mid}") 194 | 195 | # endregion 196 | 197 | 198 | class MetaControlHelper(MqttHelper): 199 | def __init__(self, config: appconfig.AppConfig, loglvl=logging.root.level, mqttLogging: bool = False) -> None: 200 | super().__init__(config, loglvl, mqttLogging) 201 | self.topic_meta_cmd_enabled = MqttHelper.combine_topic_path(config.meta.prefix, MQTT_TOPIC_META_CMD_ENABLED) 202 | self.topic_meta_core_enabled = MqttHelper.combine_topic_path(config.meta.prefix, MQTT_TOPIC_META_CORE_ENABLED) 203 | self.topic_meta_core_active = MqttHelper.combine_topic_path(config.meta.prefix, MQTT_TOPIC_META_CORE_ACTIVE) 204 | self.topic_meta_core_online = MqttHelper.combine_topic_path(config.meta.prefix, MQTT_TOPIC_META_CORE_ONLINE) 205 | self.topic_meta_core_inverter_status = MqttHelper.combine_topic_path(config.meta.prefix, MQTT_TOPIC_META_CORE_INVERTER_STATUS) 206 | self.topic_meta_tele_limit = MqttHelper.combine_topic_path(config.meta.prefix, MQTT_TOPIC_META_TELE_LIMIT) 207 | self.topic_meta_tele_cmd = MqttHelper.combine_topic_path(config.meta.prefix, MQTT_TOPIC_META_TELE_CMD) 208 | self.topic_meta_tele_reading = MqttHelper.combine_topic_path(config.meta.prefix, MQTT_TOPIC_META_TELE_READING) 209 | self.topic_meta_tele_sample = MqttHelper.combine_topic_path(config.meta.prefix, MQTT_TOPIC_META_TELE_SAMPLE) 210 | self.topic_meta_tele_overshoot = MqttHelper.combine_topic_path(config.meta.prefix, MQTT_TOPIC_META_TELE_OVERSHOOT) 211 | self.__on_cmd_enabled: Callable[[bool], None] | None = None 212 | self.has_discovery = False 213 | self.has_inverter_status = bool(config.mqtt.topics.inverter_status) 214 | self.has_inverter_power = bool(config.mqtt.topics.inverter_power) 215 | 216 | if config.meta.discovery.enabled: 217 | self.has_discovery = True 218 | self.__discovery_device = self.__create_discovery_device() 219 | self.__discovery_reading = self.__create_discovery_reading() 220 | self.__discovery_sample = self.__create_disovery_sample() 221 | self.__discovery_overshoot = self.__create_discovery_overshoot() 222 | self.__discovery_limit = self.__create_discovery_limit() 223 | self.__discovery_cmd = self.__create_discovery_command() 224 | self.__discovery_status_enabled = self.__create_discovery_status_enabled() 225 | self.__discovery_status_inverter = self.__create_discovery_status_inverter() 226 | self.__discovery_status_active = self.__create_discovery_status_active() 227 | self.__discovery_switch_status_enabled = self.__create_discovery_switch_enabled() 228 | 229 | def setup_will(self) -> None: 230 | self.client.will_set(self.topic_meta_core_online, MQTT_PL_FALSE, 0, True) 231 | 232 | def publish_meta_status_enabled(self, enabled: bool) -> None: 233 | payload = MQTT_PL_TRUE if enabled else MQTT_PL_FALSE 234 | self.publish(self.topic_meta_core_enabled, payload, 0, True) 235 | 236 | def publish_meta_status_active(self, active: bool) -> None: 237 | payload = MQTT_PL_TRUE if active else MQTT_PL_FALSE 238 | self.publish(self.topic_meta_core_active, payload, 0, True) 239 | 240 | def publish_meta_status_online(self, online: bool) -> None: 241 | payload = MQTT_PL_TRUE if online else MQTT_PL_FALSE 242 | self.publish(self.topic_meta_core_online, payload, 0, True) 243 | 244 | def publish_meta_status_inverter(self, status: bool) -> None: 245 | payload = MQTT_PL_TRUE if status else MQTT_PL_FALSE 246 | self.publish(self.topic_meta_core_inverter_status, payload, 0, True) 247 | 248 | def publish_meta_tele_reading(self, reading: float) -> None: 249 | if self.config.meta.telemetry.power: 250 | self.publish(self.topic_meta_tele_reading, f"{reading:.2f}", 0, False) 251 | 252 | def publish_meta_tele_sample(self, sample: float) -> None: 253 | if self.config.meta.telemetry.sample: 254 | self.publish(self.topic_meta_tele_sample, f"{sample:.2f}", 0, False) 255 | 256 | def publish_meta_tele_overshoot(self, overshoot: float) -> None: 257 | if self.config.meta.telemetry.overshoot: 258 | self.publish(self.topic_meta_tele_overshoot, f"{overshoot:.2f}", 0, False) 259 | 260 | def publish_meta_tele_limit(self, limit: float) -> None: 261 | if self.config.meta.telemetry.limit: 262 | self.publish(self.topic_meta_tele_limit, f"{limit:.2f}", 0, False) 263 | 264 | def publish_meta_tele_command(self, cmd: float) -> None: 265 | if self.config.meta.telemetry.command: 266 | self.publish(self.topic_meta_tele_cmd, f"{cmd:.2f}", 0, False) 267 | 268 | def publish_meta_teles(self, reading: float, sample: float, overshoot: float | None, limit: float | None) -> None: 269 | self.publish_meta_tele_reading(reading) 270 | self.publish_meta_tele_sample(sample) 271 | 272 | if overshoot is None: 273 | return 274 | 275 | self.publish_meta_tele_overshoot(overshoot) 276 | 277 | if limit is None: 278 | return 279 | 280 | self.publish_meta_tele_limit(limit) 281 | 282 | def publish_meta_ha_discovery(self) -> None: 283 | if not self.has_discovery: 284 | return 285 | 286 | self.publish(self.__discovery_status_enabled[0], self.__discovery_status_enabled[1], 0, True) 287 | self.publish(self.__discovery_status_inverter[0], self.__discovery_status_inverter[1], 0, True) 288 | self.publish(self.__discovery_status_active[0], self.__discovery_status_active[1], 0, True) 289 | self.publish(self.__discovery_switch_status_enabled[0], self.__discovery_switch_status_enabled[1], 0, True) 290 | 291 | if self.config.meta.telemetry.power: 292 | self.publish(self.__discovery_reading[0], self.__discovery_reading[1], 0, True) 293 | else: 294 | self.publish(self.__discovery_reading[0], "", 0, True) 295 | 296 | if self.config.meta.telemetry.sample: 297 | self.publish(self.__discovery_sample[0], self.__discovery_sample[1], 0, True) 298 | else: 299 | self.publish(self.__discovery_sample[0], "", 0, True) 300 | 301 | if self.config.meta.telemetry.overshoot: 302 | self.publish(self.__discovery_overshoot[0], self.__discovery_overshoot[1], 0, True) 303 | else: 304 | self.publish(self.__discovery_overshoot[0], "", 0, True) 305 | 306 | if self.config.meta.telemetry.limit: 307 | self.publish(self.__discovery_limit[0], self.__discovery_limit[1], 0, True) 308 | else: 309 | self.publish(self.__discovery_limit[0], "", 0, True) 310 | 311 | if self.config.meta.telemetry.command: 312 | self.publish(self.__discovery_cmd[0], self.__discovery_cmd[1], 0, True) 313 | else: 314 | self.publish(self.__discovery_cmd[0], "", 0, True) 315 | 316 | def subscribe_meta_cmd_enabled(self) -> None: 317 | self.subscribe(self.topic_meta_cmd_enabled) 318 | 319 | def on_meta_cmd_enabled(self, callback: Callable[[bool], None] | None) -> None: 320 | self.__on_cmd_enabled = callback 321 | if callback is None: 322 | self.client.message_callback_remove(self.topic_meta_cmd_enabled) 323 | else: 324 | self.client.message_callback_add(self.topic_meta_cmd_enabled, self.__proxy_on_meta_cmd_enabled) 325 | 326 | def __proxy_on_meta_cmd_enabled(self, client: mqtt.Client, userdata, msg: mqtt.MQTTMessage, props=None) -> None: 327 | if self.__on_cmd_enabled is None: 328 | return 329 | 330 | pl = msg.payload.decode().lower() 331 | parsed: bool | None = None 332 | 333 | if pl == MQTT_PL_TRUE: 334 | parsed = True 335 | elif pl == MQTT_PL_FALSE: 336 | parsed = False 337 | 338 | self.received_message(msg, "meta-enabled", parsed) 339 | 340 | if parsed is not None: 341 | self.__on_cmd_enabled(parsed) 342 | 343 | def __create_discovery_reading(self) -> Tuple[str, str]: 344 | config = self.config.meta.discovery 345 | uniq_id = f"sec_{config.id}_state_tele_reading" 346 | name = f"Power" 347 | node_id = f"sec_{config.id}" 348 | topic = self.__create_discovery_topic("sensor", node_id, "reading") 349 | payload = self.__create_discovery_payload_tele_sensor(name, uniq_id, self.topic_meta_tele_reading, "W", uniq_id, "power", "measurement", "mdi:power-plug") 350 | return (topic, payload) 351 | 352 | def __create_disovery_sample(self) -> Tuple[str, str]: 353 | config = self.config.meta.discovery 354 | uniq_id = f"sec_{config.id}_state_tele_sample" 355 | name = f"Sample" 356 | node_id = f"sec_{config.id}" 357 | topic = self.__create_discovery_topic("sensor", node_id, "sample") 358 | payload = self.__create_discovery_payload_tele_sensor(name, uniq_id, self.topic_meta_tele_sample, "W", uniq_id, "power", "measurement", "mdi:sine-wave") 359 | return (topic, payload) 360 | 361 | def __create_discovery_overshoot(self) -> Tuple[str, str]: 362 | config = self.config.meta.discovery 363 | uniq_id = f"sec_{config.id}_state_tele_overshoot" 364 | name = f"Overshoot" 365 | node_id = f"sec_{config.id}" 366 | topic = self.__create_discovery_topic("sensor", node_id, "overshoot") 367 | payload = self.__create_discovery_payload_tele_sensor(name, uniq_id, self.topic_meta_tele_overshoot, "W", uniq_id, "power", "measurement", "mdi:plus-minus") 368 | return (topic, payload) 369 | 370 | def __create_discovery_limit(self) -> Tuple[str, str]: 371 | config = self.config.meta.discovery 372 | uniq_id = f"sec_{config.id}_state_tele_limit" 373 | name = f"Limit" 374 | node_id = f"sec_{config.id}" 375 | topic = self.__create_discovery_topic("sensor", node_id, "limit") 376 | payload = self.__create_discovery_payload_tele_sensor(name, uniq_id, self.topic_meta_tele_limit, "W", uniq_id, "power", "measurement", "mdi:speedometer") 377 | return (topic, payload) 378 | 379 | def __create_discovery_command(self) -> Tuple[str, str]: 380 | config = self.config.meta.discovery 381 | uniq_id = f"sec_{config.id}_state_tele_command" 382 | name = f"Command" 383 | node_id = f"sec_{config.id}" 384 | topic = self.__create_discovery_topic("sensor", node_id, "command") 385 | unit = "%" if self.config.command.type == appconfig.InverterCommandType.RELATIVE else "W" 386 | payload = self.__create_discovery_payload_tele_sensor(name, uniq_id, self.topic_meta_tele_cmd, unit, uniq_id, None, None, "mdi:cube-send") 387 | return (topic, payload) 388 | 389 | def __create_discovery_status_enabled(self) -> Tuple[str, str]: 390 | config = self.config.meta.discovery 391 | uniq_id = f"sec_{config.id}_state_status_enabled" 392 | name = f"Status Enabled" 393 | node_id = f"sec_{config.id}" 394 | topic = self.__create_discovery_topic("binary_sensor", node_id, "status_enabled") 395 | payload = self.__create_discovery_payload_tele_sensor_binary(name, uniq_id, self.topic_meta_core_enabled, uniq_id) 396 | return (topic, payload) 397 | 398 | def __create_discovery_status_inverter(self) -> Tuple[str, str]: 399 | config = self.config.meta.discovery 400 | uniq_id = f"sec_{config.id}_state_status_inverter" 401 | name = f"Status Inverter" 402 | node_id = f"sec_{config.id}" 403 | topic = self.__create_discovery_topic("binary_sensor", node_id, "status_inverter") 404 | payload = self.__create_discovery_payload_tele_sensor_binary(name, uniq_id, self.topic_meta_core_inverter_status, uniq_id) 405 | return (topic, payload) 406 | 407 | def __create_discovery_status_active(self) -> Tuple[str, str]: 408 | config = self.config.meta.discovery 409 | uniq_id = f"sec_{config.id}_state_status_active" 410 | name = f"Status Active" 411 | node_id = f"sec_{config.id}" 412 | topic = self.__create_discovery_topic("binary_sensor", node_id, "status_active") 413 | payload = self.__create_discovery_payload_tele_sensor_binary(name, uniq_id, self.topic_meta_core_active, uniq_id) 414 | return (topic, payload) 415 | 416 | def __create_discovery_switch_enabled(self) -> Tuple[str, str]: 417 | config = self.config.meta.discovery 418 | device = self.__discovery_device 419 | unique_id = f"sec_{config.id}_switch_status_enabled" 420 | name = f"Switch Enabled" 421 | node_id = f"sec_{config.id}" 422 | icon = "mdi:power" 423 | topic = self.__create_discovery_topic("switch", node_id, "switch_status_enabled") 424 | payload = f'{{"name": "{name}", "object_id": "{unique_id}", "unique_id": "{unique_id}", "state_topic": "{self.topic_meta_core_enabled}", "command_topic": "{self.topic_meta_cmd_enabled}", "availability_topic": "{self.topic_meta_core_online}", "payload_on": "{MQTT_PL_TRUE}", "payload_off": "{MQTT_PL_FALSE}", "payload_available": "{MQTT_PL_TRUE}", "payload_not_available": "{MQTT_PL_FALSE}", "device": {device}, "icon": "{icon}", "optimistic": false, "qos": 0, "retain": true }}' 425 | return (topic, payload) 426 | 427 | def __create_discovery_device(self) -> str: 428 | config = self.config.meta.discovery 429 | return f'{{"name":"{config.name}", "ids":"{config.id}","mdl":"Python Application", "mf":"Solar Export Control"}}' 430 | 431 | def __create_discovery_payload_tele_sensor(self, name: str, obj_id: str, state_topic: str, unit: str, unique_id: str, dev_class: str | None, state_class: str | None, icon: str) -> str: 432 | device = self.__discovery_device 433 | dev_class_str = "null" if dev_class is None else f'"{dev_class}"' 434 | state_class_str = "null" if state_class is None else f'"{state_class}"' 435 | 436 | return f'{{"name": "{name}","object_id":"{obj_id}","state_topic": "{state_topic}","unit_of_measurement": "{unit}","unique_id": "{unique_id}","device_class": {dev_class_str},"state_class": {state_class_str},"icon": "{icon}","device": {device},"availability_mode": "all","availability": [{{"topic": "{self.topic_meta_core_online}","payload_available": "{MQTT_PL_TRUE}","payload_not_available": "{MQTT_PL_FALSE}"}},{{"topic": "{self.topic_meta_core_active}","payload_available": "{MQTT_PL_TRUE}","payload_not_available": "{MQTT_PL_FALSE}"}}]}}' 437 | 438 | def __create_discovery_payload_tele_sensor_binary(self, name: str, obj_id: str, state_topic: str, unique_id: str) -> str: 439 | device = self.__discovery_device 440 | return f'{{"name": "{name}","object_id":"{obj_id}","state_topic": "{state_topic}","payload_on": "{MQTT_PL_TRUE}","payload_off": "{MQTT_PL_FALSE}","unique_id": "{unique_id}","device": {device},"availability_mode": "any","availability": [{{"topic": "{self.topic_meta_core_online}","payload_available": "{MQTT_PL_TRUE}","payload_not_available": "{MQTT_PL_FALSE}"}}]}}' 441 | 442 | def __create_discovery_topic(self, component: str, node_id: str, obj_id: str) -> str: 443 | config = self.config.meta.discovery 444 | return self.combine_topic_path(config.prefix, component, node_id, obj_id, "config") 445 | 446 | 447 | class AppMqttHelper(MetaControlHelper): 448 | def __init__(self, config: appconfig.AppConfig, loglvl=logging.root.level, mqttLogging: bool = False) -> None: 449 | super().__init__(config, loglvl, mqttLogging) 450 | self.__on_power_reading: Callable[[float], None] | None = None 451 | self.__on_inverter_status: Callable[[bool], None] | None = None 452 | self.__on_inverter_power: Callable[[float], None] | None = None 453 | 454 | def on_power_reading(self, callback: Callable[[float], None] | None, parser: Callable[[bytes], float | None]) -> None: 455 | self.__on_power_reading = callback 456 | self.__parser_power_reading = parser 457 | 458 | if callback is None: 459 | self.client.message_callback_remove(self.config.mqtt.topics.read_power) 460 | else: 461 | self.client.message_callback_add(self.config.mqtt.topics.read_power, self.__proxy_on_power_reading) 462 | 463 | def on_inverter_status(self, callback: Callable[[bool], None] | None, parser: Callable[[bytes], bool | None]) -> None: 464 | if not bool(self.config.mqtt.topics.inverter_status): 465 | return 466 | 467 | self.__on_inverter_status = callback 468 | self.__parser_inverter_status = parser 469 | 470 | if callback is None: 471 | self.client.message_callback_remove(self.config.mqtt.topics.inverter_status) 472 | else: 473 | self.client.message_callback_add(self.config.mqtt.topics.inverter_status, self.__proxy_on_inverter_status) 474 | 475 | def on_inverter_power(self, callback: Callable[[float], None] | None, parser: Callable[[bytes], float | None]) -> None: 476 | if not bool(self.config.mqtt.topics.inverter_power): 477 | return 478 | 479 | self.__on_inverter_power = callback 480 | self.__parser_inverter_power = parser 481 | 482 | if callback is None: 483 | self.client.message_callback_remove(self.config.mqtt.topics.inverter_power) 484 | else: 485 | self.client.message_callback_add(self.config.mqtt.topics.inverter_power, self.__proxy_on_inverter_power) 486 | 487 | def __proxy_on_power_reading(self, client: mqtt.Client, userdata, msg: mqtt.MQTTMessage, props=None) -> None: 488 | if self.__on_power_reading is None: 489 | return 490 | 491 | try: 492 | value = self.__parser_power_reading(msg.payload) 493 | except Exception as ex: 494 | logging.warning(f"customize.parse_power_payload failed: {ex}") 495 | return 496 | 497 | self.received_message(msg, "power-reading", value) 498 | 499 | if value is not None: 500 | self.__on_power_reading(value) 501 | 502 | def __proxy_on_inverter_status(self, client: mqtt.Client, userdata, msg: mqtt.MQTTMessage, props=None) -> None: 503 | if self.__on_inverter_status is None or not self.has_inverter_status: 504 | return 505 | 506 | try: 507 | value = self.__parser_inverter_status(msg.payload) 508 | except Exception as ex: 509 | logging.warning(f"Failed to parse inverter status: {ex}") 510 | return 511 | 512 | self.received_message(msg, "inverter-status", value) 513 | 514 | if value is not None: 515 | self.__on_inverter_status(value) 516 | 517 | def __proxy_on_inverter_power(self, client: mqtt.Client, userdata, msg: mqtt.MQTTMessage, props=None) -> None: 518 | if self.__on_inverter_power is None or not self.has_inverter_power: 519 | return 520 | 521 | try: 522 | value = self.__parser_inverter_power(msg.payload) 523 | except Exception as ex: 524 | logging.warning(f"Failed to parse inverter power: {ex}") 525 | return 526 | 527 | self.received_message(msg, "inverter-power", value) 528 | 529 | if value is not None: 530 | self.__on_inverter_power(value) 531 | 532 | def publish_command(self, command: str) -> None: 533 | if self.config.mqtt.topics.write_command: 534 | r = self.publish(self.config.mqtt.topics.write_command, command, 0, False) 535 | logging.info(f"Published command: '{command}', Result: '{r}'") 536 | 537 | def subscribe_power_reading(self) -> None: 538 | self.subscribe(self.config.mqtt.topics.read_power, 0) 539 | 540 | def unsubscribe_power_reading(self) -> None: 541 | self.unsubscribe(self.config.mqtt.topics.read_power) 542 | 543 | def subscribe_inverter_status(self) -> None: 544 | if self.has_inverter_status and self.config.mqtt.topics.inverter_status: 545 | self.subscribe(self.config.mqtt.topics.inverter_status, 0) 546 | 547 | def unsubscribes_inverter_status(self) -> None: 548 | if self.has_inverter_status and self.config.mqtt.topics.inverter_status: 549 | self.unsubscribe(self.config.mqtt.topics.inverter_status) 550 | 551 | def subscribe_inverter_power(self) -> None: 552 | if self.has_inverter_power and self.config.mqtt.topics.inverter_power: 553 | self.subscribe(self.config.mqtt.topics.inverter_power, 0) 554 | 555 | def unsubscribe_inverter_power(self) -> None: 556 | if self.has_inverter_power and self.config.mqtt.topics.inverter_power: 557 | self.unsubscribe(self.config.mqtt.topics.inverter_power) 558 | 559 | class ActionScheduler: 560 | def __init__(self) -> None: 561 | self.items = [] 562 | self.nextTime = datetime.datetime.max 563 | 564 | def schedule(self, seconds: int, action: Callable) -> None: 565 | when = datetime.datetime.utcnow() + datetime.timedelta(seconds=seconds) 566 | self.items.append((when, action)) 567 | if self.nextTime > when: 568 | self.nextTime = when 569 | 570 | def get_due(self) -> List[Callable] | None: 571 | now = datetime.datetime.utcnow() 572 | 573 | if self.nextTime <= now: 574 | self.nextTime = datetime.datetime.max 575 | hits = [] 576 | 577 | for item in self.items: 578 | if item[0] <= now: 579 | hits.append(item) 580 | elif self.nextTime > item[0]: 581 | self.nextTime = item[0] 582 | 583 | if (len(hits) == 0): 584 | return None 585 | 586 | results = [] 587 | for hit in hits: 588 | self.items.remove(hit) 589 | results.append(hit[1]) 590 | 591 | return results 592 | return None 593 | 594 | def clear(self) -> None: 595 | self.items.clear() 596 | self.nextTime = datetime.datetime.max 597 | --------------------------------------------------------------------------------