Add integration for AirVisual Pro (#79770)

* Add integration for AirVisual Pro

* Tests

* A few more redactions

* Loggers

* Consistency

* Remove unnecessary f-string

* Use `entry.as_dict()` in diagnostics

* One call

* Integration types

* Cleanup

* Import cleanup

* Code review

* Code review

* Code review
This commit is contained in:
Aaron Bach
2022-12-12 14:28:27 -07:00
committed by GitHub
parent 41041cb673
commit 3d4ee5906d
22 changed files with 732 additions and 5 deletions

View File

@ -46,6 +46,8 @@ omit =
homeassistant/components/airtouch4/const.py
homeassistant/components/airvisual/__init__.py
homeassistant/components/airvisual/sensor.py
homeassistant/components/airvisual_pro/__init__.py
homeassistant/components/airvisual_pro/sensor.py
homeassistant/components/alarmdecoder/__init__.py
homeassistant/components/alarmdecoder/alarm_control_panel.py
homeassistant/components/alarmdecoder/binary_sensor.py

View File

@ -55,6 +55,8 @@ build.json @home-assistant/supervisor
/tests/components/airtouch4/ @LonePurpleWolf
/homeassistant/components/airvisual/ @bachya
/tests/components/airvisual/ @bachya
/homeassistant/components/airvisual_pro/ @bachya
/tests/components/airvisual_pro/ @bachya
/homeassistant/components/airzone/ @Noltari
/tests/components/airzone/ @Noltari
/homeassistant/components/aladdin_connect/ @mkmer

View File

@ -0,0 +1,5 @@
{
"domain": "airvisual",
"name": "AirVisual",
"integrations": ["airvisual", "airvisual_pro"]
}

View File

@ -1,11 +1,11 @@
{
"domain": "airvisual",
"name": "AirVisual",
"name": "AirVisual Cloud",
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/airvisual",
"requirements": ["pyairvisual==2022.11.1"],
"codeowners": ["@bachya"],
"iot_class": "cloud_polling",
"loggers": ["pyairvisual", "pysmb"],
"integration_type": "device"
"integration_type": "service"
}

View File

@ -0,0 +1,102 @@
"""The AirVisual Pro integration."""
from __future__ import annotations
from datetime import timedelta
from typing import Any
from pyairvisual import NodeSamba
from pyairvisual.node import NodeProError
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_IP_ADDRESS, CONF_PASSWORD, Platform
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.entity import DeviceInfo, EntityDescription
from homeassistant.helpers.update_coordinator import (
CoordinatorEntity,
DataUpdateCoordinator,
UpdateFailed,
)
from .const import DOMAIN, LOGGER
PLATFORMS = [Platform.SENSOR]
UPDATE_INTERVAL = timedelta(minutes=1)
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up AirVisual Pro from a config entry."""
async def async_get_data() -> dict[str, Any]:
"""Get data from the device."""
try:
async with NodeSamba(
entry.data[CONF_IP_ADDRESS], entry.data[CONF_PASSWORD]
) as node:
return await node.async_get_latest_measurements()
except NodeProError as err:
raise UpdateFailed(f"Error while retrieving data: {err}") from err
coordinator = DataUpdateCoordinator(
hass,
LOGGER,
name="Node/Pro data",
update_interval=UPDATE_INTERVAL,
update_method=async_get_data,
)
await coordinator.async_config_entry_first_refresh()
hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
return True
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Unload a config entry."""
if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS):
hass.data[DOMAIN].pop(entry.entry_id)
return unload_ok
class AirVisualProEntity(CoordinatorEntity):
"""Define a generic AirVisual Pro entity."""
def __init__(
self, coordinator: DataUpdateCoordinator, description: EntityDescription
) -> None:
"""Initialize."""
super().__init__(coordinator)
self._attr_unique_id = f"{coordinator.data['serial_number']}_{description.key}"
self.entity_description = description
@property
def device_info(self) -> DeviceInfo:
"""Return device registry information for this entity."""
return DeviceInfo(
identifiers={(DOMAIN, self.coordinator.data["serial_number"])},
manufacturer="AirVisual",
model=self.coordinator.data["status"]["model"],
name=self.coordinator.data["settings"]["node_name"],
hw_version=self.coordinator.data["status"]["system_version"],
sw_version=self.coordinator.data["status"]["app_version"],
)
@callback
def _async_update_from_latest_data(self) -> None:
"""Update the entity's underlying data."""
raise NotImplementedError
@callback
def _handle_coordinator_update(self) -> None:
"""Respond to a DataUpdateCoordinator update."""
self._async_update_from_latest_data()
self.async_write_ha_state()
async def async_added_to_hass(self) -> None:
"""Handle entity which will be added."""
await super().async_added_to_hass()
self._async_update_from_latest_data()

View File

@ -0,0 +1,66 @@
"""Define a config flow manager for AirVisual Pro."""
from __future__ import annotations
from pyairvisual import NodeSamba
from pyairvisual.node import NodeProError
import voluptuous as vol
from homeassistant import config_entries
from homeassistant.const import CONF_IP_ADDRESS, CONF_PASSWORD
from homeassistant.data_entry_flow import FlowResult
from .const import DOMAIN, LOGGER
STEP_USER_SCHEMA = vol.Schema(
{
vol.Required(CONF_IP_ADDRESS): str,
vol.Required(CONF_PASSWORD): str,
}
)
class AirVisualProFlowHandler(config_entries.ConfigFlow, domain=DOMAIN):
"""Handle an AirVisual Pro config flow."""
VERSION = 1
async def async_step_user(
self, user_input: dict[str, str] | None = None
) -> FlowResult:
"""Handle the initial step."""
if not user_input:
return self.async_show_form(step_id="user", data_schema=STEP_USER_SCHEMA)
await self.async_set_unique_id(user_input[CONF_IP_ADDRESS])
self._abort_if_unique_id_configured()
errors = {}
node = NodeSamba(user_input[CONF_IP_ADDRESS], user_input[CONF_PASSWORD])
try:
await node.async_connect()
except NodeProError as err:
LOGGER.error(
"Samba error while connecting to %s: %s",
user_input[CONF_IP_ADDRESS],
err,
)
errors["base"] = "cannot_connect"
except Exception as err: # pylint: disable=broad-except
LOGGER.error(
"Unknown error while connecting to %s: %s",
user_input[CONF_IP_ADDRESS],
err,
)
errors["base"] = "unknown"
finally:
await node.async_disconnect()
if errors:
return self.async_show_form(
step_id="user", data_schema=STEP_USER_SCHEMA, errors=errors
)
return self.async_create_entry(
title=user_input[CONF_IP_ADDRESS], data=user_input
)

View File

@ -0,0 +1,6 @@
"""Constants for the AirVisual Pro integration."""
import logging
DOMAIN = "airvisual_pro"
LOGGER = logging.getLogger(__package__)

View File

@ -0,0 +1,36 @@
"""Support for AirVisual Pro diagnostics."""
from __future__ import annotations
from typing import Any
from homeassistant.components.diagnostics import async_redact_data
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_PASSWORD
from homeassistant.core import HomeAssistant
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
from .const import DOMAIN
CONF_MAC_ADDRESS = "mac_address"
CONF_SERIAL_NUMBER = "serial_number"
TO_REDACT = {
CONF_MAC_ADDRESS,
CONF_PASSWORD,
CONF_SERIAL_NUMBER,
}
async def async_get_config_entry_diagnostics(
hass: HomeAssistant, entry: ConfigEntry
) -> dict[str, Any]:
"""Return diagnostics for a config entry."""
coordinator: DataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id]
return async_redact_data(
{
"entry": entry.as_dict(),
"data": coordinator.data,
},
TO_REDACT,
)

View File

@ -0,0 +1,11 @@
{
"domain": "airvisual_pro",
"name": "AirVisual Pro",
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/airvisual_pro",
"requirements": ["pyairvisual==2022.11.1"],
"codeowners": ["@bachya"],
"iot_class": "local_polling",
"loggers": ["pyairvisual", "pysmb"],
"integration_type": "device"
}

View File

@ -0,0 +1,165 @@
"""Support for AirVisual Pro sensors."""
from __future__ import annotations
from typing import Any
from homeassistant.components.sensor import (
SensorDeviceClass,
SensorEntity,
SensorEntityDescription,
SensorStateClass,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import (
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
CONCENTRATION_PARTS_PER_MILLION,
PERCENTAGE,
TEMP_CELSIUS,
)
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.entity import EntityCategory
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
from . import AirVisualProEntity
from .const import DOMAIN
SENSOR_KIND_AQI = "air_quality_index"
SENSOR_KIND_BATTERY_LEVEL = "battery_level"
SENSOR_KIND_CO2 = "carbon_dioxide"
SENSOR_KIND_HUMIDITY = "humidity"
SENSOR_KIND_PM_0_1 = "particulate_matter_0_1"
SENSOR_KIND_PM_1_0 = "particulate_matter_1_0"
SENSOR_KIND_PM_2_5 = "particulate_matter_2_5"
SENSOR_KIND_SENSOR_LIFE = "sensor_life"
SENSOR_KIND_TEMPERATURE = "temperature"
SENSOR_KIND_VOC = "voc"
SENSOR_DESCRIPTIONS = (
SensorEntityDescription(
key=SENSOR_KIND_AQI,
name="Air quality index",
device_class=SensorDeviceClass.AQI,
native_unit_of_measurement="AQI",
state_class=SensorStateClass.MEASUREMENT,
),
SensorEntityDescription(
key=SENSOR_KIND_BATTERY_LEVEL,
name="Battery",
device_class=SensorDeviceClass.BATTERY,
entity_category=EntityCategory.DIAGNOSTIC,
native_unit_of_measurement=PERCENTAGE,
),
SensorEntityDescription(
key=SENSOR_KIND_CO2,
name="C02",
device_class=SensorDeviceClass.CO2,
native_unit_of_measurement=CONCENTRATION_PARTS_PER_MILLION,
state_class=SensorStateClass.MEASUREMENT,
),
SensorEntityDescription(
key=SENSOR_KIND_HUMIDITY,
name="Humidity",
device_class=SensorDeviceClass.HUMIDITY,
native_unit_of_measurement=PERCENTAGE,
),
SensorEntityDescription(
key=SENSOR_KIND_PM_0_1,
name="PM 0.1",
device_class=SensorDeviceClass.PM1,
native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
state_class=SensorStateClass.MEASUREMENT,
),
SensorEntityDescription(
key=SENSOR_KIND_PM_1_0,
name="PM 1.0",
device_class=SensorDeviceClass.PM10,
native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
state_class=SensorStateClass.MEASUREMENT,
),
SensorEntityDescription(
key=SENSOR_KIND_PM_2_5,
name="PM 2.5",
device_class=SensorDeviceClass.PM25,
native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
state_class=SensorStateClass.MEASUREMENT,
),
SensorEntityDescription(
key=SENSOR_KIND_TEMPERATURE,
name="Temperature",
device_class=SensorDeviceClass.TEMPERATURE,
native_unit_of_measurement=TEMP_CELSIUS,
state_class=SensorStateClass.MEASUREMENT,
),
SensorEntityDescription(
key=SENSOR_KIND_VOC,
name="VOC",
device_class=SensorDeviceClass.VOLATILE_ORGANIC_COMPOUNDS,
native_unit_of_measurement=CONCENTRATION_PARTS_PER_MILLION,
state_class=SensorStateClass.MEASUREMENT,
),
)
@callback
def async_get_aqi_locale(settings: dict[str, Any]) -> str:
"""Return the correct AQI locale based on settings data."""
if settings["is_aqi_usa"]:
return "aqi_us"
return "aqi_cn"
async def async_setup_entry(
hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
) -> None:
"""Set up AirVisual sensors based on a config entry."""
coordinator: DataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id]
async_add_entities(
AirVisualProSensor(coordinator, description)
for description in SENSOR_DESCRIPTIONS
)
class AirVisualProSensor(AirVisualProEntity, SensorEntity):
"""Define an AirVisual Pro sensor."""
_attr_has_entity_name = True
MEASUREMENTS_KEY_TO_VALUE = {
SENSOR_KIND_CO2: "co2",
SENSOR_KIND_HUMIDITY: "humidity",
SENSOR_KIND_PM_0_1: "pm0_1",
SENSOR_KIND_PM_1_0: "pm1_0",
SENSOR_KIND_PM_2_5: "pm2_5",
SENSOR_KIND_TEMPERATURE: "temperature_C",
SENSOR_KIND_VOC: "voc",
}
@property
def measurements(self) -> dict[str, Any]:
"""Define measurements data."""
return self.coordinator.data["measurements"]
@property
def settings(self) -> dict[str, Any]:
"""Define settings data."""
return self.coordinator.data["settings"]
@property
def status(self) -> dict[str, Any]:
"""Define status data."""
return self.coordinator.data["status"]
@callback
def _async_update_from_latest_data(self) -> None:
"""Update the entity from the latest data."""
if self.entity_description.key == SENSOR_KIND_AQI:
locale = async_get_aqi_locale(self.settings)
self._attr_native_value = self.measurements[locale]
elif self.entity_description.key == SENSOR_KIND_BATTERY_LEVEL:
self._attr_native_value = self.status["battery"]
else:
self._attr_native_value = self.MEASUREMENTS_KEY_TO_VALUE[
self.entity_description.key
]

View File

@ -0,0 +1,20 @@
{
"config": {
"step": {
"user": {
"description": "The password can be retrieved from the AirVisual Pro's UI.",
"data": {
"ip_address": "[%key:common::config_flow::data::host%]",
"password": "[%key:common::config_flow::data::password%]"
}
}
},
"error": {
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
"unknown": "[%key:common::config_flow::error::unknown%]"
},
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]"
}
}
}

View File

@ -0,0 +1,20 @@
{
"config": {
"abort": {
"already_configured": "Device is already configured"
},
"error": {
"cannot_connect": "Failed to connect",
"unknown": "Unexpected error"
},
"step": {
"user": {
"data": {
"ip_address": "Host",
"password": "Password"
},
"description": "The password can be retrieved from the AirVisual Pro's UI."
}
}
}
}

View File

@ -30,6 +30,7 @@ FLOWS = {
"airthings_ble",
"airtouch4",
"airvisual",
"airvisual_pro",
"airzone",
"aladdin_connect",
"alarmdecoder",

View File

@ -120,9 +120,20 @@
},
"airvisual": {
"name": "AirVisual",
"integration_type": "device",
"config_flow": true,
"iot_class": "cloud_polling"
"integrations": {
"airvisual": {
"integration_type": "service",
"config_flow": true,
"iot_class": "cloud_polling",
"name": "AirVisual Cloud"
},
"airvisual_pro": {
"integration_type": "device",
"config_flow": true,
"iot_class": "local_polling",
"name": "AirVisual Pro"
}
}
},
"airzone": {
"name": "Airzone",

View File

@ -1460,6 +1460,7 @@ pyaftership==21.11.0
pyairnow==1.1.0
# homeassistant.components.airvisual
# homeassistant.components.airvisual_pro
pyairvisual==2022.11.1
# homeassistant.components.almond

View File

@ -1048,6 +1048,7 @@ pyaehw4a1==0.3.9
pyairnow==1.1.0
# homeassistant.components.airvisual
# homeassistant.components.airvisual_pro
pyairvisual==2022.11.1
# homeassistant.components.almond

View File

@ -0,0 +1 @@
"""Tests for the AirVisual Pro integration."""

View File

@ -0,0 +1,56 @@
"""Define test fixtures for AirVisual Pro."""
import json
from unittest.mock import patch
import pytest
from homeassistant.components.airvisual_pro.const import DOMAIN
from homeassistant.const import CONF_IP_ADDRESS, CONF_PASSWORD
from homeassistant.setup import async_setup_component
from tests.common import MockConfigEntry, load_fixture
@pytest.fixture(name="config_entry")
def config_entry_fixture(hass, config, unique_id):
"""Define a config entry fixture."""
entry = MockConfigEntry(domain=DOMAIN, unique_id=unique_id, data=config)
entry.add_to_hass(hass)
return entry
@pytest.fixture(name="config")
def config_fixture(hass):
"""Define a config entry data fixture."""
return {
CONF_IP_ADDRESS: "192.168.1.101",
CONF_PASSWORD: "password123",
}
@pytest.fixture(name="data", scope="session")
def data_fixture():
"""Define an update coordinator data example."""
return json.loads(load_fixture("data.json", "airvisual_pro"))
@pytest.fixture(name="setup_airvisual_pro")
async def setup_airvisual_pro_fixture(hass, config, data):
"""Define a fixture to set up AirVisual Pro."""
with patch("homeassistant.components.airvisual_pro.NodeSamba.async_connect"), patch(
"homeassistant.components.airvisual_pro.NodeSamba.async_get_latest_measurements",
return_value=data,
), patch(
"homeassistant.components.airvisual_pro.NodeSamba.async_disconnect"
), patch(
"homeassistant.components.airvisual.PLATFORMS", []
):
assert await async_setup_component(hass, DOMAIN, config)
await hass.async_block_till_done()
yield
@pytest.fixture(name="unique_id")
def unique_id_fixture(hass):
"""Define a config entry unique ID fixture."""
return "192.168.1.101"

View File

@ -0,0 +1 @@
"""Define data fixtures for AirVisual Pro."""

View File

@ -0,0 +1,65 @@
{
"date_and_time": {
"date": "2022/10/06",
"time": "16:00:44",
"timestamp": "1665072044"
},
"measurements": {
"co2": "472",
"humidity": "57",
"pm0_1": "0",
"pm1_0": "0",
"aqi_cn": "0",
"aqi_us": "0",
"pm2_5": "0",
"temperature_C": "23.0",
"temperature_F": "73.4",
"voc": "-1"
},
"serial_number": "XXXXXXX",
"settings": {
"follow_mode": "station",
"followed_station": "0",
"is_aqi_usa": true,
"is_concentration_showed": true,
"is_indoor": true,
"is_lcd_on": true,
"is_network_time": true,
"is_temperature_celsius": false,
"language": "en-US",
"lcd_brightness": 80,
"node_name": "Office",
"power_saving": {
"2slots": [
{ "hour_off": 9, "hour_on": 7 },
{ "hour_off": 22, "hour_on": 18 }
],
"mode": "yes",
"running_time": 99,
"yes": [
{ "hour": 8, "minute": 0 },
{ "hour": 21, "minute": 0 }
]
},
"sensor_mode": { "custom_mode_interval": 3, "mode": 1 },
"speed_unit": "mph",
"timezone": "America/New York",
"tvoc_unit": "ppb"
},
"status": {
"app_version": "1.1826",
"battery": 100,
"datetime": 1665072044,
"device_name": "AIRVISUAL-XXXXXXX",
"ip_address": "192.168.1.101",
"mac_address": "1234567890ab",
"model": 20,
"sensor_life": { "pm2_5": 1567924345130 },
"sensor_pm25_serial": "00000005050224011145",
"sync_time": 250000,
"system_version": "KBG63F84",
"used_memory": 3,
"wifi_strength": 4
},
"last_measurement_timestamp": 1665072044
}

View File

@ -0,0 +1,70 @@
"""Test the AirVisual Pro config flow."""
from unittest.mock import patch
from pyairvisual.node import NodeProError
import pytest
from homeassistant import data_entry_flow
from homeassistant.components.airvisual_pro.const import DOMAIN
from homeassistant.config_entries import SOURCE_USER
from homeassistant.const import CONF_IP_ADDRESS, CONF_PASSWORD
async def test_duplicate_error(hass, config, config_entry):
"""Test that errors are shown when duplicates are added."""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_USER}, data=config
)
assert result["type"] == data_entry_flow.FlowResultType.ABORT
assert result["reason"] == "already_configured"
@pytest.mark.parametrize(
"exc,errors",
[
(NodeProError, {"base": "cannot_connect"}),
(Exception, {"base": "unknown"}),
],
)
async def test_errors(hass, config, exc, errors, setup_airvisual_pro):
"""Test that an exceptions show an error."""
with patch(
"homeassistant.components.airvisual_pro.config_flow.NodeSamba.async_connect",
side_effect=exc,
):
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_USER}, data=config
)
assert result["type"] == data_entry_flow.FlowResultType.FORM
assert result["errors"] == errors
# Validate that we can still proceed after an error if the underlying condition
# resolves:
result = await hass.config_entries.flow.async_configure(
result["flow_id"], user_input=config
)
assert result["type"] == data_entry_flow.FlowResultType.CREATE_ENTRY
assert result["title"] == "192.168.1.101"
assert result["data"] == {
CONF_IP_ADDRESS: "192.168.1.101",
CONF_PASSWORD: "password123",
}
async def test_step_user(hass, config, setup_airvisual_pro):
"""Test that the user step works."""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_USER}
)
assert result["type"] == data_entry_flow.FlowResultType.FORM
assert result["step_id"] == "user"
result = await hass.config_entries.flow.async_configure(
result["flow_id"], user_input=config
)
assert result["type"] == data_entry_flow.FlowResultType.CREATE_ENTRY
assert result["title"] == "192.168.1.101"
assert result["data"] == {
CONF_IP_ADDRESS: "192.168.1.101",
CONF_PASSWORD: "password123",
}

View File

@ -0,0 +1,85 @@
"""Test AirVisual Pro diagnostics."""
from homeassistant.components.diagnostics import REDACTED
from tests.components.diagnostics import get_diagnostics_for_config_entry
async def test_entry_diagnostics(hass, config_entry, hass_client, setup_airvisual_pro):
"""Test config entry diagnostics."""
assert await get_diagnostics_for_config_entry(hass, hass_client, config_entry) == {
"entry": {
"entry_id": config_entry.entry_id,
"version": 1,
"domain": "airvisual_pro",
"title": "Mock Title",
"data": {"ip_address": "192.168.1.101", "password": REDACTED},
"options": {},
"pref_disable_new_entities": False,
"pref_disable_polling": False,
"source": "user",
"unique_id": "192.168.1.101",
"disabled_by": None,
},
"data": {
"date_and_time": {
"date": "2022/10/06",
"time": "16:00:44",
"timestamp": "1665072044",
},
"measurements": {
"co2": "472",
"humidity": "57",
"pm0_1": "0",
"pm1_0": "0",
"aqi_cn": "0",
"aqi_us": "0",
"pm2_5": "0",
"temperature_C": "23.0",
"temperature_F": "73.4",
"voc": "-1",
},
"serial_number": REDACTED,
"settings": {
"follow_mode": "station",
"followed_station": "0",
"is_aqi_usa": True,
"is_concentration_showed": True,
"is_indoor": True,
"is_lcd_on": True,
"is_network_time": True,
"is_temperature_celsius": False,
"language": "en-US",
"lcd_brightness": 80,
"node_name": "Office",
"power_saving": {
"2slots": [
{"hour_off": 9, "hour_on": 7},
{"hour_off": 22, "hour_on": 18},
],
"mode": "yes",
"running_time": 99,
"yes": [{"hour": 8, "minute": 0}, {"hour": 21, "minute": 0}],
},
"sensor_mode": {"custom_mode_interval": 3, "mode": 1},
"speed_unit": "mph",
"timezone": "America/New York",
"tvoc_unit": "ppb",
},
"status": {
"app_version": "1.1826",
"battery": 100,
"datetime": 1665072044,
"device_name": "AIRVISUAL-XXXXXXX",
"ip_address": "192.168.1.101",
"mac_address": REDACTED,
"model": 20,
"sensor_life": {"pm2_5": 1567924345130},
"sensor_pm25_serial": "00000005050224011145",
"sync_time": 250000,
"system_version": "KBG63F84",
"used_memory": 3,
"wifi_strength": 4,
},
"last_measurement_timestamp": 1665072044,
},
}