[aranet] Add Aranet BLE sensor support (#18263)

Co-authored-by: J. Nick Koston <nick@koston.org>
Co-authored-by: J. Nick Koston <nick@home-assistant.io>
This commit is contained in:
Patrick O'Doherty
2026-10-12 14:11:10 -10:00
committed by GitHub
co-authored by J. Nick Koston J. Nick Koston
parent 80fa6efd1e
commit 8aab968136
12 changed files with 618 additions and 0 deletions
+1
View File
@@ -44,6 +44,7 @@ esphome/components/apds9306/* @aodrenah
esphome/components/apds9930/* @iyesin
esphome/components/api/* @esphome/core
esphome/components/aqi/* @freekode @jasstrong @ximex
esphome/components/aranet/* @patrickod
esphome/components/as5600/* @ammmze
esphome/components/as5600/sensor/* @ammmze
esphome/components/as7341/* @mrgnr
+2
View File
@@ -0,0 +1,2 @@
CODEOWNERS = ["@patrickod"]
DOMAIN = "aranet"
+157
View File
@@ -0,0 +1,157 @@
#include "aranet.h"
#include <cinttypes>
#include "esphome/core/helpers.h"
#include "esphome/core/log.h"
namespace esphome::aranet {
ESPHOME_LOG_TAG(TAG, "aranet");
static constexpr uint16_t ARANET_MANUFACTURER_ID = 0x0702;
static constexpr size_t ARANET4_DATA_LENGTH = 22;
static constexpr size_t OTHER_ARANET_DATA_LENGTH = 24;
enum class AranetDeviceType : uint8_t {
ARANET_DEVICE_TYPE_ARANET4 = 0,
ARANET_DEVICE_TYPE_ARANET2 = 1,
ARANET_DEVICE_TYPE_RADIATION = 2,
ARANET_DEVICE_TYPE_RADON = 3,
};
static inline void publish_if_set(sensor::Sensor *sensor, float value) {
if (sensor != nullptr)
sensor->publish_state(value);
}
// Aranet does not publish an official advertisement specification. The layout below is corroborated by these
// independently maintained implementations:
// https://github.com/Anrijs/Aranet4-Python/blob/master/aranet4/client.py
// https://github.com/Anrijs/Aranet4-ESP32/blob/main/src/Aranet4.h
void Aranet::dump_config() {
ESP_LOGCONFIG(TAG, "Aranet");
LOG_SENSOR(" ", "Carbon Dioxide", this->co2_sensor_);
LOG_SENSOR(" ", "Temperature", this->temperature_sensor_);
LOG_SENSOR(" ", "Humidity", this->humidity_sensor_);
LOG_SENSOR(" ", "Pressure", this->pressure_sensor_);
LOG_SENSOR(" ", "Battery Level", this->battery_level_sensor_);
LOG_SENSOR(" ", "Measurement Interval", this->measurement_interval_sensor_);
LOG_SENSOR(" ", "Measurement Age", this->measurement_age_sensor_);
LOG_SENSOR(" ", "Signal Strength", this->signal_strength_sensor_);
LOG_SENSOR(" ", "Radon Concentration", this->radon_sensor_);
LOG_SENSOR(" ", "Radiation Dose Rate", this->radiation_rate_sensor_);
LOG_SENSOR(" ", "Radiation Total Dose", this->radiation_total_sensor_);
LOG_SENSOR(" ", "Radiation Dose Duration", this->radiation_duration_sensor_);
}
bool Aranet::parse_device(const ble_device_base::ESPBTDevice &device) {
if (device.address_uint64() != this->address_)
return false;
const auto manufacturer_uuid = ble_device_base::ESPBTUUID::from_uint16(ARANET_MANUFACTURER_ID);
for (const auto &manufacturer_data : device.get_manufacturer_datas()) {
if (manufacturer_data.uuid != manufacturer_uuid)
continue;
const auto &data = manufacturer_data.data;
if (data.size() != ARANET4_DATA_LENGTH && data.size() != OTHER_ARANET_DATA_LENGTH) {
if ((this->warned_ & WARNED_INVALID_LENGTH) == 0) {
ESP_LOGW(TAG, "Unexpected manufacturer data length: %u", static_cast<unsigned>(data.size()));
this->warned_ |= WARNED_INVALID_LENGTH;
}
return false;
}
// Aranet4 has no device type byte, so its flags and trailer start one byte earlier
const bool is_aranet4 = data.size() == ARANET4_DATA_LENGTH;
if ((data[is_aranet4 ? 0 : 1] & 0x20) == 0) {
if ((this->warned_ & WARNED_INTEGRATION_DISABLED) == 0) {
ESP_LOGW(TAG, "Smart Home integration is disabled on the Aranet device");
this->warned_ |= WARNED_INTEGRATION_DISABLED;
}
return false;
}
if (!is_aranet4 && (data[0] == 0 || data[0] > 3)) {
if ((this->warned_ & WARNED_UNSUPPORTED_TYPE) == 0) {
ESP_LOGW(TAG, "Unsupported Aranet device type: %u", data[0]);
this->warned_ |= WARNED_UNSUPPORTED_TYPE;
}
return false;
}
const auto device_type =
is_aranet4 ? AranetDeviceType::ARANET_DEVICE_TYPE_ARANET4 : static_cast<AranetDeviceType>(data[0]);
// Battery, measurement interval, measurement age and measurement counter
const uint8_t *trailer = &data[is_aranet4 ? 15 : 17];
const uint16_t measurement_interval = encode_uint16(trailer[3], trailer[2]);
const uint16_t measurement_age = encode_uint16(trailer[5], trailer[4]);
const uint8_t measurement_counter = trailer[6];
publish_if_set(this->measurement_age_sensor_, measurement_age);
publish_if_set(this->signal_strength_sensor_, device.get_rssi());
if (measurement_counter == this->last_measurement_counter_)
return true;
this->last_measurement_counter_ = measurement_counter;
// Not meaningful for Aranet Radiation, which stores its dose duration in these bytes
const float temperature = encode_uint16(data[11], data[10]) / 20.0f;
const float pressure = encode_uint16(data[13], data[12]) / 10.0f;
switch (device_type) {
case AranetDeviceType::ARANET_DEVICE_TYPE_ARANET4: {
const uint16_t co2 = encode_uint16(data[9], data[8]);
publish_if_set(this->co2_sensor_, co2);
publish_if_set(this->temperature_sensor_, temperature);
publish_if_set(this->pressure_sensor_, pressure);
publish_if_set(this->humidity_sensor_, data[14]);
ESP_LOGD(TAG, "Aranet4 CO2: %u ppm, temperature: %.1f C, humidity: %u%%, pressure: %.1f hPa", co2, temperature,
data[14], pressure);
break;
}
case AranetDeviceType::ARANET_DEVICE_TYPE_ARANET2: {
const float humidity = encode_uint16(data[15], data[14]) / 10.0f;
publish_if_set(this->temperature_sensor_, temperature);
publish_if_set(this->humidity_sensor_, humidity);
ESP_LOGD(TAG, "Aranet2 temperature: %.1f C, humidity: %.1f%%", temperature, humidity);
break;
}
case AranetDeviceType::ARANET_DEVICE_TYPE_RADIATION: {
const uint32_t radiation_total = encode_uint32(data[9], data[8], data[7], data[6]);
const uint32_t radiation_duration = encode_uint32(data[13], data[12], data[11], data[10]);
const uint16_t radiation_rate = encode_uint16(data[15], data[14]);
publish_if_set(this->radiation_rate_sensor_, radiation_rate / 1000.0f);
publish_if_set(this->radiation_total_sensor_, radiation_total);
publish_if_set(this->radiation_duration_sensor_, radiation_duration);
ESP_LOGD(TAG, "Aranet Radiation dose rate: %u nSv/h, total dose: %" PRIu32 " nSv", radiation_rate,
radiation_total);
break;
}
case AranetDeviceType::ARANET_DEVICE_TYPE_RADON: {
const uint16_t radon = encode_uint16(data[9], data[8]);
const float humidity = encode_uint16(data[15], data[14]) / 10.0f;
publish_if_set(this->radon_sensor_, radon);
// Aranet Radon One uses zero temperature and humidity to indicate fields not supported by that model.
if (temperature != 0)
publish_if_set(this->temperature_sensor_, temperature);
publish_if_set(this->pressure_sensor_, pressure);
if (humidity != 0)
publish_if_set(this->humidity_sensor_, humidity);
ESP_LOGD(TAG, "Aranet Radon: %u Bq/m3, temperature: %.1f C, humidity: %.1f%%, pressure: %.1f hPa", radon,
temperature, humidity, pressure);
break;
}
default:
break;
}
publish_if_set(this->battery_level_sensor_, trailer[0]);
publish_if_set(this->measurement_interval_sensor_, measurement_interval);
return true;
}
return false;
}
} // namespace esphome::aranet
+42
View File
@@ -0,0 +1,42 @@
#pragma once
#include "esphome/components/ble_device_base/ble_device.h"
#include "esphome/components/sensor/sensor.h"
#include "esphome/core/component.h"
namespace esphome::aranet {
class Aranet final : public Component, public ble_device_base::ESPBTDeviceListener {
public:
explicit Aranet(uint64_t address) : address_(address) {}
bool parse_device(const ble_device_base::ESPBTDevice &device) override;
void dump_config() override;
protected:
static constexpr uint8_t WARNED_INVALID_LENGTH = 1 << 0;
static constexpr uint8_t WARNED_INTEGRATION_DISABLED = 1 << 1;
static constexpr uint8_t WARNED_UNSUPPORTED_TYPE = 1 << 2;
// Small state first so it fills the padding before the first pointer
uint16_t last_measurement_counter_{0x100}; // Out of range of the 8-bit counter until the first measurement
uint8_t warned_{0};
SUB_SENSOR(co2)
SUB_SENSOR(temperature)
SUB_SENSOR(humidity)
SUB_SENSOR(pressure)
SUB_SENSOR(battery_level)
SUB_SENSOR(measurement_interval)
SUB_SENSOR(measurement_age)
SUB_SENSOR(signal_strength)
SUB_SENSOR(radon)
SUB_SENSOR(radiation_rate)
SUB_SENSOR(radiation_total)
SUB_SENSOR(radiation_duration)
protected:
uint64_t address_;
};
} // namespace esphome::aranet
+150
View File
@@ -0,0 +1,150 @@
import esphome.codegen as cg
from esphome.components import ble_device_base, sensor
import esphome.config_validation as cv
from esphome.const import (
CONF_BATTERY_LEVEL,
CONF_CO2,
CONF_HUMIDITY,
CONF_ID,
CONF_MAC_ADDRESS,
CONF_PRESSURE,
CONF_RADON,
CONF_SIGNAL_STRENGTH,
CONF_TEMPERATURE,
DEVICE_CLASS_BATTERY,
DEVICE_CLASS_CARBON_DIOXIDE,
DEVICE_CLASS_DURATION,
DEVICE_CLASS_HUMIDITY,
DEVICE_CLASS_PRESSURE,
DEVICE_CLASS_RADON,
DEVICE_CLASS_SIGNAL_STRENGTH,
DEVICE_CLASS_TEMPERATURE,
ENTITY_CATEGORY_DIAGNOSTIC,
ICON_RADIOACTIVE,
STATE_CLASS_MEASUREMENT,
STATE_CLASS_TOTAL_INCREASING,
UNIT_BECQUEREL_PER_CUBIC_METER,
UNIT_CELSIUS,
UNIT_DECIBEL_MILLIWATT,
UNIT_HECTOPASCAL,
UNIT_MICROSILVERTS_PER_HOUR,
UNIT_PARTS_PER_MILLION,
UNIT_PERCENT,
UNIT_SECOND,
)
from esphome.types import ConfigType
AUTO_LOAD = ["ble_device_base"]
CONF_MEASUREMENT_AGE = "measurement_age"
CONF_MEASUREMENT_INTERVAL = "measurement_interval"
CONF_RADIATION_RATE = "radiation_rate"
CONF_RADIATION_TOTAL = "radiation_total"
CONF_RADIATION_DURATION = "radiation_duration"
UNIT_NANOSIEVERT = "nSv"
aranet_ns = cg.esphome_ns.namespace("aranet")
Aranet = aranet_ns.class_("Aranet", ble_device_base.ESPBTDeviceListener, cg.Component)
DIAGNOSTIC_DURATION_SCHEMA = sensor.sensor_schema(
unit_of_measurement=UNIT_SECOND,
accuracy_decimals=0,
device_class=DEVICE_CLASS_DURATION,
state_class=STATE_CLASS_MEASUREMENT,
entity_category=ENTITY_CATEGORY_DIAGNOSTIC,
)
CONFIG_SCHEMA = (
cv.Schema(
{
cv.GenerateID(): cv.declare_id(Aranet),
cv.Required(CONF_MAC_ADDRESS): cv.mac_address,
cv.Optional(CONF_CO2): sensor.sensor_schema(
unit_of_measurement=UNIT_PARTS_PER_MILLION,
accuracy_decimals=0,
device_class=DEVICE_CLASS_CARBON_DIOXIDE,
state_class=STATE_CLASS_MEASUREMENT,
),
cv.Optional(CONF_TEMPERATURE): sensor.sensor_schema(
unit_of_measurement=UNIT_CELSIUS,
accuracy_decimals=1,
device_class=DEVICE_CLASS_TEMPERATURE,
state_class=STATE_CLASS_MEASUREMENT,
),
cv.Optional(CONF_HUMIDITY): sensor.sensor_schema(
unit_of_measurement=UNIT_PERCENT,
accuracy_decimals=1,
device_class=DEVICE_CLASS_HUMIDITY,
state_class=STATE_CLASS_MEASUREMENT,
),
cv.Optional(CONF_PRESSURE): sensor.sensor_schema(
unit_of_measurement=UNIT_HECTOPASCAL,
accuracy_decimals=1,
device_class=DEVICE_CLASS_PRESSURE,
state_class=STATE_CLASS_MEASUREMENT,
),
cv.Optional(CONF_BATTERY_LEVEL): sensor.sensor_schema(
unit_of_measurement=UNIT_PERCENT,
accuracy_decimals=0,
device_class=DEVICE_CLASS_BATTERY,
state_class=STATE_CLASS_MEASUREMENT,
entity_category=ENTITY_CATEGORY_DIAGNOSTIC,
),
cv.Optional(CONF_MEASUREMENT_INTERVAL): DIAGNOSTIC_DURATION_SCHEMA,
cv.Optional(CONF_MEASUREMENT_AGE): DIAGNOSTIC_DURATION_SCHEMA,
cv.Optional(CONF_SIGNAL_STRENGTH): sensor.sensor_schema(
unit_of_measurement=UNIT_DECIBEL_MILLIWATT,
accuracy_decimals=0,
device_class=DEVICE_CLASS_SIGNAL_STRENGTH,
state_class=STATE_CLASS_MEASUREMENT,
entity_category=ENTITY_CATEGORY_DIAGNOSTIC,
),
cv.Optional(CONF_RADON): sensor.sensor_schema(
unit_of_measurement=UNIT_BECQUEREL_PER_CUBIC_METER,
accuracy_decimals=0,
device_class=DEVICE_CLASS_RADON,
state_class=STATE_CLASS_MEASUREMENT,
),
cv.Optional(CONF_RADIATION_RATE): sensor.sensor_schema(
unit_of_measurement=UNIT_MICROSILVERTS_PER_HOUR,
accuracy_decimals=3,
icon=ICON_RADIOACTIVE,
state_class=STATE_CLASS_MEASUREMENT,
),
cv.Optional(CONF_RADIATION_TOTAL): sensor.sensor_schema(
unit_of_measurement=UNIT_NANOSIEVERT,
accuracy_decimals=0,
icon=ICON_RADIOACTIVE,
state_class=STATE_CLASS_TOTAL_INCREASING,
),
cv.Optional(CONF_RADIATION_DURATION): sensor.sensor_schema(
unit_of_measurement=UNIT_SECOND,
accuracy_decimals=0,
device_class=DEVICE_CLASS_DURATION,
state_class=STATE_CLASS_TOTAL_INCREASING,
),
}
)
.extend(cv.COMPONENT_SCHEMA)
.extend(ble_device_base.BLE_DEVICE_SCHEMA)
)
async def to_code(config: ConfigType) -> None:
var = cg.new_Pvariable(config[CONF_ID], config[CONF_MAC_ADDRESS].as_hex)
await cg.register_component(var, config)
await ble_device_base.register_ble_device(var, config)
sensors = sensor.sub_sensors(config)
await sensors(CONF_CO2, var.set_co2_sensor)
await sensors(CONF_TEMPERATURE, var.set_temperature_sensor)
await sensors(CONF_HUMIDITY, var.set_humidity_sensor)
await sensors(CONF_PRESSURE, var.set_pressure_sensor)
await sensors(CONF_BATTERY_LEVEL, var.set_battery_level_sensor)
await sensors(CONF_MEASUREMENT_INTERVAL, var.set_measurement_interval_sensor)
await sensors(CONF_MEASUREMENT_AGE, var.set_measurement_age_sensor)
await sensors(CONF_SIGNAL_STRENGTH, var.set_signal_strength_sensor)
await sensors(CONF_RADON, var.set_radon_sensor)
await sensors(CONF_RADIATION_RATE, var.set_radiation_rate_sensor)
await sensors(CONF_RADIATION_TOTAL, var.set_radiation_total_sensor)
await sensors(CONF_RADIATION_DURATION, var.set_radiation_duration_sensor)
+7
View File
@@ -0,0 +1,7 @@
sensor:
- platform: aranet
mac_address: 00:11:22:33:44:55
co2:
name: Aranet4 Carbon Dioxide
temperature:
name: Aranet4 Temperature
+70
View File
@@ -0,0 +1,70 @@
#pragma once
#include <cstdint>
#include <vector>
#include "esphome/components/aranet/aranet.h"
namespace esphome::aranet::testing {
constexpr uint8_t MAC_ADDRESS[6] = {0x55, 0x44, 0x33, 0x22, 0x11, 0x00};
constexpr uint64_t ADDRESS = 0x001122334455ULL;
inline const std::vector<uint8_t> ARANET4_PAYLOAD = {
0x20, // Smart Home integrations enabled
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x58, 0x02, // CO2: 600 ppm
0xA4, 0x01, // Temperature: 21.0 C (420 / 20)
0x94, 0x27, // Pressure: 1013.2 hPa (10132 / 10)
45, // Humidity: 45%
87, // Battery: 87%
0x00, // Display status
0x2C, 0x01, // Measurement interval: 300 s
12, 0x00, // Measurement age: 12 s
7, // Measurement counter
};
inline const std::vector<uint8_t> ARANET2_PAYLOAD = {0x01, 0x21, 0x04, 0x04, 0x01, 0x00, 0x00, 0x00,
0x00, 0x00, 0x99, 0x01, 0x00, 0x00, 0x0A, 0x02,
0x00, 0x3B, 0x09, 0x78, 0x00, 0x52, 0x00, 0x64};
inline const std::vector<uint8_t> RADIATION_PAYLOAD = {0x02, 0x21, 0x26, 0x04, 0x01, 0x00, 0xD0, 0x33,
0x00, 0x00, 0x6C, 0x60, 0x06, 0x00, 0x82, 0x00,
0x00, 0x63, 0x00, 0x2C, 0x01, 0x58, 0x00, 0x72};
inline const std::vector<uint8_t> RADON_PAYLOAD = {0x03, 0x21, 0x04, 0x06, 0x01, 0x00, 0x00, 0x00,
0x07, 0x00, 0xFE, 0x01, 0xC9, 0x27, 0xCE, 0x01,
0x00, 0x64, 0x01, 0x58, 0x02, 0xF6, 0x01, 0x08};
inline ble_device_base::ESPBTDevice make_device(const std::vector<uint8_t> &payload, int rssi = -61,
const uint8_t *mac_address = MAC_ADDRESS) {
// Manufacturer specific data AD structure for company id 0x0702
std::vector<uint8_t> advertisement{static_cast<uint8_t>(payload.size() + 3), 0xFF, 0x02, 0x07};
advertisement.insert(advertisement.end(), payload.begin(), payload.end());
ble_device_base::ESPBTDevice device;
device.from_scan_result(mac_address, rssi, 0, advertisement.data(), advertisement.size());
return device;
}
struct Harness {
Harness() {
this->aranet.set_co2_sensor(&this->co2);
this->aranet.set_temperature_sensor(&this->temperature);
this->aranet.set_humidity_sensor(&this->humidity);
this->aranet.set_pressure_sensor(&this->pressure);
this->aranet.set_battery_level_sensor(&this->battery);
this->aranet.set_measurement_interval_sensor(&this->interval);
this->aranet.set_measurement_age_sensor(&this->age);
this->aranet.set_signal_strength_sensor(&this->signal_strength);
this->aranet.set_radon_sensor(&this->radon);
this->aranet.set_radiation_rate_sensor(&this->radiation_rate);
this->aranet.set_radiation_total_sensor(&this->radiation_total);
this->aranet.set_radiation_duration_sensor(&this->radiation_duration);
}
Aranet aranet{ADDRESS};
sensor::Sensor co2, temperature, humidity, pressure, battery, interval, age, signal_strength, radon, radiation_rate,
radiation_total, radiation_duration;
};
} // namespace esphome::aranet::testing
+27
View File
@@ -0,0 +1,27 @@
sensor:
- platform: aranet
mac_address: 00:11:22:33:44:55
co2:
name: Aranet4 Carbon Dioxide
temperature:
name: Aranet4 Temperature
humidity:
name: Aranet4 Humidity
pressure:
name: Aranet4 Pressure
battery_level:
name: Aranet4 Battery Level
measurement_interval:
name: Aranet4 Measurement Interval
measurement_age:
name: Aranet4 Measurement Age
signal_strength:
name: Aranet4 Signal Strength
radon:
name: Aranet Radon Concentration
radiation_rate:
name: Aranet Radiation Dose Rate
radiation_total:
name: Aranet Radiation Total Dose
radiation_duration:
name: Aranet Radiation Dose Duration
@@ -0,0 +1,144 @@
#include <gtest/gtest.h>
#include <cstdint>
#include <vector>
#include "../common.h"
namespace esphome::aranet::testing {
namespace {
TEST(Aranet, DecodesAranet4) {
Harness h;
auto device = make_device(ARANET4_PAYLOAD);
ASSERT_EQ(device.address_uint64(), ADDRESS);
EXPECT_TRUE(h.aranet.parse_device(device));
EXPECT_FLOAT_EQ(h.co2.state, 600.0f);
EXPECT_FLOAT_EQ(h.temperature.state, 21.0f);
EXPECT_FLOAT_EQ(h.humidity.state, 45.0f);
EXPECT_FLOAT_EQ(h.pressure.state, 1013.2f);
EXPECT_FLOAT_EQ(h.battery.state, 87.0f);
EXPECT_FLOAT_EQ(h.interval.state, 300.0f);
EXPECT_FLOAT_EQ(h.age.state, 12.0f);
EXPECT_FLOAT_EQ(h.signal_strength.state, -61.0f);
EXPECT_FALSE(h.radon.has_state());
EXPECT_FALSE(h.radiation_rate.has_state());
EXPECT_FALSE(h.radiation_total.has_state());
EXPECT_FALSE(h.radiation_duration.has_state());
}
TEST(Aranet, UpdatesDiagnosticsForRepeatedMeasurement) {
Harness h;
int co2_updates = 0;
int age_updates = 0;
int signal_strength_updates = 0;
h.co2.add_on_state_callback([&co2_updates](float) { co2_updates++; });
h.age.add_on_state_callback([&age_updates](float) { age_updates++; });
h.signal_strength.add_on_state_callback([&signal_strength_updates](float) { signal_strength_updates++; });
EXPECT_TRUE(h.aranet.parse_device(make_device(ARANET4_PAYLOAD, -61)));
auto payload = ARANET4_PAYLOAD;
payload[19] = 18;
EXPECT_TRUE(h.aranet.parse_device(make_device(payload, -54)));
EXPECT_EQ(co2_updates, 1);
EXPECT_EQ(age_updates, 2);
EXPECT_EQ(signal_strength_updates, 2);
EXPECT_FLOAT_EQ(h.age.state, 18.0f);
EXPECT_FLOAT_EQ(h.signal_strength.state, -54.0f);
}
TEST(Aranet, RejectsDisabledIntegrationAndMalformedPayload) {
Harness h;
auto payload = ARANET4_PAYLOAD;
payload[0] = 0;
EXPECT_FALSE(h.aranet.parse_device(make_device(payload)));
payload.pop_back();
EXPECT_FALSE(h.aranet.parse_device(make_device(payload)));
EXPECT_FALSE(h.co2.has_state());
EXPECT_FALSE(h.signal_strength.has_state());
}
TEST(Aranet, RejectsMismatchedAddressAndUnsupportedDeviceType) {
Harness h;
constexpr uint8_t other_mac_address[6] = {0x66, 0x44, 0x33, 0x22, 0x11, 0x00};
EXPECT_FALSE(h.aranet.parse_device(make_device(ARANET4_PAYLOAD, -61, other_mac_address)));
auto payload = ARANET4_PAYLOAD;
payload.insert(payload.begin(), 0x09);
payload.push_back(0x00);
EXPECT_FALSE(h.aranet.parse_device(make_device(payload)));
// Type 0 is Aranet4, which never sends the 24 byte layout; the second warning stays quiet
payload[0] = 0x00;
EXPECT_FALSE(h.aranet.parse_device(make_device(payload)));
EXPECT_FALSE(h.signal_strength.has_state());
}
TEST(Aranet, DecodesAranet2) {
Harness h;
int temperature_updates = 0;
h.temperature.add_on_state_callback([&temperature_updates](float) { temperature_updates++; });
EXPECT_TRUE(h.aranet.parse_device(make_device(ARANET2_PAYLOAD)));
EXPECT_NEAR(h.temperature.state, 20.45f, 0.001f);
EXPECT_FLOAT_EQ(h.humidity.state, 52.2f);
EXPECT_FLOAT_EQ(h.battery.state, 59.0f);
EXPECT_FLOAT_EQ(h.interval.state, 120.0f);
EXPECT_FLOAT_EQ(h.age.state, 82.0f);
EXPECT_FALSE(h.co2.has_state());
EXPECT_FALSE(h.pressure.has_state());
auto repeated_payload = ARANET2_PAYLOAD;
repeated_payload[10] = 0x00;
repeated_payload[11] = 0x00;
EXPECT_TRUE(h.aranet.parse_device(make_device(repeated_payload)));
EXPECT_EQ(temperature_updates, 1);
}
TEST(Aranet, DecodesAranetRadiation) {
Harness h;
EXPECT_TRUE(h.aranet.parse_device(make_device(RADIATION_PAYLOAD)));
EXPECT_FLOAT_EQ(h.radiation_rate.state, 0.13f);
EXPECT_FLOAT_EQ(h.radiation_total.state, 13264.0f);
EXPECT_FLOAT_EQ(h.radiation_duration.state, 417900.0f);
EXPECT_FLOAT_EQ(h.battery.state, 99.0f);
EXPECT_FLOAT_EQ(h.interval.state, 300.0f);
EXPECT_FLOAT_EQ(h.age.state, 88.0f);
EXPECT_FALSE(h.co2.has_state());
EXPECT_FALSE(h.temperature.has_state());
EXPECT_FALSE(h.humidity.has_state());
EXPECT_FALSE(h.pressure.has_state());
EXPECT_FALSE(h.radon.has_state());
}
TEST(Aranet, DecodesAranetRadon) {
Harness h;
EXPECT_TRUE(h.aranet.parse_device(make_device(RADON_PAYLOAD)));
EXPECT_FLOAT_EQ(h.radon.state, 7.0f);
EXPECT_FLOAT_EQ(h.temperature.state, 25.5f);
EXPECT_FLOAT_EQ(h.humidity.state, 46.2f);
EXPECT_FLOAT_EQ(h.pressure.state, 1018.5f);
EXPECT_FALSE(h.co2.has_state());
EXPECT_FALSE(h.radiation_rate.has_state());
EXPECT_FALSE(h.radiation_total.has_state());
EXPECT_FALSE(h.radiation_duration.has_state());
}
TEST(Aranet, RadonOneSkipsZeroTemperatureAndHumidity) {
Harness h;
auto payload = RADON_PAYLOAD;
payload[10] = payload[11] = 0x00;
payload[14] = payload[15] = 0x00;
EXPECT_TRUE(h.aranet.parse_device(make_device(payload)));
EXPECT_FLOAT_EQ(h.radon.state, 7.0f);
EXPECT_FLOAT_EQ(h.pressure.state, 1018.5f);
EXPECT_FALSE(h.temperature.has_state());
EXPECT_FALSE(h.humidity.has_state());
}
} // namespace
} // namespace esphome::aranet::testing
@@ -0,0 +1,3 @@
packages:
ble: !include ../../test_build_components/common/ble/esp32-idf.yaml
aranet: !include common.yaml
@@ -0,0 +1,3 @@
packages:
ln882h_ble_tracker: !include ../ln882h_ble_tracker/common.yaml
aranet: !include common-ln.yaml
@@ -0,0 +1,12 @@
# Config-only: the CI base board (generic-bk7252, BLE 4.2) cannot compile the
# BLE 5.x tracker, so this fixture proves validation (schema + neutral binding)
# on a non-esp32 platform; codegen and compilation are not exercised here.
bk72xx_ble_tracker:
id: ble_hub
sensor:
- platform: aranet
ble_hub_id: ble_hub
mac_address: 00:11:22:33:44:55
co2:
name: BK Aranet4 Carbon Dioxide