Skip to content

Commit 824ec63

Browse files
authored
Merge pull request smarthomeNG#1045 from Morg42/utc
uzsu: use shng configured timezone in line with lib.orb
2 parents e0c14f5 + 06535e8 commit 824ec63

6 files changed

Lines changed: 217 additions & 27 deletions

File tree

uzsu/__init__.py

Lines changed: 21 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -180,7 +180,7 @@ def run(self):
180180
# set activeToday to false if entry is in the future (and wasn't reset correctly at midnight)
181181
for i, entry in enumerate(self._items[item].get('list')):
182182
next, _, _ = self._get_time(entry, 'next', item, i, 'dry_run')
183-
if next and next.time() > datetime.now().time() and entry.get('activeToday'):
183+
if next and next.time() > datetime.now(self._timezone).time() and entry.get('activeToday'):
184184
entry['activeToday'] = False
185185
update = 'activeToday'
186186
self.logger.debug(
@@ -1085,7 +1085,7 @@ def _get_time(self, entry, timescan, item=None, entryindex=None, caller=None):
10851085
self._ignore_once_entries is False and entry.get('activeToday') is True and timescan == 'previous'
10861086
)
10871087
active = True if caller == 'dry_run' or cond_activetoday else entry.get('active')
1088-
today = datetime.today()
1088+
today = datetime.now(self._timezone)
10891089
tomorrow = today + timedelta(days=1)
10901090
yesterday = today - timedelta(days=1)
10911091
weekbefore = today - timedelta(days=7)
@@ -1123,7 +1123,7 @@ def _get_time(self, entry, timescan, item=None, entryindex=None, caller=None):
11231123
rrule = rrulestr(entry['rrule'], dtstart=datetime.combine(weekbefore, datetime.min.time()))
11241124
rstr = str(rrule).replace('\n', ';')
11251125
self.logger.debug(f'{item}: Looking for {timescan} time. Found rrule: {rstr}')
1126-
dt = datetime.now()
1126+
dt = datetime.now(self._timezone).replace(tzinfo=None)
11271127
while self.alive:
11281128
dt = rrule.before(dt) if timescan == 'previous' else rrule.after(dt)
11291129
if dt is None:
@@ -1145,12 +1145,12 @@ def _get_time(self, entry, timescan, item=None, entryindex=None, caller=None):
11451145
self._update_suncalc(item, entry, entryindex, None)
11461146
compare_date = None if not next else next.date() - timedelta(days=1) if next_day else next.date()
11471147
if next and compare_date == dt.date():
1148-
cond_istoday = next.date() == datetime.now().date()
1148+
cond_istoday = next.date() == datetime.now(self._timezone).date()
11491149
if caller != 'dry_run' and (
11501150
not self._items[item]['interpolation'].get('perday') or cond_istoday
11511151
):
11521152
self._itpl[item][next.timestamp() * 1000.0] = value
1153-
if next - timedelta(seconds=1) > datetime.now().replace(tzinfo=self._timezone):
1153+
if next - timedelta(seconds=1) > datetime.now(self._timezone):
11541154
self.logger.debug(f'{item}: Return from rrule {timescan}: {next}, value {value}.')
11551155
return next, value, None
11561156
else:
@@ -1162,7 +1162,7 @@ def _get_time(self, entry, timescan, item=None, entryindex=None, caller=None):
11621162
datetime.combine(today, datetime.min.time()).replace(tzinfo=self._timezone), time, timescan
11631163
)
11641164
cond_future = next > datetime.now(self._timezone)
1165-
cond_istoday = next.date() == datetime.now().date()
1165+
cond_istoday = next.date() == datetime.now(self._timezone).date()
11661166
if cond_future:
11671167
self.logger.debug(f'{item}: Result parsing time today (sun) {time}: {next}')
11681168
if entryindex is not None:
@@ -1191,7 +1191,7 @@ def _get_time(self, entry, timescan, item=None, entryindex=None, caller=None):
11911191
next = self._series_get_time(entry, timescan)
11921192
if next is None:
11931193
return None, None, False
1194-
cond_istoday = next.date() == datetime.now().date()
1194+
cond_istoday = next.date() == datetime.now(self._timezone).date()
11951195
if caller != 'dry_run' and (not self._items[item]['interpolation'].get('perday') or cond_istoday):
11961196
self._itpl[item][next.timestamp() * 1000.0] = value
11971197
self.logger.debug(f'{item}: Include {timescan} of series: {next} for interpolation.')
@@ -1295,9 +1295,7 @@ def _series_calculate(self, item, caller=None, source=None):
12951295
starttime = datetime.strptime(mydict['series']['timeSeriesMin'], '%H:%M')
12961296
else:
12971297
mytime = self._sun(
1298-
datetime.now().replace(hour=0, minute=0, second=0).astimezone(self._timezone),
1299-
seriesstart,
1300-
'next',
1298+
datetime.now(self._timezone).replace(hour=0, minute=0, second=0), seriesstart, 'next'
13011299
)
13021300
starttime = f'{mytime.hour:02d}:{mytime.minute:02d}'
13031301
starttime = datetime.strptime(starttime, '%H:%M')
@@ -1309,9 +1307,7 @@ def _series_calculate(self, item, caller=None, source=None):
13091307

13101308
if seriesend is not None and 'sun' in seriesend:
13111309
mytime = self._sun(
1312-
datetime.now().replace(hour=0, minute=0, second=0).astimezone(self._timezone),
1313-
seriesend,
1314-
'next',
1310+
datetime.now(self._timezone).replace(hour=0, minute=0, second=0), seriesend, 'next'
13151311
)
13161312
endtime = f'{mytime.hour:02d}:{mytime.minute:02d}'
13171313
endtime = datetime.strptime(endtime, '%H:%M')
@@ -1346,7 +1342,8 @@ def _series_calculate(self, item, caller=None, source=None):
13461342
rrule = rrulestr(
13471343
mydict['rrule'] + ';COUNT=7',
13481344
dtstart=datetime.combine(
1349-
datetime.now(), parser.parse(str(starttime.hour) + ':' + str(starttime.minute)).time()
1345+
datetime.now(self._timezone),
1346+
parser.parse(str(starttime.hour) + ':' + str(starttime.minute)).time(),
13501347
),
13511348
)
13521349
mynewlist = []
@@ -1365,7 +1362,7 @@ def _series_calculate(self, item, caller=None, source=None):
13651362
else:
13661363
seriesstart = mydict['series']['timeSeriesMin']
13671364
mytime = self._sun(
1368-
day.replace(hour=0, minute=0, second=0).astimezone(self._timezone), seriesstart, 'next'
1365+
day.replace(hour=0, minute=0, second=0, tzinfo=self._timezone), seriesstart, 'next'
13691366
)
13701367
starttime = f'{mytime.hour:02d}:{mytime.minute:02d}'
13711368
starttime = datetime.strptime(starttime, '%H:%M')
@@ -1417,9 +1414,10 @@ def _series_calculate(self, item, caller=None, source=None):
14171414
count = 0
14181415
seriestarttime = None
14191416
actday = mydays[time.weekday()]
1420-
if time.time() < datetime.now().time() and time.date() <= datetime.now().date():
1417+
now_local = datetime.now(self._timezone).replace(tzinfo=None)
1418+
if time.time() < now_local.time() and time.date() <= now_local.date():
14211419
continue
1422-
if time >= datetime.now() + timedelta(days=7):
1420+
if time >= now_local + timedelta(days=7):
14231421
continue
14241422
if seriestarttime is None:
14251423
seriestarttime = time
@@ -1470,7 +1468,7 @@ def _get_sun4week(self, item, caller=None):
14701468
"""
14711469
dayrule = rrulestr(
14721470
'FREQ=WEEKLY;BYDAY=MO,TU,WE,TH,FR,SA,SU' + ';COUNT=7',
1473-
dtstart=datetime.now().replace(hour=0, minute=0, second=0),
1471+
dtstart=datetime.now(self._timezone).replace(hour=0, minute=0, second=0),
14741472
)
14751473
self.logger.debug(f'Get sun4week for item {item} called by {caller}')
14761474
mynewdict = {'sunrise': {}, 'sunset': {}}
@@ -1525,17 +1523,13 @@ def _series_get_time(self, mydict, timescan=''):
15251523
if 'sun' not in mydict['series']['timeSeriesMin']:
15261524
starttime = datetime.strptime(mydict['series']['timeSeriesMin'], '%H:%M')
15271525
else:
1528-
mytime = self._sun(
1529-
datetime.now().replace(hour=0, minute=0, second=0).astimezone(self._timezone), seriesstart, 'next'
1530-
)
1526+
mytime = self._sun(datetime.now(self._timezone).replace(hour=0, minute=0, second=0), seriesstart, 'next')
15311527
starttime = f'{mytime.hour:02d}:{mytime.minute:02d}'
15321528
starttime = datetime.strptime(starttime, '%H:%M')
15331529

15341530
if daycount is None and seriesend is not None:
15351531
if 'sun' in seriesend:
1536-
mytime = self._sun(
1537-
datetime.now().replace(hour=0, minute=0, second=0).astimezone(self._timezone), seriesend, 'next'
1538-
)
1532+
mytime = self._sun(datetime.now(self._timezone).replace(hour=0, minute=0, second=0), seriesend, 'next')
15391533
seriesend = f'{mytime.hour:02d}:{mytime.minute:02d}'
15401534
endtime = datetime.strptime(seriesend, '%H:%M')
15411535
else:
@@ -1576,7 +1570,7 @@ def _series_get_time(self, mydict, timescan=''):
15761570
rrule = rrulestr(
15771571
actrrule,
15781572
dtstart=datetime.combine(
1579-
datetime.now() - timedelta(days=7),
1573+
datetime.now(self._timezone) - timedelta(days=7),
15801574
parser.parse(str(starttime.hour) + ':' + str(starttime.minute)).time(),
15811575
),
15821576
)
@@ -1589,7 +1583,7 @@ def _series_get_time(self, mydict, timescan=''):
15891583
mylist[timestamp] = 'x'
15901584
mycount += 1
15911585

1592-
now = datetime.now()
1586+
now = datetime.now(self._timezone).replace(tzinfo=None)
15931587
mylist[now] = 'now'
15941588
mysortedlist = sorted(mylist)
15951589
myindex = mysortedlist.index(now)
@@ -1601,7 +1595,7 @@ def _series_get_time(self, mydict, timescan=''):
16011595
# Get correct "sun" for this Day
16021596
if 'sun' in mydict['series']['timeSeriesMin'] and returnvalue is not None:
16031597
mytime = self._sun(
1604-
returnvalue.replace(hour=0, minute=0, second=0).astimezone(self._timezone),
1598+
returnvalue.replace(hour=0, minute=0, second=0, tzinfo=self._timezone),
16051599
mydict['series']['timeSeriesMin'],
16061600
'next',
16071601
)

uzsu/tests/__init__.py

Whitespace-only changes.

uzsu/tests/base.py

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
import os
2+
import unittest
3+
4+
from tests import common
5+
from tests.mock.core import MockSmartHome
6+
7+
from lib.orb import Orb
8+
from plugins.uzsu import UZSU
9+
10+
BERLIN_LON = 13.4050
11+
BERLIN_LAT = 52.5200
12+
BERLIN_ELEV = 34
13+
14+
15+
class TestUZSUBase(unittest.TestCase):
16+
"""Base class for UZSU plugin tests.
17+
18+
Mirrors plugins/database/tests/base.py: builds a MockSmartHome with a
19+
fixed set of test items, injects plugin.yaml defaults directly (since
20+
the full parameter-loading infrastructure isn't available in test
21+
environments), and constructs the plugin.
22+
"""
23+
24+
def plugin(self, parameters=None, tz='Europe/Berlin'):
25+
self.sh = MockSmartHome()
26+
self.sh.shtime.set_tz(tz)
27+
# UZSU relies on sh.sun/sh.moon (set up by bin/smarthome.py in a real
28+
# instance) for sunrise/sunset-bound entries; MockSmartHome doesn't
29+
# provide these, so build them the same way lib/smarthome.py does.
30+
self.sh.sun = Orb('sun', BERLIN_LON, BERLIN_LAT, BERLIN_ELEV)
31+
self.sh.moon = Orb('moon', BERLIN_LON, BERLIN_LAT, BERLIN_ELEV)
32+
self.sh.with_items_from(os.path.join(os.path.dirname(__file__), 'test_items.yaml'))
33+
34+
UZSU._parameters = {
35+
'remove_duplicates': True,
36+
'ignore_once_entries': False,
37+
'suncalculation_cron': '0 0 * *',
38+
'interpolation_interval': 5,
39+
'interpolation_type': 'none',
40+
'backintime': 0,
41+
'interpolation_precision': 2,
42+
}
43+
if parameters:
44+
UZSU._parameters.update(parameters)
45+
46+
plugin = UZSU(self.sh)
47+
for item in self.sh.return_items():
48+
callback = plugin.parse_item(item)
49+
if callback is not None:
50+
plugin._items_callback = callback
51+
# run() does the real startup bookkeeping (self._series[item] = {},
52+
# self._lastvalues[item] = None, initial scheduling) that
53+
# bin/smarthome.py normally triggers once all plugins are loaded.
54+
plugin.run()
55+
return plugin
56+
57+
def plugin_with_entry(self, item_path, entry, active=True, rrule='FREQ=DAILY', **plugin_kwargs):
58+
"""Build a plugin and seed one uzsu list entry directly on
59+
plugin._items[item]. Bypasses item()'s value-change callback (not
60+
wired up in this mock setup) and writes the internal state the
61+
scheduling methods (_get_time, _schedule, ...) actually consume."""
62+
plugin = self.plugin(**plugin_kwargs)
63+
item = self.sh.return_item(item_path)
64+
full_entry = {'active': True, 'rrule': rrule, **entry}
65+
plugin._items[item]['active'] = active
66+
plugin._items[item]['list'] = [full_entry]
67+
return plugin

uzsu/tests/test_basic.py

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
from plugins.uzsu.tests.base import TestUZSUBase
2+
3+
4+
class TestUZSUBasic(TestUZSUBase):
5+
def test_plugin_constructs_and_parses_items(self):
6+
plugin = self.plugin()
7+
self.assertIn(self.sh.return_item('main.temp.uzsu'), plugin._items)
8+
self.assertIn(self.sh.return_item('main.lamp.uzsu'), plugin._items)
9+
10+
def test_parsed_item_has_default_structure(self):
11+
plugin = self.plugin()
12+
item = self.sh.return_item('main.temp.uzsu')
13+
self.assertEqual(plugin._items[item]['active'], False)
14+
self.assertEqual(plugin._items[item]['list'], [])
15+
16+
def test_activate_sets_active_flag(self):
17+
plugin = self.plugin()
18+
item = self.sh.return_item('main.temp.uzsu')
19+
plugin._items[item]['list'] = [{'value': 21.5, 'active': True, 'time': '07:00', 'rrule': 'FREQ=DAILY'}]
20+
self.assertTrue(plugin.activate(True, item))
21+
self.assertTrue(plugin._items[item]['active'])
22+
self.assertFalse(plugin.activate(False, item))
23+
self.assertFalse(plugin._items[item]['active'])
24+
25+
def test_get_type_returns_target_item_type(self):
26+
plugin = self.plugin()
27+
num_item = self.sh.return_item('main.temp.uzsu')
28+
bool_item = self.sh.return_item('main.lamp.uzsu')
29+
self.assertEqual(plugin._get_type(num_item), 'num')
30+
self.assertEqual(plugin._get_type(bool_item), 'bool')

uzsu/tests/test_items.yaml

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
main:
2+
3+
temp:
4+
type: num
5+
6+
uzsu:
7+
type: dict
8+
uzsu_item: ..
9+
10+
lamp:
11+
type: bool
12+
13+
uzsu:
14+
type: dict
15+
uzsu_item: ..

uzsu/tests/test_scheduling.py

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
import datetime
2+
3+
from tests import common
4+
5+
from plugins.uzsu.tests.base import TestUZSUBase
6+
7+
8+
class TestPlainTimeEntries(TestUZSUBase):
9+
"""Coverage for _get_time() with a plain HH:MM entry.
10+
11+
These exercise the datetime.now(self._timezone) fix in _get_time(): the
12+
rrule search seed and the final scheduled time must consistently be in
13+
shng's configured timezone.
14+
15+
NOTE: a true red/green regression test (proving the OS-tz vs configured-tz
16+
bug specifically) isn't practical here without monkeypatching
17+
datetime.datetime.now() itself - the bug only manifests when the OS
18+
timezone and configured timezone disagree about *which calendar day it
19+
currently is* (i.e. right at local midnight). force_os_tz alone doesn't
20+
shift "now" far enough to hit that window. These tests instead confirm
21+
the now-fixed code produces correct, consistently-tz-aware results.
22+
"""
23+
24+
def setUp(self):
25+
self.plugin = self.plugin_with_entry('main.temp.uzsu', {'value': 21.5, 'time': '07:00'})
26+
self.item = self.sh.return_item('main.temp.uzsu')
27+
28+
def test_next_time_is_aware_in_configured_tz(self):
29+
entry = self.plugin._items[self.item]['list'][0]
30+
nxt, value, _ = self.plugin._get_time(entry, 'next', self.item, 0, 'test')
31+
self.assertEqual(nxt.hour, 7)
32+
self.assertEqual(nxt.minute, 0)
33+
self.assertEqual(nxt.tzinfo, self.plugin._timezone)
34+
self.assertEqual(value, 21.5)
35+
36+
def test_next_time_unaffected_by_os_tz(self):
37+
entry = self.plugin._items[self.item]['list'][0]
38+
with common.force_os_tz('Pacific/Honolulu'):
39+
nxt, value, _ = self.plugin._get_time(entry, 'next', self.item, 0, 'test')
40+
self.assertEqual(nxt.hour, 7)
41+
self.assertEqual(nxt.utcoffset(), datetime.timedelta(hours=2)) # CEST in June
42+
43+
44+
class TestSunBoundEntries(TestUZSUBase):
45+
"""Coverage for _get_time()/_sun() with sunrise/sunset entries — exercises
46+
the Orb tz fixes (lib/orb.py) through the uzsu integration path."""
47+
48+
def setUp(self):
49+
self.plugin = self.plugin_with_entry('main.temp.uzsu', {'value': 18.0, 'time': 'sunset'})
50+
self.item = self.sh.return_item('main.temp.uzsu')
51+
52+
def test_sunset_entry_returns_aware_datetime_in_configured_tz(self):
53+
entry = self.plugin._items[self.item]['list'][0]
54+
nxt, value, _ = self.plugin._get_time(entry, 'next', self.item, 0, 'test')
55+
self.assertEqual(nxt.tzinfo, self.plugin._timezone)
56+
self.assertEqual(value, 18.0)
57+
58+
def test_get_sun4week_populates_sunrise_and_sunset_for_all_days(self):
59+
self.plugin._get_sun4week(self.item)
60+
suncalc = self.plugin._items[self.item]['SunCalculated']
61+
self.assertEqual(set(suncalc.keys()), {'sunrise', 'sunset'})
62+
self.assertEqual(len(suncalc['sunrise']), 7)
63+
self.assertEqual(len(suncalc['sunset']), 7)
64+
for day_str in suncalc['sunrise'].values():
65+
hour, minute = day_str.split(':')
66+
self.assertTrue(0 <= int(hour) <= 23)
67+
self.assertTrue(0 <= int(minute) <= 59)
68+
69+
70+
class TestInterpolation(TestUZSUBase):
71+
def test_interpolation_set_and_get(self):
72+
plugin = self.plugin()
73+
item = self.sh.return_item('main.temp.uzsu')
74+
result = plugin.interpolation('linear', interval=10, item=item)
75+
self.assertEqual(result['type'], 'linear')
76+
self.assertEqual(result['interval'], 10)
77+
self.assertEqual(plugin.interpolation(item=item), result)
78+
79+
def test_invalid_interpolation_type_rejected(self):
80+
plugin = self.plugin()
81+
item = self.sh.return_item('main.temp.uzsu')
82+
before = plugin.interpolation(item=item)
83+
result = plugin.interpolation('exponential', item=item)
84+
self.assertEqual(result, before)

0 commit comments

Comments
 (0)