Switched from influx to victoria metrics (more light weight for pi)

This commit is contained in:
2026-06-26 23:58:32 -04:00
parent 0c0ebfd20b
commit 6eefd1b86b
3 changed files with 168 additions and 282 deletions

View File

@@ -14,8 +14,7 @@ def cfg():
'LATITUDE': float(os.getenv('LATITUDE', '0.0')),
'LONGITUDE': float(os.getenv('LONGITUDE', '0.0')),
'ALTITUDE': float(os.getenv('ALTITUDE', '0.0')),
'INFLUX_URL': os.getenv('INFLUX_URL', 'http://localhost:8428'),
'INFLUX_BUCKET': os.getenv('INFLUX_BUCKET', 'station'),
'VM_URL': os.getenv('VM_URL', 'http://localhost:8428'),
'GPS_PORT': os.getenv('GPS_PORT', '/dev/ttyS0'),
'GPS_BAUD': int( os.getenv('GPS_BAUD', '9600')),
'GAS_REFERENCE': int( os.getenv('GAS_REFERENCE', '250000')),
@@ -27,35 +26,27 @@ def cfg():
'AS3935_NOISE_FLOOR': int( os.getenv('AS3935_NOISE_FLOOR', '5')),
'AS3935_WATCHDOG': int( os.getenv('AS3935_WATCHDOG', '3')),
'AS3935_SPIKE_REJ': int( os.getenv('AS3935_SPIKE_REJ', '7')),
'SKIN_TYPE_2_MED': int( os.getenv('SKIN_TYPE_2_MED', '200')),
'LOOP_INTERVAL': float( os.getenv('LOOP_INTERVAL', '1.0')),
'FLOOD_TEMP_THRESHOLD': float( os.getenv('FLOOD_TEMP_THRESHOLD', '10.0')),
'SLUSH_TEMP_THRESHOLD': float( os.getenv('SLUSH_TEMP_THRESHOLD', '0.0')),
'SNOW_STRENGTH_MIN': int( os.getenv('SNOW_STRENGTH_MIN', '700')),
'SEISMIC_NOISE_FLOOR': float( os.getenv('SEISMIC_NOISE_FLOOR', '0.02')),
'TEMP_CALIBRATION': float( os.getenv('TEMP_CALIBRATION', '0.0')),
'CLOUD_BUCKET_SIZE': int( os.getenv('CLOUD_BUCKET_SIZE', '5')),
'CLOUD_HISTORY': int( os.getenv('CLOUD_HISTORY', '20')),
'CLOUD_BASELINE_PCT': float( os.getenv('CLOUD_BASELINE_PCT', '0.9')),
'CLOUD_VOLATILITY_THRESH':float( os.getenv('CLOUD_VOLATILITY_THRESH', '0.3')),
'CLOUD_CLEAR_MAX': float( os.getenv('CLOUD_CLEAR_MAX', '15.0')),
'CLOUD_MOSTLY_CLEAR_MAX': float( os.getenv('CLOUD_MOSTLY_CLEAR_MAX', '40.0')),
'CLOUD_CLOUDY_MAX': float( os.getenv('CLOUD_CLOUDY_MAX', '80.0')),
'CLOUD_MIN_SAMPLES': int( os.getenv('CLOUD_MIN_SAMPLES', '5')),
'CLOUD_LUX_WINDOW': int( os.getenv('CLOUD_LUX_WINDOW', '10')),
'VISIBILITY_MAX_KM': float( os.getenv('VISIBILITY_MAX_KM', '50.0')),
'CLOUD_SUN_MIN_ELEV': float( os.getenv('CLOUD_SUN_MIN_ELEV', '5.0')),
'CLOUD_LUX_CLEAR_SKY': float( os.getenv('CLOUD_LUX_CLEAR_SKY', '100000.0')),
'CLOUD_HUMIDITY_WEIGHT': float( os.getenv('CLOUD_HUMIDITY_WEIGHT', '0.3')),
}
# ── VictoriaMetrics ───────────────────────────────────────────────────────────
def write(c, measurement, fields, tags={}):
tag_str = ',' + ','.join(f"{k}={v}" for k, v in tags.items() if v) if tags else ''
def write(c, fields):
field_str = ','.join(
f"{k}={'true' if v is True else 'false' if v is False else v}"
for k, v in fields.items() if v is not None
)
if not field_str: return
line = f"{measurement}{tag_str} {field_str}"
requests.post(f"{c['INFLUX_URL']}/write", data=line, params={'db': c['INFLUX_BUCKET']}, timeout=2)
requests.post(f"{c['VM_URL']}/write", data=f"weather {field_str}", timeout=2)
# ── Helpers ───────────────────────────────────────────────────────────────────
@@ -113,57 +104,35 @@ def vpd(tc, rh):
return round(es - (rh/100)*es, 3)
def gas_to_aqi(gas_ohms, humidity, gas_reference):
if gas_ohms is None: return None, None
if gas_ohms is None: return None
gas_score = min(gas_ohms / gas_reference, 1.0) * 75
hum_score = 25 - abs(humidity - 40) * 0.5
quality = min(max(gas_score + hum_score, 0), 100)
aqi = round((1 - quality / 100) * 500)
if aqi <= 50: label = 'Minimal'
elif aqi <= 100: label = 'Low'
elif aqi <= 150: label = 'Moderate'
elif aqi <= 200: label = 'Medium'
elif aqi <= 300: label = 'High'
else: label = 'Hazard'
return aqi, label
return aqi
def sea_level_pressure(pressure_hpa, alt_m):
if alt_m is None: return None
return round(pressure_hpa / math.pow(1 - (alt_m / 44330.0), 5.255), 2)
def frost_risk(temp_c, dew_point_c):
margin = temp_c - dew_point_c
if temp_c <= 0: return 'High'
if temp_c <= 3 and margin < 2: return 'Moderate'
if temp_c <= 5: return 'Low'
return 'None'
def get_pressure_trend(window):
if pressure_buffer.maxlen != window:
pressure_buffer.__init__(window)
if len(pressure_buffer) < 2: return None, None
if len(pressure_buffer) < 2: return None
delta = pressure_buffer[-1] - pressure_buffer[0]
label = 'Rising' if delta > 1.0 else 'Falling' if delta < -1.0 else 'Stable'
return round(delta, 2), label
return round(delta, 2)
def read_bme(c, sensor, alt_m=None):
null = {
'temperature': None, 'humidity': None, 'humidity_abs': None,
'dew_point': None, 'pressure_hpa': None, 'pressure_slp': None,
'pressure_rate': None, 'vapor_pressure_deficit': None,
'heat_index': None, 'air_quality_ohms': None, 'air_quality': None,
}
if not sensor:
write(c, 'environment', null)
return None, None
if not sensor: return None, None, None
try:
if not sensor.get_sensor_data(): return None, None
t, rh, p = sensor.data.temperature, sensor.data.humidity, sensor.data.pressure
gas = round(sensor.data.gas_resistance, 0) if sensor.data.heat_stable else None
aqi_score, aqi_label = gas_to_aqi(gas, rh, c['GAS_REFERENCE'])
if not sensor.get_sensor_data(): return None, None, None
t, rh, p = sensor.data.temperature, sensor.data.humidity, sensor.data.pressure
gas = round(sensor.data.gas_resistance, 0) if sensor.data.heat_stable else None
aqi_score = gas_to_aqi(gas, rh, c['GAS_REFERENCE'])
pressure_buffer.append(p)
p_rate, p_label = get_pressure_trend(c['PRESSURE_TREND_WINDOW'])
dp = round(t - (100-rh)/5.0, 2)
write(c, 'environment', {
p_rate = get_pressure_trend(c['PRESSURE_TREND_WINDOW'])
dp = round(t - (100 - rh) / 5.0, 2)
write(c, {
'temperature': round(t + c['TEMP_CALIBRATION'], 2),
'humidity': round(rh, 2),
'dew_point': dp,
@@ -175,15 +144,11 @@ def read_bme(c, sensor, alt_m=None):
'heat_index': heat_index(t, rh),
'air_quality_ohms': gas,
'air_quality': aqi_score,
}, tags={
'air_quality_label': aqi_label or '',
'pressure_trend': p_label or '',
'frost_risk': frost_risk(t, dp),
})
return t, rh
return t, rh, aqi_score
except Exception as e:
print(f"[BME680] {e}")
return None, None
return None, None, None
# ── MPU6050 ───────────────────────────────────────────────────────────────────
@@ -195,21 +160,21 @@ _mpu_offsets = None
def calibrate_mpu(samples=200):
if not bus: return None
print(" Calibrating MPU6050, keep still...")
sums = [0.0]*6
sums = [0.0] * 6
n = 0
for _ in range(samples):
d = try_read(lambda: bus.read_i2c_block_data(MPU_ADDR, 0x3B, 14))
if d:
sums[0] += s16((d[0] << 8)|d[1]) / 16384.0
sums[1] += s16((d[2] << 8)|d[3]) / 16384.0
sums[2] += s16((d[4] << 8)|d[5]) / 16384.0
sums[3] += s16((d[8] << 8)|d[9]) / 131.0
sums[4] += s16((d[10] << 8)|d[11]) / 131.0
sums[5] += s16((d[12] << 8)|d[13]) / 131.0
sums[0] += s16((d[0] << 8) | d[1]) / 16384.0
sums[1] += s16((d[2] << 8) | d[3]) / 16384.0
sums[2] += s16((d[4] << 8) | d[5]) / 16384.0
sums[3] += s16((d[8] << 8) | d[9]) / 131.0
sums[4] += s16((d[10] << 8) | d[11]) / 131.0
sums[5] += s16((d[12] << 8) | d[13]) / 131.0
n += 1
time.sleep(0.005)
if n == 0: return None
return [sums[0]/n, sums[1]/n, sums[2]/n-1.0, sums[3]/n, sums[4]/n, sums[5]/n]
return [sums[0]/n, sums[1]/n, sums[2]/n - 1.0, sums[3]/n, sums[4]/n, sums[5]/n]
def mpu_loop():
global _mpu_peak
@@ -218,17 +183,17 @@ def mpu_loop():
d = try_read(lambda: bus.read_i2c_block_data(MPU_ADDR, 0x3B, 14))
if d:
o = _mpu_offsets
ax = s16((d[0] << 8)|d[1]) / 16384.0 - o[0]
ay = s16((d[2] << 8)|d[3]) / 16384.0 - o[1]
az = s16((d[4] << 8)|d[5]) / 16384.0 - o[2]
ax = s16((d[0] << 8) | d[1]) / 16384.0 - o[0]
ay = s16((d[2] << 8) | d[3]) / 16384.0 - o[1]
az = s16((d[4] << 8) | d[5]) / 16384.0 - o[2]
mag = math.sqrt(ax**2 + ay**2 + (az - 1.0)**2)
with _mpu_lock:
if _mpu_peak is None or mag > _mpu_peak['magnitude']:
if _mpu_peak is None or mag > _mpu_peak['seismic_magnitude']:
_mpu_peak = {
'seismic_x': round(ax, 3),
'seismic_y': round(ay, 3),
'seismic_z': round(az, 3),
'magnitude': round(mag, 4),
'seismic_x': round(ax, 3),
'seismic_y': round(ay, 3),
'seismic_z': round(az, 3),
'seismic_magnitude': round(mag, 4),
}
time.sleep(0.01)
@@ -236,180 +201,123 @@ def flush_mpu(c):
global _mpu_peak
with _mpu_lock:
data, _mpu_peak = _mpu_peak, None
if data and data['magnitude'] > c['SEISMIC_NOISE_FLOOR']:
write(c, 'seismic', data)
if data and data['seismic_magnitude'] > c['SEISMIC_NOISE_FLOOR']:
write(c, data)
# ── QMC5883L ──────────────────────────────────────────────────────────────────
def read_compass(c):
null = {'x': None, 'y': None, 'z': None, 'heading': None}
if not bus: write(c, 'compass', null); return
if not bus: return
try:
addr = 0x1E
try_read(lambda: bus.write_byte_data(addr, 0x09, 0x1D))
d = try_read(lambda: bus.read_i2c_block_data(addr, 0x00, 6))
if not d: write(c, 'compass', null); return
x = s16((d[1] << 8)|d[0])
y = s16((d[3] << 8)|d[2])
z = s16((d[5] << 8)|d[4])
if not d: return
x = s16((d[1] << 8) | d[0])
y = s16((d[3] << 8) | d[2])
z = s16((d[5] << 8) | d[4])
heading = math.degrees(math.atan2(y, x))
if heading < 0: heading += 360
write(c, 'compass', {
'compass_x': x, 'compass_y': y, 'compass_z': z,
'heading': round(heading, 1),
})
write(c, {'compass_x': x, 'compass_y': y, 'compass_z': z, 'heading': round(heading, 1)})
except Exception as e:
print(f"[QMC5883L] {e}")
write(c, 'compass', null)
# ── LTR390 ───────────────────────────────────────────────────────────────────
uv_dose_mj = 0.0
uv_dose_date = datetime.now().date()
last_uv_time = time.time()
dli_lux_acc = 0.0
dli_date = datetime.now().date()
last_lux_time = time.time()
uv_dose_mj = 0.0
uv_dose_date = datetime.now().date()
last_uv_time = time.time()
dli_lux_acc = 0.0
dli_date = datetime.now().date()
last_lux_time = time.time()
daylight_start = None
DAYLIGHT_LUX_THRESHOLD = 50
_cloud_baseline = {}
_cloud_lux_window = deque(maxlen=10)
def sun_elevation(lat, lon):
now = datetime.now(timezone.utc)
doy = now.timetuple().tm_yday
hour_ut = now.hour + now.minute / 60 + now.second / 3600
decl = math.radians(23.45 * math.sin(math.radians((360 / 365) * (doy - 81))))
lstm = 15 * round(lon / 15)
eot = (9.87 * math.sin(math.radians(2 * (360/365) * (doy - 81)))
- 7.53 * math.cos(math.radians((360/365) * (doy - 81)))
- 1.5 * math.sin(math.radians((360/365) * (doy - 81))))
solar_time = hour_ut + (lon - lstm) / 15 + eot / 60
ha = math.radians(15 * (solar_time - 12))
lat_r = math.radians(lat)
elev = math.degrees(math.asin(
math.sin(lat_r) * math.sin(decl) +
math.cos(lat_r) * math.cos(decl) * math.cos(ha)
))
return elev
def _el_bucket(solar_el, bucket_size):
if solar_el is None: return None
return round(solar_el / bucket_size) * bucket_size
def estimate_cloud_cover(c, lux, temp, dew_point, lat, lon):
elev = sun_elevation(lat, lon)
temp_dp_spread = temp - dew_point
hum_cloud = max(0.0, min(100.0, (1 - temp_dp_spread / 20.0) * 100))
def moon_phase_factor():
known_new = datetime(2024, 1, 11, tzinfo=timezone.utc)
lunar_cycle = 29.53058867
days = (datetime.now(timezone.utc) - known_new).total_seconds() / 86400
phase = (days % lunar_cycle) / lunar_cycle
return round(math.cos(math.pi * (phase - 0.5)) * 0.5 + 0.5, 3)
if elev < c['CLOUD_SUN_MIN_ELEV']:
return round(hum_cloud, 1), round(elev, 2)
def update_cloud_baseline(c, solar_el, lux):
bucket = _el_bucket(solar_el, c['CLOUD_BUCKET_SIZE'])
if bucket is None: return
if bucket not in _cloud_baseline:
_cloud_baseline[bucket] = deque(maxlen=c['CLOUD_HISTORY'])
_cloud_baseline[bucket].append(lux)
theoretical = max(c['CLOUD_LUX_CLEAR_SKY'] * math.sin(math.radians(elev)), 1.0)
solar_ratio = min(lux / theoretical, 1.0)
solar_cloud = (1.0 - solar_ratio) * 100
def get_expected_lux(c, solar_el):
bucket = _el_bucket(solar_el, c['CLOUD_BUCKET_SIZE'])
if bucket is None: return None
history = _cloud_baseline.get(bucket)
if not history or len(history) < c['CLOUD_MIN_SAMPLES']: return None
sorted_vals = sorted(history)
idx = max(0, int(len(sorted_vals) * c['CLOUD_BASELINE_PCT']) - 1)
baseline = sorted_vals[idx]
if solar_el is not None and solar_el < 0:
baseline = baseline * moon_phase_factor()
return round(baseline, 2)
w = c['CLOUD_HUMIDITY_WEIGHT']
blended = (1 - w) * solar_cloud + w * hum_cloud
return round(min(max(blended, 0.0), 100.0), 1), round(elev, 2)
def classify_clouds(c, lux, solar_el):
if _cloud_lux_window.maxlen != c['CLOUD_LUX_WINDOW']:
_cloud_lux_window.__init__(maxlen=c['CLOUD_LUX_WINDOW'])
_cloud_lux_window.append(lux)
def estimate_visibility(c, rh, aqi):
rh_clamped = min(rh, 99.5)
beta_rh = 0.000146 * math.exp(0.06 * rh_clamped)
aqi_factor = 1.0 + ((aqi or 0) / 500.0) * 0.5
extinction = beta_rh * aqi_factor
return round(min(3.912 / extinction, c['VISIBILITY_MAX_KM']), 2)
expected = get_expected_lux(c, solar_el)
if expected is None or expected < 0.1:
return None, None, None
ratio = min(lux / expected, 1.0)
cloud_pct = round((1 - ratio) * 100, 1)
volatility = None
if len(_cloud_lux_window) >= 3:
mean = statistics.mean(_cloud_lux_window)
if mean > 0:
volatility = round(statistics.stdev(_cloud_lux_window) / mean, 3)
vt = c['CLOUD_VOLATILITY_THRESH']
if volatility is not None and volatility > vt and c['CLOUD_CLEAR_MAX'] < cloud_pct < c['CLOUD_CLOUDY_MAX']:
label = 'Partly_Cloudy'
elif cloud_pct < c['CLOUD_CLEAR_MAX']:
label = 'Clear'
elif cloud_pct < c['CLOUD_MOSTLY_CLEAR_MAX']:
label = 'Mostly_Clear'
elif cloud_pct < c['CLOUD_CLOUDY_MAX']:
label = 'Cloudy'
else:
label = 'Overcast'
return cloud_pct, label, volatility
def visibility_estimate(lux, humidity, solar_el=None):
base_km = 50.0
hum_factor = max(0, 1 - ((humidity - 40) / 60)) if humidity > 40 else 1.0
if solar_el is not None and solar_el > 5:
lux_factor = min(lux / 10000, 1.0)
else:
lux_factor = 1.0
return round(base_km * hum_factor * lux_factor, 1)
def uv_index_label(uvi):
if uvi < 3: return 'Low'
elif uvi < 6: return 'Moderate'
elif uvi < 8: return 'High'
elif uvi < 11: return 'Very_High'
else: return 'Extreme'
def read_ltr(c, sensor, solar_el, humidity=None):
def read_ltr(c, sensor, temp=None, dew_point=None, rh=None, aqi=None, lat=0.0, lon=0.0):
global uv_dose_mj, uv_dose_date, last_uv_time
global dli_lux_acc, dli_date, last_lux_time, daylight_start
null = {
'lux': None, 'uv_index': None, 'solar_wm2': None,
'uv_dose': None, 'clouds': None, 'daily_light_integral': None,
'daylight': None, 'visibility': None,
}
if not sensor: write(c, 'light', null); return
if not sensor: return
try:
lux = round(sensor.lux, 2)
uvi = round(sensor.uvi, 2)
except Exception as e:
print(f"[LTR390] {e}")
write(c, 'light', null)
return
now = time.time()
today = datetime.now().date()
if today != uv_dose_date:
uv_dose_mj, uv_dose_date = 0.0, today
if today != dli_date:
dli_lux_acc, dli_date, daylight_start = 0.0, today, None
if today != uv_dose_date: uv_dose_mj, uv_dose_date = 0.0, today
if today != dli_date: dli_lux_acc, dli_date, daylight_start = 0.0, today, None
dt = now - last_uv_time
uv_dose_mj += uvi * 25 * dt
uv_dose_mj += uvi * 25 * (now - last_uv_time)
dli_lux_acc += lux * (now - last_lux_time)
last_uv_time = now
last_lux_time = now
dli = round(dli_lux_acc / 54 / 1_000_000, 4)
if lux >= DAYLIGHT_LUX_THRESHOLD and daylight_start is None:
daylight_start = now
daylight_hours = round((now - daylight_start) / 3600, 2) if daylight_start else 0.0
update_cloud_baseline(c, solar_el, lux)
cloud_pct, cloud_label, volatility = classify_clouds(c, lux, solar_el)
write(c, 'light', {
fields = {
'lux': lux,
'uv_index': uvi,
'solar_wm2': round(lux / 120, 2),
'uv_dose': round(uv_dose_mj, 2),
'clouds': cloud_pct,
'daily_light_integral': dli,
'daylight': daylight_hours,
'visibility': visibility_estimate(lux, humidity, solar_el) if humidity else None,
}, tags={
'clouds_label': cloud_label or '',
'uv_index_label': uv_index_label(uvi),
})
'daily_light_integral': round(dli_lux_acc / 54 / 1_000_000, 4),
}
if temp is not None and dew_point is not None:
cloud, sun_elev = estimate_cloud_cover(c, lux, temp, dew_point, lat, lon)
fields['cloud_cover'] = cloud
fields['sun_elevation'] = sun_elev
if rh is not None:
fields['visibility_km'] = estimate_visibility(c, rh, aqi)
write(c, fields)
# ── TF-Luna ───────────────────────────────────────────────────────────────────
@@ -423,47 +331,35 @@ def calibrate_luna(c):
for _ in range(c['LUNA_CAL_SAMPLES']):
d = try_read(lambda: bus.read_i2c_block_data(0x10, 0x00, 6))
if d:
dist, strength = d[0]|(d[1]<<8), d[2]|(d[3]<<8)
dist, strength = d[0] | (d[1] << 8), d[2] | (d[3] << 8)
if strength >= 100 and 0 < dist <= c['LUNA_MAX_DIST_CM']:
readings.append(dist)
time.sleep(0.1)
if readings:
luna_baseline_cm = round(sum(readings)/len(readings), 1)
luna_baseline_cm = round(sum(readings) / len(readings), 1)
print(f" ✅ TF-Luna baseline: {luna_baseline_cm} cm")
else:
print(" ❌ TF-Luna: calibration failed")
def read_luna(c, temp_c):
null = {'ground_distance': None, 'lidar_strength': None}
if not bus: write(c, 'accumulation', null); return
def read_luna(c):
if not bus: return
try:
d = try_read(lambda: bus.read_i2c_block_data(0x10, 0x00, 6))
if not d: write(c, 'ground', null); return
dist = d[0]|(d[1]<<8)
strength = d[2]|(d[3]<<8)
if strength < 100 or dist <= 0 or dist > c['LUNA_MAX_DIST_CM']:
write(c, 'accumulation', null); return
if not d: return
dist = d[0] | (d[1] << 8)
strength = d[2] | (d[3] << 8)
if strength < 100 or dist <= 0 or dist > c['LUNA_MAX_DIST_CM']: return
fields = {'ground_distance': dist, 'ground_calibration': luna_baseline_cm, 'lidar_strength': strength}
kind = 'none'
if luna_baseline_cm and (luna_baseline_cm - dist) >= c['LUNA_MIN_DEPTH_CM']:
t = temp_c
depth = round(luna_baseline_cm - dist, 1)
kind = (
'flood' if t is not None and t > c['FLOOD_TEMP_THRESHOLD'] else
'slush' if t is not None and t > c['SLUSH_TEMP_THRESHOLD'] else
'snow' if strength > c['SNOW_STRENGTH_MIN'] else 'ice'
)
depth = round(luna_baseline_cm - dist, 1)
fields['accumulation'] = depth
else:
fields['accumulation'] = 0
write(c, 'accumulation', fields, tags={'accumulation_type': kind})
write(c, fields)
except Exception as e:
print(f"[TF-Luna] {e}")
write(c, 'accumulation', null)
# ── AS3935 ───────────────────────────────────────────────────────────────────
# ── AS3935 ───────────────────────────────────────────────────────────────────
lightning_distances = deque(maxlen=10)
lightning_count = 0
@@ -473,8 +369,7 @@ def init_as3935(c):
if not bus: return
try:
addr = c['AS3935_ADDR']
val = (c['AS3935_NOISE_FLOOR'] << 4) | c['AS3935_WATCHDOG']
bus.write_byte_data(addr, 0x01, val)
bus.write_byte_data(addr, 0x01, (c['AS3935_NOISE_FLOOR'] << 4) | c['AS3935_WATCHDOG'])
bus.write_byte_data(addr, 0x02, c['AS3935_SPIKE_REJ'])
bus.write_byte_data(addr, 0x00, 0x24)
print(" ✅ AS3935")
@@ -492,25 +387,27 @@ def read_as3935(c):
energy = ((data[5] & 0x1F) << 16) | (data[4] << 8) | data[3]
try_read(lambda: bus.read_byte_data(addr, 0x03))
if interrupt not in (0x04, 0x08) or energy < 1000: return
dist = data[6] & 0x3F
lightning_distances.append(dist)
lightning_count += 1
elapsed = time.time() - lightning_window_start
if elapsed >= 3600:
lightning_count = 1
lightning_window_start = time.time()
elapsed = 1
strike_rate = round(lightning_count / (elapsed / 3600), 1)
lightning_count, lightning_window_start, elapsed = 1, time.time(), 1
trend = 'Stationary'
if len(lightning_distances) >= 3:
avg_early = sum(list(lightning_distances)[:3]) / 3
avg_early = sum(list(lightning_distances)[:3]) / 3
avg_late = sum(list(lightning_distances)[-3:]) / 3
if avg_late < avg_early - 2: trend = 'Approaching'
elif avg_late > avg_early + 2: trend = 'Retreating'
write(c, 'lightning', {
write(c, {
'lightning_distance': dist,
'lightning_rate': strike_rate,
}, tags={'storm_direction': trend})
'lightning_rate': round(lightning_count / (elapsed / 3600), 1),
'storm_trend': trend,
})
except Exception as e:
print(f"[AS3935] {e}")
@@ -532,56 +429,40 @@ def read_gps(c):
'gps_satellites': int(msg.num_sats),
})
if gps_cache:
write(c, 'gps', {k: v for k, v in gps_cache.items() if v is not None})
write(c, {k: v for k, v in gps_cache.items() if v is not None})
except Exception as e:
print(f"[GPS] {e}")
write(c, 'gps', {'latitude': None, 'longitude': None, 'altitude': None, 'gps_satellites': None})
return gps_cache
def solar_elevation(lat, lon):
now = datetime.now(timezone.utc)
day = now.timetuple().tm_yday
decl = math.radians(23.45 * math.sin(math.radians((360/365)*(day-81))))
hour_angle = math.radians(((now.hour + now.minute/60) - 12) * 15 + lon)
lat_r = math.radians(lat)
el = math.degrees(math.asin(
math.sin(lat_r)*math.sin(decl) +
math.cos(lat_r)*math.cos(decl)*math.cos(hour_angle)
))
return round(el, 2)
# ── Rain ──────────────────────────────────────────────────────────────────────
def read_rain(c):
write(c, 'rain', {'precipitation': 0}, tags={'raining': False})
write(c, {'precipitation': 0, 'raining': False})
# ── Wind ──────────────────────────────────────────────────────────────────────
def read_wind(c):
write(c, 'wind', {'wind_direction': 0, 'wind_speed': 0, 'wind_gusts': 0})
write(c, {'wind_direction': 0, 'wind_speed': 0, 'wind_gusts': 0})
# ── System ────────────────────────────────────────────────────────────────────
def read_system(c):
try:
cpu_temp = None
temps = psutil.sensors_temperatures()
for key in ('cpu_thermal', 'coretemp', 'k10temp', 'acpitz'):
if key in temps:
if key in (temps := psutil.sensors_temperatures()):
cpu_temp = round(temps[key][0].current, 1)
break
cpu_freq = psutil.cpu_freq()
vm = psutil.virtual_memory()
mem = psutil.virtual_memory()
disk = psutil.disk_usage('/')
write(c, 'system', {
write(c, {
'cpu_temp': cpu_temp,
'cpu_usage': round(psutil.cpu_percent(interval=None), 1),
'cpu_freq_mhz': round(cpu_freq.current, 1) if cpu_freq else None,
'mem_used_mb': round(vm.used / 1024**2, 1),
'mem_total_mb': round(vm.total / 1024**2, 1),
'mem_percent': vm.percent,
'mem_used_mb': round(mem.used / 1024**2, 1),
'mem_total_mb': round(mem.total / 1024**2, 1),
'mem_percent': mem.percent,
'disk_used_gb': round(disk.used / 1024**3, 2),
'disk_total_gb': round(disk.total / 1024**3, 2),
'disk_percent': disk.percent,
@@ -614,24 +495,31 @@ if __name__ == '__main__':
last_temp = None
last_rh = None
last_aqi = None
while True:
c = cfg()
gps = read_gps(c)
lat = gps.get('latitude') or c['LATITUDE']
lat = gps.get('latitude') or c['LATITUDE']
lon = gps.get('longitude') or c['LONGITUDE']
alt = gps.get('altitude') or c['ALTITUDE']
sol = solar_elevation(lat, lon) if lat and lon else None
alt = gps.get('altitude') or c['ALTITUDE']
result = read_bme(c, bme_sensor, alt_m=alt)
if result and result[0] is not None:
last_temp, last_rh = result
last_temp, last_rh, last_aqi = result
dp = None
if last_temp is not None and last_rh is not None:
dp = round(last_temp - (100 - last_rh) / 5.0, 2)
flush_mpu(c)
read_compass(c)
read_ltr(c, ltr_sensor, sol, humidity=last_rh)
read_luna(c, last_temp)
read_ltr(c, ltr_sensor,
temp=last_temp, dew_point=dp,
rh=last_rh, aqi=last_aqi,
lat=lat, lon=lon)
read_luna(c)
read_as3935(c)
read_rain(c)
read_wind(c)

View File

@@ -1,7 +1,7 @@
import {cfg} from './config.mjs';
async function query(c, q, start, end) {
const url = new URL('/api/v1/query_range', c.INFLUX_URL);
const url = new URL('/api/v1/query_range', c.VM_URL);
url.searchParams.set('query', q);
url.searchParams.set('step', '60s');
if (start) url.searchParams.set('start', start);
@@ -12,7 +12,7 @@ async function query(c, q, start, end) {
}
async function queryInstant(c, q) {
const url = new URL('/api/v1/query', c.INFLUX_URL);
const url = new URL('/api/v1/query', c.VM_URL);
url.searchParams.set('query', q);
const res = await fetch(url);
const json = await res.json();
@@ -22,22 +22,16 @@ async function queryInstant(c, q) {
function metricToFields(results) {
const out = {};
for (const r of results) {
const field = r.metric.__name__ || r.metric.field;
const val = r.value?.[1] ?? r.values?.at(-1)?.[1];
if (val !== undefined) out[field] = parseFloat(val);
const val = r.value?.[1] ?? r.values?.at(-1)?.[1];
if (val !== undefined) out[r.metric.__name__] = parseFloat(val);
}
return out;
}
// vm.mjs
export async function queryCurrent() {
const c = cfg();
const out = {time: new Date()};
// Query all metrics at once, no measurement filter needed
const c = cfg();
const results = await queryInstant(c, `{__name__!=""}`);
Object.assign(out, metricToFields(results));
return out;
return {time: new Date(), ...metricToFields(results)};
}
export async function queryHourly(start, end) {
@@ -48,11 +42,10 @@ export async function queryHourly(start, end) {
const results = await query(c, `avg_over_time({__name__!=""}[1h])`, s, e);
results.forEach(r => {
const field = r.metric.__name__;
r.values.forEach(([ts, val]) => {
const time = new Date(ts * 1000).toISOString().slice(0, 13) + ':00';
if (!byTime[time]) byTime[time] = {time};
byTime[time][field] = parseFloat(val);
byTime[time][r.metric.__name__] = parseFloat(val);
});
});
return Object.values(byTime).sort((a, b) => a.time.localeCompare(b.time));
@@ -78,24 +71,30 @@ export async function queryDaily(start, end) {
});
});
const applyMinMax = (results, suffix) => results.forEach(r => {
mins.forEach(r => {
if (r.metric.__name__ !== 'temperature') return;
r.values.forEach(([ts, val]) => {
const time = new Date(ts * 1000).toISOString().slice(0, 10);
if (!byTime[time]) byTime[time] = {time};
byTime[time][suffix] = parseFloat(val);
byTime[time].env_temp_min_c = parseFloat(val);
});
});
applyMinMax(mins, 'env_temp_min_c');
applyMinMax(maxs, 'env_temp_max_c');
maxs.forEach(r => {
if (r.metric.__name__ !== 'temperature') return;
r.values.forEach(([ts, val]) => {
const time = new Date(ts * 1000).toISOString().slice(0, 10);
if (!byTime[time]) byTime[time] = {time};
byTime[time].env_temp_max_c = parseFloat(val);
});
});
return Object.values(byTime).sort((a, b) => a.time.localeCompare(b.time));
}
export async function getCoords() {
const c = cfg();
const results = await queryInstant(c, `{measurement="gps"}`);
const results = await queryInstant(c, `{__name__=~"latitude|longitude|altitude"}`);
const fields = metricToFields(results);
if (fields.latitude && fields.longitude) {

View File

@@ -2,7 +2,7 @@ import express from 'express';
import {resolve, dirname} from 'path';
import {fileURLToPath} from 'url';
import {cfg, localDateStr} from './config.mjs';
import {queryCurrent, queryHourly, queryDaily, getCoords} from './influx.mjs';
import {queryCurrent, queryHourly, queryDaily, getCoords} from './sensors.mjs';
import {getCelestialCurrent, getCelestialForecast} from './celestial.mjs';
import {apiReference} from '@scalar/express-api-reference';
import {spec} from './spec.mjs';
@@ -12,7 +12,6 @@ import {getADSB, getADSBHistory, getADSBRange, initAircraftDb} from './adsb.mjs'
import {fetchIcon, getWeatherCondition} from './openweather.mjs';
import {lastForecast, getForecast, forecastTTL} from './forecast.mjs';
import {dailyWeather, hourlyWeather} from './openmeteo.mjs';
// import {Aurora} from './aurora.mjs';
// ── Uncaught error handlers ───────────────────────────────────────────────────