enviroplussensorstomqtt 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.
@@ -0,0 +1,5 @@
1
+ """Enviroplus sensors to MQTT."""
2
+
3
+ __version__ = "0.1.0"
4
+
5
+ __all__ = ["__version__"]
@@ -0,0 +1,160 @@
1
+ """Args handler module."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import os
7
+ from typing import Any
8
+
9
+ from .config import parse_config, validate_config
10
+
11
+
12
+ def args_handler(
13
+ argv: list[str] | None = None,
14
+ *,
15
+ config_file: str | None = None,
16
+ validate: bool = False,
17
+ ) -> dict[str, Any]:
18
+ """
19
+ Function for reading arguments and config file.
20
+
21
+ Args:
22
+ argv (list[str], optional): Arguments list to parse. Defaults to sys.argv[1:].
23
+ config_file (str, optional): Default config file to use if not overridden by CLI.
24
+ validate (bool, optional): Whether to validate config immediately against AppConfig schema.
25
+
26
+ Returns:
27
+ dict: Parsed and merged configuration dictionary.
28
+ """
29
+ parser = argparse.ArgumentParser(description="Publish Enviro+ sensor data to MQTT")
30
+ parser.add_argument("--username", type=str, required=False, help="MQTT username")
31
+ parser.add_argument("--password", type=str, required=False, help="MQTT password")
32
+ parser.add_argument("--host", type=str, required=False, help="MQTT broker host")
33
+ parser.add_argument("--port", type=int, required=False, help="MQTT broker port")
34
+ parser.add_argument("--topics", type=str, required=False, help="Comma-separated MQTT topics")
35
+ parser.add_argument("--config_file", type=str, required=False, help="Path to YAML configuration file")
36
+ parser.add_argument("--log_file", type=str, required=False, help="Path to log file")
37
+ parser.add_argument("--interval", type=float, required=False, help="Interval in seconds between cycles")
38
+ parser.add_argument("--measurements", type=int, required=False, help="Number of samples to average per cycle")
39
+ parser.add_argument(
40
+ "--temperature_offset",
41
+ type=float,
42
+ required=False,
43
+ help="Temperature offset in °C to subtract/compensate for self-heating",
44
+ )
45
+ parser.add_argument("--client_id", type=str, required=False, help="MQTT client ID")
46
+ parser.add_argument("--tls", action=argparse.BooleanOptionalAction, default=None, help="Enable TLS")
47
+ parser.add_argument("--tls_ca_certs", type=str, required=False, help="Path to CA certificate")
48
+ parser.add_argument("--tls_certfile", type=str, required=False, help="Path to client certificate")
49
+ parser.add_argument("--tls_keyfile", type=str, required=False, help="Path to client key")
50
+ parser.add_argument("--tls_insecure", action=argparse.BooleanOptionalAction, default=None, help="Skip TLS verify")
51
+ parser.add_argument(
52
+ "--per_metric_topics", action=argparse.BooleanOptionalAction, default=None, help="Publish per-metric"
53
+ )
54
+ parser.add_argument(
55
+ "--ha_discovery", action=argparse.BooleanOptionalAction, default=None, help="Enable HA discovery"
56
+ )
57
+ parser.add_argument("--ha_discovery_prefix", type=str, required=False, help="HA discovery prefix")
58
+ parser.add_argument("--device_id", type=str, required=False, help="Device ID for HA discovery")
59
+ parser.add_argument(
60
+ "--disable-ltr559",
61
+ action="store_true",
62
+ help="Disable LTR559 light and proximity sensor",
63
+ )
64
+ parser.add_argument("-D", "--debug", action="store_true", help="Enable debug logging")
65
+ args = parser.parse_args(argv)
66
+
67
+ if args.config_file:
68
+ config = parse_config(config_file=args.config_file)
69
+ elif config_file:
70
+ config = parse_config(config_file=config_file)
71
+ elif os.path.isfile("/etc/enviroplussensorstomqtt.yaml"):
72
+ config = parse_config(config_file="/etc/enviroplussensorstomqtt.yaml")
73
+ elif os.path.isfile("config.yaml"):
74
+ config = parse_config(config_file="config.yaml")
75
+ else:
76
+ config = parse_config()
77
+
78
+ if args.username is not None:
79
+ config["username"] = args.username
80
+
81
+ if args.password is not None:
82
+ config["password"] = args.password
83
+
84
+ if args.host is not None:
85
+ config["host"] = args.host
86
+
87
+ if args.port is not None:
88
+ config["port"] = args.port
89
+
90
+ if args.debug:
91
+ config["debug"] = True
92
+
93
+ if args.disable_ltr559:
94
+ config["enable_ltr559"] = False
95
+
96
+ if args.log_file is not None:
97
+ config["log_file"] = args.log_file
98
+
99
+ if args.interval is not None:
100
+ config["interval"] = args.interval
101
+
102
+ if args.measurements is not None:
103
+ config["measurements"] = args.measurements
104
+
105
+ if args.temperature_offset is not None:
106
+ config["temperature_offset"] = args.temperature_offset
107
+
108
+ if args.topics is not None:
109
+ config["topics"] = [item.strip() for item in args.topics.split(",") if item.strip()]
110
+
111
+ if args.client_id is not None:
112
+ config["client_id"] = args.client_id
113
+
114
+ if args.tls is not None:
115
+ config["tls"] = args.tls
116
+
117
+ if args.tls_ca_certs is not None:
118
+ config["tls_ca_certs"] = args.tls_ca_certs
119
+
120
+ if args.tls_certfile is not None:
121
+ config["tls_certfile"] = args.tls_certfile
122
+
123
+ if args.tls_keyfile is not None:
124
+ config["tls_keyfile"] = args.tls_keyfile
125
+
126
+ if args.tls_insecure is not None:
127
+ config["tls_insecure"] = args.tls_insecure
128
+
129
+ if args.per_metric_topics is not None:
130
+ config["per_metric_topics"] = args.per_metric_topics
131
+
132
+ if args.ha_discovery is not None:
133
+ config["ha_discovery"] = args.ha_discovery
134
+
135
+ if args.ha_discovery_prefix is not None:
136
+ config["ha_discovery_prefix"] = args.ha_discovery_prefix
137
+
138
+ if args.device_id is not None:
139
+ config["device_id"] = args.device_id
140
+
141
+ # Environment variable fallbacks for secrets and broker config
142
+ if not config.get("password") and "MQTT_PASSWORD" in os.environ:
143
+ config["password"] = os.environ["MQTT_PASSWORD"]
144
+
145
+ if not config.get("username") and "MQTT_USERNAME" in os.environ:
146
+ config["username"] = os.environ["MQTT_USERNAME"]
147
+
148
+ if not config.get("host") and "MQTT_HOST" in os.environ:
149
+ config["host"] = os.environ["MQTT_HOST"]
150
+
151
+ # Validate configuration if requested
152
+ if validate:
153
+ app_config = validate_config(config)
154
+ config = app_config.to_dict()
155
+
156
+ if config.get("debug"):
157
+ debug_config = {k: ("***" if k == "password" and v else v) for k, v in config.items()}
158
+ print(f"config: {debug_config}")
159
+
160
+ return config
@@ -0,0 +1,218 @@
1
+ """Config module."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ from dataclasses import dataclass
7
+ from typing import Any
8
+
9
+ import yaml
10
+
11
+
12
+ @dataclass
13
+ class AppConfig:
14
+ """Validated application configuration."""
15
+
16
+ host: str
17
+ topics: list[str]
18
+ port: int = 1883
19
+ username: str | None = None
20
+ password: str | None = None
21
+ client_id: str | None = None
22
+ qos: int = 1
23
+ retain: bool = True
24
+ debug: bool = False
25
+ log_file: str | None = None
26
+ interval: float = 60.0
27
+ measurements: int = 3
28
+ temperature_offset: float = 0.0
29
+ enable_ltr559: bool = True
30
+ tls: bool = False
31
+ tls_ca_certs: str | None = None
32
+ tls_certfile: str | None = None
33
+ tls_keyfile: str | None = None
34
+ tls_insecure: bool = False
35
+ per_metric_topics: bool = False
36
+ ha_discovery: bool = False
37
+ ha_discovery_prefix: str = "homeassistant"
38
+ device_id: str = "enviroplus"
39
+
40
+ def __post_init__(self) -> None:
41
+ self.validate()
42
+
43
+ def validate(self) -> None:
44
+ """Validate configuration values and raise ValueError on error."""
45
+ if not self.host or not isinstance(self.host, str) or not self.host.strip():
46
+ raise ValueError("Configuration error: 'host' must be a non-empty string.")
47
+
48
+ if not self.topics:
49
+ raise ValueError("Configuration error: 'topics' must be a non-empty list of topic strings.")
50
+
51
+ if isinstance(self.topics, str):
52
+ raise ValueError("Configuration error: 'topics' must be a list of strings, not a single string.")
53
+
54
+ if not all(isinstance(t, str) and t.strip() for t in self.topics):
55
+ raise ValueError("Configuration error: all items in 'topics' must be non-empty strings.")
56
+
57
+ try:
58
+ port_num = int(self.port)
59
+ except (ValueError, TypeError):
60
+ raise ValueError(f"Configuration error: 'port' must be an integer, got {self.port!r}.") from None
61
+
62
+ if not (1 <= port_num <= 65535):
63
+ raise ValueError(f"Configuration error: 'port' must be between 1 and 65535, got {port_num}.")
64
+ self.port = port_num
65
+
66
+ try:
67
+ qos_num = int(self.qos)
68
+ except (ValueError, TypeError):
69
+ raise ValueError(f"Configuration error: 'qos' must be an integer, got {self.qos!r}.") from None
70
+
71
+ if qos_num not in (0, 1, 2):
72
+ raise ValueError(f"Configuration error: 'qos' must be 0, 1, or 2, got {qos_num}.")
73
+ self.qos = qos_num
74
+
75
+ try:
76
+ self.interval = float(self.interval)
77
+ if self.interval <= 0:
78
+ raise ValueError
79
+ except (ValueError, TypeError):
80
+ msg = f"Configuration error: 'interval' must be a positive number, got {self.interval!r}."
81
+ raise ValueError(msg) from None
82
+
83
+ try:
84
+ self.measurements = int(self.measurements)
85
+ if self.measurements <= 0:
86
+ raise ValueError
87
+ except (ValueError, TypeError):
88
+ raise ValueError(
89
+ f"Configuration error: 'measurements' must be a positive integer, got {self.measurements!r}."
90
+ ) from None
91
+
92
+ try:
93
+ self.temperature_offset = float(self.temperature_offset)
94
+ except (ValueError, TypeError):
95
+ raise ValueError(
96
+ f"Configuration error: 'temperature_offset' must be a float, got {self.temperature_offset!r}."
97
+ ) from None
98
+
99
+ def to_dict(self) -> dict[str, Any]:
100
+ """Convert config to dictionary format for backward compatibility."""
101
+ return {
102
+ "host": self.host,
103
+ "topics": self.topics,
104
+ "port": self.port,
105
+ "username": self.username,
106
+ "password": self.password,
107
+ "client_id": self.client_id,
108
+ "qos": self.qos,
109
+ "retain": self.retain,
110
+ "debug": self.debug,
111
+ "log_file": self.log_file,
112
+ "interval": self.interval,
113
+ "measurements": self.measurements,
114
+ "temperature_offset": self.temperature_offset,
115
+ "enable_ltr559": self.enable_ltr559,
116
+ "tls": self.tls,
117
+ "tls_ca_certs": self.tls_ca_certs,
118
+ "tls_certfile": self.tls_certfile,
119
+ "tls_keyfile": self.tls_keyfile,
120
+ "tls_insecure": self.tls_insecure,
121
+ "per_metric_topics": self.per_metric_topics,
122
+ "ha_discovery": self.ha_discovery,
123
+ "ha_discovery_prefix": self.ha_discovery_prefix,
124
+ "device_id": self.device_id,
125
+ }
126
+
127
+
128
+ def get_default_config() -> dict[str, Any]:
129
+ """Return default configuration options."""
130
+ return {
131
+ "debug": False,
132
+ "port": 1883,
133
+ "client_id": None,
134
+ "qos": 1,
135
+ "retain": True,
136
+ "log_file": None,
137
+ "interval": 60.0,
138
+ "measurements": 3,
139
+ "temperature_offset": 0.0,
140
+ "enable_ltr559": True,
141
+ "tls": False,
142
+ "tls_ca_certs": None,
143
+ "tls_certfile": None,
144
+ "tls_keyfile": None,
145
+ "tls_insecure": False,
146
+ "per_metric_topics": False,
147
+ "ha_discovery": False,
148
+ "ha_discovery_prefix": "homeassistant",
149
+ "device_id": "enviroplus",
150
+ }
151
+
152
+
153
+ def parse_config(config_file: str | None = None) -> dict[str, Any]:
154
+ """
155
+ Parse configuration file in YAML format.
156
+
157
+ Args:
158
+ config_file (str, optional): Path to configuration file. If None, returns default config.
159
+
160
+ Returns:
161
+ dict: A dictionary containing parsed configuration values merged with defaults.
162
+
163
+ Raises:
164
+ FileNotFoundError: If the specified config file is not found.
165
+ """
166
+ config = get_default_config()
167
+
168
+ if config_file is not None:
169
+ if not os.path.isfile(config_file):
170
+ raise FileNotFoundError(f"Configuration file '{config_file}' not found.")
171
+
172
+ with open(config_file, encoding="utf-8") as stream:
173
+ loaded = yaml.safe_load(stream)
174
+ if loaded and isinstance(loaded, dict):
175
+ config.update(loaded)
176
+
177
+ for k, v in get_default_config().items():
178
+ config.setdefault(k, v)
179
+
180
+ # Normalize topics if given as comma-separated string in YAML
181
+ if isinstance(config.get("topics"), str):
182
+ config["topics"] = [t.strip() for t in config["topics"].split(",") if t.strip()]
183
+
184
+ return config
185
+
186
+
187
+ def validate_config(config: dict[str, Any]) -> AppConfig:
188
+ """Validate a raw configuration dict and return an AppConfig instance."""
189
+ # Normalize topics if still string
190
+ topics = config.get("topics")
191
+ if isinstance(topics, str):
192
+ topics = [t.strip() for t in topics.split(",") if t.strip()]
193
+
194
+ return AppConfig(
195
+ host=config.get("host", ""),
196
+ topics=topics if isinstance(topics, list) else [],
197
+ port=config.get("port", 1883),
198
+ username=config.get("username"),
199
+ password=config.get("password"),
200
+ client_id=config.get("client_id"),
201
+ qos=int(config.get("qos", 1)),
202
+ retain=bool(config.get("retain", True)),
203
+ debug=bool(config.get("debug", False)),
204
+ log_file=config.get("log_file"),
205
+ interval=config.get("interval", 60.0),
206
+ measurements=config.get("measurements", 3),
207
+ temperature_offset=config.get("temperature_offset", 0.0),
208
+ enable_ltr559=bool(config.get("enable_ltr559", True)),
209
+ tls=bool(config.get("tls", False)),
210
+ tls_ca_certs=config.get("tls_ca_certs"),
211
+ tls_certfile=config.get("tls_certfile"),
212
+ tls_keyfile=config.get("tls_keyfile"),
213
+ tls_insecure=bool(config.get("tls_insecure", False)),
214
+ per_metric_topics=bool(config.get("per_metric_topics", False)),
215
+ ha_discovery=bool(config.get("ha_discovery", False)),
216
+ ha_discovery_prefix=str(config.get("ha_discovery_prefix", "homeassistant")),
217
+ device_id=str(config.get("device_id", "enviroplus")),
218
+ )
@@ -0,0 +1,173 @@
1
+ """Hardware interface and adapter for Enviro+ sensors."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ from typing import NamedTuple, Protocol
7
+
8
+ LOGGER = logging.getLogger(__name__)
9
+
10
+
11
+ class GasReadings(NamedTuple):
12
+ oxidising: float
13
+ reducing: float
14
+ nh3: float
15
+
16
+
17
+ class PMReadings(NamedTuple):
18
+ pm1: float
19
+ pm25: float
20
+ pm10: float
21
+
22
+
23
+ class NoiseReadings(NamedTuple):
24
+ low: float
25
+ mid: float
26
+ high: float
27
+ amp: float
28
+
29
+
30
+ class LightReadings(NamedTuple):
31
+ lux: float
32
+ proximity: int
33
+
34
+
35
+ class HardwareInterface(Protocol):
36
+ """Protocol defining hardware operations for Enviro+ sensors."""
37
+
38
+ def read_temperature(self) -> float: ...
39
+ def read_humidity(self) -> float: ...
40
+ def read_pressure(self) -> float: ...
41
+ def read_noise(self) -> NoiseReadings: ...
42
+ def read_gas(self) -> GasReadings: ...
43
+ def read_pm(self) -> PMReadings: ...
44
+ def read_light(self) -> LightReadings: ...
45
+ def close(self) -> None: ...
46
+
47
+
48
+ class EnviroPlusHardware:
49
+ """Hardware adapter for real Enviro+ hardware."""
50
+
51
+ def __init__(self, bus_id: int = 1) -> None:
52
+ self.bus_id = bus_id
53
+ self._bus = None
54
+ self._bme280 = None
55
+ self._noise = None
56
+ self._gas = None
57
+ self._pms5003 = None
58
+ self._ltr559 = None
59
+
60
+ def get_bme280(self):
61
+ """Lazily initialize BME280 sensor."""
62
+ if self._bme280 is None:
63
+ try:
64
+ from smbus2 import SMBus
65
+ except ImportError:
66
+ from smbus import SMBus # type: ignore[import-not-found]
67
+ from bme280 import BME280
68
+
69
+ self._bus = SMBus(self.bus_id)
70
+ self._bme280 = BME280(i2c_dev=self._bus)
71
+ return self._bme280
72
+
73
+ def get_noise(self):
74
+ """Lazily initialize Noise sensor."""
75
+ if self._noise is None:
76
+ from enviroplus.noise import Noise
77
+
78
+ self._noise = Noise()
79
+ return self._noise
80
+
81
+ def get_gas(self):
82
+ """Lazily initialize Gas sensor."""
83
+ if self._gas is None:
84
+ from enviroplus import gas
85
+
86
+ self._gas = gas
87
+ return self._gas
88
+
89
+ def get_pms5003(self):
90
+ """Lazily initialize PMS5003 particle sensor."""
91
+ if self._pms5003 is None:
92
+ from pms5003 import PMS5003
93
+
94
+ self._pms5003 = PMS5003()
95
+ return self._pms5003
96
+
97
+ def get_ltr559(self):
98
+ """Lazily initialize LTR559 light and proximity sensor."""
99
+ if self._ltr559 is None:
100
+ try:
101
+ from ltr559 import LTR559
102
+
103
+ self._ltr559 = LTR559()
104
+ except Exception as exc:
105
+ LOGGER.debug("LTR559 sensor not available: %s", exc)
106
+ return None
107
+ return self._ltr559
108
+
109
+ def read_temperature(self) -> float:
110
+ """Read temperature in Celsius."""
111
+ return float(self.get_bme280().get_temperature())
112
+
113
+ def read_humidity(self) -> float:
114
+ """Read relative humidity in percent."""
115
+ return float(self.get_bme280().get_humidity())
116
+
117
+ def read_pressure(self) -> float:
118
+ """Read atmospheric pressure in mbar."""
119
+ return float(self.get_bme280().get_pressure())
120
+
121
+ def read_noise(self) -> NoiseReadings:
122
+ """Read noise frequency bands and amplitude."""
123
+ low, mid, high, amp = self.get_noise().get_noise_profile()
124
+ return NoiseReadings(low=float(low), mid=float(mid), high=float(high), amp=float(amp))
125
+
126
+ def read_gas(self) -> GasReadings:
127
+ """Read gas resistances in Ohms."""
128
+ readings = self.get_gas().read_all()
129
+ return GasReadings(
130
+ oxidising=float(readings.oxidising),
131
+ reducing=float(readings.reducing),
132
+ nh3=float(readings.nh3),
133
+ )
134
+
135
+ def read_pm(self) -> PMReadings:
136
+ """Read particulate matter (PM1, PM2.5, PM10)."""
137
+ from pms5003 import ReadTimeoutError
138
+
139
+ pms = self.get_pms5003()
140
+ try:
141
+ raw = pms.read()
142
+ except ReadTimeoutError:
143
+ pms.reset()
144
+ raw = pms.read()
145
+ return PMReadings(
146
+ pm1=float(raw.pm_ug_per_m3(1)),
147
+ pm25=float(raw.pm_ug_per_m3(2.5)),
148
+ pm10=float(raw.pm_ug_per_m3(10)),
149
+ )
150
+
151
+ def read_light(self) -> LightReadings:
152
+ """Read lux and proximity from LTR559."""
153
+ ltr = self.get_ltr559()
154
+ if ltr is None:
155
+ raise RuntimeError("LTR559 sensor not initialized")
156
+ ltr.update_sensor()
157
+ lux = float(ltr.get_lux())
158
+ proximity = int(ltr.get_proximity())
159
+ return LightReadings(lux=lux, proximity=proximity)
160
+
161
+ def close(self) -> None:
162
+ """Release hardware handles."""
163
+ if self._bus is not None and hasattr(self._bus, "close"):
164
+ try:
165
+ self._bus.close()
166
+ except Exception as exc:
167
+ LOGGER.debug("Error closing I2C bus: %s", exc)
168
+ self._bus = None
169
+ self._bme280 = None
170
+ self._noise = None
171
+ self._gas = None
172
+ self._pms5003 = None
173
+ self._ltr559 = None
@@ -0,0 +1,36 @@
1
+ """Logging setup module."""
2
+
3
+ import logging
4
+ import logging.handlers
5
+
6
+
7
+ def setup_logger(
8
+ *,
9
+ debug: bool = False,
10
+ log_file: str | None = None,
11
+ ) -> logging.Logger:
12
+ """Configure root logger with console and optional rotating file handler."""
13
+ root = logging.getLogger()
14
+ formatter = logging.Formatter(
15
+ "%(asctime)s %(process)d %(processName)-10s %(name)-8s %(funcName)-8s %(levelname)-8s %(message)s"
16
+ )
17
+
18
+ # Always attach a console handler so stdout/stderr (and systemd journald) captures logs
19
+ console_handler = logging.StreamHandler()
20
+ console_handler.setFormatter(formatter)
21
+ root.addHandler(console_handler)
22
+
23
+ if debug:
24
+ root.setLevel(logging.DEBUG)
25
+ else:
26
+ root.setLevel(logging.INFO)
27
+
28
+ if log_file:
29
+ try:
30
+ file_handler = logging.handlers.RotatingFileHandler(log_file, "a", maxBytes=3 * 10**6, backupCount=10)
31
+ file_handler.setFormatter(formatter)
32
+ root.addHandler(file_handler)
33
+ except OSError as exc:
34
+ root.warning("Failed to initialize log file '%s': %s. Continuing with console logging.", log_file, exc)
35
+
36
+ return root