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.
@@ -0,0 +1,3 @@
1
+ """Window Time - Temperature-based window management notifications."""
2
+
3
+ __version__ = "0.1.0"
@@ -0,0 +1,8 @@
1
+ """Allow running as python -m window_time."""
2
+
3
+ import sys
4
+
5
+ from .cli import main
6
+
7
+ if __name__ == "__main__":
8
+ sys.exit(main())
window_time/cli.py ADDED
@@ -0,0 +1,361 @@
1
+ """Command-line interface for window-time."""
2
+
3
+ import argparse
4
+ import logging
5
+ import sys
6
+ from datetime import date
7
+ from pathlib import Path
8
+
9
+ from . import __version__
10
+ from .config import (
11
+ Config,
12
+ get_config_path,
13
+ get_state_path,
14
+ load_config,
15
+ save_default_config,
16
+ )
17
+ from .daemon import WindowTimeDaemon, run_daemon
18
+ from .season import get_season
19
+
20
+
21
+ def setup_logging(verbose: bool = False, debug: bool = False) -> None:
22
+ """Configure logging for the application.
23
+
24
+ Args:
25
+ verbose: Enable verbose output.
26
+ debug: Enable debug output.
27
+ """
28
+ if debug:
29
+ level = logging.DEBUG
30
+ elif verbose:
31
+ level = logging.INFO
32
+ else:
33
+ level = logging.WARNING
34
+
35
+ logging.basicConfig(
36
+ level=level,
37
+ format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
38
+ datefmt="%Y-%m-%d %H:%M:%S",
39
+ )
40
+
41
+
42
+ def cmd_run(args: argparse.Namespace, config: Config) -> int:
43
+ """Run the daemon.
44
+
45
+ Args:
46
+ args: Parsed command-line arguments.
47
+ config: Application configuration.
48
+
49
+ Returns:
50
+ Exit code.
51
+ """
52
+ run_daemon(config)
53
+ return 0
54
+
55
+
56
+ def cmd_once(args: argparse.Namespace, config: Config) -> int:
57
+ """Run a single temperature check.
58
+
59
+ Args:
60
+ args: Parsed command-line arguments.
61
+ config: Application configuration.
62
+
63
+ Returns:
64
+ Exit code.
65
+ """
66
+ daemon = WindowTimeDaemon(config)
67
+ daemon.run_once()
68
+ return 0
69
+
70
+
71
+ def cmd_status(args: argparse.Namespace, config: Config) -> int:
72
+ """Show current status and temperatures.
73
+
74
+ Args:
75
+ args: Parsed command-line arguments.
76
+ config: Application configuration.
77
+
78
+ Returns:
79
+ Exit code.
80
+ """
81
+ from .influxdb_client import TemperatureClient
82
+ from .temperature_analysis import analyze_temperatures
83
+ from .window_state import load_state
84
+
85
+ print("Window Time Status")
86
+ print("==================")
87
+ print()
88
+
89
+ # Show season
90
+ today = date.today()
91
+ season = get_season(today, config.season)
92
+ print(f"Date: {today.isoformat()}")
93
+ print(f"Season: {season.value.capitalize()}")
94
+ print()
95
+
96
+ # Show current state
97
+ state = load_state()
98
+ print("Window State")
99
+ print("------------")
100
+ print(f"Last known state: {state.last_known_state.value}")
101
+ print(f"Windows opened for warmth today: {state.windows_opened_for_warmth}")
102
+ if state.last_outdoor_warmer is not None:
103
+ outdoor_status = "warmer" if state.last_outdoor_warmer else "cooler"
104
+ print(f"Last check: Outdoor was {outdoor_status} than indoor")
105
+ print()
106
+
107
+ # Fetch and show temperatures
108
+ print("Current Temperatures")
109
+ print("--------------------")
110
+ try:
111
+ with TemperatureClient(config) as client:
112
+ temp_data = client.fetch_temperature_data(lookback_hours=config.daemon.lookback_hours)
113
+
114
+ if not temp_data.has_data:
115
+ print("No temperature data available")
116
+ return 1
117
+
118
+ analysis = analyze_temperatures(temp_data)
119
+
120
+ if analysis.current_indoor_temp is not None:
121
+ print(f"Indoor: {analysis.current_indoor_temp:.1f}°")
122
+ if analysis.current_outdoor_temp is not None:
123
+ print(f"Outdoor: {analysis.current_outdoor_temp:.1f}°")
124
+
125
+ if analysis.outdoor_warmer:
126
+ print("Outdoor is currently WARMER than indoor")
127
+ else:
128
+ print("Outdoor is currently COOLER than indoor")
129
+
130
+ print()
131
+ print("Temperature Trends")
132
+ print("------------------")
133
+ if analysis.indoor_trend:
134
+ print(
135
+ f"Indoor: {analysis.indoor_trend.direction.value} "
136
+ f"({analysis.indoor_trend.rate_per_hour:+.2f}°/hr)"
137
+ )
138
+ if analysis.outdoor_trend:
139
+ print(
140
+ f"Outdoor: {analysis.outdoor_trend.direction.value} "
141
+ f"({analysis.outdoor_trend.rate_per_hour:+.2f}°/hr)"
142
+ )
143
+
144
+ if analysis.crossing_prediction and analysis.crossing_prediction.will_cross:
145
+ print()
146
+ print("Crossing Prediction")
147
+ print("-------------------")
148
+ print(
149
+ f"Temperatures will cross in "
150
+ f"{analysis.crossing_prediction.minutes_until_crossing:.0f} minutes"
151
+ )
152
+ print(f"Crossing temperature: {analysis.crossing_prediction.crossing_temp:.1f}°")
153
+ if analysis.crossing_prediction.crossing_time:
154
+ print(
155
+ f"Crossing time: {analysis.crossing_prediction.crossing_time.strftime('%H:%M')}"
156
+ )
157
+
158
+ except Exception as e:
159
+ print(f"Error fetching temperature data: {e}")
160
+ return 1
161
+
162
+ return 0
163
+
164
+
165
+ def cmd_init(args: argparse.Namespace, config: Config) -> int:
166
+ """Initialize configuration file.
167
+
168
+ Args:
169
+ args: Parsed command-line arguments.
170
+ config: Application configuration.
171
+
172
+ Returns:
173
+ Exit code.
174
+ """
175
+ config_path = Path(args.config) if args.config else None
176
+
177
+ if config_path and config_path.exists():
178
+ print(f"Configuration file already exists at {config_path}")
179
+ if not args.force:
180
+ print("Use --force to overwrite")
181
+ return 1
182
+
183
+ saved_path = save_default_config(config_path)
184
+ print(f"Configuration file created at {saved_path}")
185
+ print()
186
+ print("Please edit the configuration file to add your:")
187
+ print(" - InfluxDB token")
188
+ print(" - ntfy.sh topic ID")
189
+ print(" - Any other custom settings")
190
+ return 0
191
+
192
+
193
+ def cmd_config(args: argparse.Namespace, config: Config) -> int:
194
+ """Show configuration paths and current settings.
195
+
196
+ Args:
197
+ args: Parsed command-line arguments.
198
+ config: Application configuration.
199
+
200
+ Returns:
201
+ Exit code.
202
+ """
203
+ print("Configuration Paths")
204
+ print("===================")
205
+ print(f"Config file: {get_config_path()}")
206
+ print(f"State file: {get_state_path()}")
207
+ print()
208
+
209
+ if args.show_values:
210
+ print("Current Configuration")
211
+ print("=====================")
212
+ print()
213
+ print("InfluxDB:")
214
+ print(f" URL: {config.influxdb.url}")
215
+ print(f" Organization: {config.influxdb.org}")
216
+ print(f" Bucket: {config.influxdb.bucket}")
217
+ print(f" Measurement: {config.influxdb.measurement}")
218
+ print(f" Token: {'[set]' if config.influxdb.token else '[not set]'}")
219
+ print()
220
+ print("Notification:")
221
+ print(f" Server: {config.notification.ntfy_server}")
222
+ print(f" Topic: {config.notification.ntfy_topic or '[not set]'}")
223
+ print()
224
+ print("Season Boundaries:")
225
+ print(
226
+ f" Summer: {config.season.summer_start_month}/{config.season.summer_start_day} "
227
+ f"to {config.season.summer_end_month}/{config.season.summer_end_day}"
228
+ )
229
+ print()
230
+ print("Daemon:")
231
+ print(f" Interval: {config.daemon.interval_minutes} minutes")
232
+ print(f" Lookback: {config.daemon.lookback_hours} hour(s)")
233
+
234
+ return 0
235
+
236
+
237
+ def create_parser() -> argparse.ArgumentParser:
238
+ """Create the argument parser.
239
+
240
+ Returns:
241
+ Configured ArgumentParser.
242
+ """
243
+ parser = argparse.ArgumentParser(
244
+ prog="window-time",
245
+ description="Monitor indoor/outdoor temperatures and notify when to open or close windows",
246
+ )
247
+
248
+ parser.add_argument(
249
+ "-V",
250
+ "--version",
251
+ action="version",
252
+ version=f"%(prog)s {__version__}",
253
+ )
254
+
255
+ parser.add_argument(
256
+ "-c",
257
+ "--config",
258
+ metavar="PATH",
259
+ help="Path to configuration file (default: XDG config location)",
260
+ )
261
+
262
+ parser.add_argument(
263
+ "-v",
264
+ "--verbose",
265
+ action="store_true",
266
+ help="Enable verbose output",
267
+ )
268
+
269
+ parser.add_argument(
270
+ "--debug",
271
+ action="store_true",
272
+ help="Enable debug output",
273
+ )
274
+
275
+ subparsers = parser.add_subparsers(
276
+ title="commands",
277
+ dest="command",
278
+ metavar="COMMAND",
279
+ )
280
+
281
+ # run command (default)
282
+ run_parser = subparsers.add_parser(
283
+ "run",
284
+ help="Run the daemon (default command)",
285
+ )
286
+ run_parser.set_defaults(func=cmd_run)
287
+
288
+ # once command
289
+ once_parser = subparsers.add_parser(
290
+ "once",
291
+ help="Run a single temperature check and exit",
292
+ )
293
+ once_parser.set_defaults(func=cmd_once)
294
+
295
+ # status command
296
+ status_parser = subparsers.add_parser(
297
+ "status",
298
+ help="Show current status and temperatures",
299
+ )
300
+ status_parser.set_defaults(func=cmd_status)
301
+
302
+ # init command
303
+ init_parser = subparsers.add_parser(
304
+ "init",
305
+ help="Initialize configuration file",
306
+ )
307
+ init_parser.add_argument(
308
+ "-f",
309
+ "--force",
310
+ action="store_true",
311
+ help="Overwrite existing configuration file",
312
+ )
313
+ init_parser.set_defaults(func=cmd_init)
314
+
315
+ # config command
316
+ config_parser = subparsers.add_parser(
317
+ "config",
318
+ help="Show configuration paths and settings",
319
+ )
320
+ config_parser.add_argument(
321
+ "-s",
322
+ "--show-values",
323
+ action="store_true",
324
+ help="Show current configuration values",
325
+ )
326
+ config_parser.set_defaults(func=cmd_config)
327
+
328
+ return parser
329
+
330
+
331
+ def main(argv: list[str] | None = None) -> int:
332
+ """Main entry point.
333
+
334
+ Args:
335
+ argv: Command-line arguments. If None, uses sys.argv.
336
+
337
+ Returns:
338
+ Exit code.
339
+ """
340
+ parser = create_parser()
341
+ args = parser.parse_args(argv)
342
+
343
+ setup_logging(verbose=args.verbose, debug=args.debug)
344
+
345
+ # Load configuration
346
+ config_path = Path(args.config) if args.config else None
347
+ try:
348
+ config = load_config(config_path)
349
+ except Exception as e:
350
+ print(f"Error loading configuration: {e}", file=sys.stderr)
351
+ return 1
352
+
353
+ # Default to 'run' command if no command specified
354
+ if args.command is None:
355
+ args.func = cmd_run
356
+
357
+ return args.func(args, config)
358
+
359
+
360
+ if __name__ == "__main__":
361
+ sys.exit(main())
window_time/config.py ADDED
@@ -0,0 +1,246 @@
1
+ """Configuration management using platformdirs for XDG-compliant paths."""
2
+
3
+ import logging
4
+ import os
5
+ from dataclasses import dataclass, field
6
+ from pathlib import Path
7
+ from typing import Any
8
+
9
+ import platformdirs
10
+ import yaml
11
+
12
+ APP_NAME = "window-time"
13
+ APP_AUTHOR = "Gene Wood"
14
+
15
+ logger = logging.getLogger(__name__)
16
+
17
+
18
+ @dataclass
19
+ class InfluxDBConfig:
20
+ """InfluxDB connection configuration."""
21
+
22
+ url: str = "https://influxdb.example.com"
23
+ token: str = ""
24
+ org: str = "my-org"
25
+ bucket: str = "home"
26
+ measurement: str = "environment"
27
+ temperature_field: str = "temperature"
28
+ domain_tag: str = "domain"
29
+ location_tag: str = "location"
30
+ mac_tag: str = "mac"
31
+ indoor_domain: str = "indoors"
32
+ outdoor_domain: str = "outdoors"
33
+
34
+
35
+ @dataclass
36
+ class NotificationConfig:
37
+ """Notification configuration for ntfy.sh."""
38
+
39
+ ntfy_topic: str = ""
40
+ ntfy_server: str = "https://ntfy.sh"
41
+
42
+
43
+ @dataclass
44
+ class SeasonConfig:
45
+ """Season date boundaries configuration."""
46
+
47
+ summer_start_month: int = 4
48
+ summer_start_day: int = 8
49
+ summer_end_month: int = 11
50
+ summer_end_day: int = 11
51
+
52
+
53
+ @dataclass
54
+ class DaemonConfig:
55
+ """Daemon runtime configuration."""
56
+
57
+ interval_minutes: int = 5
58
+ lookback_hours: int = 1
59
+
60
+
61
+ @dataclass
62
+ class Config:
63
+ """Main configuration container."""
64
+
65
+ influxdb: InfluxDBConfig = field(default_factory=InfluxDBConfig)
66
+ notification: NotificationConfig = field(default_factory=NotificationConfig)
67
+ season: SeasonConfig = field(default_factory=SeasonConfig)
68
+ daemon: DaemonConfig = field(default_factory=DaemonConfig)
69
+
70
+
71
+ def get_config_dir() -> Path:
72
+ """Get the XDG-compliant configuration directory."""
73
+ return Path(platformdirs.user_config_dir(APP_NAME, APP_AUTHOR))
74
+
75
+
76
+ def get_config_path() -> Path:
77
+ """Get the path to the configuration file."""
78
+ return get_config_dir() / "config.yaml"
79
+
80
+
81
+ def get_state_dir() -> Path:
82
+ """Get the XDG-compliant state directory for runtime state."""
83
+ return Path(platformdirs.user_state_dir(APP_NAME, APP_AUTHOR))
84
+
85
+
86
+ def get_state_path() -> Path:
87
+ """Get the path to the state file."""
88
+ return get_state_dir() / "state.yaml"
89
+
90
+
91
+ def ensure_config_dir() -> Path:
92
+ """Ensure the configuration directory exists."""
93
+ config_dir = get_config_dir()
94
+ config_dir.mkdir(parents=True, exist_ok=True)
95
+ return config_dir
96
+
97
+
98
+ def ensure_state_dir() -> Path:
99
+ """Ensure the state directory exists."""
100
+ state_dir = get_state_dir()
101
+ state_dir.mkdir(parents=True, exist_ok=True)
102
+ return state_dir
103
+
104
+
105
+ def _dict_to_influxdb_config(data: dict[str, Any]) -> InfluxDBConfig:
106
+ """Convert a dictionary to InfluxDBConfig."""
107
+ config = InfluxDBConfig()
108
+ if data:
109
+ for key in [
110
+ "url",
111
+ "token",
112
+ "org",
113
+ "bucket",
114
+ "measurement",
115
+ "temperature_field",
116
+ "domain_tag",
117
+ "location_tag",
118
+ "mac_tag",
119
+ "indoor_domain",
120
+ "outdoor_domain",
121
+ ]:
122
+ if key in data:
123
+ setattr(config, key, data[key])
124
+ return config
125
+
126
+
127
+ def _dict_to_notification_config(data: dict[str, Any]) -> NotificationConfig:
128
+ """Convert a dictionary to NotificationConfig."""
129
+ config = NotificationConfig()
130
+ if data:
131
+ for key in ["ntfy_topic", "ntfy_server"]:
132
+ if key in data:
133
+ setattr(config, key, data[key])
134
+ return config
135
+
136
+
137
+ def _dict_to_season_config(data: dict[str, Any]) -> SeasonConfig:
138
+ """Convert a dictionary to SeasonConfig."""
139
+ config = SeasonConfig()
140
+ if data:
141
+ for key in [
142
+ "summer_start_month",
143
+ "summer_start_day",
144
+ "summer_end_month",
145
+ "summer_end_day",
146
+ ]:
147
+ if key in data:
148
+ setattr(config, key, data[key])
149
+ return config
150
+
151
+
152
+ def _dict_to_daemon_config(data: dict[str, Any]) -> DaemonConfig:
153
+ """Convert a dictionary to DaemonConfig."""
154
+ config = DaemonConfig()
155
+ if data:
156
+ for key in ["interval_minutes", "lookback_hours"]:
157
+ if key in data:
158
+ setattr(config, key, data[key])
159
+ return config
160
+
161
+
162
+ def load_config(config_path: Path | None = None) -> Config:
163
+ """Load configuration from a YAML file.
164
+
165
+ Args:
166
+ config_path: Path to the configuration file. If None, uses the default XDG path.
167
+
168
+ Returns:
169
+ Config object with loaded settings, falling back to defaults for missing values.
170
+ """
171
+ if config_path is None:
172
+ config_path = get_config_path()
173
+
174
+ config = Config()
175
+
176
+ if not config_path.exists():
177
+ logger.warning(f"Configuration file not found at {config_path}, using defaults")
178
+ return config
179
+
180
+ try:
181
+ with open(config_path, "r") as f:
182
+ data = yaml.safe_load(f) or {}
183
+ except yaml.YAMLError as e:
184
+ logger.error(f"Error parsing configuration file: {e}")
185
+ raise
186
+
187
+ config.influxdb = _dict_to_influxdb_config(data.get("influxdb", {}))
188
+ config.notification = _dict_to_notification_config(data.get("notification", {}))
189
+ config.season = _dict_to_season_config(data.get("season", {}))
190
+ config.daemon = _dict_to_daemon_config(data.get("daemon", {}))
191
+
192
+ # Allow environment variable override for sensitive data
193
+ if os.environ.get("INFLUXDB_TOKEN"):
194
+ config.influxdb.token = os.environ["INFLUXDB_TOKEN"]
195
+
196
+ return config
197
+
198
+
199
+ def save_default_config(config_path: Path | None = None) -> Path:
200
+ """Save a default configuration file.
201
+
202
+ Args:
203
+ config_path: Path to save the configuration file. If None, uses the default XDG path.
204
+
205
+ Returns:
206
+ Path to the saved configuration file.
207
+ """
208
+ if config_path is None:
209
+ ensure_config_dir()
210
+ config_path = get_config_path()
211
+
212
+ default_config = {
213
+ "influxdb": {
214
+ "url": "https://influxdb.example.com",
215
+ "token": "your-influxdb-token-here",
216
+ "org": "my-org",
217
+ "bucket": "home",
218
+ "measurement": "environment",
219
+ "temperature_field": "temperature",
220
+ "domain_tag": "domain",
221
+ "location_tag": "location",
222
+ "mac_tag": "mac",
223
+ "indoor_domain": "indoors",
224
+ "outdoor_domain": "outdoors",
225
+ },
226
+ "notification": {
227
+ "ntfy_topic": "your-ntfy-topic-here",
228
+ "ntfy_server": "https://ntfy.sh",
229
+ },
230
+ "season": {
231
+ "summer_start_month": 4,
232
+ "summer_start_day": 8,
233
+ "summer_end_month": 11,
234
+ "summer_end_day": 11,
235
+ },
236
+ "daemon": {
237
+ "interval_minutes": 5,
238
+ "lookback_hours": 1,
239
+ },
240
+ }
241
+
242
+ with open(config_path, "w") as f:
243
+ yaml.dump(default_config, f, default_flow_style=False, sort_keys=False)
244
+
245
+ logger.info(f"Default configuration saved to {config_path}")
246
+ return config_path