Over-engineering a home air quality dashboard

Illustration by Audrey Lee

Air—it’s invisible, I can’t see it, but I feel effects of it in so many ways, temperature, humidity, gas composition, but I lacked sensors to measure it.

Prerequisite Software

This project depends on a few software components. This post will assume that you have these set up already.

  1. HomeAssistant – Home Automation central system manages sensor lifecycle
  2. MQTT Broker – Message broker for ingesting sensor data
  3. InfluxDB – Time series database for short and medium-term data retention
  4. Grafana (Optional) – Used for building complex visualization dashboards
  5. Node Red – Graphical programming environment that I use to process sensor data
    1. Node Red Home Assistant Addon – Needs to be installed to create HA entities
  6. ESPHome – Required for DIY built sensors

Architecture Diagram

My system architecture for collecting and storing environmental sensor data looks like this:

A Quick Intro to AQI Metrics

AQI stands for Air Quality Index. It doesn’t actually represent a singular consistent metric and different organizations and governments calculate their indexes slightly different. Popular ones include the EPA AQI, AQandU

More information on the different types of indexes can be found here.

Air quality sensors don’t directly calculate the AQI and instead track low level metrics such as PM1.0, PM2.5, PM10, etc. PM2.5 corresponds to the amount of Particulate Matter that is 2.5μm or smaller.

Courtesy of the CA Air Resources Board

PM10 includes dust, pollen, and mold, whereas PM2.5 starts to identify soot and smoke. Note that there are many particles that are smaller that can’t be easily measured with most home sensors. Some sensors (like the custom sensor I have) may end up estimating PM1.0 counts from the PM2.5 counts.

Given this, we’ll need the formulas that each one uses to be able to calculate the AQI indexes.

Custom ESP Based Sensor

Photo of my DIY AQI and CO2 monitoring device

I also created my own sensor using off the shelf components.

Ingredients:

CountItemCost
1Sensiron SCD-30 NDIR CO2 Sensor – There are a few different CO2 sensors with varying accuracy on Adafruit. The NDIR CO2 sensor uses a laser spectrometer to measure how CO2 gas particles absorb the light, whereas some other sensors just estimate the compositionUS$58.95
1Plantower PM2.5 SensorUS$44.95
1ESP8266 FeatherUS$19.95
1STEMMA QT / Qwiic JST SH 4-pin CableUS$0.95
1JST SH 4-pin to Male Headers CableUS$0.95
TotalUS$125.75

ESPHome

I use ESPHome to build and update the firmware running on the controller. Originally, I wrote the code myself, but ESPHome provides all the glue code needed to pull data from i2c devices and publish it to MQTT.

secrets.yaml

# Your Wi-Fi SSID and password wifi_ssid: "SSID" wifi_password: "PASSWORD" ota: "RANDOMSTRING"
Code language: PHP (php)
esphome: name: airquality platform: ESP8266 board: huzzah # Enable logging logger: ota: password: !secret ota wifi: ssid: !secret wifi_ssid password: !secret wifi_password i2c: sda: SDA scl: SCL scan: true mqtt: broker: mqtt.home.ajacqu.es sensor: - platform: pmsa003i pm_1_0: name: "PM1.0" pm_2_5: name: "PM2.5" pm_10_0: name: "PM10.0" pmc_0_3: name: "PMC <0.3µm" pmc_0_5: name: "PMC <0.5µm" pmc_1_0: name: "PMC <1µm" pmc_2_5: name: "PMC <2.5µm" pmc_5_0: name: "PMC <5µm" pmc_10_0: name: "PMC <10µm" update_interval: 60s - platform: scd30 co2: name: "CO2" accuracy_decimals: 1 temperature: name: "Temperature" accuracy_decimals: 2 humidity: name: "Humidity" accuracy_decimals: 1 # The below number is based on the average air pressure for my # area in mBar divided by 1000 (1.009 == 1009mBar) # If you don't know what this is, then disable this. ambient_pressure_compensation: '1.009' temperature_offset: 1.5 address: 0x61 update_interval: 60s
Code language: YAML (yaml)

Accuracy and Calibration

One of the issues I’ve noticed with the CO2 sensor is that it seems to show sensor values that are incorrect–below the average outdoor CO2 ppm. The NDIR sensor I have needs to be recalibrated periodically to know how the spectrometer measurements correlate to a specific PPM. The Sensiron SCD-30 data sheet specifies an accuracy of ±30ppm from 400-10k ppm. The auto calibration mode assumes that the sensor is periodically exposed to outdoor fresh air and assumes that outdoor ppm is 400ppm.

ASC assumes that the lowest CO2 concentration the
SCD30 is exposed to corresponds to 400 ppm. The sensor
estimates the most likely reading corresponding to this
background level and identifies this as 400ppm.

SCD32 Field Calibration Guide (pdf)

Due to global CO2 emissions, the average outdoor CO2 concentration is slowly increasing over the years, so this will sensor will slowly diverge:

From Climate.gov. Average CO2 concentration as measured from Mauna Loa Observatory in Hawaii

In addition, the ambient air pressure influences sensor readings and this changes throughout the day due to changing weather conditions.

Air pressure fluctuations over a day

Thus, we have multiple variables that will all cause drift over time:

  • Sensor drift due to spectrometer sensor drifts
  • Global CO2 concentration increasing causing underestimation of CO2 ppm over years
  • Ambient pressure fluctuations due to changing weather
  • Not exposing the sensor to fresh air when auto calibration is enabled

There’s only so much I can do to ensure accuracy of this data. I set an average air pressure to compensate and open the window once or twice a week to ensure a reference value.

PurpleAir Indoor Sensor

Before I decided to purchase an air quality sensor, I looked up some independent reviews of the accuracy of different sensors. The US EPA released a report showing that the PurpleAir did reasonably well for the price point, so I bought one to test it out.

Manufacturer Detail Page

Measures:

  • Air Quality Index
  • Air Pressure
  • Temperature
  • Humidity

PurpleAir sensors can publish data directly to the PurpleAir Map, but I want to export this data to Prometheus or InfluxDB. One way is to use an already built Purple Prometheus Exporter (purpleprom) which fetches from Purple’s API, calculates several AQI metrics, then exposes them to a Prometheus scraper.

This will work, however this depends on their API called over the Internet and only gets the data into Prometheus. My home lab tries to avoid dependencies on Internet services and use local devices where possible.

Luckily, after connecting to my Wi-Fi network, the devices exposes an HTTP endpoint with all sensor data at the URL: http://{ip}:80/json. The following shows an example response (Interesting values are highlighted):

{ "SensorId": "01:23:45:67:89:ab", "DateTime": "2022/07/26T05:18:38z", "Geo": "PurpleAir-1234", "Mem": 17552, "memfrag": 18, "memfb": 14464, "memcs": 936, "Id": 8436, "lat": 47.00000, "lon": -122.00000, "Adc": 0.02, "loggingrate": 15, "place": "inside", "version": "7.00", "uptime": 253112, "rssi": -72, "period": 119, "httpsuccess": 8501, "httpsends": 8503, "hardwareversion": "2.0", "hardwarediscovered": "2.0+BME280+PMSX003-A", "current_temp_f": 87, "current_humidity": 33, "current_dewpoint_f": 54, "pressure": 1003.65, "p25aqic": "rgb(174,246,0)", "pm2.5_aqi": 44, "pm1_0_cf_1": 5.4, "p_0_3_um": 1215.85, "pm2_5_cf_1": 10.65, "p_0_5_um": 375.31, "pm10_0_cf_1": 10.96, "p_1_0_um": 74.28, "pm1_0_atm": 5.4, "p_2_5_um": 9.75, "pm2_5_atm": 10.65, "p_5_0_um": 0.58, "pm10_0_atm": 10.96, "p_10_0_um": 0, "pa_latency": 177, // ... Some more }
Code language: JSON / JSON with Comments (json)

These fields provide the relevant information to be able to calculate the AQI.

Processing Sensor Data

I now have two different sensors that are ready to provide data. My custom sensor is publishing data through MQTT (and some data is already available in Home Assistant) and the PurpleAir sensor is on the Wi-Fi network and ready. Feel free to skip parts if you have only sensor but not the other.

PurpleAir Sensor

Every minute, my Node Red flow calls the HTTP endpoint on the sensor to grab the latest sensor values, calculates the AQI from the PM2.5 metric, then populates several sensors into Home Assistant.

Here’s the Node Red flow for above. Copy and paste this into the Node Red UI to use it as a template.

[{"id":"96894d899bdea100","type":"change","z":"e3a310770f878b93","name":"","rules":[{"t":"set","p":"sensor_name","pt":"msg","to":"living_room","tot":"str"},{"t":"set","p":"pm25","pt":"msg","to":"payload.pm2_5_cf_1","tot":"msg"}],"action":"","property":"","from":"","to":"","reg":false,"x":700,"y":700,"wires":[["23819d9cb67c9132","f024a8c76b1f9ce0","c121d65dd0f7dc45","f12427f26b727dc9","98a883db43c85b0f","72c9117f6ebac15d"]]},{"id":"9e79ed78dd67fc08","type":"switch","z":"e3a310770f878b93","name":"If error","property":"error","propertyType":"msg","rules":[{"t":"null"},{"t":"nnull"}],"checkall":"true","repair":false,"outputs":2,"x":530,"y":700,"wires":[["96894d899bdea100"],[]]},{"id":"23819d9cb67c9132","type":"switch","z":"e3a310770f878b93","name":"> 0","property":"pm25","propertyType":"msg","rules":[{"t":"gte","v":"0","vt":"num"}],"checkall":"true","repair":false,"outputs":1,"x":890,"y":560,"wires":[["997b6a53a7c7857a"]]},{"id":"f024a8c76b1f9ce0","type":"function","z":"e3a310770f878b93","name":"Normalize Temperature","func":"msg.payload = msg.payload.current_temp_f - 8;\n\nreturn msg;","outputs":1,"noerr":0,"initialize":"","finalize":"","libs":[],"x":1090,"y":700,"wires":[["24eaa8400b7264e6"]]},{"id":"997b6a53a7c7857a","type":"smooth","z":"e3a310770f878b93","name":"10 Minute Average","property":"pm25","action":"mean","count":"10","round":"","mult":"single","reduce":false,"x":1070,"y":560,"wires":[["e2790ee924377bb6"]]},{"id":"3ec53bbca46d0c79","type":"http request","z":"e3a310770f878b93","name":"","method":"GET","ret":"obj","paytoqs":"ignore","url":"http://192.168.1.45/json","tls":"","persist":false,"proxy":"","authType":"","senderr":false,"x":370,"y":700,"wires":[["9e79ed78dd67fc08"]]},{"id":"c121d65dd0f7dc45","type":"switch","z":"e3a310770f878b93","name":"> 0","property":"payload.pressure","propertyType":"msg","rules":[{"t":"gt","v":"0","vt":"num"}],"checkall":"true","repair":false,"outputs":1,"x":1030,"y":740,"wires":[["3d98f8971f231416"]]},{"id":"feea3b65bc43902a","type":"ha-entity","z":"e3a310770f878b93","name":"Living Room PurpleAir Humidity","server":"434f6479916be393","version":2,"debugenabled":false,"outputs":1,"entityType":"sensor","config":[{"property":"name","value":"Living Room PurpleAir Humidity"},{"property":"device_class","value":"humidity"},{"property":"icon","value":"mdi:water-percent"},{"property":"unit_of_measurement","value":"%"},{"property":"state_class","value":"measurement"},{"property":"last_reset","value":""}],"state":"payload.current_humidity","stateType":"msg","attributes":[],"resend":true,"outputLocation":"payload","outputLocationType":"none","inputOverride":"allow","outputOnStateChange":false,"outputPayload":"","outputPayloadType":"str","x":1510,"y":780,"wires":[[]]},{"id":"24eaa8400b7264e6","type":"ha-entity","z":"e3a310770f878b93","name":"Living Room PurpleAir Temperature","server":"434f6479916be393","version":2,"debugenabled":false,"outputs":1,"entityType":"sensor","config":[{"property":"name","value":"Living Room PurpleAir Temperature"},{"property":"device_class","value":"temperature"},{"property":"icon","value":""},{"property":"unit_of_measurement","value":"°F"},{"property":"state_class","value":"measurement"},{"property":"last_reset","value":""}],"state":"payload","stateType":"msg","attributes":[],"resend":true,"outputLocation":"payload","outputLocationType":"none","inputOverride":"allow","outputOnStateChange":false,"outputPayload":"","outputPayloadType":"str","x":1520,"y":700,"wires":[[]]},{"id":"b96470e4363c8622","type":"inject","z":"e3a310770f878b93","name":"every minute","props":[{"p":"payload"},{"p":"topic","vt":"str"}],"repeat":"60","crontab":"","once":false,"onceDelay":0.1,"topic":"","payload":"","payloadType":"date","x":180,"y":700,"wires":[["3ec53bbca46d0c79"]]},{"id":"5bd2e57458ace436","type":"ha-entity","z":"e3a310770f878b93","name":"Living Room PurpleAir 1m iAQI","server":"434f6479916be393","version":2,"debugenabled":false,"outputs":1,"entityType":"sensor","config":[{"property":"name","value":""},{"property":"device_class","value":"sensor"},{"property":"icon","value":"mdi:blur"},{"property":"unit_of_measurement","value":"aqi"},{"property":"state_class","value":"measurement"},{"property":"last_reset","value":""}],"state":"iaqi.epa","stateType":"msg","attributes":[{"property":"type","value":"iaqi","valueType":"str"}],"resend":true,"outputLocation":"payload","outputLocationType":"none","inputOverride":"allow","outputOnStateChange":false,"outputPayload":"","outputPayloadType":"str","x":1510,"y":660,"wires":[[]]},{"id":"68b9c0de9a150c81","type":"ha-entity","z":"e3a310770f878b93","name":"Living Room PurpleAir 10m EPA","server":"434f6479916be393","version":2,"debugenabled":false,"outputs":1,"entityType":"sensor","config":[{"property":"name","value":""},{"property":"device_class","value":"sensor"},{"property":"icon","value":"mdi:blur"},{"property":"unit_of_measurement","value":"aqi"},{"property":"state_class","value":"measurement"},{"property":"last_reset","value":""}],"state":"iaqi.epa","stateType":"msg","attributes":[],"resend":true,"outputLocation":"payload","outputLocationType":"none","inputOverride":"allow","outputOnStateChange":false,"outputPayload":"","outputPayloadType":"str","x":1530,"y":520,"wires":[[]]},{"id":"3d98f8971f231416","type":"ha-entity","z":"e3a310770f878b93","name":"Living Room PurpleAir Air Pressure","server":"434f6479916be393","version":2,"debugenabled":false,"outputs":1,"entityType":"sensor","config":[{"property":"name","value":"Living Room PurpleAir Temperature"},{"property":"device_class","value":"temperature"},{"property":"icon","value":""},{"property":"unit_of_measurement","value":"mBar"},{"property":"state_class","value":"measurement"},{"property":"last_reset","value":""}],"state":"payload.pressure","stateType":"msg","attributes":[],"resend":true,"outputLocation":"payload","outputLocationType":"none","inputOverride":"allow","outputOnStateChange":false,"outputPayload":"","outputPayloadType":"str","x":1520,"y":740,"wires":[[]]},{"id":"e2790ee924377bb6","type":"link call","z":"e3a310770f878b93","name":"","links":["cf070f0e806b1534"],"linkType":"static","timeout":"30","x":1280,"y":560,"wires":[["68b9c0de9a150c81","4920fa785578a3ea","1a141e63173f8846"]]},{"id":"98a883db43c85b0f","type":"link call","z":"e3a310770f878b93","name":"","links":["cf070f0e806b1534"],"linkType":"static","timeout":"30","x":1060,"y":660,"wires":[["5bd2e57458ace436"]]},{"id":"4920fa785578a3ea","type":"ha-entity","z":"e3a310770f878b93","name":"Living Room PurpleAir 10m AQandU","server":"434f6479916be393","version":2,"debugenabled":false,"outputs":1,"entityType":"sensor","config":[{"property":"name","value":""},{"property":"device_class","value":"sensor"},{"property":"icon","value":"mdi:blur"},{"property":"unit_of_measurement","value":"aqi"},{"property":"state_class","value":"measurement"},{"property":"last_reset","value":""}],"state":"iaqi.aqandu","stateType":"msg","attributes":[],"resend":true,"outputLocation":"payload","outputLocationType":"none","inputOverride":"allow","outputOnStateChange":false,"outputPayload":"","outputPayloadType":"str","x":1550,"y":560,"wires":[[]]},{"id":"1a141e63173f8846","type":"ha-entity","z":"e3a310770f878b93","name":"Living Room PurpleAir 10m LRAPA","server":"434f6479916be393","version":2,"debugenabled":false,"outputs":1,"entityType":"sensor","config":[{"property":"name","value":""},{"property":"device_class","value":"sensor"},{"property":"icon","value":"mdi:blur"},{"property":"unit_of_measurement","value":"aqi"},{"property":"state_class","value":"measurement"},{"property":"last_reset","value":""}],"state":"iaqi.lrapa","stateType":"msg","attributes":[],"resend":true,"outputLocation":"payload","outputLocationType":"none","inputOverride":"allow","outputOnStateChange":false,"outputPayload":"","outputPayloadType":"str","x":1540,"y":600,"wires":[[]]},{"id":"72c9117f6ebac15d","type":"switch","z":"e3a310770f878b93","name":"> 0","property":"payload.val","propertyType":"msg","rules":[{"t":"gt","v":"0","vt":"num"}],"checkall":"true","repair":false,"outputs":1,"x":1030,"y":840,"wires":[["f7b6ecb71a65f4d6"]]},{"id":"f7b6ecb71a65f4d6","type":"ha-entity","z":"e3a310770f878b93","name":"Living Room PurpleAir Pressure","server":"434f6479916be393","version":2,"debugenabled":false,"outputs":1,"entityType":"sensor","config":[{"property":"name","value":"Living Room PurpleAir Pressure"},{"property":"device_class","value":"pressure"},{"property":"icon","value":""},{"property":"unit_of_measurement","value":"mbar"},{"property":"state_class","value":"measurement"},{"property":"last_reset","value":""}],"state":"payload","stateType":"msg","attributes":[],"resend":true,"outputLocation":"payload","outputLocationType":"none","inputOverride":"allow","outputOnStateChange":false,"outputPayload":"","outputPayloadType":"str","x":1510,"y":840,"wires":[[]]},{"id":"f12427f26b727dc9","type":"junction","z":"e3a310770f878b93","x":980,"y":780,"wires":[["feea3b65bc43902a"]]},{"id":"434f6479916be393","type":"server","name":"Home Assistant","version":4,"addon":false,"rejectUnauthorizedCerts":true,"ha_boolean":"y|yes|true|on|home|open","connectionDelay":true,"cacheJson":true,"heartbeat":false,"heartbeatInterval":"30","areaSelector":"friendlyName","deviceSelector":"friendlyName","entitySelector":"friendlyName","statusSeparator":"at: ","statusYear":"hidden","statusMonth":"short","statusDay":"numeric","statusHourCycle":"h23","statusTimeFormat":"h:m"}]

Custom Sensor

I configured ESPHome to publish raw sensor data to MQTT and HomeAssistant is configured to also connect to the broker and create entities. When I turned on my sensor, I get the following sensors:

The only thing I’m missing is the IAQI metrics. Using the same function node from before, this is easy:

And the JSON for Node Red (copy and paste into the UI):

[{"id":"2ff1b23a8ce77d67","type":"change","z":"e3a310770f878b93","name":"Parse","rules":[{"t":"set","p":"pm25","pt":"msg","to":"$number(payload)","tot":"jsonata"},{"t":"delete","p":"payload","pt":"msg"}],"action":"","property":"","from":"","to":"","reg":false,"x":750,"y":200,"wires":[["56b0acbdbb335737"]]},{"id":"38c0c4b4a2270338","type":"mqtt in","z":"e3a310770f878b93","name":"","topic":"airquality/sensor/pm25/state","qos":"2","datatype":"auto","broker":"5301212c31515363","nl":false,"rap":true,"rh":"2","inputs":0,"x":500,"y":200,"wires":[["2ff1b23a8ce77d67"]]},{"id":"56b0acbdbb335737","type":"smooth","z":"e3a310770f878b93","name":"10 Minute Average","property":"pm25","action":"mean","count":"10","round":"","mult":"single","reduce":false,"x":930,"y":200,"wires":[["236f73aaa854a377"]]},{"id":"cf070f0e806b1534","type":"link in","z":"e3a310770f878b93","name":"Compute AQI","links":[],"x":525,"y":280,"wires":[["69226d4949759a8c"]]},{"id":"696e5e7297fd8db8","type":"link out","z":"e3a310770f878b93","name":"link out 1","mode":"return","links":[],"x":835,"y":280,"wires":[]},{"id":"69226d4949759a8c","type":"function","z":"e3a310770f878b93","name":"Compute AQI","func":"// https://github.com/hrbonz/python-aqi/blob/master/aqi/algos/epa.py\nconst data = {\n    'aqi': [\n            [0, 50],\n            [51, 100],\n            [101, 150],\n            [151, 200],\n            [201, 300],\n            [301, 400],\n            [401, 500]\n    ],\n    'bp': {\n        POLLUTANT_PM25: [\n            [0.0, 12.0],\n            [12.1, 35.4],\n            [35.5, 55.4],\n            [55.5, 150.4],\n            [150.5, 250.4],\n            [250.5, 350.4],\n            [350.5, 500.4],\n        ]\n    }\n}\n\nfunction convertToIaqi(value) {\n    if (value < 0) {\n        throw new Error(`PM2.5 can't be negative: ${value}`);\n    }\n    const adjValue = Math.floor(value * 10) / 10;\n    const breakpoints = data.bp.POLLUTANT_PM25;\n    var bplo = 0;\n    var bphi = 0;\n    var index = 0;\n    for (const bp of breakpoints) {\n        if (adjValue >= bp[0] && adjValue <= bp[1]) {\n            bplo = bp[0];\n            bphi = bp[1];\n            break;\n        }\n        index++;\n    }\n    \n    const aqival = data.aqi[index];\n    if (!aqival) {\n        throw new Error(`Can't calculate AQI for '${value}' index out of range: ${index}.`);\n    }\n    //return index;\n    const aqiValue = (aqival[1] - aqival[0]) / (bphi - bplo) * (adjValue - bplo) + aqival[0];\n    \n    return Math.round(aqiValue);\n}\n\nmsg.iaqi = {\n    epa: convertToIaqi(msg.pm25),\n    aqandu: convertToIaqi(0.778 * msg.pm25 + 2.65),\n    lrapa: convertToIaqi(Math.max(0, 0.5 * msg.pm25 - 0.66))\n};\n\nreturn msg;","outputs":1,"noerr":0,"initialize":"","finalize":"","libs":[],"x":680,"y":280,"wires":[["696e5e7297fd8db8"]]},{"id":"236f73aaa854a377","type":"link call","z":"e3a310770f878b93","name":"","links":["cf070f0e806b1534"],"linkType":"static","timeout":"30","x":1140,"y":200,"wires":[["fe6de37406c56ba9","1e55c8beb4c439a0","bd08b2cd883eb613"]]},{"id":"1e55c8beb4c439a0","type":"ha-entity","z":"e3a310770f878b93","name":"Bedroom 10m AQandU","server":"434f6479916be393","version":2,"debugenabled":false,"outputs":1,"entityType":"sensor","config":[{"property":"name","value":"Bedroom 10m AQandU"},{"property":"device_class","value":"sensor"},{"property":"icon","value":"mdi:blur"},{"property":"unit_of_measurement","value":"aqi"},{"property":"state_class","value":"measurement"},{"property":"last_reset","value":""}],"state":"iaqi.aqandu","stateType":"msg","attributes":[],"resend":true,"outputLocation":"payload","outputLocationType":"none","inputOverride":"allow","outputOnStateChange":false,"outputPayload":"","outputPayloadType":"str","x":1370,"y":200,"wires":[[]]},{"id":"bd08b2cd883eb613","type":"ha-entity","z":"e3a310770f878b93","name":"Bedroom 10m LRAPA","server":"434f6479916be393","version":2,"debugenabled":false,"outputs":1,"entityType":"sensor","config":[{"property":"name","value":"Bedroom 10m LRAPA"},{"property":"device_class","value":"sensor"},{"property":"icon","value":"mdi:blur"},{"property":"unit_of_measurement","value":"aqi"},{"property":"state_class","value":"measurement"},{"property":"last_reset","value":""}],"state":"iaqi.lrapa","stateType":"msg","attributes":[],"resend":true,"outputLocation":"payload","outputLocationType":"none","inputOverride":"allow","outputOnStateChange":false,"outputPayload":"","outputPayloadType":"str","x":1360,"y":240,"wires":[[]]},{"id":"5301212c31515363","type":"mqtt-broker","name":"","broker":"mqtt-headless.smarthome.svc.cluster.local.","port":"1883","tls":"","clientid":"","autoConnect":true,"usetls":false,"protocolVersion":"5","keepalive":"60","cleansession":true,"birthTopic":"","birthQos":"0","birthPayload":"","birthMsg":{},"closeTopic":"","closeQos":"0","closePayload":"","closeMsg":{},"willTopic":"","willQos":"0","willPayload":"","willMsg":{},"sessionExpiry":""},{"id":"434f6479916be393","type":"server","name":"Home Assistant","version":4,"addon":false,"rejectUnauthorizedCerts":true,"ha_boolean":"y|yes|true|on|home|open","connectionDelay":true,"cacheJson":true,"heartbeat":false,"heartbeatInterval":"30","areaSelector":"friendlyName","deviceSelector":"friendlyName","entitySelector":"friendlyName","statusSeparator":"at: ","statusYear":"hidden","statusMonth":"short","statusDay":"numeric","statusHourCycle":"h23","statusTimeFormat":"h:m"}]

Publishing to InfluxDB

HomeAssistant stores sensor data in the Recorder, which by default uses SQLite, but can be changed to use any SQL database like Postgres or MySQL. These aren’t efficient at storing large amounts of time series data, so instead I store time series data long-term in InfluxDB using the HA Integration.

HomeAssistant by default publishes all entities and all attributes for each state to InfluxDB. This results in *a lot* of useless data being stored and will quickly consume a large amount of disk space. To prevent this, I will explicitly whitelist just the sensor data and ignore any attributes that aren’t useful.

influxdb: host: influxdb-influxdb2.datastore.svc.cluster.local. port: 8086 ssl: false api_version: 2 token: '<strong>token</strong>' bucket: homeassistant organization: influxdata component_config_domain: sensor: ignore_attributes: - attribution - device_class - state_class - last_reset - integration - description - unit_of_measurement - type include: domain: - sensor
Code language: YAML (yaml)

Grafana

Grafana provides the ability to build richer dashboards compared to HomeAssistant. I created an Air Quality monitoring dashboard that fetches data from InfluxDB:

Grafana Dashboard JSON. To import, go to Grafana > Dashboards > Import.

{"__inputs":[{"name":"DS_INFLUXDB","label":"InfluxDB","description":"","type":"datasource","pluginId":"influxdb","pluginName":"InfluxDB"}],"__elements":{},"__requires":[{"type":"panel","id":"gauge","name":"Gauge","version":""},{"type":"grafana","id":"grafana","name":"Grafana","version":"9.0.5"},{"type":"datasource","id":"influxdb","name":"InfluxDB","version":"1.0.0"},{"type":"panel","id":"state-timeline","name":"State timeline","version":""},{"type":"panel","id":"timeseries","name":"Time series","version":""}],"annotations":{"list":[{"builtIn":1,"datasource":{"type":"datasource","uid":"grafana"},"enable":true,"hide":true,"iconColor":"rgba(0, 211, 255, 1)","name":"Annotations & Alerts","target":{"limit":100,"matchAny":false,"tags":[],"type":"dashboard"},"type":"dashboard"}]},"editable":true,"fiscalYearStartMonth":0,"graphTooltip":0,"id":null,"links":[],"liveNow":false,"panels":[{"collapsed":false,"datasource":{"type":"prometheus","uid":"PBFA97CFB590B2093"},"gridPos":{"h":1,"w":24,"x":0,"y":0},"id":18,"panels":[],"title":"Current AQI","type":"row"},{"datasource":{"type":"influxdb","uid":"${DS_INFLUXDB}"},"fieldConfig":{"defaults":{"color":{"mode":"thresholds"},"mappings":[],"max":500,"min":0,"thresholds":{"mode":"absolute","steps":[{"color":"#009966","value":null},{"color":"#ffde33","value":50},{"color":"#ff9933","value":100},{"color":"#cc0033","value":150},{"color":"#660099","value":200},{"color":"#7e0023","value":300}]}},"overrides":[]},"gridPos":{"h":5,"w":3,"x":0,"y":1},"id":19,"options":{"orientation":"auto","reduceOptions":{"calcs":["lastNotNull"],"fields":"","values":false},"showThresholdLabels":false,"showThresholdMarkers":true,"text":{}},"pluginVersion":"9.0.5","targets":[{"datasource":{"type":"influxdb","uid":"${DS_INFLUXDB}"},"query":"import \"strings\"\r\n\r\nfrom(bucket: \"homeassistant\")\r\n  |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\r\n  |> filter(fn: (r) => r[\"_measurement\"] == \"aqi\")\r\n  |> filter(fn: (r) => r[\"_field\"] == \"value\")\r\n  |> filter(fn: (r) => strings.hasSuffix(v: r[\"entity_id\"], suffix: \"_iaqi\"))\r\n  |> last()\r\n  |> keep(columns: [\"_value\"])\r\n  |> mean()","refId":"A"}],"title":"Current PM 2.5 AQI","type":"gauge"},{"datasource":{"type":"influxdb","uid":"${DS_INFLUXDB}"},"fieldConfig":{"defaults":{"mappings":[],"max":500,"min":0,"thresholds":{"mode":"absolute","steps":[{"color":"#009966","value":null},{"color":"#ffde33","value":50},{"color":"#ff9933","value":100},{"color":"#cc0033","value":150},{"color":"#660099","value":200},{"color":"#7e0023","value":300}]}},"overrides":[]},"gridPos":{"h":5,"w":3,"x":3,"y":1},"id":20,"options":{"orientation":"auto","reduceOptions":{"calcs":["mean"],"fields":"","values":false},"showThresholdLabels":false,"showThresholdMarkers":true,"text":{}},"pluginVersion":"9.0.5","targets":[{"datasource":{"type":"influxdb","uid":"${DS_INFLUXDB}"},"query":"import \"strings\"\r\n\r\nfrom(bucket: \"homeassistant\")\r\n  |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\r\n  |> filter(fn: (r) => r[\"_measurement\"] == \"aqi\")\r\n  |> filter(fn: (r) => r[\"_field\"] == \"value\")\r\n  |> filter(fn: (r) => strings.hasSuffix(v: r[\"entity_id\"], suffix: \"_aqandu\"))\r\n  |> last()\r\n  |> keep(columns: [\"_value\"])\r\n  |> mean()","refId":"A"}],"title":"Current PM 2.5 AQI (AQandU)","type":"gauge"},{"datasource":{"type":"influxdb","uid":"${DS_INFLUXDB}"},"fieldConfig":{"defaults":{"mappings":[],"max":500,"min":0,"thresholds":{"mode":"absolute","steps":[{"color":"#009966","value":null},{"color":"#ffde33","value":50},{"color":"#ff9933","value":100},{"color":"#cc0033","value":150},{"color":"#660099","value":200},{"color":"#7e0023","value":300}]}},"overrides":[]},"gridPos":{"h":5,"w":3,"x":6,"y":1},"id":21,"options":{"orientation":"auto","reduceOptions":{"calcs":["mean"],"fields":"","values":false},"showThresholdLabels":false,"showThresholdMarkers":true,"text":{}},"pluginVersion":"9.0.5","targets":[{"datasource":{"type":"influxdb","uid":"${DS_INFLUXDB}"},"query":"import \"strings\"\r\n\r\nfrom(bucket: \"homeassistant\")\r\n  |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\r\n  |> filter(fn: (r) => r[\"_measurement\"] == \"aqi\")\r\n  |> filter(fn: (r) => r[\"_field\"] == \"value\")\r\n  |> filter(fn: (r) => strings.hasSuffix(v: r[\"entity_id\"], suffix: \"_lrapa\"))\r\n  |> last()\r\n  |> keep(columns: [\"_value\"])\r\n  |> mean()","refId":"A"}],"title":"Current PM 2.5 AQI (LRAPA)","type":"gauge"},{"collapsed":false,"datasource":{"type":"prometheus","uid":"PBFA97CFB590B2093"},"gridPos":{"h":1,"w":24,"x":0,"y":6},"id":8,"panels":[],"title":"AQI History","type":"row"},{"datasource":{"type":"influxdb","uid":"${DS_INFLUXDB}"},"fieldConfig":{"defaults":{"color":{"mode":"palette-classic"},"custom":{"axisLabel":"","axisPlacement":"auto","barAlignment":0,"drawStyle":"line","fillOpacity":0,"gradientMode":"none","hideFrom":{"legend":false,"tooltip":false,"viz":false},"lineInterpolation":"linear","lineWidth":1,"pointSize":5,"scaleDistribution":{"type":"linear"},"showPoints":"auto","spanNulls":false,"stacking":{"group":"A","mode":"none"},"thresholdsStyle":{"mode":"area"}},"mappings":[],"thresholds":{"mode":"absolute","steps":[{"color":"#009966","value":null},{"color":"#ffde33","value":50},{"color":"#ff9933","value":100},{"color":"#cc0033","value":150},{"color":"#660099","value":200},{"color":"#7e0023","value":300}]}},"overrides":[{"matcher":{"id":"byFrameRefID","options":"G"},"properties":[{"id":"color","value":{"fixedColor":"blue","mode":"fixed"}}]},{"matcher":{"id":"byFrameRefID","options":"F"},"properties":[{"id":"color","value":{"fixedColor":"green","mode":"fixed"}}]},{"matcher":{"id":"byFrameRefID","options":"E"},"properties":[{"id":"color","value":{"fixedColor":"red","mode":"fixed"}}]}]},"gridPos":{"h":11,"w":8,"x":0,"y":7},"id":2,"options":{"legend":{"calcs":[],"displayMode":"list","placement":"bottom"},"tooltip":{"mode":"single","sort":"none"}},"pluginVersion":"8.3.3","targets":[{"datasource":{"type":"influxdb","uid":"${DS_INFLUXDB}"},"query":"from(bucket: \"homeassistant\")\r\n  |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\r\n  |> filter(fn: (r) => r[\"_measurement\"] == \"aqi\")\r\n  |> filter(fn: (r) => r[\"_field\"] == \"value\")\r\n  |> aggregateWindow(every: v.windowPeriod, fn: mean, createEmpty: false)\r\n  |> yield(name: \"mean\")","refId":"A"}],"title":"PM 2.5 AQI","type":"timeseries"},{"datasource":{"type":"influxdb","uid":"${DS_INFLUXDB}"},"fieldConfig":{"defaults":{"color":{"mode":"palette-classic"},"custom":{"axisLabel":"","axisPlacement":"auto","barAlignment":0,"drawStyle":"line","fillOpacity":0,"gradientMode":"none","hideFrom":{"legend":false,"tooltip":false,"viz":false},"lineInterpolation":"linear","lineWidth":1,"pointSize":5,"scaleDistribution":{"type":"linear"},"showPoints":"auto","spanNulls":false,"stacking":{"group":"A","mode":"none"},"thresholdsStyle":{"mode":"off"}},"mappings":[],"max":100,"min":0,"thresholds":{"mode":"absolute","steps":[{"color":"green","value":null}]},"unit":"percent"},"overrides":[]},"gridPos":{"h":11,"w":7,"x":8,"y":7},"id":23,"options":{"legend":{"calcs":[],"displayMode":"list","placement":"bottom"},"tooltip":{"mode":"single","sort":"none"}},"targets":[{"datasource":{"type":"influxdb","uid":"${DS_INFLUXDB}"},"query":"from(bucket: \"homeassistant\")\r\n  |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\r\n  |> filter(fn: (r) => r[\"_measurement\"] == \"%\")\r\n  |> filter(fn: (r) => r[\"_field\"] == \"value\")\r\n  |> aggregateWindow(every: v.windowPeriod, fn: mean, createEmpty: false)\r\n  |> yield(name: \"mean\")","refId":"A"}],"title":"Humidity","type":"timeseries"},{"datasource":{"type":"influxdb","uid":"${DS_INFLUXDB}"},"fieldConfig":{"defaults":{"color":{"mode":"palette-classic"},"custom":{"axisLabel":"°F","axisPlacement":"auto","barAlignment":0,"drawStyle":"line","fillOpacity":0,"gradientMode":"none","hideFrom":{"legend":false,"tooltip":false,"viz":false},"lineInterpolation":"linear","lineWidth":1,"pointSize":5,"scaleDistribution":{"type":"linear"},"showPoints":"auto","spanNulls":false,"stacking":{"group":"A","mode":"none"},"thresholdsStyle":{"mode":"line"}},"mappings":[],"thresholds":{"mode":"absolute","steps":[{"color":"yellow","value":null},{"color":"green","value":60},{"color":"red","value":80}]}},"overrides":[{"matcher":{"id":"byFrameRefID","options":"A"},"properties":[{"id":"displayName","value":"${__field.labels.entity_id}"}]},{"matcher":{"id":"byFrameRefID","options":"B"},"properties":[{"id":"color","value":{"mode":"fixed"}}]}]},"gridPos":{"h":11,"w":9,"x":15,"y":7},"id":39,"interval":"5m","options":{"legend":{"calcs":[],"displayMode":"list","placement":"bottom"},"tooltip":{"mode":"single","sort":"none"}},"targets":[{"datasource":{"type":"influxdb","uid":"${DS_INFLUXDB}"},"query":"import \"strings\"\r\n\r\nfrom(bucket: \"homeassistant\")\r\n  |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\r\n  |> filter(fn: (r) => r[\"entity_id\"] != \"fridge_door_temperature_measurement\" and not strings.hasSuffix(v: r[\"entity_id\"], suffix: \"index\") and not strings.hasSuffix(v: r[\"entity_id\"], suffix: \"point\"))\r\n  |> filter(fn: (r) => r[\"_measurement\"] == \"°F\")\r\n  |> filter(fn: (r) => r[\"_field\"] == \"value\")\r\n  |> filter(fn: (r) => r[\"domain\"] == \"sensor\")\r\n  |> aggregateWindow(every: v.windowPeriod, fn: mean, createEmpty: true)\r\n  |> fill(usePrevious: true)\r\n  |> map(fn: (r) => ({  r with name: r.entity_id }))\r\n  |> yield(name: \"mean\")\r\n\r\n// |> filter(fn: (r) => r[\"entity_id\"] == \"living_room_temperature\" or r[\"entity_id\"] == \"home_temperature\" or r[\"entity_id\"] == \"button_temperature_measurement\" or r[\"entity_id\"] == \"bedroom_temperature_esp\" or r[\"entity_id\"] == \"bedroom_temperature_ecobee\" or r[\"entity_id\"] == \"hallway_button_temperature_measurement\")","refId":"A"}],"title":"Temperature","type":"timeseries"},{"datasource":{"type":"influxdb","uid":"${DS_INFLUXDB}"},"fieldConfig":{"defaults":{"color":{"mode":"palette-classic"},"custom":{"axisLabel":"","axisPlacement":"auto","barAlignment":0,"drawStyle":"line","fillOpacity":0,"gradientMode":"none","hideFrom":{"legend":false,"tooltip":false,"viz":false},"lineInterpolation":"linear","lineWidth":1,"pointSize":5,"scaleDistribution":{"type":"linear"},"showPoints":"auto","spanNulls":false,"stacking":{"group":"A","mode":"none"},"thresholdsStyle":{"mode":"line"}},"decimals":4,"mappings":[],"thresholds":{"mode":"absolute","steps":[{"color":"green","value":null}]},"unit":"pressurembar"},"overrides":[]},"gridPos":{"h":8,"w":3,"x":0,"y":18},"id":33,"options":{"legend":{"calcs":[],"displayMode":"list","placement":"bottom"},"tooltip":{"mode":"single","sort":"none"}},"targets":[{"datasource":{"type":"influxdb","uid":"${DS_INFLUXDB}"},"query":"from(bucket: \"homeassistant\")\r\n  |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\r\n  |> filter(fn: (r) => r[\"_measurement\"] == \"mbar\")\r\n  |> filter(fn: (r) => r[\"_field\"] == \"value\")\r\n  |> filter(fn: (r) => r[\"domain\"] == \"sensor\")\r\n  |> aggregateWindow(every: v.windowPeriod, fn: mean, createEmpty: false)\r\n  |> yield(name: \"mean\")","refId":"A"}],"title":"Air Pressure","type":"timeseries"},{"alert":{"alertRuleTags":{},"conditions":[{"evaluator":{"params":[1000],"type":"gt"},"operator":{"type":"and"},"query":{"params":["A","5m","now"]},"reducer":{"params":[],"type":"avg"},"type":"query"}],"executionErrorState":"alerting","for":"5m","frequency":"1m","handler":1,"name":"CO2 PPM alert","noDataState":"no_data","notifications":[]},"datasource":{"type":"influxdb","uid":"${DS_INFLUXDB}"},"fieldConfig":{"defaults":{"color":{"mode":"thresholds"},"custom":{"axisLabel":"","axisPlacement":"auto","barAlignment":0,"drawStyle":"line","fillOpacity":0,"gradientMode":"none","hideFrom":{"legend":false,"tooltip":false,"viz":false},"lineInterpolation":"linear","lineWidth":1,"pointSize":5,"scaleDistribution":{"type":"linear"},"showPoints":"auto","spanNulls":false,"stacking":{"group":"A","mode":"none"},"thresholdsStyle":{"mode":"area"}},"links":[],"mappings":[],"thresholds":{"mode":"absolute","steps":[{"color":"green","value":null},{"color":"#EAB839","value":900},{"color":"red","value":1000}]},"unit":"ppm"},"overrides":[]},"gridPos":{"h":7,"w":4,"x":3,"y":18},"id":29,"options":{"legend":{"calcs":[],"displayMode":"list","placement":"bottom"},"tooltip":{"mode":"single","sort":"none"}},"targets":[{"datasource":{"type":"influxdb","uid":"${DS_INFLUXDB}"},"query":"from(bucket: \"homeassistant\")\r\n  |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\r\n  |> filter(fn: (r) => r[\"_measurement\"] == \"ppm\")\r\n  |> filter(fn: (r) => r[\"_field\"] == \"value\")\r\n  |> filter(fn: (r) => r[\"domain\"] == \"sensor\")\r\n  |> filter(fn: (r) => r[\"entity_id\"] == \"bedroom_co2\")\r\n  |> aggregateWindow(every: v.windowPeriod, fn: mean, createEmpty: false)\r\n  |> yield(name: \"mean\")","refId":"A"}],"thresholds":[{"colorMode":"critical","op":"gt","value":1000,"visible":true}],"title":"CO2 PPM","type":"timeseries"},{"datasource":{"type":"influxdb","uid":"${DS_INFLUXDB}"},"fieldConfig":{"defaults":{"color":{"mode":"thresholds"},"custom":{"fillOpacity":70,"lineWidth":0,"spanNulls":false},"displayName":"${__field.labels.entity_id}","mappings":[],"thresholds":{"mode":"absolute","steps":[{"color":"#009966","value":null},{"color":"#ffde33","value":50},{"color":"#ff9933","value":100},{"color":"#cc0033","value":150},{"color":"#660099","value":200},{"color":"#7e0023","value":300}]}},"overrides":[]},"gridPos":{"h":7,"w":10,"x":7,"y":18},"id":31,"options":{"alignValue":"left","legend":{"displayMode":"list","placement":"bottom"},"mergeValues":true,"rowHeight":0.9,"showValue":"never","tooltip":{"mode":"single","sort":"none"}},"targets":[{"datasource":{"type":"influxdb","uid":"${DS_INFLUXDB}"},"query":"import \"strings\"\r\n\r\nfrom(bucket: \"homeassistant\")\r\n  |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\r\n  |> filter(fn: (r) => r[\"_measurement\"] == \"aqi\")\r\n  |> filter(fn: (r) => r[\"_field\"] == \"value\")\r\n  |> filter(fn: (r) => strings.hasSuffix(v: r[\"entity_id\"], suffix: \"_iaqi\"))\r\n  |> yield(name: \"mean\")","refId":"A"}],"title":"Air Quality","type":"state-timeline"}],"refresh":"","schemaVersion":36,"style":"dark","tags":[],"templating":{"list":[]},"time":{"from":"now-3h","to":"now"},"timepicker":{"refresh_intervals":["10s","30s","1m","5m","15m","30m","1h","2h","1d"]},"timezone":"America/Los_Angeles","title":"Air Quality (Public)","uid":"ASFASr23rfasdf","version":4,"weekStart":""}

Conclusion

In this post, I walked through two different sensors: a DIY sensor and a prebuilt solution. Showed how to connect to Home Assistant, InfluxDB, and create some basic dashboards for visualization. Future projects include improving accuracy or even designing a custom PCB instead of stringing together sensors with wires.

Accurate, Local Home Energy Monitoring: Part 3 – Software Config

This entry is part 3 of 3 in the series Home Energy Monitoring

In the previous post in this series, I selected an energy monitoring system that is purely local based (no cloud), integrates into the breaker box, and showed how to connect it to the network and configure the size of each circuit. In this post, I’ll show how to connect the BrulTech GreenEye Energy Monitor to HomeAssistant and create some useful monitoring dashboards.

Continue reading “Accurate, Local Home Energy Monitoring: Part 3 – Software Config”

Split Horizon DNS with external-dns and cert-manager for Kubernetes

There were a few services that I ran that I wanted to be able to access from both inside my home network and outside my home network. If I was inside my home network, I wanted to route directly to the service, but if I was outside I needed to be able to route traffic through a proxy that would then route into my home lab. Additionally, I wanted to support SSL on all my services for security using cert-manager

Since my IPv4 addresses differ inside my network vs outside, I need to use split-horizon DNS to respond with the correct DNS query. Split-horizon DNS refers to the DNS on one horizon (inside the network) showing different results than outside the network.

Continue reading “Split Horizon DNS with external-dns and cert-manager for Kubernetes”

Best Practices for Java testing with JUnit

JUnit is a popular testing library for Java applications and I extensively used it when working at Amazon for the numerous Java applications and services there. However, I came across a number of different anti-patterns and areas to improve the quality of the test code. This post introduces many of the different tricks and patterns that I’ve learned and shared with my coworkers, and now want to share

Another library to know and reference is Mockito, which I use extensively in JUnit test cases and will reference this too below.

These are all real things that I’ve seen developers do.

Continue reading “Best Practices for Java testing with JUnit”

How to build a useful service data change audit log

If you’ve got a service that provides clients with the ability to make changes to those entities, then you probably want an audit log that tracks who makes what changes.

I decided to write this post because I frequently saw teams at Amazon not thinking through these considerations. Some of the guidance does focus on AWS IAM, but a lot of it is practical for any type of audit log.

Important aspects to an audit log:

  • Who made the change?
  • When did they make the change?
  • Where did they make the change?
  • What did they do?

Continue reading “How to build a useful service data change audit log”

Best Practices for working with Google Guice

Google Guice is a dependency injection library for Java and I frequently used it on a number of Java services. Compared to Spring, I liked how simple and narrow focused on just dependency injection it was. However, I often times saw developers using it in incorrect or non-ideal patterns that increased boilerplate or were just wrong.

These are all recommendations that I’ve accumulated over several years at working at Amazon watching engineers and sometimes myself improperly leverage Google Guice.

Continue reading “Best Practices for working with Google Guice”

Domain names actually end with a period and why that might subtly break your system

It’s not DNS, it’s never DNS. It was DNS.

DNS is the protocol that converts domain names like “technowizardry.net” into the IP address of the server that will respond like “144.217.181.222”. In DNS, domain names actually are supposed to end with a period. For example, the URL of this website is not “www.technowizardry.net”, but it’s actually “www.technowizardry.net.” Notice the period at the end.

Where does this come from? If you look at a DNS packet in a packet capture, you’ll see that each query looks something like this:

The queried domain starts right where I’ve highlighted in the above picture. Domain names are separated by each period. In this example, I have 3 separate domain parts: [“www”, “technowizardry”, “net”]. The byte sequence looks like:

Continue reading “Domain names actually end with a period and why that might subtly break your system”

Accurate, Local Home Energy Monitoring: Part 2 – Network Config

This post continues from the previous post in the series where I walked through the decision process on what energy monitor system to use and how to install Brultech GEM Monitor. I ended with the hardware physically installed and all Current Transformers (CTs) connected.

In this post, I continue from that point and walk through the network and software configuration defining each circuit size.

Continue reading “Accurate, Local Home Energy Monitoring: Part 2 – Network Config”

Kubernetes: A hybrid Calico and Layer 2 Bridge+DHCP network using Multus

Previously in my Home Lab series, I described how my home lab Kubernetes clusters runs with a DHCP CNI–all pods get an IP address on the same layer 2 network as the rest of my home and an IP from DHCP. This enabled me to run certain software that needed this like Home Assistant which wanted to be able to do mDNS and send broadcast packets to discover device.

However, not all pods actually needed to be on the same layer 2 network and lead to a few situations where I ran out of IP addresses on the DHCP server and couldn’t connect any new devices until reservations expired:

My DHCP IP pool completely out of addresses to give to clients

I also had a circular dependency where the main VLAN told clients to use a DNS server that was running in Kubernetes. If I had to reboot the cluster, my Kubernetes cluster could get stuck starting because it tried to query a DNS server that wasn’t started yet (For simplicity, I use DHCP for everything instead of static config).

In this post, I explain how I built a new home lab cluster with K3s and used Multus to run both Calico and my custom Bridge+DHCP CNI so that only pods that need layer 2 access get access.

Continue reading “Kubernetes: A hybrid Calico and Layer 2 Bridge+DHCP network using Multus”

How to gain access to a RKE2 cluster without Rancher when the CNI doesn’t work

In my previous post where I outlined challenges that I’ve encountered with Rancher. As part of the feedback to that I ended up having to rebuild one of my clusters. I took that time to try out RKE2 and K3s for my home lab. In this home lab, I use a custom CNI based on the official Bridge and DHCP IPAM CNIs (Read more) to enable my smart home software (HomeAssistant) to communicate with other devices on the same Layer 2 domain.

However, it seems that if you try to spin up a RKE2 cluster on a host with a Bridge interface setup (See here) then it’ll get stuck during provisioning and you won’t be able to download a Kube Config from Rancher Server because Rancher thinks it’s offline. I reported this issue initially here.

In this blog post, I explain more about the problem and how to directly connect to the cluster to install a working CNI such that Rancher will correctly start.

Continue reading “How to gain access to a RKE2 cluster without Rancher when the CNI doesn’t work”