├── .gitattributes ├── README.md └── custom_components └── clear_grass ├── __init__.py ├── manifest.json └── sensor.py /.gitattributes: -------------------------------------------------------------------------------- 1 | # Auto detect text files and perform LF normalization 2 | * text=auto 3 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![Stand With Ukraine](https://raw.githubusercontent.com/vshymanskyy/StandWithUkraine/main/banner-direct-single.svg)](https://stand-with-ukraine.pp.ua) 2 | 3 | # clear_grass-ha 4 | Xiaomi ClearGrass Air Detector integration into HA 5 | 6 | Based on: 7 | https://github.com/rytilahti/python-miio/blob/master/miio/airqualitymonitor.py 8 | https://github.com/syssi/xiaomi_airqualitymonitor/blob/develop/custom_components/sensor/xiaomi_miio.py 9 | 10 | ## WARNING 11 | This integration is not official. There will be official support in HA(i believe). 12 | 13 | ## Features 14 | - PM2.5 15 | - CO2 16 | - TVOC 17 | - Temperature 18 | - Humidity 19 | 20 | ## Setup 21 | 22 | Merge custom_components folder 23 | 24 | ```yaml 25 | # configuration.yaml 26 | 27 | 28 | # Same as Xiaomi Air Quality Monitor 29 | sensor: 30 | - platform: clear_grass 31 | name: Xiaomi ClearGrass Air Detector 32 | host: 192.168.130.73 33 | token: YOUR_TOKEN 34 | 35 | ``` 36 | 37 | To retreive TOKEN use this instructions: 38 | https://www.home-assistant.io/components/vacuum.xiaomi_miio/#retrieving-the-access-token 39 | 40 | ## Disclaimer 41 | This software is supplied "AS IS" without any warranties and support. 42 | 43 | -------------------------------------------------------------------------------- /custom_components/clear_grass/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Cheaterdev/clear_grass-ha/6867c12ce492384aa79cd24a5657f0bd573b2060/custom_components/clear_grass/__init__.py -------------------------------------------------------------------------------- /custom_components/clear_grass/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "domain": "clear_grass", 3 | "name": "ClearGlass", 4 | "version": "0.0.1", 5 | "documentation": "https://github.com/Cheaterdev/clear_grass-ha", 6 | "requirements": [ 7 | "construct==2.10.68", 8 | "python-miio>=0.4.5" 9 | ], 10 | "dependencies": [], 11 | "codeowners": [ 12 | "@cheater.dev" 13 | ] 14 | } 15 | -------------------------------------------------------------------------------- /custom_components/clear_grass/sensor.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from collections import defaultdict 3 | 4 | import click 5 | import json 6 | from miio.click_common import command, format_output 7 | from miio.device import Device 8 | 9 | _LOGGER = logging.getLogger(__name__) 10 | 11 | MODEL_AIRQUALITYMONITOR_S1 = 'cgllc.airmonitor.s1' 12 | 13 | AVAILABLE_PROPERTIES_COMMON = [ 'battery', 'battery_state', 'co2', 'humidity', 'pm25' , 'temperature', 'tvoc'] 14 | 15 | AVAILABLE_PROPERTIES = { 16 | MODEL_AIRQUALITYMONITOR_S1: AVAILABLE_PROPERTIES_COMMON, 17 | } 18 | 19 | class AirQualityMonitorStatus: 20 | """Container of air quality monitor status.""" 21 | 22 | def __init__(self, data): 23 | self.data = data 24 | 25 | @property 26 | def power(self) -> str: 27 | """Current power state.""" 28 | return self.data["power"] 29 | 30 | @property 31 | def is_on(self) -> bool: 32 | """Return True if the device is turned on.""" 33 | return self.power == "on" 34 | 35 | @property 36 | def temperature(self) -> bool: 37 | """Return True if the device's usb is on.""" 38 | return self.data["temperature"] 39 | 40 | @property 41 | def humidity(self) -> int: 42 | """Air quality index value. (0...600).""" 43 | return self.data["humidity"] 44 | 45 | @property 46 | def co2(self) -> int: 47 | """Air quality index value. (0...600).""" 48 | return self.data["co2"] 49 | 50 | @property 51 | def tvoc(self) -> int: 52 | """Current battery level (0...100).""" 53 | return self.data["tvoc"] 54 | 55 | @property 56 | def pm25(self) -> bool: 57 | """Display a clock instead the AQI.""" 58 | return self.data["pm25"] 59 | 60 | @property 61 | def battery(self) -> bool: 62 | """Return True if the night mode is on.""" 63 | return self.data["battery"] 64 | 65 | @property 66 | def battery_state(self) -> str: 67 | """Return the begin of the night time.""" 68 | return self.data["battery_state"] 69 | 70 | def __repr__(self) -> str: 71 | s = "" % \ 77 | (self.humidity, 78 | self.co2, 79 | self.tvoc, 80 | self.pm25, 81 | self.battery, 82 | self.battery_state, 83 | ) 84 | return s 85 | 86 | def __json__(self): 87 | return self.data 88 | 89 | 90 | class AirQualityMonitor(Device): 91 | """Xiaomi PM2.5 Air Quality Monitor.""" 92 | def __init__(self, ip: str = None, token: str = None, start_id: int = 0, 93 | debug: int = 0, lazy_discover: bool = True, 94 | model: str = MODEL_AIRQUALITYMONITOR_S1) -> None: 95 | super().__init__(ip, token, start_id, debug, lazy_discover, model=model) 96 | 97 | if model not in AVAILABLE_PROPERTIES: 98 | _LOGGER.error("Device model %s unsupported. Falling back to %s.", model, self.model) 99 | 100 | self.device_info = None 101 | 102 | @command( 103 | default_output=format_output( 104 | "" 105 | ) 106 | ) 107 | def status(self) -> AirQualityMonitorStatus: 108 | """Return device status.""" 109 | 110 | properties = AVAILABLE_PROPERTIES[self.model] 111 | 112 | values = self.send( 113 | "get_prop", 114 | properties 115 | ) 116 | 117 | properties_count = len(properties) 118 | values_count = len(values) 119 | if properties_count != values_count: 120 | _LOGGER.error( 121 | "Count (%s) of requested properties does not match the " 122 | "count (%s) of received values.", 123 | properties_count, values_count) 124 | 125 | return AirQualityMonitorStatus( 126 | defaultdict(lambda: None, values)) 127 | 128 | @command( 129 | default_output=format_output("Powering on"), 130 | ) 131 | def on(self): 132 | """Power on.""" 133 | return self.send("set_power", ["on"]) 134 | 135 | @command( 136 | default_output=format_output("Powering off"), 137 | ) 138 | def off(self): 139 | """Power off.""" 140 | return self.send("set_power", ["off"]) 141 | 142 | @command( 143 | click.argument("display_clock", type=bool), 144 | default_output=format_output( 145 | lambda led: "Turning on display clock" 146 | if led else "Turning off display clock" 147 | ) 148 | ) 149 | def set_display_clock(self, display_clock: bool): 150 | """Enable/disable displaying a clock instead the AQI.""" 151 | if display_clock: 152 | self.send("set_time_state", ["on"]) 153 | else: 154 | self.send("set_time_state", ["off"]) 155 | 156 | @command( 157 | click.argument("auto_close", type=bool), 158 | default_output=format_output( 159 | lambda led: "Turning on auto close" 160 | if led else "Turning off auto close" 161 | ) 162 | ) 163 | def set_auto_close(self, auto_close: bool): 164 | """Purpose unknown.""" 165 | if auto_close: 166 | self.send("set_auto_close", ["on"]) 167 | else: 168 | self.send("set_auto_close", ["off"]) 169 | 170 | @command( 171 | click.argument("night_mode", type=bool), 172 | default_output=format_output( 173 | lambda led: "Turning on night mode" 174 | if led else "Turning off night mode" 175 | ) 176 | ) 177 | def set_night_mode(self, night_mode: bool): 178 | """Decrease the brightness of the display.""" 179 | if night_mode: 180 | self.send("set_night_state", ["on"]) 181 | else: 182 | self.send("set_night_state", ["off"]) 183 | 184 | @command( 185 | click.argument("begin_hour", type=int), 186 | click.argument("begin_minute", type=int), 187 | click.argument("end_hour", type=int), 188 | click.argument("end_minute", type=int), 189 | default_output=format_output( 190 | "Setting night time to {begin_hour}:{begin_minute} - {end_hour}:{end_minute}") 191 | ) 192 | def set_night_time(self, begin_hour: int, begin_minute: int, 193 | end_hour: int, end_minute: int): 194 | """Enable night mode daily at bedtime.""" 195 | begin = begin_hour * 3600 + begin_minute * 60 196 | end = end_hour * 3600 + end_minute * 60 197 | 198 | if begin < 0 or begin > 86399 or end < 0 or end > 86399: 199 | raise Exception("Begin or/and end time invalid.") 200 | 201 | self.send("set_night_time", [begin, end]) 202 | 203 | 204 | """Support for Xiaomi Mi Air Quality Monitor (PM2.5).""" 205 | import logging 206 | 207 | import voluptuous as vol 208 | 209 | from homeassistant.components.sensor import PLATFORM_SCHEMA 210 | from homeassistant.const import CONF_HOST, CONF_NAME, CONF_TOKEN 211 | from homeassistant.exceptions import PlatformNotReady 212 | import homeassistant.helpers.config_validation as cv 213 | from homeassistant.helpers.entity import Entity 214 | 215 | _LOGGER = logging.getLogger(__name__) 216 | 217 | DEFAULT_NAME = 'clear_grass' 218 | DATA_KEY = 'sensor.clear_grass' 219 | 220 | PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ 221 | vol.Required(CONF_HOST): cv.string, 222 | vol.Required(CONF_TOKEN): vol.All(cv.string, vol.Length(min=32, max=32)), 223 | vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, 224 | }) 225 | 226 | 227 | ATTR_TEMPERATURE = 'temperature' 228 | ATTR_HUMIDITY = 'humidity' 229 | ATTR_CO2 = 'co2' 230 | ATTR_TVOC = 'tvoc' 231 | ATTR_PM25 = 'pm25' 232 | 233 | ATTR_BATTERY_LEVEL = 'battery_level' 234 | ATTR_BATTERY_STATE = 'battery_state' 235 | 236 | ATTR_MODEL = 'model' 237 | 238 | SUCCESS = ['ok'] 239 | 240 | 241 | async def async_setup_platform(hass, config, async_add_entities, 242 | discovery_info=None): 243 | """Set up the sensor from config.""" 244 | if DATA_KEY not in hass.data: 245 | hass.data[DATA_KEY] = {} 246 | 247 | host = config.get(CONF_HOST) 248 | name = config.get(CONF_NAME) 249 | token = config.get(CONF_TOKEN) 250 | 251 | _LOGGER.info("Initializing with host %s (token %s...)", host, token[:5]) 252 | 253 | try: 254 | air_quality_monitor = AirQualityMonitor(host, token) 255 | device_info = air_quality_monitor.info() 256 | model = device_info.model 257 | unique_id = "{}-{}".format(model, device_info.mac_address) 258 | _LOGGER.info("%s %s %s detected", 259 | model, 260 | device_info.firmware_version, 261 | device_info.hardware_version) 262 | device = ClearGrassMonitor( 263 | name, air_quality_monitor, model, unique_id) 264 | except Exception: 265 | raise PlatformNotReady 266 | 267 | hass.data[DATA_KEY][host] = device 268 | async_add_entities([device], update_before_add=True) 269 | 270 | 271 | class ClearGrassMonitor(Entity): 272 | """Representation of a Xiaomi Air Quality Monitor.""" 273 | 274 | def __init__(self, name, device, model, unique_id): 275 | """Initialize the entity.""" 276 | self._name = name 277 | self._device = device 278 | self._model = model 279 | self._unique_id = unique_id 280 | 281 | self._icon = 'mdi:cloud' 282 | self._unit_of_measurement = 'AQI' 283 | self._available = None 284 | self._state = None 285 | self._state_attrs = { 286 | ATTR_TEMPERATURE: None, 287 | ATTR_HUMIDITY: None, 288 | ATTR_CO2: None, 289 | ATTR_TVOC: None, 290 | #ATTR_PM25: None, 291 | ATTR_BATTERY_LEVEL: None, 292 | ATTR_BATTERY_STATE: None, 293 | ATTR_MODEL: self._model, 294 | } 295 | 296 | @property 297 | def should_poll(self): 298 | """Poll the miio device.""" 299 | return True 300 | 301 | @property 302 | def unique_id(self): 303 | """Return an unique ID.""" 304 | return self._unique_id 305 | 306 | @property 307 | def name(self): 308 | """Return the name of this entity, if any.""" 309 | return self._name 310 | 311 | @property 312 | def unit_of_measurement(self): 313 | """Return the unit of measurement of this entity, if any.""" 314 | return self._unit_of_measurement 315 | 316 | @property 317 | def icon(self): 318 | """Return the icon to use for device if any.""" 319 | return self._icon 320 | 321 | @property 322 | def available(self): 323 | """Return true when state is known.""" 324 | return self._available 325 | 326 | @property 327 | def state(self): 328 | """Return the state of the device.""" 329 | return self._state 330 | 331 | @property 332 | def extra_state_attributes(self): 333 | """Return the state attributes of the device.""" 334 | return self._state_attrs 335 | 336 | async def async_update(self): 337 | """Fetch state from the miio device.""" 338 | try: 339 | state = await self.hass.async_add_executor_job(self._device.status) 340 | 341 | self._available = True 342 | self._state = state.pm25 343 | self._state_attrs.update({ 344 | ATTR_TEMPERATURE: state.temperature, 345 | ATTR_HUMIDITY: state.humidity, 346 | ATTR_CO2: state.co2, 347 | ATTR_TVOC: state.tvoc, 348 | # ATTR_PM25:state.pm25, 349 | ATTR_BATTERY_LEVEL:state.battery, 350 | ATTR_BATTERY_STATE:state.battery_state, 351 | }) 352 | 353 | except Exception as ex: 354 | self._available = False 355 | _LOGGER.error("Got exception while fetching the state: %s", ex) 356 | --------------------------------------------------------------------------------