spectrum-barometer 2.2.2__tar.gz → 2.2.5__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.
- {spectrum_barometer-2.2.2/spectrum_barometer.egg-info → spectrum_barometer-2.2.5}/PKG-INFO +1 -1
- {spectrum_barometer-2.2.2 → spectrum_barometer-2.2.5}/barometer/background.py +131 -96
- {spectrum_barometer-2.2.2 → spectrum_barometer-2.2.5}/barometer/data.py +29 -18
- {spectrum_barometer-2.2.2 → spectrum_barometer-2.2.5}/barometer/graphs.py +20 -9
- {spectrum_barometer-2.2.2 → spectrum_barometer-2.2.5}/barometer_logger.py +17 -7
- {spectrum_barometer-2.2.2 → spectrum_barometer-2.2.5}/pyproject.toml +1 -1
- {spectrum_barometer-2.2.2 → spectrum_barometer-2.2.5/spectrum_barometer.egg-info}/PKG-INFO +1 -1
- {spectrum_barometer-2.2.2 → spectrum_barometer-2.2.5}/web/routes.py +3 -10
- {spectrum_barometer-2.2.2 → spectrum_barometer-2.2.5}/web/templates/dashboard.html +1 -1
- {spectrum_barometer-2.2.2 → spectrum_barometer-2.2.5}/web/templates/stats.html +1 -1
- {spectrum_barometer-2.2.2 → spectrum_barometer-2.2.5}/LICENSE.txt +0 -0
- {spectrum_barometer-2.2.2 → spectrum_barometer-2.2.5}/MANIFEST.in +0 -0
- {spectrum_barometer-2.2.2 → spectrum_barometer-2.2.5}/README.md +0 -0
- {spectrum_barometer-2.2.2 → spectrum_barometer-2.2.5}/barometer/__init__.py +0 -0
- {spectrum_barometer-2.2.2 → spectrum_barometer-2.2.5}/barometer/actions.py +0 -0
- {spectrum_barometer-2.2.2 → spectrum_barometer-2.2.5}/barometer/paths.py +0 -0
- {spectrum_barometer-2.2.2 → spectrum_barometer-2.2.5}/setup.cfg +0 -0
- {spectrum_barometer-2.2.2 → spectrum_barometer-2.2.5}/spectrum_barometer.egg-info/SOURCES.txt +0 -0
- {spectrum_barometer-2.2.2 → spectrum_barometer-2.2.5}/spectrum_barometer.egg-info/dependency_links.txt +0 -0
- {spectrum_barometer-2.2.2 → spectrum_barometer-2.2.5}/spectrum_barometer.egg-info/entry_points.txt +0 -0
- {spectrum_barometer-2.2.2 → spectrum_barometer-2.2.5}/spectrum_barometer.egg-info/requires.txt +0 -0
- {spectrum_barometer-2.2.2 → spectrum_barometer-2.2.5}/spectrum_barometer.egg-info/top_level.txt +0 -0
- {spectrum_barometer-2.2.2 → spectrum_barometer-2.2.5}/web/__init__.py +0 -0
- {spectrum_barometer-2.2.2 → spectrum_barometer-2.2.5}/web/app.py +0 -0
- {spectrum_barometer-2.2.2 → spectrum_barometer-2.2.5}/web/static/refresh.js +0 -0
- {spectrum_barometer-2.2.2 → spectrum_barometer-2.2.5}/web/static/stabbypeng.png +0 -0
- {spectrum_barometer-2.2.2 → spectrum_barometer-2.2.5}/web/templates/base.html +0 -0
|
@@ -1,114 +1,148 @@
|
|
|
1
1
|
"""Background monitoring using threading with file-based state"""
|
|
2
2
|
import threading
|
|
3
3
|
import time
|
|
4
|
+
import os
|
|
4
5
|
import logging
|
|
6
|
+
import psutil
|
|
5
7
|
from barometer.paths import get_app_dir
|
|
6
8
|
from datetime import datetime
|
|
7
9
|
from pathlib import Path
|
|
8
10
|
|
|
11
|
+
|
|
9
12
|
def get_state_file():
|
|
10
13
|
"""Get path to state file"""
|
|
11
14
|
return get_app_dir() / 'monitor.state'
|
|
12
15
|
|
|
16
|
+
|
|
13
17
|
def get_interval_file():
|
|
14
18
|
"""Get path to interval file"""
|
|
15
19
|
return get_app_dir() / 'monitor.interval'
|
|
16
20
|
|
|
21
|
+
|
|
17
22
|
def get_pid_file():
|
|
18
23
|
"""Get path to PID file"""
|
|
19
24
|
return get_app_dir() / 'monitor.pid'
|
|
20
25
|
|
|
26
|
+
|
|
21
27
|
def get_owner_file():
|
|
22
|
-
"""Get path to owner file"""
|
|
28
|
+
"""Get path to owner file (kept around, not used for access control anymore)"""
|
|
23
29
|
return get_app_dir() / 'monitor.owner'
|
|
24
30
|
|
|
31
|
+
|
|
32
|
+
def _all_state_files():
|
|
33
|
+
return [get_state_file(), get_pid_file(), get_interval_file(), get_owner_file()]
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _cleanup_state_files():
|
|
37
|
+
for f in _all_state_files():
|
|
38
|
+
f.unlink(missing_ok=True)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _write_pid_record():
|
|
42
|
+
# store pid and create_time together so a reused pid can't be mistaken
|
|
43
|
+
pid = os.getpid()
|
|
44
|
+
try:
|
|
45
|
+
create_time = psutil.Process(pid).create_time()
|
|
46
|
+
except psutil.Error:
|
|
47
|
+
create_time = None
|
|
48
|
+
get_pid_file().write_text(f"{pid}:{create_time}")
|
|
49
|
+
return pid
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _read_pid_record():
|
|
53
|
+
pid_file = get_pid_file()
|
|
54
|
+
if not pid_file.exists():
|
|
55
|
+
return None, None
|
|
56
|
+
try:
|
|
57
|
+
text = pid_file.read_text().strip()
|
|
58
|
+
pid_str, _, ct_str = text.partition(':')
|
|
59
|
+
pid = int(pid_str)
|
|
60
|
+
create_time = float(ct_str) if ct_str and ct_str != 'None' else None
|
|
61
|
+
return pid, create_time
|
|
62
|
+
except (ValueError, OSError):
|
|
63
|
+
return None, None
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _process_still_matches(pid, create_time):
|
|
67
|
+
# pid alone isn't enough it could've been recycled by a totally different process
|
|
68
|
+
if pid is None:
|
|
69
|
+
return False
|
|
70
|
+
try:
|
|
71
|
+
proc = psutil.Process(pid)
|
|
72
|
+
except (psutil.NoSuchProcess, psutil.AccessDenied):
|
|
73
|
+
return False
|
|
74
|
+
|
|
75
|
+
if create_time is None:
|
|
76
|
+
return True
|
|
77
|
+
|
|
78
|
+
try:
|
|
79
|
+
return abs(proc.create_time() - create_time) < 1.0
|
|
80
|
+
except (psutil.NoSuchProcess, psutil.AccessDenied):
|
|
81
|
+
return False
|
|
82
|
+
|
|
83
|
+
|
|
25
84
|
def is_monitoring():
|
|
26
85
|
"""Check if monitoring thread is running"""
|
|
27
|
-
import os
|
|
28
|
-
import psutil
|
|
29
|
-
|
|
30
86
|
state_file = get_state_file()
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
if not state_file.exists() or not pid_file.exists():
|
|
87
|
+
|
|
88
|
+
if not state_file.exists():
|
|
34
89
|
return False
|
|
35
|
-
|
|
90
|
+
|
|
36
91
|
try:
|
|
37
92
|
state = state_file.read_text().strip()
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
# Check if state says running AND process exists
|
|
41
|
-
if state != 'running':
|
|
42
|
-
return False
|
|
43
|
-
|
|
44
|
-
# Check if the PID is still alive
|
|
45
|
-
if psutil.pid_exists(pid):
|
|
46
|
-
return True
|
|
47
|
-
else:
|
|
48
|
-
# Stale state files - clean them up
|
|
49
|
-
state_file.unlink(missing_ok=True)
|
|
50
|
-
pid_file.unlink(missing_ok=True)
|
|
51
|
-
get_interval_file().unlink(missing_ok=True)
|
|
52
|
-
get_owner_file().unlink(missing_ok=True)
|
|
53
|
-
return False
|
|
54
|
-
except:
|
|
93
|
+
except OSError:
|
|
55
94
|
return False
|
|
56
95
|
|
|
57
|
-
|
|
58
|
-
"""Check if monitoring is owned by the current process"""
|
|
59
|
-
import os
|
|
60
|
-
|
|
61
|
-
pid_file = get_pid_file()
|
|
62
|
-
if not pid_file.exists():
|
|
63
|
-
return False
|
|
64
|
-
|
|
65
|
-
try:
|
|
66
|
-
pid = int(pid_file.read_text().strip())
|
|
67
|
-
return pid == os.getpid()
|
|
68
|
-
except:
|
|
96
|
+
if state != 'running':
|
|
69
97
|
return False
|
|
70
98
|
|
|
99
|
+
pid, create_time = _read_pid_record()
|
|
100
|
+
|
|
101
|
+
if _process_still_matches(pid, create_time):
|
|
102
|
+
return True
|
|
103
|
+
|
|
104
|
+
# stale files left over from a crash - clean up
|
|
105
|
+
_cleanup_state_files()
|
|
106
|
+
return False
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def is_owned_by_current_process():
|
|
110
|
+
"""Kept for backwards compat, not used to gate start/stop anymore"""
|
|
111
|
+
pid, _ = _read_pid_record()
|
|
112
|
+
return pid == os.getpid()
|
|
113
|
+
|
|
114
|
+
|
|
71
115
|
def get_monitor_info():
|
|
72
116
|
"""Get monitoring status"""
|
|
73
|
-
import os
|
|
74
|
-
|
|
75
117
|
interval_file = get_interval_file()
|
|
76
|
-
pid_file = get_pid_file()
|
|
77
118
|
interval = 300
|
|
78
|
-
|
|
79
|
-
owned_by_me = False
|
|
80
|
-
|
|
119
|
+
|
|
81
120
|
if interval_file.exists():
|
|
82
121
|
try:
|
|
83
122
|
interval = int(interval_file.read_text().strip())
|
|
84
123
|
except ValueError:
|
|
85
124
|
pass
|
|
86
|
-
|
|
87
|
-
if pid_file.exists():
|
|
88
|
-
try:
|
|
89
|
-
pid = int(pid_file.read_text().strip())
|
|
90
|
-
owned_by_me = (pid == os.getpid())
|
|
91
|
-
except ValueError:
|
|
92
|
-
pass
|
|
93
|
-
|
|
125
|
+
|
|
94
126
|
running = is_monitoring()
|
|
95
|
-
|
|
127
|
+
pid, _ = _read_pid_record() if running else (None, None)
|
|
128
|
+
|
|
96
129
|
return {
|
|
97
130
|
'running': running,
|
|
98
|
-
'pid': pid
|
|
131
|
+
'pid': pid,
|
|
99
132
|
'interval': interval if running else None,
|
|
100
|
-
'owned_by_me': owned_by_me if running else False,
|
|
101
133
|
}
|
|
102
134
|
|
|
135
|
+
|
|
103
136
|
# Thread management (only exists in the process that started it)
|
|
104
137
|
_monitor_thread = None
|
|
105
138
|
|
|
139
|
+
|
|
106
140
|
def _monitor_loop(interval):
|
|
107
141
|
"""Internal monitoring loop"""
|
|
108
142
|
from barometer.actions import scrape_single_reading
|
|
109
|
-
|
|
143
|
+
|
|
110
144
|
state_file = get_state_file()
|
|
111
|
-
|
|
145
|
+
|
|
112
146
|
while state_file.exists() and state_file.read_text().strip() == 'running':
|
|
113
147
|
try:
|
|
114
148
|
result = scrape_single_reading()
|
|
@@ -118,94 +152,95 @@ def _monitor_loop(interval):
|
|
|
118
152
|
logging.error(f"Background scrape failed: {result['message']}")
|
|
119
153
|
except Exception as e:
|
|
120
154
|
logging.error(f"Monitor error: {e}")
|
|
121
|
-
|
|
155
|
+
|
|
122
156
|
# Sleep in small chunks so we can stop quickly
|
|
123
157
|
for _ in range(interval):
|
|
124
158
|
if not state_file.exists() or state_file.read_text().strip() != 'running':
|
|
125
159
|
break
|
|
126
160
|
time.sleep(1)
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
state_file.unlink(missing_ok=True)
|
|
161
|
+
|
|
162
|
+
_cleanup_state_files()
|
|
130
163
|
logging.info("Background monitoring stopped")
|
|
131
164
|
|
|
165
|
+
|
|
132
166
|
def start_monitoring(interval=300):
|
|
133
167
|
"""Start monitoring in background thread"""
|
|
134
168
|
global _monitor_thread
|
|
135
|
-
|
|
136
|
-
|
|
169
|
+
|
|
137
170
|
state_file = get_state_file()
|
|
138
171
|
interval_file = get_interval_file()
|
|
139
|
-
|
|
140
|
-
owner_file = get_owner_file()
|
|
141
|
-
|
|
172
|
+
|
|
142
173
|
# Check if already running (cleans up stale files automatically)
|
|
143
174
|
if is_monitoring():
|
|
144
|
-
pid =
|
|
175
|
+
pid, _ = _read_pid_record()
|
|
145
176
|
return {
|
|
146
177
|
'success': False,
|
|
147
178
|
'message': 'Monitoring is already running',
|
|
148
179
|
'pid': pid
|
|
149
180
|
}
|
|
150
|
-
|
|
181
|
+
|
|
151
182
|
try:
|
|
152
|
-
current_pid = os.getpid()
|
|
153
183
|
state_file.write_text('running')
|
|
154
184
|
interval_file.write_text(str(interval))
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
185
|
+
current_pid = _write_pid_record()
|
|
186
|
+
|
|
158
187
|
# Start thread
|
|
159
188
|
_monitor_thread = threading.Thread(
|
|
160
|
-
target=_monitor_loop,
|
|
161
|
-
args=(interval,),
|
|
189
|
+
target=_monitor_loop,
|
|
190
|
+
args=(interval,),
|
|
162
191
|
daemon=True,
|
|
163
192
|
name="BarometerMonitor"
|
|
164
193
|
)
|
|
165
194
|
_monitor_thread.start()
|
|
166
|
-
|
|
195
|
+
|
|
167
196
|
return {
|
|
168
197
|
'success': True,
|
|
169
198
|
'message': f'Monitoring started (interval: {interval}s)',
|
|
170
199
|
'pid': current_pid
|
|
171
200
|
}
|
|
172
201
|
except Exception as e:
|
|
173
|
-
|
|
174
|
-
pid_file.unlink(missing_ok=True)
|
|
175
|
-
owner_file.unlink(missing_ok=True)
|
|
202
|
+
_cleanup_state_files()
|
|
176
203
|
return {
|
|
177
204
|
'success': False,
|
|
178
205
|
'message': f'Failed to start: {e}',
|
|
179
206
|
'pid': None
|
|
180
207
|
}
|
|
181
208
|
|
|
182
|
-
|
|
183
|
-
|
|
209
|
+
|
|
210
|
+
def stop_monitoring(wait_seconds=3):
|
|
211
|
+
"""Stop background monitoring. Works from any process - signals the
|
|
212
|
+
real owner through the state file instead of requiring a pid match."""
|
|
184
213
|
state_file = get_state_file()
|
|
185
|
-
|
|
186
|
-
pid_file = get_pid_file()
|
|
187
|
-
owner_file = get_owner_file()
|
|
188
|
-
|
|
214
|
+
|
|
189
215
|
if not state_file.exists():
|
|
216
|
+
_cleanup_state_files()
|
|
190
217
|
return {
|
|
191
218
|
'success': False,
|
|
192
219
|
'message': 'Monitoring is not running'
|
|
193
220
|
}
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
221
|
+
|
|
222
|
+
pid, create_time = _read_pid_record()
|
|
223
|
+
|
|
224
|
+
if not _process_still_matches(pid, create_time):
|
|
225
|
+
# owner's already dead/recycled, nothing to signal
|
|
226
|
+
_cleanup_state_files()
|
|
197
227
|
return {
|
|
198
|
-
'success':
|
|
199
|
-
'message': '
|
|
228
|
+
'success': True,
|
|
229
|
+
'message': 'Cleared stale monitoring state (the process behind it was no longer running)'
|
|
200
230
|
}
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
231
|
+
|
|
232
|
+
state_file.write_text('stop')
|
|
233
|
+
|
|
234
|
+
pid_file = get_pid_file()
|
|
235
|
+
for _ in range(wait_seconds * 2):
|
|
236
|
+
if not pid_file.exists():
|
|
237
|
+
return {
|
|
238
|
+
'success': True,
|
|
239
|
+
'message': 'Monitoring stopped'
|
|
240
|
+
}
|
|
241
|
+
time.sleep(0.5)
|
|
242
|
+
|
|
208
243
|
return {
|
|
209
244
|
'success': True,
|
|
210
|
-
'message': '
|
|
245
|
+
'message': 'Stop signal sent - monitoring will halt within a few seconds'
|
|
211
246
|
}
|
|
@@ -1,42 +1,57 @@
|
|
|
1
|
-
from barometer.paths import get_data_dir
|
|
1
|
+
from barometer.paths import get_data_dir, get_archive_dir
|
|
2
2
|
import pandas
|
|
3
3
|
from datetime import datetime, timedelta
|
|
4
4
|
import logging
|
|
5
5
|
from barometer.paths import get_logs_dir
|
|
6
6
|
|
|
7
|
+
|
|
8
|
+
def _read_and_clean(csv_path):
|
|
9
|
+
#drop rows with bad timestamps instead of letting them become NaT
|
|
10
|
+
#and messing up sort order / "latest reading"
|
|
11
|
+
df = pandas.read_csv(csv_path)
|
|
12
|
+
df['timestamp'] = pandas.to_datetime(df['timestamp'], format='ISO8601', errors='coerce')
|
|
13
|
+
|
|
14
|
+
bad_rows = df['timestamp'].isna().sum()
|
|
15
|
+
if bad_rows:
|
|
16
|
+
logging.warning(
|
|
17
|
+
f"Dropped {bad_rows} row(s) with unparseable/missing timestamp from {csv_path}"
|
|
18
|
+
)
|
|
19
|
+
df = df.dropna(subset=['timestamp'])
|
|
20
|
+
|
|
21
|
+
return df
|
|
22
|
+
|
|
23
|
+
|
|
7
24
|
def load_data(include_archives=False):
|
|
8
25
|
data_file = get_data_dir() / 'readings.csv'
|
|
9
|
-
|
|
26
|
+
|
|
10
27
|
if not data_file.exists():
|
|
11
28
|
return None
|
|
12
|
-
|
|
29
|
+
|
|
13
30
|
dfs = []
|
|
14
|
-
df =
|
|
15
|
-
df['timestamp'] = pandas.to_datetime(df['timestamp'], format='ISO8601')
|
|
31
|
+
df = _read_and_clean(data_file)
|
|
16
32
|
dfs.append(df)
|
|
17
|
-
|
|
33
|
+
|
|
18
34
|
if include_archives:
|
|
19
35
|
archive_dir = get_archive_dir()
|
|
20
36
|
if archive_dir.exists():
|
|
21
37
|
for csv_file in archive_dir.rglob('*.csv'):
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
dfs.append(df_archive)
|
|
25
|
-
|
|
38
|
+
dfs.append(_read_and_clean(csv_file))
|
|
39
|
+
|
|
26
40
|
if not dfs:
|
|
27
41
|
return None
|
|
28
|
-
|
|
42
|
+
|
|
29
43
|
combined = pandas.concat(dfs, ignore_index=True)
|
|
30
44
|
combined = combined.sort_values('timestamp').reset_index(drop=True)
|
|
31
45
|
combined = combined.drop_duplicates(subset=['timestamp'], keep='last')
|
|
32
|
-
|
|
46
|
+
|
|
33
47
|
return combined
|
|
34
48
|
|
|
49
|
+
|
|
35
50
|
def setup_logging(verbose=False):
|
|
36
51
|
# configure logging
|
|
37
52
|
level = logging.DEBUG if verbose else logging.INFO
|
|
38
53
|
log_file = get_logs_dir() / 'barometer.log'
|
|
39
|
-
|
|
54
|
+
|
|
40
55
|
logging.basicConfig(
|
|
41
56
|
level=level,
|
|
42
57
|
format='%(asctime)s - %(levelname)s - %(message)s',
|
|
@@ -44,8 +59,4 @@ def setup_logging(verbose=False):
|
|
|
44
59
|
logging.FileHandler(log_file),
|
|
45
60
|
logging.StreamHandler()
|
|
46
61
|
]
|
|
47
|
-
)
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
62
|
+
)
|
|
@@ -12,7 +12,18 @@ from qbstyles import mpl_style
|
|
|
12
12
|
from pathlib import Path
|
|
13
13
|
|
|
14
14
|
|
|
15
|
-
mpl_style(dark=True)
|
|
15
|
+
mpl_style(dark=True, minor_ticks=False)
|
|
16
|
+
|
|
17
|
+
def _apply_x_axis_format(ax, days):
|
|
18
|
+
"""Show hours if generating one day"""
|
|
19
|
+
if days == 1:
|
|
20
|
+
ax.xaxis.set_major_locator(mdates.HourLocator(interval=1))
|
|
21
|
+
ax.xaxis.set_major_formatter(mdates.DateFormatter('%H:%M'))
|
|
22
|
+
|
|
23
|
+
else:
|
|
24
|
+
ax.xaxis.set_major_locator(mdates.AutoDateLocator())
|
|
25
|
+
ax.xaxis.set_major_formatter(mdates.DateFormatter('%m/%d %H:%M'))
|
|
26
|
+
|
|
16
27
|
def generate_line_graph(df, output, days):
|
|
17
28
|
"""Standard line graph"""
|
|
18
29
|
fig, ax = plt.subplots(figsize=(12, 6))
|
|
@@ -25,7 +36,7 @@ def generate_line_graph(df, output, days):
|
|
|
25
36
|
ax.set_title(f'Barometric Pressure - Last {days} Days', fontsize=14, fontweight='bold')
|
|
26
37
|
ax.grid(True, alpha=0.3)
|
|
27
38
|
ax.legend()
|
|
28
|
-
ax
|
|
39
|
+
_apply_x_axis_format(ax, days)
|
|
29
40
|
plt.xticks(rotation=45)
|
|
30
41
|
plt.tight_layout()
|
|
31
42
|
plt.savefig(output, dpi=150, bbox_inches='tight')
|
|
@@ -50,7 +61,7 @@ def generate_smooth_graph(df, output, days, window=12):
|
|
|
50
61
|
ax.set_title(f'Barometric Pressure with Trend - Last {days} Days', fontsize=14, fontweight='bold')
|
|
51
62
|
ax.grid(True, alpha=0.3)
|
|
52
63
|
ax.legend()
|
|
53
|
-
ax
|
|
64
|
+
_apply_x_axis_format(ax, days)
|
|
54
65
|
plt.xticks(rotation=45)
|
|
55
66
|
plt.tight_layout()
|
|
56
67
|
plt.savefig(output, dpi=150, bbox_inches='tight')
|
|
@@ -71,7 +82,7 @@ def generate_area_graph(df, output, days):
|
|
|
71
82
|
ax.set_title(f'Barometric Pressure Area - Last {days} Days', fontsize=14, fontweight='bold')
|
|
72
83
|
ax.grid(True, alpha=0.3)
|
|
73
84
|
ax.legend()
|
|
74
|
-
ax
|
|
85
|
+
_apply_x_axis_format(ax, days)
|
|
75
86
|
plt.xticks(rotation=45)
|
|
76
87
|
plt.tight_layout()
|
|
77
88
|
plt.savefig(output, dpi=150, bbox_inches='tight')
|
|
@@ -160,7 +171,7 @@ def generate_rate_of_change(df, output, days):
|
|
|
160
171
|
ax.set_ylabel('Pressure Change (hPa/hour)', fontsize=12)
|
|
161
172
|
ax.set_title(f'Rate of Pressure Change - Last {days} Days', fontsize=14, fontweight='bold')
|
|
162
173
|
ax.grid(True, alpha=0.3, axis='y')
|
|
163
|
-
ax
|
|
174
|
+
_apply_x_axis_format(ax, days)
|
|
164
175
|
plt.xticks(rotation=45)
|
|
165
176
|
|
|
166
177
|
# Legend
|
|
@@ -187,8 +198,8 @@ def generate_dashboard(df, output, days):
|
|
|
187
198
|
ax1.set_title('Pressure Over Time', fontweight='bold')
|
|
188
199
|
ax1.set_ylabel('Pressure (hPa)')
|
|
189
200
|
ax1.grid(True, alpha=0.3)
|
|
190
|
-
ax1.xaxis.set_major_formatter(mdates.DateFormatter('%m/%d'))
|
|
191
|
-
|
|
201
|
+
ax1.xaxis.set_major_formatter(mdates.DateFormatter('%H:%M' if days == 1 else '%m/%d'))
|
|
202
|
+
|
|
192
203
|
# 2. Distribution
|
|
193
204
|
ax2 = fig.add_subplot(gs[1, 0])
|
|
194
205
|
ax2.hist(df['pressure_hpa'], bins=20, color='#2E86AB', alpha=0.7, edgecolor='black')
|
|
@@ -249,9 +260,9 @@ def generate_dashboard(df, output, days):
|
|
|
249
260
|
|
|
250
261
|
def apply_theme(theme: str):
|
|
251
262
|
if theme == "dark":
|
|
252
|
-
mpl_style(dark=True)
|
|
263
|
+
mpl_style(dark=True, minor_ticks=False)
|
|
253
264
|
else:
|
|
254
|
-
mpl_style(dark=False)
|
|
265
|
+
mpl_style(dark=False, minor_ticks=False)
|
|
255
266
|
|
|
256
267
|
|
|
257
268
|
def generate_graph(days=7, output=None, graph_type='line',
|
|
@@ -6,6 +6,7 @@ import yaml
|
|
|
6
6
|
import time
|
|
7
7
|
import re
|
|
8
8
|
import click
|
|
9
|
+
import fcntl
|
|
9
10
|
from io import StringIO
|
|
10
11
|
from datetime import datetime, timedelta
|
|
11
12
|
import os
|
|
@@ -88,18 +89,27 @@ class BarometerScraper:
|
|
|
88
89
|
|
|
89
90
|
def save_reading(self, pressure):
|
|
90
91
|
# save pressure reading to CSV file
|
|
92
|
+
# locked so a concurrent scrape and background thread can't interleave writes
|
|
91
93
|
data_file = get_data_dir() / 'readings.csv'
|
|
92
|
-
|
|
94
|
+
timestamp = datetime.now().isoformat()
|
|
95
|
+
|
|
93
96
|
data = {
|
|
94
|
-
'timestamp': [
|
|
97
|
+
'timestamp': [timestamp],
|
|
95
98
|
'pressure_pa': [pressure],
|
|
96
99
|
'pressure_hpa': [pressure / 100]
|
|
97
100
|
}
|
|
98
|
-
|
|
99
101
|
df = pandas.DataFrame(data)
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
102
|
+
|
|
103
|
+
with open(data_file, 'a', newline='') as f:
|
|
104
|
+
fcntl.flock(f.fileno(), fcntl.LOCK_EX)
|
|
105
|
+
try:
|
|
106
|
+
write_header = f.tell() == 0
|
|
107
|
+
df.to_csv(f, mode='a', header=write_header, index=False)
|
|
108
|
+
f.flush()
|
|
109
|
+
os.fsync(f.fileno())
|
|
110
|
+
finally:
|
|
111
|
+
fcntl.flock(f.fileno(), fcntl.LOCK_UN)
|
|
112
|
+
|
|
103
113
|
return True
|
|
104
114
|
|
|
105
115
|
|
|
@@ -124,7 +134,7 @@ def cli(ctx, verbose):
|
|
|
124
134
|
@cli.command()
|
|
125
135
|
def version():
|
|
126
136
|
"""Show version information"""
|
|
127
|
-
click.echo("spectrum-barometer version 2.2.
|
|
137
|
+
click.echo("spectrum-barometer version 2.2.5! (that was fast :3)")
|
|
128
138
|
|
|
129
139
|
|
|
130
140
|
@cli.command()
|
|
@@ -24,7 +24,7 @@ def dashboard():
|
|
|
24
24
|
theme=theme,
|
|
25
25
|
graph_ts=int(time()),
|
|
26
26
|
monitor=monitor_info,
|
|
27
|
-
can_control_monitor=
|
|
27
|
+
can_control_monitor=True,
|
|
28
28
|
graph_types=['line', 'smooth', 'area', 'daily', 'distribution', 'change', 'dashboard'],
|
|
29
29
|
)
|
|
30
30
|
|
|
@@ -134,18 +134,11 @@ def monitor_start():
|
|
|
134
134
|
|
|
135
135
|
@bp.route("/monitor/stop", methods=["POST"])
|
|
136
136
|
def monitor_stop():
|
|
137
|
-
from barometer.background import is_owned_by_current_process
|
|
138
|
-
|
|
139
|
-
# check if we can stop it
|
|
140
|
-
if not is_owned_by_current_process():
|
|
141
|
-
flash("Cannot stop monitoring - it's running in a different process (e.g., CLI). Stop it from where you started it", "error")
|
|
142
|
-
return redirect(url_for("main.dashboard"))
|
|
143
|
-
|
|
144
137
|
result = stop_monitoring()
|
|
145
|
-
|
|
138
|
+
|
|
146
139
|
if result['success']:
|
|
147
140
|
flash(result['message'], "success")
|
|
148
141
|
else:
|
|
149
142
|
flash(result['message'], "error")
|
|
150
|
-
|
|
143
|
+
|
|
151
144
|
return redirect(url_for("main.dashboard"))
|
|
@@ -275,7 +275,7 @@
|
|
|
275
275
|
<nav>
|
|
276
276
|
<a href="{{ url_for('main.dashboard') }}" >Dashboard</a>
|
|
277
277
|
<a href="{{ url_for('main.stats') }}">Statistics</a>
|
|
278
|
-
<a class="version" href="https://github.com/BobaTeagrl/spectrum-barometer">V 2.2.
|
|
278
|
+
<a class="version" href="https://github.com/BobaTeagrl/spectrum-barometer">V 2.2.5 :3</a>
|
|
279
279
|
</nav>
|
|
280
280
|
|
|
281
281
|
<!-- Flash messages -->
|
|
@@ -69,7 +69,7 @@
|
|
|
69
69
|
<nav>
|
|
70
70
|
<a href="{{ url_for('main.dashboard') }}">Dashboard</a>
|
|
71
71
|
<a href="{{ url_for('main.stats') }}">Statistics</a>
|
|
72
|
-
<a class="version" href="https://github.com/BobaTeagrl/spectrum-barometer">V 2.2.
|
|
72
|
+
<a class="version" href="https://github.com/BobaTeagrl/spectrum-barometer">V 2.2.5 :3</a>
|
|
73
73
|
|
|
74
74
|
</nav>
|
|
75
75
|
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{spectrum_barometer-2.2.2 → spectrum_barometer-2.2.5}/spectrum_barometer.egg-info/SOURCES.txt
RENAMED
|
File without changes
|
|
File without changes
|
{spectrum_barometer-2.2.2 → spectrum_barometer-2.2.5}/spectrum_barometer.egg-info/entry_points.txt
RENAMED
|
File without changes
|
{spectrum_barometer-2.2.2 → spectrum_barometer-2.2.5}/spectrum_barometer.egg-info/requires.txt
RENAMED
|
File without changes
|
{spectrum_barometer-2.2.2 → spectrum_barometer-2.2.5}/spectrum_barometer.egg-info/top_level.txt
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|