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/__init__.py
ADDED
window_time/__main__.py
ADDED
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
|