From 33d48732aa1c6b7c2c4f7e5cb48faa95e1f03bcf Mon Sep 17 00:00:00 2001 From: Andrew Klaus <2087223+precurse@users.noreply.github.com> Date: Wed, 9 Jul 2025 19:50:01 -0600 Subject: [PATCH] Adding support for Airthings Wave Gen2 (#8460) Co-authored-by: J. Nick Koston --- CODEOWNERS | 2 +- .../airthings_wave_plus/__init__.py | 2 +- .../airthings_wave_plus.cpp | 26 +++++- .../airthings_wave_plus/airthings_wave_plus.h | 11 ++- .../components/airthings_wave_plus/sensor.py | 83 ++++++++++++------- 5 files changed, 89 insertions(+), 35 deletions(-) diff --git a/CODEOWNERS b/CODEOWNERS index 9b4681fcf2..2975080ba9 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -28,7 +28,7 @@ esphome/components/aic3204/* @kbx81 esphome/components/airthings_ble/* @jeromelaban esphome/components/airthings_wave_base/* @jeromelaban @kpfleming @ncareau esphome/components/airthings_wave_mini/* @ncareau -esphome/components/airthings_wave_plus/* @jeromelaban +esphome/components/airthings_wave_plus/* @jeromelaban @precurse esphome/components/alarm_control_panel/* @grahambrown11 @hwstar esphome/components/alpha3/* @jan-hofmeier esphome/components/am2315c/* @swoboda1337 diff --git a/esphome/components/airthings_wave_plus/__init__.py b/esphome/components/airthings_wave_plus/__init__.py index 1aff461edd..e26bfd471b 100644 --- a/esphome/components/airthings_wave_plus/__init__.py +++ b/esphome/components/airthings_wave_plus/__init__.py @@ -1 +1 @@ -CODEOWNERS = ["@jeromelaban"] +CODEOWNERS = ["@jeromelaban", "@precurse"] diff --git a/esphome/components/airthings_wave_plus/airthings_wave_plus.cpp b/esphome/components/airthings_wave_plus/airthings_wave_plus.cpp index 8c8c514fdb..5ed62fff62 100644 --- a/esphome/components/airthings_wave_plus/airthings_wave_plus.cpp +++ b/esphome/components/airthings_wave_plus/airthings_wave_plus.cpp @@ -73,11 +73,29 @@ void AirthingsWavePlus::dump_config() { LOG_SENSOR(" ", "Illuminance", this->illuminance_sensor_); } -AirthingsWavePlus::AirthingsWavePlus() { - this->service_uuid_ = espbt::ESPBTUUID::from_raw(SERVICE_UUID); - this->sensors_data_characteristic_uuid_ = espbt::ESPBTUUID::from_raw(CHARACTERISTIC_UUID); +void AirthingsWavePlus::setup() { + const char *service_uuid; + const char *characteristic_uuid; + const char *access_control_point_characteristic_uuid; + + // Change UUIDs for Wave Radon Gen2 + switch (this->wave_device_type_) { + case WaveDeviceType::WAVE_GEN2: + service_uuid = SERVICE_UUID_WAVE_RADON_GEN2; + characteristic_uuid = CHARACTERISTIC_UUID_WAVE_RADON_GEN2; + access_control_point_characteristic_uuid = ACCESS_CONTROL_POINT_CHARACTERISTIC_UUID_WAVE_RADON_GEN2; + break; + default: + // Wave Plus + service_uuid = SERVICE_UUID; + characteristic_uuid = CHARACTERISTIC_UUID; + access_control_point_characteristic_uuid = ACCESS_CONTROL_POINT_CHARACTERISTIC_UUID; + } + + this->service_uuid_ = espbt::ESPBTUUID::from_raw(service_uuid); + this->sensors_data_characteristic_uuid_ = espbt::ESPBTUUID::from_raw(characteristic_uuid); this->access_control_point_characteristic_uuid_ = - espbt::ESPBTUUID::from_raw(ACCESS_CONTROL_POINT_CHARACTERISTIC_UUID); + espbt::ESPBTUUID::from_raw(access_control_point_characteristic_uuid); } } // namespace airthings_wave_plus diff --git a/esphome/components/airthings_wave_plus/airthings_wave_plus.h b/esphome/components/airthings_wave_plus/airthings_wave_plus.h index bd7a40ef8b..c978a9af92 100644 --- a/esphome/components/airthings_wave_plus/airthings_wave_plus.h +++ b/esphome/components/airthings_wave_plus/airthings_wave_plus.h @@ -9,13 +9,20 @@ namespace airthings_wave_plus { namespace espbt = esphome::esp32_ble_tracker; +enum WaveDeviceType : uint8_t { WAVE_PLUS = 0, WAVE_GEN2 = 1 }; + static const char *const SERVICE_UUID = "b42e1c08-ade7-11e4-89d3-123b93f75cba"; static const char *const CHARACTERISTIC_UUID = "b42e2a68-ade7-11e4-89d3-123b93f75cba"; static const char *const ACCESS_CONTROL_POINT_CHARACTERISTIC_UUID = "b42e2d06-ade7-11e4-89d3-123b93f75cba"; +static const char *const SERVICE_UUID_WAVE_RADON_GEN2 = "b42e4a8e-ade7-11e4-89d3-123b93f75cba"; +static const char *const CHARACTERISTIC_UUID_WAVE_RADON_GEN2 = "b42e4dcc-ade7-11e4-89d3-123b93f75cba"; +static const char *const ACCESS_CONTROL_POINT_CHARACTERISTIC_UUID_WAVE_RADON_GEN2 = + "b42e50d8-ade7-11e4-89d3-123b93f75cba"; + class AirthingsWavePlus : public airthings_wave_base::AirthingsWaveBase { public: - AirthingsWavePlus(); + void setup() override; void dump_config() override; @@ -23,12 +30,14 @@ class AirthingsWavePlus : public airthings_wave_base::AirthingsWaveBase { void set_radon_long_term(sensor::Sensor *radon_long_term) { radon_long_term_sensor_ = radon_long_term; } void set_co2(sensor::Sensor *co2) { co2_sensor_ = co2; } void set_illuminance(sensor::Sensor *illuminance) { illuminance_sensor_ = illuminance; } + void set_device_type(WaveDeviceType wave_device_type) { wave_device_type_ = wave_device_type; } protected: bool is_valid_radon_value_(uint16_t radon); bool is_valid_co2_value_(uint16_t co2); void read_sensors(uint8_t *raw_value, uint16_t value_len) override; + WaveDeviceType wave_device_type_{WaveDeviceType::WAVE_PLUS}; sensor::Sensor *radon_sensor_{nullptr}; sensor::Sensor *radon_long_term_sensor_{nullptr}; diff --git a/esphome/components/airthings_wave_plus/sensor.py b/esphome/components/airthings_wave_plus/sensor.py index e0e90735f0..a12c70f04c 100644 --- a/esphome/components/airthings_wave_plus/sensor.py +++ b/esphome/components/airthings_wave_plus/sensor.py @@ -7,6 +7,7 @@ from esphome.const import ( CONF_ILLUMINANCE, CONF_RADON, CONF_RADON_LONG_TERM, + CONF_TVOC, DEVICE_CLASS_CARBON_DIOXIDE, DEVICE_CLASS_ILLUMINANCE, ICON_RADIOACTIVE, @@ -15,6 +16,7 @@ from esphome.const import ( UNIT_LUX, UNIT_PARTS_PER_MILLION, ) +from esphome.types import ConfigType DEPENDENCIES = airthings_wave_base.DEPENDENCIES @@ -25,35 +27,59 @@ AirthingsWavePlus = airthings_wave_plus_ns.class_( "AirthingsWavePlus", airthings_wave_base.AirthingsWaveBase ) +CONF_DEVICE_TYPE = "device_type" +WaveDeviceType = airthings_wave_plus_ns.enum("WaveDeviceType") +DEVICE_TYPES = { + "WAVE_PLUS": WaveDeviceType.WAVE_PLUS, + "WAVE_GEN2": WaveDeviceType.WAVE_GEN2, +} -CONFIG_SCHEMA = airthings_wave_base.BASE_SCHEMA.extend( - { - cv.GenerateID(): cv.declare_id(AirthingsWavePlus), - cv.Optional(CONF_RADON): sensor.sensor_schema( - unit_of_measurement=UNIT_BECQUEREL_PER_CUBIC_METER, - icon=ICON_RADIOACTIVE, - accuracy_decimals=0, - state_class=STATE_CLASS_MEASUREMENT, - ), - cv.Optional(CONF_RADON_LONG_TERM): sensor.sensor_schema( - unit_of_measurement=UNIT_BECQUEREL_PER_CUBIC_METER, - icon=ICON_RADIOACTIVE, - accuracy_decimals=0, - state_class=STATE_CLASS_MEASUREMENT, - ), - 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_ILLUMINANCE): sensor.sensor_schema( - unit_of_measurement=UNIT_LUX, - accuracy_decimals=0, - device_class=DEVICE_CLASS_ILLUMINANCE, - state_class=STATE_CLASS_MEASUREMENT, - ), - } + +def validate_wave_gen2_config(config: ConfigType) -> ConfigType: + """Validate that Wave Gen2 devices don't have CO2 or TVOC sensors.""" + if config[CONF_DEVICE_TYPE] == "WAVE_GEN2": + if CONF_CO2 in config: + raise cv.Invalid("Wave Gen2 devices do not support CO2 sensor") + # Check for TVOC in the base schema config + if CONF_TVOC in config: + raise cv.Invalid("Wave Gen2 devices do not support TVOC sensor") + return config + + +CONFIG_SCHEMA = cv.All( + airthings_wave_base.BASE_SCHEMA.extend( + { + cv.GenerateID(): cv.declare_id(AirthingsWavePlus), + cv.Optional(CONF_RADON): sensor.sensor_schema( + unit_of_measurement=UNIT_BECQUEREL_PER_CUBIC_METER, + icon=ICON_RADIOACTIVE, + accuracy_decimals=0, + state_class=STATE_CLASS_MEASUREMENT, + ), + cv.Optional(CONF_RADON_LONG_TERM): sensor.sensor_schema( + unit_of_measurement=UNIT_BECQUEREL_PER_CUBIC_METER, + icon=ICON_RADIOACTIVE, + accuracy_decimals=0, + state_class=STATE_CLASS_MEASUREMENT, + ), + 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_ILLUMINANCE): sensor.sensor_schema( + unit_of_measurement=UNIT_LUX, + accuracy_decimals=0, + device_class=DEVICE_CLASS_ILLUMINANCE, + state_class=STATE_CLASS_MEASUREMENT, + ), + cv.Optional(CONF_DEVICE_TYPE, default="WAVE_PLUS"): cv.enum( + DEVICE_TYPES, upper=True + ), + } + ), + validate_wave_gen2_config, ) @@ -73,3 +99,4 @@ async def to_code(config): if config_illuminance := config.get(CONF_ILLUMINANCE): sens = await sensor.new_sensor(config_illuminance) cg.add(var.set_illuminance(sens)) + cg.add(var.set_device_type(config[CONF_DEVICE_TYPE]))