spectrum-barometer 1.0.3__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-1.0.3/LICENSE.txt +16 -0
- spectrum_barometer-1.0.3/PKG-INFO +133 -0
- spectrum_barometer-1.0.3/README.md +100 -0
- spectrum_barometer-1.0.3/barometer_logger.py +831 -0
- spectrum_barometer-1.0.3/pyproject.toml +49 -0
- spectrum_barometer-1.0.3/setup.cfg +4 -0
- spectrum_barometer-1.0.3/spectrum_barometer.egg-info/PKG-INFO +133 -0
- spectrum_barometer-1.0.3/spectrum_barometer.egg-info/SOURCES.txt +10 -0
- spectrum_barometer-1.0.3/spectrum_barometer.egg-info/dependency_links.txt +1 -0
- spectrum_barometer-1.0.3/spectrum_barometer.egg-info/entry_points.txt +2 -0
- spectrum_barometer-1.0.3/spectrum_barometer.egg-info/requires.txt +9 -0
- spectrum_barometer-1.0.3/spectrum_barometer.egg-info/top_level.txt +1 -0
|
@@ -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,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
|
+

|
|
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,100 @@
|
|
|
1
|
+
# Spectrum Router Barometer
|
|
2
|
+
|
|
3
|
+
Monitor barometric pressure from Spectrum SAX2V1S routers.
|
|
4
|
+
|
|
5
|
+
## Installation
|
|
6
|
+
|
|
7
|
+
### Quick Install (Recommended)
|
|
8
|
+
```bash
|
|
9
|
+
pipx install git+https://github.com/BobaTeagrl/spectrum-barometer.git
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
Or with pip:
|
|
13
|
+
```bash
|
|
14
|
+
pip install git+https://github.com/BobaTeagrl/spectrum-barometer.git
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
## Updating
|
|
18
|
+
|
|
19
|
+
### For now you need to uninstall and reinstall i plan on making this cleaner soon but pipx only likes git so much
|
|
20
|
+
```bash
|
|
21
|
+
pipx uninstall spectrum-barometer
|
|
22
|
+
pipx install git+https://github.com/BobaTeagrl/spectrum-barometer.git
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
### First Time Setup
|
|
26
|
+
```bash
|
|
27
|
+
# Configure your router credentials
|
|
28
|
+
barometer config
|
|
29
|
+
|
|
30
|
+
# Test the connection
|
|
31
|
+
barometer test
|
|
32
|
+
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+

|
|
36
|
+
|
|
37
|
+
All data is stored in `~/spectrum-barometer/` regardless of where you run the command.
|
|
38
|
+
|
|
39
|
+
## Usage
|
|
40
|
+
```bash
|
|
41
|
+
# Collect a single reading
|
|
42
|
+
barometer scrape
|
|
43
|
+
|
|
44
|
+
# Start continuous monitoring (every 5 minutes by default)
|
|
45
|
+
barometer monitor
|
|
46
|
+
|
|
47
|
+
# Generate a graph
|
|
48
|
+
barometer graph
|
|
49
|
+
|
|
50
|
+
# View statistics
|
|
51
|
+
barometer stats
|
|
52
|
+
|
|
53
|
+
# Archive old data
|
|
54
|
+
barometer archive
|
|
55
|
+
|
|
56
|
+
Append --help to any command to see extra options
|
|
57
|
+
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
## Finding Your Data
|
|
61
|
+
|
|
62
|
+
Everything is stored in `~/spectrum-barometer/`:
|
|
63
|
+
|
|
64
|
+
- **Graphs**: `~/spectrum-barometer/graphs/`
|
|
65
|
+
- **Data**: `~/spectrum-barometer/data/readings.csv`
|
|
66
|
+
- **Config**: `~/spectrum-barometer/config.yaml`
|
|
67
|
+
- **Logs**: `~/spectrum-barometer/logs/barometer.log`
|
|
68
|
+
|
|
69
|
+
You can run `barometer info` to see exact paths and current data.
|
|
70
|
+
|
|
71
|
+
# FAQ
|
|
72
|
+
|
|
73
|
+
### Why make this?
|
|
74
|
+
|
|
75
|
+
I find it funny.
|
|
76
|
+
|
|
77
|
+
### Any other reason?
|
|
78
|
+
|
|
79
|
+
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.
|
|
80
|
+
|
|
81
|
+
### How often can it update?
|
|
82
|
+
|
|
83
|
+
The barometer seems to update every second but that's super overkill lol but you can if you want to.
|
|
84
|
+
|
|
85
|
+
### Will you update this ever?
|
|
86
|
+
|
|
87
|
+
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
|
|
88
|
+
|
|
89
|
+
### How hard is it to run?
|
|
90
|
+
|
|
91
|
+
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)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
# None of this would be possible without the work of MeisterLone on github
|
|
95
|
+
|
|
96
|
+
## He actually put in the work to reverse engineer this stupid router and i wouldnt have even realized routers had barometers without it lmao
|
|
97
|
+
|
|
98
|
+
## https://github.com/MeisterLone/Askey-RT5010W-D187-REV6
|
|
99
|
+
|
|
100
|
+
|
|
@@ -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,49 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=61.0"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "spectrum-barometer"
|
|
7
|
+
version = "1.0.3"
|
|
8
|
+
description = "Monitor barometric pressure from Spectrum SAX2V1S routers"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.8"
|
|
11
|
+
license = {text = "MIT"}
|
|
12
|
+
authors = [
|
|
13
|
+
{name = "BobaTeagrl", email = "Unsorted2750@proton.me"}
|
|
14
|
+
]
|
|
15
|
+
maintainers = [
|
|
16
|
+
{name = "BobaTeagrl", email = "Unsorted2750@proton.me"}
|
|
17
|
+
]
|
|
18
|
+
keywords = ["barometer", "pressure", "monitoring", "spectrum", "router", "messurement"]
|
|
19
|
+
|
|
20
|
+
classifiers = [
|
|
21
|
+
"Development Status :: 5 - Production/Stable",
|
|
22
|
+
"Intended Audience :: End Users/Desktop",
|
|
23
|
+
"License :: OSI Approved",
|
|
24
|
+
"Programming Language :: Python :: 3",
|
|
25
|
+
"Programming Language :: Python :: 3.8",
|
|
26
|
+
"Programming Language :: Python :: 3.9",
|
|
27
|
+
"Programming Language :: Python :: 3.10",
|
|
28
|
+
"Programming Language :: Python :: 3.11",
|
|
29
|
+
]
|
|
30
|
+
|
|
31
|
+
dependencies = [
|
|
32
|
+
"click>=8.0",
|
|
33
|
+
"pandas>=1.3",
|
|
34
|
+
"pyyaml>=5.4",
|
|
35
|
+
"requests>=2.26",
|
|
36
|
+
"urllib3>=1.26",
|
|
37
|
+
"matplotlib>=3.4",
|
|
38
|
+
"lxml>=4.6",
|
|
39
|
+
"html5lib>=1.1",
|
|
40
|
+
"numpy>=1.20",
|
|
41
|
+
]
|
|
42
|
+
|
|
43
|
+
[project.urls]
|
|
44
|
+
Homepage = "https://github.com/BobaTeagrl/spectrum-barometer"
|
|
45
|
+
Repository = "https://github.com/BobaTeagrl/spectrum-barometer"
|
|
46
|
+
Issues = "https://github.com/BobaTeagrl/spectrum-barometer/issues"
|
|
47
|
+
|
|
48
|
+
[project.scripts]
|
|
49
|
+
barometer = "barometer_logger:cli"
|
|
@@ -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
|
+

|
|
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,10 @@
|
|
|
1
|
+
LICENSE.txt
|
|
2
|
+
README.md
|
|
3
|
+
barometer_logger.py
|
|
4
|
+
pyproject.toml
|
|
5
|
+
spectrum_barometer.egg-info/PKG-INFO
|
|
6
|
+
spectrum_barometer.egg-info/SOURCES.txt
|
|
7
|
+
spectrum_barometer.egg-info/dependency_links.txt
|
|
8
|
+
spectrum_barometer.egg-info/entry_points.txt
|
|
9
|
+
spectrum_barometer.egg-info/requires.txt
|
|
10
|
+
spectrum_barometer.egg-info/top_level.txt
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
barometer_logger
|