├── .gitattributes ├── .github ├── ISSUE_TEMPLATE │ ├── config.yml │ ├── feature_request.yml │ └── bug.yml ├── dependabot.yml ├── workflows │ ├── lint.yml │ ├── validate.yml │ └── release.yml └── copilot-instructions.md ├── custom_components └── cloudweatherproxy │ ├── translations │ ├── en.json │ └── en-GB.json │ ├── aiocloudweather │ ├── __init__.py │ ├── tests │ │ ├── test_conversion.py │ │ ├── test_server.py │ │ ├── test_sensor.py │ │ └── data │ │ │ ├── weathercloud │ │ │ └── wunderground │ ├── __main__.py │ ├── conversion.py │ ├── const.py │ ├── proxy.py │ ├── utils.py │ ├── server.py │ └── station.py │ ├── manifest.json │ ├── strings.json │ ├── web.py │ ├── sensor.py │ ├── diagnostics.py │ ├── entity.py │ ├── const.py │ ├── __init__.py │ └── config_flow.py ├── media ├── background.png └── transparent.png ├── scripts ├── lint ├── setup └── develop ├── examples ├── Corefile ├── docker-compose.yml └── Caddyfile ├── hacs.json ├── requirements.txt ├── config └── configuration.yaml ├── .vscode └── tasks.json ├── .gitignore ├── pytest.ini ├── conftest.py ├── LICENSE ├── .devcontainer.json ├── .ruff.toml ├── CONTRIBUTING.md └── README.md /.gitattributes: -------------------------------------------------------------------------------- 1 | * text=auto eol=lf -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/config.yml: -------------------------------------------------------------------------------- 1 | blank_issues_enabled: false -------------------------------------------------------------------------------- /custom_components/cloudweatherproxy/translations/en.json: -------------------------------------------------------------------------------- 1 | ../strings.json -------------------------------------------------------------------------------- /custom_components/cloudweatherproxy/translations/en-GB.json: -------------------------------------------------------------------------------- 1 | ../strings.json -------------------------------------------------------------------------------- /media/background.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lhw/cloudweatherproxy/HEAD/media/background.png -------------------------------------------------------------------------------- /media/transparent.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lhw/cloudweatherproxy/HEAD/media/transparent.png -------------------------------------------------------------------------------- /scripts/lint: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | set -e 4 | 5 | cd "$(dirname "$0")/.." 6 | 7 | ruff check . --fix 8 | -------------------------------------------------------------------------------- /scripts/setup: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | set -e 4 | 5 | cd "$(dirname "$0")/.." 6 | 7 | python3 -m pip install --requirement requirements.txt 8 | -------------------------------------------------------------------------------- /examples/Corefile: -------------------------------------------------------------------------------- 1 | . { 2 | hosts { 3 | rtupdate.wunderground.com api.weathercloud.net 4 | fallthrough 5 | } 6 | forward . tls://9.9.9.9 7 | log 8 | } 9 | -------------------------------------------------------------------------------- /hacs.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Cloud Weather Proxy", 3 | "filename": "cloudweatherproxy.zip", 4 | "hide_default_branch": true, 5 | "homeassistant": "2025.12.3", 6 | "render_readme": true, 7 | "zip_release": true 8 | } -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | pip>=24.1,<26.0 2 | ruff>=0.12,<=0.20 3 | colorlog>=6.0.0,<7.0.0 4 | aiodns>=3.5.0 5 | pycares>=4.0.0,<5.0.0 6 | homeassistant==2025.12.3 7 | pytest>=9.0.0,<10.0.0 8 | pytest-aiohttp>=1.0.0,<2.0.0 9 | pytest-asyncio>=1.0.0,<2.0.0 -------------------------------------------------------------------------------- /config/configuration.yaml: -------------------------------------------------------------------------------- 1 | # https://www.home-assistant.io/integrations/default_config/ 2 | default_config: 3 | 4 | # https://www.home-assistant.io/integrations/logger/ 5 | logger: 6 | default: info 7 | logs: 8 | custom_components.cloudweatherproxy: debug 9 | -------------------------------------------------------------------------------- /custom_components/cloudweatherproxy/aiocloudweather/__init__.py: -------------------------------------------------------------------------------- 1 | """aioCloudWeather API wrapper.""" 2 | 3 | from .server import CloudWeatherListener 4 | from .station import WeatherStation, Sensor 5 | 6 | __all__ = ["CloudWeatherListener", "WeatherStation", "Sensor"] 7 | -------------------------------------------------------------------------------- /.vscode/tasks.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "2.0.0", 3 | "tasks": [ 4 | { 5 | "label": "Run Home Assistant on port 8123", 6 | "type": "shell", 7 | "command": "scripts/develop", 8 | "problemMatcher": [] 9 | } 10 | ] 11 | } -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # artifacts 2 | __pycache__ 3 | .pytest* 4 | *.egg-info 5 | */build/* 6 | */dist/* 7 | 8 | 9 | # misc 10 | .coverage 11 | .vscode 12 | .venv 13 | .ruff_cache 14 | coverage.xml 15 | 16 | # Home Assistant configuration 17 | config/* 18 | !config/configuration.yaml 19 | 20 | # Integration specific files 21 | .access-token 22 | tests -------------------------------------------------------------------------------- /pytest.ini: -------------------------------------------------------------------------------- 1 | [pytest] 2 | asyncio_mode = auto 3 | addopts = -q 4 | 5 | # Suppress known third-party deprecation warnings during tests 6 | filterwarnings = 7 | ignore:Inheritance class HomeAssistantApplication from web.Application is discouraged:DeprecationWarning:homeassistant.components.http 8 | ignore:Support for class-based `config` is deprecated:DeprecationWarning 9 | -------------------------------------------------------------------------------- /custom_components/cloudweatherproxy/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "domain": "cloudweatherproxy", 3 | "name": "Cloud Weather Proxy", 4 | "codeowners": [ 5 | "@lhw" 6 | ], 7 | "config_flow": true, 8 | "dependencies": [ 9 | "http" 10 | ], 11 | "documentation": "https://github.com/lhw/cloudweatherproxy/blob/main/README.md", 12 | "iot_class": "local_push", 13 | "issue_tracker": "https://github.com/lhw/cloudweatherproxy/issues", 14 | "requirements": [ 15 | "aiodns>=3.5.0" 16 | ], 17 | "version": "2025.12.0" 18 | } -------------------------------------------------------------------------------- /examples/docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: "3.8" 2 | 3 | services: 4 | caddy: 5 | image: caddy:2 6 | restart: Unless-stopped 7 | ports: 8 | - ":80:8080" 9 | env: 10 | HA_ACCESS_TOKEN: abcdefghijklmnopqrstuvwxyz0123456789 11 | volumes: 12 | - ./Caddyfile:/etc/caddy/Caddyfile 13 | coredns: 14 | image: coredns/coredns 15 | restart: Unless-stopped 16 | ports: 17 | - ":53:1053/udp" 18 | volumes: 19 | - ./Corefile:/dns/Corefile 20 | command: -dns.port=1053 -conf /dns/Corefile 21 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # https://docs.github.com/en/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file 2 | version: 2 3 | updates: 4 | - package-ecosystem: "github-actions" 5 | directory: "/" 6 | schedule: 7 | interval: "weekly" 8 | 9 | - package-ecosystem: "pip" 10 | directory: "/" 11 | schedule: 12 | interval: "weekly" 13 | ignore: 14 | # Dependabot should not update Home Assistant as that should match the homeassistant key in hacs.json 15 | - dependency-name: "homeassistant" -------------------------------------------------------------------------------- /conftest.py: -------------------------------------------------------------------------------- 1 | """Pytest configuration for the test suite. 2 | 3 | This module ensures `custom_components` is available on `sys.path` 4 | so the vendored `cloudweatherproxy` package can be imported when 5 | running `pytest` from the repository root. 6 | """ 7 | 8 | import os 9 | import sys 10 | 11 | # Ensure `custom_components` is on sys.path so tests can import the vendored package 12 | ROOT = os.path.dirname(__file__) 13 | CUSTOM_COMPONENTS = os.path.join(ROOT, "custom_components") 14 | if CUSTOM_COMPONENTS not in sys.path: 15 | sys.path.insert(0, CUSTOM_COMPONENTS) 16 | -------------------------------------------------------------------------------- /scripts/develop: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | set -e 4 | 5 | cd "$(dirname "$0")/.." 6 | 7 | # Create config dir if not present 8 | if [[ ! -d "${PWD}/config" ]]; then 9 | mkdir -p "${PWD}/config" 10 | hass --config "${PWD}/config" --script ensure_config 11 | fi 12 | 13 | # Set the path to custom_components 14 | ## This let's us have the structure we want /custom_components/cloudweatherproxy 15 | ## while at the same time have Home Assistant configuration inside /config 16 | ## without resulting to symlinks. 17 | export PYTHONPATH="${PYTHONPATH}:${PWD}/custom_components" 18 | 19 | # Start Home Assistant 20 | hass --config "${PWD}/config" --debug 21 | -------------------------------------------------------------------------------- /examples/Caddyfile: -------------------------------------------------------------------------------- 1 | { 2 | http_port 8080 3 | https_port 8443 4 | auto_https off 5 | } 6 | 7 | http://rtupdate.wunderground.com { 8 | @weatherstation { 9 | path /weatherstation/updateweatherstation.php* 10 | } 11 | reverse_proxy @weatherstation { 12 | rewrite /wunderground{uri} 13 | header_up "Authorization" "Bearer {env.HA_ACCESS_TOKEN}" 14 | to 15 | } 16 | } 17 | 18 | http://api.weathercloud.net { 19 | @weatherstation { 20 | path /v01/set/* 21 | } 22 | reverse_proxy @weatherstation { 23 | rewrite /weathercloud{uri} 24 | header_up "Authorization" "Bearer {env.HA_ACCESS_TOKEN}" 25 | to 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /.github/workflows/lint.yml: -------------------------------------------------------------------------------- 1 | name: "Lint" 2 | 3 | on: 4 | push: 5 | branches: 6 | - "main" 7 | pull_request: 8 | branches: 9 | - "main" 10 | 11 | jobs: 12 | ruff: 13 | name: "Ruff" 14 | runs-on: "ubuntu-latest" 15 | steps: 16 | - name: "Checkout the repository" 17 | uses: "actions/checkout@v6" 18 | 19 | - name: "Set up Python" 20 | uses: actions/setup-python@v6 21 | with: 22 | python-version: "3.13" 23 | cache: "pip" 24 | 25 | - name: "Install requirements" 26 | run: python3 -m pip install -r requirements.txt 27 | 28 | - name: "Run" 29 | run: python3 -m ruff check . 30 | -------------------------------------------------------------------------------- /.github/workflows/validate.yml: -------------------------------------------------------------------------------- 1 | name: "Validate" 2 | 3 | on: 4 | workflow_dispatch: 5 | schedule: 6 | - cron: "0 0 * * *" 7 | push: 8 | branches: 9 | - "main" 10 | pull_request: 11 | branches: 12 | - "main" 13 | 14 | jobs: 15 | hassfest: # https://developers.home-assistant.io/blog/2020/04/16/hassfest 16 | name: "Hassfest Validation" 17 | runs-on: "ubuntu-latest" 18 | steps: 19 | - name: "Checkout the repository" 20 | uses: "actions/checkout@v6" 21 | 22 | - name: "Run hassfest validation" 23 | uses: "home-assistant/actions/hassfest@master" 24 | 25 | hacs: # https://github.com/hacs/action 26 | name: "HACS Validation" 27 | runs-on: "ubuntu-latest" 28 | steps: 29 | - name: "Checkout the repository" 30 | uses: "actions/checkout@v6" 31 | 32 | - name: "Run HACS validation" 33 | uses: "hacs/action@main" 34 | with: 35 | category: "integration" 36 | -------------------------------------------------------------------------------- /custom_components/cloudweatherproxy/aiocloudweather/tests/test_conversion.py: -------------------------------------------------------------------------------- 1 | from cloudweatherproxy.aiocloudweather.conversion import ( 2 | fahrenheit_to_celsius, 3 | in_to_mm, 4 | inhg_to_hpa, 5 | mph_to_ms, 6 | ) 7 | 8 | 9 | def test_fahrenheit_to_celsius(): 10 | assert round(fahrenheit_to_celsius(32), 2) == 0 11 | assert round(fahrenheit_to_celsius(212), 2) == 100 12 | assert round(fahrenheit_to_celsius(50), 2) == 10 13 | 14 | 15 | def test_inhg_to_hpa(): 16 | assert round(inhg_to_hpa(29.92), 2) == 1013.21 17 | assert round(inhg_to_hpa(30), 2) == 1015.92 18 | assert round(inhg_to_hpa(28), 2) == 948.19 19 | 20 | 21 | def test_in_to_mm(): 22 | assert round(in_to_mm(1), 2) == 25.4 23 | assert round(in_to_mm(2.5), 2) == 63.5 24 | assert round(in_to_mm(0.5), 2) == 12.7 25 | 26 | 27 | def test_mph_to_ms(): 28 | assert round(mph_to_ms(10), 4) == 4.4704 29 | assert round(mph_to_ms(30), 4) == 13.4112 30 | assert round(mph_to_ms(5), 4) == 2.2352 31 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: "Release" 2 | 3 | on: 4 | release: 5 | types: 6 | - "published" 7 | 8 | permissions: {} 9 | 10 | jobs: 11 | release: 12 | name: "Release" 13 | runs-on: "ubuntu-latest" 14 | permissions: 15 | contents: write 16 | steps: 17 | - name: "Checkout the repository" 18 | uses: "actions/checkout@v4" 19 | 20 | - name: "Adjust version number" 21 | shell: "bash" 22 | run: | 23 | yq -i -o json '.version="${{ github.event.release.tag_name }}"' \ 24 | "${{ github.workspace }}/custom_components/cloudweatherproxy/manifest.json" 25 | 26 | - name: "ZIP the integration directory" 27 | shell: "bash" 28 | run: | 29 | cd "${{ github.workspace }}/custom_components/cloudweatherproxy" 30 | zip cloudweatherproxy.zip -r ./ 31 | 32 | - name: "Upload the ZIP file to the release" 33 | uses: softprops/action-gh-release@v2 34 | with: 35 | files: ${{ github.workspace }}/custom_components/cloudweatherproxy/cloudweatherproxy.zip 36 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 - 2026 Lennart Weller @lhw 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /custom_components/cloudweatherproxy/aiocloudweather/tests/test_server.py: -------------------------------------------------------------------------------- 1 | import pytest # type: ignore[import-not-found] 2 | from aiohttp import web 3 | from cloudweatherproxy.aiocloudweather.server import ( 4 | CloudWeatherListener, 5 | ) 6 | 7 | 8 | @pytest.fixture 9 | async def client(aiohttp_client): 10 | app = web.Application() 11 | listener = CloudWeatherListener() 12 | app.router.add_get( 13 | "/wunderground/weatherstation/updateweatherstation.php", listener.handler) 14 | app.router.add_get("/weathercloud/v01/set/{path:.*}", listener.handler) 15 | await listener.start() 16 | client = await aiohttp_client(app) 17 | try: 18 | yield client 19 | finally: 20 | await listener.stop() 21 | await client.close() 22 | 23 | 24 | @pytest.mark.asyncio 25 | async def test_handler(client): 26 | data_files = ["tests/data/weathercloud", "tests/data/wunderground"] 27 | 28 | async def test_request(client, request_url): 29 | response = await client.get(request_url) 30 | assert response.status == 200 31 | assert await response.text() == "OK" 32 | 33 | c = client 34 | for file_path in data_files: 35 | with open(file_path, "r") as file: 36 | for line in file: 37 | request_url = line.strip() 38 | await test_request(c, request_url) 39 | -------------------------------------------------------------------------------- /.devcontainer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "lhw/cloudweatherproxy", 3 | "image": "mcr.microsoft.com/devcontainers/python:3.13-bookworm", 4 | "postCreateCommand": "scripts/setup", 5 | "forwardPorts": [ 6 | 8123 7 | ], 8 | "portsAttributes": { 9 | "8123": { 10 | "label": "Home Assistant", 11 | "onAutoForward": "notify" 12 | } 13 | }, 14 | "customizations": { 15 | "vscode": { 16 | "extensions": [ 17 | "ms-python.python", 18 | "github.vscode-pull-request-github", 19 | "ryanluker.vscode-coverage-gutters", 20 | "ms-python.vscode-pylance" 21 | ], 22 | "settings": { 23 | "files.eol": "\n", 24 | "editor.tabSize": 4, 25 | "python.pythonPath": "/usr/bin/python3", 26 | "python.analysis.autoSearchPaths": false, 27 | "python.linting.enabled": true, 28 | "python.formatting.provider": "black", 29 | "python.formatting.blackPath": "/usr/local/py-utils/bin/black", 30 | "editor.formatOnPaste": false, 31 | "editor.formatOnSave": true, 32 | "editor.formatOnType": true, 33 | "files.trimTrailingWhitespace": true, 34 | "python.linting.pylintArgs": [ 35 | "--disable=C0114,C0115,C0116" 36 | ] 37 | } 38 | } 39 | }, 40 | "remoteUser": "vscode", 41 | "features": {} 42 | } -------------------------------------------------------------------------------- /custom_components/cloudweatherproxy/strings.json: -------------------------------------------------------------------------------- 1 | { 2 | "config": { 3 | "step": { 4 | "user": { 5 | "description": "All data forwarded to Home Assistant is processed. Optionally you can forward the data to their intended data sink. Please select for which services you want to enable this feature", 6 | "data": { 7 | "weatherunderground_proxy": "Proxy Weather Underground", 8 | "weathercloud_proxy": "Proxy Weathercloud", 9 | "dns_servers": "DNS Servers" 10 | }, 11 | "data_description": { 12 | "dns_servers": "DNS Servers used for looking up the actual IPs of the domains. Can be a comma separated list of IPs." 13 | } 14 | }, 15 | "reconfigure": { 16 | "description": "All data forwarded to Home Assistant is processed. Optionally you can forward the data to their intended data sink. Please select for which services you want to enable this feature", 17 | "data": { 18 | "weatherunderground_proxy": "Proxy Weather Underground", 19 | "weathercloud_proxy": "Proxy Weathercloud", 20 | "dns_servers": "DNS Servers" 21 | }, 22 | "data_description": { 23 | "dns_servers": "DNS Servers used for looking up the actual IPs of the domains. Can be a comma separated list of IPs." 24 | } 25 | } 26 | }, 27 | "create_entry": { 28 | "default": "To finish setting up the integration, please follow the guide from the README in regards on how to setup the DNS and HTTP server.\n\nThe destination address for HomeAssistant is `https://{address}:{port}`." 29 | }, 30 | "abort": { 31 | "single_instance": "Only a single instance can run at the same time as they occupy the same paths.", 32 | "reconfigure_successful": "The configuration has been updated. The changes are live immediately." 33 | } 34 | } 35 | } -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.yml: -------------------------------------------------------------------------------- 1 | --- 2 | name: "Feature request" 3 | description: "Suggest an idea for this project" 4 | labels: "Feature+Request" 5 | body: 6 | - type: markdown 7 | attributes: 8 | value: Before you open a new feature request, search through the existing feature requests to see if others have had the same idea. 9 | - type: checkboxes 10 | attributes: 11 | label: Checklist 12 | options: 13 | - label: I have filled out the template to the best of my ability. 14 | required: true 15 | - label: This only contains 1 feature request (if you have multiple feature requests, open one feature request for each feature request). 16 | required: true 17 | - label: This issue is not a duplicate feature request of [previous feature requests](https://github.com/ludeeus/cloudweatherproxy/issues?q=is%3Aissue+label%3A%22Feature+Request%22+). 18 | required: true 19 | 20 | - type: textarea 21 | attributes: 22 | label: "Is your feature request related to a problem? Please describe." 23 | description: "A clear and concise description of what the problem is." 24 | placeholder: "I'm always frustrated when [...]" 25 | validations: 26 | required: true 27 | 28 | - type: textarea 29 | attributes: 30 | label: "Describe the solution you'd like" 31 | description: "A clear and concise description of what you want to happen." 32 | validations: 33 | required: true 34 | 35 | - type: textarea 36 | attributes: 37 | label: "Describe alternatives you've considered" 38 | description: "A clear and concise description of any alternative solutions or features you've considered." 39 | validations: 40 | required: true 41 | 42 | - type: textarea 43 | attributes: 44 | label: "Additional context" 45 | description: "Add any other context or screenshots about the feature request here." 46 | validations: 47 | required: true 48 | -------------------------------------------------------------------------------- /custom_components/cloudweatherproxy/aiocloudweather/__main__.py: -------------------------------------------------------------------------------- 1 | """Run local Test server.""" 2 | 3 | from __future__ import annotations 4 | 5 | import asyncio 6 | from dataclasses import Field, fields 7 | import logging 8 | import sys 9 | 10 | from .server import CloudWeatherListener 11 | from .proxy import DataSink 12 | from .station import Sensor, WeatherStation 13 | 14 | _LOGGER = logging.getLogger(__name__) 15 | 16 | 17 | def usage(): 18 | """Show CLI usage.""" 19 | _LOGGER.info("Usage: %s port", sys.argv[0]) 20 | 21 | 22 | async def my_handler(station: WeatherStation) -> None: 23 | """Print station sensor data.""" 24 | 25 | for sensor in fields(station): 26 | if sensor.type != Sensor: 27 | continue 28 | value: Field[Sensor] = getattr(station, sensor.name) 29 | if value is None: 30 | continue 31 | 32 | # print(f"{sensor.name}: {value.metric} ({value.metric_unit})") 33 | # print(f"{sensor.name}: {value.imperial} ({value.imperial_unit})") 34 | 35 | # print(f"{str(station)}") 36 | 37 | 38 | async def run_server(cloudweather_ws: CloudWeatherListener) -> None: 39 | """Run server in endless mode.""" 40 | await cloudweather_ws.start() 41 | while True: 42 | await asyncio.sleep(100000) 43 | 44 | 45 | def main() -> None: 46 | """Run main.""" 47 | if len(sys.argv) < 2: 48 | usage() 49 | sys.exit(1) 50 | 51 | _LOGGER.info("Firing up webserver to listen on port %s", sys.argv[1]) 52 | cloudweather_server = CloudWeatherListener( 53 | port=int(sys.argv[1]), proxy_sinks=[DataSink.WUNDERGROUND] 54 | ) 55 | 56 | cloudweather_server.new_dataset_cb.append(my_handler) 57 | try: 58 | asyncio.run(run_server(cloudweather_server)) 59 | except Exception as err: # pylint: disable=broad-except 60 | _LOGGER.exception("Server error: %s", err) 61 | _LOGGER.info("Exiting") 62 | 63 | 64 | if __name__ == "__main__": 65 | main() 66 | -------------------------------------------------------------------------------- /.ruff.toml: -------------------------------------------------------------------------------- 1 | # The contents of this file is based on https://github.com/home-assistant/core/blob/dev/pyproject.toml 2 | 3 | target-version = "py310" 4 | 5 | [lint] 6 | select = [ 7 | "B007", # Loop control variable {name} not used within loop body 8 | "B014", # Exception handler with duplicate exception 9 | "C", # complexity 10 | "D", # docstrings 11 | "E", # pycodestyle 12 | "F", # pyflakes/autoflake 13 | "ICN001", # import concentions; {name} should be imported as {asname} 14 | "PGH004", # Use specific rule codes when using noqa 15 | "PLC0414", # Useless import alias. Import alias does not rename original package. 16 | "SIM105", # Use contextlib.suppress({exception}) instead of try-except-pass 17 | "SIM117", # Merge with-statements that use the same scope 18 | "SIM118", # Use {key} in {dict} instead of {key} in {dict}.keys() 19 | "SIM201", # Use {left} != {right} instead of not {left} == {right} 20 | "SIM212", # Use {a} if {a} else {b} instead of {b} if not {a} else {a} 21 | "SIM300", # Yoda conditions. Use 'age == 42' instead of '42 == age'. 22 | "SIM401", # Use get from dict with default instead of an if block 23 | "T20", # flake8-print 24 | "TRY004", # Prefer TypeError exception for invalid type 25 | "RUF006", # Store a reference to the return value of asyncio.create_task 26 | "UP", # pyupgrade 27 | "W", # pycodestyle 28 | ] 29 | 30 | ignore = [ 31 | "D202", # No blank lines allowed after function docstring 32 | "D203", # 1 blank line required before class docstring 33 | "D213", # Multi-line docstring summary should start at the second line 34 | "D404", # First word of the docstring should not be This 35 | "D406", # Section name should end with a newline 36 | "D407", # Section name underlining 37 | "D411", # Missing blank line before section 38 | "E501", # line too long 39 | "E731", # do not assign a lambda expression, use a def 40 | ] 41 | 42 | [lint.flake8-pytest-style] 43 | fixture-parentheses = false 44 | 45 | [lint.pyupgrade] 46 | keep-runtime-typing = true 47 | 48 | [lint.mccabe] 49 | max-complexity = 25 -------------------------------------------------------------------------------- /custom_components/cloudweatherproxy/aiocloudweather/conversion.py: -------------------------------------------------------------------------------- 1 | """A list of all the unit conversions. Many are just approximations.""" 2 | 3 | from .const import ( 4 | UnitOfPrecipitationDepth, 5 | UnitOfPressure, 6 | UnitOfSpeed, 7 | UnitOfTemperature, 8 | ) 9 | 10 | # Prefer Home Assistant's unit conversion helpers when running inside HA. 11 | try: 12 | from homeassistant.util.unit_conversion import ( 13 | fahrenheit_to_celsius as _ha_fahrenheit_to_celsius, 14 | inhg_to_hpa as _ha_inhg_to_hpa, 15 | in_to_mm as _ha_in_to_mm, 16 | mph_to_ms as _ha_mph_to_ms, 17 | ) 18 | except Exception: 19 | _ha_fahrenheit_to_celsius = None 20 | _ha_inhg_to_hpa = None 21 | _ha_in_to_mm = None 22 | _ha_mph_to_ms = None 23 | 24 | 25 | def unit(output_unit): 26 | """Set the output unit of the function.""" 27 | 28 | def decorator(func): 29 | func.unit = output_unit 30 | return func 31 | 32 | return decorator 33 | 34 | 35 | # imperial shenanigans 36 | @unit(UnitOfTemperature.CELSIUS) 37 | def fahrenheit_to_celsius(temp_f: float) -> float: 38 | """Convert Fahrenheit to Celsius.""" 39 | if _ha_fahrenheit_to_celsius is not None: 40 | return _ha_fahrenheit_to_celsius(temp_f) 41 | 42 | return (temp_f - 32) * 5.0 / 9.0 43 | 44 | 45 | @unit(UnitOfPressure.HPA) 46 | def inhg_to_hpa(pressure: float) -> float: 47 | """Convert inches of mercury (inHg) to hectopascals (hPa).""" 48 | if _ha_inhg_to_hpa is not None: 49 | return _ha_inhg_to_hpa(pressure) 50 | 51 | return pressure * 33.864 52 | 53 | 54 | @unit(UnitOfPrecipitationDepth.MILLIMETERS) 55 | def in_to_mm(length: float) -> float: 56 | """Convert inches to millimeters (mm).""" 57 | if _ha_in_to_mm is not None: 58 | return _ha_in_to_mm(length) 59 | 60 | return length * 25.4 61 | 62 | 63 | @unit(UnitOfSpeed.METERS_PER_SECOND) 64 | def mph_to_ms(speed: float) -> float: 65 | """Convert miles per hour (mph) to meters per second (m/s).""" 66 | if _ha_mph_to_ms is not None: 67 | return _ha_mph_to_ms(speed) 68 | 69 | return speed * 0.44704 70 | -------------------------------------------------------------------------------- /custom_components/cloudweatherproxy/web.py: -------------------------------------------------------------------------------- 1 | """The web server for the CloudWeatherProxy integration.""" 2 | 3 | from http import HTTPStatus 4 | import logging 5 | 6 | from .aiocloudweather import CloudWeatherListener 7 | from aiohttp import web 8 | from aiohttp.web_exceptions import HTTPClientError 9 | 10 | from homeassistant.helpers.http import HomeAssistantView 11 | from homeassistant.helpers.http import KEY_AUTHENTICATED 12 | 13 | from .const import DOMAIN 14 | 15 | _LOGGER = logging.getLogger(__name__) 16 | 17 | 18 | class WundergroundReceiver(HomeAssistantView): 19 | """Wunderground receiver.""" 20 | 21 | name = f"api:{DOMAIN}:wunderground" 22 | url = "/wunderground/weatherstation/updateweatherstation.php" 23 | 24 | def __init__(self, listener: CloudWeatherListener) -> None: 25 | """Initialize the Wunderground receiver.""" 26 | self.listener = listener 27 | 28 | async def get( 29 | self, 30 | request: web.Request, 31 | ) -> web.Response: 32 | """Handle Wunderground request.""" 33 | 34 | if not request[KEY_AUTHENTICATED]: 35 | return web.Response(status=HTTPStatus.UNAUTHORIZED) 36 | 37 | try: 38 | return await self.listener.handler(request) 39 | except HTTPClientError as e: 40 | _LOGGER.error(e) 41 | return web.Response(status=HTTPStatus.BAD_REQUEST) 42 | 43 | 44 | class WeathercloudReceiver(HomeAssistantView): 45 | """Weathercloud receiver.""" 46 | 47 | name = f"api:{DOMAIN}:weathercloud" 48 | url = "/weathercloud/v01/set/{values:.*}" 49 | 50 | def __init__(self, listener: CloudWeatherListener) -> None: 51 | """Initialize the Weathercloud receiver.""" 52 | self.listener = listener 53 | 54 | async def get( 55 | self, 56 | request: web.Request, 57 | values: str, 58 | ) -> web.Response: 59 | """Handle Weathercloud request.""" 60 | 61 | if not request[KEY_AUTHENTICATED]: 62 | return web.Response(status=HTTPStatus.UNAUTHORIZED) 63 | 64 | try: 65 | return await self.listener.handler(request) 66 | except HTTPClientError as e: 67 | _LOGGER.error(e) 68 | return web.Response(status=HTTPStatus.BAD_REQUEST) 69 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug.yml: -------------------------------------------------------------------------------- 1 | --- 2 | name: "Bug report" 3 | description: "Report a bug with the integration" 4 | labels: "Bug" 5 | body: 6 | - type: markdown 7 | attributes: 8 | value: Before you open a new issue, search through the existing issues to see if others have had the same problem. 9 | - type: textarea 10 | attributes: 11 | label: "System Health details" 12 | description: "Paste the data from the System Health card in Home Assistant (https://www.home-assistant.io/more-info/system-health#github-issues)" 13 | validations: 14 | required: true 15 | - type: checkboxes 16 | attributes: 17 | label: Checklist 18 | options: 19 | - label: I have enabled debug logging for my installation. 20 | required: true 21 | - label: I have filled out the issue template to the best of my ability. 22 | required: true 23 | - label: This issue only contains 1 issue (if you have multiple issues, open one issue for each issue). 24 | required: true 25 | - label: This issue is not a duplicate issue of any [previous issues](https://github.com/ludeeus/cloudweatherproxy/issues?q=is%3Aissue+label%3A%22Bug%22+).. 26 | required: true 27 | - type: textarea 28 | attributes: 29 | label: "Describe the issue" 30 | description: "A clear and concise description of what the issue is." 31 | validations: 32 | required: true 33 | - type: textarea 34 | attributes: 35 | label: Reproduction steps 36 | description: "Without steps to reproduce, it will be hard to fix. It is very important that you fill out this part. Issues without it will be closed." 37 | value: | 38 | 1. 39 | 2. 40 | 3. 41 | ... 42 | validations: 43 | required: true 44 | - type: textarea 45 | attributes: 46 | label: "Debug logs" 47 | description: "To enable debug logs check this https://www.home-assistant.io/integrations/logger/, this **needs** to include _everything_ from startup of Home Assistant to the point where you encounter the issue." 48 | render: text 49 | validations: 50 | required: true 51 | 52 | - type: textarea 53 | attributes: 54 | label: "Diagnostics dump" 55 | description: "Drag the diagnostics dump file here. (see https://www.home-assistant.io/integrations/diagnostics/ for info)" 56 | -------------------------------------------------------------------------------- /custom_components/cloudweatherproxy/aiocloudweather/const.py: -------------------------------------------------------------------------------- 1 | """Copy of the relevant data from homeassistant/const.py without importing all of homeassistant.""" 2 | 3 | from enum import StrEnum 4 | from typing import Final 5 | 6 | # Degree units 7 | DEGREE: Final = "°" 8 | # Light units 9 | LIGHT_LUX: Final = "lx" 10 | # Percentage units 11 | PERCENTAGE: Final = "%" 12 | # UV Index units 13 | UV_INDEX: Final = "UV index" 14 | 15 | 16 | # Irradiance units 17 | class UnitOfIrradiance(StrEnum): 18 | """Irradiance units.""" 19 | 20 | WATTS_PER_SQUARE_METER = "W/m²" 21 | BTUS_PER_HOUR_SQUARE_FOOT = "BTU/(h⋅ft²)" 22 | 23 | 24 | class UnitOfPrecipitationDepth(StrEnum): 25 | """Precipitation depth. 26 | 27 | The derivation of these units is a volume of rain amassing in a container 28 | with constant cross section 29 | """ 30 | 31 | INCHES = "in" 32 | """Derived from in³/in²""" 33 | 34 | MILLIMETERS = "mm" 35 | """Derived from mm³/mm²""" 36 | 37 | CENTIMETERS = "cm" 38 | """Derived from cm³/cm²""" 39 | 40 | 41 | # Pressure units 42 | class UnitOfPressure(StrEnum): 43 | """Pressure units.""" 44 | 45 | PA = "Pa" 46 | HPA = "hPa" 47 | KPA = "kPa" 48 | BAR = "bar" 49 | CBAR = "cbar" 50 | MBAR = "mbar" 51 | MMHG = "mmHg" 52 | INHG = "inHg" 53 | PSI = "psi" 54 | 55 | 56 | # Speed units 57 | class UnitOfSpeed(StrEnum): 58 | """Speed units.""" 59 | 60 | BEAUFORT = "Beaufort" 61 | FEET_PER_SECOND = "ft/s" 62 | METERS_PER_SECOND = "m/s" 63 | KILOMETERS_PER_HOUR = "km/h" 64 | KNOTS = "kn" 65 | MILES_PER_HOUR = "mph" 66 | 67 | 68 | # Temperature units 69 | class UnitOfTemperature(StrEnum): 70 | """Temperature units.""" 71 | 72 | CELSIUS = "°C" 73 | FAHRENHEIT = "°F" 74 | KELVIN = "K" 75 | 76 | 77 | class UnitOfVolumetricFlux(StrEnum): 78 | """Volumetric flux, commonly used for precipitation intensity. 79 | 80 | The derivation of these units is a volume of rain amassing in a container 81 | with constant cross section in a given time 82 | """ 83 | 84 | INCHES_PER_DAY = "in/d" 85 | """Derived from in³/(in²⋅d)""" 86 | 87 | INCHES_PER_HOUR = "in/h" 88 | """Derived from in³/(in²⋅h)""" 89 | 90 | MILLIMETERS_PER_DAY = "mm/d" 91 | """Derived from mm³/(mm²⋅d)""" 92 | 93 | MILLIMETERS_PER_HOUR = "mm/h" 94 | """Derived from mm³/(mm²⋅h)""" 95 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contribution guidelines 2 | 3 | Contributing to this project should be as easy and transparent as possible, whether it's: 4 | 5 | - Reporting a bug 6 | - Discussing the current state of the code 7 | - Submitting a fix 8 | - Proposing new features 9 | 10 | ## Github is used for everything 11 | 12 | Github is used to host code, to track issues and feature requests, as well as accept pull requests. 13 | 14 | Pull requests are the best way to propose changes to the codebase. 15 | 16 | 1. Fork the repo and create your branch from `main`. 17 | 2. If you've changed something, update the documentation. 18 | 3. Make sure your code lints (using `scripts/lint`). 19 | 4. Test you contribution. 20 | 5. Issue that pull request! 21 | 22 | ## Any contributions you make will be under the MIT Software License 23 | 24 | In short, when you submit code changes, your submissions are understood to be under the same [MIT License](http://choosealicense.com/licenses/mit/) that covers the project. Feel free to contact the maintainers if that's a concern. 25 | 26 | ## Report bugs using Github's [issues](../../issues) 27 | 28 | GitHub issues are used to track public bugs. 29 | Report a bug by [opening a new issue](../../issues/new/choose); it's that easy! 30 | 31 | ## Write bug reports with detail, background, and sample code 32 | 33 | **Great Bug Reports** tend to have: 34 | 35 | - A quick summary and/or background 36 | - Steps to reproduce 37 | - Be specific! 38 | - Give sample code if you can. 39 | - What you expected would happen 40 | - What actually happens 41 | - Notes (possibly including why you think this might be happening, or stuff you tried that didn't work) 42 | 43 | People *love* thorough bug reports. I'm not even kidding. 44 | 45 | ## Use a Consistent Coding Style 46 | 47 | Use [black](https://github.com/ambv/black) to make sure the code follows the style. 48 | 49 | ## Test your code modification 50 | 51 | This custom component is based on [integration_blueprint template](https://github.com/ludeeus/integration_blueprint). 52 | 53 | It comes with development environment in a container, easy to launch 54 | if you use Visual Studio Code. With this container you will have a stand alone 55 | Home Assistant instance running and already configured with the included 56 | [`configuration.yaml`](./config/configuration.yaml) 57 | file. 58 | 59 | ## License 60 | 61 | By contributing, you agree that your contributions will be licensed under its MIT License. 62 | -------------------------------------------------------------------------------- /custom_components/cloudweatherproxy/sensor.py: -------------------------------------------------------------------------------- 1 | """Registering Cloud Weather Proxy Weather Stations.""" 2 | 3 | from __future__ import annotations 4 | 5 | from dataclasses import fields 6 | from .aiocloudweather.utils import resolve_caster 7 | import logging 8 | 9 | from .aiocloudweather import CloudWeatherListener, Sensor, WeatherStation 10 | 11 | from homeassistant.core import HomeAssistant 12 | from homeassistant.helpers.entity_platform import AddEntitiesCallback 13 | 14 | from . import CloudWeatherProxyConfigEntry 15 | from .entity import CloudWeatherEntity 16 | 17 | _LOGGER = logging.getLogger(__name__) 18 | 19 | 20 | async def async_setup_entry( 21 | hass: HomeAssistant, entry: CloudWeatherProxyConfigEntry, async_add_entities: AddEntitiesCallback 22 | ) -> None: 23 | """Register new weather stations.""" 24 | runtime_data = entry.runtime_data 25 | cloudweather: CloudWeatherListener = runtime_data.listener 26 | 27 | async def _new_dataset(station: WeatherStation) -> None: 28 | known_sensors: dict[str, 29 | CloudWeatherEntity] = runtime_data.known_sensors 30 | new_sensors: list[CloudWeatherEntity] = [] 31 | 32 | for field in fields(station): 33 | field_type = field.type 34 | caster = resolve_caster(field_type) 35 | if caster is not Sensor and caster is not None: 36 | if getattr(caster, "__name__", "") != getattr(Sensor, "__name__", ""): 37 | continue 38 | elif caster is None: 39 | continue 40 | sensor: Sensor | None = getattr(station, field.name) 41 | if sensor is None: 42 | continue 43 | unique_id = f"{station.station_id}-{sensor.name}" 44 | 45 | if unique_id in known_sensors: 46 | await known_sensors[unique_id].update_sensor(station) 47 | continue 48 | 49 | meta_name = field.metadata.get("name") or sensor.name 50 | new_sensor = CloudWeatherEntity(sensor, station, str(meta_name)) 51 | known_sensors[unique_id] = new_sensor 52 | new_sensors.append(new_sensor) 53 | 54 | if len(new_sensors) > 0: 55 | _LOGGER.debug("Adding %d sensors", len(new_sensors)) 56 | async_add_entities(new_sensors) 57 | for nsensor in new_sensors: 58 | nsensor.async_write_ha_state() 59 | 60 | cloudweather.new_dataset_cb.append(_new_dataset) 61 | entry.async_on_unload( 62 | lambda: cloudweather.new_dataset_cb.remove(_new_dataset)) 63 | -------------------------------------------------------------------------------- /custom_components/cloudweatherproxy/diagnostics.py: -------------------------------------------------------------------------------- 1 | """Simple diagnostics option. To be expanded later.""" 2 | 3 | from typing import Any 4 | from homeassistant.core import HomeAssistant 5 | 6 | from . import CloudWeatherProxyConfigEntry, DomainData 7 | from .entity import CloudWeatherEntity 8 | from .const import DOMAIN, CONF_WUNDERGROUND_PROXY, CONF_WEATHERCLOUD_PROXY, CONF_DNS_SERVERS 9 | 10 | 11 | async def async_get_config_entry_diagnostics(hass: HomeAssistant, entry: CloudWeatherProxyConfigEntry) -> dict[str, Any]: 12 | """Return config entry diagnostics.""" 13 | 14 | runtime_data = entry.runtime_data 15 | known_sensors: dict[str, CloudWeatherEntity] = runtime_data.known_sensors 16 | 17 | # Get the per-HASS log queue from domain-wide data 18 | logs = [] 19 | if DOMAIN in hass.data: 20 | domain_data: DomainData = hass.data[DOMAIN] 21 | log_queue = domain_data.log_queue 22 | if log_queue: 23 | # Access underlying deque of asyncio.Queue 24 | items = list(getattr(log_queue, "_queue", [])) 25 | logs = items[-100:] 26 | 27 | # Format known_sensors for human readability with masked station IDs 28 | # Create a mapping of station IDs to unique identifiers to prevent collisions 29 | station_id_mapping: dict[str, str] = {} 30 | station_counter = 1 31 | 32 | formatted_sensors = {} 33 | for unique_id, entity in known_sensors.items(): 34 | station_id = entity.station.station_id 35 | if station_id not in station_id_mapping: 36 | station_id_mapping[station_id] = f"station_{station_counter}" 37 | station_counter += 1 38 | 39 | masked_key = unique_id.replace( 40 | station_id, station_id_mapping[station_id], 1) 41 | 42 | formatted_sensors[masked_key] = { 43 | "name": entity.name, 44 | "sensor_name": entity.sensor.name if entity.sensor else None, 45 | "value": entity.sensor.value if entity.sensor else None, 46 | "unit": entity.sensor.unit if entity.sensor else None, 47 | "available": entity.available, 48 | "enabled": entity.enabled, 49 | } 50 | 51 | # Apply masking to logs 52 | masked_logs = [] 53 | for log in logs: 54 | masked_log = log 55 | for station_id, masked_id in station_id_mapping.items(): 56 | masked_log = masked_log.replace(station_id, masked_id) 57 | masked_logs.append(masked_log) 58 | 59 | formatted_entry_data = { 60 | "proxy_wunderground": entry.data.get(CONF_WUNDERGROUND_PROXY, False), 61 | "proxy_weathercloud": entry.data.get(CONF_WEATHERCLOUD_PROXY, False), 62 | "dns_servers": entry.data.get(CONF_DNS_SERVERS, ""), 63 | } 64 | 65 | return { 66 | "known_sensors": formatted_sensors, 67 | "entry_data": formatted_entry_data, 68 | "logs": { 69 | "recent": masked_logs, 70 | }, 71 | } 72 | -------------------------------------------------------------------------------- /.github/copilot-instructions.md: -------------------------------------------------------------------------------- 1 | # Cloud Weather Proxy - AI Coding Instructions 2 | 3 | ## Project Overview 4 | This is a Home Assistant custom component (`cloudweatherproxy`) that intercepts weather station traffic (Wunderground, Weathercloud) to display it locally in Home Assistant. It acts as a "Man-in-the-Middle" proxy. 5 | 6 | ## Architecture & Data Flow 7 | - **Core Logic**: Relies on the vendored library `aiocloudweather` (located in `custom_components/cloudweatherproxy/aiocloudweather`). 8 | - `station.py`: Parses raw HTTP parameters into typed `WeatherStation` objects. 9 | - `proxy.py`: Handles forwarding data to upstream services (Wunderground/Weathercloud). 10 | - **Ingestion**: `web.py` registers `HomeAssistantView` endpoints that mimic the APIs of weather services (e.g., `/wunderground/weatherstation/updateweatherstation.php`). 11 | - **Processing**: Incoming requests are passed to `CloudWeatherListener` (from `aiocloudweather`), which parses the data. 12 | - **State Updates**: `sensor.py` subscribes to `CloudWeatherListener` callbacks. When new data arrives, it dynamically creates or updates `CloudWeatherEntity` sensors. 13 | - **Configuration**: `config_flow.py` manages the setup, allowing users to enable/disable specific proxies and configure DNS servers. 14 | 15 | ## Development Workflow 16 | - **Environment Setup**: Run `scripts/setup` to install dependencies. 17 | - **Running Local HA**: Use `scripts/develop` to start a local Home Assistant instance. 18 | - **Crucial**: This script sets `PYTHONPATH="${PYTHONPATH}:${PWD}/custom_components"` to inject the component without symlinking or installing it into the HA site-packages. 19 | - The instance runs in debug mode with configuration in `config/`. 20 | - **Testing**: Since this relies on incoming HTTP requests, testing often involves sending mocked requests to the local HA instance (port 8123 by default). 21 | 22 | ## Code Conventions 23 | - **Async/Await**: All I/O operations, especially in `web.py` and `sensor.py`, must be asynchronous. 24 | - **Vendored Imports**: The `aiocloudweather` library is local. Use relative imports within the component (e.g., `from .aiocloudweather import ...`) instead of absolute imports. 25 | - **Type Hinting**: Strictly enforced. Use `typing` module and HA specific types (`HomeAssistant`, `ConfigEntry`). 26 | - **Constants**: Centralized in `const.py`. Use `UNIT_DESCRIPTION_MAPPING` for defining sensor properties based on units. 27 | - **Logging**: Use `_LOGGER` (standard python logging) for debug and error messages. 28 | 29 | ## Key Files 30 | - `custom_components/cloudweatherproxy/web.py`: HTTP endpoints for receiving weather station data. 31 | - `custom_components/cloudweatherproxy/sensor.py`: Entity logic and dynamic sensor creation. 32 | - `custom_components/cloudweatherproxy/__init__.py`: Component setup and `CloudWeatherListener` initialization. 33 | - `custom_components/cloudweatherproxy/aiocloudweather/`: The vendored library. 34 | - `station.py`: Data parsing logic. 35 | - `proxy.py`: Upstream forwarding logic. 36 | 37 | ## Integration Details 38 | - **Dependency**: The `aiocloudweather` library is now part of the codebase, not an external requirement. 39 | - **Home Assistant**: Uses standard `config_entries` and `entity_platform` patterns. 40 | -------------------------------------------------------------------------------- /custom_components/cloudweatherproxy/entity.py: -------------------------------------------------------------------------------- 1 | """Cloud Weather Proxy Entity definitions.""" 2 | 3 | from __future__ import annotations 4 | 5 | import logging 6 | import time 7 | 8 | from .aiocloudweather import Sensor, WeatherStation 9 | 10 | from homeassistant.components.sensor import SensorEntity 11 | from homeassistant.helpers.device_registry import DeviceInfo 12 | from homeassistant.helpers.entity import Entity 13 | 14 | from .const import DOMAIN, UNIT_DESCRIPTION_MAPPING 15 | 16 | _LOGGER = logging.getLogger(__name__) 17 | 18 | 19 | class CloudWeatherBaseEntity(Entity): 20 | """The Cloud Weather Proxy Entity.""" 21 | 22 | _attr_has_entity_name = True 23 | _attr_should_poll = False 24 | 25 | sensor: Sensor | None 26 | station: WeatherStation 27 | 28 | def __init__(self, sensor: Sensor, station: WeatherStation) -> None: 29 | """Construct the entity.""" 30 | self.sensor = sensor 31 | self.station = station 32 | 33 | self._attr_unique_id = f"{station.station_id}-{sensor.name}" 34 | self._attr_device_info = DeviceInfo( 35 | name=f"Weatherstation {station.station_id}", 36 | identifiers={(DOMAIN, station.station_id)}, 37 | sw_version=station.station_sw_version, 38 | manufacturer=getattr(station.vendor, "value", str(station.vendor)), 39 | ) 40 | self._attr_available = (station.update_time is not None) and ( 41 | (station.update_time + 5 * 60) > time.monotonic()) 42 | 43 | 44 | class CloudWeatherEntity(CloudWeatherBaseEntity, SensorEntity): 45 | """The Cloud Weather Proxy Sensor Entity.""" 46 | 47 | def __init__( 48 | self, 49 | sensor: Sensor, 50 | station: WeatherStation, 51 | name: str, 52 | ) -> None: 53 | """Initialize the sensor entity.""" 54 | super().__init__(sensor, station) 55 | self._attr_name = name 56 | self._attr_native_value = sensor.value if sensor else None 57 | description = UNIT_DESCRIPTION_MAPPING.get(sensor.unit) 58 | if description is None: 59 | for key, val in UNIT_DESCRIPTION_MAPPING.items(): 60 | try: 61 | if str(key) == sensor.unit: 62 | description = val 63 | break 64 | except Exception: 65 | continue 66 | if description is not None: 67 | self.entity_description = description 68 | 69 | async def update_sensor(self, station: WeatherStation) -> None: 70 | """Update the entity.""" 71 | self.station = station 72 | old_name = self.sensor.name if self.sensor is not None else None 73 | if old_name is None: 74 | return 75 | new_sensor = getattr(station, old_name, None) 76 | self.sensor = new_sensor 77 | 78 | self._attr_native_value = self.sensor.value if self.sensor else None 79 | self._attr_available = (station.update_time is not None) and ( 80 | (station.update_time + 5 * 60) > time.monotonic()) 81 | 82 | _LOGGER.debug("Updating %s [%s] with update time %s", 83 | self.unique_id, self.sensor, self.station.update_time) 84 | self.async_write_ha_state() 85 | -------------------------------------------------------------------------------- /custom_components/cloudweatherproxy/aiocloudweather/tests/test_sensor.py: -------------------------------------------------------------------------------- 1 | from cloudweatherproxy.aiocloudweather.station import ( 2 | WeatherStation, 3 | WundergroundRawSensor, 4 | WeathercloudRawSensor, 5 | ) 6 | 7 | 8 | def test_weather_station_from_wunderground(): 9 | raw_sensor_data = WundergroundRawSensor( 10 | station_id="12345", 11 | station_key="12345", 12 | barometer=29.92, 13 | temperature=72.5, 14 | humidity=44, 15 | dewpoint=49.2, 16 | rain=0, 17 | dailyrain=0, 18 | winddirection=249, 19 | windspeed=2.0, 20 | windgustspeed=2.7, 21 | uv=2, 22 | solarradiation=289.2, 23 | ) 24 | weather_station = WeatherStation.from_wunderground(raw_sensor_data) 25 | 26 | assert weather_station.station_id == "12345" 27 | assert weather_station.station_key == "12345" 28 | assert round(weather_station.barometer.value, 2) == 1013.21 29 | assert weather_station.barometer.unit == "hPa" 30 | assert weather_station.temperature.value == 22.5 31 | assert weather_station.temperature.unit == "°C" 32 | assert weather_station.humidity.value == 44 33 | assert weather_station.humidity.unit == "%" 34 | assert weather_station.solarradiation.value == 289200.0 35 | assert weather_station.solarradiation.unit == "lx" 36 | assert weather_station.winddirection.value == 249 37 | 38 | 39 | def test_weather_station_from_wunderground_2(): 40 | raw_sensor_data = WundergroundRawSensor( 41 | station_id="12345", 42 | station_key="12345", 43 | barometer=29.92, 44 | temperature=72.5, 45 | humidity=44, 46 | dewpoint=49.2, 47 | rain=0, 48 | dailyrain=0, 49 | winddirection=249, 50 | windspeed=2.0, 51 | windgustspeed=2.7, 52 | uv=2, 53 | solarradiation_new=9.57, 54 | ) 55 | weather_station = WeatherStation.from_wunderground(raw_sensor_data) 56 | 57 | assert weather_station.station_id == "12345" 58 | assert weather_station.station_key == "12345" 59 | assert weather_station.solarradiation.value == 9.57 60 | assert weather_station.solarradiation.unit == "W/m²" 61 | 62 | 63 | def test_weather_station_from_weathercloud(): 64 | raw_sensor_data = WeathercloudRawSensor( 65 | station_id="12345", 66 | station_key="12345", 67 | barometer=10130, 68 | temperature=160, 69 | humidity=80, 70 | dewpoint=129, 71 | rain=109, 72 | dailyrain=25, 73 | winddirection=288, 74 | windspeed=0, 75 | windgustspeed=0, 76 | uv=0, 77 | solarradiation=470, 78 | ) 79 | weather_station = WeatherStation.from_weathercloud(raw_sensor_data) 80 | 81 | assert weather_station.station_id == "12345" 82 | assert weather_station.station_key == "12345" 83 | assert weather_station.barometer.value == 1013 84 | assert weather_station.barometer.unit == "hPa" 85 | assert weather_station.temperature.value == 16 86 | assert weather_station.temperature.unit == "°C" 87 | assert weather_station.humidity.value == 80 88 | assert weather_station.humidity.unit == "%" 89 | assert weather_station.rain.value == 10.9 90 | assert weather_station.rain.unit == "mm/h" 91 | assert weather_station.dailyrain.value == 2.5 92 | assert weather_station.dailyrain.unit == "mm" 93 | assert weather_station.winddirection.value == 288 94 | -------------------------------------------------------------------------------- /custom_components/cloudweatherproxy/const.py: -------------------------------------------------------------------------------- 1 | """Constants for the Cloud Weather Proxy.""" 2 | 3 | from typing import Final 4 | 5 | from homeassistant.components.sensor import ( 6 | SensorDeviceClass, 7 | SensorEntityDescription, 8 | SensorStateClass, 9 | ) 10 | from homeassistant.const import ( 11 | DEGREE, 12 | LIGHT_LUX, 13 | PERCENTAGE, 14 | UV_INDEX, 15 | UnitOfIrradiance, 16 | UnitOfPrecipitationDepth, 17 | UnitOfPressure, 18 | UnitOfSpeed, 19 | UnitOfTemperature, 20 | UnitOfVolumetricFlux, 21 | ) 22 | 23 | DOMAIN = "cloudweatherproxy" 24 | 25 | UNIT_DESCRIPTION_MAPPING: Final = { 26 | UnitOfPressure.HPA: SensorEntityDescription( 27 | key="PRESSURE_HPA", 28 | device_class=SensorDeviceClass.PRESSURE, 29 | native_unit_of_measurement=UnitOfPressure.HPA, 30 | state_class=SensorStateClass.MEASUREMENT, 31 | suggested_display_precision=2, 32 | ), 33 | UnitOfTemperature.CELSIUS: SensorEntityDescription( 34 | key="TEMPERATURE_C", 35 | device_class=SensorDeviceClass.TEMPERATURE, 36 | native_unit_of_measurement=UnitOfTemperature.CELSIUS, 37 | state_class=SensorStateClass.MEASUREMENT, 38 | suggested_display_precision=2, 39 | ), 40 | UnitOfPrecipitationDepth.MILLIMETERS: SensorEntityDescription( 41 | key="RAIN_MM", 42 | device_class=SensorDeviceClass.PRECIPITATION, 43 | native_unit_of_measurement=UnitOfPrecipitationDepth.MILLIMETERS, 44 | state_class=SensorStateClass.TOTAL_INCREASING, 45 | suggested_display_precision=2, 46 | ), 47 | UnitOfSpeed.METERS_PER_SECOND: SensorEntityDescription( 48 | key="WIND_SPEED_MS", 49 | device_class=SensorDeviceClass.WIND_SPEED, 50 | native_unit_of_measurement=UnitOfSpeed.METERS_PER_SECOND, 51 | state_class=SensorStateClass.MEASUREMENT, 52 | suggested_display_precision=2, 53 | ), 54 | UnitOfIrradiance.WATTS_PER_SQUARE_METER: SensorEntityDescription( 55 | key="SOLAR_RADIATION_W_M2", 56 | device_class=SensorDeviceClass.IRRADIANCE, 57 | native_unit_of_measurement=UnitOfIrradiance.WATTS_PER_SQUARE_METER, 58 | state_class=SensorStateClass.MEASUREMENT, 59 | suggested_display_precision=2, 60 | ), 61 | LIGHT_LUX: SensorEntityDescription( 62 | key="LIGHT_LUX", 63 | device_class=SensorDeviceClass.ILLUMINANCE, 64 | native_unit_of_measurement=LIGHT_LUX, 65 | state_class=SensorStateClass.MEASUREMENT, 66 | suggested_display_precision=2, 67 | ), 68 | UnitOfVolumetricFlux.MILLIMETERS_PER_HOUR: SensorEntityDescription( 69 | key="RAIN_RATE_MM", 70 | device_class=SensorDeviceClass.PRECIPITATION_INTENSITY, 71 | native_unit_of_measurement=UnitOfVolumetricFlux.MILLIMETERS_PER_HOUR, 72 | state_class=SensorStateClass.MEASUREMENT, 73 | suggested_display_precision=2, 74 | ), 75 | DEGREE: SensorEntityDescription( 76 | key="WIND_DIRECTION", 77 | native_unit_of_measurement=DEGREE, 78 | state_class=SensorStateClass.MEASUREMENT, 79 | ), 80 | PERCENTAGE: SensorEntityDescription( 81 | key="HUMIDITY", 82 | device_class=SensorDeviceClass.HUMIDITY, 83 | native_unit_of_measurement=PERCENTAGE, 84 | state_class=SensorStateClass.MEASUREMENT, 85 | suggested_display_precision=0, 86 | ), 87 | UV_INDEX: SensorEntityDescription( 88 | key="UV_INDEX", 89 | state_class=SensorStateClass.MEASUREMENT, 90 | ), 91 | } 92 | 93 | CONF_WUNDERGROUND_PROXY: Final = "weatherunderground_proxy" 94 | CONF_WEATHERCLOUD_PROXY: Final = "weathercloud_proxy" 95 | CONF_DNS_SERVERS: Final = "dns_servers" 96 | -------------------------------------------------------------------------------- /custom_components/cloudweatherproxy/aiocloudweather/proxy.py: -------------------------------------------------------------------------------- 1 | """Proxy for forwarding data to the CloudWeather APIs.""" 2 | 3 | from enum import Enum 4 | import logging 5 | from aiohttp import web, TCPConnector, ClientSession, ClientResponse 6 | from urllib.parse import parse_qsl, urlencode 7 | from aiohttp.resolver import AsyncResolver 8 | 9 | _LOGGER = logging.getLogger(__name__) 10 | 11 | 12 | class DataSink(Enum): 13 | """Data sinks for the CloudWeather API.""" 14 | 15 | WUNDERGROUND = "wunderground" 16 | WEATHERCLOUD = "weathercloud" 17 | 18 | 19 | class CloudWeatherProxy: 20 | """Proxy for forwarding data to the CloudWeather API.""" 21 | 22 | def __init__(self, proxied_sinks: list[DataSink], dns_servers: list[str]): 23 | """Initialize CloudWeatherProxy.""" 24 | resolver = AsyncResolver(nameservers=dns_servers) 25 | self.proxied_sinks = proxied_sinks 26 | self.session = ClientSession(connector=TCPConnector(resolver=resolver)) 27 | 28 | async def close(self): 29 | """Close the session.""" 30 | if not self.session.closed: 31 | await self.session.close() 32 | 33 | async def forward_wunderground(self, request: web.Request) -> ClientResponse: 34 | """Forward Wunderground data to their API.""" 35 | if not request.query_string: 36 | _LOGGER.error( 37 | "Wunderground request missing query string: %s", request.path 38 | ) 39 | raise web.HTTPBadRequest( 40 | text="Missing query string for Wunderground request") 41 | 42 | pairs = parse_qsl(request.query_string, keep_blank_values=True) 43 | query_string = urlencode(pairs, doseq=True) 44 | 45 | url = ( 46 | f"https://rtupdate.wunderground.com/weatherstation/updateweatherstation.php?{query_string}" 47 | ) 48 | _LOGGER.debug("Forwarding Wunderground data: %s", url) 49 | return await self.session.get(url) 50 | 51 | async def forward_weathercloud(self, request: web.Request) -> ClientResponse: 52 | """Forward WeatherCloud data to their API.""" 53 | new_path = request.path[request.path.index("/v01/set"):] 54 | # If there's no dataset in the path (e.g. just /v01/set) and no 55 | # query string, nothing useful can be forwarded. 56 | path_after = new_path[len("/v01/set"):] 57 | if (not path_after or path_after == "/") and not request.query_string: 58 | _LOGGER.error( 59 | "WeatherCloud request missing payload: %s", request.path 60 | ) 61 | raise web.HTTPBadRequest( 62 | text="Missing path payload for WeatherCloud request") 63 | 64 | url = f"https://api.weathercloud.net{new_path}" 65 | if request.query_string: 66 | pairs = parse_qsl(request.query_string, keep_blank_values=True) 67 | query_string = urlencode(pairs, doseq=True) 68 | url = f"{url}?{query_string}" 69 | _LOGGER.debug("Forwarding WeatherCloud data: %s", url) 70 | return await self.session.get(url) 71 | 72 | async def forward(self, sink: DataSink, request: web.Request) -> ClientResponse: 73 | """Forward data to the CloudWeather API.""" 74 | if ( 75 | sink == DataSink.WUNDERGROUND 76 | and DataSink.WUNDERGROUND in self.proxied_sinks 77 | ): 78 | return await self.forward_wunderground(request) 79 | if ( 80 | sink == DataSink.WEATHERCLOUD 81 | and DataSink.WEATHERCLOUD in self.proxied_sinks 82 | ): 83 | return await self.forward_weathercloud(request) 84 | 85 | raise ValueError(f"Sink {sink} is not enabled or supported") 86 | -------------------------------------------------------------------------------- /custom_components/cloudweatherproxy/__init__.py: -------------------------------------------------------------------------------- 1 | """The Wunderground Receiver integration.""" 2 | 3 | from __future__ import annotations 4 | 5 | import logging 6 | import contextlib 7 | from dataclasses import dataclass, field 8 | 9 | from .aiocloudweather import CloudWeatherListener 10 | from .aiocloudweather.proxy import DataSink 11 | from .aiocloudweather.utils import LimitedSizeQueue, DiagnosticsLogHandler 12 | 13 | from homeassistant.config_entries import ConfigEntry 14 | from homeassistant.const import Platform 15 | from homeassistant.core import HomeAssistant 16 | 17 | from .const import CONF_DNS_SERVERS, CONF_WEATHERCLOUD_PROXY, CONF_WUNDERGROUND_PROXY, DOMAIN 18 | from .web import WeathercloudReceiver, WundergroundReceiver 19 | from .entity import CloudWeatherEntity 20 | 21 | PLATFORMS: list[Platform] = [Platform.SENSOR] 22 | 23 | _LOGGER = logging.getLogger(__name__) 24 | 25 | 26 | @dataclass 27 | class RuntimeData: 28 | """Runtime data for Cloud Weather Proxy.""" 29 | 30 | listener: CloudWeatherListener 31 | known_sensors: dict[str, CloudWeatherEntity] = field(default_factory=dict) 32 | 33 | 34 | CloudWeatherProxyConfigEntry = ConfigEntry[RuntimeData] 35 | 36 | 37 | @dataclass 38 | class DomainData: 39 | """Domain-wide data for Cloud Weather Proxy.""" 40 | 41 | log_queue: LimitedSizeQueue 42 | log_handler: DiagnosticsLogHandler 43 | 44 | 45 | async def async_setup_entry(hass: HomeAssistant, entry: CloudWeatherProxyConfigEntry) -> bool: 46 | """Set up Cloud Weather Proxy from a config entry.""" 47 | 48 | proxies = [ 49 | DataSink.WUNDERGROUND] if entry.data[CONF_WUNDERGROUND_PROXY] else [] 50 | proxies += [DataSink.WEATHERCLOUD] if entry.data[CONF_WEATHERCLOUD_PROXY] else [] 51 | 52 | dns_servers: list[str] = entry.data[CONF_DNS_SERVERS].split(",") 53 | 54 | _LOGGER.debug("Setting up Cloud Weather Proxy with %s and %s", 55 | proxies, dns_servers) 56 | cloudweather = CloudWeatherListener( 57 | proxy_sinks=proxies, dns_servers=dns_servers 58 | ) 59 | 60 | # Store per-entry runtime data 61 | entry.runtime_data = RuntimeData(listener=cloudweather) 62 | 63 | # Initialize domain-wide data on first entry setup 64 | if DOMAIN not in hass.data: 65 | # Per-HASS small queue to hold most recent integration logs for diagnostics 66 | per_hass_queue = LimitedSizeQueue(maxsize=100) 67 | 68 | # Attach integration-scoped diagnostics handler so logs are captured 69 | # and can be included in the HA diagnostics download. 70 | handler = DiagnosticsLogHandler(queue=per_hass_queue) 71 | # Attach to the package logger (custom_components.cloudweatherproxy) 72 | integration_logger = logging.getLogger(__package__) 73 | integration_logger.addHandler(handler) 74 | 75 | hass.data[DOMAIN] = DomainData( 76 | log_queue=per_hass_queue, log_handler=handler) 77 | 78 | await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) 79 | 80 | hass.http.register_view(WundergroundReceiver(cloudweather)) 81 | hass.http.register_view(WeathercloudReceiver(cloudweather)) 82 | 83 | return True 84 | 85 | 86 | async def async_unload_entry(hass: HomeAssistant, entry: CloudWeatherProxyConfigEntry) -> bool: 87 | """Unload a config entry.""" 88 | 89 | if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): 90 | # Cleanup listener resources (proxy session) 91 | await entry.runtime_data.listener.stop() 92 | 93 | # Check if this is the last config entry for this domain 94 | remaining_entries = [ 95 | e for e in hass.config_entries.async_entries(DOMAIN) if e.entry_id != entry.entry_id 96 | ] 97 | if not remaining_entries and DOMAIN in hass.data: 98 | # Detach diagnostics handler if present 99 | domain_data: DomainData = hass.data[DOMAIN] 100 | integration_logger = logging.getLogger(__package__) 101 | with contextlib.suppress(Exception): 102 | integration_logger.removeHandler(domain_data.log_handler) 103 | domain_data.log_handler.close() 104 | 105 | # Clean up domain-wide data 106 | hass.data.pop(DOMAIN) 107 | 108 | return unload_ok 109 | -------------------------------------------------------------------------------- /custom_components/cloudweatherproxy/aiocloudweather/utils.py: -------------------------------------------------------------------------------- 1 | """Utilities for aiocloudweather. 2 | 3 | This module provides two small helpers used across the package: 4 | - `LimitedSizeQueue`: a simple asyncio.Queue variant that discards the 5 | oldest item when full. 6 | - `resolve_caster` / `cast_value`: helpers to resolve typing hints 7 | (including Optional/Union) into a usable caster and cast values 8 | extracted from incoming requests. 9 | """ 10 | 11 | import asyncio 12 | import contextlib 13 | import logging 14 | import re 15 | from typing import Any, get_args 16 | from collections.abc import Callable 17 | 18 | 19 | class LimitedSizeQueue(asyncio.Queue): 20 | """Queue with fixed maximum size that drops oldest items when full. 21 | 22 | This is a tiny convenience used for in-memory log buffering. 23 | """ 24 | 25 | def put_nowait(self, item: Any) -> None: 26 | """Put `item` without blocking; drop oldest when the queue is full.""" 27 | if self.full(): 28 | with contextlib.suppress(Exception): 29 | self.get_nowait() 30 | super().put_nowait(item) 31 | 32 | 33 | # Public, shared in-memory log buffer for diagnostics. 34 | # Size chosen conservatively; integration also creates a smaller per-HASS queue. 35 | LOG_BUFFER: LimitedSizeQueue = LimitedSizeQueue(maxsize=500) 36 | 37 | 38 | def _mask_credentials(text: str) -> str: 39 | """Mask known credential patterns in `text` for diagnostics. 40 | 41 | - Replaces query parameters `ID` and `PASSWORD` with a fixed placeholder. 42 | - Replaces path segments `wid/` and `key/` with standardized values. 43 | - Replaces station IDs in log messages (e.g., "Found new station: xyz"). 44 | """ 45 | # Replace query params like ID=... and PASSWORD=... 46 | text = re.sub(r"(?i)(ID)=([^&\s]+)", r"\1=STATIONID", text) 47 | text = re.sub(r"(?i)(PASSWORD)=([^&\s]+)", r"\1=SECRET", text) 48 | 49 | # Replace path segments like /wid// and /key// 50 | text = re.sub(r"(?i)(/wid/)([^/\s]+)", r"\1STATIONID", text) 51 | text = re.sub(r"(?i)(/key/)([^/\s]+)", r"\1SECRET", text) 52 | 53 | # Replace station IDs in log messages like "Found new station: " 54 | text = re.sub(r"(?i)(station:\s+)([^\s]+)", r"\g<1>STATIONID", text) 55 | 56 | return text 57 | 58 | 59 | class DiagnosticsLogHandler(logging.Handler): 60 | """Logging handler that writes formatted, masked messages into a queue. 61 | 62 | The handler accepts an asyncio-compatible queue (implements `put_nowait`). 63 | """ 64 | 65 | def __init__(self, queue: LimitedSizeQueue | None = None) -> None: 66 | """Initialize the diagnostics log handler.""" 67 | super().__init__() 68 | self.queue = queue or LOG_BUFFER 69 | # Use a simple formatter if none provided 70 | self.setFormatter(logging.Formatter( 71 | "%(asctime)s %(levelname)s %(name)s: %(message)s")) 72 | 73 | def emit(self, record: logging.LogRecord) -> None: 74 | """Emit a log record after masking credentials.""" 75 | try: 76 | msg = self.format(record) 77 | msg = _mask_credentials(msg) 78 | # store the masked, formatted message 79 | with contextlib.suppress(Exception): 80 | # put_nowait will drop oldest when full 81 | self.queue.put_nowait(msg) 82 | except Exception: 83 | # Ensure logging doesn't raise 84 | self.handleError(record) 85 | 86 | 87 | def resolve_caster(type_hint: Any) -> Any: 88 | """Resolve a usable caster from a typing hint. 89 | 90 | Handles Optional/Union (PEP 604) by selecting the first non-None 91 | alternative. Returns the resolved type/caster; callers may check 92 | `callable()` on the result to determine if it can be invoked. 93 | """ 94 | args = get_args(type_hint) 95 | if args: 96 | non_none = [t for t in args if t is not type(None)] 97 | return non_none[0] if non_none else args[0] 98 | return type_hint 99 | 100 | 101 | def cast_value(type_hint: Any, value: Any) -> Any: 102 | """Cast `value` according to `type_hint` when possible. 103 | 104 | If the resolved caster is callable, it will be called with `value`. 105 | On any exception the original `value` is returned so callers can 106 | decide how to handle casting failures. 107 | """ 108 | caster: Callable[..., Any] | None = resolve_caster(type_hint) 109 | try: 110 | if callable(caster): 111 | return caster(value) 112 | except Exception: 113 | # Caller will log casting/validation failures; fall back to raw value 114 | pass 115 | return value 116 | -------------------------------------------------------------------------------- /custom_components/cloudweatherproxy/config_flow.py: -------------------------------------------------------------------------------- 1 | """Config flow for Cloud Weather Proxy.""" 2 | 3 | from __future__ import annotations 4 | 5 | import logging 6 | from typing import Any 7 | 8 | import voluptuous as vol 9 | 10 | from .aiocloudweather import CloudWeatherListener 11 | from .aiocloudweather.proxy import DataSink 12 | 13 | from yarl import URL 14 | 15 | from homeassistant.config_entries import ConfigFlow, ConfigFlowResult, ConfigEntry 16 | # from homeassistant.exceptions import HomeAssistantError 17 | from homeassistant.helpers.network import get_url 18 | 19 | from .const import CONF_WUNDERGROUND_PROXY, CONF_WEATHERCLOUD_PROXY, CONF_DNS_SERVERS, DOMAIN 20 | 21 | _LOGGER = logging.getLogger(__name__) 22 | 23 | 24 | class CloudWeatherProxyConfigFlow(ConfigFlow, domain=DOMAIN): 25 | """Config flow for the Cloud Weather Proxy.""" 26 | 27 | VERSION = 1 28 | 29 | # async def validate_input(hself, ass: HomeAssistant, data: dict[str, Any]) -> dict[str, Any]: 30 | # return {} 31 | 32 | async def async_step_user( 33 | self, user_input: dict[str, Any] | None = None 34 | ) -> ConfigFlowResult: 35 | """Handle the initial step.""" 36 | errors: dict[str, str] = {} 37 | if user_input is not None: 38 | if self._async_current_entries(): 39 | return self.async_abort(reason="single_instance") 40 | 41 | # await self.validate_input(self.hass, user_input) 42 | base_url = URL(get_url(self.hass)) 43 | assert base_url.host 44 | 45 | return self.async_create_entry( 46 | title="Cloud Weather Proxy", 47 | data=user_input, 48 | description_placeholders={ 49 | "address": base_url.host, "port": str(base_url.port) 50 | }, 51 | ) 52 | 53 | return self.async_show_form( 54 | step_id="user", 55 | data_schema=vol.Schema( 56 | { 57 | vol.Required(CONF_WUNDERGROUND_PROXY): bool, 58 | vol.Required(CONF_WEATHERCLOUD_PROXY): bool, 59 | vol.Optional(CONF_DNS_SERVERS, default="9.9.9.9"): str, 60 | } 61 | ), 62 | errors=errors, 63 | ) 64 | 65 | async def async_step_reconfigure(self, user_input: dict[str, Any] | None = None): 66 | """Add reconfigure step to allow to reconfigure a config entry.""" 67 | 68 | config_entry: ConfigEntry = self._get_reconfigure_entry() 69 | listener: CloudWeatherListener = config_entry.runtime_data.listener 70 | _LOGGER.debug("Reconfiguring Cloud Weather Proxy: %s", listener) 71 | current_proxy_settings = listener.get_active_proxies() or [] 72 | current_dns_servers = listener.get_dns_servers() 73 | _LOGGER.debug("Current configuration: %s and proxies %s", 74 | current_proxy_settings, current_dns_servers) 75 | 76 | if user_input is not None: 77 | _LOGGER.debug( 78 | "Reconfiguring Cloud Weather Proxy with %s", user_input) 79 | proxy_sinks: list[DataSink] = [] 80 | if user_input[CONF_WUNDERGROUND_PROXY]: 81 | proxy_sinks.append(DataSink.WUNDERGROUND) 82 | if user_input[CONF_WEATHERCLOUD_PROXY]: 83 | proxy_sinks.append(DataSink.WEATHERCLOUD) 84 | await listener.update_config( 85 | proxy_sinks=proxy_sinks or None, 86 | dns_servers=user_input[CONF_DNS_SERVERS].split(",") 87 | ) 88 | 89 | # Ensure the listener's internal state is consistent (aiocloudweather may not 90 | # update all internal fields on reconfiguration). 91 | listener.proxy_sinks = proxy_sinks or [] 92 | listener.dns_servers = user_input[CONF_DNS_SERVERS].split(",") 93 | listener.proxy_enabled = bool(proxy_sinks and len(proxy_sinks) > 0) 94 | if not proxy_sinks and listener.proxy: 95 | await listener.proxy.close() 96 | listener.proxy = None 97 | 98 | self._abort_if_unique_id_mismatch() 99 | return self.async_update_reload_and_abort( 100 | config_entry, 101 | data_updates={ 102 | CONF_WUNDERGROUND_PROXY: user_input[CONF_WUNDERGROUND_PROXY], 103 | CONF_WEATHERCLOUD_PROXY: user_input[CONF_WEATHERCLOUD_PROXY], 104 | CONF_DNS_SERVERS: user_input[CONF_DNS_SERVERS], 105 | }, 106 | ) 107 | 108 | return self.async_show_form( 109 | step_id="reconfigure", 110 | data_schema=vol.Schema( 111 | { 112 | vol.Required(CONF_WUNDERGROUND_PROXY, default=(DataSink.WUNDERGROUND in current_proxy_settings)): bool, 113 | vol.Required(CONF_WEATHERCLOUD_PROXY, default=(DataSink.WEATHERCLOUD in current_proxy_settings)): bool, 114 | vol.Optional(CONF_DNS_SERVERS, default=",".join(current_dns_servers)): str, 115 | } 116 | ), 117 | ) 118 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | Cloud Weather Proxy logo 3 | 4 | # Cloud Weather Proxy 5 | 6 | [![GitHub Release][releases-shield]][releases] 7 | [![GitHub Activity][commits-shield]][commits] 8 | ![Installs][installs] 9 | [![Project Maintenance][maintenance-shield]][maintainer] 10 | [![Mastodon][mastodon]][mastodon_profile] 11 | 12 | ## Description 13 | 14 | The Cloud Weather Proxy integration allows you to locally retrieve weather information from a weather station, that supports either Weather Underground or Weathercloud, and display it in Home Assistant. 15 | 16 | To use the integration an additional local setup including DNS and a forwarding HTTP server is required, as the destination URLs for the weather station need to be spoofed. 17 | 18 | This whole setup really only works with weather stations that use HTTP, i.e. unencrypted traffic. Any weather station that uses HTTPS will require a lot of more effort, unless they do not check the certificates. 19 | 20 | Generally though these weather stations use such simple TCP/HTTP libraries that they go for HTTP. Give it a try! 21 | 22 | Optionally the weather data can be passed to its indended destination. 23 | 24 | ## HomeAssistant 25 | 26 | **This integration will set up the following platforms.** 27 | 28 | | Platform | Description | 29 | | -------- | ----------------------------------- | 30 | | `sensor` | Show info from weather station API. | 31 | 32 | ## Installation 33 | 34 | 35 | ### Manual 36 | 37 | 1. Using the tool of choice open the directory (folder) for your HA configuration (where you find `configuration.yaml`). 38 | 1. If you do not have a `custom_components` directory (folder) there, you need to create it. 39 | 1. In the `custom_components` directory (folder) create a new folder called `cloudweatherproxy`. 40 | 1. Download _all_ the files from the `custom_components/cloudweatherproxy/` directory (folder) in this repository. 41 | 1. Place the files you downloaded in the new directory (folder) you created. 42 | 1. Restart Home Assistant 43 | 1. In the HA UI go to "Configuration" -> "Integrations" click "+" and search for "Cloud Weather Proxy" 44 | 45 | ### HACS 46 | 47 | #### Automatic installation 48 | 49 | [![Open your Home Assistant instance and open a repository inside the Home Assistant Community Store.][hacs-repo-badge]][hacs-install] 50 | 51 | 52 | #### Manual installation 53 | 1. Open the Home Assistant UI and navigate to the HACS section. 54 | 2. Click on "Integrations" in the HACS menu. 55 | 3. Click on the three dots in the top right corner and select "Custom repositories". 56 | 4. In the "Add custom repository" dialog, enter the URL of the custom component repository: `https://github.com/lhw/cloudweatherproxy`. 57 | 5. Select the category "Integration" and click "Add". 58 | 6. Once the repository is added, go back to the HACS menu and click on "Integrations" again. 59 | 7. Search for "Cloud Weather Proxy" and click on it. 60 | 8. Click on "Install" to install the custom component. 61 | 9. Restart Home Assistant to apply the changes. 62 | 63 | 64 | ### MITM/Spoofing Setup 65 | 66 | As the destination URLs are hard coded into most weather stations we need to spoof the DNS records in the local network. 67 | 68 | 1. Setup a local DNS server that allows overriding single entries. Some routers can do this built-in. If not simple setups like PiHole, CoreDNS and others allow for this. 69 | 2. Set the DNS server as default in your DHCP settings, if its not your router. 70 | 3. Setup a proxying HTTP server that forwards the domains and adds additional information, such as the HomeAssistant access token. 71 | 72 | An example setup is provided in the directory *examples*. It sets up a docker stack that uses Caddy and CoreDNS. Please ensure that port 80 and 53 are available on the IP you are assigning. 73 | 74 | * [docker-compose.yml](examples/docker-compose.yml) 75 | * Set the `HA_ACCESS_TOKEN` envionment variable to a permanent access token from HomeAssistant. 76 | * [Caddyfile](examples/Caddyfile) 77 | * Replace `` with your HomeAssistant address and port 78 | * [Corefile](examples/Corefile) 79 | * Replace `` with your MITM IP address, i.e. the server running the Caddy and CoreDNS. 80 | ## Configuration is done in the UI 81 | 82 | 83 | 84 | ## Contributions are welcome! 85 | 86 | If you want to contribute to this please read the [Contribution guidelines](CONTRIBUTING.md) 87 | 88 | *** 89 | 90 | [commits-shield]: https://img.shields.io/github/commit-activity/y/lhw/cloudweatherproxy.svg 91 | [commits]: https://github.com/lhw/cloudweatherproxy/commits/main 92 | [maintenance-shield]: https://img.shields.io/badge/maintainer-Lennart%20Weller%20%40lhw-blue.svg 93 | [maintainer]: https://github.com/lhw 94 | [releases-shield]: https://img.shields.io/github/release/lhw/cloudweatherproxy.svg 95 | [releases]: https://github.com/lhw/cloudweatherproxy/releases 96 | [mastodon]: https://img.shields.io/mastodon/follow/000048422?domain=https%3A%2F%2Fchaos.social 97 | [mastodon_profile]: https://chaos.social/@lhw 98 | [installs]: https://img.shields.io/badge/dynamic/json?logo=home-assistant&logoColor=ccc&label=usage&suffix=%20installs&cacheSeconds=15600&url=https://analytics.home-assistant.io/custom_integrations.json&query=$.cloudweatherproxy.total 99 | [hacs-repo-badge]: https://my.home-assistant.io/badges/hacs_repository.svg 100 | [hacs-install]: https://my.home-assistant.io/redirect/hacs_repository/?owner=lhw&repository=cloudweatherproxy&category=Integration 101 | -------------------------------------------------------------------------------- /custom_components/cloudweatherproxy/aiocloudweather/server.py: -------------------------------------------------------------------------------- 1 | """aioCloudWeather API server.""" 2 | 3 | from __future__ import annotations 4 | 5 | import logging 6 | import time 7 | from typing import Any, get_type_hints 8 | from collections.abc import Callable, Coroutine 9 | from copy import deepcopy 10 | from dataclasses import fields 11 | 12 | from aiohttp import web, ClientResponse 13 | 14 | from .proxy import CloudWeatherProxy, DataSink 15 | from .utils import cast_value 16 | from .station import ( 17 | WundergroundRawSensor, 18 | WeathercloudRawSensor, 19 | WeatherStation, 20 | ) 21 | 22 | _LOGGER = logging.getLogger(__name__) 23 | _CLOUDWEATHER_LISTEN_PORT = 49199 24 | 25 | 26 | class CloudWeatherListener: 27 | """CloudWeather Server API server.""" 28 | 29 | def __init__( 30 | self, 31 | port: int = _CLOUDWEATHER_LISTEN_PORT, 32 | proxy_sinks: list[DataSink] | None = None, 33 | dns_servers: list[str] | None = None, 34 | ): 35 | """Initialize CloudWeather Server.""" 36 | # API Constants 37 | self.port: int = port 38 | 39 | # Proxy functionality 40 | self.proxy: None | CloudWeatherProxy = None 41 | self.dns_servers: list[str] = dns_servers or ["9.9.9.9"] 42 | self.proxy_sinks: list[DataSink] = proxy_sinks or [] 43 | self.proxy_enabled: bool = bool(self.proxy_sinks) 44 | if self.proxy_enabled: 45 | self.proxy = CloudWeatherProxy(self.proxy_sinks, self.dns_servers) 46 | 47 | # webserver 48 | self.server: None | web.Server = None 49 | self.runner: None | web.ServerRunner = None 50 | self.site: None | web.TCPSite = None 51 | 52 | # internal data 53 | self.last_values: dict[str, WeatherStation] = {} 54 | self.last_updates: dict[str, float] = {} 55 | self.new_dataset_cb: list[ 56 | Callable[[WeatherStation], Coroutine[Any, Any, Any]] 57 | ] = [] 58 | 59 | # storage 60 | self.stations: list[str] = [] 61 | 62 | async def update_config( 63 | self, 64 | proxy_sinks: list[DataSink] | None = None, 65 | dns_servers: list[str] | None = None, 66 | ) -> None: 67 | """Update the proxy configuration.""" 68 | self.proxy_sinks = proxy_sinks or [] 69 | self.dns_servers = dns_servers or self.dns_servers or ["9.9.9.9"] 70 | self.proxy_enabled = bool(self.proxy_sinks) 71 | 72 | if self.proxy: 73 | await self.proxy.close() 74 | self.proxy = None 75 | 76 | if self.proxy_enabled: 77 | self.proxy = CloudWeatherProxy(self.proxy_sinks, self.dns_servers) 78 | 79 | def get_active_proxies(self) -> list[DataSink]: 80 | """Get the active proxies.""" 81 | return self.proxy_sinks or [] 82 | 83 | def get_dns_servers(self) -> list[str]: 84 | """Get the DNS servers.""" 85 | return self.dns_servers 86 | 87 | async def _new_dataset_cb(self, dataset: WeatherStation) -> None: 88 | """Call new dataset callbacks.""" 89 | for callback in self.new_dataset_cb: 90 | await callback(dataset) 91 | 92 | async def process_wunderground( 93 | self, data: dict[str, str | float] 94 | ) -> WeatherStation: 95 | """Process Wunderground data.""" 96 | 97 | dfields = { 98 | f.metadata["arg"]: f 99 | for f in fields(WundergroundRawSensor) 100 | if "arg" in f.metadata 101 | } 102 | type_hints = get_type_hints(WundergroundRawSensor) 103 | instance_data = {} 104 | 105 | for arg, field in dfields.items(): 106 | if arg in data: 107 | value = data[arg] 108 | try: 109 | instance_data[field.name] = cast_value( 110 | type_hints[field.name], value) 111 | except (ValueError, TypeError) as err: 112 | _LOGGER.warning( 113 | "Failed to cast field %s value '%s': %s", field.name, value, err 114 | ) 115 | 116 | return WeatherStation.from_wunderground(WundergroundRawSensor(**instance_data)) 117 | 118 | async def process_weathercloud(self, segments: list[str]) -> WeatherStation: 119 | """Process WeatherCloud data.""" 120 | 121 | data = dict(zip(segments[::2], segments[1::2])) 122 | dfields = { 123 | f.metadata["arg"]: f 124 | for f in fields(WeathercloudRawSensor) 125 | if "arg" in f.metadata 126 | } 127 | type_hints = get_type_hints(WeathercloudRawSensor) 128 | instance_data = {} 129 | 130 | for arg, field in dfields.items(): 131 | if arg in data: 132 | value = data[arg] 133 | try: 134 | instance_data[field.name] = cast_value( 135 | type_hints[field.name], value) 136 | except (ValueError, TypeError) as err: 137 | 138 | _LOGGER.warning( 139 | "Failed to cast field %s value '%s': %s", field.name, value, err 140 | ) 141 | 142 | return WeatherStation.from_weathercloud(WeathercloudRawSensor(**instance_data)) 143 | 144 | async def handler(self, request: web.BaseRequest) -> web.Response: 145 | """AIOHTTP handler for the API.""" 146 | 147 | if not isinstance(request, web.Request): 148 | raise web.HTTPBadRequest() 149 | 150 | if request.method != "GET" or request.path is None: 151 | raise web.HTTPBadRequest() 152 | 153 | station_id: str | None = None 154 | dataset: WeatherStation | None = None 155 | sink: DataSink | None = None 156 | if request.path.endswith("/weatherstation/updateweatherstation.php"): 157 | dataset = await self.process_wunderground(dict(request.query)) 158 | station_id = dataset.station_id 159 | sink = DataSink.WUNDERGROUND 160 | elif "/v01/set" in request.path: 161 | dataset_path = request.path.split("/v01/set/", 1)[1] 162 | path_segments = dataset_path.split("/") 163 | dataset = await self.process_weathercloud(path_segments) 164 | station_id = dataset.station_id 165 | sink = DataSink.WEATHERCLOUD 166 | else: 167 | return web.Response(status=404, text="Not Found") 168 | 169 | assert dataset is not None 170 | assert station_id is not None 171 | 172 | if station_id not in self.stations: 173 | _LOGGER.debug("Found new station: %s", station_id) 174 | self.stations.append(station_id) 175 | 176 | self.last_updates[station_id] = time.monotonic() 177 | dataset.update_time = self.last_updates[station_id] 178 | 179 | # The User-Agent is the only recognizable information we have aside from the IP 180 | # In case of the station at hand it just shows lwIP/2.1.2 of their IP stack 181 | user_agent = request.headers.get("User-Agent") 182 | if user_agent: 183 | dataset.station_sw_version = user_agent 184 | 185 | # Extract client IP from request just in case 186 | if "X-Real-IP" in request.headers: 187 | dataset.station_client_ip = request.headers["X-Real-IP"] 188 | else: 189 | dataset.station_client_ip = request.remote or "" 190 | 191 | try: 192 | await self._new_dataset_cb(dataset) 193 | except Exception as err: # pylint: disable=broad-except 194 | _LOGGER.warning("CloudWeather new dataset callback error: %s", err) 195 | 196 | if self.proxy and sink is not None: 197 | try: 198 | response: ClientResponse = await self.proxy.forward(sink, request) 199 | _LOGGER.debug( 200 | "CloudWeather proxy response[%d]: %s", 201 | response.status, 202 | await response.text(), 203 | ) 204 | except Exception as err: # pylint: disable=broad-except 205 | _LOGGER.warning("CloudWeather proxy error: %s", err) 206 | 207 | self.last_values[station_id] = deepcopy(dataset) 208 | return web.Response(text="OK") 209 | 210 | async def start(self) -> None: 211 | """Listen and process.""" 212 | 213 | self.server = web.Server(self.handler) 214 | self.runner = web.ServerRunner(self.server) 215 | await self.runner.setup() 216 | self.site = web.TCPSite(self.runner, port=self.port) 217 | await self.site.start() 218 | 219 | async def stop(self) -> None: 220 | """Stop listening.""" 221 | if self.site: 222 | await self.site.stop() 223 | if self.proxy: 224 | await self.proxy.close() 225 | -------------------------------------------------------------------------------- /custom_components/cloudweatherproxy/aiocloudweather/tests/data/weathercloud: -------------------------------------------------------------------------------- 1 | /v01/set/wid/12345/key/12345/bar/10101/temp/149/hum/95/wdir/265/wspd/3/dew/140/heat/149/rainrate/0/rain/6/uvi/0/solarrad/224 2 | /v01/set/wid/12345/key/12345/bar/10168/temp/168/hum/85/wdir/277/wspd/0/dew/142/heat/168/rainrate/0/rain/0/uvi/20/solarrad/2546 3 | /v01/set/wid/12345/key/12345/bar/10101/temp/151/hum/95/wdir/279/wspd/0/dew/142/heat/151/rainrate/0/rain/6/uvi/10/solarrad/552 4 | /v01/set/wid/12345/key/12345/bar/10166/temp/145/hum/93/wdir/292/wspd/0/dew/133/heat/145/rainrate/0/rain/45/uvi/0/solarrad/0 5 | /v01/set/wid/12345/key/12345/bar/10136/temp/138/hum/87/wdir/284/wspd/0/dew/116/heat/138/rainrate/0/rain/0/uvi/0/solarrad/0 6 | /v01/set/wid/12345/key/12345/bar/10123/temp/141/hum/98/wdir/91/wspd/6/dew/137/heat/141/rainrate/41/rain/148/uvi/0/solarrad/135 7 | /v01/set/wid/12345/key/12345/bar/10168/temp/129/hum/96/wdir/271/wspd/1/dew/122/heat/129/rainrate/0/rain/0/uvi/0/solarrad/0 8 | /v01/set/wid/12345/key/12345/bar/10112/temp/182/hum/87/wdir/77/wspd/3/dew/159/heat/182/rainrate/0/rain/6/uvi/20/solarrad/1169 9 | /v01/set/wid/12345/key/12345/bar/10152/temp/182/hum/85/wdir/273/wspd/0/dew/156/heat/182/rainrate/0/rain/38/uvi/20/solarrad/2268 10 | /v01/set/wid/12345/key/12345/bar/10110/temp/203/hum/77/wdir/26/wspd/1/dew/161/heat/203/rainrate/0/rain/6/uvi/10/solarrad/977 11 | /v01/set/wid/12345/key/12345/bar/10157/temp/194/hum/70/wdir/267/wspd/3/dew/137/heat/194/rainrate/0/rain/0/uvi/30/solarrad/3196 12 | /v01/set/wid/12345/key/12345/bar/10152/temp/191/hum/84/wdir/299/wspd/0/dew/163/heat/191/rainrate/0/rain/38/uvi/20/solarrad/2426 13 | /v01/set/wid/12345/key/12345/bar/10166/temp/146/hum/92/wdir/295/wspd/0/dew/133/heat/146/rainrate/0/rain/45/uvi/0/solarrad/0 14 | /v01/set/wid/12345/key/12345/bar/10168/temp/142/hum/95/wdir/286/wspd/0/dew/133/heat/142/rainrate/0/rain/45/uvi/0/solarrad/0 15 | /v01/set/wid/12345/key/12345/bar/10168/temp/127/hum/97/wdir/279/wspd/0/dew/122/heat/127/rainrate/0/rain/0/uvi/0/solarrad/3 16 | /v01/set/wid/12345/key/12345/bar/10166/temp/132/hum/96/wdir/265/wspd/2/dew/125/heat/132/rainrate/0/rain/0/uvi/0/solarrad/0 17 | /v01/set/wid/12345/key/12345/bar/10141/temp/140/hum/99/wdir/100/wspd/10/dew/138/heat/140/rainrate/0/rain/0/uvi/0/solarrad/0 18 | /v01/set/wid/12345/key/12345/bar/10157/temp/195/hum/70/wdir/267/wspd/0/dew/138/heat/195/rainrate/0/rain/0/uvi/20/solarrad/1391 19 | /v01/set/wid/12345/key/12345/bar/10166/temp/175/hum/82/wdir/254/wspd/8/dew/143/heat/175/rainrate/0/rain/0/uvi/30/solarrad/5263 20 | /v01/set/wid/12345/key/12345/bar/10156/temp/226/hum/60/wdir/252/wspd/3/dew/144/heat/226/rainrate/0/rain/0/uvi/20/solarrad/1445 21 | /v01/set/wid/12345/key/12345/bar/10106/temp/141/hum/90/wdir/301/wspd/0/dew/124/heat/141/rainrate/0/rain/0/uvi/0/solarrad/0 22 | /v01/set/wid/12345/key/12345/bar/10141/temp/120/hum/95/wdir/272/wspd/0/dew/112/heat/120/rainrate/0/rain/0/uvi/0/solarrad/0 23 | /v01/set/wid/12345/key/12345/bar/10157/temp/193/hum/71/wdir/324/wspd/0/dew/138/heat/193/rainrate/0/rain/0/uvi/20/solarrad/1760 24 | /v01/set/wid/12345/key/12345/bar/10129/temp/185/hum/77/wdir/255/wspd/1/dew/143/heat/185/rainrate/0/rain/0/uvi/10/solarrad/404 25 | /v01/set/wid/12345/key/12345/bar/10168/temp/149/hum/92/wdir/270/wspd/0/dew/136/heat/149/rainrate/0/rain/0/uvi/10/solarrad/718 26 | /v01/set/wid/12345/key/12345/bar/10161/temp/225/hum/69/wdir/280/wspd/1/dew/165/heat/225/rainrate/0/rain/0/uvi/50/solarrad/6744 27 | /v01/set/wid/12345/key/12345/bar/10170/temp/168/hum/86/wdir/266/wspd/1/dew/144/heat/168/rainrate/0/rain/0/uvi/20/solarrad/1656 28 | /v01/set/wid/12345/key/12345/bar/10168/temp/126/hum/97/wdir/302/wspd/0/dew/121/heat/126/rainrate/0/rain/0/uvi/0/solarrad/0 29 | /v01/set/wid/12345/key/12345/bar/10169/temp/202/hum/64/wdir/82/wspd/3/dew/131/heat/202/rainrate/0/rain/2/uvi/30/solarrad/2369 30 | /v01/set/wid/12345/key/12345/bar/10160/temp/152/hum/91/wdir/274/wspd/0/dew/137/heat/152/rainrate/0/rain/45/uvi/0/solarrad/12 31 | /v01/set/wid/12345/key/12345/bar/10097/temp/145/hum/90/wdir/257/wspd/5/dew/128/heat/145/rainrate/0/rain/0/uvi/0/solarrad/0 32 | /v01/set/wid/12345/key/12345/bar/10101/temp/149/hum/95/wdir/242/wspd/5/dew/140/heat/149/rainrate/0/rain/6/uvi/0/solarrad/245 33 | /v01/set/wid/12345/key/12345/bar/10157/temp/223/hum/65/wdir/252/wspd/0/dew/153/heat/223/rainrate/0/rain/0/uvi/30/solarrad/3688 34 | /v01/set/wid/12345/key/12345/bar/10139/temp/142/hum/99/wdir/102/wspd/1/dew/140/heat/142/rainrate/0/rain/229/uvi/0/solarrad/0 35 | /v01/set/wid/12345/key/12345/bar/10104/temp/142/hum/90/wdir/267/wspd/2/dew/125/heat/142/rainrate/0/rain/0/uvi/0/solarrad/0 36 | /v01/set/wid/12345/key/12345/bar/10144/temp/140/hum/99/wdir/107/wspd/0/dew/138/heat/140/rainrate/0/rain/2/uvi/0/solarrad/11 37 | /v01/set/wid/12345/key/12345/bar/10135/temp/146/hum/87/wdir/286/wspd/0/dew/124/heat/146/rainrate/0/rain/0/uvi/0/solarrad/0 38 | /v01/set/wid/12345/key/12345/bar/10133/temp/203/hum/70/wdir/270/wspd/5/dew/146/heat/203/rainrate/0/rain/0/uvi/10/solarrad/1361 39 | /v01/set/wid/12345/key/12345/bar/10143/temp/118/hum/97/wdir/273/wspd/0/dew/113/heat/118/rainrate/0/rain/0/uvi/0/solarrad/1 40 | /v01/set/wid/12345/key/12345/bar/10148/temp/133/hum/96/wdir/281/wspd/0/dew/126/heat/133/rainrate/0/rain/0/uvi/10/solarrad/823 41 | /v01/set/wid/12345/key/12345/bar/10144/temp/118/hum/97/wdir/273/wspd/0/dew/113/heat/118/rainrate/0/rain/0/uvi/0/solarrad/8 42 | /v01/set/wid/12345/key/12345/bar/10153/temp/224/hum/60/wdir/270/wspd/1/dew/142/heat/224/rainrate/0/rain/0/uvi/20/solarrad/1579 43 | /v01/set/wid/12345/key/12345/bar/10128/temp/193/hum/75/wdir/267/wspd/3/dew/147/heat/193/rainrate/0/rain/0/uvi/10/solarrad/738 44 | /v01/set/wid/12345/key/12345/bar/10123/temp/142/hum/98/wdir/99/wspd/7/dew/138/heat/142/rainrate/0/rain/148/uvi/0/solarrad/133 45 | /v01/set/wid/12345/key/12345/bar/10122/temp/154/hum/87/wdir/270/wspd/3/dew/132/heat/154/rainrate/0/rain/0/uvi/0/solarrad/0 46 | /v01/set/wid/12345/key/12345/bar/10156/temp/169/hum/94/wdir/94/wspd/0/dew/159/heat/169/rainrate/0/rain/2/uvi/20/solarrad/1497 47 | /v01/set/wid/12345/key/12345/bar/10136/temp/140/hum/89/wdir/270/wspd/0/dew/122/heat/140/rainrate/0/rain/0/uvi/0/solarrad/0 48 | /v01/set/wid/12345/key/12345/bar/10156/temp/169/hum/93/wdir/85/wspd/2/dew/157/heat/169/rainrate/0/rain/2/uvi/30/solarrad/4475 49 | /v01/set/wid/12345/key/12345/bar/10168/temp/141/hum/94/wdir/275/wspd/0/dew/131/heat/141/rainrate/0/rain/45/uvi/0/solarrad/0 50 | /v01/set/wid/12345/key/12345/bar/10181/temp/201/hum/64/wdir/112/wspd/4/dew/130/heat/201/rainrate/0/rain/2/uvi/20/solarrad/1878 51 | /v01/set/wid/12345/key/12345/bar/10156/temp/162/hum/89/wdir/313/wspd/1/dew/143/heat/162/rainrate/109/rain/25/uvi/0/solarrad/117 52 | /v01/set/wid/12345/key/12345/bar/10156/temp/184/hum/77/wdir/290/wspd/0/dew/142/heat/184/rainrate/0/rain/38/uvi/10/solarrad/336 53 | /v01/set/wid/12345/key/12345/bar/10104/temp/166/hum/92/wdir/277/wspd/0/dew/152/heat/166/rainrate/0/rain/6/uvi/10/solarrad/643 54 | /v01/set/wid/12345/key/12345/bar/10153/temp/194/hum/81/wdir/270/wspd/0/dew/160/heat/194/rainrate/0/rain/38/uvi/10/solarrad/787 55 | /v01/set/wid/12345/key/12345/bar/10130/temp/163/hum/81/wdir/287/wspd/0/dew/130/heat/163/rainrate/0/rain/0/uvi/0/solarrad/40 56 | /v01/set/wid/12345/key/12345/bar/10139/temp/139/hum/90/wdir/273/wspd/0/dew/122/heat/139/rainrate/0/rain/0/uvi/0/solarrad/0 57 | /v01/set/wid/12345/key/12345/bar/10153/temp/197/hum/82/wdir/310/wspd/0/dew/165/heat/197/rainrate/0/rain/38/uvi/10/solarrad/2163 58 | /v01/set/wid/12345/key/12345/bar/10154/temp/205/hum/68/wdir/292/wspd/0/dew/143/heat/205/rainrate/0/rain/0/uvi/30/solarrad/4002 59 | /v01/set/wid/12345/key/12345/bar/10136/temp/132/hum/89/wdir/284/wspd/0/dew/114/heat/132/rainrate/0/rain/0/uvi/0/solarrad/0 60 | /v01/set/wid/12345/key/12345/bar/10152/temp/157/hum/91/wdir/274/wspd/0/dew/142/heat/157/rainrate/0/rain/38/uvi/10/solarrad/721 61 | /v01/set/wid/12345/key/12345/bar/10169/temp/173/hum/82/wdir/257/wspd/3/dew/141/heat/173/rainrate/0/rain/0/uvi/20/solarrad/2149 62 | /v01/set/wid/12345/key/12345/bar/10123/temp/143/hum/97/wdir/106/wspd/3/dew/138/heat/143/rainrate/68/rain/128/uvi/0/solarrad/91 63 | /v01/set/wid/12345/key/12345/bar/10163/temp/185/hum/72/wdir/82/wspd/6/dew/133/heat/185/rainrate/0/rain/2/uvi/30/solarrad/3419 64 | /v01/set/wid/12345/key/12345/bar/10128/temp/159/hum/85/wdir/263/wspd/4/dew/133/heat/159/rainrate/0/rain/0/uvi/0/solarrad/0 65 | /v01/set/wid/12345/key/12345/bar/10169/temp/172/hum/82/wdir/189/wspd/5/dew/140/heat/172/rainrate/0/rain/0/uvi/20/solarrad/1298 66 | /v01/set/wid/12345/key/12345/bar/10114/temp/144/hum/88/wdir/263/wspd/5/dew/124/heat/144/rainrate/0/rain/0/uvi/0/solarrad/0 67 | /v01/set/wid/12345/key/12345/bar/10166/temp/128/hum/96/wdir/270/wspd/0/dew/121/heat/128/rainrate/0/rain/0/uvi/0/solarrad/0 68 | /v01/set/wid/12345/key/12345/bar/10156/temp/168/hum/91/wdir/117/wspd/4/dew/153/heat/168/rainrate/0/rain/2/uvi/20/solarrad/2510 69 | /v01/set/wid/12345/key/12345/bar/10149/temp/142/hum/99/wdir/107/wspd/0/dew/140/heat/142/rainrate/0/rain/2/uvi/0/solarrad/132 70 | /v01/set/wid/12345/key/12345/bar/10160/temp/152/hum/91/wdir/297/wspd/1/dew/137/heat/152/rainrate/0/rain/45/uvi/0/solarrad/13 71 | /v01/set/wid/12345/key/12345/bar/10138/temp/140/hum/99/wdir/111/wspd/5/dew/138/heat/140/rainrate/0/rain/0/uvi/0/solarrad/0 72 | /v01/set/wid/12345/key/12345/bar/10172/temp/205/hum/62/wdir/91/wspd/0/dew/129/heat/205/rainrate/0/rain/2/uvi/20/solarrad/1302 73 | /v01/set/wid/12345/key/12345/bar/10178/temp/198/hum/68/wdir/104/wspd/0/dew/137/heat/198/rainrate/0/rain/2/uvi/10/solarrad/652 74 | /v01/set/wid/12345/key/12345/bar/10143/temp/118/hum/96/wdir/273/wspd/0/dew/111/heat/118/rainrate/0/rain/0/uvi/0/solarrad/39 75 | /v01/set/wid/12345/key/12345/bar/10147/temp/140/hum/99/wdir/107/wspd/0/dew/138/heat/140/rainrate/0/rain/2/uvi/0/solarrad/29 76 | /v01/set/wid/12345/key/12345/bar/10166/temp/139/hum/94/wdir/265/wspd/2/dew/129/heat/139/rainrate/0/rain/0/uvi/20/solarrad/429 77 | /v01/set/wid/12345/key/12345/bar/10120/temp/147/hum/97/wdir/114/wspd/3/dew/142/heat/147/rainrate/54/rain/100/uvi/0/solarrad/73 78 | /v01/set/wid/12345/key/12345/bar/10144/temp/140/hum/99/wdir/107/wspd/0/dew/138/heat/140/rainrate/0/rain/2/uvi/0/solarrad/0 79 | /v01/set/wid/12345/key/12345/bar/10111/temp/180/hum/87/wdir/110/wspd/1/dew/157/heat/180/rainrate/0/rain/6/uvi/20/solarrad/1434 80 | /v01/set/wid/12345/key/12345/bar/10169/temp/132/hum/96/wdir/282/wspd/1/dew/125/heat/132/rainrate/0/rain/0/uvi/0/solarrad/0 81 | /v01/set/wid/12345/key/12345/bar/10167/temp/138/hum/96/wdir/278/wspd/0/dew/131/heat/138/rainrate/0/rain/45/uvi/0/solarrad/0 82 | /v01/set/wid/12345/key/12345/bar/10170/temp/159/hum/89/wdir/263/wspd/0/dew/140/heat/159/rainrate/0/rain/0/uvi/20/solarrad/1431 83 | /v01/set/wid/12345/key/12345/bar/10131/temp/202/hum/71/wdir/246/wspd/4/dew/147/heat/202/rainrate/0/rain/0/uvi/10/solarrad/1248 84 | /v01/set/wid/12345/key/12345/bar/10166/temp/188/hum/68/wdir/134/wspd/15/dew/127/heat/188/rainrate/0/rain/2/uvi/70/solarrad/7089 85 | /v01/set/wid/12345/key/12345/bar/10099/temp/150/hum/94/wdir/247/wspd/4/dew/140/heat/150/rainrate/13/rain/4/uvi/0/solarrad/138 86 | /v01/set/wid/12345/key/12345/bar/10168/temp/169/hum/84/wdir/250/wspd/3/dew/141/heat/169/rainrate/0/rain/0/uvi/20/solarrad/2960 87 | /v01/set/wid/12345/key/12345/bar/10155/temp/198/hum/78/wdir/283/wspd/1/dew/158/heat/198/rainrate/0/rain/38/uvi/10/solarrad/1082 88 | /v01/set/wid/12345/key/12345/bar/10130/temp/167/hum/79/wdir/282/wspd/0/dew/130/heat/167/rainrate/0/rain/0/uvi/0/solarrad/75 89 | /v01/set/wid/12345/key/12345/bar/10128/temp/159/hum/85/wdir/249/wspd/3/dew/133/heat/159/rainrate/0/rain/0/uvi/0/solarrad/0 90 | /v01/set/wid/12345/key/12345/bar/10102/temp/166/hum/92/wdir/287/wspd/0/dew/152/heat/166/rainrate/0/rain/6/uvi/20/solarrad/1259 91 | /v01/set/wid/12345/key/12345/bar/10111/temp/171/hum/87/wdir/94/wspd/3/dew/149/heat/171/rainrate/0/rain/6/uvi/0/solarrad/145 92 | /v01/set/wid/12345/key/12345/bar/10114/temp/181/hum/88/wdir/164/wspd/0/dew/160/heat/181/rainrate/0/rain/6/uvi/10/solarrad/654 93 | /v01/set/wid/12345/key/12345/bar/10168/temp/126/hum/97/wdir/286/wspd/0/dew/121/heat/126/rainrate/0/rain/0/uvi/0/solarrad/0 94 | /v01/set/wid/12345/key/12345/bar/10166/temp/223/hum/60/wdir/65/wspd/5/dew/141/heat/223/rainrate/0/rain/2/uvi/30/solarrad/2506 95 | /v01/set/wid/12345/key/12345/bar/10166/temp/130/hum/97/wdir/259/wspd/0/dew/125/heat/130/rainrate/0/rain/0/uvi/0/solarrad/139 96 | /v01/set/wid/12345/key/12345/bar/10147/temp/140/hum/99/wdir/107/wspd/0/dew/138/heat/140/rainrate/0/rain/2/uvi/0/solarrad/5 97 | /v01/set/wid/12345/key/12345/bar/10167/temp/200/hum/74/wdir/268/wspd/4/dew/152/heat/200/rainrate/0/rain/0/uvi/50/solarrad/6324 98 | /v01/set/wid/12345/key/12345/bar/10147/temp/140/hum/99/wdir/107/wspd/0/dew/138/heat/140/rainrate/0/rain/2/uvi/0/solarrad/73 99 | /v01/set/wid/12345/key/12345/bar/10133/temp/203/hum/70/wdir/236/wspd/17/dew/146/heat/203/rainrate/0/rain/0/uvi/20/solarrad/1514 100 | /v01/set/wid/12345/key/12345/bar/10157/temp/223/hum/63/wdir/248/wspd/0/dew/149/heat/223/rainrate/0/rain/0/uvi/30/solarrad/3714 -------------------------------------------------------------------------------- /custom_components/cloudweatherproxy/aiocloudweather/station.py: -------------------------------------------------------------------------------- 1 | """The module parses incoming weather data from various sources into a common format.""" 2 | 3 | from dataclasses import dataclass, field, fields 4 | from enum import Enum 5 | import logging 6 | from typing import Final 7 | 8 | from .conversion import ( 9 | fahrenheit_to_celsius, 10 | in_to_mm, 11 | inhg_to_hpa, 12 | mph_to_ms, 13 | ) 14 | from .const import ( 15 | DEGREE, 16 | LIGHT_LUX, 17 | PERCENTAGE, 18 | UV_INDEX, 19 | UnitOfIrradiance, 20 | UnitOfPrecipitationDepth, 21 | UnitOfPressure, 22 | UnitOfSpeed, 23 | UnitOfTemperature, 24 | UnitOfVolumetricFlux, 25 | ) 26 | 27 | _LOGGER = logging.getLogger(__name__) 28 | 29 | 30 | class WeatherstationVendor(Enum): 31 | """The weather station cloud vendor.""" 32 | 33 | WUNDERGROUND = "Weather Underground" 34 | WEATHERCLOUD = "Weathercloud.net" 35 | 36 | 37 | @dataclass 38 | class WundergroundRawSensor: 39 | """Wunderground sensor parsed from query string.""" 40 | 41 | # /weatherstation/updateweatherstation.php?ID=12345&PASSWORD=12345&dateutc=2024-5-18+16%3A42%3A43&baromin=29.92&tempf=72.5&humidity=44&dewptf=49.2&rainin=0&dailyrainin=0&winddir=249&windspeedmph=2.0&windgustmph=2.7&UV=2&solarRadiation=289.2 42 | # Additional fields from https://www.openhab.org/addons/bindings/wundergroundupdatereceiver/ 43 | station_id: str = field(metadata={"arg": "ID"}) 44 | station_key: str = field(metadata={"arg": "PASSWORD"}) 45 | 46 | date_utc: str | None = field( 47 | default=None, 48 | metadata={"arg": "dateutc", 49 | "format_string": "%Y-%m-%d+%H%%3A%M%%3A%S"}, 50 | ) 51 | 52 | barometer: float | None = field( 53 | default=None, metadata={"unit": UnitOfPressure.INHG, "arg": "baromin"} 54 | ) 55 | temperature: float | None = field( 56 | default=None, metadata={"unit": UnitOfTemperature.FAHRENHEIT, "arg": "tempf"} 57 | ) 58 | humidity: float | None = field( 59 | default=None, metadata={"unit": PERCENTAGE, "arg": "humidity"} 60 | ) 61 | indoortemperature: float | None = field( 62 | default=None, 63 | metadata={"unit": UnitOfTemperature.FAHRENHEIT, "arg": "indoortempf"}, 64 | ) 65 | indoorhumidity: float | None = field( 66 | default=None, metadata={"unit": PERCENTAGE, "arg": "indoorhumidity"} 67 | ) 68 | 69 | dewpoint: float | None = field( 70 | default=None, metadata={"unit": UnitOfTemperature.FAHRENHEIT, "arg": "dewptf"} 71 | ) 72 | rain: float | None = field( 73 | default=None, 74 | metadata={"unit": UnitOfPrecipitationDepth.INCHES, "arg": "rainin"}, 75 | ) 76 | dailyrain: float | None = field( 77 | default=None, 78 | metadata={"unit": UnitOfPrecipitationDepth.INCHES, 79 | "arg": "dailyrainin"}, 80 | ) 81 | winddirection: float | None = field( 82 | default=None, metadata={"unit": DEGREE, "arg": "winddir"} 83 | ) 84 | windspeed: float | None = field( 85 | default=None, 86 | metadata={"unit": UnitOfSpeed.MILES_PER_HOUR, "arg": "windspeedmph"}, 87 | ) 88 | windgustspeed: float | None = field( 89 | default=None, 90 | metadata={"unit": UnitOfSpeed.MILES_PER_HOUR, "arg": "windgustmph"}, 91 | ) 92 | windgustdirection: float | None = field( 93 | default=None, metadata={"unit": DEGREE, "arg": "windgustdir"} 94 | ) 95 | windspeedavg: float | None = field( 96 | default=None, metadata={"unit": UnitOfSpeed.MILES_PER_HOUR, "arg": "windspdmph_avg2m"} 97 | ) 98 | winddirectionavg: float | None = field( 99 | default=None, metadata={"unit": DEGREE, "arg": "winddir_avg2m"} 100 | ) 101 | windgustspeed10m: float | None = field( 102 | default=None, metadata={"unit": UnitOfSpeed.MILES_PER_HOUR, "arg": "windgustmph_10m"} 103 | ) 104 | windgustdirection10m: float | None = field( 105 | default=None, metadata={"unit": DEGREE, "arg": "windgustdir_10m"} 106 | ) 107 | windchill: float | None = field( 108 | default=None, metadata={"unit": UnitOfTemperature.FAHRENHEIT, "arg": "windchillf"} 109 | ) 110 | absbarometer: float | None = field( 111 | default=None, metadata={"unit": UnitOfPressure.INHG, "arg": "absbaromin"} 112 | ) 113 | weeklyrain: float | None = field( 114 | default=None, metadata={"unit": UnitOfPrecipitationDepth.INCHES, "arg": "weeklyrainin"} 115 | ) 116 | monthlyrain: float | None = field( 117 | default=None, metadata={"unit": UnitOfPrecipitationDepth.INCHES, "arg": "monthlyrainin"} 118 | ) 119 | uv: int | None = field(default=None, metadata={ 120 | "unit": UV_INDEX, "arg": "UV"}) 121 | solarradiation: float | None = field( 122 | default=None, 123 | metadata={ 124 | "unit": LIGHT_LUX, 125 | "factor": 1000, 126 | "arg": "solarRadiation", 127 | }, 128 | ) 129 | solarradiation_new: float | None = field( 130 | default=None, 131 | metadata={ 132 | "unit": UnitOfIrradiance.WATTS_PER_SQUARE_METER, 133 | "arg": "solarradiation", 134 | "alternative_for": "solarradiation", 135 | }, 136 | ) 137 | 138 | 139 | @dataclass 140 | class WeathercloudRawSensor: 141 | """WeatherCloud API sensor parsed from the query path.""" 142 | 143 | # /v01/set/wid/12345/key/12345/bar/10130/temp/164/hum/80/wdir/288/wspd/0/dew/129/heat/164/rainrate/0/rain/0/uvi/0/solarrad/47 144 | # Additional fields from https://groups.google.com/g/weewx-development/c/hLuHxl_W6kM/m/wQ61KIhNBoQJ 145 | station_id: str = field(metadata={"arg": "wid"}) 146 | station_key: str = field(metadata={"arg": "key"}) 147 | 148 | barometer: int | None = field( 149 | default=None, metadata={"unit": UnitOfPressure.HPA, "arg": "bar"} 150 | ) 151 | temperature: int | None = field( 152 | default=None, metadata={"unit": UnitOfTemperature.CELSIUS, "arg": "temp"} 153 | ) 154 | temperature2: int | None = field( 155 | default=None, metadata={"unit": UnitOfTemperature.CELSIUS, "arg": "temp02"} 156 | ) 157 | humidity: int | None = field(default=None, metadata={ 158 | "unit": PERCENTAGE, "arg": "hum"}) 159 | humidity2: int | None = field(default=None, metadata={ 160 | "unit": PERCENTAGE, "arg": "hum02"}) 161 | indoortemperature: int | None = field( 162 | default=None, metadata={"unit": UnitOfTemperature.CELSIUS, "arg": "tempin"} 163 | ) 164 | indoorhumidity: int | None = field( 165 | default=None, metadata={"unit": PERCENTAGE, "arg": "humin"} 166 | ) 167 | dewpoint: int | None = field( 168 | default=None, metadata={"unit": UnitOfTemperature.CELSIUS, "arg": "dew"} 169 | ) 170 | dewpointindoor: int | None = field( 171 | default=None, metadata={"unit": UnitOfTemperature.CELSIUS, "arg": "dewin"} 172 | ) 173 | dailyrain: int | None = field( 174 | default=None, 175 | metadata={"unit": UnitOfPrecipitationDepth.MILLIMETERS, "arg": "rain"}, 176 | ) 177 | rain: int | None = field( 178 | default=None, 179 | metadata={ 180 | "unit": UnitOfVolumetricFlux.MILLIMETERS_PER_HOUR, 181 | "arg": "rainrate", 182 | }, 183 | ) 184 | heatindex: int | None = field( 185 | default=None, metadata={"unit": UnitOfTemperature.CELSIUS, "arg": "heat"} 186 | ) 187 | heatindexindoor: int | None = field( 188 | default=None, metadata={"unit": UnitOfTemperature.CELSIUS, "arg": "heatin"} 189 | ) 190 | temphumiditywind: int | None = field( 191 | default=None, metadata={"unit": UnitOfTemperature.CELSIUS, "arg": "thw"} 192 | ) 193 | winddirection: int | None = field(default=None, metadata={ 194 | "unit": DEGREE, "arg": "wdir"}) 195 | windspeed: int | None = field( 196 | default=None, metadata={"unit": UnitOfSpeed.METERS_PER_SECOND, "arg": "wspd"} 197 | ) 198 | windgustspeed: int | None = field( 199 | default=None, metadata={"unit": UnitOfSpeed.METERS_PER_SECOND, "arg": "wspdhi"} 200 | ) 201 | windchill: int | None = field( 202 | default=None, metadata={"unit": UnitOfTemperature.CELSIUS, "arg": "chill"} 203 | ) 204 | windspeedavg: int | None = field( 205 | default=None, metadata={"unit": UnitOfSpeed.METERS_PER_SECOND, "arg": "wspdavg"} 206 | ) 207 | uv: int | None = field(default=None, metadata={ 208 | "unit": UV_INDEX, "arg": "uvi"}) 209 | solarradiation: int | None = field( 210 | default=None, 211 | metadata={ 212 | "unit": UnitOfIrradiance.WATTS_PER_SQUARE_METER, 213 | "arg": "solarrad", 214 | }, 215 | ) 216 | visibility: int | None = field(default=None, metadata={ 217 | "unit": "km", "arg": "vis"}) 218 | 219 | 220 | IMPERIAL_TO_METRIC: Final = { 221 | UnitOfPressure.INHG: inhg_to_hpa, 222 | UnitOfTemperature.FAHRENHEIT: fahrenheit_to_celsius, 223 | UnitOfPrecipitationDepth.INCHES: in_to_mm, 224 | UnitOfSpeed.MILES_PER_HOUR: mph_to_ms, 225 | } 226 | 227 | 228 | @dataclass 229 | class Sensor: 230 | """Represents a weather sensor.""" 231 | 232 | name: str 233 | 234 | value: float 235 | unit: str 236 | 237 | 238 | @dataclass 239 | class WeatherStation: 240 | """Represents a weather station with various sensor readings.""" 241 | 242 | station_id: str 243 | station_key: str 244 | vendor: WeatherstationVendor 245 | 246 | station_sw_version: str | None = field(default=None) 247 | station_client_ip: str | None = field(default=None) 248 | update_time: float | None = field(default=None) 249 | 250 | date_utc: str | None = field(default=None) 251 | 252 | barometer: Sensor | None = field(default=None, metadata={ 253 | "name": "Absolute Pressure"}) 254 | temperature: Sensor | None = field(default=None, metadata={ 255 | "name": "Outdoor Temperature"}) 256 | temperature2: Sensor | None = field( 257 | default=None, metadata={"name": "Outdoor Temperature #2"} 258 | ) 259 | temphumiditywind: Sensor | None = field( 260 | default=None, 261 | metadata={"name": "Temperature-Humidity-Wind Index or 'feels like'"}, 262 | ) 263 | humidity: Sensor | None = field(default=None, metadata={ 264 | "name": "Outdoor Humidity"}) 265 | humidity2: Sensor | None = field(default=None, metadata={ 266 | "name": "Outdoor Humidity #2"}) 267 | indoortemperature: Sensor | None = field( 268 | default=None, metadata={"name": "Indoor Temperature"} 269 | ) 270 | indoorhumidity: Sensor | None = field(default=None, metadata={ 271 | "name": "Indoor Humidity"}) 272 | dewpoint: Sensor | None = field(default=None, metadata={ 273 | "name": "Outdoor Dewpoint"}) 274 | dewpointindoor: Sensor | None = field(default=None, metadata={ 275 | "name": "Indoor Dewpoint"}) 276 | rain: Sensor | None = field(default=None, metadata={"name": "Rain Rate"}) 277 | dailyrain: Sensor | None = field(default=None, metadata={ 278 | "name": "Daily Rain Rate"}) 279 | winddirection: Sensor | None = field(default=None, metadata={ 280 | "name": "Wind Direction"}) 281 | windspeed: Sensor | None = field( 282 | default=None, metadata={"name": "Wind Speed"}) 283 | windgustspeed: Sensor | None = field( 284 | default=None, metadata={"name": "Wind Gust"}) 285 | windgustdirection: Sensor | None = field( 286 | default=None, metadata={"name": "Wind Gust Direction"} 287 | ) 288 | windspeedavg: Sensor | None = field(default=None, metadata={ 289 | "name": "Wind Speed Average"}) 290 | winddirectionavg: Sensor | None = field( 291 | default=None, metadata={"name": "Wind Direction Average"}) 292 | windgustspeed10m: Sensor | None = field( 293 | default=None, metadata={"name": "Wind Gust Speed (10m)"}) 294 | windgustdirection10m: Sensor | None = field( 295 | default=None, metadata={"name": "Wind Gust Direction (10m)"}) 296 | windchill: Sensor | None = field( 297 | default=None, metadata={"name": "Wind Chill"}) 298 | absbarometer: Sensor | None = field(default=None, metadata={ 299 | "name": "Absolute Pressure"}) 300 | weeklyrain: Sensor | None = field(default=None, metadata={ 301 | "name": "Weekly Rain Rate"}) 302 | monthlyrain: Sensor | None = field(default=None, metadata={ 303 | "name": "Monthly Rain Rate"}) 304 | uv: Sensor | None = field(default=None, metadata={"name": "UV Index"}) 305 | solarradiation: Sensor | None = field(default=None, metadata={ 306 | "name": "Solar Radiation"}) 307 | visibility: Sensor | None = field( 308 | default=None, metadata={"name": "Visibility"}) 309 | heatindex: Sensor | None = field( 310 | default=None, metadata={"name": "Heat Index"}) 311 | heatindexindoor: Sensor | None = field( 312 | default=None, metadata={"name": "Indoor Heat Index"} 313 | ) 314 | 315 | @staticmethod 316 | def from_wunderground(data: WundergroundRawSensor) -> "WeatherStation": 317 | """Convert raw sensor data from the Wunderground API into a WeatherStation object. 318 | 319 | Args: 320 | data (WundergroundRawSensor): The raw sensor data from the Wunderground API. 321 | 322 | Returns: 323 | WeatherStation: The converted WeatherStation object. 324 | 325 | Raises: 326 | TypeError: If there is an error converting the sensor data. 327 | 328 | """ 329 | sensor_data = {} 330 | for sensor_field in fields(data): 331 | if sensor_field.name in ("station_id", "station_key"): 332 | continue 333 | value = getattr(data, sensor_field.name) 334 | if value is None: 335 | continue 336 | 337 | value = value * sensor_field.metadata.get("factor", 1) 338 | unit = sensor_field.metadata.get("unit") 339 | # metadata.get returns Any; mypy may complain when using it as a dict key 340 | conversion_func = IMPERIAL_TO_METRIC.get( 341 | unit) # type: ignore[arg-type] 342 | sensor_name = sensor_field.metadata.get( 343 | "alternative_for", sensor_field.name 344 | ) 345 | if conversion_func: 346 | try: 347 | converted_value = conversion_func(value) 348 | except TypeError as e: 349 | _LOGGER.error( 350 | "Failed to convert %s from %s to %s: %s[%s] -> %s", 351 | sensor_field, 352 | unit, 353 | conversion_func.unit, 354 | value, 355 | type(value), 356 | e, 357 | ) 358 | continue 359 | sensor_data[sensor_name] = Sensor( 360 | name=sensor_name, 361 | value=converted_value, 362 | unit=str(conversion_func.unit), 363 | ) 364 | else: 365 | sensor_data[sensor_name] = Sensor( 366 | name=sensor_name, 367 | value=value, 368 | unit=str(unit) if unit else "", 369 | ) 370 | from typing import Any, cast 371 | 372 | return WeatherStation( 373 | station_id=data.station_id, 374 | station_key=data.station_key, 375 | vendor=WeatherstationVendor.WUNDERGROUND, 376 | **cast(dict[str, Any], sensor_data), 377 | ) 378 | 379 | @staticmethod 380 | def from_weathercloud(data: WeathercloudRawSensor) -> "WeatherStation": 381 | """Convert raw sensor data from the Weathercloud.net API into a WeatherStation object. 382 | 383 | Args: 384 | data (WeathercloudRawSensor): The raw sensor data from the Weathercloud.net API. 385 | 386 | Returns: 387 | WeatherStation: The converted WeatherStation object. 388 | 389 | Raises: 390 | TypeError: If there is an error converting the sensor data. 391 | 392 | """ 393 | sensor_data = {} 394 | for sensor_field in fields(data): 395 | if sensor_field.name in ("station_id", "station_key"): 396 | continue 397 | value = getattr(data, sensor_field.name) 398 | if value is None: 399 | continue 400 | 401 | # value = sensor_field.type(value) # No idea why this is needed 402 | unit = sensor_field.metadata.get("unit") 403 | if unit not in [PERCENTAGE, DEGREE]: 404 | # All values are shifted by 10 405 | value = float(value) / 10 406 | 407 | sensor_data[sensor_field.name] = Sensor( 408 | name=sensor_field.name, 409 | value=value, 410 | unit=str(unit) if unit else "", 411 | ) 412 | 413 | from typing import Any, cast 414 | 415 | return WeatherStation( 416 | station_id=str(data.station_id), 417 | station_key=str(data.station_key), 418 | vendor=WeatherstationVendor.WEATHERCLOUD, 419 | **cast(dict[str, Any], sensor_data), 420 | ) 421 | -------------------------------------------------------------------------------- /custom_components/cloudweatherproxy/aiocloudweather/tests/data/wunderground: -------------------------------------------------------------------------------- 1 | /weatherstation/updateweatherstation.php?ID=12345&PASSWORD=12345&dateutc=2024-5-20+3%3A55%3A56&baromin=29.95&tempf=53.2&humidity=97&dewptf=52.2&rainin=0&dailyrainin=0&winddir=272&windspeedmph=0&windgustmph=0&UV=0&solarRadiation=0.5 2 | /weatherstation/updateweatherstation.php?ID=12345&PASSWORD=12345&dateutc=2024-5-23+15%3A18%3A47&baromin=30.05&tempf=68.3&humidity=68&dewptf=57.2&rainin=0&dailyrainin=0&winddir=181&windspeedmph=0.5&windgustmph=1.1&UV=2&solarRadiation=123.5 3 | /weatherstation/updateweatherstation.php?ID=12345&PASSWORD=12345&dateutc=2024-5-23+5%3A18%3A24&baromin=29.96&tempf=57.5&humidity=99&dewptf=57.2&rainin=0&dailyrainin=0&winddir=107&windspeedmph=0&windgustmph=0&UV=0&solarRadiation=12.3 4 | /weatherstation/updateweatherstation.php?ID=12345&PASSWORD=12345&dateutc=2024-5-20+19%3A16%3A17&baromin=29.98&tempf=63.5&humidity=80&dewptf=57.2&rainin=0&dailyrainin=0.15&winddir=282&windspeedmph=0&windgustmph=0&UV=1&solarRadiation=13.5 5 | /weatherstation/updateweatherstation.php?ID=12345&PASSWORD=12345&dateutc=2024-5-20+10%3A55%3A57&baromin=30.00&tempf=71.5&humidity=64&dewptf=58.5&rainin=0&dailyrainin=0&winddir=256&windspeedmph=0.3&windgustmph=0.3&UV=4&solarRadiation=419.2 6 | /weatherstation/updateweatherstation.php?ID=12345&PASSWORD=12345&dateutc=2024-5-21+8%3A27%3A18&baromin=30.03&tempf=63.2&humidity=83&dewptf=57.9&rainin=0&dailyrainin=0&winddir=287&windspeedmph=0.4&windgustmph=0.8&UV=2&solarRadiation=245.1 7 | /weatherstation/updateweatherstation.php?ID=12345&PASSWORD=12345&dateutc=2024-5-19+0%3A25%3A23&baromin=29.92&tempf=57.2&humidity=81&dewptf=51.4&rainin=0&dailyrainin=0&winddir=282&windspeedmph=0&windgustmph=0&UV=0&solarRadiation=0 8 | /weatherstation/updateweatherstation.php?ID=12345&PASSWORD=12345&dateutc=2024-5-21+14%3A14%3A47&baromin=29.95&tempf=71.4&humidity=70&dewptf=60.9&rainin=0&dailyrainin=0&winddir=171&windspeedmph=0&windgustmph=0.8&UV=2&solarRadiation=79.9 9 | /weatherstation/updateweatherstation.php?ID=12345&PASSWORD=12345&dateutc=2024-5-19+1%3A53%3A11&baromin=29.92&tempf=55.0&humidity=86&dewptf=50.9&rainin=0&dailyrainin=0&winddir=280&windspeedmph=0&windgustmph=0&UV=0&solarRadiation=0 10 | /weatherstation/updateweatherstation.php?ID=12345&PASSWORD=12345&dateutc=2024-5-21+10%3A21%3A44&baromin=30.02&tempf=68.0&humidity=74&dewptf=59.2&rainin=0&dailyrainin=0&winddir=275&windspeedmph=1.5&windgustmph=2.7&UV=5&solarRadiation=623.5 11 | /weatherstation/updateweatherstation.php?ID=12345&PASSWORD=12345&dateutc=2024-5-22+23%3A52%3A39&baromin=29.95&tempf=57.2&humidity=99&dewptf=56.7&rainin=0&dailyrainin=0&winddir=112&windspeedmph=0.8&windgustmph=1.1&UV=0&solarRadiation=0 12 | /weatherstation/updateweatherstation.php?ID=12345&PASSWORD=12345&dateutc=2024-5-20+10%3A27%3A9&baromin=30.00&tempf=68.9&humidity=67&dewptf=57.2&rainin=0&dailyrainin=0&winddir=306&windspeedmph=0.5&windgustmph=1.1&UV=2&solarRadiation=216.0 13 | /weatherstation/updateweatherstation.php?ID=12345&PASSWORD=12345&dateutc=2024-5-19+19%3A17%3A42&baromin=29.88&tempf=63.0&humidity=76&dewptf=55.4&rainin=0&dailyrainin=0&winddir=285&windspeedmph=0&windgustmph=0&UV=0&solarRadiation=12.5 14 | /weatherstation/updateweatherstation.php?ID=12345&PASSWORD=12345&dateutc=2024-5-19+12%3A0%3A36&baromin=29.90&tempf=71.9&humidity=56&dewptf=55.2&rainin=0&dailyrainin=0&winddir=239&windspeedmph=0.7&windgustmph=1.6&UV=8&solarRadiation=764.4 15 | /weatherstation/updateweatherstation.php?ID=12345&PASSWORD=12345&dateutc=2024-5-22+3%3A57%3A8&baromin=29.81&tempf=58.4&humidity=90&dewptf=55.4&rainin=0&dailyrainin=0&winddir=263&windspeedmph=1.1&windgustmph=1.6&UV=0&solarRadiation=0.4 16 | /weatherstation/updateweatherstation.php?ID=12345&PASSWORD=12345&dateutc=2024-5-20+18%3A29%3A58&baromin=29.98&tempf=67.5&humidity=78&dewptf=60.4&rainin=0&dailyrainin=0.15&winddir=304&windspeedmph=0.7&windgustmph=0.8&UV=1&solarRadiation=108.0 17 | /weatherstation/updateweatherstation.php?ID=12345&PASSWORD=12345&dateutc=2024-5-19+21%3A6%3A18&baromin=29.93&tempf=58.0&humidity=87&dewptf=54.0&rainin=0&dailyrainin=0&winddir=286&windspeedmph=0&windgustmph=0&UV=0&solarRadiation=0 18 | /weatherstation/updateweatherstation.php?ID=12345&PASSWORD=12345&dateutc=2024-5-19+4%3A3%3A40&baromin=29.89&tempf=53.5&humidity=89&dewptf=50.2&rainin=0&dailyrainin=0&winddir=278&windspeedmph=0.3&windgustmph=0.8&UV=0&solarRadiation=0.8 19 | /weatherstation/updateweatherstation.php?ID=12345&PASSWORD=12345&dateutc=2024-5-21+6%3A43%3A30&baromin=30.02&tempf=58.2&humidity=93&dewptf=56.0&rainin=0&dailyrainin=0&winddir=263&windspeedmph=0&windgustmph=0.8&UV=1&solarRadiation=77.5 20 | /weatherstation/updateweatherstation.php?ID=12345&PASSWORD=12345&dateutc=2024-5-20+9%3A44%3A40&baromin=29.98&tempf=66.1&humidity=71&dewptf=56.4&rainin=0&dailyrainin=0&winddir=311&windspeedmph=0&windgustmph=0.3&UV=2&solarRadiation=164.8 21 | /weatherstation/updateweatherstation.php?ID=12345&PASSWORD=12345&dateutc=2024-5-23+2%3A55%3A13&baromin=29.95&tempf=57.2&humidity=99&dewptf=57.0&rainin=0&dailyrainin=0&winddir=107&windspeedmph=0&windgustmph=0&UV=0&solarRadiation=0 22 | /weatherstation/updateweatherstation.php?ID=12345&PASSWORD=12345&dateutc=2024-5-20+20%3A6%3A41&baromin=29.98&tempf=59.7&humidity=92&dewptf=57.2&rainin=0&dailyrainin=0.18&winddir=284&windspeedmph=0&windgustmph=0&UV=0&solarRadiation=1.7 23 | /weatherstation/updateweatherstation.php?ID=12345&PASSWORD=12345&dateutc=2024-5-19+13%3A38%3A46&baromin=29.89&tempf=68.3&humidity=66&dewptf=56.4&rainin=0&dailyrainin=0&winddir=261&windspeedmph=0.3&windgustmph=0.3&UV=2&solarRadiation=83.1 24 | /weatherstation/updateweatherstation.php?ID=12345&PASSWORD=12345&dateutc=2024-5-21+18%3A9%3A21&baromin=29.90&tempf=66.3&humidity=75&dewptf=58.0&rainin=0&dailyrainin=0&winddir=261&windspeedmph=0.8&windgustmph=1.1&UV=1&solarRadiation=72.6 25 | /weatherstation/updateweatherstation.php?ID=12345&PASSWORD=12345&dateutc=2024-5-22+20%3A50%3A28&baromin=29.93&tempf=57.2&humidity=99&dewptf=57.0&rainin=0&dailyrainin=0.88&winddir=91&windspeedmph=0&windgustmph=0&UV=0&solarRadiation=0 26 | /weatherstation/updateweatherstation.php?ID=12345&PASSWORD=12345&dateutc=2024-5-21+10%3A13%3A34&baromin=30.02&tempf=66.9&humidity=76&dewptf=59.0&rainin=0&dailyrainin=0&winddir=241&windspeedmph=0.6&windgustmph=0.8&UV=5&solarRadiation=659.4 27 | /weatherstation/updateweatherstation.php?ID=12345&PASSWORD=12345&dateutc=2024-5-21+11%3A12%3A8&baromin=30.00&tempf=71.4&humidity=68&dewptf=60.2&rainin=0&dailyrainin=0&winddir=276&windspeedmph=0.9&windgustmph=4.0&UV=7&solarRadiation=697.7 28 | /weatherstation/updateweatherstation.php?ID=12345&PASSWORD=12345&dateutc=2024-5-22+23%3A19%3A52&baromin=29.95&tempf=57.2&humidity=99&dewptf=57.0&rainin=0&dailyrainin=0&winddir=110&windspeedmph=0&windgustmph=0.8&UV=0&solarRadiation=0 29 | /weatherstation/updateweatherstation.php?ID=12345&PASSWORD=12345&dateutc=2024-5-22+11%3A0%3A17&baromin=29.85&tempf=70.5&humidity=75&dewptf=62.0&rainin=0&dailyrainin=0.01&winddir=345&windspeedmph=0&windgustmph=0&UV=2&solarRadiation=152.8 30 | /weatherstation/updateweatherstation.php?ID=12345&PASSWORD=12345&dateutc=2024-5-22+7%3A44%3A0&baromin=29.82&tempf=59.0&humidity=95&dewptf=57.5&rainin=0&dailyrainin=0.01&winddir=278&windspeedmph=0.4&windgustmph=0.8&UV=1&solarRadiation=67.6 31 | /weatherstation/updateweatherstation.php?ID=12345&PASSWORD=12345&dateutc=2024-5-23+11%3A57%3A36&baromin=30.03&tempf=68.5&humidity=63&dewptf=55.4&rainin=0&dailyrainin=0&winddir=130&windspeedmph=0.4&windgustmph=0.8&UV=2&solarRadiation=171.5 32 | /weatherstation/updateweatherstation.php?ID=12345&PASSWORD=12345&dateutc=2024-5-22+4%3A58%3A48&baromin=29.81&tempf=59.0&humidity=90&dewptf=55.9&rainin=0&dailyrainin=0&winddir=272&windspeedmph=0.3&windgustmph=1.1&UV=0&solarRadiation=8.8 33 | /weatherstation/updateweatherstation.php?ID=12345&PASSWORD=12345&dateutc=2024-5-21+10%3A35%3A21&baromin=30.02&tempf=70.3&humidity=70&dewptf=59.9&rainin=0&dailyrainin=0&winddir=257&windspeedmph=0.6&windgustmph=0.8&UV=6&solarRadiation=653.0 34 | /weatherstation/updateweatherstation.php?ID=12345&PASSWORD=12345&dateutc=2024-5-21+17%3A18%3A54&baromin=29.92&tempf=68.6&humidity=70&dewptf=58.4&rainin=0&dailyrainin=0&winddir=230&windspeedmph=0.7&windgustmph=1.1&UV=1&solarRadiation=125.5 35 | /weatherstation/updateweatherstation.php?ID=12345&PASSWORD=12345&dateutc=2024-5-23+5%3A52%3A31&baromin=29.97&tempf=57.9&humidity=99&dewptf=57.5&rainin=0&dailyrainin=0&winddir=109&windspeedmph=0&windgustmph=0&UV=1&solarRadiation=40.5 36 | /weatherstation/updateweatherstation.php?ID=12345&PASSWORD=12345&dateutc=2024-5-21+13%3A6%3A37&baromin=29.97&tempf=71.5&humidity=69&dewptf=60.7&rainin=0&dailyrainin=0&winddir=287&windspeedmph=1.1&windgustmph=1.1&UV=2&solarRadiation=91.0 37 | /weatherstation/updateweatherstation.php?ID=12345&PASSWORD=12345&dateutc=2024-5-22+16%3A59%3A19&baromin=29.88&tempf=58.2&humidity=97&dewptf=57.2&rainin=0.31&dailyrainin=0.43&winddir=246&windspeedmph=1.3&windgustmph=2.4&UV=0&solarRadiation=7.6 38 | /weatherstation/updateweatherstation.php?ID=12345&PASSWORD=12345&dateutc=2024-5-20+5%3A26%3A7&baromin=29.96&tempf=54.0&humidity=97&dewptf=53.2&rainin=0&dailyrainin=0&winddir=289&windspeedmph=0&windgustmph=0.3&UV=0&solarRadiation=14.3 39 | /weatherstation/updateweatherstation.php?ID=12345&PASSWORD=12345&dateutc=2024-5-20+15%3A54%3A39&baromin=29.98&tempf=59.7&humidity=91&dewptf=57.0&rainin=0&dailyrainin=0.15&winddir=274&windspeedmph=0&windgustmph=0&UV=1&solarRadiation=38.2 40 | /weatherstation/updateweatherstation.php?ID=12345&PASSWORD=12345&dateutc=2024-5-22+22%3A18%3A11&baromin=29.94&tempf=57.2&humidity=99&dewptf=57.0&rainin=0&dailyrainin=0.91&winddir=104&windspeedmph=0.5&windgustmph=1.6&UV=0&solarRadiation=0 41 | /weatherstation/updateweatherstation.php?ID=12345&PASSWORD=12345&dateutc=2024-5-21+0%3A57%3A18&baromin=30.03&tempf=55.7&humidity=96&dewptf=54.5&rainin=0&dailyrainin=0&winddir=272&windspeedmph=0&windgustmph=0&UV=0&solarRadiation=0 42 | /weatherstation/updateweatherstation.php?ID=12345&PASSWORD=12345&dateutc=2024-5-20+23%3A29%3A56&baromin=30.03&tempf=56.2&humidity=96&dewptf=55.0&rainin=0&dailyrainin=0&winddir=270&windspeedmph=0&windgustmph=0&UV=0&solarRadiation=0 43 | /weatherstation/updateweatherstation.php?ID=12345&PASSWORD=12345&dateutc=2024-5-20+14%3A18%3A45&baromin=29.98&tempf=64.5&humidity=79&dewptf=57.7&rainin=0&dailyrainin=0&winddir=0&windspeedmph=0&windgustmph=0&UV=1&solarRadiation=39.5 44 | /weatherstation/updateweatherstation.php?ID=12345&PASSWORD=12345&dateutc=2024-5-22+13%3A45%3A18&baromin=29.87&tempf=64.5&humidity=89&dewptf=61.0&rainin=0&dailyrainin=0.01&winddir=98&windspeedmph=0.4&windgustmph=0.8&UV=1&solarRadiation=82.0 45 | /weatherstation/updateweatherstation.php?ID=12345&PASSWORD=12345&dateutc=2024-5-21+5%3A47%3A18&baromin=30.01&tempf=56.0&humidity=96&dewptf=54.7&rainin=0&dailyrainin=0&winddir=290&windspeedmph=0&windgustmph=0.3&UV=1&solarRadiation=34.7 46 | /weatherstation/updateweatherstation.php?ID=12345&PASSWORD=12345&dateutc=2024-5-21+15%3A14%3A47&baromin=29.94&tempf=71.1&humidity=68&dewptf=60.0&rainin=0&dailyrainin=0&winddir=269&windspeedmph=0.7&windgustmph=1.6&UV=2&solarRadiation=104.0 47 | /weatherstation/updateweatherstation.php?ID=12345&PASSWORD=12345&dateutc=2024-5-19+23%3A9%3A41&baromin=29.93&tempf=56.7&humidity=89&dewptf=53.5&rainin=0&dailyrainin=0&winddir=284&windspeedmph=0&windgustmph=0&UV=0&solarRadiation=0 48 | /weatherstation/updateweatherstation.php?ID=12345&PASSWORD=12345&dateutc=2024-5-23+1%3A26%3A39&baromin=29.94&tempf=57.2&humidity=99&dewptf=57.0&rainin=0&dailyrainin=0&winddir=116&windspeedmph=0.9&windgustmph=0.9&UV=0&solarRadiation=0 49 | /weatherstation/updateweatherstation.php?ID=12345&PASSWORD=12345&dateutc=2024-5-20+1%3A2%3A45&baromin=29.94&tempf=56.0&humidity=92&dewptf=53.7&rainin=0&dailyrainin=0&winddir=273&windspeedmph=0&windgustmph=0&UV=0&solarRadiation=0 50 | /weatherstation/updateweatherstation.php?ID=12345&PASSWORD=12345&dateutc=2024-5-23+8%3A20%3A7&baromin=29.98&tempf=62.4&humidity=92&dewptf=59.9&rainin=0&dailyrainin=0&winddir=83&windspeedmph=0&windgustmph=0.3&UV=2&solarRadiation=208.6 51 | /weatherstation/updateweatherstation.php?ID=12345&PASSWORD=12345&dateutc=2024-5-22+18%3A13%3A6&baromin=29.89&tempf=57.5&humidity=98&dewptf=56.7&rainin=0&dailyrainin=0.57&winddir=128&windspeedmph=0&windgustmph=0&UV=0&solarRadiation=3.5 52 | /weatherstation/updateweatherstation.php?ID=12345&PASSWORD=12345&dateutc=2024-5-22+11%3A49%3A22&baromin=29.86&tempf=68.9&humidity=79&dewptf=62.0&rainin=0&dailyrainin=0.01&winddir=15&windspeedmph=0&windgustmph=0&UV=2&solarRadiation=99.5 53 | /weatherstation/updateweatherstation.php?ID=12345&PASSWORD=12345&dateutc=2024-5-23+1%3A2%3A8&baromin=29.94&tempf=57.2&humidity=99&dewptf=56.7&rainin=0&dailyrainin=0&winddir=114&windspeedmph=0.3&windgustmph=0.8&UV=0&solarRadiation=0 54 | /weatherstation/updateweatherstation.php?ID=12345&PASSWORD=12345&dateutc=2024-5-19+3%3A24%3A54&baromin=29.90&tempf=52.7&humidity=89&dewptf=49.5&rainin=0&dailyrainin=0&winddir=279&windspeedmph=0&windgustmph=0&UV=0&solarRadiation=0 55 | /weatherstation/updateweatherstation.php?ID=12345&PASSWORD=12345&dateutc=2024-5-20+16%3A35%3A32&baromin=29.97&tempf=61.5&humidity=90&dewptf=58.4&rainin=0&dailyrainin=0.15&winddir=273&windspeedmph=0&windgustmph=0&UV=2&solarRadiation=65.9 56 | /weatherstation/updateweatherstation.php?ID=12345&PASSWORD=12345&dateutc=2024-5-20+23%3A9%3A28&baromin=30.03&tempf=56.5&humidity=96&dewptf=55.4&rainin=0&dailyrainin=0&winddir=264&windspeedmph=0&windgustmph=0&UV=0&solarRadiation=0 57 | /weatherstation/updateweatherstation.php?ID=12345&PASSWORD=12345&dateutc=2024-5-19+15%3A46%3A46&baromin=29.88&tempf=67.8&humidity=59&dewptf=52.7&rainin=0&dailyrainin=0&winddir=202&windspeedmph=0&windgustmph=0&UV=2&solarRadiation=90.1 58 | /weatherstation/updateweatherstation.php?ID=12345&PASSWORD=12345&dateutc=2024-5-22+0%3A40%3A2&baromin=29.86&tempf=57.5&humidity=89&dewptf=54.2&rainin=0&dailyrainin=0&winddir=278&windspeedmph=1.3&windgustmph=1.6&UV=0&solarRadiation=0 59 | /weatherstation/updateweatherstation.php?ID=12345&PASSWORD=12345&dateutc=2024-5-21+13%3A17%3A31&baromin=29.97&tempf=72.0&humidity=68&dewptf=60.7&rainin=0&dailyrainin=0&winddir=249&windspeedmph=2.4&windgustmph=2.4&UV=2&solarRadiation=116.6 60 | /weatherstation/updateweatherstation.php?ID=12345&PASSWORD=12345&dateutc=2024-5-19+2%3A25%3A29&baromin=29.92&tempf=54.0&humidity=87&dewptf=50.0&rainin=0&dailyrainin=0&winddir=280&windspeedmph=0&windgustmph=0&UV=0&solarRadiation=0 61 | /weatherstation/updateweatherstation.php?ID=12345&PASSWORD=12345&dateutc=2024-5-20+23%3A21%3A44&baromin=30.03&tempf=56.4&humidity=96&dewptf=55.2&rainin=0&dailyrainin=0&winddir=268&windspeedmph=0&windgustmph=0&UV=0&solarRadiation=0 62 | /weatherstation/updateweatherstation.php?ID=12345&PASSWORD=12345&dateutc=2024-5-19+2%3A42%3A17&baromin=29.90&tempf=53.5&humidity=88&dewptf=50.0&rainin=0&dailyrainin=0&winddir=279&windspeedmph=0&windgustmph=0&UV=0&solarRadiation=0 63 | /weatherstation/updateweatherstation.php?ID=12345&PASSWORD=12345&dateutc=2024-5-19+10%3A23%3A43&baromin=29.92&tempf=70.0&humidity=62&dewptf=56.4&rainin=0&dailyrainin=0&winddir=319&windspeedmph=0.3&windgustmph=1.1&UV=2&solarRadiation=192.8 64 | /weatherstation/updateweatherstation.php?ID=12345&PASSWORD=12345&dateutc=2024-5-20+5%3A8%3A21&baromin=29.95&tempf=53.9&humidity=97&dewptf=53.0&rainin=0&dailyrainin=0&winddir=269&windspeedmph=0&windgustmph=0&UV=0&solarRadiation=16.2 65 | /weatherstation/updateweatherstation.php?ID=12345&PASSWORD=12345&dateutc=2024-5-19+17%3A39%3A21&baromin=29.87&tempf=64.6&humidity=68&dewptf=53.7&rainin=0&dailyrainin=0&winddir=282&windspeedmph=0&windgustmph=0&UV=1&solarRadiation=55.0 66 | /weatherstation/updateweatherstation.php?ID=12345&PASSWORD=12345&dateutc=2024-5-23+6%3A34%3A54&baromin=29.97&tempf=59.0&humidity=99&dewptf=58.7&rainin=0&dailyrainin=0&winddir=109&windspeedmph=0&windgustmph=0&UV=1&solarRadiation=52.5 67 | /weatherstation/updateweatherstation.php?ID=12345&PASSWORD=12345&dateutc=2024-5-21+19%3A32%3A32&baromin=29.89&tempf=63.2&humidity=80&dewptf=57.0&rainin=0&dailyrainin=0&winddir=166&windspeedmph=0.4&windgustmph=3.2&UV=0&solarRadiation=17.3 68 | /weatherstation/updateweatherstation.php?ID=12345&PASSWORD=12345&dateutc=2024-5-21+6%3A48%3A58&baromin=30.02&tempf=58.2&humidity=92&dewptf=55.9&rainin=0&dailyrainin=0&winddir=247&windspeedmph=1.7&windgustmph=2.4&UV=2&solarRadiation=55.9 69 | /weatherstation/updateweatherstation.php?ID=12345&PASSWORD=12345&dateutc=2024-5-20+8%3A38%3A54&baromin=29.98&tempf=65.0&humidity=76&dewptf=57.2&rainin=0&dailyrainin=0&winddir=17&windspeedmph=0&windgustmph=0.3&UV=3&solarRadiation=435.3 70 | /weatherstation/updateweatherstation.php?ID=12345&PASSWORD=12345&dateutc=2024-5-23+0%3A14%3A27&baromin=29.94&tempf=57.2&humidity=99&dewptf=56.7&rainin=0&dailyrainin=0&winddir=108&windspeedmph=1.1&windgustmph=1.6&UV=0&solarRadiation=0 71 | /weatherstation/updateweatherstation.php?ID=12345&PASSWORD=12345&dateutc=2024-5-19+8%3A31%3A21&baromin=29.93&tempf=64.0&humidity=78&dewptf=57.0&rainin=0&dailyrainin=0&winddir=257&windspeedmph=0.4&windgustmph=0.8&UV=2&solarRadiation=225.6 72 | /weatherstation/updateweatherstation.php?ID=12345&PASSWORD=12345&dateutc=2024-5-19+5%3A38%3A15&baromin=29.92&tempf=54.0&humidity=90&dewptf=51.0&rainin=0&dailyrainin=0&winddir=276&windspeedmph=0&windgustmph=0&UV=0&solarRadiation=8.1 73 | /weatherstation/updateweatherstation.php?ID=12345&PASSWORD=12345&dateutc=2024-5-23+11%3A39%3A47&baromin=30.02&tempf=70.8&humidity=60&dewptf=56.0&rainin=0&dailyrainin=0&winddir=141&windspeedmph=1.6&windgustmph=2.4&UV=7&solarRadiation=792.7 74 | /weatherstation/updateweatherstation.php?ID=12345&PASSWORD=12345&dateutc=2024-5-19+7%3A51%3A18&baromin=29.92&tempf=60.5&humidity=85&dewptf=55.9&rainin=0&dailyrainin=0&winddir=207&windspeedmph=0&windgustmph=0.3&UV=1&solarRadiation=131.1 75 | /weatherstation/updateweatherstation.php?ID=12345&PASSWORD=12345&dateutc=2024-5-20+19%3A13%3A33&baromin=29.98&tempf=63.7&humidity=79&dewptf=57.0&rainin=0&dailyrainin=0.15&winddir=309&windspeedmph=0&windgustmph=0&UV=1&solarRadiation=17.2 76 | /weatherstation/updateweatherstation.php?ID=12345&PASSWORD=12345&dateutc=2024-5-21+5%3A56%3A54&baromin=30.01&tempf=56.4&humidity=96&dewptf=55.2&rainin=0&dailyrainin=0&winddir=258&windspeedmph=0&windgustmph=0&UV=1&solarRadiation=33.9 77 | /weatherstation/updateweatherstation.php?ID=12345&PASSWORD=12345&dateutc=2024-5-22+5%3A4%3A18&baromin=29.81&tempf=59.0&humidity=90&dewptf=55.9&rainin=0&dailyrainin=0&winddir=275&windspeedmph=0.7&windgustmph=0.7&UV=0&solarRadiation=11.3 78 | /weatherstation/updateweatherstation.php?ID=12345&PASSWORD=12345&dateutc=2024-5-19+20%3A11%3A29&baromin=29.92&tempf=61.0&humidity=81&dewptf=55.2&rainin=0&dailyrainin=0&winddir=288&windspeedmph=0&windgustmph=0&UV=0&solarRadiation=3.7 79 | /weatherstation/updateweatherstation.php?ID=12345&PASSWORD=12345&dateutc=2024-5-23+14%3A9%3A10&baromin=30.04&tempf=68.9&humidity=62&dewptf=55.2&rainin=0&dailyrainin=0&winddir=101&windspeedmph=0.3&windgustmph=1.1&UV=2&solarRadiation=176.6 80 | /weatherstation/updateweatherstation.php?ID=12345&PASSWORD=12345&dateutc=2024-5-19+22%3A25%3A48&baromin=29.93&tempf=55.7&humidity=89&dewptf=52.5&rainin=0&dailyrainin=0&winddir=284&windspeedmph=0&windgustmph=0&UV=0&solarRadiation=0 81 | /weatherstation/updateweatherstation.php?ID=12345&PASSWORD=12345&dateutc=2024-5-22+7%3A24%3A56&baromin=29.82&tempf=58.7&humidity=96&dewptf=57.5&rainin=0&dailyrainin=0.01&winddir=270&windspeedmph=0&windgustmph=0&UV=0&solarRadiation=21.7 82 | /weatherstation/updateweatherstation.php?ID=12345&PASSWORD=12345&dateutc=2024-5-19+8%3A18%3A26&baromin=29.93&tempf=63.5&humidity=78&dewptf=56.4&rainin=0&dailyrainin=0&winddir=153&windspeedmph=0&windgustmph=0&UV=2&solarRadiation=190.6 83 | /weatherstation/updateweatherstation.php?ID=12345&PASSWORD=12345&dateutc=2024-5-23+14%3A11%3A55&baromin=30.04&tempf=69.0&humidity=61&dewptf=55.0&rainin=0&dailyrainin=0&winddir=109&windspeedmph=0.8&windgustmph=1.1&UV=2&solarRadiation=158.0 84 | /weatherstation/updateweatherstation.php?ID=12345&PASSWORD=12345&dateutc=2024-5-21+18%3A54%3A21&baromin=29.90&tempf=64.9&humidity=78&dewptf=57.7&rainin=0&dailyrainin=0&winddir=263&windspeedmph=1.6&windgustmph=2.0&UV=1&solarRadiation=33.7 85 | /weatherstation/updateweatherstation.php?ID=12345&PASSWORD=12345&dateutc=2024-5-23+1%3A29%3A22&baromin=29.94&tempf=57.2&humidity=99&dewptf=57.0&rainin=0&dailyrainin=0&winddir=116&windspeedmph=0&windgustmph=1.1&UV=0&solarRadiation=0 86 | /weatherstation/updateweatherstation.php?ID=12345&PASSWORD=12345&dateutc=2024-5-23+1%3A38%3A55&baromin=29.94&tempf=57.2&humidity=99&dewptf=57.0&rainin=0&dailyrainin=0&winddir=116&windspeedmph=0&windgustmph=0&UV=0&solarRadiation=0 87 | /weatherstation/updateweatherstation.php?ID=12345&PASSWORD=12345&dateutc=2024-5-22+10%3A4%3A22&baromin=29.85&tempf=65.0&humidity=85&dewptf=60.4&rainin=0&dailyrainin=0.01&winddir=281&windspeedmph=1.1&windgustmph=2.4&UV=3&solarRadiation=363.3 88 | /weatherstation/updateweatherstation.php?ID=12345&PASSWORD=12345&dateutc=2024-5-23+10%3A29%3A57&baromin=30.01&tempf=68.0&humidity=71&dewptf=58.0&rainin=0&dailyrainin=0&winddir=100&windspeedmph=0&windgustmph=1.1&UV=7&solarRadiation=782.0 89 | /weatherstation/updateweatherstation.php?ID=12345&PASSWORD=12345&dateutc=2024-5-22+6%3A16%3A48&baromin=29.82&tempf=59.0&humidity=92&dewptf=56.5&rainin=0&dailyrainin=0&winddir=271&windspeedmph=0&windgustmph=0.3&UV=0&solarRadiation=7.4 90 | /weatherstation/updateweatherstation.php?ID=12345&PASSWORD=12345&dateutc=2024-5-21+16%3A16%3A9&baromin=29.93&tempf=70.3&humidity=69&dewptf=59.5&rainin=0&dailyrainin=0&winddir=270&windspeedmph=0.5&windgustmph=0.8&UV=2&solarRadiation=124.0 91 | /weatherstation/updateweatherstation.php?ID=12345&PASSWORD=12345&dateutc=2024-5-22+4%3A57%3A26&baromin=29.81&tempf=59.0&humidity=90&dewptf=55.9&rainin=0&dailyrainin=0&winddir=253&windspeedmph=0.6&windgustmph=0.6&UV=0&solarRadiation=8.3 92 | /weatherstation/updateweatherstation.php?ID=12345&PASSWORD=12345&dateutc=2024-5-20+10%3A54%3A34&baromin=30.00&tempf=71.0&humidity=64&dewptf=58.0&rainin=0&dailyrainin=0&winddir=264&windspeedmph=1.1&windgustmph=1.1&UV=5&solarRadiation=478.0 93 | /weatherstation/updateweatherstation.php?ID=12345&PASSWORD=12345&dateutc=2024-5-21+13%3A1%3A9&baromin=29.97&tempf=71.9&humidity=71&dewptf=61.7&rainin=0&dailyrainin=0&winddir=251&windspeedmph=0.8&windgustmph=1.6&UV=2&solarRadiation=118.1 94 | /weatherstation/updateweatherstation.php?ID=12345&PASSWORD=12345&dateutc=2024-5-19+16%3A59%3A13&baromin=29.87&tempf=68.3&humidity=59&dewptf=53.4&rainin=0&dailyrainin=0&winddir=22&windspeedmph=0&windgustmph=2.4&UV=1&solarRadiation=135.8 95 | /weatherstation/updateweatherstation.php?ID=12345&PASSWORD=12345&dateutc=2024-5-20+1%3A6%3A51&baromin=29.94&tempf=55.9&humidity=92&dewptf=53.5&rainin=0&dailyrainin=0&winddir=273&windspeedmph=0&windgustmph=0&UV=0&solarRadiation=0 96 | /weatherstation/updateweatherstation.php?ID=12345&PASSWORD=12345&dateutc=2024-5-19+20%3A18%3A20&baromin=29.92&tempf=60.9&humidity=83&dewptf=55.7&rainin=0&dailyrainin=0&winddir=286&windspeedmph=0&windgustmph=0&UV=0&solarRadiation=2.7 97 | /weatherstation/updateweatherstation.php?ID=12345&PASSWORD=12345&dateutc=2024-5-21+1%3A26%3A0&baromin=30.03&tempf=55.7&humidity=96&dewptf=54.5&rainin=0&dailyrainin=0&winddir=286&windspeedmph=0&windgustmph=0.3&UV=0&solarRadiation=0 98 | /weatherstation/updateweatherstation.php?ID=12345&PASSWORD=12345&dateutc=2024-5-19+18%3A15%3A35&baromin=29.87&tempf=64.4&humidity=73&dewptf=55.4&rainin=0&dailyrainin=0&winddir=287&windspeedmph=0&windgustmph=0&UV=1&solarRadiation=35.7 99 | /weatherstation/updateweatherstation.php?ID=12345&PASSWORD=12345&dateutc=2024-5-22+10%3A26%3A11&baromin=29.85&tempf=70.6&humidity=76&dewptf=62.5&rainin=0&dailyrainin=0.01&winddir=316&windspeedmph=0.3&windgustmph=0.3&UV=6&solarRadiation=561.4 100 | /weatherstation/updateweatherstation.php?ID=12345&PASSWORD=12345&dateutc=2024-5-21+18%3A3%3A54&baromin=29.90&tempf=66.6&humidity=75&dewptf=58.4&rainin=0&dailyrainin=0&winddir=200&windspeedmph=0.4&windgustmph=0.8&UV=1&solarRadiation=73.6 101 | /weatherstation/updateweatherstation.php?ID=12345&PASSWORD=12345&indoortempf=67.4&indoorhumidity=40&tempf=41.3&humidity=91&dewptf=38.9&windchillf=41.3&absbaromin=29.08&baromin=29.39&windspeedmph=0.2&windgustmph=0.7&winddir=270&windspdmph_avg2m=0.0&winddir_avg2m=90&windgustmph_10m=0.2&windgustdir_10m=45&rainin=0.02&dailyrainin=0.04&weeklyrainin=0.59&monthlyrainin=0.59&solarradiation=9.57&UV=0&dateutc=2025-2-8%2021:41:13&action=updateraw&realtime=1&rtfreq=5 --------------------------------------------------------------------------------