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.
- enviroplussensorstomqtt/__init__.py +5 -0
- enviroplussensorstomqtt/args.py +160 -0
- enviroplussensorstomqtt/config.py +218 -0
- enviroplussensorstomqtt/hardware.py +173 -0
- enviroplussensorstomqtt/log_setup.py +36 -0
- enviroplussensorstomqtt/main.py +141 -0
- enviroplussensorstomqtt/mqtt.py +238 -0
- enviroplussensorstomqtt/sd_notify.py +51 -0
- enviroplussensorstomqtt/sensor.py +265 -0
- enviroplussensorstomqtt-0.1.0.dist-info/METADATA +208 -0
- enviroplussensorstomqtt-0.1.0.dist-info/RECORD +15 -0
- enviroplussensorstomqtt-0.1.0.dist-info/WHEEL +5 -0
- enviroplussensorstomqtt-0.1.0.dist-info/entry_points.txt +2 -0
- enviroplussensorstomqtt-0.1.0.dist-info/licenses/LICENSE +21 -0
- enviroplussensorstomqtt-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -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
|