diff --git a/scripts/config.php b/scripts/config.php index 327a32b..bee9afa 100644 --- a/scripts/config.php +++ b/scripts/config.php @@ -131,6 +131,7 @@ function syslog_shell_exec($cmd, $sudo_user = null) { $ha_url = isset($_GET['ha_url']) ? trim(str_replace('"', '', $_GET['ha_url'])) : ''; $ha_token = isset($_GET['ha_token']) ? trim(str_replace('"', '', $_GET['ha_token'])) : ''; $ha_temp_entity = isset($_GET['ha_temp_entity']) ? trim(str_replace('"', '', $_GET['ha_temp_entity'])) : ''; + $ha_weather_entity = isset($_GET['ha_weather_entity']) ? trim(str_replace('"', '', $_GET['ha_weather_entity'])) : ''; # Display & units $temperature_unit = (isset($_GET['temperature_unit']) && $_GET['temperature_unit'] === 'celsius') ? 'celsius' : 'fahrenheit'; @@ -210,6 +211,13 @@ function() { $contents = preg_replace("/HA_URL=.*/", "HA_URL=\"$ha_url\"", $contents); $contents = preg_replace("/HA_TOKEN=.*/", "HA_TOKEN=\"$ha_token\"", $contents); $contents = preg_replace("/HA_TEMP_ENTITY=.*/", "HA_TEMP_ENTITY=\"$ha_temp_entity\"", $contents); + if (preg_match('/^HA_WEATHER_ENTITY=.*$/m', $contents)) { + $contents = preg_replace('/^HA_WEATHER_ENTITY=.*$/m', "HA_WEATHER_ENTITY=\"$ha_weather_entity\"", $contents); + } else { + // Older restored configs may predate this setting; append it rather than + // silently leaving the field unable to save. + $contents = rtrim($contents) . "\nHA_WEATHER_ENTITY=\"$ha_weather_entity\"\n"; + } $contents = preg_replace("/SIDEBAR_SITE_NAME=.*/", "SIDEBAR_SITE_NAME=$sidebar_site_name", $contents); $contents = preg_replace("/IMAGE_PROVIDER=.*/", "IMAGE_PROVIDER=$image_provider", $contents); $contents = preg_replace("/FLICKR_API_KEY=.*/", "FLICKR_API_KEY=$flickr_api_key", $contents); @@ -496,7 +504,7 @@ function runProcess() { Enable weather syncing

Enabled by default. Turning this off stops all Open-Meteo and Home Assistant weather requests. Stored weather history is kept.

-

Local temperature sensor (optional)

+

Local weather station (optional)

@@ -510,10 +518,15 @@ function runProcess() { + + + +
-

Leave blank to use online weather. When weather syncing is enabled and these fields are set, the current hour's temperature comes from your own sensor, - and falls back to online weather automatically if the sensor is unreachable or its reading hasn't changed - in over an hour. Create a long-lived access token in Home Assistant under your profile → Security.

+

Leave blank to use online weather. When weather syncing is enabled and the temperature entity is set, the current hour's temperature comes from your own sensor. + When the weather entity is set, the current hour's wind speed, wind direction, sky condition, and day/night also come from your own station instead of Open-Meteo + — this is a Home Assistant weather.* entity (the kind most weather-station integrations create), not a plain sensor. Both fields fall back to + online weather automatically and independently if unreachable or unchanged in over an hour. Create a long-lived access token in Home Assistant under your profile → Security.


Display & Units

diff --git a/scripts/install_config.sh b/scripts/install_config.sh index 850afe9..5f48d24 100755 --- a/scripts/install_config.sh +++ b/scripts/install_config.sh @@ -121,14 +121,18 @@ APPRISE_QUIET_HOURS_END="" ## Detections of a species within this many quiet minutes belong to one visit VISIT_GAP_MINUTES=5 -#---------------------- Local Temperature Sensor ------------------------# -## Optional: current-hour temperature from a Home Assistant sensor instead of -## the online weather model. Leave blank to disable. Falls back to online -## weather automatically when the sensor is unreachable, unavailable, or its -## value has not changed in over an hour. +#---------------------- Local Weather Station ------------------------# +## Optional: current-hour weather from Home Assistant instead of the online +## weather model. HA_TEMP_ENTITY (a plain sensor) overrides temperature only; +## HA_WEATHER_ENTITY (a weather.* entity, the kind most weather-station +## integrations create) also overrides wind speed/direction, sky condition, +## and day/night. Leave either blank to disable it. Both fall back to online +## weather automatically and independently when unreachable, unavailable, or +## unchanged for over an hour. HA_URL="" HA_TOKEN="" HA_TEMP_ENTITY="" +HA_WEATHER_ENTITY="" #---------------------- Display & Units ------------------------# ## fahrenheit or celsius (weather is stored in Fahrenheit; display converts) diff --git a/scripts/utils/weather.py b/scripts/utils/weather.py index 90857e3..de52300 100644 --- a/scripts/utils/weather.py +++ b/scripts/utils/weather.py @@ -26,6 +26,27 @@ HA_MAX_AGE_SECONDS = 3600 HA_TIMEOUT_SECONDS = 10 +# HA's weather.* domain entities (WeatherFlow/Tempest, Ecowitt, AccuWeather, +# etc.) report `state` as one of a small fixed set of condition strings +# rather than a WMO code. Map the ones with an unambiguous WMO equivalent; +# anything else (e.g. "windy", "exceptional") is left unmapped on purpose so +# fetch_ha_weather() falls back to Open-Meteo's condition for that hour +# instead of guessing. +HA_CONDITION_TO_WMO_CODE = { + 'clear-night': 0, + 'sunny': 0, + 'partlycloudy': 2, + 'cloudy': 3, + 'fog': 45, + 'rainy': 61, + 'pouring': 65, + 'snowy': 71, + 'snowy-rainy': 71, + 'lightning': 95, + 'lightning-rainy': 95, + 'hail': 99, +} + def weather_sync_enabled(conf): """Return False only for an explicit WEATHER_ENABLED=0 setting.""" @@ -118,6 +139,106 @@ def fetch_ha_temperature(conf): return temp_f +def fetch_ha_is_day(conf): + """1/0 from Home Assistant's sun.sun entity, or None to use the online value. + + Only called when HA_WEATHER_ENTITY is configured - sun.sun is a stock + entity present on every HA install, so no separate config knob for it. + """ + url = (conf.get('HA_URL') or '').strip().rstrip('/') + token = (conf.get('HA_TOKEN') or '').strip() + if not url or not token: + return None + + try: + response = requests.get( + f"{url}/api/states/sun.sun", + headers={'Authorization': f'Bearer {token}'}, + timeout=HA_TIMEOUT_SECONDS, + ) + response.raise_for_status() + state = response.json().get('state') + except Exception as e: + log.warning(f"sun.sun unreachable ({e}); using online day/night.") + return None + + if state == 'above_horizon': + return 1 + if state == 'below_horizon': + return 0 + return None + + +def fetch_ha_weather(conf): + """Wind speed/direction, sky condition, and day/night from a Home + Assistant weather.* entity (HA_WEATHER_ENTITY), as a dict containing only + the keys read successfully - an empty dict means "use Open-Meteo for + everything this function covers". + + These fields are independent of each other and of HA_TEMP_ENTITY, so a + partial result (e.g. wind but no condition_code, because the condition + string had no WMO mapping) is normal, not an error. + """ + url = (conf.get('HA_URL') or '').strip().rstrip('/') + token = (conf.get('HA_TOKEN') or '').strip() + entity = (conf.get('HA_WEATHER_ENTITY') or '').strip() + if not url or not token or not entity: + return {} + + try: + response = requests.get( + f"{url}/api/states/{entity}", + headers={'Authorization': f'Bearer {token}'}, + timeout=HA_TIMEOUT_SECONDS, + ) + response.raise_for_status() + state = response.json() + except Exception as e: + log.warning(f"Local weather entity {entity} unreachable ({e}); using online weather.") + return {} + + # weather.* entities update their numeric attributes far more often than + # their condition (`state`) string changes, so staleness has to be judged + # against last_updated - last_changed only moves when the condition + # itself flips and can otherwise sit unchanged for hours on a clear day. + last_updated = state.get('last_updated') or '' + try: + updated_at = datetime.fromisoformat(last_updated.replace('Z', '+00:00')) + age = (datetime.now(updated_at.tzinfo) - updated_at).total_seconds() + except ValueError: + log.warning(f"Local weather entity {entity} has unparseable last_updated {last_updated!r}; using online weather.") + return {} + if age > HA_MAX_AGE_SECONDS: + log.warning(f"Local weather entity {entity} unchanged for {int(age // 60)} min; using online weather.") + return {} + + attrs = state.get('attributes') or {} + result = {} + + wind_speed = attrs.get('wind_speed') + if isinstance(wind_speed, (int, float)): + result['WindSpeed'] = wind_speed + + wind_bearing = attrs.get('wind_bearing') + if isinstance(wind_bearing, (int, float)): + result['WindDirection'] = wind_bearing + + condition = state.get('state') + code = HA_CONDITION_TO_WMO_CODE.get(condition) + if code is not None: + result['ConditionCode'] = code + elif condition not in (None, 'unavailable', 'unknown'): + log.info(f"Local weather entity {entity} condition {condition!r} has no WMO mapping; keeping online condition.") + + is_day = fetch_ha_is_day(conf) + if is_day is not None: + result['IsDay'] = is_day + + if result: + log.info(f"Local weather entity {entity}: {result} (updated {int(age)}s ago).") + return result + + def ensure_weather_schema(): """Create/upgrade the weather table. @@ -183,6 +304,7 @@ def update_weather(): return local_temp = fetch_ha_temperature(conf) + local_weather = fetch_ha_weather(conf) # Parse data times = data['hourly']['time'] @@ -208,14 +330,20 @@ def update_weather(): cur.execute("INSERT OR REPLACE INTO weather (Date, Hour, Temp, ConditionCode, IsDay, WindSpeed, WindDirection) VALUES (?, ?, ?, ?, ?, ?, ?)", (date_str, hour, temp, code, is_day, wind, direction)) - # The local sensor wins the current hour when healthy; every other - # hour (and every fallback case) keeps the online value. + # The local sensor/entity wins the current hour when healthy; every + # other hour (and every fallback case) keeps the online value. + now = datetime.now() if local_temp is not None: - now = datetime.now() cur.execute("UPDATE weather SET Temp = ? WHERE Date = ? AND Hour = ?", (local_temp, now.strftime('%Y-%m-%d'), now.hour)) log.info(f"Current hour temperature set from local sensor: {local_temp}°F.") + if local_weather: + set_clause = ', '.join(f"{column} = ?" for column in local_weather) + params = list(local_weather.values()) + [now.strftime('%Y-%m-%d'), now.hour] + cur.execute(f"UPDATE weather SET {set_clause} WHERE Date = ? AND Hour = ?", params) + log.info(f"Current hour {list(local_weather.keys())} set from local weather entity.") + con.commit() con.close() log.info("Hourly weather data synced successfully to birds.db.") diff --git a/tests/test_weather_switch.py b/tests/test_weather_switch.py index ff337bd..39b15eb 100644 --- a/tests/test_weather_switch.py +++ b/tests/test_weather_switch.py @@ -1,6 +1,7 @@ import sys +from datetime import datetime, timedelta, timezone from pathlib import Path -from unittest.mock import patch +from unittest.mock import MagicMock, patch UTILS_DIR = Path(__file__).resolve().parents[1] / 'scripts' / 'utils' @@ -10,6 +11,14 @@ from helpers import get_settings # noqa: E402 +def _ha_response(json_body, status_ok=True): + response = MagicMock() + response.json.return_value = json_body + if not status_ok: + response.raise_for_status.side_effect = Exception('boom') + return response + + def test_weather_switch_defaults_to_enabled(): assert weather.weather_sync_enabled({}) is True assert weather.weather_sync_enabled({'WEATHER_ENABLED': '1'}) is True @@ -37,12 +46,94 @@ def test_disabled_weather_exits_before_database_or_network_work(): with patch.object(weather, 'get_settings', return_value={'WEATHER_ENABLED': '0'}), \ patch.object(weather, 'ensure_weather_schema') as ensure_schema, \ patch.object(weather, 'fetch_hourly') as fetch_hourly, \ - patch.object(weather, 'fetch_ha_temperature') as fetch_ha: + patch.object(weather, 'fetch_ha_temperature') as fetch_ha_temp, \ + patch.object(weather, 'fetch_ha_weather') as fetch_ha_weather: weather.update_weather() ensure_schema.assert_not_called() fetch_hourly.assert_not_called() - fetch_ha.assert_not_called() + fetch_ha_temp.assert_not_called() + fetch_ha_weather.assert_not_called() + + +def test_fetch_ha_weather_returns_empty_when_unconfigured(): + assert weather.fetch_ha_weather({}) == {} + assert weather.fetch_ha_weather({'HA_URL': 'http://ha', 'HA_TOKEN': 't'}) == {} + + +def test_fetch_ha_weather_reads_wind_condition_and_day(): + conf = { + 'HA_URL': 'http://ha:8123', + 'HA_TOKEN': 'token', + 'HA_WEATHER_ENTITY': 'weather.backyard', + } + fresh = datetime.now(timezone.utc).isoformat() + weather_body = _ha_response({ + 'state': 'rainy', + 'last_updated': fresh, + 'attributes': {'wind_speed': 12.3, 'wind_bearing': 200}, + }) + sun_body = _ha_response({'state': 'above_horizon'}) + + with patch.object(weather.requests, 'get', side_effect=[weather_body, sun_body]): + result = weather.fetch_ha_weather(conf) + + assert result == { + 'WindSpeed': 12.3, + 'WindDirection': 200, + 'ConditionCode': 61, + 'IsDay': 1, + } + + +def test_fetch_ha_weather_stale_last_updated_falls_back(): + conf = { + 'HA_URL': 'http://ha:8123', + 'HA_TOKEN': 'token', + 'HA_WEATHER_ENTITY': 'weather.backyard', + } + stale = (datetime.now(timezone.utc) - timedelta(hours=2)).isoformat() + weather_body = _ha_response({ + 'state': 'sunny', + 'last_updated': stale, + 'attributes': {'wind_speed': 5, 'wind_bearing': 90}, + }) + + with patch.object(weather.requests, 'get', return_value=weather_body): + result = weather.fetch_ha_weather(conf) + + assert result == {} + + +def test_fetch_ha_weather_unmapped_condition_keeps_wind_but_drops_code(): + conf = { + 'HA_URL': 'http://ha:8123', + 'HA_TOKEN': 'token', + 'HA_WEATHER_ENTITY': 'weather.backyard', + } + fresh = datetime.now(timezone.utc).isoformat() + weather_body = _ha_response({ + 'state': 'windy', + 'last_updated': fresh, + 'attributes': {'wind_speed': 8, 'wind_bearing': 10}, + }) + sun_body = _ha_response({'state': 'below_horizon'}) + + with patch.object(weather.requests, 'get', side_effect=[weather_body, sun_body]): + result = weather.fetch_ha_weather(conf) + + assert result == {'WindSpeed': 8, 'WindDirection': 10, 'IsDay': 0} + assert 'ConditionCode' not in result + + +def test_fetch_ha_is_day_maps_sun_state(): + conf = {'HA_URL': 'http://ha:8123', 'HA_TOKEN': 'token'} + with patch.object(weather.requests, 'get', return_value=_ha_response({'state': 'above_horizon'})): + assert weather.fetch_ha_is_day(conf) == 1 + with patch.object(weather.requests, 'get', return_value=_ha_response({'state': 'below_horizon'})): + assert weather.fetch_ha_is_day(conf) == 0 + with patch.object(weather.requests, 'get', side_effect=Exception('boom')): + assert weather.fetch_ha_is_day(conf) is None def test_missing_switch_keeps_existing_update_path_enabled():