window-time 0.1.0__tar.gz
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-0.1.0/PKG-INFO +32 -0
- window_time-0.1.0/pyproject.toml +68 -0
- window_time-0.1.0/src/window_time/__init__.py +3 -0
- window_time-0.1.0/src/window_time/__main__.py +8 -0
- window_time-0.1.0/src/window_time/cli.py +361 -0
- window_time-0.1.0/src/window_time/config.py +246 -0
- window_time-0.1.0/src/window_time/daemon.py +156 -0
- window_time-0.1.0/src/window_time/influxdb_client.py +155 -0
- window_time-0.1.0/src/window_time/notifications.py +118 -0
- window_time-0.1.0/src/window_time/season.py +68 -0
- window_time-0.1.0/src/window_time/temperature_analysis.py +292 -0
- window_time-0.1.0/src/window_time/window_state.py +341 -0
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: window-time
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: A daemon that monitors indoor/outdoor temperatures and notifies when to open or close windows
|
|
5
|
+
Project-URL: Homepage, https://github.com/gene1wood/window-time
|
|
6
|
+
Project-URL: Repository, https://github.com/gene1wood/window-time
|
|
7
|
+
Project-URL: Issues, https://github.com/gene1wood/window-time/issues
|
|
8
|
+
Author: Gene Wood
|
|
9
|
+
License-Expression: MIT
|
|
10
|
+
Keywords: home-automation,influxdb,notifications,temperature
|
|
11
|
+
Classifier: Development Status :: 4 - Beta
|
|
12
|
+
Classifier: Environment :: Console
|
|
13
|
+
Classifier: Intended Audience :: End Users/Desktop
|
|
14
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
15
|
+
Classifier: Operating System :: OS Independent
|
|
16
|
+
Classifier: Programming Language :: Python :: 3
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
21
|
+
Classifier: Topic :: Home Automation
|
|
22
|
+
Requires-Python: >=3.10
|
|
23
|
+
Requires-Dist: influxdb-client>=1.36.0
|
|
24
|
+
Requires-Dist: numpy>=1.24.0
|
|
25
|
+
Requires-Dist: platformdirs>=4.0.0
|
|
26
|
+
Requires-Dist: pyyaml>=6.0
|
|
27
|
+
Requires-Dist: requests>=2.28.0
|
|
28
|
+
Provides-Extra: dev
|
|
29
|
+
Requires-Dist: black>=23.0.0; extra == 'dev'
|
|
30
|
+
Requires-Dist: pytest-cov>=4.0.0; extra == 'dev'
|
|
31
|
+
Requires-Dist: pytest>=7.0.0; extra == 'dev'
|
|
32
|
+
Requires-Dist: ruff>=0.1.0; extra == 'dev'
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "window-time"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "A daemon that monitors indoor/outdoor temperatures and notifies when to open or close windows"
|
|
9
|
+
license = "MIT"
|
|
10
|
+
requires-python = ">=3.10"
|
|
11
|
+
authors = [
|
|
12
|
+
{ name = "Gene Wood" }
|
|
13
|
+
]
|
|
14
|
+
keywords = ["temperature", "home-automation", "influxdb", "notifications"]
|
|
15
|
+
classifiers = [
|
|
16
|
+
"Development Status :: 4 - Beta",
|
|
17
|
+
"Environment :: Console",
|
|
18
|
+
"Intended Audience :: End Users/Desktop",
|
|
19
|
+
"License :: OSI Approved :: MIT License",
|
|
20
|
+
"Operating System :: OS Independent",
|
|
21
|
+
"Programming Language :: Python :: 3",
|
|
22
|
+
"Programming Language :: Python :: 3.9",
|
|
23
|
+
"Programming Language :: Python :: 3.10",
|
|
24
|
+
"Programming Language :: Python :: 3.11",
|
|
25
|
+
"Programming Language :: Python :: 3.12",
|
|
26
|
+
"Topic :: Home Automation",
|
|
27
|
+
]
|
|
28
|
+
dependencies = [
|
|
29
|
+
"influxdb-client>=1.36.0",
|
|
30
|
+
"platformdirs>=4.0.0",
|
|
31
|
+
"pyyaml>=6.0",
|
|
32
|
+
"requests>=2.28.0",
|
|
33
|
+
"numpy>=1.24.0",
|
|
34
|
+
]
|
|
35
|
+
|
|
36
|
+
[project.optional-dependencies]
|
|
37
|
+
dev = [
|
|
38
|
+
"pytest>=7.0.0",
|
|
39
|
+
"pytest-cov>=4.0.0",
|
|
40
|
+
"black>=23.0.0",
|
|
41
|
+
"ruff>=0.1.0",
|
|
42
|
+
]
|
|
43
|
+
|
|
44
|
+
[project.scripts]
|
|
45
|
+
window-time = "window_time.cli:main"
|
|
46
|
+
|
|
47
|
+
[project.urls]
|
|
48
|
+
Homepage = "https://github.com/gene1wood/window-time"
|
|
49
|
+
Repository = "https://github.com/gene1wood/window-time"
|
|
50
|
+
Issues = "https://github.com/gene1wood/window-time/issues"
|
|
51
|
+
|
|
52
|
+
[tool.hatch.build.targets.sdist]
|
|
53
|
+
include = [
|
|
54
|
+
"/src",
|
|
55
|
+
]
|
|
56
|
+
|
|
57
|
+
[tool.hatch.build.targets.wheel]
|
|
58
|
+
packages = ["src/window_time"]
|
|
59
|
+
|
|
60
|
+
[tool.ruff]
|
|
61
|
+
line-length = 100
|
|
62
|
+
target-version = "py310"
|
|
63
|
+
|
|
64
|
+
[tool.ruff.lint]
|
|
65
|
+
select = ["E", "F", "I", "W"]
|
|
66
|
+
|
|
67
|
+
[tool.pytest.ini_options]
|
|
68
|
+
testpaths = ["tests"]
|
|
@@ -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())
|