├── .github ├── ISSUE_TEMPLATE │ ├── bug-report.yml │ └── feature-request.yml └── workflows │ └── publish.yml ├── .gitignore ├── CHANGELOG.md ├── CHANGELOG.old.md ├── DOCS.md ├── Dockerfile ├── LATEST_CHANGES.md ├── LICENSE ├── README.md ├── config.json ├── config_example_AIR_SKY.yaml ├── config_example_TEMPEST.yaml ├── docker-compose.yml ├── hass-weatherflow2mqtt.code-workspace ├── icon.png ├── logo.png ├── repository.json ├── requirements.txt ├── setup.py ├── test_requirements.txt ├── tox.ini └── weatherflow2mqtt ├── __init__.py ├── __main__.py ├── __version__.py ├── const.py ├── forecast.py ├── helpers.py ├── sensor_description.py ├── sqlite.py ├── translations ├── da.json ├── de.json ├── en.json ├── fr.json ├── nl.json ├── se.json └── si.json └── weatherflow_mqtt.py /.github/ISSUE_TEMPLATE/bug-report.yml: -------------------------------------------------------------------------------- 1 | name: Bug Report 2 | description: File a bug report for Weatherflow2MQTT 3 | labels: ["bug"] 4 | body: 5 | - type: markdown 6 | attributes: 7 | value: | 8 | Thanks for taking the time to fill out this bug report! 9 | - type: input 10 | id: ha-version 11 | attributes: 12 | label: Home Assistant Version? 13 | description: What version of Home Assistant are you running? 14 | placeholder: "2021.12.3" 15 | validations: 16 | required: true 17 | - type: dropdown 18 | id: addon-selfhosted 19 | attributes: 20 | label: Add-On or Self Hosted? 21 | description: Is Weatherflow2MQTT running as a HA Supervised Add-On or a Self managed container? 22 | options: 23 | - HA Supervised (Add-On) 24 | - Self managed Container 25 | validations: 26 | required: true 27 | - type: input 28 | id: addon-version 29 | attributes: 30 | label: Weatherflow2MQTT version? 31 | description: What version of Weatherflow2MQTT is causing the error? 32 | placeholder: "3.0.4" 33 | validations: 34 | required: true 35 | - type: textarea 36 | id: what-happened 37 | attributes: 38 | label: What happened? 39 | description: Also tell us, what did you expect to happen? 40 | placeholder: Describe the bug 41 | validations: 42 | required: true 43 | - type: textarea 44 | id: logs 45 | attributes: 46 | label: Relevant log output 47 | description: Please copy and paste any relevant log output. This will be automatically formatted into code, so no need for backticks. 48 | render: shell 49 | validations: 50 | required: false 51 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature-request.yml: -------------------------------------------------------------------------------- 1 | name: Feature Request 2 | description: Ask for a new feature for Weatherflow2MQTT 3 | labels: ["enhancement"] 4 | body: 5 | - type: markdown 6 | attributes: 7 | value: | 8 | Thanks for taking the time to fill out this feature request. 9 | - type: textarea 10 | id: new-feature 11 | attributes: 12 | label: New Feature 13 | description: Please describe in detail what feature you would like to have. 14 | placeholder: Describe the feature 15 | validations: 16 | required: true 17 | - type: textarea 18 | id: additional-context 19 | attributes: 20 | label: Additional context 21 | description: Add any other context or screenshots about the feature request here. 22 | validations: 23 | required: false 24 | -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | name: publish 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | paths-ignore: 8 | - '**/README.md' 9 | - '**/CHANGELOG.md' 10 | - '**/config_example.yaml' 11 | 12 | 13 | jobs: 14 | publish-weatherflow2mqtt-image: 15 | runs-on: ubuntu-latest 16 | 17 | steps: 18 | - uses: actions/checkout@v2 19 | 20 | - name: Login to GitHub Container Registry 21 | uses: docker/login-action@v1 22 | with: 23 | registry: ghcr.io 24 | username: ${{ github.actor }} 25 | password: ${{ secrets.GITHUB_TOKEN }} 26 | 27 | - name: Build the WeatherFlow2MQTT Docker image 28 | run: | 29 | VERSION=$(cat VERSION) 30 | echo VERSION=$VERSION 31 | 32 | docker build . --tag ghcr.io/briis/hass-weatherflow2mqtt:latest --tag ghcr.io/briis/hass-weatherflow2mqtt:$VERSION 33 | docker create -v $(pwd):/config -p 50222:50222/udp ghcr.io/briis/hass-weatherflow2mqtt:$VERSION 34 | docker create -v $(pwd):/config -p 50222:50222/udp ghcr.io/briis/hass-weatherflow2mqtt:latest 35 | docker push ghcr.io/briis/hass-weatherflow2mqtt:$VERSION 36 | docker push ghcr.io/briis/hass-weatherflow2mqtt:latest 37 | -------------------------------------------------------------------------------- /.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 | pip-wheel-metadata/ 24 | share/python-wheels/ 25 | *.egg-info/ 26 | .installed.cfg 27 | *.egg 28 | MANIFEST 29 | 30 | # PyInstaller 31 | # Usually these files are written by a python script from a template 32 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 33 | *.manifest 34 | *.spec 35 | 36 | # Installer logs 37 | pip-log.txt 38 | pip-delete-this-directory.txt 39 | 40 | # Unit test / coverage reports 41 | htmlcov/ 42 | .tox/ 43 | .nox/ 44 | .coverage 45 | .coverage.* 46 | .cache 47 | nosetests.xml 48 | coverage.xml 49 | *.cover 50 | *.py,cover 51 | .hypothesis/ 52 | .pytest_cache/ 53 | 54 | # Translations 55 | *.mo 56 | *.pot 57 | 58 | # Django stuff: 59 | *.log 60 | local_settings.py 61 | db.sqlite3 62 | db.sqlite3-journal 63 | 64 | # Flask stuff: 65 | instance/ 66 | .webassets-cache 67 | 68 | # Scrapy stuff: 69 | .scrapy 70 | 71 | # Sphinx documentation 72 | docs/_build/ 73 | 74 | # PyBuilder 75 | target/ 76 | 77 | # Jupyter Notebook 78 | .ipynb_checkpoints 79 | 80 | # IPython 81 | profile_default/ 82 | ipython_config.py 83 | 84 | # pyenv 85 | .python-version 86 | 87 | # pipenv 88 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 89 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 90 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 91 | # install all needed dependencies. 92 | #Pipfile.lock 93 | 94 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 95 | __pypackages__/ 96 | 97 | # Celery stuff 98 | celerybeat-schedule 99 | celerybeat.pid 100 | 101 | # SageMath parsed files 102 | *.sage.py 103 | 104 | # Environments 105 | .env 106 | .venv 107 | env/ 108 | venv/ 109 | ENV/ 110 | env.bak/ 111 | venv.bak/ 112 | 113 | # Spyder project settings 114 | .spyderproject 115 | .spyproject 116 | 117 | # Rope project settings 118 | .ropeproject 119 | 120 | # mkdocs documentation 121 | /site 122 | 123 | # mypy 124 | .mypy_cache/ 125 | .dmypy.json 126 | dmypy.json 127 | 128 | # Pyre type checker 129 | .pyre/ 130 | test.py 131 | config.yaml 132 | build_run.sh 133 | data.json 134 | storage.json 135 | .storage.json 136 | .lightning.data 137 | forecast.json 138 | weatherfunctions.js 139 | .pressure.data 140 | weatherflow2mqtt.db 141 | config_bck.yaml 142 | max_min_day.sql 143 | upload_docker.sh 144 | .DS_Store 145 | upload_docker 146 | build_run 147 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Change Log 2 | 3 | All notable changes to this project will be documented in this file. 4 | 5 | ## [3.2.2] - 2023-10-08 6 | 7 | ### BREAKING Announcement 8 | 9 | As there is now a `Home Assistant Core` integration for WeatherFlow which uses the UDP API, I had to make a [new Integration](https://github.com/briis/weatherflow_forecast) that uses the REST API, with a different name (WeatherFlow Forecast). The new integration is up-to-date with the latest specs for how to create a Weather Forecast, and also gives the option to only add the Forecast, and no additional sensors. 10 | 11 | There is no *Weather Entity* in Home Assistant for MQTT, so after attributes are deprecated in Home Assistant 2024.3, there is no option to add the Forecast to Home Assistant. 12 | As a consequence of that, I have decided to remove the ability for this Add-On to add Forecast data to MQTT and Home Assistant. This Add-On will still be maintained, but just without the option of a Forecast - meaning it will be 100% local. 13 | If you want the forecast in combination with this Add-On, install the new integration mentioned above, just leave the *Add sensors* box unchecked. 14 | 15 | There is not an exact date for when this will happen, but it will be before end of February 2024. 16 | 17 | ### Changes 18 | 19 | - Added Slovenian language file. This was unfortunately placed in a wrong directory and as such it was not read by the integration. Fixing issue #236 20 | - Fixed issue #244 with deprecated forecast values. Thank you @mjmeli 21 | - Corrected visibility imperial unit from nautical mile (nmi) to mile (mi) 22 | 23 | ## [3.2.1] - 2023-08-31 24 | 25 | ### Changes 26 | 27 | - Some stations do not get the Sea Level Pressure and/or the UV value in the Hourly Forecast. It is not clear why this happens, but the issue is with WeatherFlow. The change implemented here, ensures that the system does not break because of that. If not present a 0 value is returned. 28 | This fixes Issue #234, #238 and maybe also #239 29 | 30 | ## [3.2.0] - 2023-08-29 31 | 32 | ### Changes 33 | 34 | - Fixed wrong type string in the `rain_type` function, so that it should now also get a string for Heavy Rain. Thanks to @GlennGoddard for spotting this. Closing #205 35 | - Changing units using ^ to conform with HA standards 36 | - Adding new device classes to selected sensors. (Wind Speed, Distance, Irradiation, Precipiation etc.) 37 | - Closing #198 and #215, by trying to ensure that correct timezone and unit system is always set 38 | - Added swedish translation. Thank you to @Bo1jo 39 | - Bumped docker image to `python:3.11-slim-buster` and @pcfens optimized the `Dockerfile`` to create a faster and smaller image. 40 | - Bumped all dependency modules to latest available version 41 | - Thanks @quentinmit the following improvements have been made, that makes it easier to run the program without Docker in a more traditional `setuptools` way. 42 | - Translations are installed and loaded as package data 43 | - The no-longer-supported asyncio PyPI package is removed from requirements.txt 44 | - Pint 0.20 and 0.21 are supported (also requires the pyweatherflowudp patch I sent separately) 45 | - @prigorus added the Slovenian translation 46 | 47 | -------------------------------------------------------------------------------- /CHANGELOG.old.md: -------------------------------------------------------------------------------- 1 | # Change Log 2 | All notable changes to this project will be documented in this file. 3 | 4 | 5 | ## [3.1.6] - 2023-01-08 6 | 7 | ### Fixed 8 | 9 | - Fixed the Tempest battery % calculation to show a more accurate calculation. 10 | 11 | ## [3.1.4] - 2022-12-29 12 | 13 | ### Added 14 | 15 | - Added new sensor `fog_probability` which returns the probability for fog based on current conditions. Thanks to @GlennGoddard for creating the formula. 16 | - Added new sensor `snow_probability` which returns the probability of snow based on current conditions. Thanks to @GlennGoddard for creating the formula. 17 | - Added new version attribute to the `sensor.hub_SERIAL_NUMBER_status`. This attribute will always hold the current version of the Docker Container. 18 | 19 | ### Changed 20 | 21 | - Updated the French Translations. Thank you @MichelJourdain 22 | - Issue #194. Adjusted Tempest minimum battery level to 2.11 V. The specs say that min is 1.8 V but experience show that below 2.11 the Tempest device does not work. So battery percent will now use this new min value. 23 | 24 | ## [3.1.3] - 2022-08-20 25 | 26 | ### Added 27 | 28 | - Added support for the Dutch Language. Big thanks to @vdbrink for adding this. 29 | 30 | ### Changed 31 | 32 | - Updated README to clarify how to get Station ID. 33 | 34 | ## [3.1.1] - 2022-07-12 35 | 36 | ### Fixed 37 | 38 | - Fixed Issue #163 Bumped `pyweatherflowudp` to V1.4.1. Thank you to @natekspencer. 39 | - Adjusted logic for calculate_sea_level_pressure to match WeatherFlow (https://weatherflow.github.io/Tempest/api/derived-metric-formulas.html#sea-level-pressure) 40 | 41 | ## [3.1.0] - 2022-07-03 42 | 43 | ### Changed 44 | 45 | - Bumped `pyweatherflowudp` to V1.4.0. Thank you to @natekspencer for keeping this module in good shape. 46 | - Adjusted logic for `wind_direction` and `wind_direction_cardinal` to report based on the last wind event or observation, whichever is most recent (similar to `wind_speed`) 47 | - Added properties for `wind_direction_average` and `wind_direction_average_cardinal` to report only on the average wind direction 48 | - Handle UnicodeDecodeError during message processing 49 | - Bump Pint to ^0.19 50 | - Bumped Docker image to `python:3.10-slim-buster`, to get on par with Home Assistant. 51 | 52 | **Breaking Changes**: 53 | 54 | - The properties `wind_direction` and `wind_direction_cardinal` now report based on the last wind event or observation, whichever is most recent. If you want the wind direction average (previous logic), please use the properties `wind_direction_average` and `wind_direction_average_cardinal`, respectively 55 | - The default symbol for `rain_rate` is now `mm/h` instead of `mm/hr` due to Pint 0.19 - https://github.com/hgrecco/pint/pull/1454 56 | 57 | ### Added 58 | 59 | - Added new sensor `solar_elevation` which returns Sun Elevation in Degrees with respect to the Horizon. Thanks to @GlennGoddard for creating the formula. 60 | **NOTE**: If you are not running this module as a HASS Add-On, you must ensure that environment variables `LATITUDE` and `LONGITUDE` are set in the Docker Startup command, as these values are used to calculate this value. 61 | - Added new sensor `solar_insolation` which returns Estimation of Solar Radiation at current sun elevation angle. Thanks to @GlennGoddard for creating the formula. 62 | - Added new sensors `zambretti_number` and `zambretti_text`, which are sensors that uses local data to create Weather Forecast for the near future. In order to optimize the forecast, these values need the *All Time High and Low Sea Level Presurre*. Per default these are set to 960Mb for Low and 1050Mb for High when using Metric units - 28.35inHg and 31.30inHg when using Imperial units. They can be changed by adding the Environment Variables `ZAMBRETTI_MIN_PRESSURE` and `ZAMBRETTI_MAX_PRESSURE` to the Docker Start command. Thanks to @GlennGoddard for creating the formula and help with all the testing on this. 63 | 64 | ## [3.0.8] - 2022-06-06 65 | 66 | ### Fixed 67 | 68 | - BUGFIX: Changed spelling error in `en.json` file. Tnanks to @jazzyisj 69 | - BUGFIX: Bump `pyweatherflowudp` to V1.3.1 to handle `up_since` oscillation on devices 70 | 71 | ### Added 72 | 73 | - NEW: Added Latitude and Longitude environment variables. These will be used in later additions for new calculated sensors. If you run the Supervised Add-On, then these will be provided automatically by Home Assistent. If you run a standalone docker container, you must add: `-e LATITUDE=your_latiude e- LONGITUDE=your_longitude` to the Docker startup command. 74 | 75 | 76 | ## [3.0.7] - 2022-01-12 77 | 78 | ### Fixed 79 | 80 | - BUGFIX: Don't add battery mode sensor for Air/Sky devices. If the sensor was already created, you will have to delete it manually in MQTT. 81 | - BUGFIX: In rare occasions the forecast icon is not present in data supplied from WeatherFlow. Will now be set to *Cloudy* as default. 82 | 83 | ## [3.0.6] - 2021-12-30 84 | 85 | ### Fixed 86 | 87 | - BUGFIX: Handle MQTT qos>0 messages appropriately by calling loop_start() on the MQTT client 88 | - See https://github.com/eclipse/paho.mqtt.python#client 89 | - Fixing Issue #46. 90 | 91 | ## [3.0.5] - 2021-12-24 92 | 93 | ### Changes 94 | 95 | - Add cloud base altitude and freezing level altitude sensors 96 | - Migrate sea level pressure sensor to use the pyweatherflowudp calculation 97 | - Bump pyweatherflowudp to 1.3.0 98 | 99 | ## [3.0.4] - 2021-12-22 100 | 101 | ### Changes 102 | 103 | - Add a debug log when updating forecast data and set qos=1 to ensure delivery 104 | - Add another debug log to indicate next forecast update 105 | - Docker container is now using the `slim-buster` version of Ubuntu instead of the full version, reducing the size of the Container to one third of the original size. 106 | 107 | 108 | ## [3.0.3] - 2021-12-15 109 | 110 | ### Changes 111 | 112 | - Add discord link to README 113 | - Remove obsolete references to obs[] data points 114 | 115 | ### Fixed 116 | 117 | - Handle timestamps in their own event so they are only updated when a value exists. Fixing #117 118 | - Fix high/low update 119 | 120 | ## [3.0.2] - 2021-12-11 121 | 122 | ### Changes 123 | 124 | - Uses rain_rate from the pyweatherflowudp package now that it is available. This should solve an issue with Heavy Rain #100 where, when on imperial, rain rate was first converted to in/hr but then passed to rain intensity which is based on a mm/hr rate 125 | - Sends the forecast data to MQTT with a retain=True value so that it can be restored on a Home Assistant restart instead of waiting for the next forecast update 126 | - Reduces the loops for setting up the hub sensor by separating device and hub sensors 127 | - Handles unknown timestamps for last lightning/rain so that it shows up as "Unknown" instead of "52 years ago" when there is no value 128 | - Changes the number of Decimal places for Air Density to 5. 129 | 130 | 131 | ## [3.0.1] - 2021-12-10 132 | 133 | ### Fixed 134 | 135 | - Issue #109. Adding better handling of missing data points when parsing messages which may occure when the firmware revision changes, to ensure the program keeps running. 136 | 137 | ## [3.0.0] - 2021-12-10 138 | 139 | This is the first part of a major re-write of this Add-On. Please note **this version has breaking changes** so ensure to read these release notes carefully. Most of the changes are related to the internal workings of this Add-On, and as a user you will not see a change in the data available in Home Assistant. However the Device structure has changed, to make it possible to support multiple devices. 140 | 141 | I want to extend a big THANK YOU to @natekspencer, who has done all the work on these changes. 142 | 143 | The UDP communication with the WeatherFlow Hub, has, until this version, been built in to the Add-On. This has now been split out to a separate module, which makes it a lot easier to maintain new data points going forward. 144 | @natekspencer has done a tremendous job in modernizing the [`pyweatherflowudp`](https://github.com/briis/pyweatherflowudp) package. This package does all the UDP communication with the WeatherFlow hub and using this module, we can now remove all that code from this Add-On. 145 | 146 | ### Breaking Changes 147 | 148 | - With the support for multiple devices per Hub, we need to ensure that we know what data comes from what device. All sensors will as minimum get a new name. Previously sensors were named `WF Sensor Name` now they will be named `DEVICE SERIAL_NUMBER Sensor Name`. The entity_id will be `sensor.devicename_serialnumber_SENSORNAME`. 149 | - For existing installations with templates, automations, scripts or more that reference the previous `sensor.wf_` entities, it may be easier to perform the following steps after updating to this release than finding everywhere they have been used: 150 | 1. Ensure you have advanced mode turned on for your user profile
[![Open your Home Assistant instance and show your Home Assistant user's profile.](https://my.home-assistant.io/badges/profile.svg)](https://my.home-assistant.io/redirect/profile/) 151 | 2. Navigate to integrations
[![Open your Home Assistant instance and show your integrations.](https://my.home-assistant.io/badges/integrations.svg)](https://my.home-assistant.io/redirect/integrations/) 152 | 3. Click on "# devices" under your MQTT integration 153 | 4. Click on the "WeatherFlow2MQTT" device (or whatever you renamed it to) and then delete it 154 | 5. Go back to the MQTT devices and click on one of your sensor devices (Air, Sky, Tempest) 155 | 6. Edit the device name and set it to "WF" 156 | 7. Click on "UPDATE" 157 | 8. A popup will ask if you also want to rename the entity IDs (requires advanced mode as stated in step 1). Click on "RENAME" and Home Assistant will rename all the entities to `sensor.wf_`. 158 | - If any entity IDs clash, you will get an error, but you can handle these individually as you deem necessary 159 | 9. You can then rename the device back to your sensor name. Just click "NO" on the popup asking to change the entity IDs or you will have to repeat the process 160 | 10. Repeat steps 5-9 for each sensor (mostly applicable to air & sky setups). 161 | - Status sensor is now a timestamp (referencing the up_since timestamp of the device) instead of the "humanized" time string since HA takes care of "humanizing" on the front end. This reduces state updates on the sensor since it doesn't have to update every time the uptime seconds change 162 | - `device`\_status (where device is hub, air, sky or tempest) is now just status 163 | - similarly, battery\_`sensor`, battery_level\_`sensor` and voltage\_`sensor` are now just battery, battery_level and voltage, respectively 164 | 165 | ### Changes 166 | 167 | - Multiple devices are now created in mqtt (one for each device) 168 | - Removes the TEMPEST_DEVICE environment variable/config option since we no longer need a user to tell us the type of device 169 | - You will get a warning in the supervisor logs about the TEMPEST_DEVICE option being set until it is removed from your add-on configuration yaml. 170 | 171 | ## [2.2.5] - 2021-12-04 172 | 173 | ### Fixed 174 | 175 | - With HA V2021.12 all date and time values need to be in utc time with timezone information. #105 176 | 177 | ## [2.2.4] - 2021-11-20 178 | 179 | ### Changed 180 | 181 | - @natekspencer did inital work to implement a easier to manage class structure to ease future development. 182 | - **BREAKING CHANGE** If you run the Non-Supervised mode of the program, you must make a change in your docker configuration to ensure you point to the same data directory as before. Change this `-v $(pwd): /usr/local/config` to this `-v $(pwd): /data` 183 | 184 | ## [2.2.3] - 2021-11-17 185 | 186 | ### Changed 187 | 188 | - @natekspencer optimized the unit conversion code. 189 | - New Logos for the Home Assistant Add-On 190 | 191 | ## [2.2.2] - 2021-11-15 192 | 193 | ### Changed 194 | 195 | - Issue #93. A user reported that Temperature sensors creates an error when being exported to `homekit`. This is not a consistent error, but might be due to unicoding of the degree symbol. The `unit_of_measurement` value has now been changed so that it reflects the constants from Home Assistant. 196 | 197 | ## [2.2.1] - 2021-11-13 198 | 199 | @natekspencer further enhanced the Home Assistant Add-On experience and made this more compliant with the way the Add-On is setup. Also he added a new option to filter out sensors _you do not want_, plus a few other great things you can read about below. Thank you @natekspencer. 200 | 201 | ### Changed 202 | 203 | - **BREAKING CHANGE** Move mapped volume from /usr/local/config to /data to support supervisor. If you are not running the Home Assistant supervised version, then you will need change this `v $(pwd):/usr/local/config` to this `v $(pwd):/data`. 204 | - Move configuration defaults to code and gracefully handle retrieval 205 | - Cleanup environment variables in Dockerfile since they are now handled in code 206 | - Simplify config loading between environment/supervisor options 207 | - Remove TZ option from HA supervisor configuration since it should be loaded from HA 208 | 209 | ### Added 210 | 211 | - Add options for FILTER_SENSORS and INVERT_FILTER to avoid having to load a config.yaml file in HA 212 | - Add a list of obsolete sensors that can be used to handle cleanup of old sensors when they are deprecated and removed 213 | 214 | ## [2.2.0] - 2021-11-10 215 | 216 | ### Changed 217 | 218 | - **BREAKING CHANGE** The sensor `sensor.wf_uptime` has been renamed to `sensor.wf_hub_status`. This sensor will now have more attributes on the status of the Hub, like Serial Number, FW Version etc. 219 | 220 | ### Added 221 | 222 | - Thanks to @natekspencer this image can now be installed and managed from the Home Assistant Add-On store. This is not part of the default store yet, but to use it from the Add-On store, just click the button 'ADD REPOSITORY' in the top of the README.md file. **NOTE** Remember to stop/remove the container running outside the Add-On store before attempting to install. 223 | - Depending on what HW you have, there will be 1 or 2 new sensors created, called either `sensor.wf_tempest_status` (If you have a Tempest device) or `sensor.wf_air_status` and `sensor.wf_sky_status`. The state of the sensors will display the Uptime of each device, and then there will attributes giving more details about each HW device. 224 | 225 | ## 2.1.1 226 | 227 | **Release Date**: October 17th, 2021 228 | 229 | ### Changes in release 2.1.1 230 | 231 | * `NEW`: Discussion #83, added new sensor called `sensor.wf_absolute_humidity`, which shows the actual amount of water in volume of air. Thank you to @GlennGoddard for creating the formula. 232 | 233 | ## Version 2.1.0 234 | 235 | **Release Date**: October 11th, 2021 236 | 237 | ### Changes in release 2.1.0 238 | 239 | * `FIX`: Issue #78, wrong Hex code used for decimal symbols. 240 | * `NEW`: Issue #77. Added new sensor called `sensor.wf_battery_mode`. This sensor reports a mode between 0 and 3, and the description for the mode is added as an attribute to the sensor. Basically it shows how the Tempest device operates with the current Voltage. You can read more about this on the [WeatherFlow Website](https://help.weatherflow.com/hc/en-us/articles/360048877194-Solar-Power-Rechargeable-Battery). **This sensor is only available for Tempest devices**
Thank you to @GlennGoddard for creating the formula. 241 | * `NEW`: Issue #81. Added `state_class` attributes to all relevant sensors, so that they can be used with [Long Term Statistics](https://www.home-assistant.io/blog/2021/08/04/release-20218/#long-term-statistics). See the README file for a list of supported sensors. 242 | * `NEW`: Discussion #83. Added new sensor `sensor.wf_rain_intensity`. This sensor shows a descriptive text about the current rain rate. See more on the [Weatherflow Community Forum](https://community.weatherflow.com/t/rain-intensity-values/806). The French and German translations are done by me, so they might need some checking to see if they are correct. 243 | 244 | ## Version 2.0.17 245 | 246 | **Release Date**: August 18th, 2021 247 | 248 | ### Changes in release 2.0.17 249 | 250 | `NEW`: Battery Percent sensors added. There will 2 new sensors if using AIR & SKY devices and 1 new if using the Tempest device. At the same time, the previous `battery` sensor name is now renamed to `voltage` as this is a more correct description of the value. Thanks to @GlennGoddard for creating the formula to do the conversion.
251 | **BREAKING** If you already have a running installation, you will have to manually rename two sensors entity_id, to correspond to the new naming: If you have a Tempest device rename `sensor.wf_battery_tempest` to `sensor.wf_voltage_tempest` and `sensor.wf_battery_tempest_2` to `sensor.wf_battery_tempest`. If you have AIR and SKY devices rename `sensor.wf_battery_sky` to `sensor.wf_voltage_sky` and `sensor.wf_battery_sky_2` to `sensor.wf_battery_sky` 252 | 253 | `NEW`: Wet Bulb Globe Temperature. The WetBulb Globe Temperature (WBGT) is a measure of the heat stress in direct sunlight. Thanks to @GlennGoddard for creating the formula 254 | 255 | `FIX`: Fixing issue #71. Device status was reported wrong. Thank you to @WM for catching this and proposing the fix. 256 | 257 | `CHANGE`: Ensure SHOW_DEBUG flag is used everywhere. 258 | 259 | `NEW`: Added German Translation. Thank you to @The-Deep-Sea for doing this. 260 | 261 | ## Version 2.0.16 262 | 263 | **Release Date**: August 9th, 2021 264 | 265 | Just back from vacation, I release what was is done now. There are more requests, which will be added in the following days. 266 | 267 | ### Changes in release 2.0.16 268 | 269 | `CHANGE`: Issue #60. Renamed the state for Rain to `heavy` from `heavy rain`. 270 | 271 | `FIX`: Visibility calculation not working. 272 | 273 | ## Version 2.0.15 274 | 275 | **Release Date**: August 9th, 2021 276 | 277 | Just back from vacation, I release what was is done now. There are more requests, which will be added in the following days. 278 | 279 | ### Changes in release 2.0.15 280 | 281 | `NEW`: **BREAKING CHANGE** 2 new Lightning sensors have been added, `lightning_strike_count_1hr` and `lightning_strike_count_3hr`. They represent the number of lightning strikes within the last hour and the last 3 hours. The 3 hour counter is in reality not new, as this was previously named `lightning_strike_count`, but has now been renamed. The `lightning_strike_count` now shows the number of lightning strikes in the last minute and can be used to give an indication of the severity of the thunderstorm. 282 | 283 | `FIX`: Issue #51. Delta_T value was wrong when using `imperial` units. The fix applied in 2.0.14 was not correct, but hopefully this works now. 284 | 285 | `NEW`: @crzykidd added a Docker Compose file, so if you are using Docker Compose, find the file `docker-compose.yml` and modify this with your own setup. 286 | 287 | `CHANGE`: @GlennGoddard fintuned the visibility calculation, so that sensor is now more accurate, taking more parameters in to account. 288 | 289 | ## Version 2.0.14 290 | 291 | **Release Date**: July 25th, 2021 292 | 293 | ### Changes in release 2.0.14 294 | 295 | `FIX`: Issue #44. A user reported a wrong value for the Forecast Condition icon. It cannot be reproduced, but this version adds better error handling, and logging of the value that causes the error, should it occur again. 296 | 297 | `FIX`: Issue #51. Delta_T value was wrong when using `imperial` units. Thanks to @GlennGoddard for spotting the issue, and suggesting the solution. 298 | 299 | ## Version 2.0.11 300 | 301 | **Release Date**: July 23rd, 2021 302 | 303 | ### Changes in release 2.0.11 304 | 305 | `FIX`: Visibility sensor caused a crash after 2.0.10, due to missing vars. This is now fixed. 306 | 307 | ## Version 2.0.10 308 | 309 | **Release Date**: July 20th, 2021 310 | 311 | ### Changes in release 2.0.10 312 | 313 | `CHANGE`: To support multi platform docker containers the new home for the container is on Docker Hub with the name **briis/weatherflow2mqtt**. This is where future upgrades will land. So please change your docker command to use this location. README file is updated with the location. 314 | So please change your docker command to use this location. README file is updated with the location. 315 | With this change, you should no longer have to build the container yourself if you run on a non-Intel HW platform like a Raspberry PI. 316 | I recommend you delete the current container and image, and then re-load it using the new location. 317 | 318 | `FIX`: Visibility sensor now takes in to account current weather conditions. Thanks to @GlennGoddard for making this change. Fixing issue #29 319 | 320 | ## Version 2.0.9 321 | 322 | **Release Date**: July 6th, 2021 323 | 324 | ### Changes in release 2.0.9 325 | 326 | `FIX`: Wetbulb Calculation crashed the system if one of the sensors had a NoneType value. Fixing issue #33 327 | 328 | `NEW`: Added French Tranlation, thanks to @titilambert. 329 | 330 | `FIX`: Issue #37, where the device status check could fail. thanks to @titilambert for fixing this. 331 | 332 | ## Version 2.0.8 333 | 334 | **Release Date**: June 24th, 2021 335 | 336 | ### Changes in release 2.0.8 337 | 338 | `FIX`: Wrong key in the Temperature Level description 339 | 340 | ## Version 2.0.7 341 | 342 | **Release Date**: June 24th, 2021 343 | 344 | ### Changes in release 2.0.7 345 | 346 | `NEW`: There is now multi language support for text states and other strings. Currently the support is limited to Danish (da) and English (en), and the default is English. In order to active another language than English add the following to the Docker Run command: `-e LANGUAGE=da`. If LANGUAGE is omitted, english will be used. So if this is the language you want, you don't have to do anything. 347 | If you want to help translate in to another language, go to Github and in the translations directory, download the `en.json` file, save it as `yourlanguagecode.json`, translate the strings, and make a pull request on Github. 348 | `FIX`: `sensor.wf_dewpoint_comfort_level` was not showing the correct value when using Imperial Units. 349 | `NEW`: A new sensor called `sensor.wf_beaufort_scale` is added. The Beaufort scale is an empirical measure that relates wind speed to observed conditions at sea or on land and holds a value between 0 and 12, where 0 is Calm and 12 is Hurricane force. The state holds the numeric value and there is an Attribute named `description` that holds the textual representation. 350 | 351 | ## Version 2.0.6 352 | 353 | **Release Date**: June 23rd, 2021 354 | 355 | ### Changes in release 2.0.6 356 | 357 | `FIX`: (Issue #28) The fix on release 2.0.5, was not completely solving the issue. Now a base Base value of Steady will be returned, if we are not able to calculate the Trend due to lack of data. 358 | `NEW`: (Issue #29) Adding new sensor `sensor.wf_dewpoint_comfort_level` which gives a textual representation of the Dewpoint value. 359 | `NEW`: (Issue #29) Adding new sensor `sensor.wf_temperature_level` which gives a textual representation of the Outside Air Temperature value. 360 | `NEW`: (Issue #29) Adding new sensor `sensor.wf_uv_level` which gives a textual representation of the UV Index value. 361 | 362 | ## Version 2.0.5 363 | 364 | **Release Date**: June 22nd, 2021 365 | 366 | ### Changes in release 2.0.5 367 | 368 | `FIX`: (Issue #28) Sometimes the Pressure Trend calculation would get the program to crash due a timing in when data was logged by the system. With this fix, a `None` value will be returned instead. 369 | 370 | ## Version 2.0.4 371 | 372 | **Release Date**: June 21st, 2021 373 | 374 | ### Changes in release 2.0.4 375 | 376 | `NEW`: A new sensor called `wf_wet_bulb_temperature` has been added. This sensor returns the temperature of a parcel of air cooled to saturation (100% relative humidity) 377 | `NEW`: A new sensor called `wf_delta_t` has been added. Delta T, is used in agriculture to indicate acceptable conditions for spraying pesticides and fertilizers. It is simply the difference between the air temperature (aka "dry bulb temperature") and the wet bulb temperature 378 | `NEW`: Added monthly min and max values to selected sensors. **Note** Data will only be updated once a day, so first values will be shown after midnight after the upgrade and new Attributes will require a restart of HA before they appear. 379 | `FIXED`: Daily Max value did not reset for some sensors at midnight. 380 | `FIXED`: When using the WeatherFlow forecast, there could be a mismatch in the condition state. 381 | `CHANGES`: Some *Under the Hood* changes to prepare for future enhancements. 382 | 383 | | Attribute Name | Description | 384 | | --- | --- | 385 | | `max_month` | Maximum value for the current month. Reset when new month. | 386 | | `max_month_time` | UTC time when the max value was recorded. Reset when new month. | 387 | | `min_month` | Minimum value for the current month. Reset when new month. | 388 | | `min_month_time` | UTC time when the min value was recorded. Reset when new month. | 389 | 390 | ## Version 2.0.3 391 | 392 | **Release Date**: June 18th, 2021 393 | 394 | ### Changes in release 2.0.3 395 | 396 | `NEW`: A new sensor called `wf_visibility`has been added. This sensor shows the distance to the horizon, in either km or nautical miles, depending on the unit_system. 397 | 398 | ## Version 2.0.2 399 | 400 | **Release Date**: June 18th, 2021 401 | 402 | ### Changes in release 2.0.2 403 | 404 | **Please make a backup of `weatherflow2mqtt.db` before upgrading. Just in case anything goes wrong.** 405 | 406 | `FIX`: If the forecast data from WeatherFlow is not available, the program will now just skip the update, and wait for the next timely update, instead of crashing the Container. 407 | 408 | `CHANGED`: Attributes for each sensors are now moved from the event topics, to each individual sensor, so that we can add sensor specific attributes. This will have no impact on a running system. 409 | 410 | `NEW`: Started the work on creating Sensors for High and Low values. A new table is created and daily high/low will be calculated and written to this table. Currently only day high and low plus all-time high and low values are calculated. The values are written as attributes to each individual sensor where I believe it is relevant to have these values. **Note** It takes 10 min before the daily max and min values are shown, and all-time values are first shown the following day after upgrading, or on the first run of this program. 411 | 412 | | Attribute Name | Description | 413 | | --- | --- | 414 | | `max_day` | Maximum value for the current day. Reset at midnight. | 415 | | `max_day_time` | UTC time when the max value was recorded. Reset at midnight. | 416 | | `min_day` | Minimum value for the current day. Reset at midnight. | 417 | | `min_day_time` | UTC time when the min value was recorded. Reset at midnight. | 418 | | `max_all` | Maximum value ever recorded. Updated at midnight every day. | 419 | | `max_all_time` | UTC time when the all-time max value was recorded. Updated at midnight every day. | 420 | | `min_all` | Minimum value ever recorded. Updated at midnight every day. | 421 | | `min_all_time` | UTC time when the all-time min value was recorded. Updated at midnight every day. | 422 | 423 | The following sensors are displaying Max and Min values: 424 | 425 | | Sensor ID | Max Value | Min Value | 426 | | --- | --- | --- | 427 | | `air_temperature` | Yes | Yes | 428 | | `dewpoint` | Yes | Yes | 429 | | `illuminance` | Yes | No | 430 | | `lightning_strike_count_today` | Yes | No | 431 | | `lightning_strike_energy` | Yes | No | 432 | | `rain_rate` | Yes | No | 433 | | `rain_duration_today` | Yes | No | 434 | | `relative_humidity` | Yes | Yes | 435 | | `sealevel_pressure` | Yes | Yes | 436 | | `solar_radiation` | Yes | No | 437 | | `uv` | Yes | No | 438 | | `wind_gust` | Yes | No | 439 | | `wind_lull` | Yes | No | 440 | | `wind_speed_avg` | Yes | No | 441 | 442 | ## Version 2.0.1 443 | 444 | **Release Date**: June 15th, 2021 445 | 446 | ### Changes in release 2.0.1 447 | 448 | * `FIX`: Fixing the AIR Density value, when using Imperial Metrics 449 | 450 | ## Version 2.0.0 451 | 452 | **Release Date**: June 15th, 2021 453 | 454 | ### Changes in release 2.0.0 455 | 456 | * `NEW`: There is now a new sensor called `pressure_trend`. This sensor monitors the Sea Level pressure changes. The Pressure Trend state is determined by the rate of change over the past 3 hours. It can be one of the following: `Steady`, `Falling` and `Rising`. Please note we will need to gather 3 hours of data, before the returned value will be correct. Until then the value will be `Steady`. 457 | * `NEW`: The simple storage system, that used two flat files, has now been rewritten, to use a SQLite Database instead. This will make future developments easier, and is also the foundation for the new Pressure Trend sensor. If you are already up and running with this program, your old data will automatically be migrated in to the new SQLite Database. And once you are confident that all is running, you can safely delete `.storage.json` and `.lightning.data`. 458 | * `BREAKING CHANGE`: To better seperate the sensors created by this Integration from other Weather related sensors, this version now prefixes all sensor names with `wf_` and all Friendly Names with `WF`. As each sensor has a Unique ID that does not change, the sensors will keep the old Entity Id, and just change the name, and only the Friendly Name will change after this upgrade. But if you delete the Integration, and re-add it, then all the sensors will have the `wf_` as a prefix. The same goes for new sensors that might be added in the future. So if you want to avoid any future issues, I recommend deleting the `WeatherFlow2MQTT` device from the MQTT Integration, and then restart the Docker Container, to get all the sensors added again, with the new naming convention. 459 | -------------------------------------------------------------------------------- /DOCS.md: -------------------------------------------------------------------------------- 1 | # Home Assistant Community Add-on: WeatherFlow2MQTT 2 | 3 | This add-on allows you to get data from a WeatherFlow weather station using UDP. There is support for both the new Tempest station and the older AIR & SKY station. 4 | 5 | **Important**: this add-on uses the same timezone and unit system as your Home Assistant instance, so make sure it has been properly set. 6 | 7 | ## Installation 8 | 9 | To install the add-on, first follow the installation steps from the [README on GitHub](https://github.com/briis/hass-weatherflow2mqtt/blob/main/README.md). 10 | 11 | ## Configuration 12 | 13 | ### Option: `ELEVATION`: (default: Home Assistant Elevation) 14 | 15 | Set the height above sea level for where the station is placed. This is used when calculating some of the sensor values. Station elevation plus Device height above ground. The value has to be in meters (`meters = feet * 0.3048`). Default is _Home Assistant Elevation_ 16 | 17 | ### Option: `LATITUDE`: (default: Home Assistant Latitude) 18 | 19 | Set the latitude for where the station is placed. This is used when calculating some of the sensor values. Default is _Home Assistant Latitude_ 20 | 21 | ### Option: `LONGITUDE`: (default: Home Assistant Longitude) 22 | 23 | Set the longitude for where the station is placed. This is used when calculating some of the sensor values. Default is _Home Assistant Longitude_ 24 | 25 | ### Option: `RAPID_WIND_INTERVAL`: (default: 0) 26 | 27 | The weather stations delivers wind speed and bearing every 2 seconds. If you don't want to update the HA sensors so often, you can set a number here (in seconds), for how often they are updated. Default is _0_, which means data are updated when received from the station. 28 | 29 | ### Option: `STATION_ID`: (default: None) 30 | 31 | Enter your Station ID for your WeatherFlow Station. 32 | 33 | ### Option: `STATION_TOKEN`: (default: None) 34 | 35 | Enter your personal access Token to allow retrieval of data. If you don't have the token [login with your account](https://tempestwx.com/settings/tokens) and create the token. **NOTE** You must own a WeatherFlow station to get this token. 36 | 37 | ### Option: `FORECAST_INTERVAL`: (default: 30) 38 | 39 | The interval in minutes, between updates of the Forecast data. 40 | 41 | ### Option: `LANGUAGE`: (default: en) 42 | 43 | Use this to set the language for Wind Direction cardinals and other sensors with text strings as state value. These strings will then be displayed in HA in the selected language. 44 | 45 | ### Option: `FILTER_SENSORS`: (default: None) 46 | 47 | A comma-separated list of sensors to include instead of loading all sensors. Default is _None_, which disables filtering such that all sensors are loaded. 48 | 49 | ### Option: `INVERT_FILTER`: (default: False) 50 | 51 | If set to True, `FILTER_SENSORS` will be treated as an exclusion list such that the specified sensors are ignored. Default is _False_. 52 | 53 | ### Option: `MQTT_HOST`: (default: Installed MQTT Add-On IP) 54 | 55 | The IP address of your mqtt server. Even though you have the MQTT Server on the same machine as this Container, don't use `127.0.0.1` as this will resolve to an IP Address inside your container. Use the external IP Address. 56 | 57 | ### Option: `MQTT_PORT`: (default: 1883) 58 | 59 | The Port for your mqtt server. Default value is _1883_ 60 | 61 | ### Option: `MQTT_USERNAME`: (default: Installed MQTT Add-On username) 62 | 63 | The username used to connect to the mqtt server. Leave blank to use Anonymous connection. 64 | 65 | ### Option: `MQTT_PASSWORD`: (default: Installed MQTT Add-On password) 66 | 67 | The password used to connect to the mqtt server. Leave blank to use Anonymous connection. 68 | 69 | ### Option: `MQTT_DEBUG`: (default: False) 70 | 71 | Set this to True, to get some more mqtt debugging messages in the Container log file. 72 | 73 | ### Option: `WF_HOST`: (default: 0.0.0.0) 74 | 75 | Unless you have a very special IP setup or the Weatherflow hub is on a different network, you should not change this. Default is _0.0.0.0_ 76 | 77 | ### Option: `WF_PORT`: (default: 50222) 78 | 79 | Weatherflow always broadcasts on port 50222/udp, so don't change this. Default is _50222_ 80 | 81 | ### Option: `DEBUG`: (default: False) 82 | 83 | Set this to True to enable more debug data in the Container Log. 84 | 85 | ## Troubleshooting 86 | 87 | ### VLANs and Subnets 88 | WeatherFlow2MQTT will not discover your base station if it is on a separate VLAN/subnet from your Home Assistant/Docker instance. Common indications that you are on a different VLAN are error messages such as: 89 | ```weatherflow2mqtt.weatherflow_mqtt:Could not start listening to the UDP Socket. Error is: Could not open a local UDP endpoint``` 90 | 91 | Although WeatherFlow2MQTT allows you to manually specify the Weatherflow host, there will still be issues getting the UDP broadcast to travel across the VLAN. 92 | 93 | ## Authors & contributors 94 | 95 | The original setup of this repository is by [Bjarne Riis](https://github.com/briis). 96 | 97 | For a full list of all authors and contributors, check the [contributor's page](https://github.com/briis/hass-weatherflow2mqtt/graphs/contributors). 98 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3.11-slim-buster 2 | LABEL org.opencontainers.image.source="https://github.com/briis/hass-weatherflow2mqtt" 3 | 4 | RUN mkdir -p /data 5 | WORKDIR /src/weatherflow2mqtt 6 | 7 | ADD requirements.txt test_requirements.txt /src/weatherflow2mqtt/ 8 | ADD weatherflow2mqtt /src/weatherflow2mqtt/weatherflow2mqtt/ 9 | ADD setup.py /src/weatherflow2mqtt/ 10 | 11 | RUN apt-get update \ 12 | && apt-get -y install build-essential \ 13 | && pip install --upgrade --no-cache-dir pip \ 14 | && pip install --no-cache-dir -r requirements.txt \ 15 | && python setup.py install \ 16 | && apt-get purge -y --auto-remove build-essential \ 17 | && rm -rf /var/lib/apt/lists/* 18 | 19 | 20 | ENV TZ=Europe/Copenhagen 21 | 22 | EXPOSE 50222/udp 23 | EXPOSE 1883 24 | 25 | CMD [ "weatherflow2mqtt" ] 26 | -------------------------------------------------------------------------------- /LATEST_CHANGES.md: -------------------------------------------------------------------------------- 1 | # Change Log 2 | 3 | All notable changes to this project will be documented in this file. 4 | 5 | ## [3.2.2] - 2023-10-08 6 | 7 | ### BREAKING Announcement 8 | 9 | As there is now a `Home Assistant Core` integration for WeatherFlow which uses the UDP API, I had to make a [new Integration](https://github.com/briis/weatherflow_forecast) that uses the REST API, with a different name (WeatherFlow Forecast). The new integration is up-to-date with the latest specs for how to create a Weather Forecast, and also gives the option to only add the Forecast, and no additional sensors. 10 | 11 | There is no *Weather Entity* in Home Assistant for MQTT, so after attributes are deprecated in Home Assistant 2024.3, there is no option to add the Forecast to Home Assistant. 12 | As a consequence of that, I have decided to remove the ability for this Add-On to add Forecast data to MQTT and Home Assistant. This Add-On will still be maintained, but just without the option of a Forecast - meaning it will be 100% local. 13 | If you want the forecast in combination with this Add-On, install the new integration mentioned above, just leave the *Add sensors* box unchecked. 14 | 15 | There is not an exact date for when this will happen, but it will be before end of February 2024. 16 | 17 | ### Changes 18 | 19 | - Added Slovenian language file. This was unfortunately placed in a wrong directory and as such it was not read by the integration. Fixing issue #236 20 | - Fixed issue #244 with deprecated forecast values. Thank you @mjmeli 21 | - Corrected visibility imperial unit from nautical mile (nmi) to mile (mi) -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Bjarne Riis 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 | -------------------------------------------------------------------------------- /config.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "WeatherFlow to MQTT", 3 | "description": "WeatherFlow to MQTT for Home Assistant", 4 | "slug": "weatherflow2mqtt", 5 | "url": "https://github.com/briis/hass-weatherflow2mqtt", 6 | "image": "briis/weatherflow2mqtt", 7 | "version": "3.2.2", 8 | "arch": [ 9 | "armv7", 10 | "aarch64", 11 | "amd64" 12 | ], 13 | "startup": "application", 14 | "boot": "auto", 15 | "ports": { 16 | "50222/udp": 50222 17 | }, 18 | "ports_description": { 19 | "50222/udp": "WeatherFlow socket" 20 | }, 21 | "environment": { 22 | "HA_SUPERVISOR": "True" 23 | }, 24 | "homeassistant_api": true, 25 | "services": [ 26 | "mqtt:want" 27 | ], 28 | "options": {}, 29 | "schema": { 30 | "ELEVATION": "float?", 31 | "LATITUDE": "float?", 32 | "LONGITUDE": "float?", 33 | "RAPID_WIND_INTERVAL": "int?", 34 | "STATION_ID": "str?", 35 | "STATION_TOKEN": "str?", 36 | "FORECAST_INTERVAL": "int?", 37 | "LANGUAGE": "list(en|da|de|fr|nl|se)?", 38 | "FILTER_SENSORS": "str?", 39 | "INVERT_FILTER": "bool?", 40 | "MQTT_HOST": "str?", 41 | "MQTT_PORT": "port?", 42 | "MQTT_USERNAME": "str?", 43 | "MQTT_PASSWORD": "password?", 44 | "MQTT_DEBUG": "bool?", 45 | "WF_HOST": "str?", 46 | "WF_PORT": "port?", 47 | "DEBUG": "bool?", 48 | "ZAMBRETTI_MIN_PRESSURE": "float?", 49 | "ZAMBRETTI_MAX_PRESSURE": "float?" 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /config_example_AIR_SKY.yaml: -------------------------------------------------------------------------------- 1 | sensors: 2 | - absolute_humidity 3 | - air_temperature 4 | - battery 5 | - battery_level 6 | - beaufort 7 | - dewpoint 8 | - dewpoint_description 9 | - delta_t 10 | - feelslike 11 | - illuminance 12 | - lightning_strike_count 13 | - lightning_strike_count_1hr 14 | - lightning_strike_count_3hr 15 | - lightning_strike_count_today 16 | - lightning_strike_distance 17 | - lightning_strike_energy 18 | - lightning_strike_time 19 | - precipitation_type 20 | - pressure_trend 21 | - rain_intensity 22 | - rain_rate 23 | - rain_start_time 24 | - rain_today 25 | - rain_yesterday 26 | - rain_duration_today 27 | - rain_duration_yesterday 28 | - relative_humidity 29 | - sealevel_pressure 30 | - sky_status 31 | - solar_elevation 32 | - solar_insolation 33 | - solar_radiation 34 | - station_pressure 35 | - status 36 | - temperature_description 37 | - uv 38 | - uv_description 39 | - visibility 40 | - wetbulb 41 | - wbgt #wet_bulb_globe_temperature 42 | - wind_bearing 43 | - wind_bearing_avg 44 | - wind_direction 45 | - wind_direction_avg 46 | - wind_gust 47 | - wind_lull 48 | - wind_speed 49 | - wind_speed_avg 50 | - weather # Only if you are loading the Forecast 51 | -------------------------------------------------------------------------------- /config_example_TEMPEST.yaml: -------------------------------------------------------------------------------- 1 | sensors: 2 | - absolute_humidity 3 | - air_temperature 4 | - battery 5 | - battery_level 6 | - battery_mode 7 | - beaufort 8 | - dewpoint 9 | - dewpoint_description 10 | - delta_t 11 | - feelslike 12 | - illuminance 13 | - lightning_strike_count 14 | - lightning_strike_count_1hr 15 | - lightning_strike_count_3hr 16 | - lightning_strike_count_today 17 | - lightning_strike_distance 18 | - lightning_strike_energy 19 | - lightning_strike_time 20 | - precipitation_type 21 | - pressure_trend 22 | - rain_intensity 23 | - rain_rate 24 | - rain_start_time 25 | - rain_today 26 | - rain_yesterday 27 | - rain_duration_today 28 | - rain_duration_yesterday 29 | - relative_humidity 30 | - sealevel_pressure 31 | - solar_elevation 32 | - solar_insolation 33 | - solar_radiation 34 | - station_pressure 35 | - temperature_description 36 | - tempest_status 37 | - uv 38 | - uv_description 39 | - visibility 40 | - wetbulb 41 | - wbgt #wet_bulb_globe_temperature 42 | - wind_bearing 43 | - wind_bearing_avg 44 | - wind_direction 45 | - wind_direction_avg 46 | - wind_gust 47 | - wind_lull 48 | - wind_speed 49 | - wind_speed_avg 50 | - weather # Only if you are loading the Forecast 51 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: "2" 2 | services: 3 | weatherflow2mqtt: 4 | image: briis/weatherflow2mqtt:latest 5 | restart: unless-stopped 6 | environment: 7 | - TZ=America/Los_Angeles 8 | - UNIT_SYSTEM=imperial 9 | - LANGUAGE=en 10 | - RAPID_WIND_INTERVAL=0 11 | - DEBUG=False 12 | - ELEVATION=0 13 | - LATITUDE=00.0000 14 | - LONGITUDE=000.0000 15 | - ZAMBRETTI_MIN_PRESSURE= 16 | - ZAMBRETTI_MAX_PRESSURE= 17 | - WF_HOST=0.0.0.0 18 | - WF_PORT=50222 19 | - MQTT_HOST= 20 | - MQTT_PORT=1883 21 | - MQTT_USERNAME= 22 | - MQTT_PASSWORD= 23 | - MQTT_DEBUG=False 24 | - STATION_ID= 25 | - STATION_TOKEN= 26 | - FORECAST_INTERVAL=30 27 | volumes: 28 | - /YOUR_STORAGE_AREA/PATH:/data 29 | ports: 30 | - 0.0.0.0:50222:50222/udp 31 | -------------------------------------------------------------------------------- /hass-weatherflow2mqtt.code-workspace: -------------------------------------------------------------------------------- 1 | { 2 | "folders": [ 3 | { 4 | "path": "." 5 | } 6 | ], 7 | "settings": { 8 | "python.pythonPath": "/usr/local/bin/python3" 9 | } 10 | } -------------------------------------------------------------------------------- /icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/briis/hass-weatherflow2mqtt/24cfd044480f272c8432728e7bbad7d0a66750a3/icon.png -------------------------------------------------------------------------------- /logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/briis/hass-weatherflow2mqtt/24cfd044480f272c8432728e7bbad7d0a66750a3/logo.png -------------------------------------------------------------------------------- /repository.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "WeatherFlow to MQTT", 3 | "url": "https://github.com/briis/hass-weatherflow2mqtt" 4 | } -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | paho-mqtt==1.6.1 2 | aiohttp==3.8.4 3 | PyYAML==6.0.1 4 | pytz==2023.3 5 | pyweatherflowudp==1.4.2 6 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | import sys 2 | 3 | from setuptools import setup 4 | from weatherflow2mqtt.__version__ import VERSION 5 | 6 | if sys.version_info < (3,10): 7 | sys.exit('Sorry, Python < 3.10 is not supported') 8 | 9 | install_requires = list(val.strip() for val in open('requirements.txt')) 10 | tests_require = list(val.strip() for val in open('test_requirements.txt')) 11 | 12 | setup(name='weatherflow2mqtt', 13 | version=VERSION, 14 | description=('WeatherFlow-2-MQTT for Home Assistant'), 15 | author='Bjarne Riis', 16 | author_email='bjarne@briis.com', 17 | url='https://github.com/briis/hass-weatherflow2mqtt', 18 | package_data={ 19 | '': ['LICENSE.txt'], 20 | 'weatherflow2mqtt': ['translations/*.json'], 21 | }, 22 | include_package_data=True, 23 | packages=['weatherflow2mqtt'], 24 | entry_points={ 25 | 'console_scripts': [ 26 | 'weatherflow2mqtt = weatherflow2mqtt.__main__:main' 27 | ] 28 | }, 29 | license='MIT', 30 | install_requires=install_requires, 31 | tests_require=tests_require, 32 | classifiers=[ 33 | 'Programming Language :: Python :: 3.10', 34 | ] 35 | 36 | ) 37 | -------------------------------------------------------------------------------- /test_requirements.txt: -------------------------------------------------------------------------------- 1 | flake8 2 | pycodestyle==2.5.0 3 | pydocstyle 4 | pylint 5 | pytest==4.4.1 6 | pytest-cov==2.6.1 7 | pytest-timeout==1.3.3 8 | #Sphinx==1.8.4 9 | #sphinx-rtd-theme==0.4.3 10 | #tox==3.9.0 11 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [flake8] 2 | ignore = E226,E302,E41, W503 3 | max-line-length = 100 4 | exclude = tests/* 5 | max-complexity = 10 6 | -------------------------------------------------------------------------------- /weatherflow2mqtt/__init__.py: -------------------------------------------------------------------------------- 1 | """A Program to receive UDP data from Weatherflow and Publish to MQTT.""" 2 | -------------------------------------------------------------------------------- /weatherflow2mqtt/__main__.py: -------------------------------------------------------------------------------- 1 | """Main module.""" 2 | import asyncio 3 | 4 | from weatherflow2mqtt import weatherflow_mqtt 5 | 6 | 7 | def main(): 8 | """Start Main Program.""" 9 | try: 10 | asyncio.run(weatherflow_mqtt.main()) 11 | except KeyboardInterrupt: 12 | print("\nExiting Program") 13 | 14 | 15 | if __name__ == "__main__": 16 | main() 17 | -------------------------------------------------------------------------------- /weatherflow2mqtt/__version__.py: -------------------------------------------------------------------------------- 1 | """Module defining version.""" 2 | VERSION = "3.2.2" 3 | -------------------------------------------------------------------------------- /weatherflow2mqtt/const.py: -------------------------------------------------------------------------------- 1 | """Constant file for weatherflow2mqtt.""" 2 | import datetime 3 | import os 4 | 5 | ATTRIBUTION = "Powered by WeatherFlow2MQTT" 6 | DOMAIN = "weatherflow2mqtt" 7 | MANUFACTURER = "WeatherFlow" 8 | 9 | ATTR_ATTRIBUTION = "attribution" 10 | ATTR_FORECAST_CONDITION = "condition" 11 | ATTR_FORECAST_PRECIPITATION = "precipitation" 12 | ATTR_FORECAST_PRECIPITATION_PROBABILITY = "precipitation_probability" 13 | ATTR_FORECAST_PRESSURE = "pressure" 14 | ATTR_FORECAST_TEMP = "temperature" 15 | ATTR_FORECAST_TEMP_LOW = "templow" 16 | ATTR_FORECAST_TIME = "datetime" 17 | ATTR_FORECAST_WIND_BEARING = "wind_bearing" 18 | ATTR_FORECAST_WIND_SPEED = "wind_speed" 19 | ATTR_FORECAST_HUMIDITY = "humidity" 20 | 21 | EXTERNAL_DIRECTORY = os.environ.get("EXTERNAL_DIRECTORY", "/data") 22 | INTERNAL_DIRECTORY = "/app" 23 | STORAGE_FILE = f"{EXTERNAL_DIRECTORY}/.storage.json" 24 | DATABASE = f"{EXTERNAL_DIRECTORY}/weatherflow2mqtt.db" 25 | DATABASE_VERSION = 2 26 | STORAGE_ID = 1 27 | 28 | TABLE_STORAGE = """ CREATE TABLE IF NOT EXISTS storage ( 29 | id integer PRIMARY KEY, 30 | rain_today real, 31 | rain_yesterday real, 32 | rain_start real, 33 | rain_duration_today integer, 34 | rain_duration_yesterday integer, 35 | lightning_count integer, 36 | lightning_count_today integer, 37 | last_lightning_time real, 38 | last_lightning_distance integer, 39 | last_lightning_energy 40 | );""" 41 | 42 | TABLE_PRESSURE = """ CREATE TABLE IF NOT EXISTS pressure ( 43 | timestamp real PRIMARY KEY, 44 | pressure real 45 | );""" 46 | 47 | TABLE_LIGHTNING = """ CREATE TABLE IF NOT EXISTS lightning ( 48 | timestamp real PRIMARY KEY 49 | );""" 50 | 51 | TABLE_HIGH_LOW = """ 52 | CREATE TABLE IF NOT EXISTS high_low ( 53 | sensorid TEXT PRIMARY KEY, 54 | latest REAL, 55 | max_day REAL, 56 | max_day_time REAL, 57 | min_day REAL, 58 | min_day_time REAL, 59 | max_yday REAL, 60 | max_yday_time REAL, 61 | min_yday REAL, 62 | min_yday_time REAL, 63 | max_week REAL, 64 | max_week_time REAL, 65 | min_week REAL, 66 | min_week_time REAL, 67 | max_month REAL, 68 | max_month_time REAL, 69 | min_month REAL, 70 | min_month_time REAL, 71 | max_year REAL, 72 | max_year_time REAL, 73 | min_year REAL, 74 | min_year_time REAL, 75 | max_all REAL, 76 | max_all_time REAL, 77 | min_all REAL, 78 | min_all_time REAL 79 | ); 80 | """ 81 | 82 | TABLE_DAY_DATA = """ CREATE TABLE IF NOT EXISTS day_data ( 83 | timestamp REAL PRIMARY KEY, 84 | air_temperature REAL, 85 | relative_humidity REAL, 86 | dewpoint REAL, 87 | illuminance REAL, 88 | rain_duration_today REAL, 89 | rain_rate REAL, 90 | wind_gust REAL, 91 | wind_lull REAL, 92 | wind_speed_avg REAL, 93 | lightning_strike_energy REAL, 94 | lightning_strike_count_today REAL, 95 | sealevel_pressure REAL, 96 | uv REAL, 97 | solar_radiation REAL 98 | );""" 99 | 100 | COL_TEMPERATURE = "air_temperature" 101 | COL_HUMIDITY = "relative_humidity" 102 | COL_DEWPOINT = "dewpoint" 103 | COL_ILLUMINANCE = "illuminance" 104 | COL_RAINDURATION = "rain_duration_today" 105 | COL_RAINRATE = "rain_rate" 106 | COL_WINDGUST = "wind_gust" 107 | COL_WINDLULL = "wind_lull" 108 | COL_WINDSPEED = "wind_speed_avg" 109 | COL_STRIKEENERGY = "lightning_strike_energy" 110 | COL_STRIKECOUNT = "lightning_strike_count_today" 111 | COL_PRESSURE = "sealevel_pressure" 112 | COL_UV = "uv" 113 | COL_SOLARRAD = "solar_radiation" 114 | 115 | BASE_URL = "https://swd.weatherflow.com/swd/rest" 116 | 117 | BATTERY_MODE_DESCRIPTION = [ 118 | "All sensors enabled and operating at full performance. Wind sampling interval every 3 seconds", 119 | "Wind sampling interval set to 6 seconds", 120 | "Wind sampling interval set to one minute", 121 | "Wind sampling interval set to 5 minutes. All other sensors sampling interval set to 5 minutes. Haptic Rain sensor disabled from active listening", 122 | ] 123 | 124 | DEFAULT_TIMEOUT = 10 125 | 126 | DEVICE_CLASS_BATTERY = "battery" 127 | DEVICE_CLASS_DISTANCE = "distance" 128 | DEVICE_CLASS_HUMIDITY = "humidity" 129 | DEVICE_CLASS_ILLUMINANCE = "illuminance" 130 | DEVICE_CLASS_IRRADIANCE = "irradiance" 131 | DEVICE_CLASS_PRECIPITATION="precipitation" 132 | DEVICE_CLASS_PRECIPITATION_INTENSITY="precipitation_intensity" 133 | DEVICE_CLASS_PRESSURE = "pressure" 134 | DEVICE_CLASS_TEMPERATURE = "temperature" 135 | DEVICE_CLASS_TIMESTAMP = "timestamp" 136 | DEVICE_CLASS_VOLTAGE = "voltage" 137 | DEVICE_CLASS_WIND_SPEED="wind_speed" 138 | 139 | 140 | STATE_CLASS_MEASUREMENT = "measurement" 141 | STATE_CLASS_INCREASING = "total_increasing" 142 | 143 | EVENT_FORECAST = "weather" 144 | EVENT_HIGH_LOW = "high_low" 145 | 146 | FORECAST_TYPE_DAILY = "daily" 147 | FORECAST_TYPE_HOURLY = "hourly" 148 | FORECAST_ENTITY = "weather" 149 | FORECAST_HOURLY_HOURS = 36 150 | 151 | STRIKE_COUNT_TIMER = 3 * 60 * 60 152 | PRESSURE_TREND_TIMER = 3 * 60 * 60 153 | HIGH_LOW_TIMER = 10 * 60 154 | 155 | LANGUAGE_ENGLISH = "en" 156 | LANGUAGE_DANISH = "da" 157 | LANGUAGE_GERMAN = "de" 158 | LANGUAGE_FRENCH = "fr" 159 | LANGUAGE_DUTCH = "nl" 160 | LANGUAGE_SLOVENIA = "si" 161 | SUPPORTED_LANGUAGES = [ 162 | LANGUAGE_ENGLISH, 163 | LANGUAGE_DANISH, 164 | LANGUAGE_GERMAN, 165 | LANGUAGE_FRENCH, 166 | LANGUAGE_DUTCH, 167 | LANGUAGE_SLOVENIA, 168 | ] 169 | 170 | TEMP_CELSIUS = "°C" 171 | TEMP_FAHRENHEIT = "°F" 172 | UNITS_IMPERIAL = "imperial" 173 | UNITS_METRIC = "metric" 174 | 175 | UTC = datetime.timezone.utc 176 | 177 | ZAMBRETTI_MIN_PRESSURE = 960 178 | ZAMBRETTI_MAX_PRESSURE = 1060 179 | 180 | CONDITION_CLASSES = { 181 | "clear-night": ["clear-night"], 182 | "cloudy": ["cloudy"], 183 | "exceptional": ["cloudy"], 184 | "fog": ["foggy"], 185 | "hail": ["hail"], 186 | "lightning": ["thunderstorm"], 187 | "lightning-rainy": ["possibly-thunderstorm-day", "possibly-thunderstorm-night"], 188 | "partlycloudy": [ 189 | "partly-cloudy-day", 190 | "partly-cloudy-night", 191 | ], 192 | "rainy": [ 193 | "rainy", 194 | "possibly-rainy-day", 195 | "possibly-rainy-night", 196 | ], 197 | "snowy": ["snow", "possibly-snow-day", "possibly-snow-night"], 198 | "snowy-rainy": ["sleet", "possibly-sleet-day", "possibly-sleet-night"], 199 | "sunny": ["clear-day"], 200 | "windy": ["windy"], 201 | } 202 | -------------------------------------------------------------------------------- /weatherflow2mqtt/forecast.py: -------------------------------------------------------------------------------- 1 | """Module to get forecast using REST from WeatherFlow.""" 2 | from __future__ import annotations 3 | 4 | import asyncio 5 | import logging 6 | from dataclasses import dataclass 7 | from datetime import datetime 8 | from typing import Any, OrderedDict 9 | 10 | from aiohttp import ClientSession, ClientTimeout 11 | from aiohttp.client_exceptions import ClientError 12 | 13 | from .const import ( 14 | ATTR_ATTRIBUTION, 15 | ATTR_FORECAST_CONDITION, 16 | ATTR_FORECAST_HUMIDITY, 17 | ATTR_FORECAST_PRECIPITATION, 18 | ATTR_FORECAST_PRECIPITATION_PROBABILITY, 19 | ATTR_FORECAST_PRESSURE, 20 | ATTR_FORECAST_TEMP, 21 | ATTR_FORECAST_TEMP_LOW, 22 | ATTR_FORECAST_TIME, 23 | ATTR_FORECAST_WIND_BEARING, 24 | ATTR_FORECAST_WIND_SPEED, 25 | ATTRIBUTION, 26 | BASE_URL, 27 | CONDITION_CLASSES, 28 | DEFAULT_TIMEOUT, 29 | FORECAST_HOURLY_HOURS, 30 | FORECAST_TYPE_DAILY, 31 | FORECAST_TYPE_HOURLY, 32 | LANGUAGE_ENGLISH, 33 | UNITS_METRIC, 34 | ) 35 | from .helpers import ConversionFunctions 36 | 37 | _LOGGER = logging.getLogger(__name__) 38 | 39 | 40 | @dataclass 41 | class ForecastConfig: 42 | """Forecast config.""" 43 | 44 | station_id: str 45 | token: str 46 | interval: int = 30 47 | 48 | 49 | class Forecast: 50 | """Forecast.""" 51 | 52 | def __init__( 53 | self, 54 | station_id: str, 55 | token: str, 56 | interval: int = 30, 57 | conversions: ConversionFunctions = ConversionFunctions( 58 | unit_system=UNITS_METRIC, language=LANGUAGE_ENGLISH 59 | ), 60 | session: ClientSession | None = None, 61 | ): 62 | """Initialize a Forecast object.""" 63 | self.station_id = station_id 64 | self.token = token 65 | self.interval = interval 66 | self.conversions = conversions 67 | self._session: ClientSession = session 68 | 69 | @classmethod 70 | def from_config( 71 | cls, 72 | config: ForecastConfig, 73 | conversions: ConversionFunctions = ConversionFunctions( 74 | unit_system=UNITS_METRIC, language=LANGUAGE_ENGLISH 75 | ), 76 | session: ClientSession | None = None, 77 | ) -> Forecast: 78 | """Create a Forecast from a Forecast Config.""" 79 | return cls( 80 | station_id=config.station_id, 81 | token=config.token, 82 | interval=config.interval, 83 | conversions=conversions, 84 | session=session, 85 | ) 86 | 87 | async def update_forecast(self): 88 | """Return the formatted forecast data.""" 89 | json_data = await self.async_request( 90 | method="get", 91 | endpoint=f"better_forecast?station_id={self.station_id}&token={self.token}", 92 | ) 93 | items = [] 94 | 95 | if json_data is not None: 96 | # We need a few Items from the Current Conditions section 97 | current_cond = json_data.get("current_conditions") 98 | current_icon = current_cond["icon"] 99 | today = datetime.date(datetime.now()) 100 | 101 | # Prepare for MQTT 102 | condition_data = OrderedDict() 103 | condition_state = self.ha_condition_value(current_icon) 104 | condition_data["weather"] = condition_state 105 | 106 | forecast_data = json_data.get("forecast") 107 | 108 | # We also need Day hign and low Temp from Today 109 | temp_high_today = self.conversions.temperature( 110 | forecast_data[FORECAST_TYPE_DAILY][0]["air_temp_high"] 111 | ) 112 | temp_low_today = self.conversions.temperature( 113 | forecast_data[FORECAST_TYPE_DAILY][0]["air_temp_low"] 114 | ) 115 | 116 | # Process Daily Forecast 117 | fcst_data = OrderedDict() 118 | fcst_data[ATTR_ATTRIBUTION] = ATTRIBUTION 119 | fcst_data["temp_high_today"] = temp_high_today 120 | fcst_data["temp_low_today"] = temp_low_today 121 | 122 | for row in forecast_data[FORECAST_TYPE_DAILY]: 123 | # Skip over past forecasts - seems the API sometimes returns old forecasts 124 | forecast_time = datetime.date( 125 | datetime.fromtimestamp(row["day_start_local"]) 126 | ) 127 | if today > forecast_time: 128 | continue 129 | 130 | # Calculate data from hourly that's not summed up in the daily. 131 | precip = 0 132 | wind_avg = [] 133 | wind_bearing = [] 134 | for hourly in forecast_data["hourly"]: 135 | if hourly["local_day"] == row["day_num"]: 136 | precip += hourly["precip"] 137 | wind_avg.append(hourly["wind_avg"]) 138 | wind_bearing.append(hourly["wind_direction"]) 139 | sum_wind_avg = sum(wind_avg) / len(wind_avg) 140 | sum_wind_bearing = sum(wind_bearing) / len(wind_bearing) % 360 141 | 142 | item = { 143 | ATTR_FORECAST_TIME: self.conversions.utc_from_timestamp( 144 | row["day_start_local"] 145 | ), 146 | ATTR_FORECAST_CONDITION: "cloudy" if row.get("icon") is None else self.ha_condition_value(row["icon"]), 147 | ATTR_FORECAST_TEMP: self.conversions.temperature( 148 | row["air_temp_high"] 149 | ), 150 | ATTR_FORECAST_TEMP_LOW: self.conversions.temperature( 151 | row["air_temp_low"] 152 | ), 153 | ATTR_FORECAST_PRECIPITATION: self.conversions.rain(precip), 154 | ATTR_FORECAST_PRECIPITATION_PROBABILITY: row["precip_probability"], 155 | ATTR_FORECAST_WIND_SPEED: self.conversions.speed( 156 | sum_wind_avg, True 157 | ), 158 | ATTR_FORECAST_WIND_BEARING: int(sum_wind_bearing), 159 | } 160 | items.append(item) 161 | fcst_data["daily_forecast"] = items 162 | 163 | cnt = 0 164 | items = [] 165 | for row in forecast_data[FORECAST_TYPE_HOURLY]: 166 | # Skip over past forecasts - seems the API sometimes returns old forecasts 167 | forecast_time = datetime.fromtimestamp(row["time"]) 168 | if datetime.now() > forecast_time: 169 | continue 170 | 171 | item = { 172 | ATTR_FORECAST_TIME: self.conversions.utc_from_timestamp( 173 | row["time"] 174 | ), 175 | ATTR_FORECAST_CONDITION: self.ha_condition_value(row.get("icon")), 176 | ATTR_FORECAST_TEMP: self.conversions.temperature( 177 | row["air_temperature"] 178 | ), 179 | ATTR_FORECAST_PRESSURE: self.conversions.pressure( 180 | row.get("sea_level_pressure", 0) 181 | ), 182 | ATTR_FORECAST_HUMIDITY: row["relative_humidity"], 183 | ATTR_FORECAST_PRECIPITATION: self.conversions.rain(row["precip"]), 184 | ATTR_FORECAST_PRECIPITATION_PROBABILITY: row["precip_probability"], 185 | ATTR_FORECAST_WIND_SPEED: self.conversions.speed( 186 | row["wind_avg"], True 187 | ), 188 | ATTR_FORECAST_WIND_BEARING: row["wind_direction"], 189 | } 190 | items.append(item) 191 | # Limit number of Hours 192 | cnt += 1 193 | if cnt >= FORECAST_HOURLY_HOURS: 194 | break 195 | fcst_data["hourly_forecast"] = items 196 | 197 | return condition_data, fcst_data 198 | 199 | # Return None if we could not retrieve data 200 | _LOGGER.warning("Forecast Server was unresponsive. Skipping forecast update") 201 | return None, None 202 | 203 | async def async_request(self, method: str, endpoint: str) -> dict[str, Any]: 204 | """Request data from the WeatherFlow API.""" 205 | use_running_session = self._session and not self._session.closed 206 | 207 | if use_running_session: 208 | session = self._session 209 | else: 210 | session = ClientSession(timeout=ClientTimeout(total=DEFAULT_TIMEOUT)) 211 | 212 | try: 213 | async with session.request(method, f"{BASE_URL}/{endpoint}") as resp: 214 | resp.raise_for_status() 215 | data = await resp.json() 216 | return data 217 | except asyncio.TimeoutError: 218 | _LOGGER.debug("Request to endpoint timed out: %s", endpoint) 219 | except ClientError as err: 220 | if "Unauthorized" in str(err): 221 | _LOGGER.error( 222 | "Your API Key is invalid or does not support this operation" 223 | ) 224 | if "Not Found" in str(err): 225 | _LOGGER.error("The Station ID does not exist") 226 | except Exception as exc: 227 | _LOGGER.debug("Error requesting data from %s Error: %s", endpoint, exc) 228 | 229 | finally: 230 | if not use_running_session: 231 | await session.close() 232 | 233 | def ha_condition_value(self, value: str) -> str | None: 234 | """Return Home Assistant Condition.""" 235 | try: 236 | return next( 237 | (k for k, v in CONDITION_CLASSES.items() if value in v), 238 | None, 239 | ) 240 | except Exception as exc: 241 | _LOGGER.debug( 242 | "Could not find icon with value: %s. Error message: %s", value, exc 243 | ) 244 | return None 245 | -------------------------------------------------------------------------------- /weatherflow2mqtt/sensor_description.py: -------------------------------------------------------------------------------- 1 | """Sensor descriptions.""" 2 | from __future__ import annotations 3 | 4 | from dataclasses import dataclass, field 5 | from typing import Any, Callable 6 | 7 | from pyweatherflowudp.device import ( 8 | EVENT_OBSERVATION, 9 | EVENT_RAPID_WIND, 10 | EVENT_STATUS_UPDATE, 11 | TempestDevice, 12 | WeatherFlowDevice, 13 | ) 14 | 15 | from weatherflow2mqtt.helpers import ConversionFunctions 16 | from weatherflow2mqtt.sqlite import SQLFunctions 17 | 18 | from .const import ( 19 | DEVICE_CLASS_BATTERY, 20 | DEVICE_CLASS_DISTANCE, 21 | DEVICE_CLASS_HUMIDITY, 22 | DEVICE_CLASS_ILLUMINANCE, 23 | DEVICE_CLASS_IRRADIANCE, 24 | DEVICE_CLASS_PRECIPITATION, 25 | DEVICE_CLASS_PRECIPITATION_INTENSITY, 26 | DEVICE_CLASS_PRESSURE, 27 | DEVICE_CLASS_TEMPERATURE, 28 | DEVICE_CLASS_TIMESTAMP, 29 | DEVICE_CLASS_VOLTAGE, 30 | DEVICE_CLASS_WIND_SPEED, 31 | EVENT_FORECAST, 32 | FORECAST_ENTITY, 33 | STATE_CLASS_MEASUREMENT, 34 | TEMP_CELSIUS, 35 | TEMP_FAHRENHEIT, 36 | ) 37 | from .helpers import NO_CONVERSION, no_conversion_to_none 38 | 39 | ALTITUDE_FEET = "ft" 40 | ALTITUDE_METERS = "m" 41 | UV_INDEX = "UV index" 42 | 43 | 44 | @dataclass 45 | class BaseSensorDescription: 46 | """Base sensor description.""" 47 | 48 | id: str 49 | name: str 50 | event: str 51 | 52 | attr: str | None = None 53 | device_class: str | None = None 54 | extra_att: bool = False 55 | has_description: bool = False 56 | icon: str | None = None 57 | last_reset: bool = False 58 | show_min_att: bool = False 59 | state_class: str | None = None 60 | unit_i: str | None = None 61 | unit_i_cnv: str | None = None 62 | unit_m: str | None = None 63 | unit_m_cnv: str | None = None 64 | 65 | @property 66 | def device_attr(self) -> str: 67 | """Return the device attr.""" 68 | return self.id if self.attr is None else self.attr 69 | 70 | @property 71 | def imperial_unit(self) -> str | None: 72 | """Return the imperial unit.""" 73 | return ( 74 | self.unit_i 75 | if self.unit_i_cnv is None 76 | else no_conversion_to_none(self.unit_i_cnv) 77 | ) 78 | 79 | @property 80 | def metric_unit(self) -> str | None: 81 | """Return the metric unit.""" 82 | return ( 83 | self.unit_m 84 | if self.unit_m_cnv is None 85 | else no_conversion_to_none(self.unit_m_cnv) 86 | ) 87 | 88 | 89 | @dataclass 90 | class SensorDescription(BaseSensorDescription): 91 | """Sensor description.""" 92 | 93 | custom_fn: Callable[[ConversionFunctions, WeatherFlowDevice], Any] | Callable[ 94 | [ConversionFunctions, WeatherFlowDevice, Any | None], Any 95 | ] | None = None 96 | decimals: tuple[int | None, int | None] = (None, None) 97 | inputs: tuple[str, ...] = field(default_factory=tuple[str, ...]) 98 | 99 | 100 | @dataclass 101 | class SqlSensorDescription(BaseSensorDescription): 102 | """Sql-based sensor description.""" 103 | 104 | sql_fn: Callable[[SQLFunctions], Any] | None = None 105 | 106 | 107 | @dataclass 108 | class StorageSensorDescription(BaseSensorDescription): 109 | """Storage-based sensor description.""" 110 | 111 | storage_field: str | None = None 112 | 113 | cnv_fn: Callable[[ConversionFunctions, Any], Any] | None = None 114 | 115 | def value(self, storage: dict[str, Any]) -> Any: 116 | """Return the field value from the storage.""" 117 | return storage[self.storage_field] 118 | 119 | 120 | STATUS_SENSOR = SensorDescription( 121 | id="status", 122 | name="Status", 123 | icon="clock-outline", 124 | event=EVENT_STATUS_UPDATE, 125 | attr="uptime", 126 | device_class=DEVICE_CLASS_TIMESTAMP, 127 | ) 128 | 129 | DEVICE_SENSORS: tuple[BaseSensorDescription, ...] = ( 130 | STATUS_SENSOR, 131 | SensorDescription( 132 | id="absolute_humidity", 133 | name="Absolute Humidity", 134 | event=EVENT_OBSERVATION, 135 | unit_m="g/m³", 136 | unit_i="g/m³", 137 | state_class=STATE_CLASS_MEASUREMENT, 138 | icon="water-opacity", 139 | attr="relative_humidity", 140 | custom_fn=lambda cnv, device: None 141 | if None in (device.air_temperature, device.relative_humidity) 142 | else cnv.absolute_humidity( 143 | device.air_temperature.m, device.relative_humidity.m 144 | ), 145 | ), 146 | SensorDescription( 147 | id="air_density", 148 | name="Air Density", 149 | event=EVENT_OBSERVATION, 150 | unit_m="kg/m³", 151 | unit_i="lb/ft³", 152 | state_class=STATE_CLASS_MEASUREMENT, 153 | icon="air-filter", 154 | decimals=(5, 5), 155 | ), 156 | SensorDescription( 157 | id="air_temperature", 158 | name="Temperature", 159 | unit_m=TEMP_CELSIUS, 160 | unit_i=TEMP_FAHRENHEIT, 161 | device_class=DEVICE_CLASS_TEMPERATURE, 162 | state_class=STATE_CLASS_MEASUREMENT, 163 | event=EVENT_OBSERVATION, 164 | extra_att=True, 165 | show_min_att=True, 166 | decimals=(1, 1), 167 | ), 168 | SensorDescription( 169 | id="battery", 170 | name="Voltage", 171 | unit_m="V", 172 | unit_i="V", 173 | device_class=DEVICE_CLASS_VOLTAGE, 174 | state_class=STATE_CLASS_MEASUREMENT, 175 | event=EVENT_OBSERVATION, 176 | decimals=(2, 2), 177 | ), 178 | SensorDescription( 179 | id="battery_level", 180 | name="Battery", 181 | unit_m="%", 182 | unit_i="%", 183 | device_class=DEVICE_CLASS_BATTERY, 184 | state_class=STATE_CLASS_MEASUREMENT, 185 | event=EVENT_OBSERVATION, 186 | attr="battery", 187 | custom_fn=lambda cnv, device: None 188 | if device.battery is None 189 | else cnv.battery_level(device.battery.m, isinstance(device, TempestDevice)), 190 | ), 191 | SensorDescription( 192 | id="battery_mode", 193 | name="Battery Mode", 194 | icon="information-outline", 195 | event=EVENT_OBSERVATION, 196 | attr="battery", 197 | has_description=True, 198 | custom_fn=lambda cnv, device: (None, None) 199 | if None in (device.battery, device.solar_radiation) 200 | else cnv.battery_mode(device.battery.m, device.solar_radiation.m), 201 | ), 202 | SensorDescription( 203 | id="beaufort", 204 | name="Beaufort Scale", 205 | state_class=STATE_CLASS_MEASUREMENT, 206 | icon="tailwind", 207 | event=EVENT_OBSERVATION, 208 | attr="wind_speed", 209 | custom_fn=lambda cnv, device: (None, None) 210 | if device.wind_speed is None 211 | else cnv.beaufort(device.wind_speed.m), 212 | has_description=True, 213 | ), 214 | SensorDescription( 215 | id="cloud_base", 216 | name="Cloud Base Altitude", 217 | icon="weather-cloudy", 218 | unit_m=ALTITUDE_METERS, 219 | unit_i=ALTITUDE_FEET, 220 | state_class=STATE_CLASS_MEASUREMENT, 221 | event=EVENT_OBSERVATION, 222 | attr="calculate_cloud_base", 223 | decimals=(0, 0), 224 | inputs=("altitude",), 225 | ), 226 | SensorDescription( 227 | id="delta_t", 228 | name="Delta T", 229 | unit_m=TEMP_CELSIUS, 230 | unit_m_cnv="delta_degC", 231 | unit_i=TEMP_FAHRENHEIT, 232 | unit_i_cnv="delta_degF", 233 | device_class=DEVICE_CLASS_TEMPERATURE, 234 | state_class=STATE_CLASS_MEASUREMENT, 235 | event=EVENT_OBSERVATION, 236 | decimals=(1, 1), 237 | ), 238 | SensorDescription( 239 | id="dewpoint", 240 | name="Dew Point", 241 | unit_m=TEMP_CELSIUS, 242 | unit_i=TEMP_FAHRENHEIT, 243 | device_class=DEVICE_CLASS_TEMPERATURE, 244 | state_class=STATE_CLASS_MEASUREMENT, 245 | icon="thermometer-lines", 246 | event=EVENT_OBSERVATION, 247 | extra_att=True, 248 | show_min_att=True, 249 | attr="dew_point_temperature", 250 | decimals=(1, 1), 251 | ), 252 | SensorDescription( 253 | id="dewpoint_description", 254 | name="Dewpoint Comfort Level", 255 | icon="text-box-outline", 256 | event=EVENT_OBSERVATION, 257 | attr="dew_point_temperature", 258 | custom_fn=lambda cnv, device: None 259 | if device.dew_point_temperature is None 260 | else cnv.dewpoint_level(device.dew_point_temperature.m, True), 261 | ), 262 | SensorDescription( 263 | id="feelslike", 264 | name="Feels Like Temperature", 265 | unit_m=TEMP_CELSIUS, 266 | unit_i=TEMP_FAHRENHEIT, 267 | device_class=DEVICE_CLASS_TEMPERATURE, 268 | state_class=STATE_CLASS_MEASUREMENT, 269 | event=EVENT_OBSERVATION, 270 | attr="air_temperature", 271 | decimals=(1, 1), 272 | custom_fn=lambda cnv, device, wind_speed: None 273 | if wind_speed is None 274 | or None in (device.air_temperature, device.relative_humidity) 275 | else cnv.feels_like( 276 | device.air_temperature.m, device.relative_humidity.m, wind_speed 277 | ), 278 | ), 279 | SensorDescription( 280 | id="freezing_level", 281 | name="Freezing Level Altitude", 282 | icon="altimeter", 283 | unit_m=ALTITUDE_METERS, 284 | unit_i=ALTITUDE_FEET, 285 | state_class=STATE_CLASS_MEASUREMENT, 286 | event=EVENT_OBSERVATION, 287 | attr="calculate_freezing_level", 288 | decimals=(0, 0), 289 | inputs=("altitude",), 290 | ), 291 | SensorDescription( 292 | id="illuminance", 293 | name="Illuminance", 294 | unit_m="lx", 295 | unit_i="lx", 296 | device_class=DEVICE_CLASS_ILLUMINANCE, 297 | state_class=STATE_CLASS_MEASUREMENT, 298 | event=EVENT_OBSERVATION, 299 | extra_att=True, 300 | ), 301 | SensorDescription( 302 | id="lightning_strike_count", 303 | name="Lightning Count", 304 | icon="weather-lightning", 305 | event=EVENT_OBSERVATION, 306 | ), 307 | SqlSensorDescription( 308 | id="lightning_strike_count_1hr", 309 | name="Lightning Count (Last hour)", 310 | icon="weather-lightning", 311 | event=EVENT_OBSERVATION, 312 | attr="lightning_strike_count", 313 | sql_fn=lambda sql: sql.readLightningCount(1), 314 | ), 315 | SqlSensorDescription( 316 | id="lightning_strike_count_3hr", 317 | name="Lightning Count (3 hours)", 318 | icon="weather-lightning", 319 | event=EVENT_OBSERVATION, 320 | attr="lightning_strike_count", 321 | sql_fn=lambda sql: sql.readLightningCount(3), 322 | ), 323 | StorageSensorDescription( 324 | id="lightning_strike_count_today", 325 | name="Lightning Count (Today)", 326 | state_class=STATE_CLASS_MEASUREMENT, 327 | icon="weather-lightning", 328 | event=EVENT_OBSERVATION, 329 | extra_att=True, 330 | last_reset=True, 331 | attr="lightning_strike_count", 332 | storage_field="lightning_count_today", 333 | ), 334 | StorageSensorDescription( 335 | id="lightning_strike_distance", 336 | name="Lightning Distance", 337 | unit_m="km", 338 | unit_i="mi", 339 | state_class=STATE_CLASS_MEASUREMENT, 340 | icon="flash", 341 | event=EVENT_OBSERVATION, 342 | attr="lightning_strike_average_distance", 343 | storage_field="last_lightning_distance", 344 | ), 345 | StorageSensorDescription( 346 | id="lightning_strike_energy", 347 | name="Lightning Energy", 348 | state_class=STATE_CLASS_MEASUREMENT, 349 | icon="flash", 350 | event=EVENT_OBSERVATION, 351 | extra_att=True, 352 | attr="last_lightning_strike_event", 353 | storage_field="last_lightning_energy", 354 | ), 355 | StorageSensorDescription( 356 | id="lightning_strike_time", 357 | name="Last Lightning Strike", 358 | device_class=DEVICE_CLASS_TIMESTAMP, 359 | icon="clock-outline", 360 | event="lightning_strike_time", 361 | attr="last_lightning_strike_event", 362 | storage_field="last_lightning_time", 363 | cnv_fn=lambda cnv, val: cnv.utc_from_timestamp(val), 364 | ), 365 | SensorDescription( 366 | id="precipitation_type", 367 | name="Precipitation Type", 368 | icon="weather-rainy", 369 | event=EVENT_OBSERVATION, 370 | custom_fn=lambda cnv, device: None 371 | if device.precipitation_type is None 372 | else cnv.rain_type(device.precipitation_type.value), 373 | ), 374 | SensorDescription( 375 | id="pressure_trend", 376 | name="Pressure Trend", 377 | icon="trending-up", 378 | event=EVENT_OBSERVATION, 379 | attr="station_pressure", 380 | ), 381 | StorageSensorDescription( 382 | id="rain_duration_today", 383 | name="Rain Duration (Today)", 384 | unit_m="min", 385 | unit_i="min", 386 | state_class=STATE_CLASS_MEASUREMENT, 387 | icon="timeline-clock-outline", 388 | event=EVENT_OBSERVATION, 389 | extra_att=True, 390 | last_reset=True, 391 | attr="rain_accumulation_previous_minute", 392 | storage_field="rain_duration_today", 393 | ), 394 | StorageSensorDescription( 395 | id="rain_duration_yesterday", 396 | name="Rain Duration (Yesterday)", 397 | unit_m="min", 398 | unit_i="min", 399 | icon="timeline-clock-outline", 400 | event=EVENT_OBSERVATION, 401 | attr="rain_accumulation_previous_minute", 402 | storage_field="rain_duration_yesterday", 403 | ), 404 | SensorDescription( 405 | id="rain_intensity", 406 | name="Rain Intensity", 407 | icon="text-box-outline", 408 | event=EVENT_OBSERVATION, 409 | attr="rain_accumulation_previous_minute", 410 | custom_fn=lambda cnv, device: None 411 | if device.rain_rate is None 412 | else cnv.rain_intensity(device.rain_rate.m), 413 | ), 414 | SensorDescription( 415 | id="rain_rate", 416 | name="Rain Rate", 417 | unit_m="mm/h", 418 | unit_m_cnv="mm/h", 419 | unit_i="in/h", 420 | unit_i_cnv="in/h", 421 | device_class=DEVICE_CLASS_PRECIPITATION_INTENSITY, 422 | state_class=STATE_CLASS_MEASUREMENT, 423 | icon="weather-pouring", 424 | event=EVENT_OBSERVATION, 425 | extra_att=True, 426 | decimals=(2, 2), 427 | ), 428 | StorageSensorDescription( 429 | id="rain_start_time", 430 | name="Last Rain start", 431 | device_class=DEVICE_CLASS_TIMESTAMP, 432 | icon="clock-outline", 433 | event="rain_start_time", 434 | attr="last_rain_start_event", 435 | storage_field="rain_start", 436 | cnv_fn=lambda cnv, val: cnv.utc_from_timestamp(val), 437 | ), 438 | StorageSensorDescription( 439 | id="rain_today", 440 | name="Rain Today", 441 | unit_m="mm", 442 | unit_i="in", 443 | device_class=DEVICE_CLASS_PRECIPITATION, 444 | state_class=STATE_CLASS_MEASUREMENT, 445 | icon="weather-pouring", 446 | event=EVENT_OBSERVATION, 447 | last_reset=True, 448 | attr="rain_accumulation_previous_minute", 449 | storage_field="rain_today", 450 | cnv_fn=lambda cnv, val: cnv.rain(val), 451 | ), 452 | StorageSensorDescription( 453 | id="rain_yesterday", 454 | name="Rain Yesterday", 455 | unit_m="mm", 456 | unit_i="in", 457 | icon="weather-pouring", 458 | device_class=DEVICE_CLASS_PRECIPITATION, 459 | event=EVENT_OBSERVATION, 460 | attr="rain_accumulation_previous_minute", 461 | storage_field="rain_yesterday", 462 | cnv_fn=lambda cnv, val: cnv.rain(val), 463 | ), 464 | SensorDescription( 465 | id="relative_humidity", 466 | name="Humidity", 467 | unit_m="%", 468 | unit_m_cnv=NO_CONVERSION, 469 | unit_i="%", 470 | unit_i_cnv=NO_CONVERSION, 471 | device_class=DEVICE_CLASS_HUMIDITY, 472 | state_class=STATE_CLASS_MEASUREMENT, 473 | event=EVENT_OBSERVATION, 474 | extra_att=True, 475 | show_min_att=True, 476 | ), 477 | SensorDescription( 478 | id="sealevel_pressure", 479 | name="Sea Level Pressure", 480 | unit_m="hPa", 481 | unit_i="inHg", 482 | device_class=DEVICE_CLASS_PRESSURE, 483 | state_class=STATE_CLASS_MEASUREMENT, 484 | event=EVENT_OBSERVATION, 485 | extra_att=True, 486 | show_min_att=True, 487 | attr="calculate_sea_level_pressure", 488 | decimals=(2, 3), 489 | inputs=("altitude",), 490 | ), 491 | SensorDescription( 492 | id="solar_radiation", 493 | name="Solar Radiation", 494 | unit_m="W/m²", 495 | unit_i="W/m²", 496 | device_class=DEVICE_CLASS_IRRADIANCE, 497 | state_class=STATE_CLASS_MEASUREMENT, 498 | icon="solar-power", 499 | event=EVENT_OBSERVATION, 500 | extra_att=True, 501 | ), 502 | SensorDescription( 503 | id="station_pressure", 504 | name="Station Pressure", 505 | unit_m="hPa", 506 | unit_i="inHg", 507 | device_class=DEVICE_CLASS_PRESSURE, 508 | state_class=STATE_CLASS_MEASUREMENT, 509 | event=EVENT_OBSERVATION, 510 | decimals=(2, 3), 511 | ), 512 | SensorDescription( 513 | id="temperature_description", 514 | name="Temperature Level", 515 | icon="text-box-outline", 516 | event=EVENT_OBSERVATION, 517 | attr="air_temperature", 518 | custom_fn=lambda cnv, device: None 519 | if device.air_temperature is None 520 | else cnv.temperature_level(device.air_temperature.m), 521 | ), 522 | SensorDescription( 523 | id="uv", 524 | name="UV Index", 525 | unit_m=UV_INDEX, 526 | unit_i=UV_INDEX, 527 | state_class=STATE_CLASS_MEASUREMENT, 528 | icon="weather-sunny-alert", 529 | event=EVENT_OBSERVATION, 530 | extra_att=True, 531 | ), 532 | SensorDescription( 533 | id="uv_description", 534 | name="UV Level", 535 | icon="text-box-outline", 536 | event=EVENT_OBSERVATION, 537 | attr="uv", 538 | custom_fn=lambda cnv, device: cnv.uv_level(device.uv), 539 | ), 540 | SensorDescription( 541 | id="visibility", 542 | name="Visibility", 543 | unit_m="km", 544 | unit_i="mi", 545 | device_class=DEVICE_CLASS_DISTANCE, 546 | state_class=STATE_CLASS_MEASUREMENT, 547 | icon="eye", 548 | event=EVENT_OBSERVATION, 549 | attr="air_temperature", 550 | custom_fn=lambda cnv, device, elevation: None 551 | if None in (device.air_temperature, device.relative_humidity) 552 | else cnv.visibility( 553 | elevation, device.air_temperature.m, device.relative_humidity.m 554 | ), 555 | ), 556 | SensorDescription( 557 | id="wbgt", 558 | name="Wet Bulb Globe Temperature", 559 | unit_m=TEMP_CELSIUS, 560 | unit_i=TEMP_FAHRENHEIT, 561 | device_class=DEVICE_CLASS_TEMPERATURE, 562 | state_class=STATE_CLASS_MEASUREMENT, 563 | event=EVENT_OBSERVATION, 564 | attr="wet_bulb_temperature", 565 | decimals=(1, 1), 566 | custom_fn=lambda cnv, device, solar_radiation: None 567 | if None 568 | in (device.air_temperature, device.relative_humidity, device.station_pressure) 569 | else cnv.wbgt( 570 | device.air_temperature.m, 571 | device.relative_humidity.m, 572 | device.station_pressure.m, 573 | solar_radiation, 574 | ), 575 | ), 576 | SensorDescription( 577 | id="wetbulb", 578 | name="Wet Bulb Temperature", 579 | unit_m=TEMP_CELSIUS, 580 | unit_i=TEMP_FAHRENHEIT, 581 | device_class=DEVICE_CLASS_TEMPERATURE, 582 | state_class=STATE_CLASS_MEASUREMENT, 583 | event=EVENT_OBSERVATION, 584 | attr="wet_bulb_temperature", 585 | decimals=(1, 1), 586 | ), 587 | SensorDescription( 588 | id="wind_bearing", 589 | name="Wind Bearing", 590 | unit_m="°", 591 | unit_i="°", 592 | state_class=STATE_CLASS_MEASUREMENT, 593 | icon="compass", 594 | event=EVENT_RAPID_WIND, 595 | attr="wind_direction", 596 | ), 597 | SensorDescription( 598 | id="wind_bearing_avg", 599 | name="Wind Bearing Avg", 600 | unit_m="°", 601 | unit_i="°", 602 | state_class=STATE_CLASS_MEASUREMENT, 603 | icon="compass", 604 | event=EVENT_OBSERVATION, 605 | attr="wind_direction", 606 | ), 607 | SensorDescription( 608 | id="wind_direction", 609 | name="Wind Direction", 610 | icon="compass-outline", 611 | event=EVENT_RAPID_WIND, 612 | ), 613 | SensorDescription( 614 | id="wind_direction_avg", 615 | name="Wind Direction Avg", 616 | icon="compass-outline", 617 | event=EVENT_OBSERVATION, 618 | attr="wind_direction", 619 | custom_fn=lambda cnv, device: None 620 | if device.wind_direction is None 621 | else cnv.direction(device.wind_direction.m), 622 | ), 623 | SensorDescription( 624 | id="wind_gust", 625 | name="Wind Gust", 626 | unit_m="m/s", 627 | unit_i="mph", 628 | device_class=DEVICE_CLASS_WIND_SPEED, 629 | state_class=STATE_CLASS_MEASUREMENT, 630 | icon="weather-windy", 631 | event=EVENT_OBSERVATION, 632 | extra_att=True, 633 | decimals=(1, 2), 634 | ), 635 | SensorDescription( 636 | id="wind_lull", 637 | name="Wind Lull", 638 | unit_m="m/s", 639 | unit_i="mph", 640 | device_class=DEVICE_CLASS_WIND_SPEED, 641 | state_class=STATE_CLASS_MEASUREMENT, 642 | icon="weather-windy-variant", 643 | event=EVENT_OBSERVATION, 644 | extra_att=True, 645 | decimals=(1, 2), 646 | ), 647 | SensorDescription( 648 | id="wind_speed", 649 | name="Wind Speed", 650 | unit_m="m/s", 651 | unit_i="mph", 652 | device_class=DEVICE_CLASS_WIND_SPEED, 653 | state_class=STATE_CLASS_MEASUREMENT, 654 | icon="weather-windy", 655 | event=EVENT_RAPID_WIND, 656 | decimals=(1, 2), 657 | ), 658 | SensorDescription( 659 | id="wind_speed_avg", 660 | name="Wind Speed Avg", 661 | unit_m="m/s", 662 | unit_i="mph", 663 | device_class=DEVICE_CLASS_WIND_SPEED, 664 | state_class=STATE_CLASS_MEASUREMENT, 665 | icon="weather-windy-variant", 666 | event=EVENT_OBSERVATION, 667 | extra_att=True, 668 | attr="wind_average", 669 | decimals=(1, 2), 670 | ), 671 | SensorDescription( 672 | id="solar_elevation", 673 | name="Solar Elevation", 674 | unit_m="°", 675 | unit_i="°", 676 | state_class=STATE_CLASS_MEASUREMENT, 677 | icon="angle-acute", 678 | event=EVENT_OBSERVATION, 679 | attr="solar_radiation", 680 | custom_fn=lambda cnv, latitude, longitude: None 681 | if None in (latitude, longitude) 682 | else cnv.solar_elevation(latitude, longitude) 683 | ), 684 | SensorDescription( 685 | id="solar_insolation", 686 | name="Solar Insolation", 687 | unit_m="W/m²", 688 | unit_i="W/m²", 689 | state_class=STATE_CLASS_MEASUREMENT, 690 | icon="solar-power", 691 | event=EVENT_OBSERVATION, 692 | attr="solar_radiation", 693 | custom_fn=lambda cnv, elevation, latitude, longitude: None 694 | if None in (elevation, latitude, longitude) 695 | else cnv.solar_insolation(elevation, latitude, longitude) 696 | ), 697 | SensorDescription( 698 | id="zambretti_number", 699 | name="Zambretti Number", 700 | state_class=STATE_CLASS_MEASUREMENT, 701 | icon="vector-bezier", 702 | event=EVENT_OBSERVATION, 703 | attr="station_pressure", 704 | custom_fn=lambda cnv, latitude, wind_direction_avg, p_hi, p_lo, pressure_trend, sealevel_pressure: None 705 | if None in (latitude, wind_direction_avg, p_hi, p_lo, pressure_trend, sealevel_pressure) 706 | else cnv.zambretti_value(latitude, wind_direction_avg, p_hi, p_lo, pressure_trend, sealevel_pressure) 707 | ), 708 | SensorDescription( 709 | id="zambretti_text", 710 | name="Zambretti Text", 711 | icon="vector-bezier", 712 | event=EVENT_OBSERVATION, 713 | attr="station_pressure", 714 | custom_fn=lambda cnv, zambretti_value: None 715 | if zambretti_value is None 716 | else cnv.zambretti_forecast(zambretti_value), 717 | ), 718 | SensorDescription( 719 | id="fog_probability", 720 | name="Fog Probability", 721 | unit_m="%", 722 | unit_i="%", 723 | state_class=STATE_CLASS_MEASUREMENT, 724 | icon="weather-fog", 725 | event=EVENT_OBSERVATION, 726 | attr="relative_humidity", 727 | custom_fn=lambda cnv, solar_elevation, wind_speed, humidity, dew_point, air_temperature: 0 728 | if None in (solar_elevation, wind_speed, humidity, dew_point, air_temperature) 729 | else cnv.fog_probability(solar_elevation, wind_speed, humidity, dew_point, air_temperature), 730 | ), 731 | SensorDescription( 732 | id="snow_probability", 733 | name="Snow Probability", 734 | unit_m="%", 735 | unit_i="%", 736 | state_class=STATE_CLASS_MEASUREMENT, 737 | icon="snowflake", 738 | event=EVENT_OBSERVATION, 739 | attr="relative_humidity", 740 | custom_fn=lambda cnv, device, freezing_level, cloud_base, elevation: None 741 | if None in (device.air_temperature, device.dew_point_temperature, device.wet_bulb_temperature, freezing_level, cloud_base) 742 | else cnv.snow_probability(device.air_temperature.m, freezing_level, cloud_base, device.dew_point_temperature.m, device.wet_bulb_temperature.m, elevation) 743 | ), 744 | 745 | SensorDescription( 746 | id="current_conditions", 747 | name="Current Conditions", 748 | icon="weather-partly-snowy-rainy", 749 | event=EVENT_OBSERVATION, 750 | attr="rain_rate", 751 | custom_fn=lambda cnv, lightning_strike_count_1hr, precipitation_type, rain_rate, wind_speed, solar_elevation, solar_radiation, solar_insolation, snow_probability, fog_probability: "clear-night" 752 | if None in (lightning_strike_count_1hr, precipitation_type, rain_rate, wind_speed, solar_elevation, solar_radiation, solar_insolation, snow_probability, fog_probability) 753 | else cnv.current_conditions(lightning_strike_count_1hr, precipitation_type, rain_rate, wind_speed, solar_elevation, solar_radiation, solar_insolation, snow_probability, fog_probability) 754 | ), 755 | ) 756 | 757 | HUB_SENSORS: tuple[BaseSensorDescription, ...] = (STATUS_SENSOR,) 758 | 759 | FORECAST_SENSORS: tuple[BaseSensorDescription, ...] = ( 760 | SensorDescription( 761 | id=FORECAST_ENTITY, 762 | name="Weather", 763 | icon="chart-box-outline", 764 | event=EVENT_FORECAST, 765 | ), 766 | ) 767 | 768 | OBSOLETE_SENSORS = ["uptime"] 769 | -------------------------------------------------------------------------------- /weatherflow2mqtt/sqlite.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | import json 3 | import logging 4 | import os.path 5 | import sqlite3 6 | import time 7 | from datetime import timezone 8 | from sqlite3 import Error as SQLError 9 | from typing import OrderedDict 10 | 11 | from .const import ( 12 | COL_DEWPOINT, 13 | COL_HUMIDITY, 14 | COL_ILLUMINANCE, 15 | COL_PRESSURE, 16 | COL_RAINDURATION, 17 | COL_RAINRATE, 18 | COL_SOLARRAD, 19 | COL_STRIKECOUNT, 20 | COL_STRIKEENERGY, 21 | COL_TEMPERATURE, 22 | COL_UV, 23 | COL_WINDGUST, 24 | COL_WINDLULL, 25 | COL_WINDSPEED, 26 | DATABASE_VERSION, 27 | PRESSURE_TREND_TIMER, 28 | STORAGE_FILE, 29 | STORAGE_ID, 30 | STRIKE_COUNT_TIMER, 31 | TABLE_HIGH_LOW, 32 | TABLE_LIGHTNING, 33 | TABLE_PRESSURE, 34 | TABLE_STORAGE, 35 | UNITS_IMPERIAL, 36 | UTC, 37 | ) 38 | 39 | _LOGGER = logging.getLogger(__name__) 40 | 41 | 42 | class SQLFunctions: 43 | """Class to handle SQLLite functions.""" 44 | 45 | def __init__(self, unit_system, debug=False): 46 | """Initialize SQLFunctions.""" 47 | self.connection = None 48 | self._unit_system = unit_system 49 | self._debug = debug 50 | 51 | def create_connection(self, db_file): 52 | """Create a database connection to a SQLite database.""" 53 | try: 54 | self.connection = sqlite3.connect(db_file) 55 | 56 | except SQLError as e: 57 | _LOGGER.error("Could not create SQL Database. Error: %s", e) 58 | 59 | def create_table(self, create_table_sql): 60 | """Create table from the create_table_sql statement. 61 | 62 | :param conn: Connection object 63 | :param create_table_sql: a CREATE TABLE statement 64 | :return: 65 | """ 66 | try: 67 | c = self.connection.cursor() 68 | c.execute(create_table_sql) 69 | except SQLError as e: 70 | _LOGGER.error("Could not create SQL Table. Error: %s", e) 71 | 72 | def create_storage_row(self, rowdata): 73 | """Create new storage row into the storage table. 74 | 75 | :param conn: 76 | :param rowdata: 77 | :return: project id 78 | """ 79 | sql = """ INSERT INTO storage(id, rain_today, rain_yesterday, rain_start, rain_duration_today, 80 | rain_duration_yesterday, lightning_count, lightning_count_today, last_lightning_time, 81 | last_lightning_distance, last_lightning_energy) 82 | VALUES(?, ?,?,?,?,?,?,?,?,?,?) """ 83 | try: 84 | cur = self.connection.cursor() 85 | cur.execute(sql, rowdata) 86 | self.connection.commit() 87 | return cur.lastrowid 88 | except SQLError as e: 89 | _LOGGER.error("Could not Insert data in table storage. Error: %s", e) 90 | 91 | def readStorage(self): 92 | """Return data from the storage table as JSON.""" 93 | try: 94 | cursor = self.connection.cursor() 95 | cursor.execute(f"SELECT * FROM storage WHERE id = {STORAGE_ID};") 96 | data = cursor.fetchall() 97 | 98 | for row in data: 99 | storage_json = { 100 | "rain_today": row[1], 101 | "rain_yesterday": row[2], 102 | "rain_start": row[3], 103 | "rain_duration_today": row[4], 104 | "rain_duration_yesterday": row[5], 105 | "lightning_count": row[6], 106 | "lightning_count_today": row[7], 107 | "last_lightning_time": row[8], 108 | "last_lightning_distance": row[9], 109 | "last_lightning_energy": row[10], 110 | } 111 | 112 | return storage_json 113 | 114 | except SQLError as e: 115 | _LOGGER.error("Could not access storage data. Error: %s", e) 116 | 117 | def writeStorage(self, json_data: OrderedDict): 118 | """Store data in the storage table from JSON.""" 119 | try: 120 | cursor = self.connection.cursor() 121 | sql_statement = """UPDATE storage 122 | SET rain_today=?, 123 | rain_yesterday=?, 124 | rain_start=?, 125 | rain_duration_today=?, 126 | rain_duration_yesterday=?, 127 | lightning_count=?, 128 | lightning_count_today=?, 129 | last_lightning_time=?, 130 | last_lightning_distance=?, 131 | last_lightning_energy=? 132 | WHERE ID = ? 133 | """ 134 | 135 | rowdata = ( 136 | json_data["rain_today"], 137 | json_data["rain_yesterday"], 138 | json_data["rain_start"], 139 | json_data["rain_duration_today"], 140 | json_data["rain_duration_yesterday"], 141 | json_data["lightning_count"], 142 | json_data["lightning_count_today"], 143 | json_data["last_lightning_time"], 144 | json_data["last_lightning_distance"], 145 | json_data["last_lightning_energy"], 146 | STORAGE_ID, 147 | ) 148 | 149 | cursor.execute(sql_statement, rowdata) 150 | self.connection.commit() 151 | 152 | except SQLError as e: 153 | _LOGGER.error("Could not update storage data. Error: %s", e) 154 | 155 | def readPressureTrend(self, new_pressure, translations): 156 | """Return Pressure Trend.""" 157 | if new_pressure is None: 158 | return "Steady", 0 159 | 160 | try: 161 | time_point = time.time() - PRESSURE_TREND_TIMER 162 | cursor = self.connection.cursor() 163 | cursor.execute( 164 | f"SELECT pressure FROM pressure WHERE timestamp < {time_point} ORDER BY timestamp DESC LIMIT 1;" 165 | ) 166 | old_pressure = cursor.fetchone() 167 | if old_pressure is None: 168 | old_pressure = new_pressure 169 | else: 170 | old_pressure = float(old_pressure[0]) 171 | pressure_delta = new_pressure - old_pressure 172 | 173 | min_value = -1 174 | max_value = 1 175 | if self._unit_system == UNITS_IMPERIAL: 176 | min_value = -0.0295 177 | max_value = 0.0295 178 | 179 | if pressure_delta > min_value and pressure_delta < max_value: 180 | return translations["trend"]["steady"], 0 181 | if pressure_delta <= min_value: 182 | return translations["trend"]["falling"], round(pressure_delta, 2) 183 | if pressure_delta >= max_value: 184 | return translations["trend"]["rising"], round(pressure_delta, 2) 185 | 186 | except SQLError as e: 187 | _LOGGER.error("Could not read pressure data. Error: %s", e) 188 | except Exception as e: 189 | _LOGGER.error("Could not calculate pressure trend. Error message: %s", e) 190 | 191 | def readPressureData(self): 192 | """Return formatted pressure data - USED FOR TESTING ONLY.""" 193 | try: 194 | cursor = self.connection.cursor() 195 | cursor.execute("SELECT * FROM pressure;") 196 | data = cursor.fetchall() 197 | 198 | for row in data: 199 | tid = datetime.datetime.fromtimestamp(row[0]).isoformat() 200 | print(tid, row[1]) 201 | 202 | except SQLError as e: 203 | _LOGGER.error("Could not access storage data. Error: %s", e) 204 | 205 | def writePressure(self, pressure): 206 | """Add entry to the Pressure Table.""" 207 | try: 208 | cur = self.connection.cursor() 209 | cur.execute( 210 | f"INSERT INTO pressure(timestamp, pressure) VALUES({time.time()}, {pressure});" 211 | ) 212 | self.connection.commit() 213 | return True 214 | except SQLError as e: 215 | _LOGGER.error("Could not Insert data in table Pressure. Error: %s", e) 216 | return False 217 | except Exception as e: 218 | _LOGGER.error("Could write to Pressure Table. Error message: %s", e) 219 | return False 220 | 221 | def readLightningCount(self, hours: int): 222 | """Return number of Lightning Strikes in the last x hours.""" 223 | try: 224 | time_point = time.time() - hours * 60 * 60 225 | cursor = self.connection.cursor() 226 | cursor.execute( 227 | f"SELECT COUNT(*) FROM lightning WHERE timestamp > {time_point};" 228 | ) 229 | data = cursor.fetchone()[0] 230 | 231 | return data 232 | 233 | except SQLError as e: 234 | _LOGGER.error("Could not access storage data. Error: %s", e) 235 | 236 | def writeLightning(self): 237 | """Adds an entry to the Lightning Table.""" 238 | 239 | try: 240 | cur = self.connection.cursor() 241 | cur.execute(f"INSERT INTO lightning(timestamp) VALUES({time.time()});") 242 | self.connection.commit() 243 | return True 244 | except SQLError as e: 245 | _LOGGER.error("Could not Insert data in table Lightning. Error: %s", e) 246 | return False 247 | except Exception as e: 248 | _LOGGER.error("Could write to Lightning Table. Error message: %s", e) 249 | return False 250 | 251 | def writeDailyLog(self, sensor_data): 252 | """Add entry to the Daily Log Table.""" 253 | try: 254 | data = json.loads(json.dumps(sensor_data)) 255 | temp = data.get("air_temperature") 256 | pres = data.get("sealevel_pressure") 257 | wspeed = data.get("wind_speed_avg") 258 | 259 | cursor = self.connection.cursor() 260 | cursor.execute( 261 | f"INSERT INTO daily_log(timestamp, temperature, pressure, windspeed) VALUES({time.time()}, ?, ?, ?)", 262 | (temp, pres, wspeed), 263 | ) 264 | self.connection.commit() 265 | 266 | except SQLError as e: 267 | _LOGGER.error("Could not Insert data in table daily_log. Error: %s", e) 268 | except Exception as e: 269 | _LOGGER.error("Could not write to daily_log Table. Error message: %s", e) 270 | 271 | def updateDayData(self, sensor_data): 272 | """Update Day Data Table.""" 273 | try: 274 | data = json.loads(json.dumps(sensor_data)) 275 | temp = data.get("air_temperature") 276 | pres = data.get("sealevel_pressure") 277 | wspeed = data.get("wind_speed_avg") 278 | hum = data.get("relative_humidity") 279 | dew = data.get("dewpoint") 280 | illum = data.get("illuminance") 281 | rain_dur = data.get("rain_duration_today") 282 | rain_rate = data.get("rain_rate") 283 | wgust = data.get("wind_gust") 284 | wlull = data.get("wind_lull") 285 | strike_e = data.get("lightning_strike_energy") 286 | strike_c = data.get("lightning_strike_count_today") 287 | uv = data.get("uv") 288 | solrad = data.get("solar_radiation") 289 | 290 | cursor = self.connection.cursor() 291 | sql_columns = "INSERT INTO day_data(" 292 | sql_columns += "timestamp, air_temperature, sealevel_pressure, wind_speed_avg, relative_humidity, dewpoint," 293 | sql_columns += "illuminance, rain_duration_today, rain_rate, wind_gust, wind_lull, lightning_strike_energy," 294 | sql_columns += "lightning_strike_count_today, uv, solar_radiation" 295 | sql_columns += ")" 296 | cursor.execute( 297 | f"{sql_columns} VALUES({time.time()}, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", 298 | ( 299 | temp, 300 | pres, 301 | wspeed, 302 | hum, 303 | dew, 304 | illum, 305 | rain_dur, 306 | rain_rate, 307 | wgust, 308 | wlull, 309 | strike_e, 310 | strike_c, 311 | uv, 312 | solrad, 313 | ), 314 | ) 315 | self.connection.commit() 316 | 317 | except SQLError as e: 318 | _LOGGER.error("Could not Insert data in table day_data. Error: %s", e) 319 | except Exception as e: 320 | _LOGGER.error("Could not write to day_data Table. Error message: %s", e) 321 | 322 | def updateHighLow(self, sensor_data): 323 | """Update High and Low Values.""" 324 | try: 325 | self.connection.row_factory = sqlite3.Row 326 | cursor = self.connection.cursor() 327 | cursor.execute("SELECT * FROM high_low;") 328 | table_data = cursor.fetchall() 329 | 330 | data = dict(sensor_data) 331 | 332 | for row in table_data: 333 | max_sql = None 334 | min_sql = None 335 | sensor_value = None 336 | do_update = False 337 | # Get Value of Sensor if available 338 | if data.get(row["sensorid"]) is not None: 339 | sensor_value = data[row["sensorid"]] 340 | 341 | # If we have a value, check if min/max changes 342 | if sensor_value is not None: 343 | if sensor_value > row["max_day"]: 344 | max_sql = ( 345 | f" max_day = {sensor_value}, max_day_time = {time.time()} " 346 | ) 347 | do_update = True 348 | if sensor_value < row["min_day"]: 349 | min_sql = ( 350 | f" min_day = {sensor_value}, min_day_time = {time.time()} " 351 | ) 352 | do_update = True 353 | 354 | # If min/max changes, update the record 355 | sql = "UPDATE high_low SET" 356 | if do_update: 357 | if max_sql: 358 | sql = f"{sql} {max_sql}" 359 | if max_sql and min_sql: 360 | sql = f"{sql}," 361 | if min_sql: 362 | sql = f"{sql} {min_sql}" 363 | sql = f"{sql}, latest = {sensor_value} WHERE sensorid = '{row['sensorid']}'" 364 | if self._debug: 365 | _LOGGER.debug("Min/Max SQL: %s", sql) 366 | cursor.execute(sql) 367 | self.connection.commit() 368 | else: 369 | if sensor_value is not None: 370 | sql = f"{sql} latest = {sensor_value} WHERE sensorid = '{row['sensorid']}'" 371 | if self._debug: 372 | _LOGGER.debug("Latest SQL: %s", sql) 373 | cursor.execute(sql) 374 | self.connection.commit() 375 | 376 | except SQLError as e: 377 | _LOGGER.error("Could not update High and Low data. Error: %s", e) 378 | return False 379 | except Exception as e: 380 | _LOGGER.error("Could not write to High and Low Table. Error message: %s", e) 381 | return False 382 | 383 | def readHighLow(self): 384 | """Return data from the high_low table as JSON.""" 385 | try: 386 | self.connection.row_factory = sqlite3.Row 387 | cursor = self.connection.cursor() 388 | cursor.execute("SELECT * FROM high_low") 389 | data = cursor.fetchall() 390 | 391 | sensor_json = {} 392 | for row in data: 393 | sensor_json[row["sensorid"]] = { 394 | "max_day": row["max_day"], 395 | "max_day_time": None 396 | if not row["max_day_time"] 397 | else datetime.datetime.utcfromtimestamp(round(row["max_day_time"])) 398 | .replace(tzinfo=UTC) 399 | .isoformat(), 400 | "max_month": row["max_month"], 401 | "max_month_time": None 402 | if not row["max_month_time"] 403 | else datetime.datetime.utcfromtimestamp( 404 | round(row["max_month_time"]) 405 | ) 406 | .replace(tzinfo=UTC) 407 | .isoformat(), 408 | "max_all": row["max_all"], 409 | "max_all_time": None 410 | if not row["max_all_time"] 411 | else datetime.datetime.utcfromtimestamp(round(row["max_all_time"])) 412 | .replace(tzinfo=UTC) 413 | .isoformat(), 414 | "min_day": row["min_day"], 415 | "min_day_time": None 416 | if not row["min_day_time"] 417 | else datetime.datetime.utcfromtimestamp(round(row["min_day_time"])) 418 | .replace(tzinfo=UTC) 419 | .isoformat(), 420 | "min_month": row["min_month"], 421 | "min_month_time": None 422 | if not row["min_month_time"] 423 | else datetime.datetime.utcfromtimestamp( 424 | round(row["min_month_time"]) 425 | ) 426 | .replace(tzinfo=UTC) 427 | .isoformat(), 428 | "min_all": row["min_all"], 429 | "min_all_time": None 430 | if not row["min_all_time"] 431 | else datetime.datetime.utcfromtimestamp(round(row["min_all_time"])) 432 | .replace(tzinfo=UTC) 433 | .isoformat(), 434 | } 435 | return sensor_json 436 | 437 | except SQLError as e: 438 | _LOGGER.error("Could not access high_low data. Error: %s", e) 439 | return None 440 | except Exception as e: 441 | _LOGGER.error("Could not get all High Low values. Error message: %s", e) 442 | return sensor_json 443 | 444 | def migrateStorageFile(self): 445 | """Migrate old .storage.json file to the database.""" 446 | try: 447 | with open(STORAGE_FILE, "r") as jsonFile: 448 | old_data = json.load(jsonFile) 449 | 450 | # We need to convert the Rain Start to timestamp 451 | dt = datetime.datetime.strptime( 452 | old_data["rain_start"], "%Y-%m-%dT%H:%M:%S" 453 | ) 454 | timestamp = dt.replace(tzinfo=timezone.utc).timestamp() 455 | 456 | storage_json = { 457 | "rain_today": old_data["rain_today"], 458 | "rain_yesterday": old_data["rain_yesterday"], 459 | "rain_start": timestamp, 460 | "rain_duration_today": old_data["rain_duration_today"], 461 | "rain_duration_yesterday": old_data["rain_duration_yesterday"], 462 | "lightning_count": old_data["lightning_count"], 463 | "lightning_count_today": old_data["lightning_count_today"], 464 | "last_lightning_time": old_data["last_lightning_time"], 465 | "last_lightning_distance": old_data["last_lightning_distance"], 466 | "last_lightning_energy": old_data["last_lightning_energy"], 467 | } 468 | 469 | self.writeStorage(storage_json) 470 | 471 | except FileNotFoundError as e: 472 | _LOGGER.error("Could not find old storage file. Error message: %s", e) 473 | except Exception as e: 474 | _LOGGER.error("Could not Read storage file. Error message: %s", e) 475 | 476 | def createInitialDataset(self): 477 | """Initialize Initial database, and migrate data if needed.""" 478 | try: 479 | with self.connection: 480 | # Create Empty Tables 481 | self.create_table(TABLE_STORAGE) 482 | self.create_table(TABLE_LIGHTNING) 483 | self.create_table(TABLE_PRESSURE) 484 | self.create_table(TABLE_HIGH_LOW) 485 | 486 | # Store Initial Data 487 | storage = (STORAGE_ID, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0) 488 | self.create_storage_row(storage) 489 | self.initializeHighLow() 490 | 491 | # Update the version number 492 | cursor = self.connection.cursor() 493 | cursor.execute(f"PRAGMA main.user_version = {DATABASE_VERSION};") 494 | 495 | # Migrate data if they exist 496 | if os.path.isfile(STORAGE_FILE): 497 | self.migrateStorageFile() 498 | 499 | except Exception as e: 500 | _LOGGER.error("Could not Read storage file. Error message: %s", e) 501 | 502 | def upgradeDatabase(self): 503 | """Upgrade Database to ensure tables and columns are correct.""" 504 | try: 505 | # Get Database Version 506 | cursor = self.connection.cursor() 507 | cursor.execute("PRAGMA main.user_version;") 508 | db_version = int(cursor.fetchone()[0]) 509 | 510 | if db_version < 1: 511 | _LOGGER.info("Upgrading the database to version 1") 512 | # Create Empty Tables 513 | self.create_table(TABLE_HIGH_LOW) 514 | # Add Initial data to High Low 515 | self.initializeHighLow() 516 | 517 | if db_version < DATABASE_VERSION: 518 | _LOGGER.info("Upgrading the database to version 2") 519 | cursor.execute("ALTER TABLE high_low ADD max_yday REAL") 520 | cursor.execute("ALTER TABLE high_low ADD max_yday_time REAL") 521 | cursor.execute("ALTER TABLE high_low ADD min_yday REAL") 522 | cursor.execute("ALTER TABLE high_low ADD min_yday_time REAL") 523 | 524 | self.connection.commit() 525 | 526 | # if db_version < 2: 527 | # _LOGGER.info("Upgrading the database to version 2") 528 | # cursor.execute("ALTER TABLE high_low ADD max_yday REAL") 529 | # cursor.execute("ALTER TABLE high_low ADD max_yday_time REAL") 530 | # cursor.execute("ALTER TABLE high_low ADD min_yday REAL") 531 | # cursor.execute("ALTER TABLE high_low ADD min_yday_time REAL") 532 | 533 | # if db_version < DATABASE_VERSION: 534 | # _LOGGER.info("Upgrading the database to version %s...", DATABASE_VERSION) 535 | # self.create_table(TABLE_DAY_DATA) 536 | # self.connection.commit() 537 | 538 | # Finally update the version number 539 | cursor.execute(f"PRAGMA main.user_version = {DATABASE_VERSION};") 540 | _LOGGER.info("Database now version %s", DATABASE_VERSION) 541 | 542 | except Exception as e: 543 | _LOGGER.error("An undefined error occured. Error message: %s", e) 544 | 545 | def initializeHighLow(self): 546 | """Write Initial Data to the High Low Tabble.""" 547 | try: 548 | cursor = self.connection.cursor() 549 | cursor.execute( 550 | f"INSERT INTO high_low(sensorid, max_day, min_day) VALUES('{COL_DEWPOINT}', -9999, 9999);" 551 | ) 552 | cursor.execute( 553 | f"INSERT INTO high_low(sensorid, max_day, min_day) VALUES('{COL_HUMIDITY}', -9999, 9999);" 554 | ) 555 | cursor.execute( 556 | f"INSERT INTO high_low(sensorid, max_day, min_day) VALUES('{COL_ILLUMINANCE}', 0, 0);" 557 | ) 558 | cursor.execute( 559 | f"INSERT INTO high_low(sensorid, max_day, min_day) VALUES('{COL_PRESSURE}', -9999, 9999);" 560 | ) 561 | cursor.execute( 562 | f"INSERT INTO high_low(sensorid, max_day, min_day) VALUES('{COL_RAINDURATION}', 0, 0);" 563 | ) 564 | cursor.execute( 565 | f"INSERT INTO high_low(sensorid, max_day, min_day) VALUES('{COL_RAINRATE}', 0, 0);" 566 | ) 567 | cursor.execute( 568 | f"INSERT INTO high_low(sensorid, max_day, min_day) VALUES('{COL_SOLARRAD}', 0, 0);" 569 | ) 570 | cursor.execute( 571 | f"INSERT INTO high_low(sensorid, max_day, min_day) VALUES('{COL_STRIKECOUNT}', 0, 0);" 572 | ) 573 | cursor.execute( 574 | f"INSERT INTO high_low(sensorid, max_day, min_day) VALUES('{COL_STRIKEENERGY}', 0, 0);" 575 | ) 576 | cursor.execute( 577 | f"INSERT INTO high_low(sensorid, max_day, min_day) VALUES('{COL_TEMPERATURE}', -9999, 9999);" 578 | ) 579 | cursor.execute( 580 | f"INSERT INTO high_low(sensorid, max_day, min_day) VALUES('{COL_UV}', 0, 0);" 581 | ) 582 | cursor.execute( 583 | f"INSERT INTO high_low(sensorid, max_day, min_day) VALUES('{COL_WINDGUST}', 0, 0);" 584 | ) 585 | cursor.execute( 586 | f"INSERT INTO high_low(sensorid, max_day, min_day) VALUES('{COL_WINDLULL}', 0, 0);" 587 | ) 588 | cursor.execute( 589 | f"INSERT INTO high_low(sensorid, max_day, min_day) VALUES('{COL_WINDSPEED}', 0, 0);" 590 | ) 591 | self.connection.commit() 592 | 593 | except SQLError as e: 594 | _LOGGER.error("Could not Insert data in table high_low. Error: %s", e) 595 | except Exception as e: 596 | _LOGGER.error("Could write to high_low Table. Error message: %s", e) 597 | 598 | def dailyHousekeeping(self): 599 | """Clean up old data, daily.""" 600 | try: 601 | # Cleanup the Pressure Table 602 | pres_time_point = time.time() - PRESSURE_TREND_TIMER - 60 603 | cursor = self.connection.cursor() 604 | cursor.execute(f"DELETE FROM pressure WHERE timestamp < {pres_time_point};") 605 | 606 | # Cleanup the Lightning Table 607 | strike_time_point = time.time() - STRIKE_COUNT_TIMER - 60 608 | cursor.execute( 609 | f"DELETE FROM lightning WHERE timestamp < {strike_time_point};" 610 | ) 611 | 612 | # Update All Time Values values 613 | cursor.execute( 614 | f"UPDATE high_low SET max_all = max_day, max_all_time = max_day_time WHERE max_day > max_all or max_all IS NULL" 615 | ) 616 | cursor.execute( 617 | f"UPDATE high_low SET min_all = min_day, min_all_time = min_day_time WHERE (min_day < min_all or min_all IS NULL) and min_day_time IS NOT NULL" 618 | ) 619 | 620 | # Update or Reset Year Values 621 | cursor.execute( 622 | f"UPDATE high_low SET max_year = max_day, max_year_time = max_day_time WHERE (max_day > max_year or max_year IS NULL) AND strftime('%Y', 'now') = strftime('%Y', datetime(max_day_time, 'unixepoch', 'localtime'))" 623 | ) 624 | cursor.execute( 625 | f"UPDATE high_low SET min_year = min_day, min_year_time = min_day_time WHERE ((min_day < min_year or min_year IS NULL) AND min_day_time IS NOT NULL) AND strftime('%Y', 'now') = strftime('%Y', datetime(min_day_time, 'unixepoch', 'localtime'))" 626 | ) 627 | cursor.execute( 628 | f"UPDATE high_low SET max_year = latest, max_year_time = {time.time()}, min_year = latest, min_year_time = {time.time()} WHERE min_day <> 0 AND strftime('%Y', 'now') <> strftime('%Y', datetime(max_day_time, 'unixepoch', 'localtime'))" 629 | ) 630 | cursor.execute( 631 | f"UPDATE high_low SET max_year = 0, max_year_time = {time.time()} WHERE min_day = 0 AND strftime('%Y', 'now') <> strftime('%Y', datetime(max_day_time, 'unixepoch', 'localtime'))" 632 | ) 633 | 634 | # Update or Reset Month Values 635 | cursor.execute( 636 | f"UPDATE high_low SET max_month = max_day, max_month_time = max_day_time WHERE (max_day > max_month or max_month IS NULL) AND strftime('%m', 'now') = strftime('%m', datetime(max_day_time, 'unixepoch', 'localtime'))" 637 | ) 638 | cursor.execute( 639 | f"UPDATE high_low SET min_month = min_day, min_month_time = min_day_time WHERE ((min_day < min_month or min_month IS NULL) AND min_day_time IS NOT NULL) AND strftime('%m', 'now') = strftime('%m', datetime(min_day_time, 'unixepoch', 'localtime'))" 640 | ) 641 | cursor.execute( 642 | f"UPDATE high_low SET max_month = latest, max_month_time = {time.time()}, min_month = latest, min_month_time = {time.time()} WHERE min_day <> 0 AND strftime('%m', 'now') <> strftime('%m', datetime(max_day_time, 'unixepoch', 'localtime'))" 643 | ) 644 | cursor.execute( 645 | f"UPDATE high_low SET max_month = 0, max_month_time = {time.time()} WHERE min_day = 0 AND strftime('%m', 'now') <> strftime('%m', datetime(max_day_time, 'unixepoch', 'localtime'))" 646 | ) 647 | 648 | # Update or Reset Week Values 649 | cursor.execute( 650 | f"UPDATE high_low SET max_week = max_day, max_week_time = max_day_time WHERE (max_day > max_week or max_week IS NULL) AND strftime('%W', 'now') = strftime('%W', datetime(max_day_time, 'unixepoch', 'localtime'))" 651 | ) 652 | cursor.execute( 653 | f"UPDATE high_low SET min_week = min_day, min_week_time = min_day_time WHERE ((min_day < min_week or min_week IS NULL) AND min_day_time IS NOT NULL) AND strftime('%W', 'now') = strftime('%W', datetime(min_day_time, 'unixepoch', 'localtime'))" 654 | ) 655 | cursor.execute( 656 | f"UPDATE high_low SET max_week = latest, max_week_time = {time.time()}, min_week = latest, min_week_time = {time.time()} WHERE min_day <> 0 AND strftime('%W', 'now') <> strftime('%W', datetime(max_day_time, 'unixepoch', 'localtime'))" 657 | ) 658 | cursor.execute( 659 | f"UPDATE high_low SET max_week = 0, max_week_time = {time.time()} WHERE min_day = 0 AND strftime('%W', 'now') <> strftime('%W', datetime(max_day_time, 'unixepoch', 'localtime'))" 660 | ) 661 | 662 | # Update Yesterday Values 663 | cursor.execute( 664 | f"UPDATE high_low SET max_yday = max_day, max_yday_time = max_day_time, min_yday = min_day, min_yday_time = min_day_time" 665 | ) 666 | 667 | # Reset Day High and Low values 668 | cursor.execute( 669 | f"UPDATE high_low SET max_day = latest, max_day_time = {time.time()}, min_day = latest, min_day_time = {time.time()} WHERE min_day <> 0" 670 | ) 671 | cursor.execute( 672 | f"UPDATE high_low SET max_day = 0, max_day_time = {time.time()} WHERE min_day = 0" 673 | ) 674 | self.connection.commit() 675 | 676 | return True 677 | 678 | except SQLError as e: 679 | _LOGGER.error("Could not perform daily housekeeping. Error: %s", e) 680 | return False 681 | except Exception as e: 682 | _LOGGER.error("Could not perform daily housekeeping. Error message: %s", e) 683 | return False 684 | -------------------------------------------------------------------------------- /weatherflow2mqtt/translations/da.json: -------------------------------------------------------------------------------- 1 | { 2 | "beaufort": { 3 | "0": "Stille", 4 | "1": "Næsten stille", 5 | "2": "Svag vind", 6 | "3": "Let vind", 7 | "4": "Jævn vind", 8 | "5": "Frisk vind", 9 | "6": "Hård vind", 10 | "7": "Stiv kuling", 11 | "8": "Hård kuling", 12 | "9": "Stormende kuling", 13 | "10": "Storm", 14 | "11": "Stærk storm", 15 | "12": "Orkan" 16 | }, 17 | "wind_dir": { 18 | "N": "N", 19 | "NNE": "NNØ", 20 | "NE": "NØ", 21 | "ENE": "ØNØ", 22 | "E": "Ø", 23 | "ESE": "ØSØ", 24 | "SE": "SØ", 25 | "SSE": "SSØ", 26 | "S": "S", 27 | "SSW": "SSV", 28 | "SW": "SV", 29 | "WSW": "VSV", 30 | "W": "V", 31 | "WNW": "VNV", 32 | "NW": "NV", 33 | "NNW": "NNV" 34 | }, 35 | "trend": { 36 | "falling": "Falder", 37 | "rising": "Stiger", 38 | "steady": "Stabil" 39 | }, 40 | "dewpoint": { 41 | "severely-high": "Meget høj", 42 | "miserable": "Miserabel", 43 | "oppressive": "Tryggende", 44 | "uncomfortable": "Ubehagelig", 45 | "ok-for-most": "Ok for de fleste", 46 | "comfortable": "Behageligt", 47 | "very-comfortable": "Meget behageligt", 48 | "somewhat-dry": "Delvist tørt", 49 | "dry": "Tørt", 50 | "very-dry": "Meget tørt", 51 | "undefined": "Ingen data" 52 | }, 53 | "temperature": { 54 | "inferno": "Mega varmt", 55 | "very-hot": "Meget varmt", 56 | "hot": "Varmt", 57 | "warm": "Behagelig varmt", 58 | "nice": "Dejligt", 59 | "cool": "Kølig", 60 | "chilly": "Meget køligt", 61 | "cold": "Koldt", 62 | "freezing": "Meget koldt", 63 | "fridged": "Frysende", 64 | "undefined": "Ingen data" 65 | }, 66 | "uv": { 67 | "extreme": "Ekstrem høj", 68 | "very-high": "Meget høj", 69 | "high": "Høj", 70 | "moderate": "Moderat", 71 | "low": "Lav", 72 | "none": "Ingen data" 73 | }, 74 | "precip_type": { 75 | "none": "Ingen", 76 | "rain": "Regn", 77 | "hail": "Hagl", 78 | "heavy-rain": "Kraftig regn" 79 | }, 80 | "rain_intensity": { 81 | "NONE": "Ingen", 82 | "VERYLIGHT": "Meget Let", 83 | "LIGHT": "Let", 84 | "MODERATE": "Moderat", 85 | "HEAVY": "Kraftig", 86 | "VERYHEAVY": "Meget Kraftig", 87 | "EXTREME": "Ekstrem" 88 | }, 89 | "zambretti": { 90 | "A": "Stabilt fint vejr", 91 | "B": "Fint vejr", 92 | "C": "Opklarende, bliver fint", 93 | "D": "Fint. Bliver mere ustabilt", 94 | "E": "Fint, med mulighed for lette byger", 95 | "F": "Opholdsvejr. Bedre vejr senere", 96 | "G": "Opholdsvejr. Mulighed for lette byger senere", 97 | "H": "Opholdsvejr. Regn senere", 98 | "I": "Tidlig lette regnbyer, bedre senere", 99 | "J": "Vekslende vejr, bliver bedre", 100 | "K": "Rimeligt fint vejr, mulighed for byger senere", 101 | "L": "Temmelig ustabilt vejr, klarer op senere", 102 | "M": "Ustabilt, bedres formentlig", 103 | "N": "Byger, med jævne intervaller", 104 | "O": "Byger, bliver mere ustabilt", 105 | "P": "Foranderligt, nogen regn", 106 | "Q": "Byger, med små korte intervaller", 107 | "R": "Ustabilt, regn senere", 108 | "S": "Ustabilt, regn med jævne mellemrum", 109 | "T": "Meget ustabilt, men mulighed for opholdsvejr", 110 | "U": "Regnbyger, bliver mere ustabilt", 111 | "V": "Regnbyer, bliver meget ustabilt", 112 | "W": "Regn med jævne mellemrum", 113 | "X": "Meget ustabilt, regn", 114 | "Y": "Storm, bliver muligvis bedre", 115 | "Z": "Storm, megen regn" 116 | } 117 | } -------------------------------------------------------------------------------- /weatherflow2mqtt/translations/de.json: -------------------------------------------------------------------------------- 1 | { 2 | "beaufort": { 3 | "0": "Windstille", 4 | "1": "Leiser Zug", 5 | "2": "Leichte Brise", 6 | "3": "Schwache Brise", 7 | "4": "Mäßige Brise", 8 | "5": "Frische Brise", 9 | "6": "Starker Wind", 10 | "7": "Steifer Wind", 11 | "8": "Stürmischer Wind", 12 | "9": "Sturm", 13 | "10": "Schwerer Sturm", 14 | "11": "Orkanartiger Sturm", 15 | "12": "Orkan" 16 | }, 17 | "wind_dir": { 18 | "N": "N", 19 | "NNE": "NNO", 20 | "NE": "NO", 21 | "ENE": "ONO", 22 | "E": "O", 23 | "ESE": "OSO", 24 | "SE": "SO", 25 | "SSE": "SSO", 26 | "S": "S", 27 | "SSW": "SSW", 28 | "SW": "SW", 29 | "WSW": "WSW", 30 | "W": "W", 31 | "WNW": "WNW", 32 | "NW": "NW", 33 | "NNW": "NNW" 34 | }, 35 | "trend": { 36 | "falling": "Fallend", 37 | "rising": "Steigend", 38 | "steady": "Stabil" 39 | }, 40 | "dewpoint": { 41 | "severely-high": "Ernsthaft hoch", 42 | "miserable": "Miserabel", 43 | "oppressive": "Drückend", 44 | "uncomfortable": "Unangenehm", 45 | "ok-for-most": "Großteils okay", 46 | "comfortable": "Angenehm", 47 | "very-comfortable": "Sehr angenehm", 48 | "somewhat-dry": "Etwas trocken", 49 | "dry": "Trocken", 50 | "very-dry": "Sehr trocken", 51 | "undefined": "Undefiniert" 52 | }, 53 | "temperature": { 54 | "inferno": "Inferno", 55 | "very-hot": "Sehr heiß", 56 | "hot": "Heiß", 57 | "warm": "Warm", 58 | "nice": "Angenehm", 59 | "cool": "Frisch", 60 | "chilly": "Kühl", 61 | "cold": "Kalt", 62 | "freezing": "Eiskalt", 63 | "fridged": "Gefroren", 64 | "undefined": "Undefiniert" 65 | }, 66 | "uv": { 67 | "extreme": "Extrem", 68 | "very-high": "Sehr hoch", 69 | "high": "Hoch", 70 | "moderate": "Mäßig", 71 | "low": "Niedrig", 72 | "none": "Keines" 73 | }, 74 | "precip_type": { 75 | "none": "Keiner", 76 | "rain": "Regen", 77 | "hail": "Hagel", 78 | "heavy-rain": "Starkregen" 79 | }, 80 | "rain_intensity": { 81 | "NONE": "Keiner", 82 | "VERYLIGHT": "Sehr Leicht", 83 | "LIGHT": "Leicht", 84 | "MODERATE": "Mäßig", 85 | "HEAVY": "Schwer", 86 | "VERYHEAVY": "Seht Schwer", 87 | "EXTREME": "Extrem" 88 | }, 89 | "zambretti": { 90 | "A": "Beständiges Schönwetter", 91 | "B": "Schönes Wetter", 92 | "C": "Wetter wird gut", 93 | "D": "Schön, wird wechselhaft", 94 | "E": "Schön, Regenschauer möglich", 95 | "F": "Ziemlich gut, verbessert sich", 96 | "G": "Ziemlich gut, frühe Regenschauer möglich", 97 | "H": "Ziemlich gut, spätere Regenschauer", 98 | "I": "Früh schauerhaft, verbessert sich", 99 | "J": "Wechselhaft, verbessert sich", 100 | "K": "Ziemlich gut, Regenschauer möglich", 101 | "L": "Eher veränderlich, klart später auf", 102 | "M": "Veränderlich, verbessert sich wahrscheinlich", 103 | "N": "Regnerisch mit Aufhellungen", 104 | "O": "Regnerisch, wird veränderlich", 105 | "P": "Veränderlich mit wenig Regen", 106 | "Q": "Veränderlich, mit kurzen schönen Intervallen", 107 | "R": "Veränderlich, später Regen", 108 | "S": "Veränderlich, zeitweise Regen", 109 | "T": "Stark wechselnd, zeitweise schöner", 110 | "U": "Zeitweise Regen, verschlechtert sich", 111 | "V": "Zeitweise Regen, wird sehr unruhig", 112 | "W": "Regen in regelmässigen Abständen", 113 | "X": "Sehr veränderlich, Regen", 114 | "Y": "Stürmisch, verbessert sich wahrscheinlich", 115 | "Z": "Stürmisch, viel Regen" 116 | } 117 | } 118 | -------------------------------------------------------------------------------- /weatherflow2mqtt/translations/en.json: -------------------------------------------------------------------------------- 1 | { 2 | "beaufort": { 3 | "0": "Calm", 4 | "1": "Light air", 5 | "2": "Light breeze", 6 | "3": "Gentle breeze", 7 | "4": "Moderate breeze", 8 | "5": "Fresh breeze", 9 | "6": "Strong breeze", 10 | "7": "Moderate Gale", 11 | "8": "Fresh Gale", 12 | "9": "Strong Gale", 13 | "10": "Storm", 14 | "11": "Violent Storm", 15 | "12": "Hurricane" 16 | }, 17 | "wind_dir": { 18 | "N": "N", 19 | "NNE": "NNE", 20 | "NE": "NE", 21 | "ENE": "ENE", 22 | "E": "E", 23 | "ESE": "ESE", 24 | "SE": "SE", 25 | "SSE": "SSE", 26 | "S": "S", 27 | "SSW": "SSW", 28 | "SW": "SW", 29 | "WSW": "WSW", 30 | "W": "W", 31 | "WNW": "WNW", 32 | "NW": "NW", 33 | "NNW": "NNW" 34 | }, 35 | "trend": { 36 | "falling": "Falling", 37 | "rising": "Rising", 38 | "steady": "Steady" 39 | }, 40 | "dewpoint": { 41 | "severely-high": "Severely High", 42 | "miserable": "Miserable", 43 | "oppressive": "Oppressive", 44 | "uncomfortable": "Uncomfortable", 45 | "ok-for-most": "Ok for Most", 46 | "comfortable": "Comfortable", 47 | "very-comfortable": "Very Comfortable", 48 | "somewhat-dry": "Somewhat Dry", 49 | "dry": "Dry", 50 | "very-dry": "Very Dry", 51 | "undefined": "Undefined" 52 | }, 53 | "temperature": { 54 | "inferno": "Inferno", 55 | "very-hot": "Very Hot", 56 | "hot": "Hot", 57 | "warm": "Warm", 58 | "nice": "Nice", 59 | "cool": "Cool", 60 | "chilly": "Chilly", 61 | "cold": "Cold", 62 | "freezing": "Freezing", 63 | "fridged": "Frigid", 64 | "undefined": "Undefined" 65 | }, 66 | "uv": { 67 | "extreme": "Extreme", 68 | "very-high": "Very High", 69 | "high": "High", 70 | "moderate": "Moderate", 71 | "low": "Low", 72 | "none": "None" 73 | }, 74 | "precip_type": { 75 | "none": "None", 76 | "rain": "Rain", 77 | "hail": "Hail", 78 | "heavy-rain": "Heavy Rain" 79 | }, 80 | "rain_intensity": { 81 | "NONE": "None", 82 | "VERYLIGHT": "Very Light", 83 | "LIGHT": "Light", 84 | "MODERATE": "Moderate", 85 | "HEAVY": "Heavy", 86 | "VERYHEAVY": "Very Heavy", 87 | "EXTREME": "Extreme" 88 | }, 89 | "zambretti": { 90 | "A": "Settled fine weather", 91 | "B": "Fine weather", 92 | "C": "Becoming fine", 93 | "D": "Fine, becoming less settled", 94 | "E": "Fine, possibly showers", 95 | "F": "Fairly fine, improving", 96 | "G": "Fairly fine, possibly showers early", 97 | "H": "Fairly fine, showers later", 98 | "I": "Showery early, improving", 99 | "J": "Changeable, improving", 100 | "K": "Fairly fine, showers likely", 101 | "L": "Rather unsettled, clearing later", 102 | "M": "Unsettled, probably improving", 103 | "N": "Showery, bright intervals", 104 | "O": "Showery, becoming unsettled", 105 | "P": "Changeable, some rain", 106 | "Q": "Unsettled, short fine intervals", 107 | "R": "Unsettled, rain later", 108 | "S": "Unsettled, rain at times", 109 | "T": "Very unsettled, finer at times", 110 | "U": "Rain at times, worse later", 111 | "V": "Rain at times, becoming very unsettled", 112 | "W": "Rain at frequent intervals", 113 | "X": "Very unsettled, rain", 114 | "Y": "Stormy, possibly improving", 115 | "Z": "Stormy, much rain" 116 | } 117 | } 118 | -------------------------------------------------------------------------------- /weatherflow2mqtt/translations/fr.json: -------------------------------------------------------------------------------- 1 | { 2 | "beaufort": { 3 | "0": "Calme", 4 | "1": "Léger", 5 | "2": "Brise légère", 6 | "3": "Brise légère", 7 | "4": "Brise moyenne", 8 | "5": "Brise fraîche", 9 | "6": "Brise Forte", 10 | "7": "Vent modéré", 11 | "8": "Vent frais", 12 | "9": "Vent fort", 13 | "10": "Tempête", 14 | "11": "Violente tempête", 15 | "12": "Ouragan" 16 | }, 17 | "wind_dir": { 18 | "N": "N", 19 | "NNE": "NNE", 20 | "NE": "NE", 21 | "ENE": "ENE", 22 | "E": "E", 23 | "ESE": "ESE", 24 | "SE": "SE", 25 | "SSE": "SSE", 26 | "S": "S", 27 | "SSW": "SSO", 28 | "SW": "SO", 29 | "WSW": "OSO", 30 | "W": "O", 31 | "WNW": "ONO", 32 | "NW": "NO", 33 | "NNW": "NNO" 34 | }, 35 | "trend": { 36 | "falling": "En baisse", 37 | "rising": "En hausse", 38 | "steady": "Stable" 39 | }, 40 | "dewpoint": { 41 | "severely-high": "Sévèrement élevé", 42 | "miserable": "Misérable", 43 | "oppressive": "Oppressif", 44 | "uncomfortable": "Inconfortable", 45 | "ok-for-most": "Ok pour la plupart", 46 | "comfortable": "Comfortable", 47 | "very-comfortable": "Très confortable", 48 | "somewhat-dry": "Un peu sec", 49 | "dry": "Sec", 50 | "very-dry": "Très sec", 51 | "undefined": "Non défini" 52 | }, 53 | "temperature": { 54 | "inferno": "D’enfer", 55 | "very-hot": "Très chaude", 56 | "hot": "Chaude", 57 | "warm": "Chaleureuse", 58 | "nice": "Agréable", 59 | "cool": "Fraiche", 60 | "chilly": "Froide", 61 | "cold": "Froide", 62 | "freezing": "Gel", 63 | "fridged": "Réfrigéré", 64 | "undefined": "Non définie" 65 | }, 66 | "uv": { 67 | "extreme": "Extrême", 68 | "very-high": "Très haut", 69 | "high": "Haut", 70 | "moderate": "Moyen", 71 | "low": "Bas", 72 | "none": "Aucun" 73 | }, 74 | "precip_type": { 75 | "none": "Aucune", 76 | "rain": "Pluie", 77 | "hail": "Grêle", 78 | "heavy-rain": "Forte pluie" 79 | }, 80 | "rain_intensity": { 81 | "NONE": "Aucune", 82 | "VERYLIGHT": "Très faible", 83 | "LIGHT": "Faible", 84 | "MODERATE": "Modérée", 85 | "HEAVY": "Forte", 86 | "VERYHEAVY": "Très forte", 87 | "EXTREME": "Extrême" 88 | }, 89 | "zambretti": { 90 | "A": "Beau temps installé", 91 | "B": "Beau temps", 92 | "C": "Amélioration de la météo", 93 | "D": "Beau temps, deviens changeant", 94 | "E": "Beau temps, Possibilité d'averses", 95 | "F": "Assez bon, en amélioration", 96 | "G": "Assez bon, des averses précoces sont possibles", 97 | "H": "Assez bon, des averses plus tard", 98 | "I": "Pluie précoce, amélioration", 99 | "J": "Changeant s'améliore", 100 | "K": "Assez bon, averses probables", 101 | "L": "Plutôt variable, s'éclaircira plus tard", 102 | "M": "Instable, probablement en amélioration", 103 | "N": "Pluie avec éclaircissement", 104 | "O": "Pluvieux, devient instable", 105 | "P": "Variable avec peu d'averses", 106 | "Q": "Instable, avec quelques intervalles de beau", 107 | "R": "Instable, averses plus tard", 108 | "S": "Instable, averses occasionnelles", 109 | "T": "Très Instable, parfois plus beau", 110 | "U": "Averses par moments, plus mauvais plus tard", 111 | "V": "Averses par moments, devient très instable", 112 | "W": "Averses à intervalles fréquents", 113 | "X": "Très instable, Pluie", 114 | "Y": "Orageux, possible amélioration", 115 | "Z": "Orageux, beaucoup de pluie" 116 | } 117 | } 118 | -------------------------------------------------------------------------------- /weatherflow2mqtt/translations/nl.json: -------------------------------------------------------------------------------- 1 | { 2 | "beaufort": { 3 | "0": "Stil", 4 | "1": "Zeer zwak", 5 | "2": "Zwak", 6 | "3": "Vrij matig", 7 | "4": "Matig", 8 | "5": "Vrij krachtig", 9 | "6": "Krachtig", 10 | "7": "Hard", 11 | "8": "Stormachtig", 12 | "9": "Storm", 13 | "10": "Zware storm", 14 | "11": "Zeer zware storm", 15 | "12": "Orkaan" 16 | }, 17 | "wind_dir": { 18 | "N": "N", 19 | "NNE": "NNO", 20 | "NE": "NO", 21 | "ENE": "ONO", 22 | "E": "O", 23 | "ESE": "OZO", 24 | "SE": "ZO", 25 | "SSE": "ZZO", 26 | "S": "Z", 27 | "SSW": "ZZW", 28 | "SW": "ZW", 29 | "WSW": "WZW", 30 | "W": "W", 31 | "WNW": "WNW", 32 | "NW": "NW", 33 | "NNW": "NNW" 34 | }, 35 | "trend": { 36 | "falling": "Dalend", 37 | "rising": "Stijgend", 38 | "steady": "Stabiel" 39 | }, 40 | "dewpoint": { 41 | "severely-high": "Gevaarlijk hoog", 42 | "miserable": "Ellendig", 43 | "oppressive": "Drukkend", 44 | "uncomfortable": "Ongemakkelijk", 45 | "ok-for-most": "Redelijk goed", 46 | "comfortable": "Comfortabel", 47 | "very-comfortable": "Zeer comfortabel", 48 | "somewhat-dry": "Licht droog", 49 | "dry": "Droog", 50 | "very-dry": "Zeer droog", 51 | "undefined": "Ongedefinieerd" 52 | }, 53 | "temperature": { 54 | "inferno": "Extreem heet", 55 | "very-hot": "Erg heet", 56 | "hot": "Heet", 57 | "warm": "Warm", 58 | "nice": "Lekker", 59 | "cool": "Koel", 60 | "chilly": "Fris", 61 | "cold": "Koud", 62 | "freezing": "IJskoud", 63 | "fridged": "Vrieskou", 64 | "undefined": "Ongedefinieerd" 65 | }, 66 | "uv": { 67 | "extreme": "Extreem", 68 | "very-high": "Erg hoog", 69 | "high": "Hoog", 70 | "moderate": "Gemiddeld", 71 | "low": "Laag", 72 | "none": "Geen" 73 | }, 74 | "precip_type": { 75 | "none": "Geen", 76 | "rain": "Regen", 77 | "hail": "Hagel", 78 | "heavy-rain": "Zware regen" 79 | }, 80 | "rain_intensity": { 81 | "NONE": "Geen", 82 | "VERYLIGHT": "Erg licht", 83 | "LIGHT": "Licht", 84 | "MODERATE": "Gemiddeld", 85 | "HEAVY": "Zwaar", 86 | "VERYHEAVY": "Zeer zwaar", 87 | "EXTREME": "Extreem" 88 | }, 89 | "zambretti": { 90 | "A": "Stabiel mooi weer", 91 | "B": "Mooi weer", 92 | "C": "Aankomend mooi weer", 93 | "D": "Mooi, het wordt minder stabiel", 94 | "E": "Mooi, mogelijk regen", 95 | "F": "Goed, verbeterend", 96 | "G": "Goed, mogelijk regen", 97 | "H": "Goed, mogelijk later regen", 98 | "I": "Regenachtig, verbeterend", 99 | "J": "Veranderlijk, verbeterend", 100 | "K": "Redelijk goed, mogelijk regen", 101 | "L": "Redelijk onstabiel, later mooier", 102 | "M": "Onstabiel, mogelijk verbeterend", 103 | "N": "Regenachtig met heldere opklaringen", 104 | "O": "Regenachtig, het wordt onstabiel", 105 | "P": "Veranderlijk, soms regen", 106 | "Q": "Onstabiel met korte opklaringen", 107 | "R": "Onstabiel, later regen", 108 | "S": "Onveranderlijk, geregeld regen", 109 | "T": "Zeer onstabiel, af en toe beter", 110 | "U": "Geregeld regen, later slechter", 111 | "V": "Geregeld regen, het wordt onstabiel", 112 | "W": "Geregeld regen", 113 | "X": "Erg onstabiel, regen", 114 | "Y": "Stormachtig, mogelijk verbeterend", 115 | "Z": "Stormachtig, veel regen" 116 | } 117 | } 118 | -------------------------------------------------------------------------------- /weatherflow2mqtt/translations/se.json: -------------------------------------------------------------------------------- 1 | { 2 | "beaufort": { 3 | "0": "Stiltje", 4 | "1": "Nästan stiltje", 5 | "2": "Lätt bris", 6 | "3": "God bris", 7 | "4": "Frisk bris", 8 | "5": "Styv bris", 9 | "6": "Hård bris", 10 | "7": "Styv kuling", 11 | "8": "Hård kuling", 12 | "9": "Halv storm", 13 | "10": "Storm", 14 | "11": "Svår storm", 15 | "12": "Orkan" 16 | }, 17 | "wind_dir": { 18 | "N": "N", 19 | "NNE": "NNÖ", 20 | "NE": "NÖ", 21 | "ENE": "ÖNÖ", 22 | "E": "Ö", 23 | "ESE": "ÖSÖ", 24 | "SE": "SÖ", 25 | "SSE": "SSÖ", 26 | "S": "S", 27 | "SSW": "SSV", 28 | "SW": "SV", 29 | "WSW": "VSV", 30 | "W": "V", 31 | "WNW": "VNV", 32 | "NW": "NV", 33 | "NNW": "NNV" 34 | }, 35 | "trend": { 36 | "falling": "Fallande", 37 | "rising": "Stigande", 38 | "steady": "Stadig" 39 | }, 40 | "dewpoint": { 41 | "severely-high": "Mycket hög", 42 | "miserable": "Miserabel", 43 | "oppressive": "Tryckande", 44 | "uncomfortable": "Obehaglig", 45 | "ok-for-most": "Ok för de flesta", 46 | "comfortable": "Behaglig", 47 | "very-comfortable": "Väldigt behaglig", 48 | "somewhat-dry": "Delvis torr", 49 | "dry": "Torr", 50 | "very-dry": "Väldigt torr", 51 | "undefined": "Ingen data" 52 | }, 53 | "temperature": { 54 | "inferno": "Extremt varmt", 55 | "very-hot": "Mycket varmt", 56 | "hot": "Varmt", 57 | "warm": "Behagligt varmt", 58 | "nice": "Fint", 59 | "cool": "Kyligt", 60 | "chilly": "Mycket kyligt", 61 | "cold": "Kallt", 62 | "freezing": "Mycket kallt", 63 | "fridged": "Extremt kallt", 64 | "undefined": "Ingen data" 65 | }, 66 | "uv": { 67 | "extreme": "Extremt hög", 68 | "very-high": "Mycket hög", 69 | "high": "Hög", 70 | "moderate": "Moderat", 71 | "low": "Låg", 72 | "none": "Ingen" 73 | }, 74 | "precip_type": { 75 | "none": "Ingen", 76 | "rain": "Regn", 77 | "hail": "Hagel", 78 | "heavy-rain": "Ösregn" 79 | }, 80 | "rain_intensity": { 81 | "NONE": "Uppehåll", 82 | "VERYLIGHT": "Mycket lätt", 83 | "LIGHT": "Lätt", 84 | "MODERATE": "Moderat", 85 | "HEAVY": "Kraftig", 86 | "VERYHEAVY": "Väldigt kraftig", 87 | "EXTREME": "Extrem" 88 | }, 89 | "zambretti": { 90 | "A": "Stadigt fint väder", 91 | "B": "Fint väder", 92 | "C": "Uppklarnande, blir fint", 93 | "D": "Fint, blir mer ostadigt", 94 | "E": "Fint, med risk för lätta skurar", 95 | "F": "Uppehåll, bättre senare", 96 | "G": "Uppehåll, risk för lätta skurar senare", 97 | "H": "Uppehåll, regn senare", 98 | "I": "Tidigt lätta skurar, bättre senare", 99 | "J": "Växlande, bättre senare", 100 | "K": "Ganska fint, risk för lätta skurar senare", 101 | "L": "Ganska ostadigt, klarnar upp senare", 102 | "M": "Ostabilt, troligen bättre senare", 103 | "N": "Regnbyar, med jämna mellanrum", 104 | "O": "Regnbyar, blir mer ostadigt", 105 | "P": "Omväxlande, lite regn", 106 | "Q": "Ostadigt, regnbyar", 107 | "R": "Ostadigt, regn senare", 108 | "S": "Ostadigt, regn med jämna mellanrum", 109 | "T": "Mycket ostadigt, men möjlighet till uppehållsväder", 110 | "U": "Regnbyar, sämre senare", 111 | "V": "Regnbyar, blir mer ostadigt", 112 | "W": "Regn med jämna mellanrum", 113 | "X": "Mycket ostabilt, regn", 114 | "Y": "Storm, möjlig förbättring", 115 | "Z": "Storm, mycket regn" 116 | } 117 | } -------------------------------------------------------------------------------- /weatherflow2mqtt/translations/si.json: -------------------------------------------------------------------------------- 1 | { 2 | "beaufort": { 3 | "0": "Tišina", 4 | "1": "Lahen vetrič", 5 | "2": "Vetrič", 6 | "3": "Slab veter", 7 | "4": "Zmeren veter", 8 | "5": "Zmerno močan veter", 9 | "6": "Močan veter", 10 | "7": "Zelo močan veter", 11 | "8": "Viharni veter", 12 | "9": "Vihar", 13 | "10": "Močan vihar", 14 | "11": "Orkanski veter", 15 | "12": "Orkan" 16 | }, 17 | "wind_dir": { 18 | "N": "Sever", 19 | "NNE": "Sever-severovzhod", 20 | "NE": "Severovzhod", 21 | "ENE": "Vzhod-Severovzhod", 22 | "E": "Vzhod", 23 | "ESE": "Vzhod-jugovzhod", 24 | "SE": "Jugovzhod", 25 | "SSE": "Jug-jugovzhod", 26 | "S": "Jug", 27 | "SSW": "Jug-jugozahod", 28 | "SW": "Jugozahod", 29 | "WSW": "Zahod-jugozahod", 30 | "W": "Zahod", 31 | "WNW": "Zahod-severozahod", 32 | "NW": "Severozahod", 33 | "NNW": "Sever-severozahod" 34 | }, 35 | "trend": { 36 | "falling": "Padajoče", 37 | "rising": "Naraščajoče", 38 | "steady": "Stalno" 39 | }, 40 | "dewpoint": { 41 | "severely-high": "Izjemno visoko", 42 | "miserable": "Neznosno", 43 | "oppressive": "Zatohlo", 44 | "uncomfortable": "Neprijetno", 45 | "ok-for-most": "Za večino v redu", 46 | "comfortable": "Prijetno", 47 | "very-comfortable": "Zelo prijetno", 48 | "somewhat-dry": "Precej suho", 49 | "dry": "Suho", 50 | "very-dry": "Zelo suho", 51 | "undefined": "Nedoločeno" 52 | }, 53 | "temperature": { 54 | "inferno": "Pekel", 55 | "very-hot": "Zelo vroče", 56 | "hot": "Vroče", 57 | "warm": "Toplo", 58 | "nice": "Prijetno", 59 | "cool": "Ohlajeno", 60 | "chilly": "Hladno", 61 | "cold": "Mrzlo", 62 | "freezing": "Zelo mrzlo", 63 | "fridged": "Ledeno", 64 | "undefined": "Nedoločeno" 65 | }, 66 | "uv": { 67 | "extreme": "Ekstremno", 68 | "very-high": "Zelo visoko", 69 | "high": "Visoko", 70 | "moderate": "Zmerno", 71 | "low": "Nizko", 72 | "none": "Brez" 73 | }, 74 | "precip_type": { 75 | "none": "Brez padavin", 76 | "rain": "Dež", 77 | "hail": "Toča", 78 | "heavy-rain": "Naliv" 79 | }, 80 | "rain_intensity": { 81 | "NONE": "Brez padavin", 82 | "VERYLIGHT": "Zelo rahelo", 83 | "LIGHT": "Rahlo", 84 | "MODERATE": "Zmerno", 85 | "HEAVY": "Močno", 86 | "VERYHEAVY": "Zelo močno", 87 | "EXTREME": "Ekstremno" 88 | }, 89 | "zambretti": { 90 | "A": "Stanovitno lepo vreme", 91 | "B": "Lepo vreme", 92 | "C": "Postaja lepo", 93 | "D": "Lepo, a manj stanovitno", 94 | "E": "Lepo, možni so kratki nalivi", 95 | "F": "Dokaj lepo, se izboljšuje", 96 | "G": "Dokaj lepo, z možnostjo zgodnjih nalivov", 97 | "H": "Dokaj lepo, nalivi kasneje", 98 | "I": "Deževno zgodaj, se izboljšuje", 99 | "J": "Spremenljivo, se izboljšuje", 100 | "K": "Dokaj lepo, verjetnost nalivov", 101 | "L": "Precej nestanovitno, zjasnitev kasneje", 102 | "M": "Nestanovitno, verjetno se izboljšuje", 103 | "N": "Deževno z občasnimi zjasnitvami", 104 | "O": "Deževno, postaja nestanovitno", 105 | "P": "Spremenljivo, nekaj dežja", 106 | "Q": "Nestanovitno, kratka obdobja lepega vremena", 107 | "R": "Nestanovitno, dež pozneje", 108 | "S": "Nestanovitno, občasno deževno", 109 | "T": "Zelo nestanovitno, občasno lepo", 110 | "U": "Občasno deževno, kasneje slabše", 111 | "V": "Občasno deževno, postaja zelo nestanovitno", 112 | "W": "Pogosti intervali dežja", 113 | "X": "Zelo nestanovitno, dež", 114 | "Y": "Nevihta, možno izboljšanje", 115 | "Z": "Nevihta, veliko dežja" 116 | } 117 | } 118 | -------------------------------------------------------------------------------- /weatherflow2mqtt/weatherflow_mqtt.py: -------------------------------------------------------------------------------- 1 | """Program listening to the UDP Broadcast. 2 | 3 | from a WeatherFlow Weather Station and publishing sensor data to MQTT. 4 | """ 5 | from __future__ import annotations 6 | 7 | import asyncio 8 | import json 9 | import logging 10 | import os 11 | import sys 12 | from dataclasses import dataclass 13 | from datetime import datetime 14 | from math import ceil 15 | from typing import Any, Callable, OrderedDict 16 | 17 | from paho.mqtt.client import Client as MqttClient 18 | from pint import Quantity 19 | from pyweatherflowudp.client import EVENT_DEVICE_DISCOVERED, WeatherFlowListener 20 | from pyweatherflowudp.const import UNIT_METERS 21 | from pyweatherflowudp.device import ( 22 | EVENT_LOAD_COMPLETE, 23 | EVENT_OBSERVATION, 24 | EVENT_RAIN_START, 25 | EVENT_RAPID_WIND, 26 | EVENT_STATUS_UPDATE, 27 | EVENT_STRIKE, 28 | AirSensorType, 29 | HubDevice, 30 | SkySensorType, 31 | TempestDevice, 32 | WeatherFlowDevice, 33 | WeatherFlowSensorDevice, 34 | ) 35 | from pyweatherflowudp.event import ( 36 | CustomEvent, 37 | LightningStrikeEvent, 38 | RainStartEvent, 39 | WindEvent, 40 | ) 41 | 42 | from .__version__ import VERSION 43 | from .const import ( 44 | ATTR_ATTRIBUTION, 45 | ATTRIBUTION, 46 | DATABASE, 47 | DEVICE_CLASS_TIMESTAMP, 48 | DOMAIN, 49 | EVENT_HIGH_LOW, 50 | EXTERNAL_DIRECTORY, 51 | FORECAST_ENTITY, 52 | HIGH_LOW_TIMER, 53 | LANGUAGE_ENGLISH, 54 | MANUFACTURER, 55 | TEMP_CELSIUS, 56 | UNITS_IMPERIAL, 57 | UNITS_METRIC, 58 | ZAMBRETTI_MAX_PRESSURE, 59 | ZAMBRETTI_MIN_PRESSURE, 60 | ) 61 | from .forecast import Forecast, ForecastConfig 62 | from .helpers import ConversionFunctions, read_config, truebool 63 | from .sensor_description import ( 64 | DEVICE_SENSORS, 65 | FORECAST_SENSORS, 66 | HUB_SENSORS, 67 | OBSOLETE_SENSORS, 68 | BaseSensorDescription, 69 | SensorDescription, 70 | SqlSensorDescription, 71 | StorageSensorDescription, 72 | ) 73 | from .sqlite import SQLFunctions 74 | 75 | _LOGGER = logging.getLogger(__name__) 76 | 77 | MQTT_TOPIC_FORMAT = "homeassistant/sensor/{}/{}/{}" 78 | DEVICE_SERIAL_FORMAT = f"{DOMAIN}_{{}}" 79 | 80 | 81 | @dataclass 82 | class HostPortConfig: 83 | """Dataclass to define a Host/Port configuration.""" 84 | 85 | host: str 86 | port: int 87 | 88 | 89 | @dataclass 90 | class MqttConfig(HostPortConfig): 91 | """Dataclass to define a MQTT configuration.""" 92 | 93 | host: str = "127.0.0.1" 94 | port: int = 1883 95 | username: str | None = None 96 | password: str | None = None 97 | debug: bool = False 98 | 99 | 100 | @dataclass 101 | class WeatherFlowUdpConfig(HostPortConfig): 102 | """Dataclass to define a UDP configuration.""" 103 | 104 | host: str = "0.0.0.0" 105 | port: int = 50222 106 | 107 | 108 | class WeatherFlowMqtt: 109 | """Class to handle WeatherFlow to MQTT communication.""" 110 | 111 | def __init__( 112 | self, 113 | elevation: float = 0, 114 | latitude: float = 0, 115 | longitude: float = 0, 116 | unit_system: str = UNITS_METRIC, 117 | rapid_wind_interval: int = 0, 118 | language: str = LANGUAGE_ENGLISH, 119 | mqtt_config: MqttConfig = MqttConfig(), 120 | udp_config: WeatherFlowUdpConfig = WeatherFlowUdpConfig(), 121 | forecast_config: ForecastConfig = None, 122 | database_file: str = None, 123 | filter_sensors: list[str] | None = None, 124 | invert_filter: bool = False, 125 | zambretti_min_pressure = ZAMBRETTI_MIN_PRESSURE, 126 | zambretti_max_pressure = ZAMBRETTI_MAX_PRESSURE 127 | ) -> None: 128 | """Initialize a WeatherFlow MQTT.""" 129 | self.elevation = elevation 130 | self.latitude = latitude 131 | self.longitude = longitude 132 | self.unit_system = unit_system 133 | self.rapid_wind_interval = rapid_wind_interval 134 | self.sealevel_pressure_all_high = zambretti_max_pressure 135 | self.sealevel_pressure_all_low = zambretti_min_pressure 136 | 137 | self.cnv = ConversionFunctions(unit_system, language) 138 | 139 | self.mqtt_config = mqtt_config 140 | self.udp_config = udp_config 141 | 142 | self.forecast = ( 143 | Forecast.from_config(config=forecast_config, conversions=self.cnv) 144 | if forecast_config is not None 145 | else None 146 | ) 147 | 148 | self.mqtt_client: MqttClient = None 149 | self.listener: WeatherFlowListener | None = None 150 | self._queue: asyncio.Queue | None = None 151 | self._queue_task: asyncio.Task | None = None 152 | self._init_sql_db(database_file=database_file) 153 | 154 | self._filter_sensors = filter_sensors 155 | self._invert_filter = invert_filter 156 | 157 | # Set timer variables 158 | self._forecast_next_run: float = 0 159 | self.rapid_last_run = 1621229580.583215 # A time in the past 160 | self.high_low_last_run = 1621229580.583215 # A time in the past 161 | self.current_day = datetime.today().weekday() 162 | self.last_midnight = self.cnv.utc_last_midnight() 163 | 164 | # Read stored Values and set variable values 165 | self.fog_probability = None 166 | self.wind_speed = None 167 | self.snow_probability = None 168 | self.solar_radiation = None 169 | self.solar_elevation = None 170 | self.solar_insolation = None 171 | 172 | 173 | # Zambretti Forecast Vars 174 | self.wind_bearing_avg = None 175 | self.sealevel_pressure = None 176 | self.pressure_trend = None 177 | self.zambretti_number = None 178 | 179 | @property 180 | def is_imperial(self) -> bool: 181 | """Return `True` if the unit system is imperial, else `False`.""" 182 | return self.unit_system == UNITS_IMPERIAL 183 | 184 | async def connect(self) -> None: 185 | """Connect to MQTT and UDP.""" 186 | if self.mqtt_client is None: 187 | self._setup_mqtt_client() 188 | 189 | try: 190 | self.mqtt_client.connect( 191 | self.mqtt_config.host, port=self.mqtt_config.port, keepalive=300 192 | ) 193 | self.mqtt_client.loop_start() 194 | _LOGGER.info( 195 | "Connected to the MQTT server at %s:%s", 196 | self.mqtt_config.host, 197 | self.mqtt_config.port, 198 | ) 199 | except Exception as e: 200 | _LOGGER.error("Could not connect to MQTT Server. Error is: %s", e) 201 | sys.exit(1) 202 | 203 | self.listener = WeatherFlowListener(self.udp_config.host, self.udp_config.port) 204 | self.listener.on( 205 | EVENT_DEVICE_DISCOVERED, lambda device: self._device_discovered(device) 206 | ) 207 | try: 208 | await self.listener.start_listening() 209 | _LOGGER.info("The UDP server is listening on port %s", self.udp_config.port) 210 | except Exception as e: 211 | _LOGGER.error( 212 | "Could not start listening to the UDP Socket. Error is: %s", e 213 | ) 214 | sys.exit(1) 215 | 216 | self._queue = asyncio.Queue() 217 | self._queue_task = asyncio.ensure_future(self._mqtt_queue_processor()) 218 | 219 | async def run_time_based_updates(self) -> None: 220 | """Run some time based updates.""" 221 | # Run New day function if Midnight 222 | if self.current_day != datetime.today().weekday(): 223 | self.storage["rain_yesterday"] = self.storage["rain_today"] 224 | self.storage["rain_duration_yesterday"] = self.storage[ 225 | "rain_duration_today" 226 | ] 227 | self.storage["rain_today"] = 0 228 | self.storage["rain_duration_today"] = 0 229 | self.storage["lightning_count_today"] = 0 230 | self.last_midnight = self.cnv.utc_last_midnight() 231 | self.sql.writeStorage(self.storage) 232 | self.sql.dailyHousekeeping() 233 | self.current_day = datetime.today().weekday() 234 | 235 | if self.forecast is not None: 236 | await self._update_forecast() 237 | 238 | def _add_to_queue( 239 | self, topic: str, payload: str | None = None, qos: int = 0, retain: bool = False 240 | ) -> None: 241 | """Add an item to the queue.""" 242 | self._queue.put_nowait((topic, payload, qos, retain)) 243 | 244 | def _device_discovered(self, device: WeatherFlowDevice) -> None: 245 | """Handle a discovered device.""" 246 | 247 | def _load_complete(): 248 | _LOGGER.debug("Found device: %s", device) 249 | self._setup_sensors(device) 250 | device.on( 251 | EVENT_STATUS_UPDATE, 252 | lambda event: self._handle_status_update_event(device, event), 253 | ) 254 | if isinstance(device, WeatherFlowSensorDevice): 255 | device.on( 256 | EVENT_OBSERVATION, 257 | lambda event: self._handle_observation_event(device, event), 258 | ) 259 | if isinstance(device, AirSensorType): 260 | device.on( 261 | EVENT_STRIKE, 262 | lambda event: self._handle_strike_event(device, event), 263 | ) 264 | if isinstance(device, SkySensorType): 265 | device.on( 266 | EVENT_RAPID_WIND, 267 | lambda event: self._handle_wind_event(device, event), 268 | ) 269 | device.on( 270 | EVENT_RAIN_START, 271 | lambda event: self._handle_rain_start_event(device, event), 272 | ) 273 | 274 | device.on(EVENT_LOAD_COMPLETE, lambda _: _load_complete()) 275 | 276 | def _get_sensor_payload( 277 | self, 278 | sensor: BaseSensorDescription, 279 | device: WeatherFlowDevice, 280 | state_topic: str, 281 | attr_topic: str, 282 | ) -> OrderedDict: 283 | """Construct and return a sensor payload.""" 284 | payload = OrderedDict() 285 | model = device.model 286 | serial_number = device.serial_number 287 | 288 | payload["name"] = f"{sensor.name}" 289 | payload["unique_id"] = f"{serial_number}-{sensor.id}" 290 | if (units := sensor.unit_i if self.is_imperial else sensor.unit_m) is not None: 291 | payload["unit_of_measurement"] = units 292 | if (device_class := sensor.device_class) is not None: 293 | payload["device_class"] = device_class 294 | if (state_class := sensor.state_class) is not None: 295 | payload["state_class"] = state_class 296 | if (icon := sensor.icon) is not None: 297 | payload["icon"] = f"mdi:{icon}" 298 | payload["state_topic"] = state_topic 299 | payload["value_template"] = f"{{{{ value_json.{sensor.id} }}}}" 300 | payload["json_attributes_topic"] = attr_topic 301 | payload["device"] = { 302 | "identifiers": [f"{DOMAIN}_{serial_number}"], 303 | "manufacturer": MANUFACTURER, 304 | "name": f"{model} {serial_number}", 305 | "model": model, 306 | "sw_version": device.firmware_revision, 307 | **( 308 | {"via_device": f"{DOMAIN}_{device.hub_sn}"} 309 | if isinstance(device, WeatherFlowSensorDevice) 310 | else {} 311 | ), 312 | } 313 | 314 | return payload 315 | 316 | def _handle_observation_event( 317 | self, device: WeatherFlowSensorDevice, event: CustomEvent 318 | ) -> None: 319 | """Handle an observation event.""" 320 | _LOGGER.debug("Observation event from: %s", device) 321 | 322 | # Set some class level variables to help with sensors that may not have all data points available 323 | if (val := getattr(device, "solar_radiation", None)) is not None: 324 | self.solar_radiation = val.m 325 | if ( 326 | val := getattr(device, "rain_accumulation_previous_minute", None) 327 | ) is not None: 328 | if val.m > 0: 329 | self.storage["rain_today"] += val.m 330 | self.storage["rain_duration_today"] += 1 331 | self.sql.writeStorage(self.storage) 332 | 333 | event_data: dict[str, OrderedDict] = {} 334 | 335 | for sensor in DEVICE_SENSORS: 336 | # Skip if this device is missing the attribute 337 | if ( 338 | sensor.event in (EVENT_RAPID_WIND, EVENT_STATUS_UPDATE) 339 | or not hasattr(device, sensor.device_attr) 340 | or ( 341 | sensor.id == "battery_mode" 342 | and not isinstance(device, TempestDevice) 343 | ) 344 | ): 345 | continue 346 | 347 | attr = getattr(device, sensor.device_attr) 348 | 349 | if sensor.event not in event_data: 350 | event_data[sensor.event] = OrderedDict() 351 | 352 | try: 353 | if isinstance(sensor, SensorDescription): 354 | # TODO: Handle unique data points more elegantly... 355 | if sensor.id == "pressure_trend": 356 | continue 357 | 358 | if isinstance(attr, Callable): 359 | inputs = {} 360 | if "altitude" in sensor.inputs: 361 | inputs["altitude"] = self.elevation * UNIT_METERS 362 | attr = attr(**inputs) 363 | 364 | # Check for a custom function 365 | _data = event_data[EVENT_OBSERVATION] 366 | 367 | if (fn := sensor.custom_fn) is not None: 368 | # TODO: Handle unique data points more elegantly 369 | if sensor.id == "feelslike": 370 | attr = fn(self.cnv, device, self.wind_speed) 371 | elif sensor.id in ("visibility"): 372 | attr = fn(self.cnv, device, self.elevation) 373 | elif sensor.id == "wbgt": 374 | attr = fn(self.cnv, device, self.solar_radiation) 375 | elif sensor.id == "solar_elevation": 376 | self.solar_elevation = fn(self.cnv, self.latitude, self.longitude) 377 | attr = self.solar_elevation 378 | elif sensor.id == "solar_insolation": 379 | self.solar_insolation = fn(self.cnv, self.elevation, self.latitude, self.longitude) 380 | attr = self.solar_insolation 381 | elif sensor.id == "zambretti_number": 382 | self.zambretti_number = fn(self.cnv, self.latitude, _data.get("wind_bearing_avg"), self.sealevel_pressure_all_high, self.sealevel_pressure_all_low, self.pressure_trend, self.sealevel_pressure) 383 | attr = self.zambretti_number 384 | elif sensor.id == "zambretti_text": 385 | attr = fn(self.cnv, self.zambretti_number) 386 | elif sensor.id == "fog_probability": 387 | self.fog_probability = fn(self.cnv, self.solar_elevation, self.wind_speed, _data.get("relative_humidity"), _data.get("dewpoint"), _data.get("air_temperature")) 388 | attr = self.fog_probability 389 | elif sensor.id == "snow_probability": 390 | self.snow_probability = fn(self.cnv, device, _data.get("freezing_level"), _data.get("cloud_base"), self.elevation) 391 | attr = self.snow_probability 392 | elif sensor.id == "current_conditions": 393 | attr = fn(self.cnv, _data.get("lightning_strike_count_1hr"), _data.get("precipitation_type"), _data.get("rain_rate"), self.wind_speed, self.solar_elevation, self.solar_radiation, self.solar_insolation, self.snow_probability, self.fog_probability) 394 | else: 395 | attr = fn(self.cnv, device) 396 | 397 | # Check if a description is included 398 | if sensor.has_description and isinstance(attr, tuple): 399 | ( 400 | attr, 401 | event_data[sensor.event][f"{sensor.id}_description"], 402 | ) = attr 403 | 404 | # Check if the attr is a Quantity object 405 | elif isinstance(attr, Quantity): 406 | # See if conversion is needed 407 | if ( 408 | unit := sensor.imperial_unit 409 | if self.is_imperial 410 | else sensor.metric_unit 411 | ) is not None: 412 | attr = attr.to(unit) 413 | 414 | # Set the attribute to the Quantity's magnitude 415 | attr = attr.m 416 | 417 | # Check if rounding is needed 418 | if ( 419 | attr is not None 420 | and (decimals := sensor.decimals[1 if self.is_imperial else 0]) 421 | is not None 422 | ): 423 | attr = round(attr, decimals) 424 | 425 | elif isinstance(sensor, SqlSensorDescription): 426 | attr = sensor.sql_fn(self.sql) 427 | 428 | elif isinstance(sensor, StorageSensorDescription): 429 | attr = sensor.value(self.storage) 430 | 431 | if (fn := sensor.cnv_fn) is not None: 432 | attr = fn(self.cnv, attr) 433 | 434 | # Handle timestamp None value 435 | if sensor.device_class == DEVICE_CLASS_TIMESTAMP and attr is None: 436 | continue 437 | 438 | # Set the attribute in the payload 439 | event_data[sensor.event][sensor.id] = attr 440 | _LOGGER.debug("Setting payload: %s = %s", sensor.id, attr) 441 | except Exception as ex: 442 | _LOGGER.error("Error setting sensor data for %s: %s", sensor.id, ex) 443 | 444 | data = event_data[EVENT_OBSERVATION] 445 | 446 | # TODO: Handle unique data points more elegantly... 447 | if data.get("sealevel_pressure") is not None: 448 | ( 449 | data["pressure_trend"], 450 | data["pressure_trend_value"], 451 | ) = self.sql.readPressureTrend( 452 | data["sealevel_pressure"], self.cnv.translations 453 | ) 454 | self.pressure_trend = data["pressure_trend_value"] 455 | self.sealevel_pressure = data["sealevel_pressure"] 456 | self.sql.writePressure(data["sealevel_pressure"]) 457 | 458 | data["last_reset_midnight"] = self.last_midnight 459 | 460 | for (evt, data) in event_data.items(): 461 | if data: 462 | state_topic = MQTT_TOPIC_FORMAT.format( 463 | DEVICE_SERIAL_FORMAT.format(device.serial_number), evt, "state" 464 | ) 465 | self._add_to_queue(state_topic, json.dumps(data)) 466 | 467 | self.sql.updateHighLow(event_data[EVENT_OBSERVATION]) 468 | # self.sql.updateDayData(event_data[EVENT_OBSERVATION]) 469 | 470 | self._send_high_low_update(device=device) 471 | 472 | def _handle_rain_start_event( 473 | self, device: SkySensorType, event: RainStartEvent 474 | ) -> None: 475 | """Handle a rain start event.""" 476 | _LOGGER.debug("Rain start event from: %s", device) 477 | self.storage["rain_start"] = event.epoch 478 | self.sql.writeStorage(self.storage) 479 | 480 | def _handle_status_update_event( 481 | self, device: HubDevice | WeatherFlowSensorDevice, event: CustomEvent 482 | ) -> None: 483 | """Handle a hub status event.""" 484 | _LOGGER.debug("Status update event from: %s", device) 485 | device_serial = DEVICE_SERIAL_FORMAT.format(device.serial_number) 486 | 487 | state_topic = MQTT_TOPIC_FORMAT.format( 488 | device_serial, EVENT_STATUS_UPDATE, "state" 489 | ) 490 | state_data = OrderedDict() 491 | state_data["status"] = device.up_since.isoformat() 492 | self._add_to_queue(state_topic, json.dumps(state_data)) 493 | 494 | attr_topic = MQTT_TOPIC_FORMAT.format(device_serial, "status", "attributes") 495 | attr_data = OrderedDict() 496 | attr_data[ATTR_ATTRIBUTION] = ATTRIBUTION 497 | attr_data["serial_number"] = device.serial_number 498 | attr_data["rssi"] = device.rssi.m 499 | attr_data["version"] = VERSION 500 | 501 | if isinstance(device, HubDevice): 502 | attr_data["reset_flags"] = device.reset_flags 503 | _LOGGER.debug("HUB Reset Flags: %s", device.reset_flags) 504 | else: 505 | attr_data["voltage"] = device._voltage 506 | attr_data["sensor_status"] = device.sensor_status 507 | 508 | if device.sensor_status: 509 | _LOGGER.debug( 510 | "Device %s has reported a sensor fault. Reason: %s", 511 | device.serial_number, 512 | device.sensor_status, 513 | ) 514 | _LOGGER.debug( 515 | "DEVICE STATUS TRIGGERED AT %s\n -- Device: %s\n -- Firmware Revision: %s\n -- Voltage: %s", 516 | str(datetime.now()), 517 | device.serial_number, 518 | device.firmware_revision, 519 | device._voltage, 520 | ) 521 | 522 | self._add_to_queue(attr_topic, json.dumps(attr_data)) 523 | 524 | def _handle_strike_event( 525 | self, device: AirSensorType, event: LightningStrikeEvent 526 | ) -> None: 527 | """Handle a strike event.""" 528 | _LOGGER.debug("Lightning strike event from: %s", device) 529 | self.sql.writeLightning() 530 | self.storage["lightning_count_today"] += 1 531 | self.storage["last_lightning_distance"] = self.cnv.distance(event.distance.m) 532 | self.storage["last_lightning_energy"] = event.energy 533 | self.storage["last_lightning_time"] = event.epoch 534 | self.sql.writeStorage(self.storage) 535 | 536 | def _handle_wind_event(self, device: SkySensorType, event: WindEvent) -> None: 537 | """Handle a wind event.""" 538 | _LOGGER.debug("Wind event from: %s", device) 539 | data = OrderedDict() 540 | state_topic = MQTT_TOPIC_FORMAT.format( 541 | DEVICE_SERIAL_FORMAT.format(device.serial_number), EVENT_RAPID_WIND, "state" 542 | ) 543 | now = datetime.now().timestamp() 544 | if (now - self.rapid_last_run) >= self.rapid_wind_interval: 545 | data["wind_speed"] = self.cnv.speed(event.speed.m) 546 | data["wind_bearing"] = event.direction.m 547 | data["wind_direction"] = self.cnv.direction(event.direction.m) 548 | self.wind_speed = event.speed.m 549 | self._add_to_queue(state_topic, json.dumps(data)) 550 | self.rapid_last_run = datetime.now().timestamp() 551 | 552 | def _init_sql_db(self, database_file: str = None) -> None: 553 | """Initialize the self.sqlite DB.""" 554 | self.sql = SQLFunctions(self.unit_system) 555 | database_exist = os.path.isfile(database_file) 556 | self.sql.create_connection(database_file) 557 | if not database_exist: 558 | self.sql.createInitialDataset() 559 | # Upgrade Database if needed 560 | self.sql.upgradeDatabase() 561 | 562 | self.storage = self.sql.readStorage() 563 | 564 | def _send_high_low_update(self, device: WeatherFlowSensorDevice) -> None: 565 | # Update High and Low values if it is time 566 | now = datetime.now().timestamp() 567 | if (now - self.high_low_last_run) >= HIGH_LOW_TIMER: 568 | highlow_topic = MQTT_TOPIC_FORMAT.format( 569 | DEVICE_SERIAL_FORMAT.format(device.serial_number), 570 | EVENT_HIGH_LOW, 571 | "attributes", 572 | ) 573 | high_low_data = self.sql.readHighLow() 574 | self._add_to_queue( 575 | highlow_topic, json.dumps(high_low_data), qos=1, retain=True 576 | ) 577 | self.high_low_last_run = datetime.now().timestamp() 578 | 579 | def _setup_mqtt_client(self) -> MqttClient: 580 | """Initialize MQTT client.""" 581 | if ( 582 | anonymous := not self.mqtt_config.username or not self.mqtt_config.password 583 | ) and self.mqtt_config.debug: 584 | _LOGGER.debug("MQTT Credentials not needed") 585 | 586 | self.mqtt_client = client = MqttClient() 587 | 588 | if not anonymous: 589 | client.username_pw_set( 590 | username=self.mqtt_config.username, password=self.mqtt_config.password 591 | ) 592 | if self.mqtt_config.debug: 593 | client.enable_logger() 594 | _LOGGER.debug( 595 | "MQTT Credentials: %s - %s", 596 | self.mqtt_config.username, 597 | self.mqtt_config.password, 598 | ) 599 | 600 | def _setup_sensors(self, device: WeatherFlowDevice) -> None: 601 | """Create Sensors in Home Assistant.""" 602 | serial_number = device.serial_number 603 | domain_serial = DEVICE_SERIAL_FORMAT.format(serial_number) 604 | 605 | SENSORS = ( 606 | DEVICE_SENSORS 607 | if isinstance(device, WeatherFlowSensorDevice) 608 | else HUB_SENSORS 609 | ) 610 | 611 | # Create the config for the Sensors 612 | for sensor in SENSORS: 613 | sensor_id = sensor.id 614 | sensor_event = sensor.event 615 | 616 | if not hasattr(device, sensor.device_attr) or ( 617 | sensor.id == "battery_mode" and not isinstance(device, TempestDevice) 618 | ): 619 | # Don't add sensors for devices that don't report on that attribute 620 | continue 621 | 622 | state_topic = MQTT_TOPIC_FORMAT.format(domain_serial, sensor_event, "state") 623 | attr_topic = MQTT_TOPIC_FORMAT.format( 624 | domain_serial, sensor_id, "attributes" 625 | ) 626 | discovery_topic = MQTT_TOPIC_FORMAT.format( 627 | domain_serial, sensor_id, "config" 628 | ) 629 | 630 | attribution = OrderedDict() 631 | payload: OrderedDict | None = None 632 | 633 | if self._filter_sensors is None or ( 634 | (sensor_id in self._filter_sensors) is not self._invert_filter 635 | ): 636 | _LOGGER.info("Setting up %s sensor: %s", device.model, sensor.name) 637 | 638 | # Payload 639 | payload = self._get_sensor_payload( 640 | sensor=sensor, 641 | device=device, 642 | state_topic=state_topic, 643 | attr_topic=attr_topic, 644 | ) 645 | 646 | # Attributes 647 | attribution[ATTR_ATTRIBUTION] = ATTRIBUTION 648 | 649 | # Add description if needed 650 | if sensor.has_description: 651 | payload["json_attributes_topic"] = state_topic 652 | template = OrderedDict() 653 | template = attribution 654 | template[ 655 | "description" 656 | ] = f"{{{{ value_json.{sensor_id}_description }}}}" 657 | payload["json_attributes_template"] = json.dumps(template) 658 | 659 | # Add additional attributes to some sensors 660 | if sensor_id == "pressure_trend": 661 | payload["json_attributes_topic"] = state_topic 662 | template = OrderedDict() 663 | template = attribution 664 | template["trend_value"] = "{{ value_json.pressure_trend_value }}" 665 | payload["json_attributes_template"] = json.dumps(template) 666 | 667 | # Add extra attributes if needed 668 | if sensor.extra_att: 669 | payload["json_attributes_topic"] = MQTT_TOPIC_FORMAT.format( 670 | domain_serial, EVENT_HIGH_LOW, "attributes" 671 | ) 672 | template = OrderedDict() 673 | template = attribution 674 | template["max_day"] = f"{{{{ value_json.{sensor_id}['max_day'] }}}}" 675 | template[ 676 | "max_day_time" 677 | ] = f"{{{{ value_json.{sensor_id}['max_day_time'] }}}}" 678 | template[ 679 | "max_month" 680 | ] = f"{{{{ value_json.{sensor_id}['max_month'] }}}}" 681 | template[ 682 | "max_month_time" 683 | ] = f"{{{{ value_json.{sensor_id}['max_month_time'] }}}}" 684 | template["max_all"] = f"{{{{ value_json.{sensor_id}['max_all'] }}}}" 685 | template[ 686 | "max_all_time" 687 | ] = f"{{{{ value_json.{sensor_id}['max_all_time'] }}}}" 688 | if sensor.show_min_att: 689 | template[ 690 | "min_day" 691 | ] = f"{{{{ value_json.{sensor_id}['min_day'] }}}}" 692 | template[ 693 | "min_day_time" 694 | ] = f"{{{{ value_json.{sensor_id}['min_day_time'] }}}}" 695 | template[ 696 | "min_month" 697 | ] = f"{{{{ value_json.{sensor_id}['min_month'] }}}}" 698 | template[ 699 | "min_month_time" 700 | ] = f"{{{{ value_json.{sensor_id}['min_month_time'] }}}}" 701 | template[ 702 | "min_all" 703 | ] = f"{{{{ value_json.{sensor_id}['min_all'] }}}}" 704 | template[ 705 | "min_all_time" 706 | ] = f"{{{{ value_json.{sensor_id}['min_all_time'] }}}}" 707 | payload["json_attributes_template"] = json.dumps(template) 708 | 709 | self._add_to_queue( 710 | discovery_topic, json.dumps(payload or {}), qos=1, retain=True 711 | ) 712 | self._add_to_queue(attr_topic, json.dumps(attribution), qos=1, retain=True) 713 | 714 | if isinstance(device, HubDevice): 715 | run_forecast = False 716 | fcst_state_topic = MQTT_TOPIC_FORMAT.format( 717 | DOMAIN, FORECAST_ENTITY, "state" 718 | ) 719 | fcst_attr_topic = MQTT_TOPIC_FORMAT.format( 720 | DOMAIN, FORECAST_ENTITY, "attributes" 721 | ) 722 | for sensor in FORECAST_SENSORS: 723 | discovery_topic = MQTT_TOPIC_FORMAT.format(DOMAIN, sensor.id, "config") 724 | payload: OrderedDict | None = None 725 | if self.forecast is not None: 726 | _LOGGER.info("Setting up %s sensor: %s", device.model, sensor.name) 727 | run_forecast = True 728 | payload = self._get_sensor_payload( 729 | sensor=sensor, 730 | device=device, 731 | state_topic=fcst_state_topic, 732 | attr_topic=fcst_attr_topic, 733 | ) 734 | self._add_to_queue( 735 | discovery_topic, json.dumps(payload or {}), qos=1, retain=True 736 | ) 737 | 738 | if run_forecast: 739 | asyncio.ensure_future(self._update_forecast()) 740 | 741 | # cleanup obsolete sensors 742 | for sensor in OBSOLETE_SENSORS: 743 | self._add_to_queue( 744 | topic=MQTT_TOPIC_FORMAT.format(domain_serial, sensor, "config") 745 | ) 746 | 747 | async def _mqtt_queue_processor(self) -> None: 748 | """MQTT queue processor.""" 749 | while True: 750 | topic, payload, qos, retain = await self._queue.get() 751 | await self._publish_mqtt(topic, payload, qos, retain) 752 | self._queue.task_done() 753 | 754 | async def _publish_mqtt( 755 | self, topic: str, payload: str | None = None, qos: int = 0, retain: bool = False 756 | ) -> None: 757 | """Publish a MQTT topic with payload.""" 758 | try: 759 | self.mqtt_client.publish(topic, payload, qos=qos, retain=retain) 760 | except Exception as e: 761 | _LOGGER.error("Could not connect to MQTT Server. Error is: %s", e) 762 | await asyncio.sleep(0.01) 763 | 764 | async def _update_forecast(self) -> None: 765 | """Attempt to update the forecast.""" 766 | # Update the Forecast if it is time and enabled 767 | assert self.forecast 768 | if (now := datetime.now().timestamp()) >= self._forecast_next_run: 769 | if any(forecast := await self.forecast.update_forecast()): 770 | _LOGGER.debug("Sending updated forecast data to MQTT") 771 | for topic, data in zip(("state", "attributes"), forecast): 772 | self._add_to_queue( 773 | MQTT_TOPIC_FORMAT.format(DOMAIN, FORECAST_ENTITY, topic), 774 | json.dumps(data), 775 | qos=1, 776 | retain=True, 777 | ) 778 | self._forecast_next_run = now + self.forecast.interval * 60 779 | else: 780 | _LOGGER.debug( 781 | "Forecast update will run in ~%s minutes", 782 | ceil((self._forecast_next_run - now) / 60), 783 | ) 784 | 785 | 786 | async def main(): 787 | """Entry point for program.""" 788 | logging.basicConfig(level=logging.INFO) 789 | 790 | try: 791 | if is_supervisor := truebool(os.getenv("HA_SUPERVISOR")): 792 | config = await get_supervisor_configuration() 793 | else: 794 | config = os.environ 795 | except: 796 | config = os.environ 797 | 798 | _LOGGER.info("Timezone is %s", os.environ.get("TZ")) 799 | 800 | # Read the config Settings 801 | elevation = float(config.get("ELEVATION", 0)) 802 | latitude = float(config.get("LATITUDE", 0)) 803 | longitude = float(config.get("LONGITUDE", 0)) 804 | unit_system = config.get("UNIT_SYSTEM", UNITS_METRIC) 805 | _LOGGER.info("Unit System is %s", unit_system) 806 | rw_interval = int(config.get("RAPID_WIND_INTERVAL", 0)) 807 | language = config.get("LANGUAGE", LANGUAGE_ENGLISH).lower() 808 | zambretti_min_default = ZAMBRETTI_MIN_PRESSURE if unit_system == UNITS_METRIC else ZAMBRETTI_MIN_PRESSURE * 0.029530 809 | zambretti_max_default = ZAMBRETTI_MAX_PRESSURE if unit_system == UNITS_METRIC else ZAMBRETTI_MAX_PRESSURE * 0.029530 810 | zambretti_min_pressure = float(config.get("ZAMBRETTI_MIN_PRESSURE", zambretti_min_default)) 811 | zambretti_max_pressure = float(config.get("ZAMBRETTI_MAX_PRESSURE", zambretti_max_default)) 812 | _LOGGER.info("Zambretti Values: %s and %s", zambretti_min_pressure, zambretti_max_pressure) 813 | 814 | mqtt_config = MqttConfig( 815 | host=config.get("MQTT_HOST", "127.0.0.1"), 816 | port=int(config.get("MQTT_PORT", 1883)), 817 | username=config.get("MQTT_USERNAME"), 818 | password=config.get("MQTT_PASSWORD"), 819 | debug=truebool(config.get("MQTT_DEBUG")), 820 | ) 821 | 822 | udp_config = WeatherFlowUdpConfig( 823 | host=config.get("WF_HOST", "0.0.0.0"), port=int(config.get("WF_PORT", 50222)) 824 | ) 825 | 826 | forecast_config = ( 827 | ForecastConfig( 828 | station_id=station_id, 829 | token=station_token, 830 | interval=int(config.get("FORECAST_INTERVAL", 30)), 831 | ) 832 | if ( 833 | (station_id := config.get("STATION_ID")) 834 | and (station_token := config.get("STATION_TOKEN")) 835 | ) 836 | else None 837 | ) 838 | 839 | if truebool(config.get("DEBUG")): 840 | logging.getLogger().setLevel(logging.DEBUG) 841 | 842 | if isinstance(filter_sensors := config.get("FILTER_SENSORS"), str): 843 | filter_sensors = [sensor.strip() for sensor in filter_sensors.split(",")] 844 | invert_filter = truebool(config.get("INVERT_FILTER")) 845 | 846 | # Read the sensor config 847 | if filter_sensors is None and not is_supervisor: 848 | filter_sensors = read_config() 849 | 850 | weatherflowmqtt = WeatherFlowMqtt( 851 | elevation=elevation, 852 | latitude=latitude, 853 | longitude=longitude, 854 | unit_system=unit_system, 855 | rapid_wind_interval=rw_interval, 856 | language=language, 857 | mqtt_config=mqtt_config, 858 | udp_config=udp_config, 859 | forecast_config=forecast_config, 860 | database_file=DATABASE, 861 | filter_sensors=filter_sensors, 862 | invert_filter=invert_filter, 863 | zambretti_min_pressure=zambretti_min_pressure, 864 | zambretti_max_pressure=zambretti_max_pressure, 865 | ) 866 | await weatherflowmqtt.connect() 867 | 868 | # Watch for message from the UDP socket 869 | while weatherflowmqtt.listener.is_listening: 870 | await asyncio.sleep(60) 871 | await weatherflowmqtt.run_time_based_updates() 872 | 873 | 874 | async def get_supervisor_configuration() -> dict[str, Any]: 875 | """Get the configuration from Home Assistant Supervisor.""" 876 | from aiohttp import ClientSession 877 | 878 | _LOGGER.info("🏠 Home Assistant Supervisor Mode 🏠") 879 | 880 | config: dict[str, Any] = {} 881 | 882 | supervisor_url = "http://supervisor" 883 | headers = {"Authorization": "Bearer " + os.getenv("SUPERVISOR_TOKEN")} 884 | 885 | async with ClientSession() as session: 886 | try: 887 | async with session.get( 888 | supervisor_url + "/core/api/config", 889 | headers=headers, 890 | ) as resp: 891 | if (data := await resp.json()) is not None: 892 | config.update( 893 | { 894 | "ELEVATION": data.get("elevation"), 895 | "LATITUDE": data.get("latitude"), 896 | "LONGITUDE": data.get("longitude"), 897 | "UNIT_SYSTEM": UNITS_METRIC 898 | if data.get("unit_system", {}).get("temperature") 899 | == TEMP_CELSIUS 900 | else UNITS_IMPERIAL, 901 | } 902 | ) 903 | _LOGGER.info("Add-On value Unit System is: %s", data.get("unit_system", {})) 904 | except Exception as e: 905 | _LOGGER.error("Could not read Home Assistant core config: %s", e) 906 | 907 | try: 908 | async with session.get( 909 | supervisor_url + "/services/mqtt", 910 | headers=headers, 911 | ) as resp: 912 | resp_json = await resp.json() 913 | if "ok" in resp_json.get("result"): 914 | data = resp_json["data"] 915 | config.update( 916 | { 917 | "MQTT_HOST": data["host"], 918 | "MQTT_PORT": data["port"], 919 | "MQTT_USERNAME": data["username"], 920 | "MQTT_PASSWORD": data["password"], 921 | } 922 | ) 923 | except Exception as e: 924 | _LOGGER.error("Could not read Home Assistant MQTT config: %s", e) 925 | 926 | if os.path.exists(options_file := f"{EXTERNAL_DIRECTORY}/options.json"): 927 | with open(options_file, "r") as f: 928 | config.update(json.load(f)) 929 | 930 | return config 931 | 932 | 933 | # Main Program starts 934 | if __name__ == "__main__": 935 | try: 936 | asyncio.run(main()) 937 | except KeyboardInterrupt: 938 | print("\nExiting Program") 939 | --------------------------------------------------------------------------------