qilowatt 2024.2.1__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- qilowatt-2024.2.1/.github/workflows/publish.yml +39 -0
- qilowatt-2024.2.1/.gitignore +1 -0
- qilowatt-2024.2.1/LICENSE.txt +21 -0
- qilowatt-2024.2.1/PKG-INFO +34 -0
- qilowatt-2024.2.1/README.md +6 -0
- qilowatt-2024.2.1/example.py +79 -0
- qilowatt-2024.2.1/pyproject.toml +46 -0
- qilowatt-2024.2.1/setup.cfg +4 -0
- qilowatt-2024.2.1/src/qilowatt/__init__.py +36 -0
- qilowatt-2024.2.1/src/qilowatt/client.py +373 -0
- qilowatt-2024.2.1/src/qilowatt/constants.py +1 -0
- qilowatt-2024.2.1/src/qilowatt/exceptions.py +15 -0
- qilowatt-2024.2.1/src/qilowatt/models.py +165 -0
- qilowatt-2024.2.1/src/qilowatt.egg-info/PKG-INFO +34 -0
- qilowatt-2024.2.1/src/qilowatt.egg-info/SOURCES.txt +16 -0
- qilowatt-2024.2.1/src/qilowatt.egg-info/dependency_links.txt +1 -0
- qilowatt-2024.2.1/src/qilowatt.egg-info/requires.txt +2 -0
- qilowatt-2024.2.1/src/qilowatt.egg-info/top_level.txt +1 -0
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
name: Build and Publish Package
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
tags:
|
|
6
|
+
- 'v*.*.*' # Triggers on version tags like v1.0.0
|
|
7
|
+
|
|
8
|
+
jobs:
|
|
9
|
+
build-and-publish:
|
|
10
|
+
runs-on: ubuntu-latest
|
|
11
|
+
environment: release
|
|
12
|
+
permissions:
|
|
13
|
+
contents: read # For actions/checkout
|
|
14
|
+
packages: write # For publishing to PyPI
|
|
15
|
+
id-token: write
|
|
16
|
+
|
|
17
|
+
steps:
|
|
18
|
+
- name: Checkout code
|
|
19
|
+
uses: actions/checkout@v3
|
|
20
|
+
|
|
21
|
+
- name: Set up Python
|
|
22
|
+
uses: actions/setup-python@v4
|
|
23
|
+
with:
|
|
24
|
+
python-version: '3.9' # Use the Python version compatible with your package
|
|
25
|
+
|
|
26
|
+
- name: Install build tools
|
|
27
|
+
run: |
|
|
28
|
+
python -m pip install --upgrade pip
|
|
29
|
+
pip install build twine setuptools_scm
|
|
30
|
+
|
|
31
|
+
- name: Build package
|
|
32
|
+
run: |
|
|
33
|
+
python -m build --sdist --wheel
|
|
34
|
+
|
|
35
|
+
- name: Publish package distributions to PyPI
|
|
36
|
+
uses: pypa/gh-action-pypi-publish@release/v1
|
|
37
|
+
|
|
38
|
+
- name: Confirm success
|
|
39
|
+
run: echo "Package published successfully!"
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
.DS_Store
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2018 YOUR NAME
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
Metadata-Version: 2.2
|
|
2
|
+
Name: qilowatt
|
|
3
|
+
Version: 2024.2.1
|
|
4
|
+
Summary: Communication package for Qilowatt inverters
|
|
5
|
+
Author-email: Tanel Vakker <tanel@vakker.org>
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/qilowatt/qilowatt-py
|
|
8
|
+
Project-URL: Documentation, https://github.com/qilowatt/qilowatt-py#readme
|
|
9
|
+
Project-URL: Issues, https://github.com/qilowatt/qilowatt-py/issues
|
|
10
|
+
Project-URL: Repository, https://github.com/qilowatt/qilowatt-py
|
|
11
|
+
Keywords: mqtt,qilowatt,inverter,energy,home-assistant
|
|
12
|
+
Classifier: Development Status :: 4 - Beta
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: Topic :: Home Automation
|
|
15
|
+
Classifier: Topic :: Software Development :: Libraries
|
|
16
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
17
|
+
Classifier: Programming Language :: Python :: 3
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.7
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.8
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
22
|
+
Classifier: Operating System :: OS Independent
|
|
23
|
+
Requires-Python: >=3.7
|
|
24
|
+
Description-Content-Type: text/markdown
|
|
25
|
+
License-File: LICENSE.txt
|
|
26
|
+
Requires-Dist: paho-mqtt>=1.6.1
|
|
27
|
+
Requires-Dist: getmac>=0.8.3
|
|
28
|
+
|
|
29
|
+
# [qilowatt](<https://qilowatt.eu>) Python Module
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
- connecting to qilowatt MQTT server to send status, state and state0.
|
|
33
|
+
- Receives BACKLOG commands
|
|
34
|
+
- see example.py for usage
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
# example.py
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
from qilowatt import QilowattMQTTClient, EnergyData, MetricsData
|
|
5
|
+
import time
|
|
6
|
+
|
|
7
|
+
# Configure logging
|
|
8
|
+
logging.basicConfig(level=logging.DEBUG)
|
|
9
|
+
|
|
10
|
+
# User-provided MQTT credentials and inverter ID
|
|
11
|
+
mqtt_username = 'your_username'
|
|
12
|
+
mqtt_password = 'your_password'
|
|
13
|
+
inverter_id = 'your_inverter_id'
|
|
14
|
+
|
|
15
|
+
# Create the Qilowatt MQTT client
|
|
16
|
+
client = QilowattMQTTClient(
|
|
17
|
+
mqtt_username=mqtt_username,
|
|
18
|
+
mqtt_password=mqtt_password,
|
|
19
|
+
inverter_id=inverter_id,
|
|
20
|
+
host='test-mqtt.qilowatt.it',
|
|
21
|
+
port=8883,
|
|
22
|
+
tls=True
|
|
23
|
+
)
|
|
24
|
+
def on_command_received(command):
|
|
25
|
+
print(f"Received WorkMode Command: {command}")
|
|
26
|
+
|
|
27
|
+
# Set the command callback
|
|
28
|
+
client.set_command_callback(on_command_received)
|
|
29
|
+
|
|
30
|
+
# Connect to the MQTT broker
|
|
31
|
+
client.connect()
|
|
32
|
+
|
|
33
|
+
# Wait a moment to ensure connection is established
|
|
34
|
+
time.sleep(2)
|
|
35
|
+
|
|
36
|
+
# Prepare ENERGY and METRICS data
|
|
37
|
+
energy_data = EnergyData(
|
|
38
|
+
Power=[100.0, 100.0, 100.0],
|
|
39
|
+
Today=5.0,
|
|
40
|
+
Total=1000.0,
|
|
41
|
+
Current=[5.0, 5.0, 5.0],
|
|
42
|
+
Voltage=[230.0, 230.0, 230.0],
|
|
43
|
+
Frequency=50.0
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
metrics_data = MetricsData(
|
|
47
|
+
PvPower=[200.0, 200.0],
|
|
48
|
+
PvVoltage=[300.0, 300.0],
|
|
49
|
+
PvCurrent=[8.0, 8.0],
|
|
50
|
+
LoadPower=[500.0, 500.0, 500.0],
|
|
51
|
+
BatterySOC=80,
|
|
52
|
+
LoadCurrent=[10.0, 10.0, 10.0],
|
|
53
|
+
BatteryPower=[400.0],
|
|
54
|
+
BatteryCurrent=[15.0],
|
|
55
|
+
BatteryVoltage=[48.0],
|
|
56
|
+
GridExportLimit=5000.0,
|
|
57
|
+
BatteryTemperature=[30.0],
|
|
58
|
+
InverterTemperature=40.0
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
# Set the data
|
|
62
|
+
client.set_energy_data(energy_data)
|
|
63
|
+
client.set_metrics_data(metrics_data)
|
|
64
|
+
|
|
65
|
+
# Now the module will automatically start sending data at the specified intervals
|
|
66
|
+
|
|
67
|
+
# Keep the script running to receive commands and send data
|
|
68
|
+
try:
|
|
69
|
+
print("Running. Press Ctrl+C to exit.")
|
|
70
|
+
while True:
|
|
71
|
+
# Simulate updating data periodically
|
|
72
|
+
time.sleep(30)
|
|
73
|
+
# Update ENERGY data if needed
|
|
74
|
+
energy_data.Today += 0.1
|
|
75
|
+
client.set_energy_data(energy_data)
|
|
76
|
+
except KeyboardInterrupt:
|
|
77
|
+
print("Exiting...")
|
|
78
|
+
finally:
|
|
79
|
+
client.disconnect()
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=61.0", "wheel", "setuptools_scm"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "qilowatt"
|
|
7
|
+
dynamic = ["version"]
|
|
8
|
+
description = "Communication package for Qilowatt inverters"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.7"
|
|
11
|
+
license = { text = "MIT" }
|
|
12
|
+
authors = [
|
|
13
|
+
{ name = "Tanel Vakker", email = "tanel@vakker.org" }
|
|
14
|
+
]
|
|
15
|
+
keywords = ["mqtt", "qilowatt", "inverter", "energy", "home-assistant"]
|
|
16
|
+
classifiers = [
|
|
17
|
+
"Development Status :: 4 - Beta",
|
|
18
|
+
"Intended Audience :: Developers",
|
|
19
|
+
"Topic :: Home Automation",
|
|
20
|
+
"Topic :: Software Development :: Libraries",
|
|
21
|
+
"License :: OSI Approved :: MIT License",
|
|
22
|
+
"Programming Language :: Python :: 3",
|
|
23
|
+
"Programming Language :: Python :: 3.7",
|
|
24
|
+
"Programming Language :: Python :: 3.8",
|
|
25
|
+
"Programming Language :: Python :: 3.9",
|
|
26
|
+
"Programming Language :: Python :: 3.10",
|
|
27
|
+
"Operating System :: OS Independent",
|
|
28
|
+
]
|
|
29
|
+
|
|
30
|
+
dependencies = [
|
|
31
|
+
"paho-mqtt>=1.6.1",
|
|
32
|
+
"getmac>=0.8.3",
|
|
33
|
+
]
|
|
34
|
+
|
|
35
|
+
[project.urls]
|
|
36
|
+
Homepage = "https://github.com/qilowatt/qilowatt-py"
|
|
37
|
+
Documentation = "https://github.com/qilowatt/qilowatt-py#readme"
|
|
38
|
+
Issues = "https://github.com/qilowatt/qilowatt-py/issues"
|
|
39
|
+
Repository = "https://github.com/qilowatt/qilowatt-py"
|
|
40
|
+
|
|
41
|
+
[tool.setuptools.packages.find]
|
|
42
|
+
where = ["src"]
|
|
43
|
+
|
|
44
|
+
[tool.setuptools_scm]
|
|
45
|
+
version_scheme = "post-release"
|
|
46
|
+
local_scheme = "no-local-version"
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# qilowatt/__init__.py
|
|
2
|
+
|
|
3
|
+
from .client import QilowattMQTTClient
|
|
4
|
+
from .models import (
|
|
5
|
+
EnergyData, MetricsData, WorkModeCommand,
|
|
6
|
+
Status0Data, StatusData, StatusPRMData, StatusFWRData,
|
|
7
|
+
StatusLOGData, StatusNETData, StatusMQTData, StatusTIMData
|
|
8
|
+
)
|
|
9
|
+
from .exceptions import (
|
|
10
|
+
QilowattException,
|
|
11
|
+
ConnectionError,
|
|
12
|
+
AuthenticationError,
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
try:
|
|
16
|
+
from ._version import version as __version__
|
|
17
|
+
except ImportError:
|
|
18
|
+
__version__ = "unknown"
|
|
19
|
+
|
|
20
|
+
__all__ = [
|
|
21
|
+
"QilowattMQTTClient",
|
|
22
|
+
"EnergyData",
|
|
23
|
+
"MetricsData",
|
|
24
|
+
"WorkModeCommand",
|
|
25
|
+
"Status0Data",
|
|
26
|
+
"StatusData",
|
|
27
|
+
"StatusPRMData",
|
|
28
|
+
"StatusFWRData",
|
|
29
|
+
"StatusLOGData",
|
|
30
|
+
"StatusNETData",
|
|
31
|
+
"StatusMQTData",
|
|
32
|
+
"StatusTIMData",
|
|
33
|
+
"QilowattException",
|
|
34
|
+
"ConnectionError",
|
|
35
|
+
"AuthenticationError",
|
|
36
|
+
]
|
|
@@ -0,0 +1,373 @@
|
|
|
1
|
+
# qilowatt/client.py
|
|
2
|
+
|
|
3
|
+
import ssl
|
|
4
|
+
import json
|
|
5
|
+
import threading
|
|
6
|
+
import logging
|
|
7
|
+
import paho.mqtt.client as mqtt
|
|
8
|
+
from typing import Callable, Optional
|
|
9
|
+
from .models import SensorData, WorkModeCommand, EnergyData, MetricsData, Status0Data, StatusData, StatusPRMData, StatusFWRData, StatusLOGData, StatusNETData, StatusMQTData, StatusTIMData
|
|
10
|
+
from .exceptions import ConnectionError, AuthenticationError
|
|
11
|
+
from datetime import datetime, timezone
|
|
12
|
+
import platform
|
|
13
|
+
import socket
|
|
14
|
+
import getmac
|
|
15
|
+
|
|
16
|
+
_logger = logging.getLogger(__name__)
|
|
17
|
+
|
|
18
|
+
class QilowattMQTTClient:
|
|
19
|
+
"""Client to handle MQTT communication with Qilowatt server."""
|
|
20
|
+
|
|
21
|
+
def __init__(
|
|
22
|
+
self,
|
|
23
|
+
mqtt_username: str,
|
|
24
|
+
mqtt_password: str,
|
|
25
|
+
inverter_id: str,
|
|
26
|
+
host: str = "mqtt.qilowatt.it",
|
|
27
|
+
port: int = 8883,
|
|
28
|
+
tls: bool = True,
|
|
29
|
+
):
|
|
30
|
+
self.mqtt_username = mqtt_username
|
|
31
|
+
self.mqtt_password = mqtt_password
|
|
32
|
+
self.inverter_id = inverter_id
|
|
33
|
+
|
|
34
|
+
self.host = host
|
|
35
|
+
self.port = port
|
|
36
|
+
self.tls = tls
|
|
37
|
+
|
|
38
|
+
self._client = mqtt.Client()
|
|
39
|
+
self._connected = False
|
|
40
|
+
self._lock = threading.Lock()
|
|
41
|
+
|
|
42
|
+
self._sensor_topic = f"Q/{self.inverter_id}/SENSOR"
|
|
43
|
+
self._state_topic = f"Q/{self.inverter_id}/STATE"
|
|
44
|
+
self._status0_topic = f"Q/{self.inverter_id}/STATUS0"
|
|
45
|
+
self._command_topic = f"Q/{self.inverter_id}/cmnd/backlog"
|
|
46
|
+
|
|
47
|
+
self._workmode_command: Optional[WorkModeCommand] = None # Store the latest command
|
|
48
|
+
self._workmode_command = WorkModeCommand.from_dict({"Mode": "normal"}) # Default to normal mode
|
|
49
|
+
|
|
50
|
+
self._on_command_callback: Optional[Callable[[WorkModeCommand], None]] = None
|
|
51
|
+
|
|
52
|
+
# Data storage
|
|
53
|
+
self._energy_data: Optional[EnergyData] = None
|
|
54
|
+
self._metrics_data: Optional[MetricsData] = None
|
|
55
|
+
|
|
56
|
+
# Timers
|
|
57
|
+
self._sensor_timer_thread: Optional[threading.Thread] = None
|
|
58
|
+
self._state_timer_thread: Optional[threading.Thread] = None
|
|
59
|
+
self._status0_timer_thread: Optional[threading.Thread] = None
|
|
60
|
+
|
|
61
|
+
self._sensor_timer_stop_event = threading.Event()
|
|
62
|
+
self._state_timer_stop_event = threading.Event()
|
|
63
|
+
self._status0_timer_stop_event = threading.Event()
|
|
64
|
+
|
|
65
|
+
# Flags to indicate whether data has been initialized
|
|
66
|
+
self._data_initialized = False
|
|
67
|
+
|
|
68
|
+
# Initialize startup time and boot count
|
|
69
|
+
self._startup_utc = datetime.utcnow()
|
|
70
|
+
self._boot_count = 1 # Increment as appropriate
|
|
71
|
+
|
|
72
|
+
self._setup_client()
|
|
73
|
+
|
|
74
|
+
def _setup_client(self):
|
|
75
|
+
if self.tls:
|
|
76
|
+
self._client.tls_set(cert_reqs=ssl.CERT_NONE)
|
|
77
|
+
self._client.tls_insecure_set(True)
|
|
78
|
+
|
|
79
|
+
self._client.username_pw_set(self.mqtt_username, self.mqtt_password)
|
|
80
|
+
self._client.on_connect = self._on_connect
|
|
81
|
+
self._client.on_message = self._on_message
|
|
82
|
+
self._client.on_disconnect = self._on_disconnect
|
|
83
|
+
|
|
84
|
+
def _on_connect(self, client, userdata, flags, rc):
|
|
85
|
+
_logger.debug(f"Connected with result code {rc}")
|
|
86
|
+
if rc == 0:
|
|
87
|
+
self._connected = True
|
|
88
|
+
# Subscribe to command topic
|
|
89
|
+
client.subscribe(self._command_topic)
|
|
90
|
+
elif rc == 5:
|
|
91
|
+
raise AuthenticationError("Authentication failed")
|
|
92
|
+
else:
|
|
93
|
+
raise ConnectionError(f"Connection failed with result code {rc}")
|
|
94
|
+
|
|
95
|
+
def _on_disconnect(self, client, userdata, rc):
|
|
96
|
+
_logger.debug(f"Disconnected with result code {rc}")
|
|
97
|
+
self._connected = False
|
|
98
|
+
|
|
99
|
+
def _on_message(self, client, userdata, msg):
|
|
100
|
+
_logger.debug(f"Message received on {msg.topic}: {msg.payload}")
|
|
101
|
+
if msg.topic == self._command_topic:
|
|
102
|
+
self._handle_command_message(msg.payload)
|
|
103
|
+
|
|
104
|
+
def _handle_command_message(self, payload):
|
|
105
|
+
try:
|
|
106
|
+
message = payload.decode('utf-8')
|
|
107
|
+
if message.startswith("WORKMODE"):
|
|
108
|
+
json_part = message[len("WORKMODE "):]
|
|
109
|
+
data = json.loads(json_part)
|
|
110
|
+
command = WorkModeCommand.from_dict(data)
|
|
111
|
+
self._workmode_command = command # Store internally
|
|
112
|
+
if self._on_command_callback:
|
|
113
|
+
self._on_command_callback(command)
|
|
114
|
+
except Exception as e:
|
|
115
|
+
_logger.error(f"Error processing command message: {e}")
|
|
116
|
+
|
|
117
|
+
def set_command_callback(self, callback: Callable[[WorkModeCommand], None]):
|
|
118
|
+
"""Set the callback function to be called when a command is received."""
|
|
119
|
+
self._on_command_callback = callback
|
|
120
|
+
|
|
121
|
+
def set_energy_data(self, energy_data: EnergyData):
|
|
122
|
+
"""Set the ENERGY data."""
|
|
123
|
+
self._energy_data = energy_data
|
|
124
|
+
self._check_data_initialized()
|
|
125
|
+
|
|
126
|
+
def set_metrics_data(self, metrics_data: MetricsData):
|
|
127
|
+
"""Set the METRICS data."""
|
|
128
|
+
self._metrics_data = metrics_data
|
|
129
|
+
self._check_data_initialized()
|
|
130
|
+
|
|
131
|
+
def _check_data_initialized(self):
|
|
132
|
+
if self._energy_data and self._metrics_data and not self._data_initialized:
|
|
133
|
+
self._data_initialized = True
|
|
134
|
+
self._start_timers()
|
|
135
|
+
|
|
136
|
+
def _start_timers(self):
|
|
137
|
+
# Start the timers for sending data
|
|
138
|
+
self._start_sensor_timer()
|
|
139
|
+
self._start_state_timer()
|
|
140
|
+
self._start_status0_timer()
|
|
141
|
+
|
|
142
|
+
def _start_sensor_timer(self):
|
|
143
|
+
def sensor_timer():
|
|
144
|
+
while not self._sensor_timer_stop_event.wait(10):
|
|
145
|
+
self.publish_sensor_data()
|
|
146
|
+
|
|
147
|
+
self._sensor_timer_thread = threading.Thread(target=sensor_timer, name="SensorTimer")
|
|
148
|
+
self._sensor_timer_thread.daemon = True
|
|
149
|
+
self._sensor_timer_thread.start()
|
|
150
|
+
_logger.debug("Started sensor data timer.")
|
|
151
|
+
|
|
152
|
+
def _start_state_timer(self):
|
|
153
|
+
def state_timer():
|
|
154
|
+
while not self._state_timer_stop_event.wait(60):
|
|
155
|
+
self.publish_state_data()
|
|
156
|
+
|
|
157
|
+
self._state_timer_thread = threading.Thread(target=state_timer, name="StateTimer")
|
|
158
|
+
self._state_timer_thread.daemon = True
|
|
159
|
+
self._state_timer_thread.start()
|
|
160
|
+
_logger.debug("Started state data timer.")
|
|
161
|
+
|
|
162
|
+
def _start_status0_timer(self):
|
|
163
|
+
def status0_timer():
|
|
164
|
+
# Send at startup
|
|
165
|
+
self.publish_status0_data()
|
|
166
|
+
# Then every 60 minutes
|
|
167
|
+
while not self._status0_timer_stop_event.wait(3600):
|
|
168
|
+
self.publish_status0_data()
|
|
169
|
+
|
|
170
|
+
self._status0_timer_thread = threading.Thread(target=status0_timer, name="Status0Timer")
|
|
171
|
+
self._status0_timer_thread.daemon = True
|
|
172
|
+
self._status0_timer_thread.start()
|
|
173
|
+
_logger.debug("Started STATUS0 data timer.")
|
|
174
|
+
|
|
175
|
+
def publish_sensor_data(self):
|
|
176
|
+
"""Publish sensor data to the MQTT broker."""
|
|
177
|
+
if self._connected and self._data_initialized:
|
|
178
|
+
# Build the sensor data
|
|
179
|
+
sensor_data = SensorData(
|
|
180
|
+
ENERGY=self._energy_data,
|
|
181
|
+
METRICS=self._metrics_data,
|
|
182
|
+
WORKMODE=self._workmode_command # Use the latest WORKMODE command
|
|
183
|
+
)
|
|
184
|
+
# Update the time
|
|
185
|
+
sensor_data.Time = datetime.utcnow().isoformat()
|
|
186
|
+
payload = json.dumps(sensor_data.to_dict())
|
|
187
|
+
self._client.publish(self._sensor_topic, payload)
|
|
188
|
+
_logger.debug(f"Published sensor data to {self._sensor_topic}")
|
|
189
|
+
else:
|
|
190
|
+
_logger.warning("Cannot publish sensor data: not connected or data not initialized.")
|
|
191
|
+
|
|
192
|
+
def publish_state_data(self):
|
|
193
|
+
"""Publish state data to the MQTT broker."""
|
|
194
|
+
if self._connected:
|
|
195
|
+
state_data = self.generate_state_data()
|
|
196
|
+
payload = json.dumps(state_data)
|
|
197
|
+
self._client.publish(self._state_topic, payload)
|
|
198
|
+
_logger.debug(f"Published state data to {self._state_topic}")
|
|
199
|
+
else:
|
|
200
|
+
_logger.warning("Cannot publish state data: not connected.")
|
|
201
|
+
|
|
202
|
+
def generate_state_data(self):
|
|
203
|
+
"""Generate the STATE data."""
|
|
204
|
+
state_data = {
|
|
205
|
+
"Time": datetime.utcnow().isoformat(),
|
|
206
|
+
# Add other state fields as necessary
|
|
207
|
+
# For example:
|
|
208
|
+
"Uptime": int((datetime.utcnow() - self._startup_utc).total_seconds()),
|
|
209
|
+
"POWER1": "ON",
|
|
210
|
+
}
|
|
211
|
+
return state_data
|
|
212
|
+
|
|
213
|
+
def publish_status0_data(self):
|
|
214
|
+
"""Publish STATUS0 data to the MQTT broker."""
|
|
215
|
+
if self._connected:
|
|
216
|
+
status0_data = self.generate_status0_data()
|
|
217
|
+
payload = json.dumps(status0_data.to_dict())
|
|
218
|
+
self._client.publish(self._status0_topic, payload)
|
|
219
|
+
_logger.debug(f"Published STATUS0 data to {self._status0_topic}")
|
|
220
|
+
else:
|
|
221
|
+
_logger.warning("Cannot publish STATUS0 data: not connected.")
|
|
222
|
+
|
|
223
|
+
def generate_status0_data(self) -> Status0Data:
|
|
224
|
+
"""Generate the STATUS0 data."""
|
|
225
|
+
# Automatically determine some values
|
|
226
|
+
device_name = "Qilowatt Device"
|
|
227
|
+
friendly_name = ["Home Assistant", "", ""]
|
|
228
|
+
topic = self.inverter_id # Assuming inverter_id is unique
|
|
229
|
+
|
|
230
|
+
startup_utc = self._startup_utc.replace(tzinfo=timezone.utc).isoformat()
|
|
231
|
+
boot_count = self._boot_count
|
|
232
|
+
|
|
233
|
+
version = "QW-MQTT-API-24.10.01" # Example version
|
|
234
|
+
hardware = platform.machine()
|
|
235
|
+
|
|
236
|
+
tele_period = 10 # Example telemetry period
|
|
237
|
+
|
|
238
|
+
hostname = socket.gethostname()
|
|
239
|
+
ip_address = self._get_ip_address()
|
|
240
|
+
gateway = self._get_default_gateway()
|
|
241
|
+
subnet_mask = self._get_subnet_mask()
|
|
242
|
+
mac_address = getmac.get_mac_address()
|
|
243
|
+
|
|
244
|
+
mqtt_host = self.host
|
|
245
|
+
mqtt_port = self.port
|
|
246
|
+
mqtt_client = self._client._client_id.decode('utf-8') if self._client._client_id else ''
|
|
247
|
+
mqtt_user = self.mqtt_username
|
|
248
|
+
|
|
249
|
+
utc_now = datetime.utcnow().replace(tzinfo=timezone.utc).isoformat()
|
|
250
|
+
local_now = datetime.now().isoformat()
|
|
251
|
+
|
|
252
|
+
status = StatusData(
|
|
253
|
+
DeviceName=device_name,
|
|
254
|
+
FriendlyName=friendly_name,
|
|
255
|
+
Topic=topic,
|
|
256
|
+
)
|
|
257
|
+
|
|
258
|
+
status_prm = StatusPRMData(
|
|
259
|
+
StartupUTC=startup_utc,
|
|
260
|
+
BootCount=boot_count,
|
|
261
|
+
)
|
|
262
|
+
|
|
263
|
+
status_fwr = StatusFWRData(
|
|
264
|
+
Version=version,
|
|
265
|
+
Hardware=hardware,
|
|
266
|
+
)
|
|
267
|
+
|
|
268
|
+
status_log = StatusLOGData(
|
|
269
|
+
TelePeriod=tele_period,
|
|
270
|
+
)
|
|
271
|
+
|
|
272
|
+
status_net = StatusNETData(
|
|
273
|
+
Hostname=hostname,
|
|
274
|
+
IPAddress=ip_address,
|
|
275
|
+
Gateway=gateway,
|
|
276
|
+
Subnetmask=subnet_mask,
|
|
277
|
+
Mac=mac_address,
|
|
278
|
+
)
|
|
279
|
+
|
|
280
|
+
mqtt_client_mask = "QWAPI_%06X" # Example mask
|
|
281
|
+
|
|
282
|
+
status_mqt = StatusMQTData(
|
|
283
|
+
MqttHost=mqtt_host,
|
|
284
|
+
MqttPort=mqtt_port,
|
|
285
|
+
MqttClient=mqtt_client,
|
|
286
|
+
MqttUser=mqtt_user,
|
|
287
|
+
MqttClientMask=mqtt_client_mask,
|
|
288
|
+
)
|
|
289
|
+
|
|
290
|
+
status_tim = StatusTIMData(
|
|
291
|
+
UTC=utc_now,
|
|
292
|
+
Local=local_now,
|
|
293
|
+
)
|
|
294
|
+
|
|
295
|
+
status0_data = Status0Data(
|
|
296
|
+
Status=status,
|
|
297
|
+
StatusPRM=status_prm,
|
|
298
|
+
StatusFWR=status_fwr,
|
|
299
|
+
StatusLOG=status_log,
|
|
300
|
+
StatusNET=status_net,
|
|
301
|
+
StatusMQT=status_mqt,
|
|
302
|
+
StatusTIM=status_tim,
|
|
303
|
+
)
|
|
304
|
+
|
|
305
|
+
return status0_data
|
|
306
|
+
|
|
307
|
+
# Helper methods to retrieve network information
|
|
308
|
+
def _get_ip_address(self):
|
|
309
|
+
try:
|
|
310
|
+
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
|
311
|
+
# This IP is not used; it's just to get the appropriate interface
|
|
312
|
+
s.connect(("8.8.8.8", 80))
|
|
313
|
+
ip_address = s.getsockname()[0]
|
|
314
|
+
s.close()
|
|
315
|
+
return ip_address
|
|
316
|
+
except Exception as e:
|
|
317
|
+
_logger.error(f"Error getting IP address: {e}")
|
|
318
|
+
return "0.0.0.0"
|
|
319
|
+
|
|
320
|
+
def _get_default_gateway(self):
|
|
321
|
+
# Implement logic to retrieve default gateway
|
|
322
|
+
# Placeholder implementation
|
|
323
|
+
return "192.168.1.1"
|
|
324
|
+
|
|
325
|
+
def _get_subnet_mask(self):
|
|
326
|
+
# Implement logic to retrieve subnet mask
|
|
327
|
+
# Placeholder implementation
|
|
328
|
+
return "255.255.255.0"
|
|
329
|
+
|
|
330
|
+
def connect(self):
|
|
331
|
+
"""Connect to the MQTT broker and start the loop."""
|
|
332
|
+
with self._lock:
|
|
333
|
+
if not self._connected:
|
|
334
|
+
self._client.connect(self.host, self.port)
|
|
335
|
+
self._client.loop_start()
|
|
336
|
+
|
|
337
|
+
def disconnect(self):
|
|
338
|
+
"""Disconnect from the MQTT broker and stop the loop."""
|
|
339
|
+
with self._lock:
|
|
340
|
+
if self._connected:
|
|
341
|
+
self._client.loop_stop()
|
|
342
|
+
self._client.disconnect()
|
|
343
|
+
self._connected = False
|
|
344
|
+
self._stop_timers()
|
|
345
|
+
|
|
346
|
+
def _stop_timers(self):
|
|
347
|
+
"""Stop all running timers."""
|
|
348
|
+
if self._sensor_timer_thread:
|
|
349
|
+
self._sensor_timer_stop_event.set()
|
|
350
|
+
self._sensor_timer_thread.join()
|
|
351
|
+
_logger.debug("Stopped sensor data timer.")
|
|
352
|
+
|
|
353
|
+
if self._state_timer_thread:
|
|
354
|
+
self._state_timer_stop_event.set()
|
|
355
|
+
self._state_timer_thread.join()
|
|
356
|
+
_logger.debug("Stopped state data timer.")
|
|
357
|
+
|
|
358
|
+
if self._status0_timer_thread:
|
|
359
|
+
self._status0_timer_stop_event.set()
|
|
360
|
+
self._status0_timer_thread.join()
|
|
361
|
+
_logger.debug("Stopped STATUS0 data timer.")
|
|
362
|
+
|
|
363
|
+
# Reset events and threads
|
|
364
|
+
self._sensor_timer_stop_event.clear()
|
|
365
|
+
self._state_timer_stop_event.clear()
|
|
366
|
+
self._status0_timer_stop_event.clear()
|
|
367
|
+
self._sensor_timer_thread = None
|
|
368
|
+
self._state_timer_thread = None
|
|
369
|
+
self._status0_timer_thread = None
|
|
370
|
+
|
|
371
|
+
def get_workmode_command(self) -> Optional[WorkModeCommand]:
|
|
372
|
+
"""Get the latest WORKMODE command received."""
|
|
373
|
+
return self._workmode_command
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
QILOWATT_VERSION = "1.0.0"
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
class QilowattException(Exception):
|
|
2
|
+
"""Base exception for Qilowatt errors."""
|
|
3
|
+
pass
|
|
4
|
+
|
|
5
|
+
class ConnectionError(QilowattException):
|
|
6
|
+
"""Raised when there is a connection error."""
|
|
7
|
+
pass
|
|
8
|
+
|
|
9
|
+
class AuthenticationError(QilowattException):
|
|
10
|
+
"""Raised when authentication fails."""
|
|
11
|
+
pass
|
|
12
|
+
|
|
13
|
+
class DataValidationError(QilowattException):
|
|
14
|
+
"""Raised when data validation fails."""
|
|
15
|
+
pass
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
from dataclasses import dataclass, field
|
|
2
|
+
from typing import List, Optional
|
|
3
|
+
from datetime import datetime
|
|
4
|
+
import uuid
|
|
5
|
+
|
|
6
|
+
@dataclass
|
|
7
|
+
class PingData:
|
|
8
|
+
Reachable: bool = True
|
|
9
|
+
|
|
10
|
+
@dataclass
|
|
11
|
+
class ESP32Data:
|
|
12
|
+
Temperature: float = 64.4 # Default value
|
|
13
|
+
|
|
14
|
+
@dataclass
|
|
15
|
+
class EnergyData:
|
|
16
|
+
Power: List[float]
|
|
17
|
+
Today: float
|
|
18
|
+
Total: float
|
|
19
|
+
Current: List[float]
|
|
20
|
+
Voltage: List[float]
|
|
21
|
+
Frequency: float
|
|
22
|
+
|
|
23
|
+
@dataclass
|
|
24
|
+
class MetricsData:
|
|
25
|
+
PvPower: List[float]
|
|
26
|
+
PvVoltage: List[float]
|
|
27
|
+
PvCurrent: List[float]
|
|
28
|
+
LoadPower: List[float]
|
|
29
|
+
BatterySOC: int
|
|
30
|
+
LoadCurrent: List[float]
|
|
31
|
+
BatteryPower: List[float]
|
|
32
|
+
BatteryCurrent: List[float]
|
|
33
|
+
BatteryVoltage: List[float]
|
|
34
|
+
GridExportLimit: float
|
|
35
|
+
BatteryTemperature: List[float]
|
|
36
|
+
InverterTemperature: float
|
|
37
|
+
AlarmCodes: List[int] = field(default_factory=lambda: [0, 0, 0, 0, 0, 0])
|
|
38
|
+
InverterStatus: int = 2 # Default value
|
|
39
|
+
|
|
40
|
+
@dataclass
|
|
41
|
+
class VersionData:
|
|
42
|
+
fs: str = "24.7.1"
|
|
43
|
+
led: str = "24.3.1"
|
|
44
|
+
inverter: str = "24.10.2"
|
|
45
|
+
qilowatt: str = "24.8.1"
|
|
46
|
+
registers: float = 2.5
|
|
47
|
+
|
|
48
|
+
@dataclass
|
|
49
|
+
class WorkModeCommand:
|
|
50
|
+
Mode: str = "normal"
|
|
51
|
+
_source: Optional[str] = None
|
|
52
|
+
BatterySoc: Optional[int] = None
|
|
53
|
+
PowerLimit: Optional[int] = None
|
|
54
|
+
PeakShaving: Optional[int] = None
|
|
55
|
+
ChargeCurrent: Optional[int] = None
|
|
56
|
+
DischargeCurrent: Optional[int] = None
|
|
57
|
+
MaxPower: Optional[int] = None
|
|
58
|
+
|
|
59
|
+
@classmethod
|
|
60
|
+
def from_dict(cls, data: dict) -> 'WorkModeCommand':
|
|
61
|
+
return cls(
|
|
62
|
+
Mode=data.get("Mode"),
|
|
63
|
+
_source=data.get("_source"),
|
|
64
|
+
BatterySoc=data.get("BatterySoc"),
|
|
65
|
+
PowerLimit=data.get("PowerLimit"),
|
|
66
|
+
PeakShaving=data.get("PeakShaving"),
|
|
67
|
+
ChargeCurrent=data.get("ChargeCurrent"),
|
|
68
|
+
DischargeCurrent=data.get("DischargeCurrent"),
|
|
69
|
+
MaxPower=data.get("MaxPower"),
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
@dataclass
|
|
73
|
+
class SensorData:
|
|
74
|
+
ENERGY: EnergyData
|
|
75
|
+
METRICS: MetricsData
|
|
76
|
+
PING: PingData = field(default_factory=PingData)
|
|
77
|
+
Time: str = field(default_factory=lambda: datetime.utcnow().isoformat())
|
|
78
|
+
ESP32: ESP32Data = field(default_factory=ESP32Data)
|
|
79
|
+
VERSION: VersionData = field(default_factory=VersionData)
|
|
80
|
+
TempUnit: str = "C"
|
|
81
|
+
POWER1: int = 1
|
|
82
|
+
WORKMODE: Optional[WorkModeCommand] = None # Will be set internally
|
|
83
|
+
|
|
84
|
+
def to_dict(self) -> dict:
|
|
85
|
+
"""Convert the dataclass to a dictionary."""
|
|
86
|
+
data = {
|
|
87
|
+
"PING": self.PING.__dict__,
|
|
88
|
+
"Time": self.Time,
|
|
89
|
+
"ESP32": self.ESP32.__dict__,
|
|
90
|
+
"ENERGY": self.ENERGY.__dict__,
|
|
91
|
+
"METRICS": self.METRICS.__dict__,
|
|
92
|
+
"VERSION": self.VERSION.__dict__,
|
|
93
|
+
"TempUnit": self.TempUnit,
|
|
94
|
+
"POWER1": self.POWER1,
|
|
95
|
+
"WORKMODE": self.WORKMODE.__dict__ if self.WORKMODE else {},
|
|
96
|
+
}
|
|
97
|
+
return data
|
|
98
|
+
|
|
99
|
+
@dataclass
|
|
100
|
+
class StatusData:
|
|
101
|
+
DeviceName: str
|
|
102
|
+
FriendlyName: List[str]
|
|
103
|
+
Topic: str
|
|
104
|
+
|
|
105
|
+
@dataclass
|
|
106
|
+
class StatusPRMData:
|
|
107
|
+
StartupUTC: str
|
|
108
|
+
BootCount: int
|
|
109
|
+
|
|
110
|
+
@dataclass
|
|
111
|
+
class StatusFWRData:
|
|
112
|
+
Version: str
|
|
113
|
+
Hardware: str
|
|
114
|
+
|
|
115
|
+
@dataclass
|
|
116
|
+
class StatusLOGData:
|
|
117
|
+
TelePeriod: int
|
|
118
|
+
|
|
119
|
+
@dataclass
|
|
120
|
+
class StatusNETData:
|
|
121
|
+
Hostname: str
|
|
122
|
+
IPAddress: str
|
|
123
|
+
Gateway: str
|
|
124
|
+
Subnetmask: str
|
|
125
|
+
Mac: str
|
|
126
|
+
DNSServer1: Optional[str] = None
|
|
127
|
+
DNSServer2: Optional[str] = None
|
|
128
|
+
|
|
129
|
+
@dataclass
|
|
130
|
+
class StatusMQTData:
|
|
131
|
+
MqttHost: str
|
|
132
|
+
MqttPort: int
|
|
133
|
+
MqttClient: str
|
|
134
|
+
MqttUser: str
|
|
135
|
+
MqttCount: Optional[int] = None
|
|
136
|
+
MqttClientMask: Optional[str] = None
|
|
137
|
+
|
|
138
|
+
@dataclass
|
|
139
|
+
class StatusTIMData:
|
|
140
|
+
UTC: str
|
|
141
|
+
Local: str
|
|
142
|
+
StartDST: Optional[str] = None
|
|
143
|
+
EndDST: Optional[str] = None
|
|
144
|
+
Timezone: Optional[int] = None
|
|
145
|
+
|
|
146
|
+
@dataclass
|
|
147
|
+
class Status0Data:
|
|
148
|
+
Status: StatusData
|
|
149
|
+
StatusPRM: StatusPRMData
|
|
150
|
+
StatusFWR: StatusFWRData
|
|
151
|
+
StatusLOG: StatusLOGData
|
|
152
|
+
StatusNET: StatusNETData
|
|
153
|
+
StatusMQT: StatusMQTData
|
|
154
|
+
StatusTIM: StatusTIMData
|
|
155
|
+
|
|
156
|
+
def to_dict(self) -> dict:
|
|
157
|
+
return {
|
|
158
|
+
"Status": self.Status.__dict__,
|
|
159
|
+
"StatusPRM": self.StatusPRM.__dict__,
|
|
160
|
+
"StatusFWR": self.StatusFWR.__dict__,
|
|
161
|
+
"StatusLOG": self.StatusLOG.__dict__,
|
|
162
|
+
"StatusNET": self.StatusNET.__dict__,
|
|
163
|
+
"StatusMQT": self.StatusMQT.__dict__,
|
|
164
|
+
"StatusTIM": self.StatusTIM.__dict__,
|
|
165
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
Metadata-Version: 2.2
|
|
2
|
+
Name: qilowatt
|
|
3
|
+
Version: 2024.2.1
|
|
4
|
+
Summary: Communication package for Qilowatt inverters
|
|
5
|
+
Author-email: Tanel Vakker <tanel@vakker.org>
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/qilowatt/qilowatt-py
|
|
8
|
+
Project-URL: Documentation, https://github.com/qilowatt/qilowatt-py#readme
|
|
9
|
+
Project-URL: Issues, https://github.com/qilowatt/qilowatt-py/issues
|
|
10
|
+
Project-URL: Repository, https://github.com/qilowatt/qilowatt-py
|
|
11
|
+
Keywords: mqtt,qilowatt,inverter,energy,home-assistant
|
|
12
|
+
Classifier: Development Status :: 4 - Beta
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: Topic :: Home Automation
|
|
15
|
+
Classifier: Topic :: Software Development :: Libraries
|
|
16
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
17
|
+
Classifier: Programming Language :: Python :: 3
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.7
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.8
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
22
|
+
Classifier: Operating System :: OS Independent
|
|
23
|
+
Requires-Python: >=3.7
|
|
24
|
+
Description-Content-Type: text/markdown
|
|
25
|
+
License-File: LICENSE.txt
|
|
26
|
+
Requires-Dist: paho-mqtt>=1.6.1
|
|
27
|
+
Requires-Dist: getmac>=0.8.3
|
|
28
|
+
|
|
29
|
+
# [qilowatt](<https://qilowatt.eu>) Python Module
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
- connecting to qilowatt MQTT server to send status, state and state0.
|
|
33
|
+
- Receives BACKLOG commands
|
|
34
|
+
- see example.py for usage
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
.gitignore
|
|
2
|
+
LICENSE.txt
|
|
3
|
+
README.md
|
|
4
|
+
example.py
|
|
5
|
+
pyproject.toml
|
|
6
|
+
.github/workflows/publish.yml
|
|
7
|
+
src/qilowatt/__init__.py
|
|
8
|
+
src/qilowatt/client.py
|
|
9
|
+
src/qilowatt/constants.py
|
|
10
|
+
src/qilowatt/exceptions.py
|
|
11
|
+
src/qilowatt/models.py
|
|
12
|
+
src/qilowatt.egg-info/PKG-INFO
|
|
13
|
+
src/qilowatt.egg-info/SOURCES.txt
|
|
14
|
+
src/qilowatt.egg-info/dependency_links.txt
|
|
15
|
+
src/qilowatt.egg-info/requires.txt
|
|
16
|
+
src/qilowatt.egg-info/top_level.txt
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
qilowatt
|