gazpar2haws 0.1.0__py3-none-any.whl
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.
- CHANGELOG.md +9 -0
- gazpar2haws/__init__.py +1 -0
- gazpar2haws/__main__.py +91 -0
- gazpar2haws/bridge.py +92 -0
- gazpar2haws/config_utils.py +56 -0
- gazpar2haws/gazpar.py +114 -0
- gazpar2haws/haws.py +191 -0
- gazpar2haws/version.py +3 -0
- gazpar2haws-0.1.0.dist-info/LICENSE +21 -0
- gazpar2haws-0.1.0.dist-info/METADATA +122 -0
- gazpar2haws-0.1.0.dist-info/RECORD +12 -0
- gazpar2haws-0.1.0.dist-info/WHEEL +4 -0
CHANGELOG.md
ADDED
@@ -0,0 +1,9 @@
|
|
1
|
+
# Changelog
|
2
|
+
All notable changes to this project will be documented in this file.
|
3
|
+
|
4
|
+
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
5
|
+
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
6
|
+
|
7
|
+
## [0.1.0] - 2024-12-08
|
8
|
+
|
9
|
+
First version of the project.
|
gazpar2haws/__init__.py
ADDED
@@ -0,0 +1 @@
|
|
1
|
+
from gazpar2haws.version import __version__ # noqa: F401
|
gazpar2haws/__main__.py
ADDED
@@ -0,0 +1,91 @@
|
|
1
|
+
import asyncio
|
2
|
+
import argparse
|
3
|
+
import logging
|
4
|
+
import traceback
|
5
|
+
from gazpar2haws import config_utils
|
6
|
+
from gazpar2haws import __version__
|
7
|
+
from gazpar2haws.bridge import Bridge
|
8
|
+
|
9
|
+
Logger = logging.getLogger(__name__)
|
10
|
+
|
11
|
+
|
12
|
+
# ----------------------------------
|
13
|
+
async def main():
|
14
|
+
"""Main function"""
|
15
|
+
parser = argparse.ArgumentParser(prog="gazpar2haws", description="Gateway that reads data history from the GrDF (French gas provider) meter and send it to Home Assistant using WebSocket interface.")
|
16
|
+
parser.add_argument("-v", "--version",
|
17
|
+
action="version",
|
18
|
+
version="Gazpar2HAWS version")
|
19
|
+
parser.add_argument("-c", "--config",
|
20
|
+
required=False,
|
21
|
+
default="config/configuration.yaml",
|
22
|
+
help="Path to the configuration file")
|
23
|
+
parser.add_argument("-s", "--secrets",
|
24
|
+
required=False,
|
25
|
+
default="config/secrets.yaml",
|
26
|
+
help="Path to the secret file")
|
27
|
+
|
28
|
+
args = parser.parse_args()
|
29
|
+
|
30
|
+
try:
|
31
|
+
# Load configuration files
|
32
|
+
config = config_utils.ConfigLoader(args.config, args.secrets)
|
33
|
+
config.load_secrets()
|
34
|
+
config.load_config()
|
35
|
+
|
36
|
+
print(f"Gazpar2HAWS version: {__version__}")
|
37
|
+
|
38
|
+
# Set up logging
|
39
|
+
logging_file = config.get("logging.file")
|
40
|
+
logging_console = bool(config.get("logging.console"))
|
41
|
+
logging_level = config.get("logging.level")
|
42
|
+
logging_format = config.get("logging.format")
|
43
|
+
|
44
|
+
# Convert logging level to integer
|
45
|
+
if logging_level.upper() == "DEBUG":
|
46
|
+
level = logging.DEBUG
|
47
|
+
elif logging_level.upper() == "INFO":
|
48
|
+
level = logging.INFO
|
49
|
+
elif logging_level.upper() == "WARNING":
|
50
|
+
level = logging.WARNING
|
51
|
+
elif logging_level.upper() == "ERROR":
|
52
|
+
level = logging.ERROR
|
53
|
+
elif logging_level.upper() == "CRITICAL":
|
54
|
+
level = logging.CRITICAL
|
55
|
+
else:
|
56
|
+
level = logging.INFO
|
57
|
+
|
58
|
+
logging.basicConfig(filename=logging_file, level=level, format=logging_format)
|
59
|
+
|
60
|
+
if logging_console:
|
61
|
+
# Add a console handler manually
|
62
|
+
console_handler = logging.StreamHandler()
|
63
|
+
console_handler.setLevel(level) # Set logging level for the console
|
64
|
+
console_handler.setFormatter(logging.Formatter(logging_format)) # Customize console format
|
65
|
+
|
66
|
+
# Get the root logger and add the console handler
|
67
|
+
logging.getLogger().addHandler(console_handler)
|
68
|
+
|
69
|
+
Logger.info(f"Starting Gazpar2HAWS version {__version__}")
|
70
|
+
|
71
|
+
# Log configuration
|
72
|
+
Logger.info(f"Configuration:\n{config.dumps()}")
|
73
|
+
|
74
|
+
# Start the bridge
|
75
|
+
bridge = Bridge(config)
|
76
|
+
await bridge.run()
|
77
|
+
|
78
|
+
Logger.info("Gazpar2HAWS stopped.")
|
79
|
+
|
80
|
+
return 0
|
81
|
+
|
82
|
+
except BaseException:
|
83
|
+
errorMessage = f"An error occured while running Gazpar2HAWS: {traceback.format_exc()}"
|
84
|
+
Logger.error(errorMessage)
|
85
|
+
print(errorMessage)
|
86
|
+
return 1
|
87
|
+
|
88
|
+
|
89
|
+
# ----------------------------------
|
90
|
+
if __name__ == '__main__':
|
91
|
+
asyncio.run(main())
|
gazpar2haws/bridge.py
ADDED
@@ -0,0 +1,92 @@
|
|
1
|
+
import logging
|
2
|
+
import signal
|
3
|
+
import asyncio
|
4
|
+
from gazpar2haws import config_utils
|
5
|
+
from gazpar2haws.gazpar import Gazpar
|
6
|
+
from gazpar2haws.haws import HomeAssistantWS
|
7
|
+
|
8
|
+
Logger = logging.getLogger(__name__)
|
9
|
+
|
10
|
+
|
11
|
+
# ----------------------------------
|
12
|
+
class Bridge:
|
13
|
+
|
14
|
+
# ----------------------------------
|
15
|
+
def __init__(self, config: config_utils.ConfigLoader):
|
16
|
+
|
17
|
+
# GrDF scan interval (in seconds)
|
18
|
+
self._grdf_scan_interval = int(config.get("grdf.scan_interval"))
|
19
|
+
|
20
|
+
# Home Assistant configuration
|
21
|
+
ha_host = config.get("homeassistant.host")
|
22
|
+
ha_port = config.get("homeassistant.port")
|
23
|
+
ha_token = config.get("homeassistant.token")
|
24
|
+
|
25
|
+
# Initialize Home Assistant
|
26
|
+
self._homeassistant = HomeAssistantWS(ha_host, ha_port, ha_token)
|
27
|
+
|
28
|
+
# Initialize Gazpar
|
29
|
+
self._gazpar = []
|
30
|
+
for grdf_device_config in config.get("grdf.devices"):
|
31
|
+
self._gazpar.append(Gazpar(grdf_device_config, self._homeassistant))
|
32
|
+
|
33
|
+
# Set up signal handler
|
34
|
+
signal.signal(signal.SIGINT, self.handle_signal)
|
35
|
+
signal.signal(signal.SIGTERM, self.handle_signal)
|
36
|
+
|
37
|
+
# Initialize running flag
|
38
|
+
self._running = False
|
39
|
+
|
40
|
+
# ----------------------------------
|
41
|
+
# Graceful shutdown function
|
42
|
+
def handle_signal(self, signum, frame):
|
43
|
+
print(f"Signal {signum} received. Shutting down gracefully...")
|
44
|
+
Logger.info(f"Signal {signum} received. Shutting down gracefully...")
|
45
|
+
self._running = False
|
46
|
+
|
47
|
+
# ----------------------------------
|
48
|
+
async def run(self):
|
49
|
+
|
50
|
+
# Set running flag
|
51
|
+
self._running = True
|
52
|
+
|
53
|
+
try:
|
54
|
+
while self._running:
|
55
|
+
|
56
|
+
# Connect to Home Assistant
|
57
|
+
await self._homeassistant.connect()
|
58
|
+
|
59
|
+
# Publish Gazpar data to Home Assistant WS
|
60
|
+
Logger.info("Publishing Gazpar data to Home Assistant WS...")
|
61
|
+
|
62
|
+
for gazpar in self._gazpar:
|
63
|
+
Logger.info(f"Publishing data for device '{gazpar.name()}'...")
|
64
|
+
await gazpar.publish()
|
65
|
+
Logger.info(f"Device '{gazpar.name()}' data published to Home Assistant WS.")
|
66
|
+
|
67
|
+
Logger.info("Gazpar data published to Home Assistant WS.")
|
68
|
+
|
69
|
+
# Disconnect from Home Assistant
|
70
|
+
await self._homeassistant.disconnect()
|
71
|
+
|
72
|
+
# Wait before next scan
|
73
|
+
Logger.info(f"Waiting {self._grdf_scan_interval} minutes before next scan...")
|
74
|
+
|
75
|
+
# Check if the scan interval is 0 and leave the loop.
|
76
|
+
if self._grdf_scan_interval == 0:
|
77
|
+
break
|
78
|
+
|
79
|
+
await self._await_with_interrupt(self._grdf_scan_interval * 60, 5)
|
80
|
+
except KeyboardInterrupt:
|
81
|
+
print("Keyboard interrupt detected. Shutting down gracefully...")
|
82
|
+
Logger.info("Keyboard interrupt detected. Shutting down gracefully...")
|
83
|
+
|
84
|
+
# ----------------------------------
|
85
|
+
async def _await_with_interrupt(self, total_sleep_time: int, check_interval: int):
|
86
|
+
elapsed_time = 0
|
87
|
+
while elapsed_time < total_sleep_time:
|
88
|
+
await asyncio.sleep(check_interval)
|
89
|
+
elapsed_time += check_interval
|
90
|
+
# Check if an interrupt signal or external event requires breaking
|
91
|
+
if not self._running: # Assuming `running` is a global flag
|
92
|
+
break
|
@@ -0,0 +1,56 @@
|
|
1
|
+
import yaml
|
2
|
+
import os
|
3
|
+
|
4
|
+
|
5
|
+
class ConfigLoader:
|
6
|
+
def __init__(self, config_file="config.yaml", secrets_file="secrets.yaml"):
|
7
|
+
self.config_file = config_file
|
8
|
+
self.secrets_file = secrets_file
|
9
|
+
self.config = {}
|
10
|
+
self.secrets = {}
|
11
|
+
|
12
|
+
def load_secrets(self):
|
13
|
+
"""Load the secrets file."""
|
14
|
+
if os.path.exists(self.secrets_file):
|
15
|
+
with open(self.secrets_file, 'r') as file:
|
16
|
+
self.secrets = yaml.safe_load(file)
|
17
|
+
else:
|
18
|
+
raise FileNotFoundError(f"Secrets file '{self.secrets_file}' not found.")
|
19
|
+
|
20
|
+
def load_config(self):
|
21
|
+
"""Load the main configuration file and resolve secrets."""
|
22
|
+
if os.path.exists(self.config_file):
|
23
|
+
with open(self.config_file, 'r', encoding='utf-8') as file:
|
24
|
+
self.raw_config = yaml.safe_load(file)
|
25
|
+
self.config = self._resolve_secrets(self.raw_config)
|
26
|
+
else:
|
27
|
+
raise FileNotFoundError(f"Configuration file '{self.config_file}' not found.")
|
28
|
+
|
29
|
+
def _resolve_secrets(self, data):
|
30
|
+
"""Recursively resolve `!secret` keys in the configuration."""
|
31
|
+
if isinstance(data, dict):
|
32
|
+
return {key: self._resolve_secrets(value) for key, value in data.items()}
|
33
|
+
elif isinstance(data, list):
|
34
|
+
return [self._resolve_secrets(item) for item in data]
|
35
|
+
elif isinstance(data, str) and data.startswith("!secret"):
|
36
|
+
secret_key = data.split(" ", 1)[1]
|
37
|
+
if secret_key in self.secrets:
|
38
|
+
return self.secrets[secret_key]
|
39
|
+
else:
|
40
|
+
raise KeyError(f"Secret key '{secret_key}' not found in secrets file.")
|
41
|
+
else:
|
42
|
+
return data
|
43
|
+
|
44
|
+
def get(self, key, default=None):
|
45
|
+
"""Get a configuration value."""
|
46
|
+
keys = key.split(".")
|
47
|
+
value = self.config
|
48
|
+
try:
|
49
|
+
for k in keys:
|
50
|
+
value = value[k]
|
51
|
+
return value
|
52
|
+
except (KeyError, TypeError):
|
53
|
+
return default
|
54
|
+
|
55
|
+
def dumps(self) -> str:
|
56
|
+
return yaml.dump(self.raw_config)
|
gazpar2haws/gazpar.py
ADDED
@@ -0,0 +1,114 @@
|
|
1
|
+
import pygazpar
|
2
|
+
import traceback
|
3
|
+
import logging
|
4
|
+
import pytz
|
5
|
+
from typing import Any
|
6
|
+
from datetime import datetime, timedelta
|
7
|
+
from gazpar2haws.haws import HomeAssistantWS
|
8
|
+
|
9
|
+
Logger = logging.getLogger(__name__)
|
10
|
+
|
11
|
+
|
12
|
+
# ----------------------------------
|
13
|
+
class Gazpar:
|
14
|
+
|
15
|
+
# ----------------------------------
|
16
|
+
def __init__(self, config: dict[str, Any], homeassistant: HomeAssistantWS):
|
17
|
+
|
18
|
+
self._homeassistant = homeassistant
|
19
|
+
|
20
|
+
# GrDF configuration
|
21
|
+
self._name = config.get("name")
|
22
|
+
self._username = config.get("username")
|
23
|
+
self._password = config.get("password")
|
24
|
+
self._pce_identifier = str(config.get("pce_identifier"))
|
25
|
+
self._last_days = int(config.get("last_days"))
|
26
|
+
self._timezone = config.get("timezone")
|
27
|
+
self._reset = bool(config.get("reset"))
|
28
|
+
|
29
|
+
# ----------------------------------
|
30
|
+
def name(self):
|
31
|
+
return self._name
|
32
|
+
|
33
|
+
# ----------------------------------
|
34
|
+
# Publish Gaspar data to Home Assistant WS
|
35
|
+
async def publish(self):
|
36
|
+
|
37
|
+
# Eventually reset the sensor in Home Assistant
|
38
|
+
if self._reset:
|
39
|
+
try:
|
40
|
+
await self._homeassistant.clear_statistics([f"sensor.{self._name}"])
|
41
|
+
except Exception:
|
42
|
+
errorMessage = f"Error while resetting the sensor in Home Assistant: {traceback.format_exc()}"
|
43
|
+
Logger.warning(errorMessage)
|
44
|
+
raise Exception(errorMessage)
|
45
|
+
|
46
|
+
# Check the existence of the sensor in Home Assistant
|
47
|
+
try:
|
48
|
+
exists_statistic_id = await self._homeassistant.exists_statistic_id(f"sensor.{self._name}", "sum")
|
49
|
+
except Exception:
|
50
|
+
errorMessage = f"Error while checking the existence of the sensor in Home Assistant: {traceback.format_exc()}"
|
51
|
+
Logger.warning(errorMessage)
|
52
|
+
raise Exception(errorMessage)
|
53
|
+
|
54
|
+
if exists_statistic_id:
|
55
|
+
# Get last statistics from GrDF
|
56
|
+
try:
|
57
|
+
last_statistics = await self._homeassistant.get_last_statistic(f"sensor.{self._name}")
|
58
|
+
except Exception:
|
59
|
+
errorMessage = f"Error while fetching last statistics from Home Assistant: {traceback.format_exc()}"
|
60
|
+
Logger.warning(errorMessage)
|
61
|
+
raise Exception(errorMessage)
|
62
|
+
|
63
|
+
# Extract the end date of the last statistics from the unix timestamp
|
64
|
+
last_date = datetime.fromtimestamp(last_statistics.get("end") / 1000)
|
65
|
+
|
66
|
+
# Compute the number of days since the last statistics
|
67
|
+
last_days = (datetime.now() - last_date).days
|
68
|
+
else:
|
69
|
+
# If the sensor does not exist in Home Assistant, fetch the last days defined in the configuration
|
70
|
+
last_days = self._last_days
|
71
|
+
|
72
|
+
# Compute the corresponding last_date
|
73
|
+
last_date = datetime.now() - timedelta(days=last_days)
|
74
|
+
|
75
|
+
# Initialize PyGazpar client
|
76
|
+
client = pygazpar.Client(pygazpar.JsonWebDataSource(username=self._username, password=self._password))
|
77
|
+
|
78
|
+
try:
|
79
|
+
data = client.loadSince(pceIdentifier=self._pce_identifier, lastNDays=last_days, frequencies=[pygazpar.Frequency.DAILY])
|
80
|
+
except Exception:
|
81
|
+
errorMessage = f"Error while fetching data from GrDF: {traceback.format_exc()}"
|
82
|
+
Logger.warning(errorMessage)
|
83
|
+
data = {}
|
84
|
+
|
85
|
+
# Timezone
|
86
|
+
timezone = pytz.timezone(self._timezone)
|
87
|
+
|
88
|
+
# Fill statistics.
|
89
|
+
daily = data.get(pygazpar.Frequency.DAILY.value)
|
90
|
+
statistics = []
|
91
|
+
for reading in daily:
|
92
|
+
# Parse date format DD/MM/YYYY into datetime.
|
93
|
+
date = datetime.strptime(reading[pygazpar.PropertyName.TIME_PERIOD.value], "%d/%m/%Y")
|
94
|
+
|
95
|
+
# Skip all readings before the last statistic date.
|
96
|
+
if date.date() < last_date.date():
|
97
|
+
continue
|
98
|
+
|
99
|
+
# Set the timezone
|
100
|
+
date = timezone.localize(date)
|
101
|
+
|
102
|
+
statistics.append({
|
103
|
+
"start": date.isoformat(),
|
104
|
+
"state": reading[pygazpar.PropertyName.END_INDEX.value],
|
105
|
+
"sum": reading[pygazpar.PropertyName.END_INDEX.value]
|
106
|
+
})
|
107
|
+
|
108
|
+
# Publish statistics to Home Assistant
|
109
|
+
try:
|
110
|
+
await self._homeassistant.import_statistics(f"sensor.{self._name}", "recorder", "gazpar2haws", "m³", statistics)
|
111
|
+
except Exception:
|
112
|
+
errorMessage = f"Error while importing statistics to Home Assistant: {traceback.format_exc()}"
|
113
|
+
Logger.warning(errorMessage)
|
114
|
+
raise Exception(errorMessage)
|
gazpar2haws/haws.py
ADDED
@@ -0,0 +1,191 @@
|
|
1
|
+
import websockets
|
2
|
+
import json
|
3
|
+
from datetime import datetime, timedelta
|
4
|
+
import logging
|
5
|
+
|
6
|
+
Logger = logging.getLogger(__name__)
|
7
|
+
|
8
|
+
|
9
|
+
# ----------------------------------
|
10
|
+
class HomeAssistantWS:
|
11
|
+
def __init__(self, host, port, token):
|
12
|
+
self._host = host
|
13
|
+
self._port = port
|
14
|
+
self._token = token
|
15
|
+
self._websocket = None
|
16
|
+
self._message_id = 1
|
17
|
+
|
18
|
+
# ----------------------------------
|
19
|
+
async def connect(self):
|
20
|
+
|
21
|
+
Logger.debug(f"Connecting to Home Assistant at {self._host}:{self._port}")
|
22
|
+
|
23
|
+
ws_url = f"ws://{self._host}:{self._port}/api/websocket"
|
24
|
+
|
25
|
+
# Connect to the websocket
|
26
|
+
self._websocket = await websockets.connect(ws_url)
|
27
|
+
|
28
|
+
# When a client connects to the server, the server sends out auth_required.
|
29
|
+
connect_response = await self._websocket.recv()
|
30
|
+
connect_response_data = json.loads(connect_response)
|
31
|
+
|
32
|
+
if connect_response_data.get("type") != "auth_required":
|
33
|
+
message = f"Authentication failed: auth_required not received {connect_response_data.get('messsage')}"
|
34
|
+
Logger.warning(message)
|
35
|
+
raise Exception(message)
|
36
|
+
|
37
|
+
# The first message from the client should be an auth message. You can authorize with an access token.
|
38
|
+
auth_message = {"type": "auth", "access_token": self._token}
|
39
|
+
await self._websocket.send(json.dumps(auth_message))
|
40
|
+
|
41
|
+
# If the client supplies valid authentication, the authentication phase will complete by the server sending the auth_ok message.
|
42
|
+
auth_response = await self._websocket.recv()
|
43
|
+
auth_response_data = json.loads(auth_response)
|
44
|
+
|
45
|
+
if auth_response_data.get("type") == "auth_invalid":
|
46
|
+
message = f"Authentication failed: {auth_response_data.get('messsage')}"
|
47
|
+
Logger.warning(message)
|
48
|
+
raise Exception(message)
|
49
|
+
|
50
|
+
Logger.debug("Connected to Home Assistant")
|
51
|
+
|
52
|
+
# ----------------------------------
|
53
|
+
async def disconnect(self):
|
54
|
+
|
55
|
+
Logger.debug("Disconnecting from Home Assistant...")
|
56
|
+
|
57
|
+
await self._websocket.close()
|
58
|
+
|
59
|
+
Logger.debug("Disconnected from Home Assistant")
|
60
|
+
|
61
|
+
# ----------------------------------
|
62
|
+
async def send_message(self, message: dict) -> dict:
|
63
|
+
|
64
|
+
Logger.debug("Sending a message...")
|
65
|
+
|
66
|
+
message["id"] = self._message_id
|
67
|
+
|
68
|
+
self._message_id += 1
|
69
|
+
|
70
|
+
await self._websocket.send(json.dumps(message))
|
71
|
+
|
72
|
+
response = await self._websocket.recv()
|
73
|
+
|
74
|
+
response_data = json.loads(response)
|
75
|
+
|
76
|
+
Logger.debug(f"Received response: {response_data}")
|
77
|
+
|
78
|
+
if response_data.get("type") != "result":
|
79
|
+
raise Exception(f"Invalid response message: {response_data}")
|
80
|
+
|
81
|
+
if not response_data.get("success"):
|
82
|
+
raise Exception(f"Request failed: {response_data.get('error')}")
|
83
|
+
|
84
|
+
return response_data.get("result")
|
85
|
+
|
86
|
+
# ----------------------------------
|
87
|
+
async def list_statistic_ids(self, statistic_type: str = None) -> list[str]:
|
88
|
+
|
89
|
+
Logger.debug("Listing statistics IDs...")
|
90
|
+
|
91
|
+
# List statistics IDs message
|
92
|
+
list_statistic_ids_message = {
|
93
|
+
"type": "recorder/list_statistic_ids"
|
94
|
+
}
|
95
|
+
|
96
|
+
if statistic_type is not None:
|
97
|
+
list_statistic_ids_message["statistic_type"] = statistic_type
|
98
|
+
|
99
|
+
response = await self.send_message(list_statistic_ids_message)
|
100
|
+
|
101
|
+
Logger.debug(f"Listed statistics IDs: {len(response)} ids")
|
102
|
+
|
103
|
+
return response
|
104
|
+
|
105
|
+
# ----------------------------------
|
106
|
+
async def exists_statistic_id(self, entity_id: str, statistic_type: str = None) -> bool:
|
107
|
+
|
108
|
+
Logger.debug(f"Checking if {entity_id} exists...")
|
109
|
+
|
110
|
+
statistic_ids = await self.list_statistic_ids(statistic_type)
|
111
|
+
|
112
|
+
entity_ids = [statistic_id.get("statistic_id") for statistic_id in statistic_ids]
|
113
|
+
|
114
|
+
exists_statistic = entity_id in entity_ids
|
115
|
+
|
116
|
+
Logger.debug(f"{entity_id} exists: {exists_statistic}")
|
117
|
+
|
118
|
+
return exists_statistic
|
119
|
+
|
120
|
+
# ----------------------------------
|
121
|
+
async def statistics_during_period(self, entity_ids: list[str], start_time: datetime, end_time: datetime) -> dict:
|
122
|
+
|
123
|
+
Logger.debug(f"Getting {entity_ids} statistics during period from {start_time} to {end_time}...")
|
124
|
+
|
125
|
+
# Subscribe to statistics
|
126
|
+
statistics_message = {
|
127
|
+
"type": "recorder/statistics_during_period",
|
128
|
+
"start_time": start_time.isoformat(),
|
129
|
+
"end_time": end_time.isoformat(),
|
130
|
+
"statistic_ids": entity_ids,
|
131
|
+
"period": "day"
|
132
|
+
}
|
133
|
+
|
134
|
+
response = await self.send_message(statistics_message)
|
135
|
+
|
136
|
+
Logger.debug(f"Received {entity_ids} statistics during period from {start_time} to {end_time}")
|
137
|
+
|
138
|
+
return response
|
139
|
+
|
140
|
+
# ----------------------------------
|
141
|
+
async def get_last_statistic(self, entity_id: str) -> dict:
|
142
|
+
|
143
|
+
Logger.debug(f"Getting last statistic for {entity_id}...")
|
144
|
+
|
145
|
+
statistics = await self.statistics_during_period([entity_id], datetime.now() - timedelta(days=30), datetime.now())
|
146
|
+
|
147
|
+
Logger.debug(f"Last statistic for {entity_id}: {statistics[entity_id][-1]}")
|
148
|
+
|
149
|
+
return statistics[entity_id][-1]
|
150
|
+
|
151
|
+
# ----------------------------------
|
152
|
+
async def import_statistics(self, entity_id: str, source: str, name: str, unit_of_measurement: str, statistics: list[dict]):
|
153
|
+
|
154
|
+
Logger.debug(f"Importing {len(statistics)} statistics for {entity_id} from {source}...")
|
155
|
+
|
156
|
+
if len(statistics) == 0:
|
157
|
+
Logger.debug("No statistics to import")
|
158
|
+
return
|
159
|
+
|
160
|
+
# Import statistics message
|
161
|
+
import_statistics_message = {
|
162
|
+
"type": "recorder/import_statistics",
|
163
|
+
"metadata": {
|
164
|
+
"has_mean": False,
|
165
|
+
"has_sum": True,
|
166
|
+
"statistic_id": entity_id,
|
167
|
+
"source": source,
|
168
|
+
"name": name,
|
169
|
+
"unit_of_measurement": unit_of_measurement,
|
170
|
+
},
|
171
|
+
"stats": statistics
|
172
|
+
}
|
173
|
+
|
174
|
+
await self.send_message(import_statistics_message)
|
175
|
+
|
176
|
+
Logger.debug(f"Imported {len(statistics)} statistics for {entity_id} from {source}")
|
177
|
+
|
178
|
+
# ----------------------------------
|
179
|
+
async def clear_statistics(self, entity_ids: list[str]):
|
180
|
+
|
181
|
+
Logger.debug(f"Clearing {entity_ids} statistics...")
|
182
|
+
|
183
|
+
# Clear statistics message
|
184
|
+
clear_statistics_message = {
|
185
|
+
"type": "recorder/clear_statistics",
|
186
|
+
"statistic_ids": entity_ids
|
187
|
+
}
|
188
|
+
|
189
|
+
await self.send_message(clear_statistics_message)
|
190
|
+
|
191
|
+
Logger.debug(f"Cleared {entity_ids} statistics")
|
gazpar2haws/version.py
ADDED
@@ -0,0 +1,21 @@
|
|
1
|
+
MIT License
|
2
|
+
|
3
|
+
Copyright (c) 2024 Stéphane Senart
|
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,122 @@
|
|
1
|
+
Metadata-Version: 2.1
|
2
|
+
Name: gazpar2haws
|
3
|
+
Version: 0.1.0
|
4
|
+
Summary: Gazpar2HAWS is a gateway that reads data history from the GrDF (French gas provider) meter and send it to Home Assistant using WebSocket interface
|
5
|
+
License: MIT
|
6
|
+
Author: Stéphane Senart
|
7
|
+
Requires-Python: >=3.12,<4.0
|
8
|
+
Classifier: License :: OSI Approved :: MIT License
|
9
|
+
Classifier: Programming Language :: Python :: 3
|
10
|
+
Classifier: Programming Language :: Python :: 3.12
|
11
|
+
Classifier: Programming Language :: Python :: 3.13
|
12
|
+
Requires-Dist: pygazpar (==1.2.5)
|
13
|
+
Requires-Dist: pytest-asyncio (>=0.25.0,<0.26.0)
|
14
|
+
Requires-Dist: pyyaml (>=6.0.2,<7.0.0)
|
15
|
+
Requires-Dist: websockets (>=14.1,<15.0)
|
16
|
+
Description-Content-Type: text/markdown
|
17
|
+
|
18
|
+
# gazpar2haws
|
19
|
+
Gazpar2HAWS is a gateway that reads data history from the GrDF (French gas provider) meter and send it to Home Assistant using WebSocket interface.
|
20
|
+
|
21
|
+
It is compatible with Home Assistant Energy Dashboard and permits to upload the history and keep it updated with the latest readings.
|
22
|
+
|
23
|
+
## Installation
|
24
|
+
|
25
|
+
Gazpar2HAWS can be installed on any host as a standalone program.
|
26
|
+
|
27
|
+
### 1. Using source files
|
28
|
+
|
29
|
+
The project requires [Poetry](https://python-poetry.org/) tool for dependency and package management.
|
30
|
+
|
31
|
+
```sh
|
32
|
+
$ cd /path/to/my_install_folder/
|
33
|
+
|
34
|
+
$ git clone https://github.com/ssenart/gazpar2haws.git
|
35
|
+
|
36
|
+
$ cd gazpar2haws
|
37
|
+
|
38
|
+
$ poetry install
|
39
|
+
|
40
|
+
$ poetry shell
|
41
|
+
|
42
|
+
```
|
43
|
+
|
44
|
+
### 2. Using PIP package
|
45
|
+
|
46
|
+
```sh
|
47
|
+
$ cd /path/to/my_install_folder/
|
48
|
+
|
49
|
+
$ mkdir gazpar2haws
|
50
|
+
|
51
|
+
$ cd gazpar2haws
|
52
|
+
|
53
|
+
$ python -m venv .venv
|
54
|
+
|
55
|
+
$ source .venv/bin/activate
|
56
|
+
|
57
|
+
$ pip install gazpar2haws
|
58
|
+
|
59
|
+
```
|
60
|
+
|
61
|
+
## Usage
|
62
|
+
|
63
|
+
### Command line
|
64
|
+
|
65
|
+
```sh
|
66
|
+
$ python -m gazpar2haws --config /path/to/configuration.yaml --secrets /path/to/secrets.yaml
|
67
|
+
```
|
68
|
+
|
69
|
+
### Configuration file
|
70
|
+
|
71
|
+
The default configuration file is below.
|
72
|
+
|
73
|
+
```yaml
|
74
|
+
logging:
|
75
|
+
file: log/gazpar2haws.log
|
76
|
+
console: true
|
77
|
+
level: debug
|
78
|
+
format: '%(asctime)s %(levelname)s [%(name)s] %(message)s'
|
79
|
+
|
80
|
+
grdf:
|
81
|
+
scan_interval: 0 # Number of minutes between each data retrieval (0 means no scan: a single data retrieval at startup, then stops).
|
82
|
+
devices:
|
83
|
+
- name: gazpar2haws
|
84
|
+
username: "!secret grdf.username"
|
85
|
+
password: "!secret grdf.password"
|
86
|
+
pce_identifier: "!secret grdf.pce_identifier"
|
87
|
+
timezone: Europe/Paris
|
88
|
+
last_days: 365 # Number of days of data to retrieve
|
89
|
+
reset: false # If true, the data will be reset before the first data retrieval
|
90
|
+
|
91
|
+
homeassistant:
|
92
|
+
host: "!secret homeassistant.host"
|
93
|
+
port: "!secret homeassistant.port"
|
94
|
+
token: "!secret homeassistant.token"
|
95
|
+
```
|
96
|
+
|
97
|
+
The default secret file:
|
98
|
+
|
99
|
+
```yaml
|
100
|
+
grdf.username: ${GRDF_USERNAME}
|
101
|
+
grdf.password: ${GRDF_PASSWORD}
|
102
|
+
grdf.pce_identifier: ${GRDF_PCE_IDENTIFIER}
|
103
|
+
|
104
|
+
homeassistant.host: ${HA_HOST}
|
105
|
+
homeassistant.port: ${HA_PORT}
|
106
|
+
homeassistant.token: ${HA_TOKEN}
|
107
|
+
```
|
108
|
+
|
109
|
+
## Contributing
|
110
|
+
Pull requests are welcome. For major changes, please open an issue first to discuss what you would like to change.
|
111
|
+
|
112
|
+
Please make sure to update tests as appropriate.
|
113
|
+
|
114
|
+
## License
|
115
|
+
[MIT](https://choosealicense.com/licenses/mit/)
|
116
|
+
|
117
|
+
## Project status
|
118
|
+
Gazpar2HAWS has been initiated for integration with [Home Assistant](https://www.home-assistant.io/) energy dashboard.
|
119
|
+
|
120
|
+
|
121
|
+
|
122
|
+
|
@@ -0,0 +1,12 @@
|
|
1
|
+
CHANGELOG.md,sha256=8JDG8V_DLBTwnVlZErwlK9PNI-xiwpU6vaqSKavYvKw,316
|
2
|
+
gazpar2haws/__init__.py,sha256=yzol8uZSBI7pIRGUmYJ6-vRBwkM4MI3IGf5cQpNsaFw,57
|
3
|
+
gazpar2haws/__main__.py,sha256=g8xk0x_kprBHKHLzgf9y9EY2_gKC9V3k4z0n-EDTd-I,3253
|
4
|
+
gazpar2haws/bridge.py,sha256=TiPmvRBxzgwOKKmWNXMOaP0pLg-Wls_SmiwHczNEM_4,3466
|
5
|
+
gazpar2haws/config_utils.py,sha256=D0lu-3KY-vLEyD2vDN05UABkMnpMJjqw1RuDJVrkGFs,2123
|
6
|
+
gazpar2haws/gazpar.py,sha256=4bEp0JDrLAFxbADSoRH76UD3OC8ls5GBhUcoBQ9WQxw,4663
|
7
|
+
gazpar2haws/haws.py,sha256=EXs5Tdla4u2BVtupCCLITyvdL_W288LrCxrTzskWSRY,6783
|
8
|
+
gazpar2haws/version.py,sha256=ebdTNl4h0hNKmN3Gbs592VJsYbMmrkB47WyZMJevaQo,86
|
9
|
+
gazpar2haws-0.1.0.dist-info/LICENSE,sha256=G6JttcnlwcRHYzIcDflSGOVrHTtaP3BEegM2lH00xHw,1094
|
10
|
+
gazpar2haws-0.1.0.dist-info/METADATA,sha256=H6EGtnQczzLQKLDwdPtAd2TwAzzVIi82AURA36Khwns,3099
|
11
|
+
gazpar2haws-0.1.0.dist-info/WHEEL,sha256=Nq82e9rUAnEjt98J6MlVmMCZb-t9cYE2Ir1kpBmnWfs,88
|
12
|
+
gazpar2haws-0.1.0.dist-info/RECORD,,
|