Skip to content

Commit

Permalink
Handle both old- and new-style Ambient Weather query parameters
Browse files Browse the repository at this point in the history
  • Loading branch information
bachya committed Nov 2, 2023
1 parent b3698c5 commit 1859e4b
Show file tree
Hide file tree
Showing 2 changed files with 74 additions and 31 deletions.
58 changes: 29 additions & 29 deletions ecowitt2mqtt/helpers/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,19 +15,21 @@
CallbackT = Callable[[dict[str, Any]], None]


def get_params_from_request_path(request: Request) -> dict[str, Any]:
"""Get the parameters from a request path.
def get_request_query_params(request: Request) -> dict[str, Any]:
"""Get the query parameters from a request.
This is used in cases (like Ambient Weather) where the request payload is
passed as a non-standard query string.
Some older devices will pass the parameters as part of a non-standard query string;
we look for those cases there.
Args:
request: A FastAPI Request object.
Returns:
A dictionary containing the request parameters.
"""
return dict(urllib.parse.parse_qsl(request.path_params["param_string"]))
if request.path_params:
return dict(urllib.parse.parse_qsl(request.path_params["param_string"]))
return dict(request.query_params)


class InputDataFormat(StrEnum):
Expand All @@ -53,18 +55,16 @@ def __init__(self, fastapi: FastAPI, endpoint: str) -> None:
self._endpoint = endpoint
self._payload_received_callbacks: list[CallbackT] = []

normalized_endpoint = self._normalize_endpoint(endpoint)
LOGGER.debug(normalized_endpoint)

for route in (normalized_endpoint, f"{normalized_endpoint}/"):
fastapi.add_api_route(
route,
self._async_handle_query, # type: ignore[arg-type]
methods=[self.HTTP_REQUEST_VERB.lower()],
response_class=Response,
response_model=None,
status_code=status.HTTP_204_NO_CONTENT,
)
for normalized_endpoint in self._normalize_endpoints(endpoint):
for route in (normalized_endpoint, f"{normalized_endpoint}/"):
fastapi.add_api_route(
route,
self._async_handle_query, # type: ignore[arg-type]
methods=[self.HTTP_REQUEST_VERB.lower()],
response_class=Response,
response_model=None,
status_code=status.HTTP_204_NO_CONTENT,
)

async def _async_handle_query(self, request: Request) -> None:
"""Handle an API query.
Expand All @@ -78,8 +78,8 @@ async def _async_handle_query(self, request: Request) -> None:
for callback in self._payload_received_callbacks:
callback(payload)

def _normalize_endpoint(self, endpoint: str) -> str:
"""Normalize the endpoint to work with this server.
def _normalize_endpoints(self, endpoint: str) -> list[str]:
"""Return the endpoints this server should expose.
Args:
endpoint: The endpoint to normalize.
Expand All @@ -88,8 +88,8 @@ def _normalize_endpoint(self, endpoint: str) -> str:
A normalized endpoint.
"""
if endpoint.endswith("/"):
return endpoint[:-1]
return endpoint
return [endpoint[:-1]]
return [endpoint]

def add_payload_callback(self, callback: CallbackT) -> None:
"""Add a callback to be called when a new payload is received.
Expand All @@ -116,16 +116,16 @@ class AmbientWeatherAPIServer(APIServer):

HTTP_REQUEST_VERB = hdrs.METH_GET

def _normalize_endpoint(self, endpoint: str) -> str:
"""Normalize the endpoint to work with this server.
def _normalize_endpoints(self, endpoint: str) -> list[str]:
"""Return the endpoints this server should expose.
Args:
endpoint: The endpoint to normalize.
Returns:
A normalized endpoint.
"""
return endpoint + "{param_string}"
return [endpoint, endpoint + "{param_string}"]

async def async_parse_request_payload(self, request: Request) -> dict[str, Any]:
"""Parse and return the request payload.
Expand All @@ -136,7 +136,7 @@ async def async_parse_request_payload(self, request: Request) -> dict[str, Any]:
Returns:
A dictionary containing the request payload.
"""
params = get_params_from_request_path(request)
params = get_request_query_params(request)

# Ambient Weather uses a MAC address (with colons) as the PASSKEY; the colons
# can cause issues with Home Assistant MQTT Discovery, so we remove them:
Expand Down Expand Up @@ -168,16 +168,16 @@ class WUndergroundAPIServer(APIServer):

HTTP_REQUEST_VERB = hdrs.METH_GET

def _normalize_endpoint(self, endpoint: str) -> str:
"""Normalize the endpoint to work with this server.
def _normalize_endpoints(self, endpoint: str) -> list[str]:
"""Return the endpoints this server should expose.
Args:
endpoint: The endpoint to normalize.
Returns:
A normalized endpoint.
"""
return endpoint + "{param_string}"
return [endpoint, endpoint + "{param_string}"]

async def async_parse_request_payload(self, request: Request) -> dict[str, Any]:
"""Parse and return the request payload.
Expand All @@ -188,7 +188,7 @@ async def async_parse_request_payload(self, request: Request) -> dict[str, Any]:
Returns:
A dictionary containing the request payload.
"""
params = get_params_from_request_path(request)
params = get_request_query_params(request)
for field_to_ignore in "PASSWORD":
params.pop(field_to_ignore, None)
params["PASSKEY"] = params["ID"]
Expand Down
47 changes: 45 additions & 2 deletions tests/test_runtime.py
Original file line number Diff line number Diff line change
Expand Up @@ -64,15 +64,58 @@ async def test_publish_failure(
TEST_CONFIG_JSON | {CONF_INPUT_DATA_FORMAT: InputDataFormat.AMBIENT_WEATHER},
],
)
async def test_publish_ambient_weather_success(
async def test_publish_ambient_weather_new_format_success(
caplog: Mock,
device_data: dict[str, Any],
ecowitt: Ecowitt,
mock_aiomqtt_client: MagicMock,
setup_aiomqtt: AsyncGenerator[None, None],
setup_uvicorn_server: AsyncGenerator[None, None],
) -> None:
"""Test a successful Ambient Weather payload being received and published.
"""Test a successful new-format Ambient Weather payload being received and published.
Args:
caplog: A mock logging utility.
device_data: A dictionary of device data.
ecowitt: A parsed Ecowitt object.
mock_aiomqtt_client: A mocked aiomqtt Client object.
setup_aiomqtt: A mock aiomqtt client connection.
setup_uvicorn_server: A mock Uvicorn + FastAPI application.
"""
ambient_payload = json.loads(load_fixture("payload_ambweather.json"))

async with ClientSession() as session:
resp = await session.request(
"get",
f"http://127.0.0.1:{TEST_PORT}{TEST_ENDPOINT}",
params=ambient_payload,
)

await asyncio.sleep(0.1)
assert resp.status == 204
mock_aiomqtt_client.publish.assert_awaited_with(
TEST_MQTT_TOPIC,
payload=b'{"tempin": 67.3, "humidityin": 33.0, "baromrel": 29.616, "baromabs": 24.679, "temp": 53.8, "humidity": 30.0, "winddir": 99.0, "windspeed": 4.5, "windgust": 6.9, "maxdailygust": 14.8, "hourlyrain": 0.0, "eventrain": 0.0, "dailyrain": 0.0, "weeklyrain": 0.024, "monthlyrain": 0.311, "totalrain": 48.811, "solarradiation": 39.02, "uv": 0.0, "batt_co2": "ON", "beaufortscale": 2, "dewpoint": 23.12793817902528, "feelslike": 53.8, "frostpoint": 20.34536144435649, "frostrisk": "No risk", "heatindex": 50.28999999999999, "humidex": 9, "humidex_perception": "Comfortable", "humidityabs": 0.00020090062644380612, "humidityabsin": 0.00020090062644380612, "relative_strain_index": null, "relative_strain_index_perception": null, "safe_exposure_time_skin_type_1": null, "safe_exposure_time_skin_type_2": null, "safe_exposure_time_skin_type_3": null, "safe_exposure_time_skin_type_4": null, "safe_exposure_time_skin_type_5": null, "safe_exposure_time_skin_type_6": null, "simmerindex": null, "simmerzone": null, "solarradiation_perceived": 73.87320347536115, "thermalperception": "Dry", "windchill": null}', # noqa: E501
retain=False,
)


@pytest.mark.asyncio
@pytest.mark.parametrize(
"config",
[
TEST_CONFIG_JSON | {CONF_INPUT_DATA_FORMAT: InputDataFormat.AMBIENT_WEATHER},
],
)
async def test_publish_ambient_weather_old_format_success(
caplog: Mock,
device_data: dict[str, Any],
ecowitt: Ecowitt,
mock_aiomqtt_client: MagicMock,
setup_aiomqtt: AsyncGenerator[None, None],
setup_uvicorn_server: AsyncGenerator[None, None],
) -> None:
"""Test a successful old-format Ambient Weather payload being received and published.
Args:
caplog: A mock logging utility.
Expand Down

0 comments on commit 1859e4b

Please sign in to comment.