├── .github └── workflows │ └── stale.yml ├── .gitignore ├── CHANGELOG.md ├── LICENSE ├── README.md ├── custom_components └── smartweatherudp │ ├── __init__.py │ ├── config_flow.py │ ├── const.py │ ├── manifest.json │ ├── sensor.py │ ├── strings.json │ └── translations │ ├── en.json │ └── sensor.en.json ├── hacs.json ├── info.md ├── logo.png └── pyproject.toml /.github/workflows/stale.yml: -------------------------------------------------------------------------------- 1 | # This workflow warns and then closes issues and PRs that have had no activity for a specified amount of time. 2 | # 3 | # You can adjust the behavior by modifying this file. 4 | # For more information, see: 5 | # https://github.com/actions/stale 6 | name: Close stale issues and PRs 7 | 8 | on: 9 | schedule: 10 | - cron: '0 0 * * *' 11 | 12 | jobs: 13 | stale: 14 | 15 | runs-on: ubuntu-latest 16 | 17 | steps: 18 | - uses: actions/stale@v4 19 | with: 20 | repo-token: ${{ secrets.GITHUB_TOKEN }} 21 | days-before-stale: 90 22 | days-before-close: 7 23 | days-before-pr-close: 14 24 | 25 | stale-issue-label: 'stale' 26 | exempt-issue-labels: 'question' 27 | stale-issue-message: > 28 | There hasn't been any activity on this issue recently. It has now been marked 29 | as stale and will automatically be closed if no further activity occurs. 30 | 31 | stale-pr-label: 'stale' 32 | stale-pr-message: > 33 | There hasn't been any activity on this PR recently. It has now been marked 34 | as stale and will automatically be closed if no further activity occurs. 35 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | *.egg-info/ 24 | .installed.cfg 25 | *.egg 26 | MANIFEST 27 | 28 | # PyInstaller 29 | # Usually these files are written by a python script from a template 30 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 31 | *.manifest 32 | *.spec 33 | 34 | # Installer logs 35 | pip-log.txt 36 | pip-delete-this-directory.txt 37 | 38 | # Unit test / coverage reports 39 | htmlcov/ 40 | .tox/ 41 | .coverage 42 | .coverage.* 43 | .cache 44 | nosetests.xml 45 | coverage.xml 46 | *.cover 47 | .hypothesis/ 48 | .pytest_cache/ 49 | 50 | # Translations 51 | *.mo 52 | *.pot 53 | 54 | # Django stuff: 55 | *.log 56 | local_settings.py 57 | db.sqlite3 58 | 59 | # Flask stuff: 60 | instance/ 61 | .webassets-cache 62 | 63 | # Scrapy stuff: 64 | .scrapy 65 | 66 | # Sphinx documentation 67 | docs/_build/ 68 | 69 | # PyBuilder 70 | target/ 71 | 72 | # Jupyter Notebook 73 | .ipynb_checkpoints 74 | 75 | # pyenv 76 | .python-version 77 | 78 | # celery beat schedule file 79 | celerybeat-schedule 80 | 81 | # SageMath parsed files 82 | *.sage.py 83 | 84 | # Environments 85 | .env 86 | .venv 87 | env/ 88 | venv/ 89 | ENV/ 90 | env.bak/ 91 | venv.bak/ 92 | 93 | # Spyder project settings 94 | .spyderproject 95 | .spyproject 96 | 97 | # Rope project settings 98 | .ropeproject 99 | 100 | # mkdocs documentation 101 | /site 102 | 103 | # mypy 104 | .mypy_cache/ 105 | smartweatherudp.code-workspace 106 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # List of Changes 2 | 3 | ## Version 2023.2.0 4 | 5 | - Use async_forward_entry_setups instead of async_setup_platforms 6 | - Resolve HA warnings on device class/units 7 | 8 | ## Version 2022.12.0 9 | 10 | - Fix issues with deprecated HA code 11 | 12 | ## Version 2022.7.0 13 | 14 | - Bump pyweatherflowudp to 1.4.1 - [Changelog](https://github.com/briis/pyweatherflowudp/blob/main/CHANGELOG.md) 15 | - Add `wind direction average` sensor 16 | - The previous `wind direction` sensor will now update based on the rapid wind speed event and align with the `wind speed` sensor 17 | 18 | ## Version 2022.6.0 19 | 20 | - Bump pyweatherflowudp to 1.3.1 - [Changelog](https://github.com/briis/pyweatherflowudp/blob/main/CHANGELOG.md) 21 | 22 | ## Version 2022.5.0 23 | 24 | - Ensure HA has started before attempting to add devices 25 | 26 | ## Version 2022.4.0 27 | 28 | - Update sensors to use EntityCategory enums 29 | - Update minimum HA version to 2022.3 30 | 31 | ## Version 2022.3.0 32 | 33 | - Bump pyweatherflowudp to 1.3.0 - [Changelog](https://github.com/briis/pyweatherflowudp/blob/main/CHANGELOG.md) 34 | - Add sensor for precipitation type with english translation file 35 | - Update sensors to use SensorDeviceClass and SensorStateClass enums 36 | - Update minimum HA version to 2021.12 37 | 38 | ## Version 2021.12.5 39 | 40 | - Bump pyweatherflowudp to 1.2.0 - [Changelog](https://github.com/briis/pyweatherflowudp/blob/main/CHANGELOG.md) 41 | 42 | ## Version 2021.12.4 43 | 44 | - Bump pyweatherflowudp to 1.1.2 - [Changelog](https://github.com/briis/pyweatherflowudp/blob/main/CHANGELOG.md) 45 | 46 | ## Version 2021.12.3 47 | 48 | - Add an additional debug log when setting up sensors 49 | 50 | ## Version 2021.12.2 51 | 52 | - Bump pyweatherflowudp to 1.1.1 to better handle future firmware revisions 53 | 54 | ## Version 2021.12.1 55 | 56 | - Round temperature values to one decimal 57 | 58 | ## Version 2021.12.0 59 | 60 | ### Breaking Changes 61 | 62 | - configuration.yaml setup has been deprecated 63 | - previous configuration.yaml entries should be migrated automatically to a config entry by this release and then can be safely deleted 64 | - entity ids have changed to `sensor.__` 65 | - removes heat index and wind chill since they are incorporated in the "feels like" temperature 66 | 67 | ### What's New 68 | 69 | - Setup is now supported via the UI so there is no need to modify the configuration.yaml (as it has been deprecated) 70 | - Devices are now created based on the type (hub, air, sky, or tempest) with relevant data points 71 | - Migrated from pysmartweatherudp to pyweatherflowudp 72 | - Data points are refreshed only when the device sends an update that corresponds to that attribute 73 | - wind speed every 3 seconds or so 74 | - temperature, humidity, pressure, etc every minute 75 | 76 | ## Version 0.1.8 77 | 78 | - Add required version number to home assistant manifest 79 | 80 | ## Version 0.1.7 81 | 82 | - Added support for the Tempest Weather System 83 | 84 | ## Version 0.1.3 85 | 86 | - Added `manifest.json` to ensure compliance with Home Assistant >= 0.92.x. 87 | - Changed the Custom Updater setup, to ensure that it works with multiple files going forward. You will need to re-download `sensor.py`, `__init__.py` and `manifest.json` 88 | 89 | ## Version 0.1.1 90 | 91 | - Icons for Battery devices now reflect the current state of the Battery Charge. When new Batteries are inserted the Voltage is typically around 3.2V to 3.3V. And by experience the Unit stops working at around 2.3V +/- 0.1V. So the Icon stage reflects that Interval 92 | - Fixed documentation error in README.md, listing the wrong sensors 93 | 94 | ## Version 0.1.0 95 | 96 | - Initial Release. 97 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Bjarne Riis and Nathan Spencer 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. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # 🚨 DEPRECATED – This Repository is No Longer Maintained 2 | 3 | ⚠️ **This integration is deprecated and should be removed.** Please use the official Home Assistant integration instead. 4 | 5 | 🔗 [Home Assistant WeatherFlow integration](https://www.home-assistant.io/integrations/weatherflow) 6 | 7 | --- 8 | 9 | ![Release](https://img.shields.io/github/v/release/briis/smartweatherudp?style=for-the-badge) 10 | [![Buy Me A Coffee/Beer](https://img.shields.io/badge/Buy_Me_A_☕/🍺-F16061?style=for-the-badge&logo=ko-fi&logoColor=white&labelColor=grey)](https://ko-fi.com/natekspencer) 11 | [![HACS Badge](https://img.shields.io/badge/HACS-Custom-orange.svg?style=for-the-badge)](https://github.com/hacs/integration) 12 | [![Discord Server](https://img.shields.io/discord/918948431714738257?style=for-the-badge)](https://discord.gg/rWzPjQegRy) 13 | 14 | ![WeatherFlow Logo](logo.png) 15 | 16 | # WeatherFlow Local for Home Assistant 17 | 18 | This a _custom component_ for [Home Assistant](https://www.home-assistant.io/). It reads real-time data using the [UDP protocol](https://weatherflow.github.io/Tempest/api/udp/v171/) from a _WeatherFlow_ weather station. 19 | 20 | It will create a device with several `sensor` entities for each weather reading like Temperature, Humidity, Station Pressure, UV, etc that is associated with it. 21 | 22 | **Notes:** 23 | 24 | As this component listens for UDP broadcasts, in can take up to 1 minute before all sensors have gotten a value after restart of Home Assistant 25 | 26 | ## Installation 27 | 28 | This Integration can be installed in two ways: 29 | 30 | **HACS Installation** 31 | 32 | Add the following to the Custom Repository under `Settings` in HACS: 33 | 34 | `briis/smartweatherudp` and choose `Ìntegration` as Category 35 | 36 | **Manual Installation** 37 | 38 | 1. If you don't already have a `custom_components` directory in your Home Assistant config directory, create it. 39 | 2. Copy the `smartweatherudp` folder under `custom_components` into the `custom_components` folder on Home Assistant. 40 | 3. Or using Git, go to the `custom_components` directory and enter:
`git clone https://github.com/briis/smartweatherudp.git` 41 | 42 | ## Track Updates 43 | 44 | If installed via HACS, updates are flagged automatically. Otherwise, you will have to manually update as described in the manual installation steps above. 45 | 46 | ## Configuration 47 | 48 | There is a config flow for this integration. After installing the custom component: 49 | 50 | 1. Go to **Configuration**->**Integrations** 51 | 2. Click **+ ADD INTEGRATION** to setup a new integration 52 | 3. Search for **WeatherFlow - Local** and click on it 53 | 4. You will be guided through the rest of the setup process via the config flow 54 | - This will initially try to find devices by listening to UDP messages on `0.0.0.0`. If no devices are found, it will then ask you to enter a host address to try to listen on. Default is `0.0.0.0` but you can enter any host IP. Typically used if your Weather Station is on a different subnet than Home Assistant. 55 | 56 | ## Available Sensors\* 57 | 58 | | Name | Description | 59 | | -------------------------- | ------------------------------------------------------------------------------------------------------- | 60 | | Air Density | The current air density. | 61 | | Dew Point | The atmospheric temperature below which water droplets begin to condense and dew can form. | 62 | | Feels Like | How the temperature feels on the skin. A combination of heat index, wind chill and current temperature. | 63 | | Humidity | The relative humidity. | 64 | | Illuminance | The current brightness. | 65 | | Lightning Average Distance | The average distance detected for lightning. | 66 | | Lightning Count | The count of lightning strikes. | 67 | | Rain Amount | The rain amount over the past minute. | 68 | | Rain Rate | The current rain rate based on the past minute. | 69 | | Solar Radiation | The current Solar Radiation measured in W/m². | 70 | | Station Pressure | The current barometric pressure. | 71 | | Temperature | The current air temperature. | 72 | | UV | The UV index. | 73 | | Vapor Pressure | The current vapor pressure. | 74 | | Wet Bulb Temperature | The current wet bulb temperature. | 75 | | Wind Average | The average wind speed over the past minute. | 76 | | Wind Direction | The wind direction. | 77 | | Wind Gust | The wind gust speed. | 78 | | Wind Lull | The wind lull speed. | 79 | | Wind Speed | The current wind speed. | 80 | | Battery | The current battery voltage of the sensor. | 81 | | RSSI | The received signal strength indication of the device. | 82 | | Up Since | The UTC datetime the device last came online. | 83 | 84 | \* depends on the device 85 | -------------------------------------------------------------------------------- /custom_components/smartweatherudp/__init__.py: -------------------------------------------------------------------------------- 1 | """ Get data from Smart Weather station via UDP. """ 2 | from __future__ import annotations 3 | 4 | import logging 5 | 6 | from pyweatherflowudp.client import EVENT_DEVICE_DISCOVERED, WeatherFlowListener 7 | from pyweatherflowudp.const import DEFAULT_HOST 8 | from pyweatherflowudp.device import EVENT_LOAD_COMPLETE, WeatherFlowDevice 9 | from pyweatherflowudp.errors import ListenerError 10 | 11 | from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN 12 | from homeassistant.config_entries import ConfigEntry 13 | from homeassistant.const import ( 14 | CONF_HOST, 15 | EVENT_HOMEASSISTANT_STARTED, 16 | EVENT_HOMEASSISTANT_STOP, 17 | ) 18 | from homeassistant.core import CoreState, Event, HomeAssistant, callback 19 | from homeassistant.exceptions import ConfigEntryNotReady 20 | from homeassistant.helpers.dispatcher import async_dispatcher_send 21 | 22 | from .const import DOMAIN 23 | 24 | _LOGGER = logging.getLogger(__name__) 25 | 26 | PLATFORMS = [SENSOR_DOMAIN] 27 | 28 | 29 | async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: 30 | """Set up WeatherFlow from a config entry.""" 31 | hass.data.setdefault(DOMAIN, {}) 32 | 33 | client = hass.data[DOMAIN][entry.entry_id] = WeatherFlowListener( 34 | host=entry.data.get(CONF_HOST, DEFAULT_HOST) 35 | ) 36 | 37 | await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) 38 | 39 | @callback 40 | def device_discovered(device: WeatherFlowDevice) -> None: 41 | _LOGGER.debug("Found a device: %s", device) 42 | 43 | @callback 44 | def add_device() -> None: 45 | async_dispatcher_send( 46 | hass, f"{DOMAIN}_{entry.entry_id}_add_{SENSOR_DOMAIN}", device 47 | ) 48 | 49 | entry.async_on_unload( 50 | device.on( 51 | EVENT_LOAD_COMPLETE, 52 | lambda _: add_device() 53 | if hass.state == CoreState.running 54 | else hass.bus.async_listen_once( 55 | EVENT_HOMEASSISTANT_STARTED, lambda _: add_device() 56 | ), 57 | ) 58 | ) 59 | 60 | entry.async_on_unload(client.on(EVENT_DEVICE_DISCOVERED, device_discovered)) 61 | 62 | try: 63 | await client.start_listening() 64 | except ListenerError as ex: 65 | raise ConfigEntryNotReady from ex 66 | 67 | async def handle_ha_shutdown(event: Event) -> None: 68 | """Handle HA shutdown.""" 69 | await client.stop_listening() 70 | 71 | entry.async_on_unload( 72 | hass.bus.async_listen(EVENT_HOMEASSISTANT_STOP, handle_ha_shutdown) 73 | ) 74 | 75 | return True 76 | 77 | 78 | async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: 79 | """Unload a config entry.""" 80 | unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) 81 | 82 | client: WeatherFlowListener = hass.data[DOMAIN][entry.entry_id] 83 | await client.stop_listening() 84 | 85 | if unload_ok: 86 | hass.data[DOMAIN].pop(entry.entry_id) 87 | 88 | return unload_ok 89 | -------------------------------------------------------------------------------- /custom_components/smartweatherudp/config_flow.py: -------------------------------------------------------------------------------- 1 | """Config flow for smartweatherudp.""" 2 | from __future__ import annotations 3 | 4 | import asyncio 5 | import logging 6 | from typing import Any 7 | 8 | from async_timeout import timeout 9 | from pyweatherflowudp.client import EVENT_DEVICE_DISCOVERED, WeatherFlowListener 10 | from pyweatherflowudp.const import DEFAULT_HOST 11 | from pyweatherflowudp.errors import AddressInUseError, ListenerError 12 | import voluptuous as vol 13 | 14 | from homeassistant import config_entries 15 | from homeassistant.const import CONF_HOST, CONF_NAME 16 | from homeassistant.core import callback 17 | from homeassistant.data_entry_flow import FlowResult 18 | 19 | from .const import DOMAIN 20 | 21 | _LOGGER = logging.getLogger(__name__) 22 | 23 | STEP_USER_DATA_SCHEMA = STEP_MANUAL_DATA_SCHEMA = vol.Schema( 24 | {vol.Required(CONF_HOST): str} 25 | ) 26 | 27 | 28 | async def _async_has_devices(host: str = DEFAULT_HOST) -> bool: 29 | """Return if there are devices that can be discovered.""" 30 | event = asyncio.Event() 31 | 32 | @callback 33 | def device_discovered(): 34 | """Handle a discovered device.""" 35 | event.set() 36 | 37 | async with WeatherFlowListener(host) as client: 38 | client.on(EVENT_DEVICE_DISCOVERED, lambda _: device_discovered()) 39 | try: 40 | async with timeout(10): 41 | await event.wait() 42 | except asyncio.TimeoutError: 43 | return False 44 | 45 | return True 46 | 47 | 48 | class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): 49 | """Handle a config flow for smartweatherudp.""" 50 | 51 | VERSION = 1 52 | 53 | async def async_step_user( 54 | self, user_input: dict[str, Any] | None = None 55 | ) -> FlowResult: 56 | """Handle a flow initialized by the user.""" 57 | current_hosts = [ 58 | entry.data.get(CONF_HOST, DEFAULT_HOST) 59 | for entry in self._async_current_entries() 60 | ] 61 | 62 | if user_input is None: 63 | if DEFAULT_HOST in current_hosts: 64 | return self.async_show_form( 65 | step_id="user", data_schema=STEP_USER_DATA_SCHEMA 66 | ) 67 | host = DEFAULT_HOST 68 | else: 69 | host = user_input.get(CONF_HOST) 70 | 71 | if host in current_hosts: 72 | return self.async_abort(reason="single_instance_allowed") 73 | 74 | # Get current discovered entries. 75 | in_progress = self._async_in_progress() 76 | 77 | if not (has_devices := in_progress): 78 | errors = {} 79 | try: 80 | has_devices = await self.hass.async_add_job(_async_has_devices, host) 81 | except AddressInUseError: 82 | errors["base"] = "address_in_use" 83 | except ListenerError: 84 | errors["base"] = "cannot_connect" 85 | 86 | if errors or (not has_devices and user_input is None): 87 | return self.async_show_form( 88 | step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors 89 | ) 90 | 91 | if not has_devices: 92 | return self.async_abort(reason="no_devices_found") 93 | 94 | # Cancel other flows. 95 | for flow in in_progress: 96 | self.hass.config_entries.flow.async_abort(flow["flow_id"]) 97 | 98 | return self.async_create_entry( 99 | title=f"WeatherFlow{f' ({host})' if host != DEFAULT_HOST else ''}", 100 | data=user_input or {}, 101 | ) 102 | 103 | async def async_step_import(self, config: dict[str, Any] | None) -> FlowResult: 104 | """Handle a flow initialized by import.""" 105 | if config is None or (host := config.get(CONF_HOST)) in [ 106 | entry.data.get(CONF_HOST) for entry in self._async_current_entries() 107 | ]: 108 | return self.async_abort(reason="single_instance_allowed") 109 | 110 | # Cancel other flows. 111 | in_progress = self._async_in_progress() 112 | for flow in in_progress: 113 | self.hass.config_entries.flow.async_abort(flow["flow_id"]) 114 | 115 | return self.async_create_entry( 116 | title=f"{config.get(CONF_NAME, 'WeatherFlow')}{f' ({host})' if host != DEFAULT_HOST else ''}", 117 | data=config, 118 | ) 119 | -------------------------------------------------------------------------------- /custom_components/smartweatherudp/const.py: -------------------------------------------------------------------------------- 1 | """Constants for smartweatherudp.""" 2 | DOMAIN = "smartweatherudp" 3 | -------------------------------------------------------------------------------- /custom_components/smartweatherudp/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "domain": "smartweatherudp", 3 | "name": "WeatherFlow - Local", 4 | "codeowners": ["@natekspencer"], 5 | "config_flow": true, 6 | "documentation": "https://github.com/briis/smartweatherudp", 7 | "integration_type": "hub", 8 | "iot_class": "local_push", 9 | "issue_tracker": "https://github.com/briis/smartweatherudp/issues", 10 | "loggers": ["pyweatherflowudp"], 11 | "requirements": ["pyweatherflowudp==1.4.1"], 12 | "version": "2023.2.0" 13 | } 14 | -------------------------------------------------------------------------------- /custom_components/smartweatherudp/sensor.py: -------------------------------------------------------------------------------- 1 | """Sensors for the smartweatherudp integration.""" 2 | from __future__ import annotations 3 | 4 | from dataclasses import dataclass, field 5 | from datetime import datetime 6 | from enum import Enum 7 | import logging 8 | from typing import Any 9 | 10 | from pyweatherflowudp.calc import Quantity 11 | from pyweatherflowudp.const import EVENT_RAPID_WIND 12 | from pyweatherflowudp.device import ( 13 | EVENT_OBSERVATION, 14 | EVENT_STATUS_UPDATE, 15 | WeatherFlowDevice, 16 | WeatherFlowSensorDevice, 17 | ) 18 | import voluptuous as vol 19 | 20 | from homeassistant.components.sensor import ( 21 | DOMAIN as SENSOR_DOMAIN, 22 | PLATFORM_SCHEMA, 23 | SensorDeviceClass, 24 | SensorEntity, 25 | SensorEntityDescription, 26 | SensorStateClass, 27 | ) 28 | from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry 29 | from homeassistant.const import ( 30 | CONF_HOST, 31 | CONF_MONITORED_CONDITIONS, 32 | CONF_NAME, 33 | DEGREE, 34 | LIGHT_LUX, 35 | PERCENTAGE, 36 | SIGNAL_STRENGTH_DECIBELS_MILLIWATT, 37 | UV_INDEX, 38 | UnitOfElectricPotential, 39 | UnitOfIrradiance, 40 | UnitOfLength, 41 | UnitOfPrecipitationDepth, 42 | UnitOfPressure, 43 | UnitOfSpeed, 44 | UnitOfTemperature, 45 | UnitOfVolumetricFlux, 46 | ) 47 | from homeassistant.core import Callable, HomeAssistant, callback 48 | import homeassistant.helpers.config_validation as cv 49 | from homeassistant.helpers.dispatcher import async_dispatcher_connect 50 | from homeassistant.helpers.entity import DeviceInfo, EntityCategory 51 | from homeassistant.helpers.entity_platform import AddEntitiesCallback 52 | from homeassistant.helpers.typing import ConfigType, StateType 53 | from homeassistant.util.unit_system import METRIC_SYSTEM 54 | 55 | from .const import DOMAIN 56 | 57 | _LOGGER = logging.getLogger(__name__) 58 | 59 | CONCENTRATION_KILOGRAMS_PER_CUBIC_METER = "kg/m³" 60 | CONCENTRATION_POUNDS_PER_CUBIC_FOOT = "lbs/ft³" 61 | 62 | QUANTITY_KILOMETERS_PER_HOUR = "kph" 63 | 64 | IMPERIAL_UNIT_MAP = { 65 | CONCENTRATION_KILOGRAMS_PER_CUBIC_METER: CONCENTRATION_POUNDS_PER_CUBIC_FOOT, 66 | UnitOfLength.KILOMETERS: UnitOfLength.MILES, 67 | UnitOfPrecipitationDepth.MILLIMETERS: UnitOfPrecipitationDepth.INCHES, 68 | UnitOfVolumetricFlux.MILLIMETERS_PER_HOUR: UnitOfVolumetricFlux.INCHES_PER_HOUR, 69 | UnitOfPressure.MBAR: UnitOfPressure.INHG, 70 | UnitOfSpeed.KILOMETERS_PER_HOUR: UnitOfSpeed.MILES_PER_HOUR, 71 | } 72 | 73 | # Deprecated configuration.yaml 74 | DEPRECATED_CONF_WIND_UNIT = "wind_unit" 75 | DEPRECATED_SENSOR_TYPES = [ 76 | "temperature", 77 | "dewpoint", 78 | "feels_like", 79 | "heat_index", 80 | "wind_chill", 81 | "wind_speed", 82 | "wind_bearing", 83 | "wind_speed_rapid", 84 | "wind_bearing_rapid", 85 | "wind_gust", 86 | "wind_lull", 87 | "wind_direction", 88 | "precipitation", 89 | "precipitation_rate", 90 | "humidity", 91 | "pressure", 92 | "uv", 93 | "solar_radiation", 94 | "illuminance", 95 | "lightning_count", 96 | "airbattery", 97 | "skybattery", 98 | ] 99 | 100 | 101 | PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( 102 | { 103 | vol.Required( 104 | CONF_MONITORED_CONDITIONS, default=DEPRECATED_SENSOR_TYPES 105 | ): vol.All(cv.ensure_list, [vol.In(DEPRECATED_SENSOR_TYPES)]), 106 | vol.Optional(DEPRECATED_CONF_WIND_UNIT, default="ms"): cv.string, 107 | vol.Optional(CONF_HOST, default="0.0.0.0"): cv.string, 108 | vol.Optional(CONF_NAME, default=DOMAIN): cv.string, 109 | } 110 | ) 111 | 112 | 113 | async def async_setup_platform( 114 | hass: HomeAssistant, 115 | config: ConfigType, 116 | async_add_entities: AddEntitiesCallback, 117 | discovery_info: dict[str, Any] | None = None, 118 | ) -> None: 119 | """Import smartweatherudp configuration from YAML.""" 120 | _LOGGER.warning( 121 | "Configuration of the smartweatherudp platform in YAML is deprecated and will be " 122 | "removed in a future version; Your existing configuration has been imported into " 123 | "the UI automatically and can safely be removed from your configuration.yaml file" 124 | ) 125 | hass.async_create_task( 126 | hass.config_entries.flow.async_init( 127 | DOMAIN, 128 | context={"source": SOURCE_IMPORT}, 129 | data=config, 130 | ) 131 | ) 132 | 133 | 134 | @dataclass 135 | class WeatherFlowSensorEntityDescription(SensorEntityDescription): 136 | """Describes a WeatherFlow sensor entity description.""" 137 | 138 | attr: str | None = None 139 | conversion_fn: Callable[[Quantity], Quantity] | None = None 140 | decimals: int | None = None 141 | event_subscriptions: list[str] = field(default_factory=lambda: [EVENT_OBSERVATION]) 142 | value_fn: Callable[[Quantity], Quantity] | None = None 143 | 144 | 145 | @dataclass 146 | class WeatherFlowTemperatureSensorEntityDescription(WeatherFlowSensorEntityDescription): 147 | """Describes a WeatherFlow temperature sensor entity description.""" 148 | 149 | def __post_init__(self) -> None: 150 | """Post initialisation processing.""" 151 | self.native_unit_of_measurement = UnitOfTemperature.CELSIUS 152 | self.device_class = SensorDeviceClass.TEMPERATURE 153 | self.state_class = SensorStateClass.MEASUREMENT 154 | self.decimals = 1 155 | 156 | 157 | @dataclass 158 | class WeatherFlowWindSensorEntityDescription(WeatherFlowSensorEntityDescription): 159 | """Describes a WeatherFlow wind sensor entity description.""" 160 | 161 | def __post_init__(self) -> None: 162 | """Post initialisation processing.""" 163 | self.icon = "mdi:weather-windy" 164 | self.native_unit_of_measurement = UnitOfSpeed.KILOMETERS_PER_HOUR 165 | self.state_class = SensorStateClass.MEASUREMENT 166 | self.conversion_fn = lambda attr: attr.to(UnitOfSpeed.MILES_PER_HOUR) 167 | self.decimals = 2 168 | self.value_fn = lambda attr: attr.to(QUANTITY_KILOMETERS_PER_HOUR) 169 | 170 | 171 | SENSORS: tuple[WeatherFlowSensorEntityDescription, ...] = ( 172 | WeatherFlowTemperatureSensorEntityDescription( 173 | key="air_temperature", 174 | name="Temperature", 175 | ), 176 | WeatherFlowSensorEntityDescription( 177 | key="air_density", 178 | name="Air Density", 179 | native_unit_of_measurement=CONCENTRATION_KILOGRAMS_PER_CUBIC_METER, 180 | state_class=SensorStateClass.MEASUREMENT, 181 | conversion_fn=lambda attr: attr.to(CONCENTRATION_POUNDS_PER_CUBIC_FOOT), 182 | decimals=5, 183 | ), 184 | WeatherFlowTemperatureSensorEntityDescription( 185 | key="dew_point_temperature", 186 | name="Dew Point", 187 | ), 188 | WeatherFlowSensorEntityDescription( 189 | key="battery", 190 | name="Battery Voltage", 191 | native_unit_of_measurement=UnitOfElectricPotential.VOLT, 192 | device_class=SensorDeviceClass.VOLTAGE, 193 | entity_category=EntityCategory.DIAGNOSTIC, 194 | state_class=SensorStateClass.MEASUREMENT, 195 | ), 196 | WeatherFlowTemperatureSensorEntityDescription( 197 | key="feels_like_temperature", 198 | name="Feels Like", 199 | ), 200 | WeatherFlowSensorEntityDescription( 201 | key="illuminance", 202 | name="Illuminance", 203 | native_unit_of_measurement=LIGHT_LUX, 204 | device_class=SensorDeviceClass.ILLUMINANCE, 205 | state_class=SensorStateClass.MEASUREMENT, 206 | ), 207 | WeatherFlowSensorEntityDescription( 208 | key="lightning_strike_average_distance", 209 | name="Lightning Average Distance", 210 | icon="mdi:lightning-bolt", 211 | native_unit_of_measurement=UnitOfLength.KILOMETERS, 212 | conversion_fn=lambda attr: attr.to(UnitOfLength.MILES), 213 | decimals=2, 214 | ), 215 | WeatherFlowSensorEntityDescription( 216 | key="lightning_strike_count", 217 | name="Lightning Count", 218 | icon="mdi:lightning-bolt", 219 | ), 220 | WeatherFlowSensorEntityDescription( 221 | key="precipitation_type", 222 | name="Precipitation Type", 223 | icon="mdi:weather-rainy", 224 | ), 225 | WeatherFlowSensorEntityDescription( 226 | key="rain_amount", 227 | name="Rain Amount", 228 | icon="mdi:weather-rainy", 229 | native_unit_of_measurement=UnitOfPrecipitationDepth.MILLIMETERS, 230 | state_class=SensorStateClass.TOTAL, 231 | attr="rain_accumulation_previous_minute", 232 | conversion_fn=lambda attr: attr.to(UnitOfPrecipitationDepth.INCHES), 233 | ), 234 | WeatherFlowSensorEntityDescription( 235 | key="rain_rate", 236 | name="Rain Rate", 237 | icon="mdi:weather-rainy", 238 | native_unit_of_measurement=UnitOfVolumetricFlux.MILLIMETERS_PER_HOUR, 239 | attr="rain_rate", 240 | conversion_fn=lambda attr: attr.to(UnitOfVolumetricFlux.INCHES_PER_HOUR), 241 | ), 242 | WeatherFlowSensorEntityDescription( 243 | key="relative_humidity", 244 | name="Humidity", 245 | native_unit_of_measurement=PERCENTAGE, 246 | device_class=SensorDeviceClass.HUMIDITY, 247 | state_class=SensorStateClass.MEASUREMENT, 248 | ), 249 | WeatherFlowSensorEntityDescription( 250 | key="rssi", 251 | name="RSSI", 252 | native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS_MILLIWATT, 253 | device_class=SensorDeviceClass.SIGNAL_STRENGTH, 254 | entity_category=EntityCategory.DIAGNOSTIC, 255 | state_class=SensorStateClass.MEASUREMENT, 256 | entity_registry_enabled_default=False, 257 | event_subscriptions=[EVENT_STATUS_UPDATE], 258 | ), 259 | WeatherFlowSensorEntityDescription( 260 | key="station_pressure", 261 | name="Station Pressure", 262 | native_unit_of_measurement=UnitOfPressure.MBAR, 263 | device_class=SensorDeviceClass.PRESSURE, 264 | state_class=SensorStateClass.MEASUREMENT, 265 | conversion_fn=lambda attr: attr.to(UnitOfPressure.INHG), 266 | decimals=5, 267 | ), 268 | WeatherFlowSensorEntityDescription( 269 | key="solar_radiation", 270 | name="Solar Radiation", 271 | native_unit_of_measurement=UnitOfIrradiance.WATTS_PER_SQUARE_METER, 272 | device_class=SensorDeviceClass.IRRADIANCE, 273 | state_class=SensorStateClass.MEASUREMENT, 274 | ), 275 | WeatherFlowSensorEntityDescription( 276 | key="up_since", 277 | name="Up Since", 278 | device_class=SensorDeviceClass.TIMESTAMP, 279 | entity_category=EntityCategory.DIAGNOSTIC, 280 | entity_registry_enabled_default=False, 281 | event_subscriptions=[EVENT_STATUS_UPDATE], 282 | ), 283 | WeatherFlowSensorEntityDescription( 284 | key="uv", 285 | name="UV", 286 | native_unit_of_measurement=UV_INDEX, 287 | state_class=SensorStateClass.MEASUREMENT, 288 | ), 289 | WeatherFlowSensorEntityDescription( 290 | key="vapor_pressure", 291 | name="Vapor Pressure", 292 | native_unit_of_measurement=UnitOfPressure.MBAR, 293 | device_class=SensorDeviceClass.PRESSURE, 294 | state_class=SensorStateClass.MEASUREMENT, 295 | conversion_fn=lambda attr: attr.to(UnitOfPressure.INHG), 296 | decimals=5, 297 | ), 298 | WeatherFlowTemperatureSensorEntityDescription( 299 | key="wet_bulb_temperature", 300 | name="Wet Bulb Temperature", 301 | ), 302 | WeatherFlowWindSensorEntityDescription( 303 | key="wind_average", 304 | name="Wind Average", 305 | ), 306 | WeatherFlowSensorEntityDescription( 307 | key="wind_direction", 308 | name="Wind Direction", 309 | icon="mdi:compass-outline", 310 | native_unit_of_measurement=DEGREE, 311 | state_class=SensorStateClass.MEASUREMENT, 312 | event_subscriptions=[EVENT_RAPID_WIND, EVENT_OBSERVATION], 313 | ), 314 | WeatherFlowSensorEntityDescription( 315 | key="wind_direction_average", 316 | name="Wind Direction Average", 317 | icon="mdi:compass-outline", 318 | native_unit_of_measurement=DEGREE, 319 | state_class=SensorStateClass.MEASUREMENT, 320 | ), 321 | WeatherFlowWindSensorEntityDescription( 322 | key="wind_gust", 323 | name="Wind Gust", 324 | ), 325 | WeatherFlowWindSensorEntityDescription( 326 | key="wind_lull", 327 | name="Wind Lull", 328 | ), 329 | WeatherFlowWindSensorEntityDescription( 330 | key="wind_speed", 331 | name="Wind Speed", 332 | event_subscriptions=[EVENT_RAPID_WIND, EVENT_OBSERVATION], 333 | ), 334 | ) 335 | 336 | 337 | async def async_setup_entry( 338 | hass: HomeAssistant, 339 | config_entry: ConfigEntry, 340 | async_add_entities: AddEntitiesCallback, 341 | ): 342 | """Set up WeatherFlow sensors using config entry.""" 343 | 344 | @callback 345 | def async_add_sensor(device: WeatherFlowDevice) -> None: 346 | """Add WeatherFlow sensor.""" 347 | _LOGGER.debug("Adding sensors for %s", device) 348 | async_add_entities( 349 | WeatherFlowSensorEntity( 350 | device, description, hass.config.units is METRIC_SYSTEM 351 | ) 352 | for description in SENSORS 353 | if hasattr( 354 | device, 355 | description.key if description.attr is None else description.attr, 356 | ) 357 | ) 358 | 359 | config_entry.async_on_unload( 360 | async_dispatcher_connect( 361 | hass, 362 | f"{DOMAIN}_{config_entry.entry_id}_add_{SENSOR_DOMAIN}", 363 | async_add_sensor, 364 | ) 365 | ) 366 | 367 | 368 | class WeatherFlowSensorEntity(SensorEntity): 369 | """Defines a WeatherFlow sensor entity.""" 370 | 371 | entity_description: WeatherFlowSensorEntityDescription 372 | _attr_should_poll = False 373 | 374 | def __init__( 375 | self, 376 | device: WeatherFlowDevice, 377 | description: WeatherFlowSensorEntityDescription, 378 | is_metric: bool = True, 379 | ) -> None: 380 | """Initialize a WeatherFlow sensor entity.""" 381 | self.device = device 382 | if not is_metric and ( 383 | (unit := IMPERIAL_UNIT_MAP.get(description.native_unit_of_measurement)) 384 | is not None 385 | ): 386 | description.native_unit_of_measurement = unit 387 | self.entity_description = description 388 | self._attr_device_info = DeviceInfo( 389 | identifiers={(DOMAIN, self.device.serial_number)}, 390 | manufacturer="WeatherFlow", 391 | model=self.device.model, 392 | name=f"{self.device.model} {self.device.serial_number}", 393 | sw_version=self.device.firmware_revision, 394 | suggested_area="Backyard", 395 | ) 396 | if isinstance(device, WeatherFlowSensorDevice): 397 | self._attr_device_info["via_device"] = (DOMAIN, self.device.hub_sn) 398 | self._attr_name = ( 399 | f"{self.device.model} {self.device.serial_number} {description.name}" 400 | ) 401 | self._attr_unique_id = f"{DOMAIN}_{self.device.serial_number}_{description.key}" 402 | 403 | @property 404 | def last_reset(self) -> datetime | None: 405 | """Return the time when the sensor was last reset, if any.""" 406 | if self.entity_description.state_class == SensorStateClass.TOTAL: 407 | return self.device.last_report 408 | return None 409 | 410 | @property 411 | def native_value(self) -> datetime | StateType: 412 | """Return the state of the sensor.""" 413 | attr = getattr( 414 | self.device, 415 | self.entity_description.key 416 | if self.entity_description.attr is None 417 | else self.entity_description.attr, 418 | ) 419 | 420 | if attr is None: 421 | return attr 422 | 423 | if ( 424 | not self.hass.config.units is METRIC_SYSTEM 425 | and (fn := self.entity_description.conversion_fn) is not None 426 | ) or (fn := self.entity_description.value_fn) is not None: 427 | attr = fn(attr) 428 | 429 | if isinstance(attr, Quantity): 430 | attr = attr.m 431 | elif isinstance(attr, Enum): 432 | attr = attr.name 433 | 434 | if (decimals := self.entity_description.decimals) is not None: 435 | attr = round(attr, decimals) 436 | return attr 437 | 438 | async def async_added_to_hass(self) -> None: 439 | """Subscribe to events.""" 440 | for event in self.entity_description.event_subscriptions: 441 | self.async_on_remove( 442 | self.device.on(event, lambda _: self.async_write_ha_state()) 443 | ) 444 | -------------------------------------------------------------------------------- /custom_components/smartweatherudp/strings.json: -------------------------------------------------------------------------------- 1 | { 2 | "config": { 3 | "step": { 4 | "user": { 5 | "data": { 6 | "host": "[%key:common::config_flow::data::host%]" 7 | } 8 | } 9 | }, 10 | "error": { 11 | "address_in_use": "The host address is already in use. Please specify a different host address below.", 12 | "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]" 13 | }, 14 | "abort": { 15 | "single_instance_allowed": "[%key:common::config_flow::abort::single_instance_allowed%]", 16 | "no_devices_found": "[%key:common::config_flow::abort::no_devices_found%]" 17 | } 18 | } 19 | } -------------------------------------------------------------------------------- /custom_components/smartweatherudp/translations/en.json: -------------------------------------------------------------------------------- 1 | { 2 | "config": { 3 | "step": { 4 | "user": { 5 | "data": { 6 | "host": "Host" 7 | } 8 | } 9 | }, 10 | "error": { 11 | "address_in_use": "The host address is already in use. Please specify a different host address below.", 12 | "cannot_connect": "Failed to connect" 13 | }, 14 | "abort": { 15 | "single_instance_allowed": "Already configured. Only a single configuration possible.", 16 | "no_devices_found": "No devices found on the network" 17 | } 18 | } 19 | } -------------------------------------------------------------------------------- /custom_components/smartweatherudp/translations/sensor.en.json: -------------------------------------------------------------------------------- 1 | { 2 | "state": { 3 | "_": { 4 | "NONE": "None", 5 | "RAIN": "Rain", 6 | "HAIL": "Hail", 7 | "RAIN_HAIL": "Rain, hail" 8 | } 9 | } 10 | } -------------------------------------------------------------------------------- /hacs.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "WeatherFlow - Local", 3 | "domains": ["sensor"], 4 | "homeassistant": "2023.1.0" 5 | } 6 | -------------------------------------------------------------------------------- /info.md: -------------------------------------------------------------------------------- 1 | # WeatherFlow Local for Home Assistant 2 | 3 | This a _custom component_ for [Home Assistant](https://www.home-assistant.io/). It reads real-time data using the [UDP protocol](https://weatherflow.github.io/Tempest/api/udp/v171/) from a _WeatherFlow_ weather station. 4 | 5 | It will create a device with several `sensor` entities for each weather reading like Temperature, Humidity, Station Pressure, UV, etc that is associated with it. 6 | 7 | ## Configuration 8 | 9 | There is a config flow for this integration. After installing the custom component: 10 | 11 | 1. Go to **Configuration**->**Integrations** 12 | 2. Click **+ ADD INTEGRATION** to setup a new integration 13 | 3. Search for **WeatherFlow - Local** and click on it 14 | 4. You will be guided through the rest of the setup process via the config flow 15 | - This will initially try to find devices by listening to UDP messages on `0.0.0.0`. If no devices are found, it will then ask you to enter a host address to try to listen on. Default is `0.0.0.0` but you can enter any host IP. Typically used if your Weather Station is on a different subnet than Home Assistant. 16 | -------------------------------------------------------------------------------- /logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/briis/smartweatherudp/a5087b2bd07ee228aa6d202862e864a32e631a5b/logo.png -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.isort] 2 | # https://github.com/PyCQA/isort/wiki/isort-Settings 3 | profile = "black" 4 | # will group `import x` and `from x import` of the same module. 5 | force_sort_within_sections = true 6 | known_first_party = [ 7 | "homeassistant", 8 | "tests", 9 | ] 10 | forced_separate = [ 11 | "tests", 12 | ] 13 | combine_as_imports = true --------------------------------------------------------------------------------