pax_global_header00006660000000000000000000000064152707104370014520gustar00rootroot0000000000000052 comment=8f71bb57b938e21f6e41b4326937437e0ad5c564 fwestenberg-stookwijzer-8f71bb5/000077500000000000000000000000001527071043700170435ustar00rootroot00000000000000fwestenberg-stookwijzer-8f71bb5/.github/000077500000000000000000000000001527071043700204035ustar00rootroot00000000000000fwestenberg-stookwijzer-8f71bb5/.github/workflows/000077500000000000000000000000001527071043700224405ustar00rootroot00000000000000fwestenberg-stookwijzer-8f71bb5/.github/workflows/python-publish.yml000066400000000000000000000016121527071043700261500ustar00rootroot00000000000000# This workflow will upload a Python Package using Twine when a release is created # For more information see: https://help.github.com/en/actions/language-and-framework-guides/using-python-with-github-actions#publishing-to-package-registries name: Upload Python Package on: release: types: [published] permissions: contents: read jobs: deploy: runs-on: ubuntu-latest environment: pypi permissions: id-token: write steps: - name: Checkout uses: actions/checkout@v7 - name: Set up Python uses: actions/setup-python@v7 with: python-version: '3.x' - name: Install dependencies run: | python -m pip install --upgrade pip python -m pip install build - name: Build package run: python -m build - name: Publish package uses: pypa/gh-action-pypi-publish@release/v1 fwestenberg-stookwijzer-8f71bb5/.gitignore000066400000000000000000000034071527071043700210370ustar00rootroot00000000000000# Byte-compiled / optimized / DLL files __pycache__/ *.py[cod] *$py.class # C extensions *.so # Distribution / packaging .Python build/ develop-eggs/ dist/ downloads/ eggs/ .eggs/ lib/ lib64/ parts/ sdist/ var/ wheels/ pip-wheel-metadata/ share/python-wheels/ *.egg-info/ .installed.cfg *.egg MANIFEST # PyInstaller # Usually these files are written by a python script from a template # before PyInstaller builds the exe, so as to inject date/other infos into it. *.manifest *.spec # Installer logs pip-log.txt pip-delete-this-directory.txt # Unit test / coverage reports htmlcov/ .tox/ .nox/ .coverage .coverage.* .cache nosetests.xml coverage.xml *.cover *.py,cover .hypothesis/ .pytest_cache/ # Translations *.mo *.pot # Django stuff: *.log local_settings.py db.sqlite3 db.sqlite3-journal # Flask stuff: instance/ .webassets-cache # Scrapy stuff: .scrapy # Sphinx documentation docs/_build/ # PyBuilder target/ # Jupyter Notebook .ipynb_checkpoints # IPython profile_default/ ipython_config.py # pyenv .python-version # pipenv # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. # However, in case of collaboration, if having platform-specific dependencies or dependencies # having no cross-platform support, pipenv may install dependencies that don't work, or not # install all needed dependencies. #Pipfile.lock # PEP 582; used by e.g. github.com/David-OConnor/pyflow __pypackages__/ # Celery stuff celerybeat-schedule celerybeat.pid # SageMath parsed files *.sage.py # Environments .env .venv env/ venv/ ENV/ env.bak/ venv.bak/ # Spyder project settings .spyderproject .spyproject # Rope project settings .ropeproject # mkdocs documentation /site # mypy .mypy_cache/ .dmypy.json dmypy.json # Pyre type checker .pyre/ fwestenberg-stookwijzer-8f71bb5/LICENSE000066400000000000000000000020541527071043700200510ustar00rootroot00000000000000MIT License Copyright (c) 2022 fwestenberg Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. fwestenberg-stookwijzer-8f71bb5/README.md000066400000000000000000000004131527071043700203200ustar00rootroot00000000000000# Stookwijzer This is a Python package for the Stookwijzer API. The Stookwijzer data is retrieved from the [Atlas Leefomgeving](https://www.atlasleefomgeving.nl/stookwijzer) API. ## Installation ## Stookwijzer requires Python 3.6+ to run. `pip install stookwijzer` fwestenberg-stookwijzer-8f71bb5/setup.cfg000066400000000000000000000000501527071043700206570ustar00rootroot00000000000000[metadata] description-file = README.md fwestenberg-stookwijzer-8f71bb5/setup.py000066400000000000000000000015221527071043700205550ustar00rootroot00000000000000from distutils.core import setup setup( name = 'stookwijzer', packages = ['stookwijzer'], version = '1.6.2', license='MIT', description = 'Stookwijzer package', long_description_content_type="text/markdown", long_description='Stookwijzer package', author = 'fwestenberg', author_email = '', url = 'https://github.com/fwestenberg/stookwijzer', download_url = 'https://github.com/fwestenberg/stookwijzer/releases/latest', keywords = ['Stookwijzer', 'Home-Assistant'], install_requires=[ 'aiohttp', 'pytz' ], classifiers=[ 'Development Status :: 5 - Production/Stable', 'Intended Audience :: Developers', 'Topic :: Software Development :: Build Tools', 'License :: OSI Approved :: MIT License', 'Programming Language :: Python :: 3.12', 'Programming Language :: Python :: 3.13' ], ) fwestenberg-stookwijzer-8f71bb5/stookwijzer/000077500000000000000000000000001527071043700214355ustar00rootroot00000000000000fwestenberg-stookwijzer-8f71bb5/stookwijzer/__init__.py000066400000000000000000000000641527071043700235460ustar00rootroot00000000000000from stookwijzer.stookwijzerapi import Stookwijzer fwestenberg-stookwijzer-8f71bb5/stookwijzer/const.py000066400000000000000000000001501527071043700231310ustar00rootroot00000000000000"""Constants for the Stookwijzer integration.""" import logging LOGGER = logging.getLogger(__package__)fwestenberg-stookwijzer-8f71bb5/stookwijzer/example.py000066400000000000000000000015371527071043700234500ustar00rootroot00000000000000"""Example usage of the Stookwijzer API.""" import aiohttp import asyncio import stookwijzerapi async def main(): xy = await stookwijzerapi.Stookwijzer.async_transform_coordinates(52.123456, 6.123456) print(f"x: {xy['x']}") print(f"y: {xy['y']}") if xy: session = aiohttp.ClientSession(connector=aiohttp.TCPConnector(ssl=False)) sw = stookwijzerapi.Stookwijzer(session, xy['x'], xy['y']) await sw.async_update() print() print(f"advice: {sw.advice}") print(f"windspeed bft: {sw.windspeed_bft}") print(f"windspeed ms: {sw.windspeed_ms}") print(f"lki: {sw.lki}") print() print(f"forecast_advice: {await sw.async_get_forecast()}") await session.close() if __name__ == "__main__": asyncio.run(main()) fwestenberg-stookwijzer-8f71bb5/stookwijzer/stookwijzerapi.py000066400000000000000000000131361527071043700250770ustar00rootroot00000000000000"""The Stookwijze API.""" from datetime import datetime, timedelta import aiohttp import asyncio import json import logging import pytz _LOGGER = logging.getLogger(__name__) class Stookwijzer: """The Stookwijze API.""" def __init__(self, session: aiohttp.ClientSession, x: float, y: float): self._boundary_box = self.get_boundary_box(x, y) self._advice = None self._alert = None self._last_updated = None self._stookwijzer = None self._session = session @property def advice(self) -> str | None: """Return the advice.""" return self._advice @property def windspeed_bft(self) -> int | None: """Return the windspeed in bft.""" return self.get_property("wind_bft") @property def windspeed_ms(self) -> float | None: """Return the windspeed in m/s.""" windspeed = self.get_property("wind") return round(float(windspeed), 1) if windspeed else windspeed @property def lki(self) -> int | None: """Return the lki.""" return self.get_property("lki") @property def last_updated(self) -> datetime | None: """Get the last updated date.""" return self._last_updated @staticmethod async def async_transform_coordinates(latitude: float, longitude: float) -> dict | None: """Transform the coordinates from EPSG:4326 to EPSG:28992.""" x0 = 155000 y0 = 463000 f0 = 52.15517440 # f => phi l0 = 5.38720621 # l => lambda Rp = [0, 1, 2, 0, 1, 3, 1, 0, 2] Rq = [1, 1, 1, 3, 0, 1, 3, 2, 3] Rpq = [ 190094.945, -11832.228, -114.221, -32.391, -0.705, -2.34, -0.608, -0.008, 0.148, ] Sp = [1, 0, 2, 1, 3, 0, 2, 1, 0, 1] Sq = [0, 2, 0, 2, 0, 1, 2, 1, 4, 4] Spq = [ 309056.544, 3638.893, 73.077, -157.984, 59.788, 0.433, -6.439, -0.032, 0.092, -0.054, ] df = 0.36 * (latitude - f0) dl = 0.36 * (longitude - l0) x = x0 y = y0 x += sum(Rpq[i] * (df ** Rp[i]) * (dl ** Rq[i]) for i in range(9)) y += sum(Spq[i] * (df ** Sp[i]) * (dl ** Sq[i]) for i in range(10)) return {"x": x, "y": y} async def async_update(self) -> None: """Get the stookwijzer data.""" self._stookwijzer = await self.async_get_stookwijzer() advice = self.get_property("advies_0") if advice: self._advice = self.get_color(advice) self._last_updated = datetime.now() else: self._advice = None async def async_get_forecast(self) -> list[dict[str, str]]: """Return the forecast array.""" forecast = [] runtime = self.get_property("model_runtime") if not runtime: return None dt = datetime.strptime(runtime, "%d-%m-%Y %H:%M") localdt = dt.astimezone(pytz.timezone("Europe/Amsterdam")) for offset in range(0, 19, 6): forecast.append(await self.get_forecast_at_offset(localdt, offset)) return forecast async def get_forecast_at_offset( self, runtime: datetime, offset: int ) -> dict[str, str]: """Get forecast at a certain offset.""" dt = {"datetime": (runtime + timedelta(hours=offset)).isoformat()} forecast = { "advice": self.get_color(self.get_property("advies_" + str(offset))), "final": self.get_property("definitief_" + str(offset)) == "True", } dt.update(forecast) return dt def get_boundary_box(self, x: float, y: float) -> str | None: """Create a boundary box with the coordinates""" return str(x) + "%2C" + str(y) + "%2C" + str(x + 10) + "%2C" + str(y + 10) def get_color(self, advice: str) -> str: """Convert the Stookwijzer data into a color.""" if advice == "0": return "code_yellow" if advice == "1": return "code_orange" if advice == "2": return "code_red" return "" def get_property(self, prop: str) -> str: """Get a feature from the JSON data""" try: return str(self._stookwijzer["features"][0]["properties"][prop]) except (KeyError, IndexError, TypeError): _LOGGER.error("Property %s not available", prop) return "" async def async_get_stookwijzer(self): """Get the stookwijzer data.""" url = ( "https://data.rivm.nl/geo/alo/wms?service=WMS&SERVICE=WMS&VERSION=1.3.0&REQUEST=GetFeatureInfo&FORMAT=image/png&TRANSPARENT=true&QUERY_LAYERS=stookwijzer_v2&LAYERS=stookwijzer_v2&servicekey=82b124ad-834d-4c10-8bd0-ee730d5c1cc8&STYLES=&BUFFER=1&EXCEPTIONS=INIMAGE&info_format=application/json&feature_count=1&I=139&J=222&WIDTH=256&HEIGHT=256&CRS=EPSG:28992&BBOX=" + self._boundary_box ) try: async with self._session.get( url=url, allow_redirects=False, timeout=10 ) as response: response = await response.read() return json.loads(response) except aiohttp.ClientConnectorError: _LOGGER.error("Error getting Stookwijzer data") return None except asyncio.TimeoutError: _LOGGER.error("Timeout getting Stookwijzer data") return None except KeyError: _LOGGER.error("Received invalid response from Stookwijzer") return None fwestenberg-stookwijzer-8f71bb5/tests/000077500000000000000000000000001527071043700202055ustar00rootroot00000000000000fwestenberg-stookwijzer-8f71bb5/tests/test_stookwijzerapi.py000066400000000000000000000013421527071043700247020ustar00rootroot00000000000000"""Tests for the Stookwijzer API.""" import asyncio from stookwijzer import Stookwijzer DATA = { "features": [ {"properties": {"advies_0": "1", "wind": "3.4", "wind_bft": "2", "lki": "3"}} ] } def test_failed_update_clears_advice(monkeypatch) -> None: """A failed request must not keep the advice from the previous update.""" client = Stookwijzer(None, 155000, 463000) responses = iter([DATA, None]) async def fake_get_stookwijzer(): return next(responses) monkeypatch.setattr(client, "async_get_stookwijzer", fake_get_stookwijzer) asyncio.run(client.async_update()) assert client.advice == "code_orange" asyncio.run(client.async_update()) assert client.advice is None