From 667739751737d369f385d003a1aa48395c3306a4 Mon Sep 17 00:00:00 2001 From: Aaron Bach Date: Mon, 7 Nov 2022 11:02:25 -0700 Subject: [PATCH 1/3] Create SensorModel and use throughout library --- README.md | 20 +- aiopurpleair/const.py | 27 ++ aiopurpleair/helpers/validators/sensors.py | 22 +- aiopurpleair/models/sensors.py | 445 +++++++++++++++++---- tests/endpoints/test_sensors.py | 275 ++++++------- tests/models/test_sensors.py | 140 ++++--- 6 files changed, 651 insertions(+), 278 deletions(-) diff --git a/README.md b/README.md index 0ec786f..9b5bdb2 100644 --- a/README.md +++ b/README.md @@ -80,20 +80,10 @@ async def main() -> None: # >>> response.data_time_stamp == datetime(2022, 11, 3, 19, 25, 31) # UTC # >>> response.firmware_default_version == "7.02" # >>> response.max_age == 604800 - # >>> response.channel_flags is None - # >>> response.channel_states is None - # >>> response.location_type is LocationType.OUTSIDE - # >>> response.location_types is None # >>> response.fields == ["sensor_index", "name"] # >>> response.data == { - # >>> 131075: { - # >>> "sensor_index": 131075, - # >>> "name": "Mariners Bluff", - # >>> }, - # >>> 131079: { - # >>> "sensor_index": 131079, - # >>> "name": "BRSKBV-outside", - # >>> }, + # >>> 131075: SensorModel(sensor_index=131075, name=Mariners Bluff), + # >>> 131079: SensorModel(sensor_index=131079, name=BRSKBV-outside), # >>> } @@ -124,11 +114,7 @@ async def main() -> None: # >>> response.api_version == "V1.0.11-0.0.41" # >>> response.time_stamp == datetime(2022, 11, 5, 16, 37, 3) # >>> response.data_time_stamp == datetime(2022, 11, 5, 16, 36, 21) - # >>> response.sensor == { - # >>> "sensor_index": 131075, - # >>> "last_modified": 1635632829, - # >>> ... - # >>> } + # >>> response.sensor == SensorModel(sensor_index=131075, ...), asyncio.run(main()) diff --git a/aiopurpleair/const.py b/aiopurpleair/const.py index 3c077c7..013a56e 100644 --- a/aiopurpleair/const.py +++ b/aiopurpleair/const.py @@ -1,8 +1,35 @@ """Define package constants.""" import logging +from enum import Enum LOGGER = logging.getLogger(__package__) + +class ChannelFlag(Enum): + """Define a channel flag.""" + + NORMAL = 0 + A_DOWNGRADED = 1 + B_DOWNGRADED = 2 + A_B_DOWNGRADED = 3 + + +class ChannelState(Enum): + """Define a channel state.""" + + NO_PM = 0 + PM_A = 1 + PM_B = 2 + PM_A_PM_B = 3 + + +class LocationType(Enum): + """Define a location type.""" + + OUTSIDE = 0 + INSIDE = 1 + + SENSOR_FIELDS = { "0.3_um_count", "0.3_um_count_a", diff --git a/aiopurpleair/helpers/validators/sensors.py b/aiopurpleair/helpers/validators/sensors.py index da6c857..6aee97a 100644 --- a/aiopurpleair/helpers/validators/sensors.py +++ b/aiopurpleair/helpers/validators/sensors.py @@ -1,5 +1,23 @@ -"""Define Pydantic validors for sensors.""" -from aiopurpleair.const import SENSOR_FIELDS +"""Define reusable Pydantic validors for sensors.""" +from aiopurpleair.const import SENSOR_FIELDS, ChannelFlag + + +def validate_channel_flag(value: int) -> ChannelFlag: + """Validate the channel flag. + + Args: + value: The integer-based interpretation of a channel flag. + + Returns: + A ChannelFlag value. + + Raises: + ValueError: Raised upon an unknown location type. + """ + try: + return ChannelFlag(value) + except ValueError as err: + raise ValueError(f"{value} is an unknown channel flag") from err def validate_fields_request(value: list[str]) -> str: diff --git a/aiopurpleair/models/sensors.py b/aiopurpleair/models/sensors.py index 557fb24..2ee31fa 100644 --- a/aiopurpleair/models/sensors.py +++ b/aiopurpleair/models/sensors.py @@ -3,14 +3,14 @@ from __future__ import annotations from datetime import datetime -from enum import Enum -from typing import Any, Literal, Optional +from typing import Any, Optional from pydantic import BaseModel, root_validator, validator -from aiopurpleair.const import SENSOR_FIELDS +from aiopurpleair.const import SENSOR_FIELDS, ChannelFlag, ChannelState, LocationType from aiopurpleair.helpers.validators import validate_timestamp from aiopurpleair.helpers.validators.sensors import ( + validate_channel_flag, validate_fields_request, validate_latitude, validate_longitude, @@ -18,11 +18,329 @@ from aiopurpleair.util.dt import utc_to_timestamp -class LocationType(Enum): - """Define a location type.""" +class SensorModelStats(BaseModel): + """Define a model for sensor statistics.""" - OUTSIDE = 0 - INSIDE = 1 + pm2_5: float + pm2_5_10minute: float + pm2_5_1week: float + pm2_5_24hour: float + pm2_5_30minute: float + pm2_5_60minute: float + pm2_5_6hour: float + time_stamp: datetime + + class Config: + """Define configuration for this model.""" + + fields = { + "pm2_5": "pm2.5", + "pm2_5_10minute": "pm2.5_10minute", + "pm2_5_1week": "pm2.5_1week", + "pm2_5_24hour": "pm2.5_24hour", + "pm2_5_30minute": "pm2.5_30minute", + "pm2_5_60minute": "pm2.5_60minute", + "pm2_5_6hour": "pm2.5_6hour", + } + frozen = True + + validate_time_stamp = validator( + "time_stamp", + allow_reuse=True, + pre=True, + )(validate_timestamp) + + +class SensorModel(BaseModel): + """Define a model for a sensor.""" + + sensor_index: int + + altitude: Optional[float] = None + analog_input: Optional[float] = None + channel_flags: Optional[ChannelFlag] = None + channel_flags_auto: Optional[ChannelFlag] = None + channel_flags_manual: Optional[ChannelFlag] = None + channel_state: Optional[ChannelState] = None + confidence: Optional[float] = None + confidence_auto: Optional[float] = None + confidence_manual: Optional[float] = None + date_created: Optional[datetime] = None + deciviews: Optional[float] = None + deciviews_a: Optional[float] = None + deciviews_b: Optional[float] = None + firmware_upgrade: Optional[str] = None + firmware_version: Optional[str] = None + hardware: Optional[str] = None + humidity: Optional[float] = None + humidity_a: Optional[float] = None + humidity_b: Optional[float] = None + icon: Optional[int] = None + is_owner: Optional[bool] = None + last_modified: Optional[datetime] = None + last_seen: Optional[datetime] = None + latitude: Optional[float] = None + led_brightness: Optional[float] = None + location_type: Optional[LocationType] = None + longitude: Optional[float] = None + memory: Optional[float] = None + model: Optional[str] = None + name: Optional[str] = None + ozone1: Optional[float] = None + pa_latency: Optional[int] = None + pm0_3_um_count: Optional[float] = None + pm0_3_um_count_a: Optional[float] = None + pm0_3_um_count_b: Optional[float] = None + pm0_5_um_count: Optional[float] = None + pm0_5_um_count_a: Optional[float] = None + pm0_5_um_count_b: Optional[float] = None + pm10_0: Optional[float] = None + pm10_0_a: Optional[float] = None + pm10_0_atm: Optional[float] = None + pm10_0_atm_a: Optional[float] = None + pm10_0_atm_b: Optional[float] = None + pm10_0_b: Optional[float] = None + pm10_0_cf_1: Optional[float] = None + pm10_0_cf_1_a: Optional[float] = None + pm10_0_cf_1_b: Optional[float] = None + pm10_0_um_count: Optional[float] = None + pm10_0_um_count_a: Optional[float] = None + pm10_0_um_count_b: Optional[float] = None + pm1_0: Optional[float] = None + pm1_0_a: Optional[float] = None + pm1_0_atm: Optional[float] = None + pm1_0_atm_a: Optional[float] = None + pm1_0_atm_b: Optional[float] = None + pm1_0_b: Optional[float] = None + pm1_0_cf_1: Optional[float] = None + pm1_0_cf_1_a: Optional[float] = None + pm1_0_cf_1_b: Optional[float] = None + pm1_0_um_count: Optional[float] = None + pm1_0_um_count_a: Optional[float] = None + pm1_0_um_count_b: Optional[float] = None + pm2_5: Optional[float] = None + pm2_5_10minute: Optional[float] = None + pm2_5_10minute_a: Optional[float] = None + pm2_5_10minute_b: Optional[float] = None + pm2_5_1week: Optional[float] = None + pm2_5_1week_a: Optional[float] = None + pm2_5_1week_b: Optional[float] = None + pm2_5_24hour: Optional[float] = None + pm2_5_24hour_a: Optional[float] = None + pm2_5_24hour_b: Optional[float] = None + pm2_5_30minute: Optional[float] = None + pm2_5_30minute_a: Optional[float] = None + pm2_5_30minute_b: Optional[float] = None + pm2_5_60minute: Optional[float] = None + pm2_5_60minute_a: Optional[float] = None + pm2_5_60minute_b: Optional[float] = None + pm2_5_6hour: Optional[float] = None + pm2_5_6hour_a: Optional[float] = None + pm2_5_6hour_b: Optional[float] = None + pm2_5_a: Optional[float] = None + pm2_5_alt: Optional[float] = None + pm2_5_alt_a: Optional[float] = None + pm2_5_alt_b: Optional[float] = None + pm2_5_atm: Optional[float] = None + pm2_5_atm_a: Optional[float] = None + pm2_5_atm_b: Optional[float] = None + pm2_5_b: Optional[float] = None + pm2_5_cf_1: Optional[float] = None + pm2_5_cf_1_a: Optional[float] = None + pm2_5_cf_1_b: Optional[float] = None + pm2_5_um_count: Optional[float] = None + pm2_5_um_count_a: Optional[float] = None + pm2_5_um_count_b: Optional[float] = None + pm5_0_um_count: Optional[float] = None + pm5_0_um_count_a: Optional[float] = None + pm5_0_um_count_b: Optional[float] = None + position_rating: Optional[int] = None + pressure: Optional[float] = None + pressure_a: Optional[float] = None + pressure_b: Optional[float] = None + primary_id_a: Optional[int] = None + primary_id_b: Optional[int] = None + primary_key_a: Optional[str] = None + primary_key_b: Optional[str] = None + private: Optional[bool] = None + rssi: Optional[int] = None + scattering_coefficient: Optional[float] = None + scattering_coefficient_a: Optional[float] = None + scattering_coefficient_b: Optional[float] = None + secondary_id_a: Optional[int] = None + secondary_id_b: Optional[int] = None + secondary_key_a: Optional[str] = None + secondary_key_b: Optional[str] = None + stats: Optional[SensorModelStats] = None + stats_a: Optional[SensorModelStats] = None + stats_b: Optional[SensorModelStats] = None + temperature: Optional[float] = None + temperature_a: Optional[float] = None + temperature_b: Optional[float] = None + uptime: Optional[int] = None + visual_range: Optional[float] = None + visual_range_a: Optional[float] = None + visual_range_b: Optional[float] = None + voc: Optional[float] = None + voc_a: Optional[float] = None + voc_b: Optional[float] = None + + class Config: + """Define configuration for this model.""" + + fields = { + "pm0_3_um_count": {"alias": "0.3_um_count"}, + "pm0_3_um_count_a": {"alias": "0.3_um_count_a"}, + "pm0_3_um_count_b": {"alias": "0.3_um_count_b"}, + "pm0_5_um_count": {"alias": "0.5_um_count"}, + "pm0_5_um_count_a": {"alias": "0.5_um_count_a"}, + "pm0_5_um_count_b": {"alias": "0.5_um_count_b"}, + "pm10_0": {"alias": "pm10.0"}, + "pm10_0_a": {"alias": "pm10.0_a"}, + "pm10_0_atm": {"alias": "pm10.0_atm"}, + "pm10_0_atm_a": {"alias": "pm10.0_atm_a"}, + "pm10_0_atm_b": {"alias": "pm10.0_atm_b"}, + "pm10_0_b": {"alias": "pm10.0_b"}, + "pm10_0_cf_1": {"alias": "pm10.0_cf_1"}, + "pm10_0_cf_1_a": {"alias": "pm10.0_cf_1_a"}, + "pm10_0_cf_1_b": {"alias": "pm10.0_cf_1_b"}, + "pm10_0_um_count": {"alias": "10.0_um_count"}, + "pm10_0_um_count_a": {"alias": "10.0_um_count_a"}, + "pm10_0_um_count_b": {"alias": "10.0_um_count_b"}, + "pm1_0": {"alias": "pm1.0"}, + "pm1_0_a": {"alias": "pm1.0_a"}, + "pm1_0_atm": {"alias": "pm1.0_atm"}, + "pm1_0_atm_a": {"alias": "pm1.0_atm_a"}, + "pm1_0_atm_b": {"alias": "pm1.0_atm_b"}, + "pm1_0_b": {"alias": "pm1.0_b"}, + "pm1_0_cf_1": {"alias": "pm1.0_cf_1"}, + "pm1_0_cf_1_a": {"alias": "pm1.0_cf_1_a"}, + "pm1_0_cf_1_b": {"alias": "pm1.0_cf_1_b"}, + "pm1_0_um_count": {"alias": "1.0_um_count"}, + "pm1_0_um_count_a": {"alias": "1.0_um_count_a"}, + "pm1_0_um_count_b": {"alias": "1.0_um_count_b"}, + "pm2_5": {"alias": "pm2.5"}, + "pm2_5_10minute": {"alias": "pm2.5_10minute"}, + "pm2_5_10minute_a": {"alias": "pm2.5_10minute_a"}, + "pm2_5_10minute_b": {"alias": "pm2.5_10minute_b"}, + "pm2_5_1week": {"alias": "pm2.5_1week"}, + "pm2_5_1week_a": {"alias": "pm2.5_1week_a"}, + "pm2_5_1week_b": {"alias": "pm2.5_1week_b"}, + "pm2_5_24hour": {"alias": "pm2.5_24hour"}, + "pm2_5_24hour_a": {"alias": "pm2.5_24hour_a"}, + "pm2_5_24hour_b": {"alias": "pm2.5_24hour_b"}, + "pm2_5_30minute": {"alias": "pm2.5_30minute"}, + "pm2_5_30minute_a": {"alias": "pm2.5_30minute_a"}, + "pm2_5_30minute_b": {"alias": "pm2.5_30minute_b"}, + "pm2_5_60minute": {"alias": "pm2.5_60minute"}, + "pm2_5_60minute_a": {"alias": "pm2.5_60minute_a"}, + "pm2_5_60minute_b": {"alias": "pm2.5_60minute_b"}, + "pm2_5_6hour": {"alias": "pm2.5_6hour"}, + "pm2_5_6hour_a": {"alias": "pm2.5_6hour_a"}, + "pm2_5_6hour_b": {"alias": "pm2.5_6hour_b"}, + "pm2_5_a": {"alias": "pm2.5_a"}, + "pm2_5_alt": {"alias": "pm2.5_alt"}, + "pm2_5_alt_a": {"alias": "pm2.5_alt_a"}, + "pm2_5_alt_b": {"alias": "pm2.5_alt_b"}, + "pm2_5_atm": {"alias": "pm2.5_atm"}, + "pm2_5_atm_a": {"alias": "pm2.5_atm_a"}, + "pm2_5_atm_b": {"alias": "pm2.5_atm_b"}, + "pm2_5_b": {"alias": "pm2.5_b"}, + "pm2_5_cf_1": {"alias": "pm2.5_cf_1"}, + "pm2_5_cf_1_a": {"alias": "pm2.5_cf_1_a"}, + "pm2_5_cf_1_b": {"alias": "pm2.5_cf_1_b"}, + "pm2_5_um_count": {"alias": "2.5_um_count"}, + "pm2_5_um_count_a": {"alias": "2.5_um_count_a"}, + "pm2_5_um_count_b": {"alias": "2.5_um_count_b"}, + "pm5_0_um_count": {"alias": "5.0_um_count"}, + "pm5_0_um_count_a": {"alias": "5.0_um_count_a"}, + "pm5_0_um_count_b": {"alias": "5.0_um_count_b"}, + } + frozen = True + + validate_channel_flags = validator( + "channel_flags", + allow_reuse=True, + pre=True, + )(validate_channel_flag) + + validate_channel_flags_auto = validator( + "channel_flags_auto", + allow_reuse=True, + pre=True, + )(validate_channel_flag) + + validate_channel_flags_manual = validator( + "channel_flags_manual", + allow_reuse=True, + pre=True, + )(validate_channel_flag) + + @validator("channel_state", pre=True) + @classmethod + def validate_channel_state(cls, value: int) -> ChannelState: + """Validate the channel state. + + Args: + value: The integer-based interpretation of a channel state. + + Returns: + A ChannelState value. + + Raises: + ValueError: Raised upon an unknown location type. + """ + try: + return ChannelState(value) + except ValueError as err: + raise ValueError(f"{value} is an unknown channel state") from err + + validate_last_modified = validator( + "last_modified", + allow_reuse=True, + pre=True, + )(validate_timestamp) + + validate_last_seen = validator( + "last_seen", + allow_reuse=True, + pre=True, + )(validate_timestamp) + + validate_latitude = validator( + "latitude", + allow_reuse=True, + )(validate_latitude) + + @validator("location_type", pre=True) + @classmethod + def validate_location_type_response(cls, value: int) -> LocationType: + """Validate a location type for a request payload. + + Args: + value: The integer-based interpretation of a location type. + + Returns: + A LocationType value. + + Raises: + ValueError: Raised upon an unknown location type. + """ + try: + return LocationType(value) + except ValueError as err: + raise ValueError(f"{value} is an unknown location type") from err + + validate_longitude = validator( + "longitude", + allow_reuse=True, + )(validate_longitude) + + validate_date_created = validator( + "date_created", + allow_reuse=True, + pre=True, + )(validate_timestamp) class GetSensorRequest(BaseModel): @@ -36,7 +354,10 @@ class Config: frozen = True - validate_fields = validator("fields", allow_reuse=True)(validate_fields_request) + validate_fields = validator( + "fields", + allow_reuse=True, + )(validate_fields_request) class GetSensorResponse(BaseModel): @@ -45,20 +366,24 @@ class GetSensorResponse(BaseModel): api_version: str time_stamp: datetime data_time_stamp: datetime - sensor: dict[str, Any] + sensor: SensorModel class Config: """Define configuration for this model.""" frozen = True - validate_data_time_stamp = validator("data_time_stamp", allow_reuse=True, pre=True)( - validate_timestamp - ) + validate_data_time_stamp = validator( + "data_time_stamp", + allow_reuse=True, + pre=True, + )(validate_timestamp) - validate_time_stamp = validator("time_stamp", allow_reuse=True, pre=True)( - validate_timestamp - ) + validate_time_stamp = validator( + "time_stamp", + allow_reuse=True, + pre=True, + )(validate_timestamp) class GetSensorsRequest(BaseModel): @@ -110,7 +435,10 @@ def validate_bounding_box_missing_or_complete( return values - validate_fields = validator("fields", allow_reuse=True)(validate_fields_request) + validate_fields = validator( + "fields", + allow_reuse=True, + )(validate_fields_request) @validator("location_type") @classmethod @@ -138,8 +466,15 @@ def validate_modified_since(cls, value: datetime) -> int: """ return round(utc_to_timestamp(value)) - validate_nwlat = validator("nwlat", allow_reuse=True)(validate_latitude) - validate_nwlng = validator("nwlng", allow_reuse=True)(validate_longitude) + validate_nwlat = validator( + "nwlat", + allow_reuse=True, + )(validate_latitude) + + validate_nwlng = validator( + "nwlng", + allow_reuse=True, + )(validate_longitude) @validator("read_keys") @classmethod @@ -154,8 +489,15 @@ def validate_read_keys(cls, value: list[str]) -> str: """ return ",".join(value) - validate_selat = validator("selat", allow_reuse=True)(validate_latitude) - validate_selng = validator("selng", allow_reuse=True)(validate_longitude) + validate_selat = validator( + "selat", + allow_reuse=True, + )(validate_latitude) + + validate_selng = validator( + "selng", + allow_reuse=True, + )(validate_longitude) @validator("show_only") @classmethod @@ -171,26 +513,11 @@ def validate_show_only(cls, value: list[int]) -> str: return ",".join([str(i) for i in value]) -def convert_sensor_response( - fields: list[str], field_values: list[Any] -) -> dict[str, Any]: - """Convert sensor fields into an easier-to-parse dictionary. - - Args: - fields: A list of sensor types. - field_values: A raw list of sensor fields. - - Returns: - A dictionary of sensor data. - """ - return dict(zip(fields, field_values)) - - class GetSensorsResponse(BaseModel): """Define a response to GET /v1/sensors.""" fields: list[str] - data: dict[int, dict[str, Any]] + data: dict[int, SensorModel] api_version: str time_stamp: datetime @@ -198,13 +525,6 @@ class GetSensorsResponse(BaseModel): max_age: int firmware_default_version: str - channel_flags: Optional[ - Literal["Normal", "A-Downgraded", "B-Downgraded", "A+B-Downgraded"] - ] = None - channel_states: Optional[Literal["No PM", "PM-A", "PM-B", "PM-A+PM-B"]] = None - location_type: Optional[LocationType] = None - location_types: Optional[Literal["inside", "outside"]] = None - class Config: """Define configuration for this model.""" @@ -225,13 +545,17 @@ def validate_data( A better format for the data. """ return { - sensor_values[0]: convert_sensor_response(values["fields"], sensor_values) + sensor_values[0]: SensorModel.parse_obj( + dict(zip(values["fields"], sensor_values)) + ) for sensor_values in value } - validate_data_time_stamp = validator("data_time_stamp", allow_reuse=True, pre=True)( - validate_timestamp - ) + validate_data_time_stamp = validator( + "data_time_stamp", + allow_reuse=True, + pre=True, + )(validate_timestamp) @root_validator(pre=True) @classmethod @@ -252,25 +576,8 @@ def validate_fields(cls, values: dict[str, Any]) -> dict[str, Any]: raise ValueError(f"{field} is an unknown field") return values - @validator("location_type", pre=True) - @classmethod - def validate_location_type(cls, value: int) -> LocationType: - """Validate the location type. - - Args: - value: The integer-based interpretation of a location type. - - Returns: - A LocationType value. - - Raises: - ValueError: Raised upon an unknown location type. - """ - try: - return LocationType(value) - except ValueError as err: - raise ValueError(f"{value} is an unknown location type") from err - - validate_time_stamp = validator("time_stamp", allow_reuse=True, pre=True)( - validate_timestamp - ) + validate_time_stamp = validator( + "time_stamp", + allow_reuse=True, + pre=True, + )(validate_timestamp) diff --git a/tests/endpoints/test_sensors.py b/tests/endpoints/test_sensors.py index 2005d85..844228a 100644 --- a/tests/endpoints/test_sensors.py +++ b/tests/endpoints/test_sensors.py @@ -9,13 +9,16 @@ from aresponses import ResponsesMockServer from aiopurpleair import API +from aiopurpleair.const import ChannelFlag, ChannelState, LocationType from aiopurpleair.errors import InvalidRequestError -from aiopurpleair.models.sensors import LocationType +from aiopurpleair.models.sensors import SensorModel from tests.common import TEST_API_KEY, load_fixture @pytest.mark.asyncio -async def test_get_sensor(aresponses: ResponsesMockServer) -> None: +async def test_get_sensor( # pylint: disable=too-many-statements + aresponses: ResponsesMockServer, +) -> None: """Test the GET /sensor/:sensor_index endpoint. Args: @@ -36,129 +39,127 @@ async def test_get_sensor(aresponses: ResponsesMockServer) -> None: assert response.api_version == "V1.0.11-0.0.41" assert response.time_stamp == datetime(2022, 11, 5, 16, 37, 3) assert response.data_time_stamp == datetime(2022, 11, 5, 16, 36, 21) - assert response.sensor == { - "sensor_index": 131075, - "last_modified": 1635632829, - "date_created": 1632955574, - "last_seen": 1667666162, - "private": 0, - "is_owner": 0, - "name": "Mariners Bluff", - "icon": 0, - "location_type": 0, - "model": "PA-II", - "hardware": "2.0+BME280+PMSX003-B+PMSX003-A", - "led_brightness": 35, - "firmware_version": "7.02", - "rssi": -67, - "uptime": 15682, - "pa_latency": 992, - "memory": 16008, - "position_rating": 5, - "latitude": 33.51511, - "longitude": -117.67972, - "altitude": 569, - "channel_state": 3, - "channel_flags": 0, - "channel_flags_manual": 0, - "channel_flags_auto": 0, - "confidence": 100, - "confidence_auto": 100, - "confidence_manual": 100, - "humidity": 33, - "humidity_a": 33, - "temperature": 69, - "temperature_a": 69, - "pressure": 1001.66, - "pressure_a": 1001.66, - "analog_input": 0.03, - "pm1.0": 0.0, - "pm1.0_a": 0.0, - "pm1.0_b": 0.0, - "pm2.5": 0.0, - "pm2.5_a": 0.0, - "pm2.5_b": 0.0, - "pm2.5_alt": 0.4, - "pm2.5_alt_a": 0.3, - "pm2.5_alt_b": 0.4, - "pm10.0": 0.0, - "pm10.0_a": 0.0, - "pm10.0_b": 0.0, - "0.3_um_count": 75, - "0.3_um_count_a": 65, - "0.3_um_count_b": 86, - "0.5_um_count": 65, - "0.5_um_count_a": 58, - "0.5_um_count_b": 73, - "1.0_um_count": 0, - "1.0_um_count_a": 0, - "1.0_um_count_b": 0, - "2.5_um_count": 0, - "2.5_um_count_a": 0, - "2.5_um_count_b": 0, - "5.0_um_count": 0, - "5.0_um_count_a": 0, - "5.0_um_count_b": 0, - "10.0_um_count": 0, - "10.0_um_count_a": 0, - "10.0_um_count_b": 0, - "pm1.0_cf_1": 0.0, - "pm1.0_cf_1_a": 0.0, - "pm1.0_cf_1_b": 0.0, - "pm1.0_atm": 0.0, - "pm1.0_atm_a": 0.0, - "pm1.0_atm_b": 0.0, - "pm2.5_atm": 0.0, - "pm2.5_atm_a": 0.0, - "pm2.5_atm_b": 0.0, - "pm2.5_cf_1": 0.0, - "pm2.5_cf_1_a": 0.0, - "pm2.5_cf_1_b": 0.0, - "pm10.0_atm": 0.0, - "pm10.0_atm_a": 0.0, - "pm10.0_atm_b": 0.0, - "pm10.0_cf_1": 0.0, - "pm10.0_cf_1_a": 0.0, - "pm10.0_cf_1_b": 0.0, - "primary_id_a": 1522282, - "primary_key_a": "FVXH9TQTQGG2CHEY", - "primary_id_b": 1522284, - "primary_key_b": "31ZHIMYRBK62KPY1", - "secondary_id_a": 1522283, - "secondary_key_a": "UVKQCKBKJATTQGCX", - "secondary_id_b": 1522285, - "secondary_key_b": "DT8UOXHFJS1JDONG", - "stats": { - "pm2.5": 0.0, - "pm2.5_10minute": 0.2, - "pm2.5_30minute": 1.0, - "pm2.5_60minute": 1.2, - "pm2.5_6hour": 1.2, - "pm2.5_24hour": 1.8, - "pm2.5_1week": 5.8, - "time_stamp": 1667666162, - }, - "stats_a": { - "pm2.5": 0.0, - "pm2.5_10minute": 0.1, - "pm2.5_30minute": 0.9, - "pm2.5_60minute": 1.0, - "pm2.5_6hour": 1.0, - "pm2.5_24hour": 1.4, - "pm2.5_1week": 4.8, - "time_stamp": 1667666162, - }, - "stats_b": { - "pm2.5": 0.0, - "pm2.5_10minute": 0.2, - "pm2.5_30minute": 1.2, - "pm2.5_60minute": 1.3, - "pm2.5_6hour": 1.5, - "pm2.5_24hour": 2.2, - "pm2.5_1week": 6.7, - "time_stamp": 1667666162, - }, - } + assert response.sensor.sensor_index == 131075 + assert response.sensor.altitude == 569 + assert response.sensor.analog_input == 0.03 + assert response.sensor.channel_flags == ChannelFlag.NORMAL + assert response.sensor.channel_flags_auto == ChannelFlag.NORMAL + assert response.sensor.channel_flags_manual == ChannelFlag.NORMAL + assert response.sensor.channel_state == ChannelState.PM_A_PM_B + assert response.sensor.confidence == 100 + assert response.sensor.confidence_auto == 100 + assert response.sensor.confidence_manual == 100 + assert response.sensor.date_created == datetime(2021, 9, 29, 22, 46, 14) + assert response.sensor.firmware_version == "7.02" + assert response.sensor.hardware == "2.0+BME280+PMSX003-B+PMSX003-A" + assert response.sensor.humidity == 33 + assert response.sensor.humidity_a == 33 + assert response.sensor.icon == 0 + assert response.sensor.is_owner is False + assert response.sensor.last_modified == datetime(2021, 10, 30, 22, 27, 9) + assert response.sensor.last_seen == datetime(2022, 11, 5, 16, 36, 2) + assert response.sensor.latitude == 33.51511 + assert response.sensor.led_brightness == 35 + assert response.sensor.location_type == LocationType.OUTSIDE + assert response.sensor.longitude == -117.67972 + assert response.sensor.memory == 16008 + assert response.sensor.model == "PA-II" + assert response.sensor.name == "Mariners Bluff" + assert response.sensor.pa_latency == 992 + assert response.sensor.pm0_3_um_count == 75 + assert response.sensor.pm0_3_um_count_a == 65 + assert response.sensor.pm0_3_um_count_b == 86 + assert response.sensor.pm0_5_um_count == 65 + assert response.sensor.pm0_5_um_count_a == 58 + assert response.sensor.pm0_5_um_count_b == 73 + assert response.sensor.pm10_0_cf_1_a == 0.0 + assert response.sensor.pm10_0_cf_1_b == 0.0 + assert response.sensor.pm10_0 == 0.0 + assert response.sensor.pm10_0_a == 0.0 + assert response.sensor.pm10_0_atm == 0.0 + assert response.sensor.pm10_0_atm_a == 0.0 + assert response.sensor.pm10_0_atm_b == 0.0 + assert response.sensor.pm10_0_b == 0.0 + assert response.sensor.pm10_0_cf_1 == 0.0 + assert response.sensor.pm10_0_um_count == 0 + assert response.sensor.pm10_0_um_count_a == 0 + assert response.sensor.pm10_0_um_count_b == 0 + assert response.sensor.pm1_0 == 0.0 + assert response.sensor.pm1_0_a == 0.0 + assert response.sensor.pm1_0_atm == 0.0 + assert response.sensor.pm1_0_atm_a == 0.0 + assert response.sensor.pm1_0_atm_b == 0.0 + assert response.sensor.pm1_0_b == 0.0 + assert response.sensor.pm1_0_cf_1 == 0.0 + assert response.sensor.pm1_0_cf_1_a == 0.0 + assert response.sensor.pm1_0_cf_1_b == 0.0 + assert response.sensor.pm1_0_um_count == 0 + assert response.sensor.pm1_0_um_count_a == 0 + assert response.sensor.pm1_0_um_count_b == 0 + assert response.sensor.pm2_5 == 0.0 + assert response.sensor.pm2_5_a == 0.0 + assert response.sensor.pm2_5_alt == 0.4 + assert response.sensor.pm2_5_alt_a == 0.3 + assert response.sensor.pm2_5_alt_b == 0.4 + assert response.sensor.pm2_5_atm == 0.0 + assert response.sensor.pm2_5_atm_a == 0.0 + assert response.sensor.pm2_5_atm_b == 0.0 + assert response.sensor.pm2_5_b == 0.0 + assert response.sensor.pm2_5_cf_1 == 0.0 + assert response.sensor.pm2_5_cf_1_a == 0.0 + assert response.sensor.pm2_5_cf_1_b == 0.0 + assert response.sensor.pm2_5_um_count == 0 + assert response.sensor.pm2_5_um_count_a == 0 + assert response.sensor.pm2_5_um_count_b == 0 + assert response.sensor.pm5_0_um_count == 0 + assert response.sensor.pm5_0_um_count_a == 0 + assert response.sensor.pm5_0_um_count_b == 0 + assert response.sensor.position_rating == 5 + assert response.sensor.pressure == 1001.66 + assert response.sensor.pressure_a == 1001.66 + assert response.sensor.primary_id_a == 1522282 + assert response.sensor.primary_id_b == 1522284 + assert response.sensor.primary_key_a == "FVXH9TQTQGG2CHEY" + assert response.sensor.primary_key_b == "31ZHIMYRBK62KPY1" + assert response.sensor.private is False + assert response.sensor.rssi == -67 + assert response.sensor.secondary_id_a == 1522283 + assert response.sensor.secondary_id_b == 1522285 + assert response.sensor.secondary_key_a == "UVKQCKBKJATTQGCX" + assert response.sensor.secondary_key_b == "DT8UOXHFJS1JDONG" + assert response.sensor.temperature == 69 + assert response.sensor.temperature_a == 69 + assert response.sensor.uptime == 15682 + + assert response.sensor.stats + assert response.sensor.stats.pm2_5 == 0.0 + assert response.sensor.stats.pm2_5_10minute == 0.2 + assert response.sensor.stats.pm2_5_30minute == 1.0 + assert response.sensor.stats.pm2_5_60minute == 1.2 + assert response.sensor.stats.pm2_5_6hour == 1.2 + assert response.sensor.stats.pm2_5_24hour == 1.8 + assert response.sensor.stats.pm2_5_1week == 5.8 + assert response.sensor.stats.time_stamp == datetime(2022, 11, 5, 16, 36, 2) + + assert response.sensor.stats_a + assert response.sensor.stats_a.pm2_5 == 0.0 + assert response.sensor.stats_a.pm2_5_10minute == 0.1 + assert response.sensor.stats_a.pm2_5_30minute == 0.9 + assert response.sensor.stats_a.pm2_5_60minute == 1.0 + assert response.sensor.stats_a.pm2_5_6hour == 1.0 + assert response.sensor.stats_a.pm2_5_24hour == 1.4 + assert response.sensor.stats_a.pm2_5_1week == 4.8 + assert response.sensor.stats_a.time_stamp == datetime(2022, 11, 5, 16, 36, 2) + + assert response.sensor.stats_b + assert response.sensor.stats_b.pm2_5 == 0.0 + assert response.sensor.stats_b.pm2_5_10minute == 0.2 + assert response.sensor.stats_b.pm2_5_30minute == 1.2 + assert response.sensor.stats_b.pm2_5_60minute == 1.3 + assert response.sensor.stats_b.pm2_5_6hour == 1.5 + assert response.sensor.stats_b.pm2_5_24hour == 2.2 + assert response.sensor.stats_b.pm2_5_1week == 6.7 + assert response.sensor.stats_b.time_stamp == datetime(2022, 11, 5, 16, 36, 2) aresponses.assert_plan_strictly_followed() @@ -205,20 +206,20 @@ async def test_get_sensors(aresponses: ResponsesMockServer) -> None: assert response.data_time_stamp == datetime(2022, 11, 3, 19, 25, 31) assert response.firmware_default_version == "7.02" assert response.max_age == 604800 - assert response.channel_flags is None - assert response.channel_states is None - assert response.location_type is LocationType.OUTSIDE - assert response.location_types is None assert response.fields == ["sensor_index", "name"] assert response.data == { - 131075: { - "sensor_index": 131075, - "name": "Mariners Bluff", - }, - 131079: { - "sensor_index": 131079, - "name": "BRSKBV-outside", - }, + 131075: SensorModel( + **{ + "sensor_index": 131075, + "name": "Mariners Bluff", + } + ), + 131079: SensorModel( + **{ + "sensor_index": 131079, + "name": "BRSKBV-outside", + } + ), } aresponses.assert_plan_strictly_followed() diff --git a/tests/models/test_sensors.py b/tests/models/test_sensors.py index f674d28..da2f6ab 100644 --- a/tests/models/test_sensors.py +++ b/tests/models/test_sensors.py @@ -58,6 +58,56 @@ def test_get_sensors_request( assert request.dict(exclude_none=True) == output_payload +@pytest.mark.parametrize( + "payload,error_string", + [ + ( + { + "fields": ["name", "foobar"], + }, + "foobar is an unknown field", + ), + ( + { + "fields": ["name"], + "nwlng": -0.2416796, + "nwlat": -100.2416796, + "selng": -0.8876124, + "selat": 54.7818162, + }, + "-100.2416796 is an invalid latitude", + ), + ( + { + "fields": ["name"], + "nwlng": -200.2416796, + "nwlat": 51.5285582, + "selng": -0.8876124, + "selat": 54.7818162, + }, + "-200.2416796 is an invalid longitude", + ), + ( + { + "fields": ["name"], + "nwlng": -0.2416796, + }, + "must pass none or all of the bounding box coordinates", + ), + ], +) +def test_get_sensors_request_errors(error_string: str, payload: dict[str, Any]) -> None: + """Test that an invalid GetSensorsRequest payload raises an error. + + Args: + error_string: The error string that gets raised. + payload: The payload to test. + """ + with pytest.raises(ValidationError) as err: + _ = GetSensorsRequest.parse_obj(payload) + assert error_string in str(err.value) + + @pytest.mark.parametrize( "input_payload,output_payload", [ @@ -76,7 +126,6 @@ def test_get_sensors_request( "api_version": "V1.0.11-0.0.41", "time_stamp": datetime(2022, 11, 4, 3, 38, 17), "data_time_stamp": datetime(2022, 11, 4, 3, 38, 11), - "location_type": LocationType.OUTSIDE, "max_age": 604800, "firmware_default_version": "7.02", "fields": ["sensor_index", "name", "icon"], @@ -120,8 +169,11 @@ def test_get_sensors_response( "location_type": 0, "max_age": 604800, "firmware_default_version": "7.02", - "fields": ["name", "foobar"], - "data": [[131075, "Mariners Bluff", 0], [131079, "BRSKBV-outside", 0]], + "fields": ["sensor_index", "name", "foobar"], + "data": [ + [131075, "Mariners Bluff", 0], + [131079, "BRSKBV-outside", 0], + ], }, "foobar is an unknown field", ), @@ -130,75 +182,57 @@ def test_get_sensors_response( "api_version": "V1.0.11-0.0.41", "time_stamp": 1667533097, "data_time_stamp": 1667533091, - "location_type": 2, "max_age": 604800, "firmware_default_version": "7.02", - "fields": ["name", "icon"], - "data": [[131075, "Mariners Bluff", 0], [131079, "BRSKBV-outside", 0]], + "fields": ["sensor_index", "name", "icon", "location_type"], + "data": [ + [131075, "Mariners Bluff", 0, 0], + [131079, "BRSKBV-outside", 0, 2], + ], }, "2 is an unknown location type", ), - ], -) -def test_get_sensors_response_errors( - error_string: str, payload: dict[str, Any] -) -> None: - """Test that an invalid GetSensorsResponse payload raises an error. - - Args: - error_string: The error string that gets raised. - payload: The payload to test. - """ - with pytest.raises(ValidationError) as err: - _ = GetSensorsResponse.parse_obj(payload) - assert error_string in str(err.value) - - -@pytest.mark.parametrize( - "payload,error_string", - [ - ( - { - "fields": ["name", "foobar"], - }, - "foobar is an unknown field", - ), - ( - { - "fields": ["name"], - "nwlng": -0.2416796, - "nwlat": -100.2416796, - "selng": -0.8876124, - "selat": 54.7818162, - }, - "-100.2416796 is an invalid latitude", - ), ( { - "fields": ["name"], - "nwlng": -200.2416796, - "nwlat": 51.5285582, - "selng": -0.8876124, - "selat": 54.7818162, + "api_version": "V1.0.11-0.0.41", + "time_stamp": 1667533097, + "data_time_stamp": 1667533091, + "max_age": 604800, + "firmware_default_version": "7.02", + "fields": ["sensor_index", "name", "icon", "channel_flags"], + "data": [ + [131075, "Mariners Bluff", 0, 0], + [131079, "BRSKBV-outside", 0, 7], + ], }, - "-200.2416796 is an invalid longitude", + "7 is an unknown channel flag", ), ( { - "fields": ["name"], - "nwlng": -0.2416796, + "api_version": "V1.0.11-0.0.41", + "time_stamp": 1667533097, + "data_time_stamp": 1667533091, + "max_age": 604800, + "firmware_default_version": "7.02", + "fields": ["sensor_index", "name", "icon", "channel_state"], + "data": [ + [131075, "Mariners Bluff", 0, 0], + [131079, "BRSKBV-outside", 0, 7], + ], }, - "must pass none or all of the bounding box coordinates", + "7 is an unknown channel state", ), ], ) -def test_get_sensors_request_errors(error_string: str, payload: dict[str, Any]) -> None: - """Test that an invalid GetSensorsRequest payload raises an error. +def test_get_sensors_response_errors( + error_string: str, payload: dict[str, Any] +) -> None: + """Test that an invalid GetSensorsResponse payload raises an error. Args: error_string: The error string that gets raised. payload: The payload to test. """ with pytest.raises(ValidationError) as err: - _ = GetSensorsRequest.parse_obj(payload) + _ = GetSensorsResponse.parse_obj(payload) assert error_string in str(err.value) From 7a3603f6125efa8d138919a5fd55313559b8ffe7 Mon Sep 17 00:00:00 2001 From: Aaron Bach Date: Mon, 7 Nov 2022 11:04:53 -0700 Subject: [PATCH 2/3] Docs --- README.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 9b5bdb2..e252df6 100644 --- a/README.md +++ b/README.md @@ -110,7 +110,7 @@ from aiopurpleair import API async def main() -> None: """Run.""" api = API("") - response = await api.sensors.async_get_sensor("") + response = await api.sensors.async_get_sensor(131075) # >>> response.api_version == "V1.0.11-0.0.41" # >>> response.time_stamp == datetime(2022, 11, 5, 16, 37, 3) # >>> response.data_time_stamp == datetime(2022, 11, 5, 16, 36, 21) @@ -122,6 +122,7 @@ asyncio.run(main()) `API.sensors.async_get_sensor` takes several parameters: +- `sensor_index` (required): The sensor index of the sensor to retrieve. - `fields` (optional): The sensor data fields to include. - `read_key` (optional): A read key for a private sensor. From 451b9066037e35d60c0103909c11ecb72a78f7bc Mon Sep 17 00:00:00 2001 From: Aaron Bach Date: Mon, 7 Nov 2022 11:08:06 -0700 Subject: [PATCH 3/3] Docs --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index e252df6..6c4e46d 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# 🚰 aiopurpleair: A Python3, asyncio-based library to interact with the PurpleAir API +# 🟣 aiopurpleair: A Python3, asyncio-based library to interact with the PurpleAir API [![CI](https://github.com/bachya/aiopurpleair/workflows/CI/badge.svg)](https://github.com/bachya/aiopurpleair/actions) [![PyPi](https://img.shields.io/pypi/v/aiopurpleair.svg)](https://pypi.python.org/pypi/aiopurpleair)