Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 17 additions & 4 deletions scripts/config.php
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -496,7 +504,7 @@ function runProcess() {
Enable weather syncing
</label>
<p>Enabled by default. Turning this off stops all Open-Meteo and Home Assistant weather requests. Stored weather history is kept.</p>
<h3>Local temperature sensor (optional)</h3>
<h3>Local weather station (optional)</h3>
<table class="settingstable plaintable">
<tr>
<td><label for="ha_url">Home Assistant URL:</label></td>
Expand All @@ -510,10 +518,15 @@ function runProcess() {
<td><label for="ha_temp_entity">Temperature entity:</label></td>
<td><input name="ha_temp_entity" type="text" placeholder="sensor.backyard_temperature" value="<?php print(htmlspecialchars($config['HA_TEMP_ENTITY'] ?? '')); ?>"/></td>
</tr>
<tr>
<td><label for="ha_weather_entity">Weather entity:</label></td>
<td><input name="ha_weather_entity" type="text" placeholder="weather.backyard" value="<?php print(htmlspecialchars($config['HA_WEATHER_ENTITY'] ?? '')); ?>"/></td>
</tr>
</table>
<p>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 &rarr; Security.</p>
<p>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
&mdash; this is a Home Assistant <code>weather.*</code> 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 &rarr; Security.</p>
</td></tr></table><br>
<table class="settingstable"><tr><td>
<h2 id="settings-display">Display & Units</h2>
Expand Down
14 changes: 9 additions & 5 deletions scripts/install_config.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
134 changes: 131 additions & 3 deletions scripts/utils/weather.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""
Expand Down Expand Up @@ -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.

Expand Down Expand Up @@ -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']
Expand All @@ -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.")
Expand Down
97 changes: 94 additions & 3 deletions tests/test_weather_switch.py
Original file line number Diff line number Diff line change
@@ -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'
Expand All @@ -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
Expand Down Expand Up @@ -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():
Expand Down