Skip to content

Commit 123a13a

Browse files
authored
✨ Add MQTT/Home Assistant support (#14)
* Add MQTT support for HA * Possibly improve handling without paho-mqtt * Add logging, improve docs * Log the prefix used, tweak values, minify * More logging, debugging For some reason I'm not seeing the status messages coming in, not sure why * Fix config var * decode byte string * Update docs
1 parent c7db35d commit 123a13a

File tree

7 files changed

+212
-10
lines changed

7 files changed

+212
-10
lines changed

README.md

Lines changed: 49 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,57 @@ The core logic comes from [this hackaday article](https://hackaday.io/project/53
55

66
## Setup
77

8-
Note this assumes you are running on a Raspberry Pi running Raspberry Pi OS (Buster)
8+
Note this assumes you are running on a Raspberry Pi running Raspberry Pi OS (Bullseye)
99

1010
1. Install Python 3
1111
2. Install the monitor with `python3 -m pip install co2mini[homekit]` (remove `[homekit]` if you don't use HomeKit)
1212
3. Set up CO2 udev rules by copying `90-co2mini.rules` to `/etc/udev/rules.d/90-co2mini.rules`
1313
4. Set up the service by copying `co2mini.service` to `/etc/systemd/system/co2mini.service`
14-
5. Run `systemctl enable co2mini.service`
14+
5. (Optional) Put a configuration file (see Configuration section below) in `/etc/co2mini.env`
15+
6. Run `systemctl enable co2mini.service`
16+
17+
## Configuration
18+
19+
The `/etc/co2mini.env` file contains the environment variables used to configure co2mini beyond the defaults.
20+
This is mostly necessary when enabling MQTT.
21+
22+
Example:
23+
24+
```bash
25+
MQTT_ENABLED=true
26+
MQTT_BROKER=localhost
27+
```
28+
29+
### MQTT/Home Assistant
30+
31+
The MQTT feature is meant to work with Home Assistant, although nothing bad will happen if you just want to use the MQTT messages directly.
32+
33+
When co2mini starts up, it will send out the discovery message that Home Assistant expects, as well as responding to homeassistant's status when coming online.
34+
Be sure those are enabled in the Home Assistant MQTT integration (usually is enabled by default) if you have any issues.
35+
36+
To configure co2mini, the following environment variables are available:
37+
38+
Variable | Default | Description
39+
------------------------|----------------------|---------------------------------------------------------------------------------------------------------
40+
`NAME` | `co2mini` | This is used for the default display name of the device in Home Assistant
41+
`MQTT_ENABLED` | `False` | Used to enable/disable sending information over MQTT
42+
`MQTT_BROKER` | `localhost` | MQTT Broker hostname
43+
`MQTT_PORT` | `1883` | MQTT broker port number (1883 is the standard MQTT broker port)
44+
`MQTT_USERNAME` | | Username for authenticating to MQTT broker (leave blank if no authentication is needed)
45+
`MQTT_PASSWORD` | | Password for authenticating to MQTT broker (leave blank if no authentication is needed)
46+
`MQTT_DISCOVERY_PREFIX` | `homeassistant` | Prefix for sending MQTT discovery and state messages.
47+
`MQTT_RETAIN_DISCOVERY` | `False` | Flag to enable setting `retain=True` on the discovery messages. You probably don't need this.
48+
`MQTT_OBJECT_ID` | `co2mini_{HOSTNAME}` | Override for setting the `object_id` in Home Assistant. Default builds using the hostname of the device.
49+
50+
### Homekit
51+
52+
If you have the `homekit` dependencies installed, on the first startup you will need to check the logs to get the setup code to integrate with Homekit.
53+
You can find the code using `journalctl -u co2mini.service` or possibly by checking the status with `systemctl status co2mini.service`.
54+
55+
Note also that it's sometimes possible that co2mini will have some errors logged and won't be reporting in Homekit anymore.
56+
If this happens, it seems like the easiest thing to do is to remove the device from your homekit, remove the `accessory.state` file in your home (`rm accessory.state`) and restart `co2mini` (`sudo systemctl restart co2mini.service`) to get a new code to pair.
57+
58+
## Special notes for Dietpi users
59+
60+
- Be sure to install `Python3 pip` as well (ID `130`)
61+
- Make sure the dietpi user is in `plugdev` group (`sudo usermod -aG plugdev dietpi`)

co2mini.service

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ ExecStart=/home/pi/.local/bin/co2mini /dev/co2mini0
1010
Restart=on-failure
1111
RestartSec=3
1212
Environment=PYTHONUNBUFFERED=1
13+
EnvironmentFile=/etc/co2mini.env
1314

1415
[Install]
1516
WantedBy=multi-user.target

co2mini/config.py

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
import platform
2+
3+
from environs import Env
4+
5+
env = Env()
6+
env.read_env()
7+
8+
HOSTNAME = platform.node()
9+
10+
PROMETHEUS_PORT: int = env.int("CO2_PROMETHEUS_PORT", 9999)
11+
NAME: str = env.str("CO2MINI_NAME", "co2mini")
12+
13+
with env.prefixed("MQTT_"):
14+
MQTT_ENABLED: bool = env.bool("ENABLED", False)
15+
MQTT_BROKER: str = env.str("BROKER", "localhost")
16+
MQTT_PORT: str = env.int("PORT", 1883)
17+
MQTT_USERNAME: str = env.str("USERNAME", "")
18+
MQTT_PASSWORD: str = env.str("PASSWORD", "")
19+
MQTT_DISCOVERY_PREFIX: str = env.str("DISCOVERY_PREFIX", "homeassistant")
20+
MQTT_RETAIN_DISCOVERY: bool = env.bool("RETAIN_DISCOVERY", False)
21+
# Object ID needs to be unique
22+
MQTT_OBJECT_ID: str = env.str("OBJECT_ID", f"co2mini_{HOSTNAME}")

co2mini/main.py

Lines changed: 50 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,40 @@
11
#!/usr/bin/env python3
22

33
import logging
4-
import os
54
import sys
5+
from functools import partial
66

77
from prometheus_client import Gauge, start_http_server
88

9-
from . import meter
9+
from . import config, meter
10+
11+
try:
12+
from . import mqtt
13+
except ImportError:
14+
15+
class mqtt:
16+
@staticmethod
17+
def send_co2_value(*args, **kwargs):
18+
pass
19+
20+
@staticmethod
21+
def send_temp_value(*args, **kwargs):
22+
pass
23+
24+
@staticmethod
25+
def get_mqtt_client():
26+
pass
27+
28+
@staticmethod
29+
def start_client(*args, **kwargs):
30+
pass
31+
1032

1133
co2_gauge = Gauge("co2", "CO2 levels in PPM")
1234
temp_gauge = Gauge("temperature", "Temperature in C")
1335

1436
logging.basicConfig(level=logging.INFO)
1537
logger = logging.getLogger(__name__)
16-
PROMETHEUS_PORT = os.getenv("CO2_PROMETHEUS_PORT", 9999)
1738

1839

1940
def co2_callback(sensor, value):
@@ -23,15 +44,36 @@ def co2_callback(sensor, value):
2344
temp_gauge.set(value)
2445

2546

47+
def generate_callback(mqtt_client):
48+
# TODO: Create the callback, support the loop
49+
co2_handlers = [co2_gauge.set]
50+
temp_handlers = [temp_gauge.set]
51+
if mqtt_client:
52+
co2_handlers.append(partial(mqtt.send_co2_value, mqtt_client))
53+
temp_handlers.append(partial(mqtt.send_temp_value, mqtt_client))
54+
55+
def co2_callback(sensor, value):
56+
if sensor == meter.CO2METER_CO2:
57+
for handler in co2_handlers:
58+
handler(value)
59+
elif sensor == meter.CO2METER_TEMP:
60+
for handler in temp_handlers:
61+
handler(value)
62+
63+
return co2_callback
64+
65+
2666
def main():
2767
# TODO: Better CLI handling
2868
device = sys.argv[1] if len(sys.argv) > 1 else "/dev/co2mini0"
2969
logger.info("Starting with device %s", device)
3070

3171
# Expose metrics
32-
start_http_server(PROMETHEUS_PORT)
72+
start_http_server(config.PROMETHEUS_PORT)
73+
74+
mqtt_client = mqtt.get_mqtt_client() if config.MQTT_ENABLED else None
3375

34-
co2meter = meter.CO2Meter(device=device, callback=co2_callback)
76+
co2meter = meter.CO2Meter(device=device, callback=generate_callback(mqtt_client))
3577
co2meter.start()
3678

3779
try:
@@ -42,6 +84,9 @@ def main():
4284
except ImportError:
4385
pass
4486

87+
if mqtt_client:
88+
mqtt.start_client(mqtt_client)
89+
4590
# Ensure thread doesn't just end without cleanup
4691
co2meter.join()
4792

co2mini/mqtt.py

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
import json
2+
import logging
3+
4+
import paho.mqtt.client as mqtt
5+
6+
from . import config
7+
8+
logger = logging.getLogger(__name__)
9+
HA_STATUS_TOPIC = f"{config.MQTT_DISCOVERY_PREFIX}/status"
10+
HA_PREFIX = (
11+
f"{config.MQTT_DISCOVERY_PREFIX}/sensor/{config.HOSTNAME}/{config.MQTT_OBJECT_ID}"
12+
)
13+
HA_TEMP_PREFIX = f"{HA_PREFIX}_temp"
14+
HA_CO2_PREFIX = f"{HA_PREFIX}_co2"
15+
EXPIRE_AFTER_SECONDS = 300
16+
17+
18+
def send_discovery_message(client: mqtt.Client):
19+
logger.info(f"Sending discovery message to mqtt (prefix: {HA_PREFIX})")
20+
device = {"ids": [config.HOSTNAME], "name": config.NAME}
21+
temp_config = {
22+
"dev_cla": "temperature",
23+
"expire_after": EXPIRE_AFTER_SECONDS,
24+
"stat_t": f"{HA_TEMP_PREFIX}/state",
25+
"unit_of_meas": "°C",
26+
"uniq_id": f"{config.MQTT_OBJECT_ID}_T",
27+
"dev": device,
28+
}
29+
co2_config = {
30+
"dev_cla": "carbon_dioxide",
31+
"expire_after": EXPIRE_AFTER_SECONDS,
32+
"stat_t": f"{HA_CO2_PREFIX}/state",
33+
"unit_of_meas": "ppm",
34+
"uniq_id": f"{config.MQTT_OBJECT_ID}_CO2",
35+
"dev": device,
36+
}
37+
client.publish(
38+
f"{HA_TEMP_PREFIX}/config",
39+
json.dumps(temp_config),
40+
retain=config.MQTT_RETAIN_DISCOVERY,
41+
)
42+
client.publish(
43+
f"{HA_CO2_PREFIX}/config",
44+
json.dumps(co2_config),
45+
retain=config.MQTT_RETAIN_DISCOVERY,
46+
)
47+
48+
49+
def on_connect(client: mqtt.Client, *args, **kwargs):
50+
send_discovery_message(client)
51+
client.subscribe(HA_STATUS_TOPIC, qos=1)
52+
53+
54+
def handle_homeassistant_status(client: mqtt.Client, userdata, message):
55+
status = message.payload.decode()
56+
logger.info(f"Got homeassistant status: {status}")
57+
if status == "online":
58+
send_discovery_message(client)
59+
60+
61+
def get_mqtt_client() -> mqtt.Client:
62+
client = mqtt.Client()
63+
client.on_connect = on_connect
64+
client.message_callback_add(HA_STATUS_TOPIC, handle_homeassistant_status)
65+
if config.MQTT_USERNAME:
66+
client.username_pw_set(config.MQTT_USERNAME, config.MQTT_PASSWORD or None)
67+
return client
68+
69+
70+
def send_co2_value(client: mqtt.Client, value: float):
71+
client.publish(f"{HA_CO2_PREFIX}/state", value, retain=True)
72+
73+
74+
def send_temp_value(client: mqtt.Client, value: float):
75+
client.publish(f"{HA_TEMP_PREFIX}/state", value, retain=True)
76+
77+
78+
def start_client(client: mqtt.Client):
79+
"""Blocking call to connect to the MQTT broker and loop forever"""
80+
logger.info(f"Connecting to {config.MQTT_BROKER}")
81+
client.connect(config.MQTT_BROKER)
82+
client.loop_forever(retry_first_connection=False)
83+
logger.error("MQTT Failure")

pyproject.toml

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,14 +24,16 @@ classifiers = [
2424
"Operating System :: OS Independent",
2525
"Development Status :: 3 - Alpha",
2626
]
27-
dependencies = ["prometheus_client"]
27+
dependencies = ["prometheus_client", "environs"]
2828
dynamic = ["version"]
2929

3030
[project.urls]
3131
repository = "https://github.com/jerr0328/co2-mini"
3232

3333
[project.optional-dependencies]
34-
homekit = ["HAP-python"]
34+
homekit = ["hap-python"]
35+
mqtt = ["paho-mqtt"]
36+
all = ["co2mini[homekit,mqtt]"]
3537

3638
[project.scripts]
3739
co2mini = "co2mini.main:main"

requirements.txt

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,4 @@
1-
HAP-Python
1+
environs
2+
hap-python
3+
paho-mqtt
24
prometheus_client

0 commit comments

Comments
 (0)