spectrum-barometer 1.0.3__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.
barometer_logger.py ADDED
@@ -0,0 +1,831 @@
1
+ import logging
2
+ import pandas
3
+ import requests
4
+ import urllib3
5
+ import yaml
6
+ import time
7
+ import re
8
+ import click
9
+ from io import StringIO
10
+ from datetime import datetime, timedelta
11
+ import os
12
+ import shutil
13
+ import matplotlib
14
+ matplotlib.use('Agg') # headless backend for background operation
15
+ import matplotlib.pyplot as plt
16
+ import matplotlib.dates as mdates
17
+ from matplotlib.patches import Rectangle
18
+ import numpy as np
19
+ from pathlib import Path
20
+ # disable SSL warnings
21
+ urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
22
+
23
+ # save everything to /spectrum-barometer
24
+ def get_app_dir():
25
+ """Get the application directory, create if needed"""
26
+ app_dir = Path.home() / 'spectrum-barometer'
27
+ app_dir.mkdir(exist_ok=True)
28
+ return app_dir
29
+
30
+ def get_config_file():
31
+ return get_app_dir() / 'config.yaml'
32
+
33
+ def get_data_dir():
34
+ data_dir = get_app_dir() / 'data'
35
+ data_dir.mkdir(exist_ok=True)
36
+ return data_dir
37
+
38
+ def get_logs_dir():
39
+ logs_dir = get_app_dir() / 'logs'
40
+ logs_dir.mkdir(exist_ok=True)
41
+ return logs_dir
42
+
43
+ def get_graphs_dir():
44
+ graphs_dir = get_app_dir() / 'graphs'
45
+ graphs_dir.mkdir(exist_ok=True)
46
+ return graphs_dir
47
+
48
+ def get_archive_dir():
49
+ archive_dir = get_app_dir() / 'archive'
50
+ archive_dir.mkdir(exist_ok=True)
51
+ return archive_dir
52
+
53
+
54
+ class BarometerScraper:
55
+ def __init__(self, config_file=None):
56
+ # load config file
57
+ if config_file is None:
58
+ config_file = get_config_file()
59
+
60
+ with open(config_file, 'r') as file:
61
+ config = yaml.safe_load(file)
62
+
63
+ # set values from config
64
+ self.url = config['url']
65
+ self.username = config['username']
66
+ self.password = config['password']
67
+ self.session = requests.Session()
68
+ self.session.verify = False
69
+
70
+ def login(self):
71
+ #connect to router
72
+ try:
73
+ response = self.session.get(
74
+ self.url,
75
+ auth=(self.username, self.password),
76
+ timeout=10
77
+ )
78
+
79
+ if response.status_code == 200:
80
+ return response
81
+ elif response.status_code == 401:
82
+ logging.error("Authentication failed, check username/password")
83
+ return None
84
+ else:
85
+ logging.error(f"Failed to access page. Status code: {response.status_code}")
86
+ return None
87
+
88
+ except requests.exceptions.RequestException as e:
89
+ logging.error(f"Connection error: {e}")
90
+ return None
91
+
92
+ def extract_barometer_value(self, html_content):
93
+ # extract the barometer pressure value using pandas
94
+ try:
95
+ tables = pandas.read_html(StringIO(html_content))
96
+
97
+ if not tables:
98
+ logging.error("No tables found")
99
+ return None
100
+
101
+ df = tables[0]
102
+ barometer_row = df[df['Field'] == 'Barometer Value']
103
+
104
+ if barometer_row.empty:
105
+ logging.error("Could not find 'Barometer Value' in table")
106
+ return None
107
+
108
+ setting_value = barometer_row['Setting'].values[0]
109
+ match = re.search(r'(\d+)', setting_value)
110
+
111
+ if match:
112
+ pressure = int(match.group(1))
113
+ return pressure
114
+ else:
115
+ logging.error(f"Could not parse pressure from: {setting_value}")
116
+ return None
117
+
118
+ except Exception as e:
119
+ logging.error(f"Error parsing HTML: {e}")
120
+ return None
121
+
122
+ def save_reading(self, pressure):
123
+ # save pressure reading to CSV file
124
+ data_file = get_data_dir() / 'readings.csv'
125
+
126
+ data = {
127
+ 'timestamp': [datetime.now().isoformat()],
128
+ 'pressure_pa': [pressure],
129
+ 'pressure_hpa': [pressure / 100]
130
+ }
131
+
132
+ df = pandas.DataFrame(data)
133
+ file_exists = data_file.exists()
134
+ df.to_csv(data_file, mode='a', header=not file_exists, index=False)
135
+
136
+ return True
137
+
138
+
139
+ def setup_logging(verbose=False):
140
+ # configure logging
141
+ level = logging.DEBUG if verbose else logging.INFO
142
+ log_file = get_logs_dir() / 'barometer.log'
143
+
144
+ logging.basicConfig(
145
+ level=level,
146
+ format='%(asctime)s - %(levelname)s - %(message)s',
147
+ handlers=[
148
+ logging.FileHandler(log_file),
149
+ logging.StreamHandler()
150
+ ]
151
+ )
152
+
153
+ #Load readings from CSV, optionally including archives
154
+
155
+ def load_data(include_archives=False):
156
+ data_file = get_data_dir() / 'readings.csv'
157
+
158
+ if not data_file.exists():
159
+ return None
160
+
161
+ dfs = []
162
+ df = pandas.read_csv(data_file)
163
+ df['timestamp'] = pandas.to_datetime(df['timestamp'])
164
+ dfs.append(df)
165
+
166
+ if include_archives:
167
+ archive_dir = get_archive_dir()
168
+ if archive_dir.exists():
169
+ for csv_file in archive_dir.rglob('*.csv'):
170
+ df_archive = pandas.read_csv(csv_file)
171
+ df_archive['timestamp'] = pandas.to_datetime(df_archive['timestamp'])
172
+ dfs.append(df_archive)
173
+
174
+ if not dfs:
175
+ return None
176
+
177
+ combined = pandas.concat(dfs, ignore_index=True)
178
+ combined = combined.sort_values('timestamp').reset_index(drop=True)
179
+ combined = combined.drop_duplicates(subset=['timestamp'], keep='last')
180
+
181
+ return combined
182
+
183
+
184
+ def generate_line_graph(df, output, days):
185
+ """Standard line graph"""
186
+ fig, ax = plt.subplots(figsize=(12, 6))
187
+
188
+ ax.plot(df['timestamp'], df['pressure_hpa'],
189
+ linewidth=2, color='#2E86AB', label='Barometric Pressure')
190
+
191
+ ax.set_xlabel('Time', fontsize=12)
192
+ ax.set_ylabel('Pressure (hPa)', fontsize=12)
193
+ ax.set_title(f'Barometric Pressure - Last {days} Days', fontsize=14, fontweight='bold')
194
+ ax.grid(True, alpha=0.3)
195
+ ax.legend()
196
+ ax.xaxis.set_major_formatter(mdates.DateFormatter('%m/%d %H:%M'))
197
+ plt.xticks(rotation=45)
198
+ plt.tight_layout()
199
+ plt.savefig(output, dpi=150, bbox_inches='tight')
200
+ plt.close()
201
+
202
+
203
+ def generate_smooth_graph(df, output, days, window=12):
204
+ """Line graph with rolling average"""
205
+ fig, ax = plt.subplots(figsize=(12, 6))
206
+
207
+ # Original data
208
+ ax.plot(df['timestamp'], df['pressure_hpa'],
209
+ linewidth=1, color='#2E86AB', alpha=0.4, label='Raw Data')
210
+
211
+ # Rolling average
212
+ df['rolling_avg'] = df['pressure_hpa'].rolling(window=window, center=True).mean()
213
+ ax.plot(df['timestamp'], df['rolling_avg'],
214
+ linewidth=2.5, color='#A23B72', label=f'{window}-point Moving Average')
215
+
216
+ ax.set_xlabel('Time', fontsize=12)
217
+ ax.set_ylabel('Pressure (hPa)', fontsize=12)
218
+ ax.set_title(f'Barometric Pressure with Trend - Last {days} Days', fontsize=14, fontweight='bold')
219
+ ax.grid(True, alpha=0.3)
220
+ ax.legend()
221
+ ax.xaxis.set_major_formatter(mdates.DateFormatter('%m/%d %H:%M'))
222
+ plt.xticks(rotation=45)
223
+ plt.tight_layout()
224
+ plt.savefig(output, dpi=150, bbox_inches='tight')
225
+ plt.close()
226
+
227
+
228
+ def generate_area_graph(df, output, days):
229
+ """Filled area chart"""
230
+ fig, ax = plt.subplots(figsize=(12, 6))
231
+
232
+ ax.fill_between(df['timestamp'], df['pressure_hpa'],
233
+ alpha=0.4, color='#2E86AB', label='Pressure')
234
+ ax.plot(df['timestamp'], df['pressure_hpa'],
235
+ linewidth=2, color='#1A5F7A', label='Trend Line')
236
+
237
+ ax.set_xlabel('Time', fontsize=12)
238
+ ax.set_ylabel('Pressure (hPa)', fontsize=12)
239
+ ax.set_title(f'Barometric Pressure Area - Last {days} Days', fontsize=14, fontweight='bold')
240
+ ax.grid(True, alpha=0.3)
241
+ ax.legend()
242
+ ax.xaxis.set_major_formatter(mdates.DateFormatter('%m/%d %H:%M'))
243
+ plt.xticks(rotation=45)
244
+ plt.tight_layout()
245
+ plt.savefig(output, dpi=150, bbox_inches='tight')
246
+ plt.close()
247
+
248
+
249
+ def generate_daily_summary(df, output, days):
250
+ """Daily min/max/avg bars"""
251
+ # Group by date
252
+ df['date'] = df['timestamp'].dt.date
253
+ daily = df.groupby('date').agg({
254
+ 'pressure_hpa': ['min', 'max', 'mean']
255
+ }).reset_index()
256
+ daily.columns = ['date', 'min', 'max', 'mean']
257
+
258
+ fig, ax = plt.subplots(figsize=(12, 6))
259
+
260
+ x = range(len(daily))
261
+
262
+ # Draw bars for range
263
+ for i, row in daily.iterrows():
264
+ ax.plot([i, i], [row['min'], row['max']],
265
+ color='#2E86AB', linewidth=8, alpha=0.3)
266
+ ax.scatter(i, row['mean'], color='#A23B72', s=50, zorder=3)
267
+
268
+ ax.set_xlabel('Date', fontsize=12)
269
+ ax.set_ylabel('Pressure (hPa)', fontsize=12)
270
+ ax.set_title(f'Daily Pressure Summary - Last {days} Days', fontsize=14, fontweight='bold')
271
+ ax.set_xticks(x)
272
+ ax.set_xticklabels([d.strftime('%m/%d') for d in daily['date']], rotation=45)
273
+ ax.grid(True, alpha=0.3, axis='y')
274
+
275
+ # Legend
276
+ from matplotlib.lines import Line2D
277
+ legend_elements = [
278
+ Line2D([0], [0], color='#2E86AB', linewidth=8, alpha=0.3, label='Min-Max Range'),
279
+ Line2D([0], [0], marker='o', color='w', markerfacecolor='#A23B72',
280
+ markersize=8, label='Daily Average')
281
+ ]
282
+ ax.legend(handles=legend_elements)
283
+
284
+ plt.tight_layout()
285
+ plt.savefig(output, dpi=150, bbox_inches='tight')
286
+ plt.close()
287
+
288
+
289
+ def generate_distribution(df, output, days):
290
+ """Histogram of pressure values"""
291
+ fig, ax = plt.subplots(figsize=(12, 6))
292
+
293
+ ax.hist(df['pressure_hpa'], bins=30, color='#2E86AB', alpha=0.7, edgecolor='black')
294
+
295
+ # Add mean and median lines
296
+ mean_val = df['pressure_hpa'].mean()
297
+ median_val = df['pressure_hpa'].median()
298
+
299
+ ax.axvline(mean_val, color='#A23B72', linestyle='--', linewidth=2, label=f'Mean: {mean_val:.2f} hPa')
300
+ ax.axvline(median_val, color='#F18F01', linestyle='--', linewidth=2, label=f'Median: {median_val:.2f} hPa')
301
+
302
+ ax.set_xlabel('Pressure (hPa)', fontsize=12)
303
+ ax.set_ylabel('Frequency', fontsize=12)
304
+ ax.set_title(f'Pressure Distribution - Last {days} Days', fontsize=14, fontweight='bold')
305
+ ax.legend()
306
+ ax.grid(True, alpha=0.3, axis='y')
307
+
308
+ plt.tight_layout()
309
+ plt.savefig(output, dpi=150, bbox_inches='tight')
310
+ plt.close()
311
+
312
+
313
+ def generate_rate_of_change(df, output, days):
314
+ """Pressure change over time"""
315
+ # Calculate hourly change
316
+ df = df.sort_values('timestamp')
317
+ df['change'] = df['pressure_hpa'].diff()
318
+ df['hours_diff'] = df['timestamp'].diff().dt.total_seconds() / 3600
319
+ df['hourly_change'] = df['change'] / df['hours_diff']
320
+
321
+ fig, ax = plt.subplots(figsize=(12, 6))
322
+
323
+ colors = ['#EF476F' if x < 0 else '#06D6A0' for x in df['hourly_change']]
324
+ ax.bar(df['timestamp'], df['hourly_change'], color=colors, alpha=0.6, width=0.01)
325
+
326
+ ax.axhline(y=0, color='black', linestyle='-', linewidth=0.5)
327
+ ax.set_xlabel('Time', fontsize=12)
328
+ ax.set_ylabel('Pressure Change (hPa/hour)', fontsize=12)
329
+ ax.set_title(f'Rate of Pressure Change - Last {days} Days', fontsize=14, fontweight='bold')
330
+ ax.grid(True, alpha=0.3, axis='y')
331
+ ax.xaxis.set_major_formatter(mdates.DateFormatter('%m/%d %H:%M'))
332
+ plt.xticks(rotation=45)
333
+
334
+ # Legend
335
+ from matplotlib.patches import Patch
336
+ legend_elements = [
337
+ Patch(facecolor='#06D6A0', alpha=0.6, label='Rising'),
338
+ Patch(facecolor='#EF476F', alpha=0.6, label='Falling')
339
+ ]
340
+ ax.legend(handles=legend_elements)
341
+
342
+ plt.tight_layout()
343
+ plt.savefig(output, dpi=150, bbox_inches='tight')
344
+ plt.close()
345
+
346
+
347
+ def generate_dashboard(df, output, days):
348
+ """Multi-panel dashboard view"""
349
+ fig = plt.figure(figsize=(16, 10))
350
+ gs = fig.add_gridspec(3, 2, hspace=0.3, wspace=0.3)
351
+
352
+ # 1. Main time series (top, full width)
353
+ ax1 = fig.add_subplot(gs[0, :])
354
+ ax1.plot(df['timestamp'], df['pressure_hpa'], linewidth=2, color='#2E86AB')
355
+ ax1.set_title('Pressure Over Time', fontweight='bold')
356
+ ax1.set_ylabel('Pressure (hPa)')
357
+ ax1.grid(True, alpha=0.3)
358
+ ax1.xaxis.set_major_formatter(mdates.DateFormatter('%m/%d'))
359
+
360
+ # 2. Distribution
361
+ ax2 = fig.add_subplot(gs[1, 0])
362
+ ax2.hist(df['pressure_hpa'], bins=20, color='#2E86AB', alpha=0.7, edgecolor='black')
363
+ ax2.set_title('Distribution', fontweight='bold')
364
+ ax2.set_xlabel('Pressure (hPa)')
365
+ ax2.set_ylabel('Frequency')
366
+ ax2.grid(True, alpha=0.3, axis='y')
367
+
368
+ # 3. Rate of change
369
+ ax3 = fig.add_subplot(gs[1, 1])
370
+ df_sorted = df.sort_values('timestamp')
371
+ df_sorted['change'] = df_sorted['pressure_hpa'].diff()
372
+ colors = ['#EF476F' if x < 0 else '#06D6A0' for x in df_sorted['change']]
373
+ ax3.bar(range(len(df_sorted)), df_sorted['change'], color=colors, alpha=0.6)
374
+ ax3.axhline(y=0, color='black', linestyle='-', linewidth=0.5)
375
+ ax3.set_title('Reading-to-Reading Change', fontweight='bold')
376
+ ax3.set_ylabel('Change (hPa)')
377
+ ax3.grid(True, alpha=0.3, axis='y')
378
+
379
+ # 4. Statistics box
380
+ ax4 = fig.add_subplot(gs[2, 0])
381
+ ax4.axis('off')
382
+ stats_text = f"""
383
+ STATISTICS
384
+ ──────────────────
385
+ Current: {df['pressure_hpa'].iloc[-1]:.2f} hPa
386
+ Average: {df['pressure_hpa'].mean():.2f} hPa
387
+ Minimum: {df['pressure_hpa'].min():.2f} hPa
388
+ Maximum: {df['pressure_hpa'].max():.2f} hPa
389
+ Range: {df['pressure_hpa'].max() - df['pressure_hpa'].min():.2f} hPa
390
+ Std Dev: {df['pressure_hpa'].std():.2f} hPa
391
+ """
392
+ ax4.text(0.1, 0.5, stats_text, fontsize=11, verticalalignment='center',
393
+ fontfamily='monospace', bbox=dict(boxstyle='round', facecolor='wheat', alpha=0.3))
394
+
395
+ # 5. Trend indicator
396
+ ax5 = fig.add_subplot(gs[2, 1])
397
+ ax5.axis('off')
398
+
399
+ # Calculate 24h trend
400
+ if len(df) > 1:
401
+ recent_avg = df.tail(min(12, len(df)))['pressure_hpa'].mean()
402
+ older_avg = df.head(min(12, len(df)))['pressure_hpa'].mean()
403
+ trend = recent_avg - older_avg
404
+
405
+ trend_text = "RISING ↗" if trend > 0.5 else "FALLING ↘" if trend < -0.5 else "STABLE →"
406
+ trend_color = '#06D6A0' if trend > 0.5 else '#EF476F' if trend < -0.5 else '#FFD166'
407
+
408
+ ax5.text(0.5, 0.6, 'TREND', fontsize=14, ha='center', fontweight='bold')
409
+ ax5.text(0.5, 0.4, trend_text, fontsize=24, ha='center',
410
+ color=trend_color, fontweight='bold')
411
+ ax5.text(0.5, 0.2, f'{trend:+.2f} hPa', fontsize=12, ha='center')
412
+
413
+ fig.suptitle(f'Barometer Dashboard - Last {days} Days', fontsize=16, fontweight='bold')
414
+ plt.savefig(output, dpi=150, bbox_inches='tight')
415
+ plt.close()
416
+
417
+
418
+
419
+ def generate_graph(days=7, output=None, graph_type='line', include_archives=False):
420
+ """Generate pressure graph from stored data"""
421
+ if output is None:
422
+ output = get_graphs_dir() / 'pressure.png'
423
+ else:
424
+ output = Path(output)
425
+
426
+ df = load_data(include_archives=include_archives)
427
+
428
+ if df is None or df.empty:
429
+ click.echo("No data available to graph")
430
+ return False
431
+
432
+ cutoff = datetime.now() - timedelta(days=days)
433
+ df_filtered = df[df['timestamp'] > cutoff].copy()
434
+
435
+ if df_filtered.empty:
436
+ click.echo(f"No data available for the last {days} days")
437
+ return False
438
+
439
+ output.parent.mkdir(parents=True, exist_ok=True)
440
+
441
+ try:
442
+ if graph_type == 'line':
443
+ generate_line_graph(df_filtered, str(output), days)
444
+ elif graph_type == 'smooth':
445
+ generate_smooth_graph(df_filtered, str(output), days)
446
+ elif graph_type == 'area':
447
+ generate_area_graph(df_filtered, str(output), days)
448
+ elif graph_type == 'daily':
449
+ generate_daily_summary(df_filtered, str(output), days)
450
+ elif graph_type == 'distribution':
451
+ generate_distribution(df_filtered, str(output), days)
452
+ elif graph_type == 'change':
453
+ generate_rate_of_change(df_filtered, str(output), days)
454
+ elif graph_type == 'dashboard':
455
+ generate_dashboard(df_filtered, str(output), days)
456
+ elif graph_type == 'all':
457
+ base_name = output.stem
458
+ ext = output.suffix or '.png'
459
+
460
+ generate_line_graph(df_filtered, str(output.parent / f'{base_name}_line{ext}'), days)
461
+ generate_smooth_graph(df_filtered, str(output.parent / f'{base_name}_smooth{ext}'), days)
462
+ generate_area_graph(df_filtered, str(output.parent / f'{base_name}_area{ext}'), days)
463
+ generate_daily_summary(df_filtered, str(output.parent / f'{base_name}_daily{ext}'), days)
464
+ generate_distribution(df_filtered, str(output.parent / f'{base_name}_distribution{ext}'), days)
465
+ generate_rate_of_change(df_filtered, str(output.parent / f'{base_name}_change{ext}'), days)
466
+ generate_dashboard(df_filtered, str(output.parent / f'{base_name}_dashboard{ext}'), days)
467
+
468
+ click.echo(f"Generated 7 graphs in {output.parent}")
469
+ return True
470
+ else:
471
+ click.echo(f"Unknown graph type: {graph_type}")
472
+ return False
473
+
474
+ click.echo(f"Graph saved to {output}")
475
+ return True
476
+
477
+ except Exception as e:
478
+ click.echo(f"Error generating graph: {e}")
479
+ logging.error(f"Graph generation failed: {e}")
480
+ return False
481
+
482
+
483
+
484
+
485
+
486
+
487
+ # CLI Commands
488
+
489
+ @click.group()
490
+ @click.option('--verbose', '-v', is_flag=True, help='Enable verbose logging')
491
+ @click.pass_context
492
+ def cli(ctx, verbose):
493
+ """A CLI tool to make use of the barometer in locked down spectrum routers"""
494
+ ctx.ensure_object(dict)
495
+ ctx.obj['verbose'] = verbose
496
+ setup_logging(verbose)
497
+
498
+ @cli.command()
499
+ def version():
500
+ """Show version information"""
501
+ click.echo("spectrum-barometer version 1.0.3")
502
+
503
+
504
+ @cli.command()
505
+ @click.option('--show', is_flag=True, help='Show current configuration')
506
+ @click.pass_context
507
+ def config(ctx, show):
508
+ """Manage configuration file"""
509
+ config_file = get_config_file()
510
+
511
+ if show:
512
+ if config_file.exists():
513
+ with open(config_file, 'r') as f:
514
+ cfg = yaml.safe_load(f)
515
+ click.echo("\nCurrent Configuration:")
516
+ click.echo("="*50)
517
+ click.echo(f"URL: {cfg.get('url', 'Not set')}")
518
+ click.echo(f"Username: {cfg.get('username', 'Not set')}")
519
+ click.echo(f"Password: {'*' * len(cfg.get('password', '')) if cfg.get('password') else 'Not set'}")
520
+ click.echo(f"\nConfig location: {config_file}")
521
+ else:
522
+ click.echo(f"No config file found at: {config_file}")
523
+ click.echo("Run 'barometer config' to create one")
524
+ return
525
+
526
+ click.echo("Configuration Setup")
527
+ click.echo("="*50)
528
+
529
+ if config_file.exists():
530
+ click.echo(f"\nConfig file already exists at: {config_file}")
531
+ if not click.confirm("Overwrite existing configuration?"):
532
+ click.echo("Configuration unchanged")
533
+ return
534
+
535
+ url = click.prompt("Router URL", default="https://192.168.1.1/cgi-bin/warehouse.cgi")
536
+ username = click.prompt("Username", default="ThylacineGone")
537
+ password = click.prompt("Password", default="4p@ssThats10ng")
538
+
539
+ config_data = {
540
+ 'url': url,
541
+ 'username': username,
542
+ 'password': password
543
+ }
544
+
545
+ with open(config_file, 'w') as f:
546
+ yaml.dump(config_data, f, default_flow_style=False)
547
+
548
+ click.echo(f"\nConfiguration saved to: {config_file}")
549
+ click.echo("You can now run: barometer test")
550
+
551
+
552
+ @cli.command()
553
+ @click.pass_context
554
+ def info(ctx):
555
+ """Show information about data locations and project setup"""
556
+ click.echo("\nBarometer Project Information")
557
+ click.echo("="*50)
558
+
559
+ app_dir = get_app_dir()
560
+ click.echo(f"App directory: {app_dir}")
561
+
562
+ config_file = get_config_file()
563
+ if config_file.exists():
564
+ click.echo(f"\nConfig file: {config_file}")
565
+ else:
566
+ click.echo(f"\nConfig file: NOT FOUND")
567
+ click.echo(f" Run 'barometer config' to create at: {config_file}")
568
+
569
+ data_file = get_data_dir() / 'readings.csv'
570
+ if data_file.exists():
571
+ size = data_file.stat().st_size / 1024
572
+ click.echo(f"\nData file: {data_file} ({size:.1f} KB)")
573
+ df = load_data()
574
+ if df is not None and not df.empty:
575
+ click.echo(f" - {len(df)} readings")
576
+ click.echo(f" - From {df['timestamp'].min()} to {df['timestamp'].max()}")
577
+ else:
578
+ click.echo(f"\nData file: NOT FOUND")
579
+ click.echo(f" Run 'barometer scrape' to start collecting")
580
+
581
+ graphs_dir = get_graphs_dir()
582
+ graphs = list(graphs_dir.glob('*.png'))
583
+ click.echo(f"\nGraphs directory: {graphs_dir}")
584
+ if graphs:
585
+ click.echo(f" - {len(graphs)} graph(s)")
586
+ for g in graphs:
587
+ click.echo(f" - {g.name}")
588
+ else:
589
+ click.echo(f" - No graphs yet (run 'barometer graph' to create)")
590
+
591
+ log_file = get_logs_dir() / 'barometer.log'
592
+ if log_file.exists():
593
+ size = log_file.stat().st_size / 1024
594
+ click.echo(f"\nLog file: {log_file} ({size:.1f} KB)")
595
+ else:
596
+ click.echo(f"\nLog file: {get_logs_dir() / 'barometer.log'}")
597
+ click.echo(f" - Will be created on first run")
598
+
599
+ archive_dir = get_archive_dir()
600
+ if archive_dir.exists():
601
+ archive_files = list(archive_dir.rglob('*.csv'))
602
+ if archive_files:
603
+ click.echo(f"\nArchives: {archive_dir}")
604
+ click.echo(f" - {len(archive_files)} archive file(s)")
605
+
606
+
607
+
608
+
609
+
610
+
611
+ @cli.command()
612
+ @click.pass_context
613
+ def test(ctx):
614
+ """Test connection to router and data extraction"""
615
+ click.echo("Testing connection to router...")
616
+
617
+ try:
618
+ scraper = BarometerScraper()
619
+ click.echo("Config loaded")
620
+
621
+ response = scraper.login()
622
+ if response:
623
+ click.echo("Connection successful")
624
+
625
+ pressure = scraper.extract_barometer_value(response.text)
626
+ if pressure:
627
+ click.echo(f"Data extraction successful")
628
+ click.echo(f"\nCurrent pressure: {pressure} Pa ({pressure/100:.2f} hPa)")
629
+ click.echo("\n All tests passed!")
630
+ return
631
+
632
+ click.echo("Test failed, check logs for details")
633
+
634
+ except Exception as e:
635
+ click.echo(f" Error: {e}")
636
+ logging.error(f"Test failed: {e}")
637
+
638
+
639
+ @cli.command()
640
+ @click.option('--interval', '-i', default=300, help='Interval between readings in seconds (default: 300)')
641
+ @click.pass_context
642
+ def monitor(ctx, interval):
643
+ """start continuous monitoring (default mode)"""
644
+ click.echo(f"Starting continuous monitoring (interval: {interval}s)")
645
+ click.echo("Press Ctrl+C to stop\n")
646
+
647
+ scraper = BarometerScraper()
648
+ readings_count = 0
649
+
650
+ while True:
651
+ try:
652
+ response = scraper.login()
653
+
654
+ if response:
655
+ pressure = scraper.extract_barometer_value(response.text)
656
+
657
+ if pressure:
658
+ scraper.save_reading(pressure)
659
+ readings_count += 1
660
+ timestamp = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
661
+ click.echo(f"[{timestamp}] Pressure: {pressure/100:.2f} hPa | Total readings: {readings_count}")
662
+ else:
663
+ click.echo(" Failed to extract pressure value")
664
+ else:
665
+ click.echo(" Connection failed")
666
+
667
+ except KeyboardInterrupt:
668
+ click.echo(f"\n\nStopping monitoring... (collected {readings_count} readings)")
669
+ break
670
+ except Exception as e:
671
+ click.echo(f" Error: {e}")
672
+ logging.error(f"Monitor error: {e}")
673
+
674
+ time.sleep(interval)
675
+
676
+
677
+ @cli.command()
678
+ @click.pass_context
679
+ def scrape(ctx):
680
+ """perform a single scrape and save reading"""
681
+ click.echo("Performing single scrape...")
682
+
683
+ try:
684
+ scraper = BarometerScraper()
685
+ response = scraper.login()
686
+
687
+ if response:
688
+ pressure = scraper.extract_barometer_value(response.text)
689
+
690
+ if pressure:
691
+ scraper.save_reading(pressure)
692
+ click.echo(f" Reading saved: {pressure} Pa ({pressure/100:.2f} hPa)")
693
+ else:
694
+ click.echo(" Failed to extract pressure value")
695
+ else:
696
+ click.echo(" Connection failed")
697
+
698
+ except Exception as e:
699
+ click.echo(f" Error: {e}")
700
+
701
+
702
+ @cli.command()
703
+ @click.option('--days', '-d', default=7, show_default=True, help='Number of days to display')
704
+ @click.option('--output', '-o', default='none', show_default=True, help='Output file path')
705
+ @click.option('--type', '-t', 'graph_type',
706
+ type=click.Choice(['line', 'smooth', 'area', 'daily', 'distribution', 'change', 'dashboard', 'all'],
707
+ case_sensitive=False),
708
+ default='dashboard', show_default=True,
709
+ help='Type of graph to generate')
710
+ @click.option('--archives', '-a', is_flag=True, help='Include archived data in graph')
711
+ @click.pass_context
712
+ def graph(ctx, days, output, graph_type, archives):
713
+ """\b
714
+ Generate pressure graph from stored data
715
+ \b
716
+ Graph types:
717
+
718
+ line - Standard line graph (default)
719
+
720
+ smooth - Line with moving average trend
721
+
722
+ area - Filled area chart
723
+
724
+ daily - Daily min/max/average summary
725
+
726
+ distribution - Histogram of pressure values
727
+
728
+ change - Rate of pressure change over time
729
+
730
+ dashboard - Multi-panel overview
731
+
732
+ all - Generate all graph types
733
+ """
734
+
735
+ if archives:
736
+ click.echo(f"Generating {graph_type} graph for last {days} days (including archives)...")
737
+ else:
738
+ click.echo(f"Generating {graph_type} graph for last {days} days...")
739
+
740
+ if generate_graph(days, output, graph_type, include_archives=archives):
741
+ # Show absolute path
742
+ abs_path = os.path.abspath(output)
743
+ click.echo(f"Graph generated successfully")
744
+ click.echo(f"Location: {abs_path}")
745
+ else:
746
+ click.echo("Failed to generate graph")
747
+
748
+ @cli.command()
749
+ @click.option('--keep-days', '-k', default=90, show_default=True, help='Keep data from last N days')
750
+ @click.confirmation_option(prompt='This will move old logs and data to archive. Continue?')
751
+ @click.pass_context
752
+ def archive(ctx, keep_days):
753
+ """Archive old logs and data"""
754
+ click.echo(f"Archiving data older than {keep_days} days...")
755
+
756
+ archive_dir = get_archive_dir() / datetime.now().strftime('%Y-%m')
757
+ archive_dir.mkdir(parents=True, exist_ok=True)
758
+
759
+ archived_items = 0
760
+
761
+ log_file = get_logs_dir() / 'barometer.log'
762
+ if log_file.exists():
763
+ log_size = log_file.stat().st_size / 1024 / 1024
764
+ if log_size > 10:
765
+ shutil.copy(log_file, archive_dir / 'barometer.log')
766
+ log_file.write_text('')
767
+ click.echo(f"Archived log file ({log_size:.1f} MB)")
768
+ archived_items += 1
769
+
770
+ df = load_data()
771
+ if df is not None and not df.empty:
772
+ cutoff = datetime.now() - timedelta(days=keep_days)
773
+ old_data = df[df['timestamp'] < cutoff]
774
+ recent_data = df[df['timestamp'] >= cutoff]
775
+
776
+ if not old_data.empty:
777
+ old_data.to_csv(archive_dir / 'readings_archive.csv', index=False)
778
+ data_file = get_data_dir() / 'readings.csv'
779
+ recent_data.to_csv(data_file, index=False)
780
+ click.echo(f"Archived {len(old_data)} old readings")
781
+ archived_items += 1
782
+
783
+ if archived_items == 0:
784
+ click.echo("No items needed archiving")
785
+ else:
786
+ click.echo(f"\nArchived {archived_items} item(s) to {archive_dir}")
787
+
788
+
789
+ @cli.command()
790
+ @click.pass_context
791
+ def stats(ctx):
792
+ """Show statistics about collected data"""
793
+ df = load_data(include_archives=False)
794
+
795
+ if df is None or df.empty:
796
+ click.echo("No data available")
797
+ return
798
+
799
+ click.echo("\nStatistics\n" + "="*50)
800
+ click.echo(f"Total readings: {len(df)}")
801
+ click.echo(f"First reading: {df['timestamp'].min()}")
802
+ click.echo(f"Last reading: {df['timestamp'].max()}")
803
+ click.echo(f"Duration: {(df['timestamp'].max() - df['timestamp'].min()).days} days")
804
+
805
+ click.echo(f"\nPressure Statistics (hPa)\n" + "="*50)
806
+ click.echo(f"Current: {df['pressure_hpa'].iloc[-1]:.2f}")
807
+ click.echo(f"Average: {df['pressure_hpa'].mean():.2f}")
808
+ click.echo(f"Minimum: {df['pressure_hpa'].min():.2f}")
809
+ click.echo(f"Maximum: {df['pressure_hpa'].max():.2f}")
810
+ click.echo(f"Range: {df['pressure_hpa'].max() - df['pressure_hpa'].min():.2f}")
811
+
812
+ # Last 24 hours
813
+ last_24h = df[df['timestamp'] > (datetime.now() - timedelta(hours=24))]
814
+ if not last_24h.empty:
815
+ click.echo(f"\nLast 24 Hours\n" + "="*50)
816
+ click.echo(f"Readings: {len(last_24h)}")
817
+ click.echo(f"Average: {last_24h['pressure_hpa'].mean():.2f} hPa")
818
+ change = last_24h['pressure_hpa'].iloc[-1] - last_24h['pressure_hpa'].iloc[0]
819
+ click.echo(f"Change: {change:+.2f} hPa")
820
+
821
+ # Check for archives
822
+ if os.path.exists('archive'):
823
+ archive_count = 0
824
+ for root, dirs, files in os.walk('archive'):
825
+ archive_count += len([f for f in files if f.endswith('.csv')])
826
+ if archive_count > 0:
827
+ click.echo(f"\nArchives: {archive_count} file(s) available (use --archives flag to include)")
828
+
829
+
830
+ if __name__ == "__main__":
831
+ cli(obj={})
@@ -0,0 +1,133 @@
1
+ Metadata-Version: 2.4
2
+ Name: spectrum-barometer
3
+ Version: 1.0.3
4
+ Summary: Monitor barometric pressure from Spectrum SAX2V1S routers
5
+ Author-email: BobaTeagrl <Unsorted2750@proton.me>
6
+ Maintainer-email: BobaTeagrl <Unsorted2750@proton.me>
7
+ License: MIT
8
+ Project-URL: Homepage, https://github.com/BobaTeagrl/spectrum-barometer
9
+ Project-URL: Repository, https://github.com/BobaTeagrl/spectrum-barometer
10
+ Project-URL: Issues, https://github.com/BobaTeagrl/spectrum-barometer/issues
11
+ Keywords: barometer,pressure,monitoring,spectrum,router,messurement
12
+ Classifier: Development Status :: 5 - Production/Stable
13
+ Classifier: Intended Audience :: End Users/Desktop
14
+ Classifier: License :: OSI Approved
15
+ Classifier: Programming Language :: Python :: 3
16
+ Classifier: Programming Language :: Python :: 3.8
17
+ Classifier: Programming Language :: Python :: 3.9
18
+ Classifier: Programming Language :: Python :: 3.10
19
+ Classifier: Programming Language :: Python :: 3.11
20
+ Requires-Python: >=3.8
21
+ Description-Content-Type: text/markdown
22
+ License-File: LICENSE.txt
23
+ Requires-Dist: click>=8.0
24
+ Requires-Dist: pandas>=1.3
25
+ Requires-Dist: pyyaml>=5.4
26
+ Requires-Dist: requests>=2.26
27
+ Requires-Dist: urllib3>=1.26
28
+ Requires-Dist: matplotlib>=3.4
29
+ Requires-Dist: lxml>=4.6
30
+ Requires-Dist: html5lib>=1.1
31
+ Requires-Dist: numpy>=1.20
32
+ Dynamic: license-file
33
+
34
+ # Spectrum Router Barometer
35
+
36
+ Monitor barometric pressure from Spectrum SAX2V1S routers.
37
+
38
+ ## Installation
39
+
40
+ ### Quick Install (Recommended)
41
+ ```bash
42
+ pipx install git+https://github.com/BobaTeagrl/spectrum-barometer.git
43
+ ```
44
+
45
+ Or with pip:
46
+ ```bash
47
+ pip install git+https://github.com/BobaTeagrl/spectrum-barometer.git
48
+ ```
49
+
50
+ ## Updating
51
+
52
+ ### For now you need to uninstall and reinstall i plan on making this cleaner soon but pipx only likes git so much
53
+ ```bash
54
+ pipx uninstall spectrum-barometer
55
+ pipx install git+https://github.com/BobaTeagrl/spectrum-barometer.git
56
+ ```
57
+
58
+ ### First Time Setup
59
+ ```bash
60
+ # Configure your router credentials
61
+ barometer config
62
+
63
+ # Test the connection
64
+ barometer test
65
+
66
+ ```
67
+
68
+ ![image](./install_screenshot.png "screenshot of install process")
69
+
70
+ All data is stored in `~/spectrum-barometer/` regardless of where you run the command.
71
+
72
+ ## Usage
73
+ ```bash
74
+ # Collect a single reading
75
+ barometer scrape
76
+
77
+ # Start continuous monitoring (every 5 minutes by default)
78
+ barometer monitor
79
+
80
+ # Generate a graph
81
+ barometer graph
82
+
83
+ # View statistics
84
+ barometer stats
85
+
86
+ # Archive old data
87
+ barometer archive
88
+
89
+ Append --help to any command to see extra options
90
+
91
+ ```
92
+
93
+ ## Finding Your Data
94
+
95
+ Everything is stored in `~/spectrum-barometer/`:
96
+
97
+ - **Graphs**: `~/spectrum-barometer/graphs/`
98
+ - **Data**: `~/spectrum-barometer/data/readings.csv`
99
+ - **Config**: `~/spectrum-barometer/config.yaml`
100
+ - **Logs**: `~/spectrum-barometer/logs/barometer.log`
101
+
102
+ You can run `barometer info` to see exact paths and current data.
103
+
104
+ # FAQ
105
+
106
+ ### Why make this?
107
+
108
+ I find it funny.
109
+
110
+ ### Any other reason?
111
+
112
+ Spectrum is a very anti consumer company. this whole project started because i cant even access port forwarding, A VERY BASIC FEATURE and while trying to find a way around it i found the GitHub with the page and credentials used to find the barometer.
113
+
114
+ ### How often can it update?
115
+
116
+ The barometer seems to update every second but that's super overkill lol but you can if you want to.
117
+
118
+ ### Will you update this ever?
119
+
120
+ I may make a fully optional web GUI at some point for fun but its in a very usable state already. Plus cli tools are just kinda fun :3
121
+
122
+ ### How hard is it to run?
123
+
124
+ When taking a reading it might take a few % of CPU and max ram use i personally have seen is 111MB (though not to say it can never get higher i cant know for sure im just one person) but when sitting idle its no CPU. i wanted this to be able to run on anything from a raspberry pi you already have set up running pi hole or something to someones single laptop that they are actively pushing while it runs in the background (because that's me)
125
+
126
+
127
+ # None of this would be possible without the work of MeisterLone on github
128
+
129
+ ## He actually put in the work to reverse engineer this stupid router and i wouldnt have even realized routers had barometers without it lmao
130
+
131
+ ## https://github.com/MeisterLone/Askey-RT5010W-D187-REV6
132
+
133
+
@@ -0,0 +1,7 @@
1
+ barometer_logger.py,sha256=tnlt6Ju3I1uvHokZxHZFWBSytdfPpanSdFEf3wkcohg,29490
2
+ spectrum_barometer-1.0.3.dist-info/licenses/LICENSE.txt,sha256=D9G7nkKdCWuJALZX5kz3sXl-ze6UCrJXRZu76a2gMbU,906
3
+ spectrum_barometer-1.0.3.dist-info/METADATA,sha256=Fb4z_8OeTcDo8qGI6v4W2PVKyuElJGX6UnbtfZStPHA,4157
4
+ spectrum_barometer-1.0.3.dist-info/WHEEL,sha256=qELbo2s1Yzl39ZmrAibXA2jjPLUYfnVhUNTlyF1rq0Y,92
5
+ spectrum_barometer-1.0.3.dist-info/entry_points.txt,sha256=QwtqOx-DzX5J_gN3de6Kr3wUFqaIENkPjDMQhn8BKgY,51
6
+ spectrum_barometer-1.0.3.dist-info/top_level.txt,sha256=LVOl5PWwmjuOMk_RASRAMADAZeTcQBRoznXzGixQO8Q,17
7
+ spectrum_barometer-1.0.3.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (80.10.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ barometer = barometer_logger:cli
@@ -0,0 +1,16 @@
1
+ MIT No Attribution
2
+
3
+ Copyright 2026, BobaTeagrl
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy of this
6
+ software and associated documentation files (the "Software"), to deal in the Software
7
+ without restriction, including without limitation the rights to use, copy, modify,
8
+ merge, publish, distribute, sublicense, and/or sell copies of the Software, and to
9
+ permit persons to whom the Software is furnished to do so.
10
+
11
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED,
12
+ INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A
13
+ PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
14
+ HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
15
+ OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
16
+ SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
@@ -0,0 +1 @@
1
+ barometer_logger