PyHolfuy 0.1__tar.gz → 0.3__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {pyholfuy-0.1/pyholfuy.egg-info → pyholfuy-0.3}/PKG-INFO +17 -13
- pyholfuy-0.3/README.md +38 -0
- pyholfuy-0.3/holfuy/__init__.py +162 -0
- {pyholfuy-0.1 → pyholfuy-0.3}/holfuy/const.py +4 -4
- pyholfuy-0.3/holfuy/exceptions.py +13 -0
- pyholfuy-0.3/holfuy/test_holfuy.py +178 -0
- {pyholfuy-0.1 → pyholfuy-0.3/pyholfuy.egg-info}/PKG-INFO +17 -13
- {pyholfuy-0.1 → pyholfuy-0.3}/pyholfuy.egg-info/SOURCES.txt +2 -0
- pyholfuy-0.3/pyholfuy.egg-info/requires.txt +8 -0
- {pyholfuy-0.1 → pyholfuy-0.3}/pyproject.toml +1 -14
- pyholfuy-0.1/README.md +0 -26
- pyholfuy-0.1/holfuy/__init__.py +0 -131
- pyholfuy-0.1/pyholfuy.egg-info/requires.txt +0 -16
- {pyholfuy-0.1 → pyholfuy-0.3}/LICENSE +0 -0
- {pyholfuy-0.1 → pyholfuy-0.3}/MANIFEST.in +0 -0
- {pyholfuy-0.1 → pyholfuy-0.3}/pyholfuy.egg-info/dependency_links.txt +0 -0
- {pyholfuy-0.1 → pyholfuy-0.3}/pyholfuy.egg-info/top_level.txt +0 -0
- {pyholfuy-0.1 → pyholfuy-0.3}/setup.cfg +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: pyholfuy
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3
|
|
4
4
|
Summary: A Python library for interacting with Holfuy weather stations.
|
|
5
5
|
Author-email: Håvard Dagenborg <haavardj@dagenborg.net>
|
|
6
6
|
License-Expression: MIT
|
|
@@ -16,15 +16,7 @@ Provides-Extra: test
|
|
|
16
16
|
Requires-Dist: aioresponses==0.7.8; extra == "test"
|
|
17
17
|
Requires-Dist: coverage==7.8.0; extra == "test"
|
|
18
18
|
Requires-Dist: mypy==1.15.0; extra == "test"
|
|
19
|
-
Requires-Dist: pytest-asyncio==0.26.0; extra == "test"
|
|
20
|
-
Requires-Dist: pytest-cov==6.1.1; extra == "test"
|
|
21
|
-
Requires-Dist: pytest-error-for-skips==2.0.2; extra == "test"
|
|
22
|
-
Requires-Dist: pytest-timeout==2.3.1; extra == "test"
|
|
23
|
-
Requires-Dist: pytest==8.3.5; extra == "test"
|
|
24
|
-
Requires-Dist: ruff==0.11.7; extra == "test"
|
|
25
|
-
Requires-Dist: syrupy==4.9.1; extra == "test"
|
|
26
19
|
Provides-Extra: dev
|
|
27
|
-
Requires-Dist: pre-commit==4.2.0; extra == "dev"
|
|
28
20
|
Dynamic: license-file
|
|
29
21
|
|
|
30
22
|

|
|
@@ -34,7 +26,7 @@ A Python library for talking to [Holfuy Weather Stations](https://holfuy.com/).
|
|
|
34
26
|
|
|
35
27
|
You need an API key to access the service. To obtain one, please visit the [Holfuy API](https://api.holfuy.com/live/) pages.
|
|
36
28
|
|
|
37
|
-
The
|
|
29
|
+
The library can only access live data.
|
|
38
30
|
|
|
39
31
|
## Example Code
|
|
40
32
|
Here is an example of how to use the library.
|
|
@@ -46,10 +38,22 @@ from holfuy import HolfuyService
|
|
|
46
38
|
|
|
47
39
|
async def main():
|
|
48
40
|
async with aiohttp.ClientSession() as session:
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
41
|
+
s = HolfuyService("", session)
|
|
42
|
+
data = await s.fetch_data(["101"])
|
|
43
|
+
print(data)
|
|
52
44
|
|
|
53
45
|
if __name__ == "__main__":
|
|
54
46
|
asyncio.run(main())
|
|
55
47
|
```
|
|
48
|
+
|
|
49
|
+
## Return value and errors
|
|
50
|
+
|
|
51
|
+
`fetch_data()` returns `{"measurements": [...]}` with one entry per station, also when
|
|
52
|
+
a single station is requested. A station the key cannot read is still listed, with
|
|
53
|
+
`error` and `errorCode` instead of data.
|
|
54
|
+
|
|
55
|
+
It raises, all subclasses of `holfuy.HolfuyError`:
|
|
56
|
+
|
|
57
|
+
- `HolfuyAuthError`: the API key is rejected, or it has no access to any requested station.
|
|
58
|
+
- `HolfuyConnectionError`: network error, timeout, or an HTTP error status.
|
|
59
|
+
- `HolfuyError`: any other unexpected reply.
|
pyholfuy-0.3/README.md
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+

|
|
2
|
+
|
|
3
|
+
# PyHolfuy
|
|
4
|
+
A Python library for talking to [Holfuy Weather Stations](https://holfuy.com/).
|
|
5
|
+
|
|
6
|
+
You need an API key to access the service. To obtain one, please visit the [Holfuy API](https://api.holfuy.com/live/) pages.
|
|
7
|
+
|
|
8
|
+
The library can only access live data.
|
|
9
|
+
|
|
10
|
+
## Example Code
|
|
11
|
+
Here is an example of how to use the library.
|
|
12
|
+
|
|
13
|
+
```python
|
|
14
|
+
import aiohttp
|
|
15
|
+
import asyncio
|
|
16
|
+
from holfuy import HolfuyService
|
|
17
|
+
|
|
18
|
+
async def main():
|
|
19
|
+
async with aiohttp.ClientSession() as session:
|
|
20
|
+
s = HolfuyService("", session)
|
|
21
|
+
data = await s.fetch_data(["101"])
|
|
22
|
+
print(data)
|
|
23
|
+
|
|
24
|
+
if __name__ == "__main__":
|
|
25
|
+
asyncio.run(main())
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
## Return value and errors
|
|
29
|
+
|
|
30
|
+
`fetch_data()` returns `{"measurements": [...]}` with one entry per station, also when
|
|
31
|
+
a single station is requested. A station the key cannot read is still listed, with
|
|
32
|
+
`error` and `errorCode` instead of data.
|
|
33
|
+
|
|
34
|
+
It raises, all subclasses of `holfuy.HolfuyError`:
|
|
35
|
+
|
|
36
|
+
- `HolfuyAuthError`: the API key is rejected, or it has no access to any requested station.
|
|
37
|
+
- `HolfuyConnectionError`: network error, timeout, or an HTTP error status.
|
|
38
|
+
- `HolfuyError`: any other unexpected reply.
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
"""Library to handle connection with Holfuy Live API."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import logging
|
|
5
|
+
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
import aiohttp
|
|
9
|
+
from aiohttp import ClientSession
|
|
10
|
+
|
|
11
|
+
from .const import (
|
|
12
|
+
CONF_TEMPERATURE_UNIT_CELSIUS,
|
|
13
|
+
CONF_WIND_SPEED_UNIT_MS,
|
|
14
|
+
CONF_AVERAGING_NEWEST,
|
|
15
|
+
CONF_MODE_JSON,
|
|
16
|
+
STATIONS_ALL,
|
|
17
|
+
)
|
|
18
|
+
from .exceptions import HolfuyAuthError, HolfuyConnectionError, HolfuyError
|
|
19
|
+
|
|
20
|
+
__all__ = [
|
|
21
|
+
"HolfuyAuthError",
|
|
22
|
+
"HolfuyConnectionError",
|
|
23
|
+
"HolfuyError",
|
|
24
|
+
"HolfuyService",
|
|
25
|
+
]
|
|
26
|
+
|
|
27
|
+
TIMEOUT = 30
|
|
28
|
+
_LOGGER = logging.getLogger(__name__)
|
|
29
|
+
|
|
30
|
+
API_URL = "https://api.holfuy.com/live/"
|
|
31
|
+
|
|
32
|
+
# noqa: E501
|
|
33
|
+
#### API Description
|
|
34
|
+
# URL Format: https://api.holfuy.com/live/?s=101&pw=pass&m=JSON&tu=C&su=m/s
|
|
35
|
+
#
|
|
36
|
+
# s: station(s) ID e.g. s=101 or you can get the data from more stations by one query as well (coma separated list) e.g. s=101,102,103 . If you would get all stations' data (to which you have access) by one single API call please use the "all" string -> s=all .
|
|
37
|
+
# pw: password for the API access (it could be sent by a POST "pw" parameter also)
|
|
38
|
+
# m:mode XML or CSV or JSON default: CSV
|
|
39
|
+
# avg:averaging "0": newest data from the station, "1" newest quarter hourly average, "2" newest hourly average (default = 0)
|
|
40
|
+
# su: wind speed unit: knots, km/h, m/s, mph, default: m/s
|
|
41
|
+
# tu: temperature unit C:Celsius , F: Fahrenheit default: Celsius
|
|
42
|
+
# batt: add this parameter to get the station's battery voltage (JSON only).
|
|
43
|
+
# cam: add this parameter to the URL for timestamp of the last picture from the camera. (JSON only, only for stations with a camera)
|
|
44
|
+
# daily: add this parameter to get daily Max-Min temperatures and precipitation since last midnight CE(S)T (JSON only, max 5 stations)
|
|
45
|
+
# loc: add this parameter to the URL to get the station's location in reply too (JSON only)
|
|
46
|
+
# utc: add this parameter to the URL for timestamps in UTC, otherwise times will be in CE(S)T
|
|
47
|
+
# Data: DateTime: time of the last data / average in ".date('T')." (Central European Time) or in UTC if utc param is set.
|
|
48
|
+
#####
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class HolfuyService:
|
|
52
|
+
"""Representation of Holfuy weather data."""
|
|
53
|
+
|
|
54
|
+
def __init__(
|
|
55
|
+
self,
|
|
56
|
+
api_key: str,
|
|
57
|
+
session: ClientSession,
|
|
58
|
+
temperature_unit:str=CONF_TEMPERATURE_UNIT_CELSIUS,
|
|
59
|
+
wind_speed_unit:str=CONF_WIND_SPEED_UNIT_MS,
|
|
60
|
+
timestamps_in_utc:bool=False,
|
|
61
|
+
) -> None:
|
|
62
|
+
"""Initialize the Weather object."""
|
|
63
|
+
|
|
64
|
+
self._api_url = API_URL
|
|
65
|
+
self._api_key = api_key
|
|
66
|
+
self._wind_speed_unit = wind_speed_unit
|
|
67
|
+
self._temperature_unit = temperature_unit
|
|
68
|
+
self._timestamps_in_utc = timestamps_in_utc
|
|
69
|
+
self._session = session
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
async def fetch_data(
|
|
73
|
+
self,
|
|
74
|
+
stations: list[str] | None = None,
|
|
75
|
+
averaging_mode=CONF_AVERAGING_NEWEST,
|
|
76
|
+
include_battery=False,
|
|
77
|
+
include_daily=False,
|
|
78
|
+
include_location=False,
|
|
79
|
+
) -> dict[str, Any]:
|
|
80
|
+
"""Get the latest data from Holfuy.
|
|
81
|
+
|
|
82
|
+
Returns {"measurements": [...]}, one entry per station. A station the key
|
|
83
|
+
cannot read is still listed, with "error" and "errorCode" instead of data.
|
|
84
|
+
|
|
85
|
+
Raises HolfuyAuthError if the key is rejected or can read none of the
|
|
86
|
+
requested stations, HolfuyConnectionError on network errors, timeouts and
|
|
87
|
+
HTTP errors, and HolfuyError on any other unexpected reply.
|
|
88
|
+
"""
|
|
89
|
+
|
|
90
|
+
params = {
|
|
91
|
+
"pw": self._api_key,
|
|
92
|
+
"m": CONF_MODE_JSON,
|
|
93
|
+
"tu": self._temperature_unit,
|
|
94
|
+
"su": self._wind_speed_unit,
|
|
95
|
+
"avg": averaging_mode,
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
if self._timestamps_in_utc:
|
|
99
|
+
params["utc"] = ""
|
|
100
|
+
|
|
101
|
+
if include_battery:
|
|
102
|
+
params["batt"] = ""
|
|
103
|
+
|
|
104
|
+
if include_daily:
|
|
105
|
+
params["daily"] = ""
|
|
106
|
+
|
|
107
|
+
if include_location:
|
|
108
|
+
params["loc"] = ""
|
|
109
|
+
|
|
110
|
+
if stations:
|
|
111
|
+
params["s"] = ",".join(map(str, stations))
|
|
112
|
+
else:
|
|
113
|
+
params["s"] = STATIONS_ALL
|
|
114
|
+
|
|
115
|
+
try:
|
|
116
|
+
# The timeout covers the whole exchange, body included, and the
|
|
117
|
+
# context manager releases the connection back to the pool.
|
|
118
|
+
async with (
|
|
119
|
+
asyncio.timeout(TIMEOUT),
|
|
120
|
+
self._session.get(self._api_url, params=params) as resp,
|
|
121
|
+
):
|
|
122
|
+
if resp.status >= 400:
|
|
123
|
+
raise HolfuyConnectionError(
|
|
124
|
+
f"Holfuy API returned HTTP {resp.status}"
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
# A rejected key gets HTTP 200 with the plain text "No access".
|
|
128
|
+
if resp.content_type != "application/json":
|
|
129
|
+
text = (await resp.text()).strip()
|
|
130
|
+
if text.lower().startswith("no access"):
|
|
131
|
+
raise HolfuyAuthError("Holfuy API rejected the API key")
|
|
132
|
+
raise HolfuyError(
|
|
133
|
+
f"Unexpected response from Holfuy API: {text[:100]!r}"
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
result = await resp.json()
|
|
137
|
+
|
|
138
|
+
except (TimeoutError, aiohttp.ClientError) as err:
|
|
139
|
+
# Only the type name: aiohttp messages can contain the URL, and
|
|
140
|
+
# with it the API key.
|
|
141
|
+
raise HolfuyConnectionError(
|
|
142
|
+
f"Cannot reach Holfuy API: {type(err).__name__}"
|
|
143
|
+
) from err
|
|
144
|
+
except ValueError as err:
|
|
145
|
+
raise HolfuyError("Unable to parse JSON response from Holfuy API") from err
|
|
146
|
+
|
|
147
|
+
# Request for a single station is not enveloped in a list of
|
|
148
|
+
# measurements objects. Make data consistent for all request types before it
|
|
149
|
+
# is returned.
|
|
150
|
+
if stations and len(stations) == 1:
|
|
151
|
+
result = {"measurements": [result]}
|
|
152
|
+
|
|
153
|
+
measurements = result.get("measurements") if isinstance(result, dict) else None
|
|
154
|
+
if not isinstance(measurements, list):
|
|
155
|
+
raise HolfuyError("Unexpected response from Holfuy API: no measurements")
|
|
156
|
+
|
|
157
|
+
if measurements and all(
|
|
158
|
+
m.get("errorCode") == "no_access" for m in measurements
|
|
159
|
+
):
|
|
160
|
+
raise HolfuyAuthError("API key has no access to the requested stations")
|
|
161
|
+
|
|
162
|
+
return result
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
CONF_MODE_JSON = "JSON" # We only handle JSON from the api
|
|
2
|
-
CONF_MODE_XML = "
|
|
3
|
-
CONF_MODE_CSV = "
|
|
2
|
+
CONF_MODE_XML = "XML"
|
|
3
|
+
CONF_MODE_CSV = "CSV"
|
|
4
4
|
|
|
5
5
|
CONF_AVERAGING_NEWEST = 0
|
|
6
6
|
CONF_AVERAGING_QUARTERLY = 1
|
|
@@ -11,8 +11,8 @@ CONF_WIND_SPEED_UNIT_KMH = "km/h"
|
|
|
11
11
|
CONF_WIND_SPEED_UNIT_MS = "m/s"
|
|
12
12
|
CONF_WIND_SPEED_UNIT_MPH = "mph"
|
|
13
13
|
|
|
14
|
-
|
|
15
|
-
CONF_TEMPERATURE_UNIT_FAHRENHEIT = "
|
|
14
|
+
CONF_TEMPERATURE_UNIT_CELSIUS = "C"
|
|
15
|
+
CONF_TEMPERATURE_UNIT_FAHRENHEIT = "F"
|
|
16
16
|
|
|
17
17
|
CONF_OPTION_BATT = "batt"
|
|
18
18
|
CONF_OPTION_DAILY = "daily"
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
"""Exceptions raised by the Holfuy library."""
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class HolfuyError(Exception):
|
|
5
|
+
"""Base class for all Holfuy errors, raised as is for unexpected replies."""
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class HolfuyConnectionError(HolfuyError):
|
|
9
|
+
"""The API could not be reached, timed out or returned an HTTP error."""
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class HolfuyAuthError(HolfuyError):
|
|
13
|
+
"""The API key was rejected, or it has no access to any requested station."""
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
from unittest import IsolatedAsyncioTestCase, mock
|
|
3
|
+
from unittest.mock import AsyncMock, MagicMock, patch
|
|
4
|
+
import json
|
|
5
|
+
|
|
6
|
+
import aiohttp
|
|
7
|
+
|
|
8
|
+
RESP_142 = json.loads("""{"stationId":142,"stationName":"THPK Ersfjord","location":{"latitude":"69.70017",
|
|
9
|
+
"longitude":"18.63842","altitude":130},"dateTime":"2025-05-19 15:45:00","wind":{"speed":9.4,"gust":13.3,"min":0,
|
|
10
|
+
"unit":"m/s","direction":245},"battery":4.15,"daily":{"max_temp":0,"min_temp":0},"temperature":0}""")
|
|
11
|
+
|
|
12
|
+
RESP_ALL = json.loads("""{"measurements":[{"stationId":142,"stationName":"THPK Ersfjord","location":{"latitude":"69.70017",
|
|
13
|
+
"longitude":"18.63842","altitude":130},"dateTime":"2025-05-19 15:45:00","wind":{"speed":9.4,"gust":13.3,"min":0,
|
|
14
|
+
"unit":"m/s","direction":245},"battery":4.15,"daily":{"max_temp":0,"min_temp":0},"temperature":0},
|
|
15
|
+
{"stationId":207,"stationName":"THPK Finnvikdalen","location":{"latitude":"69.76117",
|
|
16
|
+
"longitude":"18.85657","altitude":270},"dateTime":"2025-05-19 15:45:00",
|
|
17
|
+
"wind":{"speed":10,"gust":13.6,"min":0,"unit":"m/s","direction":339},"battery":4.14,
|
|
18
|
+
"daily":{"max_temp":7.5,"min_temp":4.5},"temperature":4.7},{"stationId":299,"stationName":"THPK Fjellheisen",
|
|
19
|
+
"location":{"latitude":"69.63116","longitude":"18.99377","altitude":420},"dateTime":"2025-05-19 15:45:00",
|
|
20
|
+
"wind":{"speed":7.8,"gust":10.3,"min":0,"unit":"m/s","direction":256},"battery":4.14,
|
|
21
|
+
"daily":{"max_temp":5.7,"min_temp":0},"temperature":3.6}]}""")
|
|
22
|
+
|
|
23
|
+
RESP_AUTHERROR=json.loads("""
|
|
24
|
+
{"measurements":[{"stationId":105,"error":"Sorry, you don't have access for this station.","errorCode":"no_access"},
|
|
25
|
+
{"stationId":106,"error":"Sorry, you don't have access for this station.","errorCode":"no_access"}]}""")
|
|
26
|
+
|
|
27
|
+
from holfuy import (
|
|
28
|
+
HolfuyAuthError,
|
|
29
|
+
HolfuyConnectionError,
|
|
30
|
+
HolfuyService,
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _get_returning(resp):
|
|
35
|
+
"""Mock session.get() as an async context manager yielding resp."""
|
|
36
|
+
ctx = MagicMock()
|
|
37
|
+
ctx.__aenter__ = AsyncMock(return_value=resp)
|
|
38
|
+
ctx.__aexit__ = AsyncMock(return_value=False)
|
|
39
|
+
return MagicMock(return_value=ctx)
|
|
40
|
+
|
|
41
|
+
from holfuy.const import (
|
|
42
|
+
CONF_TEMPERATURE_UNIT_CELSIUS,
|
|
43
|
+
CONF_WIND_SPEED_UNIT_MS,
|
|
44
|
+
CONF_AVERAGING_NEWEST,
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class TestHolfuyService(IsolatedAsyncioTestCase):
|
|
49
|
+
def setUp(self):
|
|
50
|
+
"""Set up test cases."""
|
|
51
|
+
self.session = AsyncMock(spec=aiohttp.ClientSession)
|
|
52
|
+
self.api = HolfuyService(
|
|
53
|
+
"test_api_key",
|
|
54
|
+
self.session,
|
|
55
|
+
CONF_TEMPERATURE_UNIT_CELSIUS,
|
|
56
|
+
CONF_WIND_SPEED_UNIT_MS,
|
|
57
|
+
False,
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
async def test_fetch_data_single_station(self):
|
|
61
|
+
"""Test fetching data for a single station."""
|
|
62
|
+
mock_response = RESP_142
|
|
63
|
+
|
|
64
|
+
mock_resp = AsyncMock()
|
|
65
|
+
mock_resp.status = 200
|
|
66
|
+
mock_resp.content_type = "application/json"
|
|
67
|
+
mock_resp.json = AsyncMock(return_value=mock_response)
|
|
68
|
+
|
|
69
|
+
self.session.get = _get_returning(mock_resp)
|
|
70
|
+
|
|
71
|
+
result = await self.api.fetch_data(["142"])
|
|
72
|
+
|
|
73
|
+
expected_params = {
|
|
74
|
+
"pw": "test_api_key",
|
|
75
|
+
"m": "JSON",
|
|
76
|
+
"tu": CONF_TEMPERATURE_UNIT_CELSIUS,
|
|
77
|
+
"su": CONF_WIND_SPEED_UNIT_MS,
|
|
78
|
+
"avg": CONF_AVERAGING_NEWEST,
|
|
79
|
+
"s": "142",
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
self.session.get.assert_called_once_with(
|
|
83
|
+
"https://api.holfuy.com/live/",
|
|
84
|
+
params=expected_params
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
self.assertEqual(result["measurements"][0]["stationId"], 142)
|
|
88
|
+
|
|
89
|
+
async def test_fetch_data_all_stations(self):
|
|
90
|
+
"""Test fetching data for multiple stations."""
|
|
91
|
+
mock_response = RESP_ALL
|
|
92
|
+
|
|
93
|
+
mock_resp = AsyncMock()
|
|
94
|
+
mock_resp.status = 200
|
|
95
|
+
mock_resp.content_type = "application/json"
|
|
96
|
+
mock_resp.json = AsyncMock(return_value=mock_response)
|
|
97
|
+
self.session.get = _get_returning(mock_resp)
|
|
98
|
+
|
|
99
|
+
result = await self.api.fetch_data(None)
|
|
100
|
+
|
|
101
|
+
expected_params = {
|
|
102
|
+
"pw": "test_api_key",
|
|
103
|
+
"m": "JSON",
|
|
104
|
+
"tu": CONF_TEMPERATURE_UNIT_CELSIUS,
|
|
105
|
+
"su": CONF_WIND_SPEED_UNIT_MS,
|
|
106
|
+
"avg": CONF_AVERAGING_NEWEST,
|
|
107
|
+
"s": "all",
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
self.session.get.assert_called_once_with(
|
|
111
|
+
"https://api.holfuy.com/live/",
|
|
112
|
+
params=expected_params
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
self.assertEqual(result, mock_response)
|
|
116
|
+
|
|
117
|
+
async def test_fetch_data_error_response(self):
|
|
118
|
+
"""Test handling of error responses."""
|
|
119
|
+
mock_resp = AsyncMock()
|
|
120
|
+
mock_resp.status = 404
|
|
121
|
+
|
|
122
|
+
self.session.get = _get_returning(mock_resp)
|
|
123
|
+
|
|
124
|
+
with self.assertRaises(HolfuyConnectionError):
|
|
125
|
+
await self.api.fetch_data(None)
|
|
126
|
+
|
|
127
|
+
async def test_fetch_data_invalid_access_token(self):
|
|
128
|
+
"""Test handling of error responses."""
|
|
129
|
+
mock_resp = AsyncMock()
|
|
130
|
+
mock_resp.status = 200
|
|
131
|
+
mock_resp.content_type = "application/json"
|
|
132
|
+
mock_response = RESP_AUTHERROR
|
|
133
|
+
|
|
134
|
+
mock_resp.json = AsyncMock(return_value=mock_response)
|
|
135
|
+
|
|
136
|
+
self.session.get = _get_returning(mock_resp)
|
|
137
|
+
|
|
138
|
+
with self.assertRaises(HolfuyAuthError):
|
|
139
|
+
await self.api.fetch_data(["105","106"])
|
|
140
|
+
|
|
141
|
+
expected_params = {
|
|
142
|
+
"pw": "test_api_key",
|
|
143
|
+
"m": "JSON",
|
|
144
|
+
"tu": CONF_TEMPERATURE_UNIT_CELSIUS,
|
|
145
|
+
"su": CONF_WIND_SPEED_UNIT_MS,
|
|
146
|
+
"avg": CONF_AVERAGING_NEWEST,
|
|
147
|
+
"s": "105,106",
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
self.session.get.assert_called_once_with(
|
|
151
|
+
"https://api.holfuy.com/live/",
|
|
152
|
+
params=expected_params
|
|
153
|
+
)
|
|
154
|
+
|
|
155
|
+
async def test_fetch_data_timeout(self):
|
|
156
|
+
"""Test handling of timeout errors."""
|
|
157
|
+
self.session.get = MagicMock(side_effect=asyncio.TimeoutError)
|
|
158
|
+
|
|
159
|
+
with self.assertRaises(HolfuyConnectionError):
|
|
160
|
+
await self.api.fetch_data(None)
|
|
161
|
+
|
|
162
|
+
async def test_fetch_data_client_error(self):
|
|
163
|
+
"""Test handling of client errors."""
|
|
164
|
+
self.session.get = MagicMock(side_effect=aiohttp.ClientError)
|
|
165
|
+
|
|
166
|
+
with self.assertRaises(HolfuyConnectionError):
|
|
167
|
+
await self.api.fetch_data(None)
|
|
168
|
+
|
|
169
|
+
async def test_fetch_data_rejected_key(self):
|
|
170
|
+
"""A rejected key gets HTTP 200 with a plain-text "No access" body."""
|
|
171
|
+
mock_resp = AsyncMock()
|
|
172
|
+
mock_resp.status = 200
|
|
173
|
+
mock_resp.content_type = "text/html"
|
|
174
|
+
mock_resp.text = AsyncMock(return_value="No access")
|
|
175
|
+
self.session.get = _get_returning(mock_resp)
|
|
176
|
+
|
|
177
|
+
with self.assertRaises(HolfuyAuthError):
|
|
178
|
+
await self.api.fetch_data(None)
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: pyholfuy
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3
|
|
4
4
|
Summary: A Python library for interacting with Holfuy weather stations.
|
|
5
5
|
Author-email: Håvard Dagenborg <haavardj@dagenborg.net>
|
|
6
6
|
License-Expression: MIT
|
|
@@ -16,15 +16,7 @@ Provides-Extra: test
|
|
|
16
16
|
Requires-Dist: aioresponses==0.7.8; extra == "test"
|
|
17
17
|
Requires-Dist: coverage==7.8.0; extra == "test"
|
|
18
18
|
Requires-Dist: mypy==1.15.0; extra == "test"
|
|
19
|
-
Requires-Dist: pytest-asyncio==0.26.0; extra == "test"
|
|
20
|
-
Requires-Dist: pytest-cov==6.1.1; extra == "test"
|
|
21
|
-
Requires-Dist: pytest-error-for-skips==2.0.2; extra == "test"
|
|
22
|
-
Requires-Dist: pytest-timeout==2.3.1; extra == "test"
|
|
23
|
-
Requires-Dist: pytest==8.3.5; extra == "test"
|
|
24
|
-
Requires-Dist: ruff==0.11.7; extra == "test"
|
|
25
|
-
Requires-Dist: syrupy==4.9.1; extra == "test"
|
|
26
19
|
Provides-Extra: dev
|
|
27
|
-
Requires-Dist: pre-commit==4.2.0; extra == "dev"
|
|
28
20
|
Dynamic: license-file
|
|
29
21
|
|
|
30
22
|

|
|
@@ -34,7 +26,7 @@ A Python library for talking to [Holfuy Weather Stations](https://holfuy.com/).
|
|
|
34
26
|
|
|
35
27
|
You need an API key to access the service. To obtain one, please visit the [Holfuy API](https://api.holfuy.com/live/) pages.
|
|
36
28
|
|
|
37
|
-
The
|
|
29
|
+
The library can only access live data.
|
|
38
30
|
|
|
39
31
|
## Example Code
|
|
40
32
|
Here is an example of how to use the library.
|
|
@@ -46,10 +38,22 @@ from holfuy import HolfuyService
|
|
|
46
38
|
|
|
47
39
|
async def main():
|
|
48
40
|
async with aiohttp.ClientSession() as session:
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
41
|
+
s = HolfuyService("", session)
|
|
42
|
+
data = await s.fetch_data(["101"])
|
|
43
|
+
print(data)
|
|
52
44
|
|
|
53
45
|
if __name__ == "__main__":
|
|
54
46
|
asyncio.run(main())
|
|
55
47
|
```
|
|
48
|
+
|
|
49
|
+
## Return value and errors
|
|
50
|
+
|
|
51
|
+
`fetch_data()` returns `{"measurements": [...]}` with one entry per station, also when
|
|
52
|
+
a single station is requested. A station the key cannot read is still listed, with
|
|
53
|
+
`error` and `errorCode` instead of data.
|
|
54
|
+
|
|
55
|
+
It raises, all subclasses of `holfuy.HolfuyError`:
|
|
56
|
+
|
|
57
|
+
- `HolfuyAuthError`: the API key is rejected, or it has no access to any requested station.
|
|
58
|
+
- `HolfuyConnectionError`: network error, timeout, or an HTTP error status.
|
|
59
|
+
- `HolfuyError`: any other unexpected reply.
|
|
@@ -6,7 +6,7 @@ build-backend = "setuptools.build_meta"
|
|
|
6
6
|
|
|
7
7
|
[project]
|
|
8
8
|
name = "pyholfuy"
|
|
9
|
-
version = "0.
|
|
9
|
+
version = "0.3"
|
|
10
10
|
description = "A Python library for interacting with Holfuy weather stations."
|
|
11
11
|
authors = [{name="Håvard Dagenborg", email="haavardj@dagenborg.net"}]
|
|
12
12
|
license = "MIT"
|
|
@@ -29,24 +29,11 @@ test = [
|
|
|
29
29
|
"aioresponses==0.7.8",
|
|
30
30
|
"coverage==7.8.0",
|
|
31
31
|
"mypy==1.15.0",
|
|
32
|
-
"pytest-asyncio==0.26.0",
|
|
33
|
-
"pytest-cov==6.1.1",
|
|
34
|
-
"pytest-error-for-skips==2.0.2",
|
|
35
|
-
"pytest-timeout==2.3.1",
|
|
36
|
-
"pytest==8.3.5",
|
|
37
|
-
"ruff==0.11.7",
|
|
38
|
-
"syrupy==4.9.1",
|
|
39
32
|
]
|
|
40
33
|
dev = [
|
|
41
|
-
"pre-commit==4.2.0",
|
|
42
34
|
]
|
|
43
35
|
|
|
44
|
-
[tool.ruff]
|
|
45
|
-
line-length = 88
|
|
46
|
-
indent-width = 4
|
|
47
|
-
select = ["E", "F", "W", "C", "N", "Q"]
|
|
48
36
|
|
|
49
37
|
[tool.pylint]
|
|
50
38
|
max-line-length = 88
|
|
51
39
|
disable = ["C0114", "C0115", "C0116"]
|
|
52
|
-
|
pyholfuy-0.1/README.md
DELETED
|
@@ -1,26 +0,0 @@
|
|
|
1
|
-

|
|
2
|
-
|
|
3
|
-
# PyHolfuy
|
|
4
|
-
A Python library for talking to [Holfuy Weather Stations](https://holfuy.com/).
|
|
5
|
-
|
|
6
|
-
You need an API key to access the service. To obtain one, please visit the [Holfuy API](https://api.holfuy.com/live/) pages.
|
|
7
|
-
|
|
8
|
-
The Library only access live data.
|
|
9
|
-
|
|
10
|
-
## Example Code
|
|
11
|
-
Here is an example of how to use the library.
|
|
12
|
-
|
|
13
|
-
```python
|
|
14
|
-
import aiohttp
|
|
15
|
-
import asyncio
|
|
16
|
-
from holfuy import HolfuyService
|
|
17
|
-
|
|
18
|
-
async def main():
|
|
19
|
-
async with aiohttp.ClientSession() as session:
|
|
20
|
-
s = HolfuyService("", session)
|
|
21
|
-
data = await s.fetch_data(["101"])
|
|
22
|
-
print(data)
|
|
23
|
-
|
|
24
|
-
if __name__ == "__main__":
|
|
25
|
-
asyncio.run(main())
|
|
26
|
-
```
|
pyholfuy-0.1/holfuy/__init__.py
DELETED
|
@@ -1,131 +0,0 @@
|
|
|
1
|
-
"""Library to handle connection with Holfuy Live API."""
|
|
2
|
-
|
|
3
|
-
import asyncio
|
|
4
|
-
import logging
|
|
5
|
-
|
|
6
|
-
from typing import Any
|
|
7
|
-
|
|
8
|
-
import aiohttp
|
|
9
|
-
|
|
10
|
-
from .const import (
|
|
11
|
-
CONF_TEMPERATURE_UNIT_CELSUIS,
|
|
12
|
-
CONF_WIND_SPEED_UNIT_MS,
|
|
13
|
-
CONF_AVERAGING_NEWEST,
|
|
14
|
-
CONF_MODE_JSON,
|
|
15
|
-
STATIONS_ALL,
|
|
16
|
-
)
|
|
17
|
-
|
|
18
|
-
TIMEOUT = 30
|
|
19
|
-
_LOGGER = logging.getLogger(__name__)
|
|
20
|
-
|
|
21
|
-
API_URL = "http://api.holfuy.com/live/"
|
|
22
|
-
|
|
23
|
-
# noqa: E501
|
|
24
|
-
#### API Description
|
|
25
|
-
# URL Format: http://api.holfuy.com/live/?s=101&pw=pass&m=JSON&tu=C&su=m/s
|
|
26
|
-
#
|
|
27
|
-
# s: station(s) ID e.g. s=101 or you can get the data from more stations by one query as well (coma separated list) e.g. s=101,102,103 . If you would get all stations' data (to which you have access) by one single API call please use the "all" string -> s=all .
|
|
28
|
-
# pw: password for the API access (it could be sent by a POST "pw" parameter also)
|
|
29
|
-
# m:mode XML or CSV or JSON default: CSV
|
|
30
|
-
# avg:averaging "0": newest data from the station, "1" newest quarter hourly average, "2" newest hourly average (default = 0)
|
|
31
|
-
# su: wind speed unit: knots, km/h, m/s, mph, default: m/s
|
|
32
|
-
# tu: temperature unit C:Celsius , F: Fahrenheit default: Celsius
|
|
33
|
-
# batt: add this parameter to get the station's battery voltage (JSON only).
|
|
34
|
-
# cam: add this parameter to the URL for timestamp of the last picture from the camera. (JSON only, only for stations with a camera)
|
|
35
|
-
# daily: add this parameter to get daily Max-Min temperatures and precipitation since last midnight CE(S)T (JSON only, max 5 stations)
|
|
36
|
-
# loc: add this parameter to the URL to get the station's location in reply too (JSON only)
|
|
37
|
-
# utc: add this parameter to the URL for timestamps in UTC, otherwise times will be in CE(S)T
|
|
38
|
-
# Data: DateTime: time of the last data / average in ".date('T')." (Central European Time) or in UTC if utc param is set.
|
|
39
|
-
#####
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
class HolfuyService:
|
|
43
|
-
"""Representation of Holfuy weather data."""
|
|
44
|
-
|
|
45
|
-
def __init__(
|
|
46
|
-
self,
|
|
47
|
-
api_key: str,
|
|
48
|
-
websession=None,
|
|
49
|
-
temperature_unit=CONF_TEMPERATURE_UNIT_CELSUIS,
|
|
50
|
-
wind_speed_unit=CONF_WIND_SPEED_UNIT_MS,
|
|
51
|
-
timestamps_in_utc=False,
|
|
52
|
-
) -> None:
|
|
53
|
-
"""Initialize the Weather object."""
|
|
54
|
-
|
|
55
|
-
self._api_url = API_URL
|
|
56
|
-
self._api_key = api_key
|
|
57
|
-
self._wind_speed_unit = wind_speed_unit
|
|
58
|
-
self._temperature_unit = temperature_unit
|
|
59
|
-
self._timestamps_in_utc = timestamps_in_utc
|
|
60
|
-
|
|
61
|
-
if websession is None:
|
|
62
|
-
|
|
63
|
-
async def _create_session():
|
|
64
|
-
return aiohttp.ClientSession()
|
|
65
|
-
|
|
66
|
-
loop = asyncio.get_event_loop()
|
|
67
|
-
self._websession = loop.run_until_complete(_create_session())
|
|
68
|
-
else:
|
|
69
|
-
self._websession = websession
|
|
70
|
-
|
|
71
|
-
async def fetch_data(
|
|
72
|
-
self,
|
|
73
|
-
stations: list[str] | None = None,
|
|
74
|
-
averaging_mode=CONF_AVERAGING_NEWEST,
|
|
75
|
-
include_battery=False,
|
|
76
|
-
include_daily=False,
|
|
77
|
-
include_location=False,
|
|
78
|
-
) -> dict[str, Any] | None:
|
|
79
|
-
"""Get the latest data from Holfuy."""
|
|
80
|
-
|
|
81
|
-
params = {
|
|
82
|
-
"pw": self._api_key,
|
|
83
|
-
"m": CONF_MODE_JSON,
|
|
84
|
-
"tu": self._temperature_unit,
|
|
85
|
-
"su": self._wind_speed_unit,
|
|
86
|
-
"avg": averaging_mode,
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
if self._timestamps_in_utc:
|
|
90
|
-
params["utc"] = ""
|
|
91
|
-
|
|
92
|
-
if include_battery:
|
|
93
|
-
params["batt"] = ""
|
|
94
|
-
|
|
95
|
-
if include_daily:
|
|
96
|
-
params["daily"] = ""
|
|
97
|
-
|
|
98
|
-
if include_location:
|
|
99
|
-
params["loc"] = ""
|
|
100
|
-
|
|
101
|
-
if stations:
|
|
102
|
-
params["s"] = ",".join(map(str, stations))
|
|
103
|
-
else:
|
|
104
|
-
params["s"] = STATIONS_ALL
|
|
105
|
-
|
|
106
|
-
try:
|
|
107
|
-
async with asyncio.timeout(TIMEOUT):
|
|
108
|
-
resp = await self._websession.get(self._api_url, params=params)
|
|
109
|
-
|
|
110
|
-
if resp.status >= 400:
|
|
111
|
-
_LOGGER.error("%s returned %s", self._api_url, resp.status)
|
|
112
|
-
return None
|
|
113
|
-
|
|
114
|
-
result = await resp.json()
|
|
115
|
-
|
|
116
|
-
# Request for a single station is not enveloped in a list of
|
|
117
|
-
# measurements objects. Make data consistent for all request types before it
|
|
118
|
-
# is returned.
|
|
119
|
-
if stations and len(stations) == 1:
|
|
120
|
-
result = {"measurements": result}
|
|
121
|
-
|
|
122
|
-
return result
|
|
123
|
-
|
|
124
|
-
except (TimeoutError, aiohttp.ClientError) as err:
|
|
125
|
-
_LOGGER.error(
|
|
126
|
-
"Access to %s returned error '%s'", self._api_url, type(err).__name__
|
|
127
|
-
)
|
|
128
|
-
return None
|
|
129
|
-
except ValueError:
|
|
130
|
-
_LOGGER.exception("Unable to parse json response from %s", self._api_url)
|
|
131
|
-
return None
|
|
@@ -1,16 +0,0 @@
|
|
|
1
|
-
aiohttp>=3.9.4
|
|
2
|
-
|
|
3
|
-
[dev]
|
|
4
|
-
pre-commit==4.2.0
|
|
5
|
-
|
|
6
|
-
[test]
|
|
7
|
-
aioresponses==0.7.8
|
|
8
|
-
coverage==7.8.0
|
|
9
|
-
mypy==1.15.0
|
|
10
|
-
pytest-asyncio==0.26.0
|
|
11
|
-
pytest-cov==6.1.1
|
|
12
|
-
pytest-error-for-skips==2.0.2
|
|
13
|
-
pytest-timeout==2.3.1
|
|
14
|
-
pytest==8.3.5
|
|
15
|
-
ruff==0.11.7
|
|
16
|
-
syrupy==4.9.1
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|