diff --git a/homeassistant/components/purpleair/__init__.py b/homeassistant/components/purpleair/__init__.py index 40139c94286..7acdbf1fabd 100644 --- a/homeassistant/components/purpleair/__init__.py +++ b/homeassistant/components/purpleair/__init__.py @@ -6,10 +6,12 @@ from aiopurpleair.models.sensors import SensorModel from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_LATITUDE, ATTR_LONGITUDE, Platform from homeassistant.core import HomeAssistant +import homeassistant.helpers.device_registry as dr from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.update_coordinator import CoordinatorEntity -from .const import DOMAIN +from .config_flow import async_remove_sensor_by_device_id +from .const import CONF_LAST_UPDATE_SENSOR_ADD, DOMAIN from .coordinator import PurpleAirDataUpdateCoordinator PLATFORMS = [Platform.SENSOR] @@ -23,9 +25,35 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + entry.async_on_unload(entry.add_update_listener(async_handle_entry_update)) + return True +async def async_handle_entry_update(hass: HomeAssistant, entry: ConfigEntry) -> None: + """Handle an options update.""" + if entry.options.get(CONF_LAST_UPDATE_SENSOR_ADD) is True: + # If the last options update was to add a sensor, we reload the config entry: + await hass.config_entries.async_reload(entry.entry_id) + + +async def async_remove_config_entry_device( + hass: HomeAssistant, config_entry: ConfigEntry, device_entry: dr.DeviceEntry +) -> bool: + """Remove a config entry from a device.""" + new_entry_options = async_remove_sensor_by_device_id( + hass, + config_entry, + device_entry.id, + # remove_device is set to False because in this instance, the device has + # already been removed: + remove_device=False, + ) + return hass.config_entries.async_update_entry( + config_entry, options=new_entry_options + ) + + 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): diff --git a/homeassistant/components/purpleair/config_flow.py b/homeassistant/components/purpleair/config_flow.py index 051f89e9ac6..6b03480fb4d 100644 --- a/homeassistant/components/purpleair/config_flow.py +++ b/homeassistant/components/purpleair/config_flow.py @@ -2,10 +2,12 @@ from __future__ import annotations from collections.abc import Mapping +from copy import deepcopy from dataclasses import dataclass, field -from typing import Any +from typing import Any, cast from aiopurpleair import API +from aiopurpleair.endpoints.sensors import NearbySensorResult from aiopurpleair.errors import InvalidApiKeyError, PurpleAirError import voluptuous as vol @@ -14,7 +16,11 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_API_KEY, CONF_LATITUDE, CONF_LONGITUDE from homeassistant.core import HomeAssistant, callback from homeassistant.data_entry_flow import FlowResult -from homeassistant.helpers import aiohttp_client, config_validation as cv +from homeassistant.helpers import ( + aiohttp_client, + config_validation as cv, + device_registry as dr, +) from homeassistant.helpers.selector import ( SelectOptionDict, SelectSelector, @@ -22,10 +28,11 @@ from homeassistant.helpers.selector import ( SelectSelectorMode, ) -from .const import CONF_SENSOR_INDICES, DOMAIN, LOGGER +from .const import CONF_LAST_UPDATE_SENSOR_ADD, CONF_SENSOR_INDICES, DOMAIN, LOGGER CONF_DISTANCE = "distance" CONF_NEARBY_SENSOR_OPTIONS = "nearby_sensor_options" +CONF_SENSOR_DEVICE_ID = "sensor_device_id" CONF_SENSOR_INDEX = "sensor_index" DEFAULT_DISTANCE = 5 @@ -60,6 +67,20 @@ def async_get_coordinates_schema(hass: HomeAssistant) -> vol.Schema: ) +@callback +def async_get_nearby_sensors_options( + nearby_sensor_results: list[NearbySensorResult], +) -> list[SelectOptionDict]: + """Return a set of nearby sensors as SelectOptionDict objects.""" + return [ + SelectOptionDict( + value=str(result.sensor.sensor_index), + label=f"{result.sensor.name} ({round(result.distance, 1)} km away)", + ) + for result in nearby_sensor_results + ] + + @callback def async_get_nearby_sensors_schema(options: list[SelectOptionDict]) -> vol.Schema: """Define a schema for selecting a sensor from a list.""" @@ -72,6 +93,75 @@ def async_get_nearby_sensors_schema(options: list[SelectOptionDict]) -> vol.Sche ) +@callback +def async_get_remove_sensor_options( + hass: HomeAssistant, config_entry: ConfigEntry +) -> list[SelectOptionDict]: + """Return a set of already-configured sensors as SelectOptionDict objects.""" + device_registry = dr.async_get(hass) + return [ + SelectOptionDict(value=device_entry.id, label=cast(str, device_entry.name)) + for device_entry in device_registry.devices.values() + if config_entry.entry_id in device_entry.config_entries + ] + + +@callback +def async_get_remove_sensor_schema(sensors: list[SelectOptionDict]) -> vol.Schema: + """Define a schema removing a sensor.""" + return vol.Schema( + { + vol.Required(CONF_SENSOR_DEVICE_ID): SelectSelector( + SelectSelectorConfig(options=sensors, mode=SelectSelectorMode.DROPDOWN) + ) + } + ) + + +@callback +def async_get_sensor_index( + hass: HomeAssistant, config_entry: ConfigEntry, device_entry: dr.DeviceEntry +) -> int: + """Get the sensor index related to a config and device entry. + + Note that this method expects that there will always be a single sensor index per + DeviceEntry. + """ + [sensor_index] = [ + sensor_index + for sensor_index in config_entry.options[CONF_SENSOR_INDICES] + if (DOMAIN, str(sensor_index)) in device_entry.identifiers + ] + + return cast(int, sensor_index) + + +@callback +def async_remove_sensor_by_device_id( + hass: HomeAssistant, + config_entry: ConfigEntry, + device_id: str, + *, + remove_device: bool = True, +) -> dict[str, Any]: + """Remove a sensor and return update config entry options.""" + device_registry = dr.async_get(hass) + device_entry = device_registry.async_get(device_id) + assert device_entry + + removed_sensor_index = async_get_sensor_index(hass, config_entry, device_entry) + options = deepcopy({**config_entry.options}) + options[CONF_LAST_UPDATE_SENSOR_ADD] = False + options[CONF_SENSOR_INDICES].remove(removed_sensor_index) + + if remove_device: + device_registry.async_update_device( + device_entry.id, remove_config_entry_id=config_entry.entry_id + ) + + return options + + @dataclass class ValidationResult: """Define a validation result.""" @@ -146,6 +236,14 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): self._flow_data: dict[str, Any] = {} self._reauth_entry: ConfigEntry | None = None + @staticmethod + @callback + def async_get_options_flow( + config_entry: ConfigEntry, + ) -> PurpleAirOptionsFlowHandler: + """Define the config flow to handle options.""" + return PurpleAirOptionsFlowHandler(config_entry) + async def async_step_by_coordinates( self, user_input: dict[str, Any] | None = None ) -> FlowResult: @@ -170,13 +268,9 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): errors=validation.errors, ) - self._flow_data[CONF_NEARBY_SENSOR_OPTIONS] = [ - SelectOptionDict( - value=str(result.sensor.sensor_index), - label=f"{result.sensor.name} ({round(result.distance, 1)} km away)", - ) - for result in validation.data - ] + self._flow_data[CONF_NEARBY_SENSOR_OPTIONS] = async_get_nearby_sensors_options( + validation.data + ) return await self.async_step_choose_sensor() @@ -256,3 +350,91 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): self._flow_data = {CONF_API_KEY: api_key} return await self.async_step_by_coordinates() + + +class PurpleAirOptionsFlowHandler(config_entries.OptionsFlow): + """Handle a PurpleAir options flow.""" + + def __init__(self, config_entry: ConfigEntry) -> None: + """Initialize.""" + self._flow_data: dict[str, Any] = {} + self.config_entry = config_entry + + async def async_step_add_sensor( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Add a sensor.""" + if user_input is None: + return self.async_show_form( + step_id="add_sensor", + data_schema=async_get_coordinates_schema(self.hass), + ) + + validation = await async_validate_coordinates( + self.hass, + self.config_entry.data[CONF_API_KEY], + user_input[CONF_LATITUDE], + user_input[CONF_LONGITUDE], + user_input[CONF_DISTANCE], + ) + + if validation.errors: + return self.async_show_form( + step_id="add_sensor", + data_schema=async_get_coordinates_schema(self.hass), + errors=validation.errors, + ) + + self._flow_data[CONF_NEARBY_SENSOR_OPTIONS] = async_get_nearby_sensors_options( + validation.data + ) + + return await self.async_step_choose_sensor() + + async def async_step_choose_sensor( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle the selection of a sensor.""" + if user_input is None: + options = self._flow_data.pop(CONF_NEARBY_SENSOR_OPTIONS) + return self.async_show_form( + step_id="choose_sensor", + data_schema=async_get_nearby_sensors_schema(options), + ) + + sensor_index = int(user_input[CONF_SENSOR_INDEX]) + + if sensor_index in self.config_entry.options[CONF_SENSOR_INDICES]: + return self.async_abort(reason="already_configured") + + options = deepcopy({**self.config_entry.options}) + options[CONF_LAST_UPDATE_SENSOR_ADD] = True + options[CONF_SENSOR_INDICES].append(sensor_index) + return self.async_create_entry(title="", data=options) + + async def async_step_init( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Manage the options.""" + return self.async_show_menu( + step_id="init", + menu_options=["add_sensor", "remove_sensor"], + ) + + async def async_step_remove_sensor( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Add a sensor.""" + if user_input is None: + return self.async_show_form( + step_id="remove_sensor", + data_schema=async_get_remove_sensor_schema( + async_get_remove_sensor_options(self.hass, self.config_entry) + ), + ) + + new_entry_options = async_remove_sensor_by_device_id( + self.hass, self.config_entry, user_input[CONF_SENSOR_DEVICE_ID] + ) + + return self.async_create_entry(title="", data=new_entry_options) diff --git a/homeassistant/components/purpleair/const.py b/homeassistant/components/purpleair/const.py index 60f51a9e7dd..1de915e3545 100644 --- a/homeassistant/components/purpleair/const.py +++ b/homeassistant/components/purpleair/const.py @@ -5,5 +5,6 @@ DOMAIN = "purpleair" LOGGER = logging.getLogger(__package__) +CONF_LAST_UPDATE_SENSOR_ADD = "last_update_sensor_add" CONF_READ_KEY = "read_key" CONF_SENSOR_INDICES = "sensor_indices" diff --git a/homeassistant/components/purpleair/strings.json b/homeassistant/components/purpleair/strings.json index 7077273ab76..3d18fef3906 100644 --- a/homeassistant/components/purpleair/strings.json +++ b/homeassistant/components/purpleair/strings.json @@ -49,5 +49,56 @@ "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" } + }, + "options": { + "step": { + "add_sensor": { + "title": "Add Sensor", + "description": "[%key:component::purpleair::config::step::by_coordinates::description%]", + "data": { + "latitude": "[%key:component::purpleair::config::step::by_coordinates::data::latitude%]", + "longitude": "[%key:component::purpleair::config::step::by_coordinates::data::longitude%]", + "distance": "[%key:component::purpleair::config::step::by_coordinates::data::distance%]" + }, + "data_description": { + "latitude": "[%key:component::purpleair::config::step::by_coordinates::data_description::latitude%]", + "longitude": "[%key:component::purpleair::config::step::by_coordinates::data_description::longitude%]", + "distance": "[%key:component::purpleair::config::step::by_coordinates::data_description::distance%]" + } + }, + "choose_sensor": { + "title": "Choose Sensor to Add", + "description": "[%key:component::purpleair::config::step::choose_sensor::description%]", + "data": { + "sensor_index": "[%key:component::purpleair::config::step::choose_sensor::data::sensor_index%]" + }, + "data_description": { + "sensor_index": "[%key:component::purpleair::config::step::choose_sensor::data_description::sensor_index%]" + } + }, + "init": { + "menu_options": { + "add_sensor": "Add sensor", + "remove_sensor": "Remove sensor" + } + }, + "remove_sensor": { + "title": "Remove Sensor", + "data": { + "sensor_device_id": "Sensor Name" + }, + "data_description": { + "sensor_device_id": "The sensor to remove" + } + } + }, + "error": { + "invalid_api_key": "[%key:common::config_flow::error::invalid_api_key%]", + "no_sensors_near_coordinates": "[%key:component::purpleair::config::error::no_sensors_near_coordinates%]", + "unknown": "[%key:common::config_flow::error::unknown%]" + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" + } } } diff --git a/homeassistant/components/purpleair/translations/en.json b/homeassistant/components/purpleair/translations/en.json index 2934efa2fc3..d769f788a55 100644 --- a/homeassistant/components/purpleair/translations/en.json +++ b/homeassistant/components/purpleair/translations/en.json @@ -49,5 +49,56 @@ } } } + }, + "options": { + "abort": { + "already_configured": "Device is already configured" + }, + "error": { + "invalid_api_key": "Invalid API key", + "no_sensors_near_coordinates": "No sensors found near coordinates (within distance)", + "unknown": "Unexpected error" + }, + "step": { + "add_sensor": { + "data": { + "distance": "Search Radius", + "latitude": "Latitude", + "longitude": "Longitude" + }, + "data_description": { + "distance": "The radius (in kilometers) of the circle to search within", + "latitude": "The latitude around which to search for sensors", + "longitude": "The longitude around which to search for sensors" + }, + "description": "Search for a PurpleAir sensor within a certain distance of a latitude/longitude.", + "title": "Add Sensor" + }, + "choose_sensor": { + "data": { + "sensor_index": "Sensor" + }, + "data_description": { + "sensor_index": "The sensor to track" + }, + "description": "Which of the nearby sensors would you like to track?", + "title": "Choose Sensor to Add" + }, + "init": { + "menu_options": { + "add_sensor": "Add sensor", + "remove_sensor": "Remove sensor" + } + }, + "remove_sensor": { + "data": { + "sensor_device_id": "Sensor Name" + }, + "data_description": { + "sensor_device_id": "The sensor to remove" + }, + "title": "Remove Sensor" + } + } } } \ No newline at end of file diff --git a/tests/components/purpleair/conftest.py b/tests/components/purpleair/conftest.py index ee484fd641d..c19ff62fdb7 100644 --- a/tests/components/purpleair/conftest.py +++ b/tests/components/purpleair/conftest.py @@ -86,11 +86,7 @@ async def setup_purpleair_fixture(hass, api, config_entry_data): """Define a fixture to set up PurpleAir.""" with patch( "homeassistant.components.purpleair.config_flow.API", return_value=api - ), patch( - "homeassistant.components.purpleair.coordinator.API", return_value=api - ), patch( - "homeassistant.components.purpleair.PLATFORMS", [] - ): + ), patch("homeassistant.components.purpleair.coordinator.API", return_value=api): assert await async_setup_component(hass, DOMAIN, config_entry_data) await hass.async_block_till_done() yield diff --git a/tests/components/purpleair/fixtures/get_sensors_response.json b/tests/components/purpleair/fixtures/get_sensors_response.json index 21f72d687f2..6949ca6ca04 100644 --- a/tests/components/purpleair/fixtures/get_sensors_response.json +++ b/tests/components/purpleair/fixtures/get_sensors_response.json @@ -57,6 +57,32 @@ 0, 0, 0 + ], + [ + 567890, + "Test Sensor 2", + 0, + "PA-II", + "2.0+BME280+PMSX003-B+PMSX003-A", + "7.02", + -69, + 13788, + 51.5285582, + -0.2416796, + 569, + 13, + 82, + 1000.74, + null, + 0.0, + 0.0, + 0.0, + 76, + 68, + 0, + 0, + 0, + 0 ] ] } diff --git a/tests/components/purpleair/test_config_flow.py b/tests/components/purpleair/test_config_flow.py index deccbb7d822..4acb01aa305 100644 --- a/tests/components/purpleair/test_config_flow.py +++ b/tests/components/purpleair/test_config_flow.py @@ -7,6 +7,7 @@ import pytest from homeassistant import data_entry_flow from homeassistant.components.purpleair import DOMAIN from homeassistant.config_entries import SOURCE_REAUTH, SOURCE_USER +from homeassistant.helpers import device_registry as dr async def test_duplicate_error(hass, config_entry, setup_purpleair): @@ -143,3 +144,129 @@ async def test_reauth( assert result["type"] == data_entry_flow.FlowResultType.ABORT assert result["reason"] == "reauth_successful" assert len(hass.config_entries.async_entries()) == 1 + + +@pytest.mark.parametrize( + "get_nearby_sensors_mock,get_nearby_sensors_errors", + [ + (AsyncMock(return_value=[]), {"base": "no_sensors_near_coordinates"}), + (AsyncMock(side_effect=Exception), {"base": "unknown"}), + (AsyncMock(side_effect=PurpleAirError), {"base": "unknown"}), + ], +) +async def test_options_add_sensor( + hass, + api, + config_entry, + get_nearby_sensors_errors, + get_nearby_sensors_mock, + setup_purpleair, +): + """Test adding a sensor via the options flow (including errors).""" + result = await hass.config_entries.options.async_init(config_entry.entry_id) + assert result["type"] == data_entry_flow.FlowResultType.MENU + assert result["step_id"] == "init" + + result = await hass.config_entries.options.async_configure( + result["flow_id"], user_input={"next_step_id": "add_sensor"} + ) + assert result["type"] == data_entry_flow.FlowResultType.FORM + assert result["step_id"] == "add_sensor" + + # Test errors that can arise when searching for nearby sensors: + with patch.object(api.sensors, "async_get_nearby_sensors", get_nearby_sensors_mock): + result = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={ + "latitude": 51.5285582, + "longitude": -0.2416796, + "distance": 5, + }, + ) + assert result["type"] == data_entry_flow.FlowResultType.FORM + assert result["step_id"] == "add_sensor" + + result = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={ + "latitude": 51.5285582, + "longitude": -0.2416796, + "distance": 5, + }, + ) + assert result["type"] == data_entry_flow.FlowResultType.FORM + assert result["step_id"] == "choose_sensor" + + result = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={ + "sensor_index": "567890", + }, + ) + assert result["type"] == data_entry_flow.FlowResultType.CREATE_ENTRY + assert result["data"] == { + "last_update_sensor_add": True, + "sensor_indices": [123456, 567890], + } + + assert config_entry.options["sensor_indices"] == [123456, 567890] + + +async def test_options_add_sensor_duplicate(hass, config_entry, setup_purpleair): + """Test adding a duplicate sensor via the options flow.""" + result = await hass.config_entries.options.async_init(config_entry.entry_id) + assert result["type"] == data_entry_flow.FlowResultType.MENU + assert result["step_id"] == "init" + + result = await hass.config_entries.options.async_configure( + result["flow_id"], user_input={"next_step_id": "add_sensor"} + ) + assert result["type"] == data_entry_flow.FlowResultType.FORM + assert result["step_id"] == "add_sensor" + + result = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={ + "latitude": 51.5285582, + "longitude": -0.2416796, + "distance": 5, + }, + ) + assert result["type"] == data_entry_flow.FlowResultType.FORM + assert result["step_id"] == "choose_sensor" + + result = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={ + "sensor_index": "123456", + }, + ) + assert result["type"] == data_entry_flow.FlowResultType.ABORT + assert result["reason"] == "already_configured" + + +async def test_options_remove_sensor(hass, config_entry, setup_purpleair): + """Test removing a sensor via the options flow.""" + result = await hass.config_entries.options.async_init(config_entry.entry_id) + assert result["type"] == data_entry_flow.FlowResultType.MENU + assert result["step_id"] == "init" + + result = await hass.config_entries.options.async_configure( + result["flow_id"], user_input={"next_step_id": "remove_sensor"} + ) + assert result["type"] == data_entry_flow.FlowResultType.FORM + assert result["step_id"] == "remove_sensor" + + device_registry = dr.async_get(hass) + device_entry = device_registry.async_get_device({(DOMAIN, "123456")}) + result = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={"sensor_device_id": device_entry.id}, + ) + assert result["type"] == data_entry_flow.FlowResultType.CREATE_ENTRY + assert result["data"] == { + "last_update_sensor_add": False, + "sensor_indices": [], + } + + assert config_entry.options["sensor_indices"] == [] diff --git a/tests/components/purpleair/test_diagnostics.py b/tests/components/purpleair/test_diagnostics.py index 4703817f616..ee17a2889b8 100644 --- a/tests/components/purpleair/test_diagnostics.py +++ b/tests/components/purpleair/test_diagnostics.py @@ -187,7 +187,141 @@ async def test_entry_diagnostics(hass, config_entry, hass_client, setup_purpleai "voc": None, "voc_a": None, "voc_b": None, - } + }, + "567890": { + "sensor_index": 567890, + "altitude": 569.0, + "analog_input": None, + "channel_flags": None, + "channel_flags_auto": None, + "channel_flags_manual": None, + "channel_state": None, + "confidence": None, + "confidence_auto": None, + "confidence_manual": None, + "date_created_utc": None, + "deciviews": None, + "deciviews_a": None, + "deciviews_b": None, + "firmware_upgrade": None, + "firmware_version": "7.02", + "hardware": "2.0+BME280+PMSX003-B+PMSX003-A", + "humidity": 13.0, + "humidity_a": None, + "humidity_b": None, + "icon": None, + "is_owner": None, + "last_modified_utc": None, + "last_seen_utc": None, + "latitude": REDACTED, + "led_brightness": None, + "location_type": { + "__type": "", + "repr": "", + }, + "longitude": REDACTED, + "memory": None, + "model": "PA-II", + "name": "Test Sensor 2", + "ozone1": None, + "pa_latency": None, + "pm0_3_um_count": 76.0, + "pm0_3_um_count_a": None, + "pm0_3_um_count_b": None, + "pm0_5_um_count": 68.0, + "pm0_5_um_count_a": None, + "pm0_5_um_count_b": None, + "pm10_0": 0.0, + "pm10_0_a": None, + "pm10_0_atm": None, + "pm10_0_atm_a": None, + "pm10_0_atm_b": None, + "pm10_0_b": None, + "pm10_0_cf_1": None, + "pm10_0_cf_1_a": None, + "pm10_0_cf_1_b": None, + "pm10_0_um_count": 0.0, + "pm10_0_um_count_a": None, + "pm10_0_um_count_b": None, + "pm1_0": 0.0, + "pm1_0_a": None, + "pm1_0_atm": None, + "pm1_0_atm_a": None, + "pm1_0_atm_b": None, + "pm1_0_b": None, + "pm1_0_cf_1": None, + "pm1_0_cf_1_a": None, + "pm1_0_cf_1_b": None, + "pm1_0_um_count": 0.0, + "pm1_0_um_count_a": None, + "pm1_0_um_count_b": None, + "pm2_5": 0.0, + "pm2_5_10minute": None, + "pm2_5_10minute_a": None, + "pm2_5_10minute_b": None, + "pm2_5_1week": None, + "pm2_5_1week_a": None, + "pm2_5_1week_b": None, + "pm2_5_24hour": None, + "pm2_5_24hour_a": None, + "pm2_5_24hour_b": None, + "pm2_5_30minute": None, + "pm2_5_30minute_a": None, + "pm2_5_30minute_b": None, + "pm2_5_60minute": None, + "pm2_5_60minute_a": None, + "pm2_5_60minute_b": None, + "pm2_5_6hour": None, + "pm2_5_6hour_a": None, + "pm2_5_6hour_b": None, + "pm2_5_a": None, + "pm2_5_alt": None, + "pm2_5_alt_a": None, + "pm2_5_alt_b": None, + "pm2_5_atm": None, + "pm2_5_atm_a": None, + "pm2_5_atm_b": None, + "pm2_5_b": None, + "pm2_5_cf_1": None, + "pm2_5_cf_1_a": None, + "pm2_5_cf_1_b": None, + "pm2_5_um_count": 0.0, + "pm2_5_um_count_a": None, + "pm2_5_um_count_b": None, + "pm5_0_um_count": 0.0, + "pm5_0_um_count_a": None, + "pm5_0_um_count_b": None, + "position_rating": None, + "pressure": 1000.74, + "pressure_a": None, + "pressure_b": None, + "primary_id_a": None, + "primary_id_b": None, + "primary_key_a": None, + "primary_key_b": None, + "private": None, + "rssi": -69, + "scattering_coefficient": None, + "scattering_coefficient_a": None, + "scattering_coefficient_b": None, + "secondary_id_a": None, + "secondary_id_b": None, + "secondary_key_a": None, + "secondary_key_b": None, + "stats": None, + "stats_a": None, + "stats_b": None, + "temperature": 82.0, + "temperature_a": None, + "temperature_b": None, + "uptime": 13788, + "visual_range": None, + "visual_range_a": None, + "visual_range_b": None, + "voc": None, + "voc_a": None, + "voc_b": None, + }, }, "api_version": "V1.0.11-0.0.41", "firmware_default_version": "7.02",