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/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