├── .gitignore ├── hacs.json ├── images └── screenshot.png ├── logos ├── tfa-logo-2021-256.png ├── tfa-logo-2021-512.png ├── dark_tfa-logo-2021-256.png ├── dark_tfa-logo-2021-512.png ├── logo_tfa-logo-2021_256.png ├── logo_tfa-logo-2021_512.png ├── dark_logo_tfa-logo-2021_256.png ├── dark_logo_tfa-logo-2021_512.png ├── logo-white.svg └── tfa-logo-2021.svg ├── resources └── AN135-CO2mini-usb-protocol.pdf ├── .github └── workflows │ ├── Integration_Validation.yml │ └── HACS_Validation.yml ├── custom_components └── airco2ntrol │ ├── manifest.json │ ├── config_flow.py │ ├── __init__.py │ ├── sensor_reader.py │ └── sensor.py ├── LICENSE └── README.md /.gitignore: -------------------------------------------------------------------------------- 1 | /.idea 2 | /__pycache__ 3 | -------------------------------------------------------------------------------- /hacs.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "airco2ntrol" 3 | } -------------------------------------------------------------------------------- /images/screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/leorbs/airco2ntrol/HEAD/images/screenshot.png -------------------------------------------------------------------------------- /logos/tfa-logo-2021-256.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/leorbs/airco2ntrol/HEAD/logos/tfa-logo-2021-256.png -------------------------------------------------------------------------------- /logos/tfa-logo-2021-512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/leorbs/airco2ntrol/HEAD/logos/tfa-logo-2021-512.png -------------------------------------------------------------------------------- /logos/dark_tfa-logo-2021-256.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/leorbs/airco2ntrol/HEAD/logos/dark_tfa-logo-2021-256.png -------------------------------------------------------------------------------- /logos/dark_tfa-logo-2021-512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/leorbs/airco2ntrol/HEAD/logos/dark_tfa-logo-2021-512.png -------------------------------------------------------------------------------- /logos/logo_tfa-logo-2021_256.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/leorbs/airco2ntrol/HEAD/logos/logo_tfa-logo-2021_256.png -------------------------------------------------------------------------------- /logos/logo_tfa-logo-2021_512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/leorbs/airco2ntrol/HEAD/logos/logo_tfa-logo-2021_512.png -------------------------------------------------------------------------------- /logos/dark_logo_tfa-logo-2021_256.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/leorbs/airco2ntrol/HEAD/logos/dark_logo_tfa-logo-2021_256.png -------------------------------------------------------------------------------- /logos/dark_logo_tfa-logo-2021_512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/leorbs/airco2ntrol/HEAD/logos/dark_logo_tfa-logo-2021_512.png -------------------------------------------------------------------------------- /resources/AN135-CO2mini-usb-protocol.pdf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/leorbs/airco2ntrol/HEAD/resources/AN135-CO2mini-usb-protocol.pdf -------------------------------------------------------------------------------- /.github/workflows/Integration_Validation.yml: -------------------------------------------------------------------------------- 1 | name: Validate with hassfest 2 | 3 | on: 4 | push: 5 | pull_request: 6 | schedule: 7 | - cron: '0 0 * * *' 8 | 9 | jobs: 10 | validate: 11 | runs-on: "ubuntu-latest" 12 | steps: 13 | - uses: "actions/checkout@v4" 14 | - uses: "home-assistant/actions/hassfest@master" -------------------------------------------------------------------------------- /.github/workflows/HACS_Validation.yml: -------------------------------------------------------------------------------- 1 | name: HACS Action 2 | 3 | on: 4 | push: 5 | pull_request: 6 | schedule: 7 | - cron: "0 0 * * *" 8 | 9 | jobs: 10 | hacs: 11 | name: HACS Action 12 | runs-on: "ubuntu-latest" 13 | steps: 14 | - name: HACS Action 15 | uses: "hacs/action@main" 16 | with: 17 | category: "integration" -------------------------------------------------------------------------------- /custom_components/airco2ntrol/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "domain": "airco2ntrol", 3 | "name": "airco2ntrol CO2 Sensor", 4 | "codeowners": ["@leorbs"], 5 | "config_flow": true, 6 | "dependencies": [], 7 | "documentation": "https://github.com/leorbs/airco2ntrol", 8 | "iot_class": "local_polling", 9 | "issue_tracker": "https://github.com/leorbs/airco2ntrol/issues", 10 | "requirements": [], 11 | "version": "0.5.0" 12 | } 13 | -------------------------------------------------------------------------------- /custom_components/airco2ntrol/config_flow.py: -------------------------------------------------------------------------------- 1 | """Config flow for AirCO2ntrol integration.""" 2 | import logging 3 | import voluptuous as vol 4 | from homeassistant import config_entries 5 | 6 | from . import DOMAIN 7 | 8 | _LOGGER = logging.getLogger(__name__) 9 | 10 | class AirCO2ntrolConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): 11 | """Handle a config flow for AirCO2ntrol.""" 12 | 13 | VERSION = 1 14 | 15 | async def async_step_user(self, user_input=None): 16 | """Handle the initial step.""" 17 | errors = {} 18 | if user_input is not None: 19 | # Create the config entry 20 | return self.async_create_entry(title="AirCO2ntrol", data={}) 21 | 22 | return self.async_show_form( 23 | step_id="user", 24 | data_schema=vol.Schema({}), 25 | errors=errors, 26 | ) 27 | -------------------------------------------------------------------------------- /custom_components/airco2ntrol/__init__.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from homeassistant.core import HomeAssistant 3 | from homeassistant.config_entries import ConfigEntry 4 | from homeassistant.helpers.discovery import async_load_platform 5 | 6 | DOMAIN = "airco2ntrol" 7 | PLATFORMS = ["sensor"] 8 | 9 | _LOGGER = logging.getLogger(__name__) 10 | 11 | async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry): 12 | """Set up AirCO2ntrol from a config entry.""" 13 | _LOGGER.info("Setting up AirCO2ntrol integration") 14 | 15 | hass.data.setdefault(DOMAIN, {}) 16 | 17 | # Load sensor platform 18 | await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) 19 | 20 | return True 21 | 22 | async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry): 23 | """Unload an AirCO2ntrol config entry.""" 24 | return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) 25 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 leorbs 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /logos/logo-white.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | 9 | Logo TFA 10 | 11 | 12 | 13 | 14 | 16 | 17 | 18 | 22 | 23 | 24 | -------------------------------------------------------------------------------- /logos/tfa-logo-2021.svg: -------------------------------------------------------------------------------- 1 | 2 | 13 | 15 | 16 | 18 | image/svg+xml 19 | 21 | 22 | 23 | 24 | 25 | 27 | 29 | 33 | 35 | 39 | 43 | 47 | 48 | 52 | 53 | 54 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Aircontrol CO2 Monitor Mini TFA 2 | 3 | A custom [home-assistant](https://www.home-assistant.io/) component for a family of compatible CO2 monitors distributed under various names 4 | - [TFA Dostmann CO2 Monitor AIRCO2NTROL MINI](https://www.tfa-dostmann.de/en/produkt/co2-monitor-airco2ntrol-mini/). 5 | - [ZyAura ZGm053U](https://www.zyaura.com/product-detail/zgm053u/) 6 | - [TFA Dostmann CO2 Monitor AIRCO2NTROL COACH](https://www.tfa-dostmann.de/en/product/co2-monitor-airco2ntrol-coach-31-5009/) 7 | - [ZyAura ZGm27](https://www.zyaura.com/product-detail/zgm27/) 8 | 9 | Idea based on "[Reverse-Engineering a low-cost USB CO₂ monitor](https://hackaday.io/project/5301-reverse-engineering-a-low-cost-usb-co-monitor)". 10 | Thx [Henryk Plötz](https://hackaday.io/henryk). Code for this HA integration originally taken from [jansauer](https://github.com/jansauer/home-assistant_config/tree/master/config/custom_components/airco2ntrol). 11 | Some older devices have a static encryption (as [Henryk Plötz](https://hackaday.io/henryk) found out). 12 | This integration is both, with the newer and older devices compatible. 13 | 14 | ## Setup 15 | If you a very old version from this repository installed, remove ``airco2ntrol`` from your `configuration.yaml` 16 | ### Automatic 17 | 1. Visit your HACS and install the airco2ntrol integration from there 18 | 2. Follow step 2. from the "Manual" Section below 19 | 20 | ### Manual 21 | 1. Upload the `custom_components/airco2ntrol` folder to your `custom_components` folder (using Samba or FTP addons). 22 | It should look like `config/custom_components/airco2ntrol/`. 23 | 2. Restart your Home Assistant. 24 | 3. Go to your Integrations, click on "ADD INTEGRATION" and search for "`co2`" 25 | 4. choose ``airco2ntrol`` and click on "SUBMIT" 26 | 5. The integration should create 3 entities as soon as you have the CO2 sensor attached to your server 27 | 28 | ## Functionality 29 | This is how your sensors measurement might look: 30 | 31 | ![component screenshot](images/screenshot.png) 32 | 33 | This integration should provide: 34 | - CO2 values 35 | - Temperature 36 | - OPTIONAL: Humidity 37 | 38 | Some devices appear to have a humidity readings. If they do so, then the humidity entity will be usable. 39 | 40 | ## Notes 41 | The pdf in this repository describes the usb protocol used by airco2ntrol device 42 | 43 | -------------------------------------------------------------------------------- /custom_components/airco2ntrol/sensor_reader.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | IDX_FNK = 0 4 | IDX_MSB = 1 5 | IDX_LSB = 2 6 | IDX_CHK = 3 7 | 8 | KEY = [0xc4, 0xc6, 0xc0, 0x92, 0x40, 0x23, 0xdc, 0x96] 9 | CSTATE = [0x48, 0x74, 0x65, 0x6D, 0x70, 0x39, 0x39, 0x65] 10 | SHUFFLE = [2, 4, 0, 7, 1, 6, 5, 3] 11 | 12 | POLL_MODE_NORMAL = 'normal' 13 | POLL_MODE_DECRYPT = 'decrypt' 14 | 15 | _LOGGER = logging.getLogger(__name__) 16 | 17 | def _decrypt(data): 18 | phase1 = [0] * 8 19 | for i, o in enumerate(SHUFFLE): 20 | phase1[o] = data[i] 21 | phase2 = [0] * 8 22 | for i in range(8): 23 | phase2[i] = phase1[i] ^ KEY[i] 24 | phase3 = [0] * 8 25 | for i in range(8): 26 | phase3[i] = ( (phase2[i] >> 3) | (phase2[ (i-1+8)%8 ] << 5) ) & 0xff 27 | ctmp = [0] * 8 28 | for i in range(8): 29 | ctmp[i] = ( (CSTATE[i] >> 4) | (CSTATE[i]<<4) ) & 0xff 30 | out = [0] * 8 31 | for i in range(8): 32 | out[i] = (0x100 + phase3[i] - ctmp[i]) & 0xff 33 | return out 34 | 35 | class SensorReader: 36 | def __init__(self, fp): 37 | self._fp = fp 38 | self._mode = None 39 | 40 | def _init(self): 41 | _LOGGER.info("Trying to poll in 'normal' mode") 42 | data = self._poll_normal() 43 | if data is not None: 44 | _LOGGER.info("Setting poll mode to 'normal' mode") 45 | self._mode = POLL_MODE_NORMAL 46 | return 47 | 48 | _LOGGER.info("Trying to poll in 'decrypt' mode") 49 | data = self._poll_decrypt() 50 | if data is not None: 51 | _LOGGER.info("Setting poll mode to 'decrypt' mode") 52 | self._mode = POLL_MODE_DECRYPT 53 | return 54 | 55 | _LOGGER.warning("neither 'normal', nor 'decrypt' mode was successful") 56 | 57 | 58 | def poll_function_and_value(self): 59 | data = self._poll() 60 | if data is None: 61 | return None 62 | calculated_value = (data[IDX_MSB] << 8) | data[IDX_LSB] 63 | return data[IDX_FNK], calculated_value 64 | 65 | def _poll(self): 66 | if self._mode is None: 67 | self._init() 68 | 69 | if self._mode == POLL_MODE_NORMAL: 70 | return self._poll_normal() 71 | elif self._mode == POLL_MODE_DECRYPT: 72 | return self._poll_decrypt() 73 | 74 | def _poll_normal(self): 75 | data = list(self._fp.read(5)) 76 | if ((data[IDX_MSB] + data[IDX_LSB] + data[IDX_FNK]) % 256) != data[IDX_CHK]: 77 | _LOGGER.info("Checksum incorrect for 'normal' mode: %s", data) 78 | return None 79 | else: 80 | return data 81 | 82 | def _poll_decrypt(self): 83 | data = list(self._fp.read(8)) 84 | decrypted = _decrypt(data) 85 | if decrypted[4] != 0x0d or (sum(decrypted[:3]) & 0xff) != decrypted[3]: 86 | _LOGGER.info("Checksum incorrect for 'decrypt' mode: %s", data) 87 | return None 88 | else: 89 | return decrypted 90 | 91 | 92 | 93 | -------------------------------------------------------------------------------- /custom_components/airco2ntrol/sensor.py: -------------------------------------------------------------------------------- 1 | """ 2 | Home Assistant support for the TFA Dostmann: CO2 Monitor AIRCO2NTROL MINI sensor. 3 | 4 | Original Implementation: 5 | Homepage: https://github.com/jansauer/home-assistant_config/tree/master/config/custom_components/airco2ntrol 6 | """ 7 | import fcntl 8 | import logging 9 | import os 10 | import datetime 11 | 12 | from homeassistant.components.sensor import SensorDeviceClass, SensorEntity, SensorStateClass 13 | from homeassistant.const import UnitOfTemperature, CONCENTRATION_PARTS_PER_MILLION, PERCENTAGE 14 | from homeassistant.exceptions import ConfigEntryNotReady 15 | from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, CoordinatorEntity 16 | from homeassistant.helpers.entity_platform import AddEntitiesCallback 17 | from homeassistant.core import HomeAssistant 18 | from homeassistant.config_entries import ConfigEntry 19 | from custom_components.airco2ntrol.sensor_reader import SensorReader 20 | 21 | _LOGGER = logging.getLogger(__name__) 22 | 23 | HIDIOCSFEATURE_9 = 0xC0094806 24 | 25 | 26 | POLL_INTERVAL = 20 # seconds 27 | POLL_INTERVAL_TIMEDELTA = datetime.timedelta(seconds=POLL_INTERVAL) 28 | 29 | 30 | CONVERGENCE_SPEED = 20 31 | 32 | HID_KEYWORDS = ["Holtek", "zyTemp"] # Adjust based on your actual device name 33 | 34 | 35 | def get_device_unique_id(file): 36 | """Get a unique ID for the HID device based on available attributes.""" 37 | try: 38 | hid_uniq = None 39 | hid_id = None 40 | 41 | for line in file: 42 | if line.startswith("HID_UNIQ="): 43 | hid_uniq = line.strip().split("=")[1] 44 | elif line.startswith("HID_ID="): 45 | parts = line.strip().split("=")[1].split(":") 46 | if len(parts) >= 3: 47 | hid_id = f"{parts[1]}:{parts[2]}" # Extract VID:PID 48 | 49 | # Prefer HID_UNIQ if available 50 | if hid_uniq and hid_uniq != "": 51 | return hid_uniq 52 | elif hid_id: 53 | return hid_id 54 | 55 | except FileNotFoundError: 56 | _LOGGER.warning(f"Cannot read {file}. Cannot determine unique ID of device") 57 | raise 58 | 59 | 60 | def get_device_path(): 61 | """Find the correct HID device and return (device_path, unique_id).""" 62 | try: 63 | for device in os.listdir('/sys/class/hidraw/'): 64 | uevent_path = f"/sys/class/hidraw/{device}/device/uevent" 65 | 66 | try: 67 | with open(uevent_path, "r") as file: 68 | for line in file: 69 | if line.startswith("HID_NAME="): 70 | device_name = line.strip().split("=")[1] 71 | if any(keyword in device_name for keyword in HID_KEYWORDS): 72 | unique_id = get_device_unique_id(file) 73 | return f"/dev/{device}", unique_id 74 | 75 | except FileNotFoundError: 76 | _LOGGER.warning(f"Cannot read {uevent_path}, skipping.") 77 | 78 | raise FileNotFoundError("No matching HID device found.") 79 | 80 | except Exception as e: 81 | _LOGGER.error(f"Error finding HID device: {e}") 82 | raise 83 | 84 | class AirCO2ntrolReader: 85 | """Class to interact with the AirCO2ntrol sensor.""" 86 | 87 | def __init__(self): 88 | """Initialize the reader.""" 89 | self.carbon_dioxide = None 90 | self.temperature = None 91 | self.humidity = None 92 | self._sensorReader = None 93 | 94 | def _recover(self): 95 | """Attempt to recover the connection to the device.""" 96 | try: 97 | self.device_path, _ = get_device_path() 98 | _LOGGER.info("Trying to initialize connection...") 99 | _fp = open(self.device_path, 'ab+', 0) 100 | _LOGGER.info("Setting connection mode...") 101 | fcntl.ioctl(_fp, HIDIOCSFEATURE_9, bytearray.fromhex('00 c4 c6 c0 92 40 23 dc 96')) 102 | self._sensorReader = SensorReader(_fp) 103 | except FileNotFoundError as e: 104 | _LOGGER.warning(f"Did not find HID device. Is it plugged in? Message: {e}") 105 | self._sensorReader = None 106 | except Exception as e: 107 | _LOGGER.error(f"Device initialization failed: {e}") 108 | self._sensorReader = None 109 | 110 | def update(self): 111 | """Poll the latest sensor data.""" 112 | if not self._sensorReader: 113 | _LOGGER.info("Currently no device connected. Trying to find and connect to CO2 Device.") 114 | self._recover() 115 | if not self._sensorReader: 116 | return { 117 | "co2": None, 118 | "temperature": None, 119 | "humidity": None, 120 | "available": False 121 | } 122 | 123 | _LOGGER.debug("Polling latest sensor data.") 124 | got_carbon_dioxide = None 125 | got_temperature = None 126 | got_humidity = None 127 | for _ in range(CONVERGENCE_SPEED): # Try a few times 128 | function, value = self._safe_poll_function_and_value() 129 | if not function or not value: 130 | continue 131 | if function == 0x50: 132 | if value > 10000: 133 | # sometimes the first read of this value is something around 25k. 134 | # This is a safety to filter such implausible readings 135 | continue 136 | self.carbon_dioxide = value 137 | got_carbon_dioxide = True 138 | elif function == 0x42: 139 | self.temperature = value / 16.0 - 273.15 140 | got_temperature = True 141 | elif function == 0x41: 142 | self.humidity = value / 100 143 | got_humidity = True 144 | 145 | _LOGGER.debug(f"Got new values for carbon_dioxide:{got_carbon_dioxide} temperature:{got_temperature} humidity:{got_humidity}") 146 | return { 147 | # return all values, even if they are not the most recent 148 | "co2": self.carbon_dioxide, 149 | "temperature": self.temperature, 150 | "humidity": self.humidity, 151 | "available": True 152 | } 153 | 154 | def _safe_poll_function_and_value(self): 155 | try: 156 | return self._sensorReader.poll_function_and_value() 157 | except Exception as e: 158 | _LOGGER.warning(f"Error reading sensor data. Resetting device connection: {e}") 159 | self._sensorReader = None 160 | return None, None 161 | 162 | 163 | async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback): 164 | """Set up sensors from a config entry using the new DataUpdateCoordinator.""" 165 | 166 | try: 167 | _, unique_id = await hass.async_add_executor_job(get_device_path) 168 | except Exception as e: 169 | raise ConfigEntryNotReady(f"Could not setup device yet: {e}") 170 | 171 | reader = AirCO2ntrolReader() 172 | 173 | async def async_update(): 174 | _LOGGER.info("async_update called") 175 | data = await hass.async_add_executor_job(reader.update) 176 | _LOGGER.debug(f"Fetched sensor data: {data}") 177 | return data 178 | 179 | coordinator = DataUpdateCoordinator( 180 | hass=hass, 181 | logger=_LOGGER, 182 | name="AirCO2ntrol", 183 | update_method=async_update, 184 | update_interval=POLL_INTERVAL_TIMEDELTA 185 | ) 186 | 187 | await coordinator.async_config_entry_first_refresh() 188 | 189 | async_add_entities([ 190 | AirCO2ntrolCarbonDioxideSensor(coordinator, unique_id), 191 | AirCO2ntrolTemperatureSensor(coordinator, unique_id), 192 | AirCO2ntrolHumiditySensor(coordinator, unique_id) 193 | ]) 194 | 195 | 196 | class AirCO2ntrolSensor(CoordinatorEntity, SensorEntity): 197 | """Base class for AirCO2ntrol sensors.""" 198 | 199 | def __init__(self, coordinator, name, sensor_type, unit, icon, device_class, unique_id): 200 | """Initialize the sensor.""" 201 | super().__init__(coordinator) 202 | self._attr_name = name 203 | self._attr_native_unit_of_measurement = unit 204 | self._attr_icon = icon 205 | self._attr_device_class = device_class 206 | self._attr_unique_id = f"{unique_id}-{sensor_type}" 207 | self._attr_state_class = SensorStateClass.MEASUREMENT 208 | self.sensor_type = sensor_type 209 | 210 | @property 211 | def native_value(self): 212 | """Return the current sensor state.""" 213 | value = self.coordinator.data.get(self.sensor_type) 214 | _LOGGER.debug(f"Sensor {self._attr_unique_id} updated: {value}") 215 | return value 216 | 217 | 218 | @property 219 | def available(self) -> bool: 220 | return self.coordinator.data.get("available") 221 | 222 | 223 | class AirCO2ntrolCarbonDioxideSensor(AirCO2ntrolSensor): 224 | """CO2 Sensor.""" 225 | 226 | def __init__(self, coordinator, unique_id): 227 | super().__init__( 228 | coordinator, 229 | name="AirCO2ntrol Carbon Dioxide", 230 | sensor_type="co2", 231 | unit=CONCENTRATION_PARTS_PER_MILLION, 232 | icon="mdi:molecule-co2", 233 | device_class=SensorDeviceClass.CO2, 234 | unique_id=unique_id 235 | ) 236 | 237 | 238 | class AirCO2ntrolTemperatureSensor(AirCO2ntrolSensor): 239 | """Temperature Sensor.""" 240 | 241 | def __init__(self, coordinator, unique_id): 242 | super().__init__( 243 | coordinator, 244 | name="AirCO2ntrol Temperature", 245 | sensor_type="temperature", 246 | unit=UnitOfTemperature.CELSIUS, 247 | icon="mdi:thermometer", 248 | device_class=SensorDeviceClass.TEMPERATURE, 249 | unique_id=unique_id 250 | ) 251 | 252 | 253 | class AirCO2ntrolHumiditySensor(AirCO2ntrolSensor): 254 | """Humidity Sensor.""" 255 | 256 | def __init__(self, coordinator, unique_id): 257 | super().__init__( 258 | coordinator, 259 | name="AirCO2ntrol Humidity", 260 | sensor_type="humidity", 261 | unit=PERCENTAGE, 262 | icon="mdi:water-percent", 263 | device_class=SensorDeviceClass.HUMIDITY, 264 | unique_id=unique_id 265 | ) 266 | --------------------------------------------------------------------------------