window-time 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- window_time/__init__.py +3 -0
- window_time/__main__.py +8 -0
- window_time/cli.py +361 -0
- window_time/config.py +246 -0
- window_time/daemon.py +156 -0
- window_time/influxdb_client.py +155 -0
- window_time/notifications.py +118 -0
- window_time/season.py +68 -0
- window_time/temperature_analysis.py +292 -0
- window_time/window_state.py +341 -0
- window_time-0.1.0.dist-info/METADATA +32 -0
- window_time-0.1.0.dist-info/RECORD +14 -0
- window_time-0.1.0.dist-info/WHEEL +4 -0
- window_time-0.1.0.dist-info/entry_points.txt +2 -0
window_time/daemon.py
ADDED
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
"""Daemon runner for periodic temperature monitoring."""
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
import signal
|
|
5
|
+
import time
|
|
6
|
+
from datetime import datetime
|
|
7
|
+
from typing import Optional
|
|
8
|
+
|
|
9
|
+
from .config import Config
|
|
10
|
+
from .influxdb_client import TemperatureClient
|
|
11
|
+
from .notifications import notify_window_action
|
|
12
|
+
from .temperature_analysis import analyze_temperatures
|
|
13
|
+
from .window_state import (
|
|
14
|
+
DailyState,
|
|
15
|
+
WindowAction,
|
|
16
|
+
load_state,
|
|
17
|
+
make_window_decision,
|
|
18
|
+
save_state,
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
logger = logging.getLogger(__name__)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class WindowTimeDaemon:
|
|
25
|
+
"""Daemon that monitors temperatures and notifies about window actions."""
|
|
26
|
+
|
|
27
|
+
def __init__(self, config: Config):
|
|
28
|
+
"""Initialize the daemon.
|
|
29
|
+
|
|
30
|
+
Args:
|
|
31
|
+
config: Application configuration.
|
|
32
|
+
"""
|
|
33
|
+
self.config = config
|
|
34
|
+
self.running = False
|
|
35
|
+
self._setup_signal_handlers()
|
|
36
|
+
|
|
37
|
+
def _setup_signal_handlers(self) -> None:
|
|
38
|
+
"""Set up signal handlers for graceful shutdown."""
|
|
39
|
+
signal.signal(signal.SIGTERM, self._handle_signal)
|
|
40
|
+
signal.signal(signal.SIGINT, self._handle_signal)
|
|
41
|
+
|
|
42
|
+
def _handle_signal(self, signum: int, frame) -> None:
|
|
43
|
+
"""Handle shutdown signals.
|
|
44
|
+
|
|
45
|
+
Args:
|
|
46
|
+
signum: Signal number.
|
|
47
|
+
frame: Current stack frame.
|
|
48
|
+
"""
|
|
49
|
+
logger.info(f"Received signal {signum}, shutting down...")
|
|
50
|
+
self.running = False
|
|
51
|
+
|
|
52
|
+
def run_once(self, state: Optional[DailyState] = None) -> DailyState:
|
|
53
|
+
"""Run a single iteration of the temperature check.
|
|
54
|
+
|
|
55
|
+
Args:
|
|
56
|
+
state: Current daily state. If None, loads from file.
|
|
57
|
+
|
|
58
|
+
Returns:
|
|
59
|
+
Updated daily state.
|
|
60
|
+
"""
|
|
61
|
+
logger.info(f"Running temperature check at {datetime.now().isoformat()}")
|
|
62
|
+
|
|
63
|
+
# Load state if not provided
|
|
64
|
+
if state is None:
|
|
65
|
+
state = load_state()
|
|
66
|
+
|
|
67
|
+
try:
|
|
68
|
+
# Fetch temperature data
|
|
69
|
+
with TemperatureClient(self.config) as client:
|
|
70
|
+
temp_data = client.fetch_temperature_data(
|
|
71
|
+
lookback_hours=self.config.daemon.lookback_hours
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
if not temp_data.has_data:
|
|
75
|
+
logger.warning("No temperature data available")
|
|
76
|
+
return state
|
|
77
|
+
|
|
78
|
+
# Analyze temperatures
|
|
79
|
+
analysis = analyze_temperatures(temp_data)
|
|
80
|
+
|
|
81
|
+
logger.info(
|
|
82
|
+
f"Current temps - Indoor: {analysis.current_indoor_temp:.1f}° "
|
|
83
|
+
f"Outdoor: {analysis.current_outdoor_temp:.1f}°"
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
if analysis.indoor_trend:
|
|
87
|
+
logger.info(
|
|
88
|
+
f"Indoor trend: {analysis.indoor_trend.direction.value} "
|
|
89
|
+
f"({analysis.indoor_trend.rate_per_hour:+.2f}°/hr)"
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
if analysis.outdoor_trend:
|
|
93
|
+
logger.info(
|
|
94
|
+
f"Outdoor trend: {analysis.outdoor_trend.direction.value} "
|
|
95
|
+
f"({analysis.outdoor_trend.rate_per_hour:+.2f}°/hr)"
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
if analysis.crossing_prediction and analysis.crossing_prediction.will_cross:
|
|
99
|
+
logger.info(
|
|
100
|
+
f"Temperatures predicted to cross in "
|
|
101
|
+
f"{analysis.crossing_prediction.minutes_until_crossing:.0f} minutes "
|
|
102
|
+
f"at {analysis.crossing_prediction.crossing_temp:.1f}°"
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
# Make window decision
|
|
106
|
+
decision, new_state = make_window_decision(analysis, state, self.config)
|
|
107
|
+
|
|
108
|
+
if decision.action != WindowAction.NO_ACTION:
|
|
109
|
+
logger.info(f"Window action: {decision.action.value} - {decision.reason}")
|
|
110
|
+
notify_window_action(self.config, decision)
|
|
111
|
+
else:
|
|
112
|
+
logger.debug(f"No action needed: {decision.reason}")
|
|
113
|
+
|
|
114
|
+
# Save updated state
|
|
115
|
+
save_state(new_state)
|
|
116
|
+
|
|
117
|
+
return new_state
|
|
118
|
+
|
|
119
|
+
except Exception as e:
|
|
120
|
+
logger.error(f"Error during temperature check: {e}", exc_info=True)
|
|
121
|
+
return state
|
|
122
|
+
|
|
123
|
+
def run(self) -> None:
|
|
124
|
+
"""Run the daemon loop."""
|
|
125
|
+
logger.info("Starting window-time daemon")
|
|
126
|
+
logger.info(f"Check interval: {self.config.daemon.interval_minutes} minutes")
|
|
127
|
+
|
|
128
|
+
self.running = True
|
|
129
|
+
state = load_state()
|
|
130
|
+
|
|
131
|
+
while self.running:
|
|
132
|
+
state = self.run_once(state)
|
|
133
|
+
|
|
134
|
+
if not self.running:
|
|
135
|
+
break
|
|
136
|
+
|
|
137
|
+
# Sleep for the configured interval
|
|
138
|
+
sleep_seconds = self.config.daemon.interval_minutes * 60
|
|
139
|
+
logger.debug(f"Sleeping for {sleep_seconds} seconds")
|
|
140
|
+
|
|
141
|
+
# Sleep in small increments to respond to signals quickly
|
|
142
|
+
sleep_end = time.time() + sleep_seconds
|
|
143
|
+
while self.running and time.time() < sleep_end:
|
|
144
|
+
time.sleep(min(1.0, sleep_end - time.time()))
|
|
145
|
+
|
|
146
|
+
logger.info("Daemon stopped")
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def run_daemon(config: Config) -> None:
|
|
150
|
+
"""Run the window-time daemon.
|
|
151
|
+
|
|
152
|
+
Args:
|
|
153
|
+
config: Application configuration.
|
|
154
|
+
"""
|
|
155
|
+
daemon = WindowTimeDaemon(config)
|
|
156
|
+
daemon.run()
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
"""InfluxDB client for fetching temperature data."""
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from datetime import datetime
|
|
6
|
+
from typing import Optional
|
|
7
|
+
|
|
8
|
+
from influxdb_client import InfluxDBClient
|
|
9
|
+
|
|
10
|
+
from .config import Config
|
|
11
|
+
|
|
12
|
+
logger = logging.getLogger(__name__)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass
|
|
16
|
+
class TemperatureReading:
|
|
17
|
+
"""A single temperature reading from a sensor."""
|
|
18
|
+
|
|
19
|
+
timestamp: datetime
|
|
20
|
+
temperature: float
|
|
21
|
+
domain: str
|
|
22
|
+
location: str
|
|
23
|
+
mac: str
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass
|
|
27
|
+
class TemperatureData:
|
|
28
|
+
"""Collection of temperature readings separated by domain."""
|
|
29
|
+
|
|
30
|
+
indoor_readings: list[TemperatureReading]
|
|
31
|
+
outdoor_readings: list[TemperatureReading]
|
|
32
|
+
|
|
33
|
+
@property
|
|
34
|
+
def has_data(self) -> bool:
|
|
35
|
+
"""Check if there is any temperature data."""
|
|
36
|
+
return bool(self.indoor_readings) and bool(self.outdoor_readings)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class TemperatureClient:
|
|
40
|
+
"""Client for fetching temperature data from InfluxDB."""
|
|
41
|
+
|
|
42
|
+
def __init__(self, config: Config):
|
|
43
|
+
"""Initialize the temperature client.
|
|
44
|
+
|
|
45
|
+
Args:
|
|
46
|
+
config: Application configuration containing InfluxDB settings.
|
|
47
|
+
"""
|
|
48
|
+
self.config = config
|
|
49
|
+
self._client: Optional[InfluxDBClient] = None
|
|
50
|
+
|
|
51
|
+
def _get_client(self) -> InfluxDBClient:
|
|
52
|
+
"""Get or create the InfluxDB client."""
|
|
53
|
+
if self._client is None:
|
|
54
|
+
self._client = InfluxDBClient(
|
|
55
|
+
url=self.config.influxdb.url,
|
|
56
|
+
token=self.config.influxdb.token,
|
|
57
|
+
org=self.config.influxdb.org,
|
|
58
|
+
)
|
|
59
|
+
return self._client
|
|
60
|
+
|
|
61
|
+
def close(self) -> None:
|
|
62
|
+
"""Close the InfluxDB client connection."""
|
|
63
|
+
if self._client is not None:
|
|
64
|
+
self._client.close()
|
|
65
|
+
self._client = None
|
|
66
|
+
|
|
67
|
+
def fetch_temperature_data(self, lookback_hours: int = 1) -> TemperatureData:
|
|
68
|
+
"""Fetch temperature data from InfluxDB.
|
|
69
|
+
|
|
70
|
+
Args:
|
|
71
|
+
lookback_hours: Number of hours to look back for data.
|
|
72
|
+
|
|
73
|
+
Returns:
|
|
74
|
+
TemperatureData containing indoor and outdoor readings.
|
|
75
|
+
"""
|
|
76
|
+
client = self._get_client()
|
|
77
|
+
query_api = client.query_api()
|
|
78
|
+
|
|
79
|
+
influx_config = self.config.influxdb
|
|
80
|
+
|
|
81
|
+
indoor = influx_config.indoor_domain
|
|
82
|
+
outdoor = influx_config.outdoor_domain
|
|
83
|
+
domain = influx_config.domain_tag
|
|
84
|
+
|
|
85
|
+
query = f'''
|
|
86
|
+
from(bucket: "{influx_config.bucket}")
|
|
87
|
+
|> range(start: -{lookback_hours}h)
|
|
88
|
+
|> filter(fn: (r) => r["_measurement"] == "{influx_config.measurement}")
|
|
89
|
+
|> filter(fn: (r) => r["_field"] == "{influx_config.temperature_field}")
|
|
90
|
+
|> filter(fn: (r) => r["{domain}"] == "{indoor}" or r["{domain}"] == "{outdoor}")
|
|
91
|
+
|> sort(columns: ["_time"])
|
|
92
|
+
'''
|
|
93
|
+
|
|
94
|
+
logger.debug(f"Executing InfluxDB query:\n{query}")
|
|
95
|
+
|
|
96
|
+
try:
|
|
97
|
+
tables = query_api.query(query)
|
|
98
|
+
except Exception as e:
|
|
99
|
+
logger.error(f"Error querying InfluxDB: {e}")
|
|
100
|
+
raise
|
|
101
|
+
|
|
102
|
+
indoor_readings: list[TemperatureReading] = []
|
|
103
|
+
outdoor_readings: list[TemperatureReading] = []
|
|
104
|
+
|
|
105
|
+
for table in tables:
|
|
106
|
+
for record in table.records:
|
|
107
|
+
reading = TemperatureReading(
|
|
108
|
+
timestamp=record.get_time(),
|
|
109
|
+
temperature=float(record.get_value()),
|
|
110
|
+
domain=record.values.get(influx_config.domain_tag, ""),
|
|
111
|
+
location=record.values.get(influx_config.location_tag, ""),
|
|
112
|
+
mac=record.values.get(influx_config.mac_tag, ""),
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
if reading.domain == influx_config.indoor_domain:
|
|
116
|
+
indoor_readings.append(reading)
|
|
117
|
+
elif reading.domain == influx_config.outdoor_domain:
|
|
118
|
+
outdoor_readings.append(reading)
|
|
119
|
+
|
|
120
|
+
logger.info(
|
|
121
|
+
f"Fetched {len(indoor_readings)} indoor and {len(outdoor_readings)} outdoor readings"
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
return TemperatureData(
|
|
125
|
+
indoor_readings=indoor_readings,
|
|
126
|
+
outdoor_readings=outdoor_readings,
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
def get_current_temperatures(self) -> tuple[Optional[float], Optional[float]]:
|
|
130
|
+
"""Get the most recent indoor and outdoor temperatures.
|
|
131
|
+
|
|
132
|
+
Returns:
|
|
133
|
+
Tuple of (indoor_temp, outdoor_temp), either may be None if no data.
|
|
134
|
+
"""
|
|
135
|
+
data = self.fetch_temperature_data(lookback_hours=1)
|
|
136
|
+
|
|
137
|
+
indoor_temp = None
|
|
138
|
+
outdoor_temp = None
|
|
139
|
+
|
|
140
|
+
if data.indoor_readings:
|
|
141
|
+
indoor_temp = data.indoor_readings[-1].temperature
|
|
142
|
+
|
|
143
|
+
if data.outdoor_readings:
|
|
144
|
+
outdoor_temp = data.outdoor_readings[-1].temperature
|
|
145
|
+
|
|
146
|
+
return indoor_temp, outdoor_temp
|
|
147
|
+
|
|
148
|
+
def __enter__(self):
|
|
149
|
+
"""Context manager entry."""
|
|
150
|
+
return self
|
|
151
|
+
|
|
152
|
+
def __exit__(self, exc_type, exc_val, exc_tb):
|
|
153
|
+
"""Context manager exit."""
|
|
154
|
+
self.close()
|
|
155
|
+
return False
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
"""Notification system using ntfy.sh."""
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
from typing import Optional
|
|
5
|
+
|
|
6
|
+
import requests
|
|
7
|
+
|
|
8
|
+
from .config import Config
|
|
9
|
+
from .window_state import WindowAction, WindowDecision
|
|
10
|
+
|
|
11
|
+
logger = logging.getLogger(__name__)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class NotificationError(Exception):
|
|
15
|
+
"""Error sending notification."""
|
|
16
|
+
|
|
17
|
+
pass
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def send_notification(
|
|
21
|
+
config: Config,
|
|
22
|
+
title: str,
|
|
23
|
+
message: str,
|
|
24
|
+
priority: str = "default",
|
|
25
|
+
tags: Optional[list[str]] = None,
|
|
26
|
+
) -> bool:
|
|
27
|
+
"""Send a notification via ntfy.sh.
|
|
28
|
+
|
|
29
|
+
Args:
|
|
30
|
+
config: Application configuration with ntfy settings.
|
|
31
|
+
title: Notification title.
|
|
32
|
+
message: Notification message body.
|
|
33
|
+
priority: Notification priority (min, low, default, high, urgent).
|
|
34
|
+
tags: Optional list of emoji tags for the notification.
|
|
35
|
+
|
|
36
|
+
Returns:
|
|
37
|
+
True if notification was sent successfully.
|
|
38
|
+
|
|
39
|
+
Raises:
|
|
40
|
+
NotificationError: If notification fails to send.
|
|
41
|
+
"""
|
|
42
|
+
if not config.notification.ntfy_topic:
|
|
43
|
+
logger.warning("No ntfy topic configured, skipping notification")
|
|
44
|
+
return False
|
|
45
|
+
|
|
46
|
+
url = f"{config.notification.ntfy_server}/{config.notification.ntfy_topic}"
|
|
47
|
+
|
|
48
|
+
headers = {
|
|
49
|
+
"Title": title,
|
|
50
|
+
"Priority": priority,
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
if tags:
|
|
54
|
+
headers["Tags"] = ",".join(tags)
|
|
55
|
+
|
|
56
|
+
try:
|
|
57
|
+
response = requests.post(url, data=message.encode("utf-8"), headers=headers)
|
|
58
|
+
response.raise_for_status()
|
|
59
|
+
logger.info(f"Notification sent: {title}")
|
|
60
|
+
return True
|
|
61
|
+
except requests.exceptions.RequestException as e:
|
|
62
|
+
logger.error(f"Failed to send notification: {e}")
|
|
63
|
+
raise NotificationError(f"Failed to send notification: {e}") from e
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def notify_window_action(config: Config, decision: WindowDecision) -> bool:
|
|
67
|
+
"""Send a notification for a window action decision.
|
|
68
|
+
|
|
69
|
+
Args:
|
|
70
|
+
config: Application configuration.
|
|
71
|
+
decision: The window decision to notify about.
|
|
72
|
+
|
|
73
|
+
Returns:
|
|
74
|
+
True if notification was sent successfully.
|
|
75
|
+
"""
|
|
76
|
+
if decision.action == WindowAction.NO_ACTION:
|
|
77
|
+
logger.debug("No window action needed, skipping notification")
|
|
78
|
+
return False
|
|
79
|
+
|
|
80
|
+
# Format temperatures
|
|
81
|
+
indoor = (
|
|
82
|
+
f"{decision.current_indoor_temp:.1f}°"
|
|
83
|
+
if decision.current_indoor_temp is not None
|
|
84
|
+
else "N/A"
|
|
85
|
+
)
|
|
86
|
+
outdoor = (
|
|
87
|
+
f"{decision.current_outdoor_temp:.1f}°"
|
|
88
|
+
if decision.current_outdoor_temp is not None
|
|
89
|
+
else "N/A"
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
if decision.action == WindowAction.OPEN:
|
|
93
|
+
title = "Time to Open the Windows!"
|
|
94
|
+
tags = ["house", "sunny"]
|
|
95
|
+
priority = "high"
|
|
96
|
+
else: # CLOSE
|
|
97
|
+
title = "Time to Close the Windows!"
|
|
98
|
+
tags = ["house", "closed_lock_with_key"]
|
|
99
|
+
priority = "high"
|
|
100
|
+
|
|
101
|
+
message = (
|
|
102
|
+
f"{decision.reason}\n\n"
|
|
103
|
+
f"Indoor: {indoor}\n"
|
|
104
|
+
f"Outdoor: {outdoor}\n"
|
|
105
|
+
f"Season: {decision.season.value.capitalize()}"
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
try:
|
|
109
|
+
return send_notification(
|
|
110
|
+
config,
|
|
111
|
+
title=title,
|
|
112
|
+
message=message,
|
|
113
|
+
priority=priority,
|
|
114
|
+
tags=tags,
|
|
115
|
+
)
|
|
116
|
+
except NotificationError:
|
|
117
|
+
# Error already logged, return False
|
|
118
|
+
return False
|
window_time/season.py
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
"""Season detection for determining summer vs winter window behavior."""
|
|
2
|
+
|
|
3
|
+
from datetime import date
|
|
4
|
+
from enum import Enum
|
|
5
|
+
|
|
6
|
+
from .config import SeasonConfig
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class Season(Enum):
|
|
10
|
+
"""Season enumeration."""
|
|
11
|
+
|
|
12
|
+
SUMMER = "summer"
|
|
13
|
+
WINTER = "winter"
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def get_season(current_date: date, config: SeasonConfig) -> Season:
|
|
17
|
+
"""Determine the current season based on date.
|
|
18
|
+
|
|
19
|
+
Summer: April 8 to November 11 (inclusive)
|
|
20
|
+
Winter: November 12 to April 7 (inclusive)
|
|
21
|
+
|
|
22
|
+
Args:
|
|
23
|
+
current_date: The date to check.
|
|
24
|
+
config: Season configuration with boundary dates.
|
|
25
|
+
|
|
26
|
+
Returns:
|
|
27
|
+
The current season.
|
|
28
|
+
"""
|
|
29
|
+
month = current_date.month
|
|
30
|
+
day = current_date.day
|
|
31
|
+
|
|
32
|
+
# Create comparable (month, day) tuples
|
|
33
|
+
current = (month, day)
|
|
34
|
+
summer_start = (config.summer_start_month, config.summer_start_day)
|
|
35
|
+
summer_end = (config.summer_end_month, config.summer_end_day)
|
|
36
|
+
|
|
37
|
+
# Summer is from April 8 to November 11
|
|
38
|
+
# Winter is from November 12 to April 7
|
|
39
|
+
if summer_start <= current <= summer_end:
|
|
40
|
+
return Season.SUMMER
|
|
41
|
+
else:
|
|
42
|
+
return Season.WINTER
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def is_summer(current_date: date, config: SeasonConfig) -> bool:
|
|
46
|
+
"""Check if the current date is in summer.
|
|
47
|
+
|
|
48
|
+
Args:
|
|
49
|
+
current_date: The date to check.
|
|
50
|
+
config: Season configuration with boundary dates.
|
|
51
|
+
|
|
52
|
+
Returns:
|
|
53
|
+
True if it's summer, False otherwise.
|
|
54
|
+
"""
|
|
55
|
+
return get_season(current_date, config) == Season.SUMMER
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def is_winter(current_date: date, config: SeasonConfig) -> bool:
|
|
59
|
+
"""Check if the current date is in winter.
|
|
60
|
+
|
|
61
|
+
Args:
|
|
62
|
+
current_date: The date to check.
|
|
63
|
+
config: Season configuration with boundary dates.
|
|
64
|
+
|
|
65
|
+
Returns:
|
|
66
|
+
True if it's winter, False otherwise.
|
|
67
|
+
"""
|
|
68
|
+
return get_season(current_date, config) == Season.WINTER
|