From 6eefd1b86b5e0f5ba6290526adc6ed7e76306bca Mon Sep 17 00:00:00 2001 From: ztimson Date: Fri, 26 Jun 2026 23:58:32 -0400 Subject: [PATCH] Switched from influx to victoria metrics (more light weight for pi) --- sensors/main.py | 410 +++++++++---------------- server/src/{influx.mjs => sensors.mjs} | 37 ++- server/src/server.mjs | 3 +- 3 files changed, 168 insertions(+), 282 deletions(-) rename server/src/{influx.mjs => sensors.mjs} (76%) diff --git a/sensors/main.py b/sensors/main.py index 71261bf..0820bb3 100644 --- a/sensors/main.py +++ b/sensors/main.py @@ -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) diff --git a/server/src/influx.mjs b/server/src/sensors.mjs similarity index 76% rename from server/src/influx.mjs rename to server/src/sensors.mjs index 3749380..b760371 100644 --- a/server/src/influx.mjs +++ b/server/src/sensors.mjs @@ -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) { diff --git a/server/src/server.mjs b/server/src/server.mjs index 3b58551..11458f6 100644 --- a/server/src/server.mjs +++ b/server/src/server.mjs @@ -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 ───────────────────────────────────────────────────