gazpar2haws 0.2.0a20__py3-none-any.whl → 0.2.1__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.
gazpar2haws/__main__.py CHANGED
@@ -1,91 +1,104 @@
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())
1
+ import argparse
2
+ import asyncio
3
+ import logging
4
+ import traceback
5
+
6
+ from gazpar2haws import __version__, config_utils
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(
16
+ prog="gazpar2haws",
17
+ description="Gateway that reads data history from the GrDF (French gas provider) meter and send it to Home Assistant using WebSocket interface.",
18
+ )
19
+ parser.add_argument(
20
+ "-v", "--version", action="version", version="Gazpar2HAWS version"
21
+ )
22
+ parser.add_argument(
23
+ "-c",
24
+ "--config",
25
+ required=False,
26
+ default="config/configuration.yaml",
27
+ help="Path to the configuration file",
28
+ )
29
+ parser.add_argument(
30
+ "-s",
31
+ "--secrets",
32
+ required=False,
33
+ default="config/secrets.yaml",
34
+ help="Path to the secret file",
35
+ )
36
+
37
+ args = parser.parse_args()
38
+
39
+ try:
40
+ # Load configuration files
41
+ config = config_utils.ConfigLoader(args.config, args.secrets)
42
+ config.load_secrets()
43
+ config.load_config()
44
+
45
+ print(f"Gazpar2HAWS version: {__version__}")
46
+
47
+ # Set up logging
48
+ logging_file = config.get("logging.file")
49
+ logging_console = bool(config.get("logging.console"))
50
+ logging_level = config.get("logging.level")
51
+ logging_format = config.get("logging.format")
52
+
53
+ # Convert logging level to integer
54
+ if logging_level.upper() == "DEBUG":
55
+ level = logging.DEBUG
56
+ elif logging_level.upper() == "INFO":
57
+ level = logging.INFO
58
+ elif logging_level.upper() == "WARNING":
59
+ level = logging.WARNING
60
+ elif logging_level.upper() == "ERROR":
61
+ level = logging.ERROR
62
+ elif logging_level.upper() == "CRITICAL":
63
+ level = logging.CRITICAL
64
+ else:
65
+ level = logging.INFO
66
+
67
+ logging.basicConfig(filename=logging_file, level=level, format=logging_format)
68
+
69
+ if logging_console:
70
+ # Add a console handler manually
71
+ console_handler = logging.StreamHandler()
72
+ console_handler.setLevel(level) # Set logging level for the console
73
+ console_handler.setFormatter(
74
+ logging.Formatter(logging_format)
75
+ ) # Customize console format
76
+
77
+ # Get the root logger and add the console handler
78
+ logging.getLogger().addHandler(console_handler)
79
+
80
+ Logger.info(f"Starting Gazpar2HAWS version {__version__}")
81
+
82
+ # Log configuration
83
+ Logger.info(f"Configuration:\n{config.dumps()}")
84
+
85
+ # Start the bridge
86
+ bridge = Bridge(config)
87
+ await bridge.run()
88
+
89
+ Logger.info("Gazpar2HAWS stopped.")
90
+
91
+ return 0
92
+
93
+ except Exception: # pylint: disable=broad-except
94
+ errorMessage = (
95
+ f"An error occured while running Gazpar2HAWS: {traceback.format_exc()}"
96
+ )
97
+ Logger.error(errorMessage)
98
+ print(errorMessage)
99
+ return 1
100
+
101
+
102
+ # ----------------------------------
103
+ if __name__ == "__main__":
104
+ asyncio.run(main())
gazpar2haws/bridge.py CHANGED
@@ -1,6 +1,7 @@
1
+ import asyncio
1
2
  import logging
2
3
  import signal
3
- import asyncio
4
+
4
5
  from gazpar2haws import config_utils
5
6
  from gazpar2haws.gazpar import Gazpar
6
7
  from gazpar2haws.haws import HomeAssistantWS
@@ -15,12 +16,30 @@ class Bridge:
15
16
  def __init__(self, config: config_utils.ConfigLoader):
16
17
 
17
18
  # GrDF scan interval (in seconds)
19
+ if config.get("grdf.scan_interval") is None:
20
+ raise ValueError("Configuration parameter 'grdf.scan_interval' is missing")
18
21
  self._grdf_scan_interval = int(config.get("grdf.scan_interval"))
19
22
 
20
- # Home Assistant configuration
23
+ # Home Assistant configuration: host
24
+ if config.get("homeassistant.host") is None:
25
+ raise ValueError("Configuration parameter 'homeassistant.host' is missing")
21
26
  ha_host = config.get("homeassistant.host")
27
+
28
+ # Home Assistant configuration: port
29
+ if config.get("homeassistant.port") is None:
30
+ raise ValueError("Configuration parameter 'homeassistant.port' is missing")
22
31
  ha_port = config.get("homeassistant.port")
23
- ha_endpoint = config.get("homeassistant.endpoint")
32
+
33
+ # Home Assistant configuration: endpoint
34
+ ha_endpoint = (
35
+ config.get("homeassistant.endpoint")
36
+ if config.get("homeassistant.endpoint")
37
+ else "/api/websocket"
38
+ )
39
+
40
+ # Home Assistant configuration: token
41
+ if config.get("homeassistant.token") is None:
42
+ raise ValueError("Configuration parameter 'homeassistant.token' is missing")
24
43
  ha_token = config.get("homeassistant.token")
25
44
 
26
45
  # Initialize Home Assistant
@@ -28,6 +47,9 @@ class Bridge:
28
47
 
29
48
  # Initialize Gazpar
30
49
  self._gazpar = []
50
+
51
+ if config.get("grdf.devices") is None:
52
+ raise ValueError("Configuration parameter 'grdf.devices' is missing")
31
53
  for grdf_device_config in config.get("grdf.devices"):
32
54
  self._gazpar.append(Gazpar(grdf_device_config, self._homeassistant))
33
55
 
@@ -40,7 +62,7 @@ class Bridge:
40
62
 
41
63
  # ----------------------------------
42
64
  # Graceful shutdown function
43
- def handle_signal(self, signum, frame):
65
+ def handle_signal(self, signum, _):
44
66
  print(f"Signal {signum} received. Shutting down gracefully...")
45
67
  Logger.info(f"Signal {signum} received. Shutting down gracefully...")
46
68
  self._running = False
@@ -63,7 +85,9 @@ class Bridge:
63
85
  for gazpar in self._gazpar:
64
86
  Logger.info(f"Publishing data for device '{gazpar.name()}'...")
65
87
  await gazpar.publish()
66
- Logger.info(f"Device '{gazpar.name()}' data published to Home Assistant WS.")
88
+ Logger.info(
89
+ f"Device '{gazpar.name()}' data published to Home Assistant WS."
90
+ )
67
91
 
68
92
  Logger.info("Gazpar data published to Home Assistant WS.")
69
93
 
@@ -71,7 +95,9 @@ class Bridge:
71
95
  await self._homeassistant.disconnect()
72
96
 
73
97
  # Wait before next scan
74
- Logger.info(f"Waiting {self._grdf_scan_interval} minutes before next scan...")
98
+ Logger.info(
99
+ f"Waiting {self._grdf_scan_interval} minutes before next scan..."
100
+ )
75
101
 
76
102
  # Check if the scan interval is 0 and leave the loop.
77
103
  if self._grdf_scan_interval == 0:
@@ -1,56 +1,61 @@
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)
1
+ import os
2
+ from typing import Any
3
+
4
+ import yaml
5
+
6
+
7
+ class ConfigLoader:
8
+ def __init__(self, config_file: str, secrets_file: str):
9
+ self.config_file = config_file
10
+ self.secrets_file = secrets_file
11
+ self.config = dict[str, Any]()
12
+ self.secrets = dict[str, Any]()
13
+ self.raw_config = None
14
+
15
+ def load_secrets(self):
16
+ """Load the secrets file."""
17
+ if self.secrets_file is None:
18
+ return
19
+ if os.path.exists(self.secrets_file):
20
+ with open(self.secrets_file, "r", encoding="utf-8") as file:
21
+ self.secrets = yaml.safe_load(file)
22
+ else:
23
+ raise FileNotFoundError(f"Secrets file '{self.secrets_file}' not found.")
24
+
25
+ def load_config(self):
26
+ """Load the main configuration file and resolve secrets."""
27
+ if os.path.exists(self.config_file):
28
+ with open(self.config_file, "r", encoding="utf-8") as file:
29
+ self.raw_config = yaml.safe_load(file)
30
+ self.config = self._resolve_secrets(self.raw_config)
31
+ else:
32
+ raise FileNotFoundError(
33
+ f"Configuration file '{self.config_file}' not found."
34
+ )
35
+
36
+ def _resolve_secrets(self, data):
37
+ """Recursively resolve `!secret` keys in the configuration."""
38
+ if isinstance(data, dict):
39
+ return {key: self._resolve_secrets(value) for key, value in data.items()}
40
+ if isinstance(data, list):
41
+ return [self._resolve_secrets(item) for item in data]
42
+ if isinstance(data, str) and data.startswith("!secret"):
43
+ secret_key = data.split(" ", 1)[1]
44
+ if secret_key in self.secrets:
45
+ return self.secrets[secret_key]
46
+ raise KeyError(f"Secret key '{secret_key}' not found in secrets file.")
47
+ return data
48
+
49
+ def get(self, key, default=None):
50
+ """Get a configuration value."""
51
+ keys = key.split(".")
52
+ value = self.config
53
+ try:
54
+ for k in keys:
55
+ value = value[k]
56
+ return value
57
+ except (KeyError, TypeError):
58
+ return default
59
+
60
+ def dumps(self) -> str:
61
+ return yaml.dump(self.raw_config)
gazpar2haws/gazpar.py CHANGED
@@ -1,139 +1,280 @@
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
- # Volume and energy sensor names.
38
- volume_sensor_name = f"sensor.{self._name}_volume"
39
- energy_sensor_name = f"sensor.{self._name}_energy"
40
-
41
- # Eventually reset the sensor in Home Assistant
42
- if self._reset:
43
- try:
44
- await self._homeassistant.clear_statistics([volume_sensor_name, energy_sensor_name])
45
- except Exception:
46
- errorMessage = f"Error while resetting the sensor in Home Assistant: {traceback.format_exc()}"
47
- Logger.warning(errorMessage)
48
- raise Exception(errorMessage)
49
-
50
- # Publish volume sensor
51
- await self._publish_entity(volume_sensor_name, pygazpar.PropertyName.VOLUME.value, "")
52
- await self._publish_entity(energy_sensor_name, pygazpar.PropertyName.ENERGY.value, "kWh")
53
-
54
- # ----------------------------------
55
- # Publish a sensor to Home Assistant
56
- async def _publish_entity(self, entity_id: str, property_name: str, unit_of_measurement: str):
57
-
58
- # Check the existence of the sensor in Home Assistant
59
- try:
60
- exists_statistic_id = await self._homeassistant.exists_statistic_id(entity_id, "sum")
61
- except Exception:
62
- errorMessage = f"Error while checking the existence of the sensor in Home Assistant: {traceback.format_exc()}"
63
- Logger.warning(errorMessage)
64
- raise Exception(errorMessage)
65
-
66
- if exists_statistic_id:
67
- # Get the last statistic from Home Assistant
68
- try:
69
- last_statistic = await self._homeassistant.get_last_statistic(entity_id)
70
- except Exception:
71
- errorMessage = f"Error while fetching last statistics from Home Assistant: {traceback.format_exc()}"
72
- Logger.warning(errorMessage)
73
- raise Exception(errorMessage)
74
-
75
- # Extract the end date of the last statistics from the unix timestamp
76
- last_date = datetime.fromtimestamp(last_statistic.get("start") / 1000, tz=pytz.timezone(self._timezone))
77
-
78
- # Compute the number of days since the last statistics
79
- last_days = (datetime.now(tz=pytz.timezone(self._timezone)) - last_date).days
80
-
81
- # Get the last meter value
82
- last_value = last_statistic.get("sum")
83
- else:
84
- # If the sensor does not exist in Home Assistant, fetch the last days defined in the configuration
85
- last_days = self._last_days
86
-
87
- # Compute the corresponding last_date
88
- last_date = datetime.now(tz=pytz.timezone(self._timezone)) - timedelta(days=last_days)
89
-
90
- # If no statistic, the last value is initialized to zero
91
- last_value = 0
92
-
93
- Logger.debug(f"Last date: {last_date}, last days: {last_days}, last value: {last_value}")
94
-
95
- # Initialize PyGazpar client
96
- client = pygazpar.Client(pygazpar.JsonWebDataSource(username=self._username, password=self._password))
97
-
98
- try:
99
- data = client.loadSince(pceIdentifier=self._pce_identifier, lastNDays=last_days, frequencies=[pygazpar.Frequency.DAILY])
100
- except Exception:
101
- errorMessage = f"Error while fetching data from GrDF: {traceback.format_exc()}"
102
- Logger.warning(errorMessage)
103
- data = {}
104
-
105
- # Timezone
106
- timezone = pytz.timezone(self._timezone)
107
-
108
- # Compute and fill statistics.
109
- daily = data.get(pygazpar.Frequency.DAILY.value)
110
- statistics = []
111
- total = last_value
112
- for reading in daily:
113
- # Parse date format DD/MM/YYYY into datetime.
114
- date = datetime.strptime(reading[pygazpar.PropertyName.TIME_PERIOD.value], "%d/%m/%Y")
115
-
116
- # Set the timezone
117
- date = timezone.localize(date)
118
-
119
- # Skip all readings before the last statistic date.
120
- if date <= last_date:
121
- Logger.debug(f"Skip date: {date} <= {last_date}")
122
- continue
123
-
124
- # Compute the total volume and energy
125
- total += reading[property_name]
126
-
127
- statistics.append({
128
- "start": date.isoformat(),
129
- "state": total,
130
- "sum": total
131
- })
132
-
133
- # Publish statistics to Home Assistant
134
- try:
135
- await self._homeassistant.import_statistics(entity_id, "recorder", "gazpar2haws", unit_of_measurement, statistics)
136
- except Exception:
137
- errorMessage = f"Error while importing statistics to Home Assistant: {traceback.format_exc()}"
138
- Logger.warning(errorMessage)
139
- raise Exception(errorMessage)
1
+ import logging
2
+ import traceback
3
+ from datetime import datetime, timedelta
4
+ from typing import Any
5
+
6
+ import pygazpar # type: ignore
7
+ import pytz
8
+
9
+ from gazpar2haws.haws import HomeAssistantWS, HomeAssistantWSException
10
+
11
+ Logger = logging.getLogger(__name__)
12
+
13
+
14
+ # ----------------------------------
15
+ class Gazpar:
16
+
17
+ # ----------------------------------
18
+ def __init__(self, config: dict[str, Any], homeassistant: HomeAssistantWS):
19
+
20
+ self._homeassistant = homeassistant
21
+
22
+ # GrDF configuration: name
23
+ if config.get("name") is None:
24
+ raise ValueError("Configuration parameter 'grdf.devices[].name' is missing")
25
+ self._name = config.get("name")
26
+
27
+ # GrDF configuration: data source
28
+ self._data_source = (
29
+ config.get("data_source") if config.get("data_source") else "json"
30
+ )
31
+
32
+ # GrDF configuration: username
33
+ if self._data_source != "test" and config.get("username") is None:
34
+ raise ValueError(
35
+ "Configuration parameter 'grdf.devices[].username' is missing"
36
+ )
37
+ self._username = config.get("username")
38
+
39
+ # GrDF configuration: password
40
+ if self._data_source != "test" and config.get("password") is None:
41
+ raise ValueError(
42
+ "Configuration parameter 'grdf.devices[].password' is missing"
43
+ )
44
+ self._password = config.get("password")
45
+
46
+ # GrDF configuration: pce_identifier
47
+ if self._data_source != "test" and config.get("pce_identifier") is None:
48
+ raise ValueError(
49
+ "Configuration parameter 'grdf.devices[].pce_identifier' is missing"
50
+ )
51
+ self._pce_identifier = str(config.get("pce_identifier"))
52
+
53
+ # GrDF configuration: tmp_dir
54
+ self._tmp_dir = config.get("tmp_dir") if config.get("tmp_dir") else "/tmp"
55
+
56
+ # GrDF configuration: last_days
57
+ if config.get("last_days") is None:
58
+ raise ValueError(
59
+ "Configuration parameter 'grdf.devices[].last_days' is missing"
60
+ )
61
+ self._last_days = int(str(config.get("last_days")))
62
+
63
+ # GrDF configuration: timezone
64
+ if config.get("timezone") is None:
65
+ raise ValueError(
66
+ "Configuration parameter 'grdf.devices[].timezone' is missing"
67
+ )
68
+ self._timezone = str(config.get("timezone"))
69
+
70
+ # GrDF configuration: reset
71
+ if config.get("reset") is None:
72
+ raise ValueError(
73
+ "Configuration parameter 'grdf.devices[].reset' is missing"
74
+ )
75
+ self._reset = bool(config.get("reset"))
76
+
77
+ # As of date: YYYY-MM-DD
78
+ as_of_date = config.get("as_of_date")
79
+ if self._data_source is not None and str(self._data_source).lower() == "test":
80
+ self._as_of_date = (
81
+ datetime.now(tz=pytz.timezone(self._timezone))
82
+ if as_of_date is None
83
+ else datetime.strptime(as_of_date, "%Y-%m-%d")
84
+ )
85
+ else:
86
+ self._as_of_date = datetime.now(tz=pytz.timezone(self._timezone))
87
+
88
+ # Set the timezone
89
+ timezone = pytz.timezone(self._timezone)
90
+ if self._as_of_date.tzinfo is None:
91
+ self._as_of_date = timezone.localize(self._as_of_date)
92
+
93
+ # ----------------------------------
94
+ def name(self):
95
+ return self._name
96
+
97
+ # ----------------------------------
98
+ # Publish Gaspar data to Home Assistant WS
99
+ async def publish(self):
100
+
101
+ # Volume and energy sensor names.
102
+ volume_sensor_name = f"sensor.{self._name}_volume"
103
+ energy_sensor_name = f"sensor.{self._name}_energy"
104
+
105
+ # Eventually reset the sensor in Home Assistant
106
+ if self._reset:
107
+ try:
108
+ await self._homeassistant.clear_statistics(
109
+ [volume_sensor_name, energy_sensor_name]
110
+ )
111
+ except Exception:
112
+ Logger.warning(
113
+ f"Error while resetting the sensor in Home Assistant: {traceback.format_exc()}"
114
+ )
115
+ raise
116
+
117
+ # Publish volume sensor
118
+ await self._publish_entity(
119
+ volume_sensor_name, pygazpar.PropertyName.VOLUME.value, "m³"
120
+ )
121
+ await self._publish_entity(
122
+ energy_sensor_name, pygazpar.PropertyName.ENERGY.value, "kWh"
123
+ )
124
+
125
+ # ----------------------------------
126
+ # Publish a sensor to Home Assistant
127
+ async def _publish_entity(
128
+ self, entity_id: str, property_name: str, unit_of_measurement: str
129
+ ):
130
+
131
+ # Find last date, days and value of the entity.
132
+ last_date, last_days, last_value = await self._find_last_date_days_value(
133
+ entity_id
134
+ )
135
+
136
+ # Instantiate the right data source.
137
+ data_source = self._create_data_source()
138
+
139
+ # Initialize PyGazpar client
140
+ client = pygazpar.Client(data_source)
141
+
142
+ try:
143
+ data = client.loadSince(
144
+ pceIdentifier=self._pce_identifier,
145
+ lastNDays=last_days,
146
+ frequencies=[pygazpar.Frequency.DAILY],
147
+ )
148
+ except Exception: # pylint: disable=broad-except
149
+ Logger.warning(
150
+ f"Error while fetching data from GrDF: {traceback.format_exc()}"
151
+ )
152
+ data = {}
153
+
154
+ # Timezone
155
+ timezone = pytz.timezone(self._timezone)
156
+
157
+ # Compute and fill statistics.
158
+ daily = data.get(pygazpar.Frequency.DAILY.value)
159
+ statistics = []
160
+ total = last_value
161
+ for reading in daily:
162
+ # Parse date format DD/MM/YYYY into datetime.
163
+ date = datetime.strptime(
164
+ reading[pygazpar.PropertyName.TIME_PERIOD.value], "%d/%m/%Y"
165
+ )
166
+
167
+ # Set the timezone
168
+ date = timezone.localize(date)
169
+
170
+ # Skip all readings before the last statistic date.
171
+ if date <= last_date:
172
+ Logger.debug(f"Skip date: {date} <= {last_date}")
173
+ continue
174
+
175
+ # Compute the total volume and energy
176
+ if reading[property_name] is not None:
177
+ total += reading[property_name]
178
+ else:
179
+ Logger.warning(
180
+ f"Missing property {property_name} for date {date}. Skipping..."
181
+ )
182
+ continue
183
+
184
+ statistics.append({"start": date.isoformat(), "state": total, "sum": total})
185
+
186
+ # Publish statistics to Home Assistant
187
+ try:
188
+ await self._homeassistant.import_statistics(
189
+ entity_id, "recorder", "gazpar2haws", unit_of_measurement, statistics
190
+ )
191
+ except Exception:
192
+ Logger.warning(
193
+ f"Error while importing statistics to Home Assistant: {traceback.format_exc()}"
194
+ )
195
+ raise
196
+
197
+ # ----------------------------------
198
+ # Create the data source.
199
+ def _create_data_source(self) -> pygazpar.datasource.IDataSource:
200
+
201
+ if self._data_source is not None:
202
+ if str(self._data_source).lower() == "test":
203
+ return pygazpar.TestDataSource()
204
+
205
+ if str(self._data_source).lower() == "excel":
206
+ return pygazpar.ExcelWebDataSource(
207
+ username=self._username,
208
+ password=self._password,
209
+ tmpDirectory=self._tmp_dir,
210
+ )
211
+
212
+ return pygazpar.JsonWebDataSource(
213
+ username=self._username, password=self._password
214
+ )
215
+
216
+ # ----------------------------------
217
+ # Find last date, days and value of the entity.
218
+ async def _find_last_date_days_value(
219
+ self, entity_id: str
220
+ ) -> tuple[datetime, int, float]:
221
+
222
+ # Check the existence of the sensor in Home Assistant
223
+ try:
224
+ exists_statistic_id = await self._homeassistant.exists_statistic_id(
225
+ entity_id, "sum"
226
+ )
227
+ except Exception:
228
+ Logger.warning(
229
+ f"Error while checking the existence of the sensor in Home Assistant: {traceback.format_exc()}"
230
+ )
231
+ raise
232
+
233
+ if exists_statistic_id:
234
+ # Get the last statistic from Home Assistant
235
+ try:
236
+ last_statistic = await self._homeassistant.get_last_statistic(
237
+ entity_id, self._as_of_date, self._last_days
238
+ )
239
+ except HomeAssistantWSException:
240
+ Logger.warning(
241
+ f"Error while fetching last statistics from Home Assistant: {traceback.format_exc()}"
242
+ )
243
+
244
+ if last_statistic:
245
+ # Extract the end date of the last statistics from the unix timestamp
246
+ last_date = datetime.fromtimestamp(
247
+ int(str(last_statistic.get("start"))) / 1000,
248
+ tz=pytz.timezone(self._timezone),
249
+ )
250
+
251
+ # Compute the number of days since the last statistics
252
+ last_days = (self._as_of_date - last_date).days
253
+
254
+ # Get the last meter value
255
+ last_value = float(str(last_statistic.get("sum")))
256
+
257
+ Logger.debug(
258
+ f"Last date: {last_date}, last days: {last_days}, last value: {last_value}"
259
+ )
260
+
261
+ return last_date, last_days, last_value
262
+
263
+ Logger.debug(f"No statistics found for the existing sensor {entity_id}.")
264
+ else:
265
+ Logger.debug(f"Sensor {entity_id} does not exist in Home Assistant.")
266
+
267
+ # If the sensor does not exist in Home Assistant, fetch the last days defined in the configuration
268
+ last_days = self._last_days
269
+
270
+ # Compute the corresponding last_date
271
+ last_date = self._as_of_date - timedelta(days=last_days)
272
+
273
+ # If no statistic, the last value is initialized to zero
274
+ last_value = 0
275
+
276
+ Logger.debug(
277
+ f"Last date: {last_date}, last days: {last_days}, last value: {last_value}"
278
+ )
279
+
280
+ return last_date, last_days, last_value
gazpar2haws/haws.py CHANGED
@@ -1,13 +1,20 @@
1
- import websockets
2
1
  import json
3
- from datetime import datetime, timedelta
4
2
  import logging
3
+ from datetime import datetime, timedelta
4
+
5
+ import websockets
5
6
 
6
7
  Logger = logging.getLogger(__name__)
7
8
 
8
9
 
10
+ # ----------------------------------
11
+ class HomeAssistantWSException(Exception):
12
+ pass
13
+
14
+
9
15
  # ----------------------------------
10
16
  class HomeAssistantWS:
17
+ # ----------------------------------
11
18
  def __init__(self, host: str, port: str, endpoint: str, token: str):
12
19
  self._host = host
13
20
  self._port = port
@@ -24,7 +31,9 @@ class HomeAssistantWS:
24
31
  ws_url = f"ws://{self._host}:{self._port}{self._endpoint}"
25
32
 
26
33
  # Connect to the websocket
27
- self._websocket = await websockets.connect(ws_url, additional_headers={"Authorization": f"Bearer {self._token}"})
34
+ self._websocket = await websockets.connect(
35
+ ws_url, additional_headers={"Authorization": f"Bearer {self._token}"}
36
+ )
28
37
 
29
38
  # When a client connects to the server, the server sends out auth_required.
30
39
  connect_response = await self._websocket.recv()
@@ -33,7 +42,7 @@ class HomeAssistantWS:
33
42
  if connect_response_data.get("type") != "auth_required":
34
43
  message = f"Authentication failed: auth_required not received {connect_response_data.get('messsage')}"
35
44
  Logger.warning(message)
36
- raise Exception(message)
45
+ raise HomeAssistantWSException(message)
37
46
 
38
47
  # The first message from the client should be an auth message. You can authorize with an access token.
39
48
  auth_message = {"type": "auth", "access_token": self._token}
@@ -46,7 +55,7 @@ class HomeAssistantWS:
46
55
  if auth_response_data.get("type") == "auth_invalid":
47
56
  message = f"Authentication failed: {auth_response_data.get('messsage')}"
48
57
  Logger.warning(message)
49
- raise Exception(message)
58
+ raise HomeAssistantWSException(message)
50
59
 
51
60
  Logger.debug("Connected to Home Assistant")
52
61
 
@@ -60,10 +69,13 @@ class HomeAssistantWS:
60
69
  Logger.debug("Disconnected from Home Assistant")
61
70
 
62
71
  # ----------------------------------
63
- async def send_message(self, message: dict) -> dict:
72
+ async def send_message(self, message: dict) -> dict | list[dict]:
64
73
 
65
74
  Logger.debug("Sending a message...")
66
75
 
76
+ if self._websocket is None:
77
+ raise HomeAssistantWSException("Not connected to Home Assistant")
78
+
67
79
  message["id"] = self._message_id
68
80
 
69
81
  self._message_id += 1
@@ -77,40 +89,50 @@ class HomeAssistantWS:
77
89
  Logger.debug("Received response")
78
90
 
79
91
  if response_data.get("type") != "result":
80
- raise Exception(f"Invalid response message: {response_data}")
92
+ raise HomeAssistantWSException(f"Invalid response message: {response_data}")
81
93
 
82
94
  if not response_data.get("success"):
83
- raise Exception(f"Request failed: {response_data.get('error')}")
95
+ raise HomeAssistantWSException(
96
+ f"Request failed: {response_data.get('error')}"
97
+ )
84
98
 
85
99
  return response_data.get("result")
86
100
 
87
101
  # ----------------------------------
88
- async def list_statistic_ids(self, statistic_type: str = None) -> list[str]:
102
+ async def list_statistic_ids(self, statistic_type: str | None = None) -> list[dict]:
89
103
 
90
104
  Logger.debug("Listing statistics IDs...")
91
105
 
92
106
  # List statistics IDs message
93
- list_statistic_ids_message = {
94
- "type": "recorder/list_statistic_ids"
95
- }
107
+ list_statistic_ids_message = {"type": "recorder/list_statistic_ids"}
96
108
 
97
109
  if statistic_type is not None:
98
110
  list_statistic_ids_message["statistic_type"] = statistic_type
99
111
 
100
112
  response = await self.send_message(list_statistic_ids_message)
101
113
 
114
+ # Check response instance type
115
+ if not isinstance(response, list):
116
+ raise HomeAssistantWSException(
117
+ f"Invalid list_statistic_ids response type: got {type(response)} instead of list[dict]"
118
+ )
119
+
102
120
  Logger.debug(f"Listed statistics IDs: {len(response)} ids")
103
121
 
104
122
  return response
105
123
 
106
124
  # ----------------------------------
107
- async def exists_statistic_id(self, entity_id: str, statistic_type: str = None) -> bool:
125
+ async def exists_statistic_id(
126
+ self, entity_id: str, statistic_type: str | None = None
127
+ ) -> bool:
108
128
 
109
129
  Logger.debug(f"Checking if {entity_id} exists...")
110
130
 
111
131
  statistic_ids = await self.list_statistic_ids(statistic_type)
112
132
 
113
- entity_ids = [statistic_id.get("statistic_id") for statistic_id in statistic_ids]
133
+ entity_ids = [
134
+ statistic_id.get("statistic_id") for statistic_id in statistic_ids
135
+ ]
114
136
 
115
137
  exists_statistic = entity_id in entity_ids
116
138
 
@@ -119,9 +141,13 @@ class HomeAssistantWS:
119
141
  return exists_statistic
120
142
 
121
143
  # ----------------------------------
122
- async def statistics_during_period(self, entity_ids: list[str], start_time: datetime, end_time: datetime) -> dict:
144
+ async def statistics_during_period(
145
+ self, entity_ids: list[str], start_time: datetime, end_time: datetime
146
+ ) -> dict:
123
147
 
124
- Logger.debug(f"Getting {entity_ids} statistics during period from {start_time} to {end_time}...")
148
+ Logger.debug(
149
+ f"Getting {entity_ids} statistics during period from {start_time} to {end_time}..."
150
+ )
125
151
 
126
152
  # Subscribe to statistics
127
153
  statistics_message = {
@@ -129,30 +155,55 @@ class HomeAssistantWS:
129
155
  "start_time": start_time.isoformat(),
130
156
  "end_time": end_time.isoformat(),
131
157
  "statistic_ids": entity_ids,
132
- "period": "day"
158
+ "period": "day",
133
159
  }
134
160
 
135
161
  response = await self.send_message(statistics_message)
136
162
 
137
- Logger.debug(f"Received {entity_ids} statistics during period from {start_time} to {end_time}")
163
+ # Check response instance type
164
+ if not isinstance(response, dict):
165
+ raise HomeAssistantWSException(
166
+ f"Invalid statistics_during_period response type: got {type(response)} instead of dict"
167
+ )
168
+
169
+ Logger.debug(
170
+ f"Received {entity_ids} statistics during period from {start_time} to {end_time}"
171
+ )
138
172
 
139
173
  return response
140
174
 
141
175
  # ----------------------------------
142
- async def get_last_statistic(self, entity_id: str) -> dict:
176
+ async def get_last_statistic(
177
+ self, entity_id: str, as_of_date: datetime, depth_days: int
178
+ ) -> dict:
143
179
 
144
180
  Logger.debug(f"Getting last statistic for {entity_id}...")
145
181
 
146
- statistics = await self.statistics_during_period([entity_id], datetime.now() - timedelta(days=30), datetime.now())
182
+ statistics = await self.statistics_during_period(
183
+ [entity_id], as_of_date - timedelta(days=depth_days), as_of_date
184
+ )
185
+
186
+ if not statistics:
187
+ Logger.warning(f"No statistics found for {entity_id}.")
188
+ return {}
147
189
 
148
190
  Logger.debug(f"Last statistic for {entity_id}: {statistics[entity_id][-1]}")
149
191
 
150
192
  return statistics[entity_id][-1]
151
193
 
152
194
  # ----------------------------------
153
- async def import_statistics(self, entity_id: str, source: str, name: str, unit_of_measurement: str, statistics: list[dict]):
154
-
155
- Logger.debug(f"Importing {len(statistics)} statistics for {entity_id} from {source}...")
195
+ async def import_statistics(
196
+ self,
197
+ entity_id: str,
198
+ source: str,
199
+ name: str,
200
+ unit_of_measurement: str,
201
+ statistics: list[dict],
202
+ ):
203
+
204
+ Logger.debug(
205
+ f"Importing {len(statistics)} statistics for {entity_id} from {source}..."
206
+ )
156
207
 
157
208
  if len(statistics) == 0:
158
209
  Logger.debug("No statistics to import")
@@ -162,19 +213,21 @@ class HomeAssistantWS:
162
213
  import_statistics_message = {
163
214
  "type": "recorder/import_statistics",
164
215
  "metadata": {
165
- "has_mean": False,
166
- "has_sum": True,
167
- "statistic_id": entity_id,
168
- "source": source,
169
- "name": name,
170
- "unit_of_measurement": unit_of_measurement,
216
+ "has_mean": False,
217
+ "has_sum": True,
218
+ "statistic_id": entity_id,
219
+ "source": source,
220
+ "name": name,
221
+ "unit_of_measurement": unit_of_measurement,
171
222
  },
172
- "stats": statistics
223
+ "stats": statistics,
173
224
  }
174
225
 
175
226
  await self.send_message(import_statistics_message)
176
227
 
177
- Logger.debug(f"Imported {len(statistics)} statistics for {entity_id} from {source}")
228
+ Logger.debug(
229
+ f"Imported {len(statistics)} statistics for {entity_id} from {source}"
230
+ )
178
231
 
179
232
  # ----------------------------------
180
233
  async def clear_statistics(self, entity_ids: list[str]):
@@ -184,7 +237,7 @@ class HomeAssistantWS:
184
237
  # Clear statistics message
185
238
  clear_statistics_message = {
186
239
  "type": "recorder/clear_statistics",
187
- "statistic_ids": entity_ids
240
+ "statistic_ids": entity_ids,
188
241
  }
189
242
 
190
243
  await self.send_message(clear_statistics_message)
gazpar2haws/version.py CHANGED
@@ -1,3 +1,3 @@
1
1
  import importlib.metadata
2
2
 
3
- __version__ = importlib.metadata.version('gazpar2haws')
3
+ __version__ = importlib.metadata.version("gazpar2haws")
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: gazpar2haws
3
- Version: 0.2.0a20
3
+ Version: 0.2.1
4
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
5
  License: MIT License
6
6
 
@@ -52,7 +52,7 @@ Gazpar2HAWS can be installed in many ways.
52
52
 
53
53
  ### 1. Home Assistant Add-on
54
54
 
55
- In the **Add-on store**, click **⋮ → Repositories**, fill in **`https://github.com/ssenart/gazpar2haws`** and click **Add → Close** or click the **Add repository** button below, click **Add → Close** (You might need to enter the **internal IP address** of your Home Assistant instance first).
55
+ In the **Add-on store**, click **⋮ → Repositories**, fill in **`https://github.com/ssenart/gazpar2haws`** and click **Add → Close** or click the **Add repository** button below, click **Add → Close** (You might need to enter the **internal IP address** of your Home Assistant instance first).
56
56
 
57
57
  [![Open your Home Assistant instance and show the add add-on repository dialog with a specific repository URL pre-filled.](https://my.home-assistant.io/badges/supervisor_add_addon_repository.svg)](https://my.home-assistant.io/redirect/supervisor_add_addon_repository/?repository_url=https%3A%2F%2Fgithub.com%2Fssenart%2Fgazpar2haws)
58
58
 
@@ -67,11 +67,11 @@ The following steps permits to run a container from an existing image available
67
67
  ```yaml
68
68
  services:
69
69
  gazpar2haws:
70
- image: ssenart/gazpar2haws:latest
70
+ image: ssenart/gazpar2haws:latest
71
71
  container_name: gazpar2haws
72
72
  restart: unless-stopped
73
73
  network_mode: bridge
74
- user: "1000:1000"
74
+ user: "1000:1000"
75
75
  volumes:
76
76
  - ./gazpar2haws/config:/app/config
77
77
  - ./gazpar2haws/log:/app/log
@@ -86,6 +86,7 @@ services:
86
86
  Edit the environment variable section according to your setup.
87
87
 
88
88
  2. Run the container:
89
+
89
90
  ```sh
90
91
  $ docker compose up -d
91
92
  ```
@@ -112,27 +113,33 @@ $ pip install gazpar2haws
112
113
  The following steps permit to build the Docker image based on the local source files.
113
114
 
114
115
  1. Clone the repo locally:
116
+
115
117
  ```sh
116
118
  $ cd /path/to/my_install_folder/
117
119
 
118
120
  $ git clone https://github.com/ssenart/gazpar2haws.git
119
121
  ```
122
+
120
123
  2. Edit the docker-compose.yaml file by setting the environment variables corresponding to your GrDF account and Home Assistant setup:
121
124
 
122
125
  ```yaml
123
- environment:
124
- - GRDF_USERNAME=<GrDF account username>
125
- - GRDF_PASSWORD=<GrDF account password>
126
- - GRDF_PCE_IDENTIFIER=<GrDF PCE meter identifier>
127
- - HOMEASSISTANT_HOST=<Home Assistant instance host name>
128
- - HOMEASSISTANT_PORT=<Home Assistant instance port number>
129
- - HOMEASSISTANT_TOKEN=<Home Assistant access token>
126
+ environment:
127
+ - GRDF_USERNAME=<GrDF account username>
128
+ - GRDF_PASSWORD=<GrDF account password>
129
+ - GRDF_PCE_IDENTIFIER=<GrDF PCE meter identifier>
130
+ - HOMEASSISTANT_HOST=<Home Assistant instance host name>
131
+ - HOMEASSISTANT_PORT=<Home Assistant instance port number>
132
+ - HOMEASSISTANT_TOKEN=<Home Assistant access token>
130
133
  ```
134
+
131
135
  3. Build the image:
136
+
132
137
  ```sh
133
138
  $ docker compose -f docker/docker-compose.yaml build
134
139
  ```
140
+
135
141
  4. Run the container:
142
+
136
143
  ```sh
137
144
  $ docker compose -f docker/docker-compose.yaml up -d
138
145
  ```
@@ -171,18 +178,18 @@ logging:
171
178
  file: log/gazpar2haws.log
172
179
  console: true
173
180
  level: debug
174
- format: '%(asctime)s %(levelname)s [%(name)s] %(message)s'
181
+ format: "%(asctime)s %(levelname)s [%(name)s] %(message)s"
175
182
 
176
183
  grdf:
177
184
  scan_interval: 0 # Number of minutes between each data retrieval (0 means no scan: a single data retrieval at startup, then stops).
178
185
  devices:
179
- - name: gazpar2haws # Name of the device in home assistant. It will be used as the entity_ids: sensor.${name}_volume and sensor.${name}_energy.
180
- username: "!secret grdf.username"
181
- password: "!secret grdf.password"
182
- pce_identifier: "!secret grdf.pce_identifier"
183
- timezone: Europe/Paris
184
- last_days: 365 # Number of days of data to retrieve
185
- reset: false # If true, the data will be reset before the first data retrieval
186
+ - name: gazpar2haws # Name of the device in home assistant. It will be used as the entity_ids: sensor.${name}_volume and sensor.${name}_energy.
187
+ username: "!secret grdf.username"
188
+ password: "!secret grdf.password"
189
+ pce_identifier: "!secret grdf.pce_identifier"
190
+ timezone: Europe/Paris
191
+ last_days: 365 # Number of days of data to retrieve
192
+ reset: false # If true, the data will be reset before the first data retrieval
186
193
 
187
194
  homeassistant:
188
195
  host: "!secret homeassistant.host"
@@ -203,8 +210,9 @@ homeassistant.token: ${HA_TOKEN}
203
210
  ```
204
211
 
205
212
  The history is uploaded on the entities with names:
206
- - sensor.${name}_volume: Volume history in m³.
207
- - sensor.${name}_energy: Energy history in kWh.
213
+
214
+ - sensor.${name}\_volume: Volume history in m³.
215
+ - sensor.${name}\_energy: Energy history in kWh.
208
216
 
209
217
  `${name}` is 'gazpar2haws' defined in the above configuration file. It can be replaced by any other name.
210
218
 
@@ -212,16 +220,16 @@ The history is uploaded on the entities with names:
212
220
 
213
221
  In a Docker environment, the configurations files are instantiated by replacing the environment variables below in the template files:
214
222
 
215
- | Environment variable | Description | Required | Default value |
216
- |---|---|---|---|
217
- | GRDF_USERNAME | GrDF account user name | Yes | - |
218
- | GRDF_PASSWORD | GrDF account password (avoid using special characters) | Yes | - |
219
- | GRDF_PCE_IDENTIFIER | GrDF meter PCE identifier | Yes | - |
220
- | GRDF_SCAN_INTERVAL | Period in minutes to refresh meter data (0 means one single refresh and stop) | No | 480 (8 hours) |
221
- | GRDF_LAST_DAYS | Number of days of history data to retrieve | No | 1095 (3 years) |
222
- | HOMEASSISTANT_HOST | Home Assistant instance host name | Yes | - |
223
- | HOMEASSISTANT_PORT | Home Assistant instance port number | No | 8123 |
224
- | HOMEASSISTANT_TOKEN | Home Assistant access token | Yes | - |
223
+ | Environment variable | Description | Required | Default value |
224
+ | -------------------- | ----------------------------------------------------------------------------- | -------- | -------------- |
225
+ | GRDF_USERNAME | GrDF account user name | Yes | - |
226
+ | GRDF_PASSWORD | GrDF account password (avoid using special characters) | Yes | - |
227
+ | GRDF_PCE_IDENTIFIER | GrDF meter PCE identifier | Yes | - |
228
+ | GRDF_SCAN_INTERVAL | Period in minutes to refresh meter data (0 means one single refresh and stop) | No | 480 (8 hours) |
229
+ | GRDF_LAST_DAYS | Number of days of history data to retrieve | No | 1095 (3 years) |
230
+ | HOMEASSISTANT_HOST | Home Assistant instance host name | Yes | - |
231
+ | HOMEASSISTANT_PORT | Home Assistant instance port number | No | 8123 |
232
+ | HOMEASSISTANT_TOKEN | Home Assistant access token | Yes | - |
225
233
 
226
234
  You can setup them directly in a docker-compose.yaml file (environment section) or from a Docker command line (-e option).
227
235
 
@@ -273,6 +281,3 @@ Please make sure to update tests as appropriate.
273
281
 
274
282
  Gazpar2HAWS has been initiated for integration with [Home Assistant](https://www.home-assistant.io/) energy dashboard.
275
283
 
276
-
277
-
278
-
@@ -0,0 +1,11 @@
1
+ gazpar2haws/__init__.py,sha256=3MCDQdGGmT3FQMKaAB3mBJq7L75T_bJSdpDRjE-pado,58
2
+ gazpar2haws/__main__.py,sha256=KdhkowqJP6L8WTF9FNpTy4ZzDf1OPARm6mVtMclxGFk,3265
3
+ gazpar2haws/bridge.py,sha256=IDunENo7bQ9NDov76IEefAkMOen-6Wc_2NZcHGyk_XI,4601
4
+ gazpar2haws/config_utils.py,sha256=r8BvrA1r8huF-qjvQb0P1JYDLqzDleirLjOcj0xOyyU,2253
5
+ gazpar2haws/gazpar.py,sha256=29RfLxBaBCpoopOBpCX3xh681Ksm5pjZlLu9l3PqTAI,10321
6
+ gazpar2haws/haws.py,sha256=H--UWGECYq9JYlbU6IHLO2btaRXInwoVRZnHIXSA1ts,8169
7
+ gazpar2haws/version.py,sha256=9Iq5Jm63Ev7QquCjhDqa9_KAgHdKl9FJHynq8M6JNrY,83
8
+ gazpar2haws-0.2.1.dist-info/LICENSE,sha256=ajApZPyhVx8AU9wN7DXeRGhoWFqY2ylBZUa5GRhTmok,1073
9
+ gazpar2haws-0.2.1.dist-info/METADATA,sha256=Fbf6evtMKByRJCEWcE179YQHhmtsa4COSYluCIcGaKo,10050
10
+ gazpar2haws-0.2.1.dist-info/WHEEL,sha256=RaoafKOydTQ7I_I3JTrPCg6kUmTgtm4BornzOqyEfJ8,88
11
+ gazpar2haws-0.2.1.dist-info/RECORD,,
@@ -1,11 +0,0 @@
1
- gazpar2haws/__init__.py,sha256=3MCDQdGGmT3FQMKaAB3mBJq7L75T_bJSdpDRjE-pado,58
2
- gazpar2haws/__main__.py,sha256=EMWGYVVfKEJySSvn8fmNfzFZWVjsPefUFyt4gTC506w,3162
3
- gazpar2haws/bridge.py,sha256=plcXR8y6lH84OSHuUOogNcbM7uua24inoF9SSERKGHo,3539
4
- gazpar2haws/config_utils.py,sha256=Q_-07kAIqvjjHG27tHLLnyaTAZcFVdt1iRzksz2wy1k,2067
5
- gazpar2haws/gazpar.py,sha256=jXpOtqWW6fv6BQmVLoA0G7B93HjztY7MemvGnszBXPU,5615
6
- gazpar2haws/haws.py,sha256=H0Qa01Qtsn3QdnGqIGkXE-Ympf7MSXkbFwAbzaMAodM,6895
7
- gazpar2haws/version.py,sha256=tJINl5RAPtGkwDz8nWdcM1emyqLY2N2XfgsBHuofz5U,83
8
- gazpar2haws-0.2.0a20.dist-info/LICENSE,sha256=ajApZPyhVx8AU9wN7DXeRGhoWFqY2ylBZUa5GRhTmok,1073
9
- gazpar2haws-0.2.0a20.dist-info/METADATA,sha256=WsuiUmV_gYflm_ILcy4pIsuGWBn0rsPOWEb9UxVAIe0,9453
10
- gazpar2haws-0.2.0a20.dist-info/WHEEL,sha256=RaoafKOydTQ7I_I3JTrPCg6kUmTgtm4BornzOqyEfJ8,88
11
- gazpar2haws-0.2.0a20.dist-info/RECORD,,