Initial commit: Resensys site status app

This commit is contained in:
lakshay
2026-06-29 13:32:41 -04:00
commit 6617c33f23
8 changed files with 4658 additions and 0 deletions

27
.gitignore vendored Normal file
View File

@@ -0,0 +1,27 @@
# Python
.venv/
__pycache__/
*.pyc
*.pyo
.pytest_cache/
# Secrets & local config
.env
.env.*
# OS / editor
.DS_Store
Thumbs.db
.vscode/
# Logs & caches
*.log
.cache/
# Backups / old credentials
*OLD_PASSWORD*
api_bkup.py
api_copy_*.py
# Lambda Dependencies
lambda_dependencies/

658
api.py Normal file
View File

@@ -0,0 +1,658 @@
#!/usr/bin/env python3
import os
import time
import json
import re
import requests
import redis
from flask import Flask, jsonify, request, send_from_directory, Blueprint
from dotenv import load_dotenv
bp = Blueprint("api_extra", __name__)
API_KEY = "f1bd537847b87739d5f27b54c2b38cd3fb8aa6e1d19592e6363878805e47f6cd"
API_URL = "https://rai.resensys.cloud:8443/api/v3"
SITE_ID_RE = re.compile(r"^[A-Za-z0-9\-_.:]+$") # simple safety gate for keys
cr123_types = ["MRT", "STRED", "STRAIN", "4CHSTR", "DISP", "2DHRT", "HUT", "HPA", "WETNESS"]
rechargables = ["SNMX", "WLS_S"]
load_dotenv()
# REDIS_HOST = os.getenv("REDIS_HOST", "127.0.0.1")
REDIS_HOST = os.getenv("REDIS_HOST", "172.31.25.139")
REDIS_PORT = int(os.getenv("REDIS_PORT", "6380"))
REDIS_DB = int(os.getenv("REDIS_DB", "0"))
ONLINE_SECS = 90 * 60
RECENT_SECS = 24 * 3600
app = Flask(__name__, static_folder="static")
r = redis.Redis(host=REDIS_HOST, port=REDIS_PORT, db=REDIS_DB, decode_responses=True)
# Parse DF_BY_TYPE from the same env var the Lambda uses (optional but convenient)
try:
DF_BY_TYPE = json.loads(os.getenv("DF_BY_TYPE_JSON", '{"STRED":[10002]}'))
DF_BY_TYPE = {k: [int(x) for x in v] for k, v in DF_BY_TYPE.items()}
except Exception:
DF_BY_TYPE = {"STRED": [10002]}
def _hget_int(h, key, default=0):
try:
return int(h.get(key) or default)
except Exception:
return default
def _ok_site_id(sid: str) -> bool:
return bool(sid and SITE_ID_RE.match(sid))
def _meta_key(sid: str) -> str:
return f"site:{sid}:meta"
def _site_tags_key(sid: str) -> str:
return f"site:{sid}:tags"
def _tag_sites_key(tag: str) -> str:
return f"tag:{tag}:sites"
def _derive_site_state(last_ts: int, now_ts: int | None = None) -> str:
"""
Derive state from last contact time.
Online : within last 90 minutes
recently offline : older than 90 minutes but within last 24 hours
Offline : older than 24 hours, or no timestamp
"""
now_ts = now_ts or int(time.time())
if not last_ts or last_ts <= 0:
return "offline"
age = now_ts - last_ts
if age < 0:
# clock skew / future timestamp
age = 0
if age <= ONLINE_SECS:
return "online"
elif age <= RECENT_SECS:
return "recent"
else:
return "offline"
def _safe_json_post(endpoint: str, payload: dict, timeout: int = 10):
try:
resp = requests.post(
f"{API_URL}/{endpoint}",
headers={
"X-API-Key": API_KEY,
"Content-Type": "application/json"
},
json=payload,
timeout=timeout,
)
except requests.RequestException as e:
return None, (jsonify({"ok": False, "error": f"upstream error: {e}"}), 502)
if resp.status_code != 200:
return None, (jsonify({
"ok": False,
"status_code": resp.status_code,
"body": resp.text
}), 502)
try:
upstream = resp.json()
except ValueError:
upstream = {}
return upstream, None
def _infer_passed(upstream: dict, default: bool = True) -> bool:
if isinstance(upstream.get("ok"), bool):
return upstream["ok"]
status = str(upstream.get("status", "")).lower()
if status:
return ("ok" in status) or ("pass" in status) or ("good" in status) or ("within" in status)
return default
@app.get("/")
def root():
return send_from_directory("templates", "index.html")
@app.get("/api/site-grid")
def site_grid():
q = (request.args.get("q") or "").strip().lower()
requested_state = (request.args.get("state") or "").strip().lower()
port = (request.args.get("port") or "").strip().lower()
tag = (request.args.get("tag") or "").strip()
limit = min(int(request.args.get("limit", 2000)), 5000)
now_ts = int(time.time())
items = []
# Use scan_iter instead of keys for better Redis behavior at scale
for k in r.scan_iter("site:*:summary"):
parts = k.split(":")
if len(parts) < 3:
continue
sid = parts[1]
data = r.hgetall(k) or {}
meta = r.hgetall(_meta_key(sid)) or {}
site_name = meta.get("name", "")
tags = sorted(list(r.smembers(_site_tags_key(sid))))
# Search site_id and site_name
if q and q not in sid.lower() and q not in site_name.lower():
continue
if port and (data.get("port") or "").lower() != port:
continue
if tag and tag not in tags:
continue
last_ts = _hget_int(data, "last_contact_ts", 0)
derived_state = _derive_site_state(last_ts, now_ts)
# Filter using derived state, not stored redis state
if requested_state and derived_state != requested_state:
continue
age_seconds = None if last_ts <= 0 else max(0, now_ts - last_ts)
items.append({
"site_id": sid,
"site_name": site_name,
"site_tags": tags,
"state": derived_state,
"port": data.get("port"),
"last_contact_ts": last_ts if last_ts > 0 else None,
"last_contact_age_sec": age_seconds,
"last_contact_age_min": (age_seconds // 60) if age_seconds is not None else None,
"num_gateways": _hget_int(data, "num_gateways", 0),
"num_devices": _hget_int(data, "num_devices", 0),
"num_inactive_24h": _hget_int(data, "num_inactive_24h", 0),
})
if len(items) >= limit:
break
items.sort(key=lambda x: x["last_contact_ts"] or 0, reverse=True)
return jsonify(items)
@app.get("/api/devices")
def devices():
"""Optional: list devices for a given site_id."""
sid = request.args.get("site_id")
if not sid:
return jsonify({"error": "site_id required"}), 400
# Sets are unordered; make a stable order for the UI
dids = sorted(r.smembers(f"site:{sid}:devices"))
# Fetch all device hashes in one round-trip
pipe = r.pipeline()
for d in dids:
pipe.hgetall(f"device:{d}")
devrows = pipe.execute()
out = []
for d, row in zip(dids, devrows):
lat_h = r.hgetall(f"device:{d}:latest")
latest_rssi = lat_h.get("rssi") or ""
latest_voltage = lat_h.get("voltage") or ""
row = row or {}
device_class = ""
if row.get("device_type_code") in cr123_types:
device_class = "non_recharge"
elif row.get("device_type_code") in rechargables:
device_class = "recharge"
out.append({
"device_id": d,
"site_id": row.get("site_id"),
"local_address": row.get("local_address"),
"last_voltage_ts": int(row.get("last_voltage_ts") or 0),
"last_gateway_id": row.get("last_gateway_id"),
"device_type": row.get("device_type") or "",
"device_class": device_class,
"device_type_code": row.get("device_type_code") or "",
"rssi": latest_rssi,
"voltage": latest_voltage
})
return jsonify(out)
@app.route("/api/device-cache", methods=["POST"])
def device_cache():
"""
Body: {"device_ids": ["AA-BB-CC-DD", ...]}
Returns core device info + cached DF values based on device type code.
"""
data = request.get_json(silent=True) or {}
dids = data.get("device_ids") or []
if not isinstance(dids, list) or not dids:
return jsonify({"ok": False, "error": "device_ids required"}), 400
devices_out = []
df_map = {}
for did in dids:
key_dev = f"device:{did}"
key_latest = f"device:{did}:latest"
dev_h = r.hgetall(key_dev)
lat_h = r.hgetall(key_latest)
print("lat_h: ", lat_h)
dev_type = dev_h.get("device_type") or ""
dev_type_code = dev_h.get("device_type_code") or ""
site_id = dev_h.get("site_id") or lat_h.get("siteID") or ""
local_addr = dev_h.get("local_address") or lat_h.get("localAddress") or ""
latest_ts = _hget_int(dev_h, "last_voltage_ts", _hget_int(lat_h, "timestamp", 0))
latest_rssi = lat_h.get("rssi") or ""
latest_voltage = lat_h.get("voltage") or ""
devices_out.append({
"device_id": did,
"site_id": site_id,
"device_type": dev_type,
"device_type_code": dev_type_code,
"local_address": local_addr,
"latest_ts": latest_ts,
"rssi": latest_rssi,
"voltage": latest_voltage
})
dfs_cfg = [int(x) for x in DF_BY_TYPE.get(dev_type_code, [])]
per_df = {}
# 1) Try configured DFs first
for df in dfs_cfg:
h = r.hgetall(f"device:{did}:df:{df}")
if h:
per_df[str(df)] = {
"value": h.get("value", ""),
"optional": h.get("optional", ""),
"ts": h.get("ts", ""),
"type_code": h.get("type_code", dev_type_code),
"cached": True,
}
# 2) Fall back to any seen DFs
if not per_df:
seen = sorted(
r.smembers(f"device:{did}:dataformats"),
key=lambda x: int(x) if str(x).isdigit() else 0
)
for df in seen:
key = f"device:{did}:df:{df}"
h = r.hgetall(key)
per_df[str(df)] = {
"value": h.get("value", "") if h else "10",
"optional": h.get("optional", "") if h else "10",
"ts": h.get("ts", "") if h else "10",
"type_code": h.get("type_code", dev_type_code),
"cached": bool(h),
}
df_map[did] = per_df
return jsonify({"ok": True, "devices": devices_out, "df_map": df_map})
@app.get("/api/site-meta")
def get_site_meta():
sid = (request.args.get("site_id") or "").strip()
if not _ok_site_id(sid):
return jsonify({"error": "invalid or missing site_id"}), 400
meta = r.hgetall(_meta_key(sid)) or {}
tags = sorted(list(r.smembers(_site_tags_key(sid))))
return jsonify({
"site_id": sid,
"name": meta.get("name", ""),
"tags": tags,
})
@app.post("/api/site-meta")
def post_site_meta():
try:
payload = request.get_json(force=True, silent=False) or {}
except Exception:
return jsonify({"error": "invalid JSON"}), 400
sid = (payload.get("site_id") or "").strip()
name = (payload.get("name") or "").strip()
tags = payload.get("tags") or []
print(f"Trying to add tags {tags} for site {sid}.")
if not _ok_site_id(sid):
return jsonify({"error": "invalid or missing site_id"}), 400
if not isinstance(tags, list) or not all(isinstance(t, str) and t.strip() for t in tags):
return jsonify({"error": "tags must be a non-empty list of strings (or empty list)"}), 400
norm_tags = []
seen = set()
for t in tags:
tt = t.strip()
if tt and tt not in seen:
seen.add(tt)
norm_tags.append(tt)
site_meta_key = _meta_key(sid)
site_tags_key = _site_tags_key(sid)
old_tags = set(r.smembers(site_tags_key))
new_tags = set(norm_tags)
to_add = list(new_tags - old_tags)
to_remove = list(old_tags - new_tags)
pipe = r.pipeline()
pipe.hset(site_meta_key, mapping={"name": name})
for t in to_add:
pipe.sadd(site_tags_key, t)
pipe.sadd(_tag_sites_key(t), sid)
for t in to_remove:
pipe.srem(site_tags_key, t)
pipe.srem(_tag_sites_key(t), sid)
pipe.execute()
return jsonify({"site_id": sid, "name": name, "tags": norm_tags})
@app.get("/api/site-bundle")
def site_bundle():
sid = (request.args.get("site_id") or "").strip()
if not sid:
return jsonify({"error": "site_id required"}), 400
data = r.hgetall(f"site:{sid}:bundle") or {}
out = {
"site_id": sid,
"firmware_version": data.get("firmware_version", ""),
"error_code": data.get("error_code", ""),
"iccid": data.get("iccid", ""),
"imei": data.get("imei", ""),
"iccid_str": data.get("iccid_str", ""),
"imei_str": data.get("imei_str", ""),
"bundle_updated_at": int(data.get("bundle_updated_at", 0) or 0),
}
return jsonify(out)
@app.route("/internal/check-signal", methods=["POST"])
def check_signal():
data = request.get_json(silent=True) or {}
did = data.get("did")
if not did:
return jsonify({"ok": False, "error": "did required"}), 400
upstream, err = _safe_json_post("rssi_status", {"did": did})
if err:
return err
print("RSSI backend response: ", upstream)
latest = upstream.get("latest", "unknown")
status = str(upstream.get("status", "")).lower()
if status == "good":
explain = f"RSSI and device signal are GOOD with latest reading being {latest}."
else:
explain = f"RSSI and device signal are WEAK with latest reading being {latest}."
passed = _infer_passed(upstream, default=True)
return jsonify({
"ok": passed,
"raw": upstream,
"explain": explain,
"metrics": {"RSSI": latest}
})
@app.route("/internal/check-strain", methods=["POST"])
def check_strain():
data = request.get_json(silent=True) or {}
did = data.get("did")
if not did:
return jsonify({"ok": False, "error": "did required"}), 400
upstream, err = _safe_json_post("strain_status", {"did": did})
if err:
return err
print("Strain backend response: ", upstream)
latest = upstream.get("latest", "unknown")
i2_median = upstream.get("i2_median", "unknown")
status = str(upstream.get("status", "")).lower()
if status == "good":
explain = (
f"Strain reading quality is GOOD with latest raw strain reading being {latest} "
f"and infofield2 within range -90 +/- 20 counts at {i2_median}."
)
else:
explain = (
f"Strain quality is not good and the median infofield2 value is out of range "
f"of -90 +/- 20 counts at {i2_median}."
)
passed = _infer_passed(upstream, default=True)
return jsonify({
"ok": passed,
"raw": upstream,
"explain": explain,
"metrics": {"Strain": latest, "Infofield2": i2_median}
})
@app.route("/internal/check-hpa", methods=["POST"])
def check_hpa():
data = request.get_json(silent=True) or {}
did = data.get("did")
if not did:
return jsonify({"ok": False, "error": "did required"}), 400
upstream, err = _safe_json_post("hpa_status", {"did": did})
if err:
return err
print("HPA backend response: ", upstream)
latest = upstream.get("latest", "unknown")
status = str(upstream.get("status", "")).lower()
if status == "good":
explain = f"Accelerometer readings are GOOD with latest reading for HPA-Z being {latest}."
else:
explain = f"Accelerometer readings are POOR with latest reading for HPA-Z being {latest}."
try:
firmware_version = float(upstream.get("firmware_version", 0) or 0)
except Exception:
firmware_version = 0.0
try:
if firmware_version >= 13:
accel_threshold = float(upstream.get("i2", 0) or 0)
else:
accel_threshold = float(upstream.get("i1", 0) or 0)
except Exception:
accel_threshold = 0.0
if accel_threshold < 50:
explain = "Trigger threshold is too low. Consider raising to at least 180 mg."
passed = _infer_passed(upstream, default=True)
return jsonify({
"ok": passed,
"raw": upstream,
"explain": explain,
"metrics": {
"HPA-Z (latest)": latest,
"Threshold": f"{accel_threshold} mg"
}
})
@app.route("/internal/check-wls", methods=["POST"])
def check_wls():
data = request.get_json(silent=True) or {}
did = data.get("did")
if not did:
return jsonify({"ok": False, "error": "did required"}), 400
upstream, err = _safe_json_post("wls_status", {"did": did})
if err:
return err
print("WLS backend response: ", upstream)
latest = upstream.get("latest", "unknown")
status = str(upstream.get("status", "")).lower()
if status == "good":
explain = f"Water level readings are GOOD with latest reading for level being {latest}."
else:
explain = f"Water level readings are POOR with latest reading for level being {latest}."
passed = _infer_passed(upstream, default=True)
return jsonify({
"ok": passed,
"raw": upstream,
"explain": explain,
"metrics": {"Level": latest}
})
@app.route("/internal/check-battery", methods=["POST"])
def check_battery():
data = request.get_json(silent=True) or {}
did = data.get("did")
if not did:
return jsonify({"ok": False, "error": "did required"}), 400
upstream, err = _safe_json_post("battery_status", {"did": did})
if err:
return err
print("Battery backend response: ", upstream)
latest = upstream.get("latest", "unknown")
status = str(upstream.get("status", "")).lower()
if status == "good":
explain = f"Battery is GOOD with latest reading being {latest}."
else:
explain = f"Battery is LOW with latest reading being {latest}."
passed = _infer_passed(upstream, default=False)
return jsonify({
"ok": passed,
"raw": upstream,
"explain": explain,
"metrics": {"Battery Voltage": latest}
})
@app.route("/internal/check-charge", methods=["POST"])
def check_charge():
data = request.get_json(silent=True) or {}
did = data.get("did")
if not did:
return jsonify({"ok": False, "error": "did required"}), 400
upstream, err = _safe_json_post("charge_status", {"did": did})
if err:
return err
print("Charge backend response: ", upstream)
max_current = upstream.get("max", "unknown")
status = str(upstream.get("status", "")).lower()
if status == "good":
explain = f"Charging is GOOD with maximum reading being {max_current} mA."
else:
explain = (
f"Charging is LOW with maximum reading being {max_current} mA. "
f"Please try adjusting the solar panel toward south sky."
)
passed = _infer_passed(upstream, default=False)
return jsonify({
"ok": passed,
"raw": upstream,
"explain": explain,
"metrics": {"Charging Current": f"{max_current} mA"}
})
@app.route("/api/device-info", methods=["GET"])
def api_device_info():
"""
Internal proxy for retrieving a device's type information.
Frontend calls /api/device-info?did=15-03-77-44
This server then POSTs to the upstream Resensys API with the API key.
"""
did = request.args.get("did")
if not did:
return jsonify({"ok": False, "error": "Query parameter 'did' is required"}), 400
upstream, err = _safe_json_post("device_type", {"did": did})
if err:
return err
device_type = upstream.get("device_type") or upstream.get("type")
code_name = upstream.get("code_name")
print(f"Device {did} is {code_name}.")
return jsonify({
"ok": True,
"device_id": did,
"device_type": device_type,
"code_name": code_name,
"raw": upstream
})
if __name__ == "__main__":
# run: python api.py
app.run(host="0.0.0.0", port=5556, debug=True)

39
checkAllSites.py Normal file
View File

@@ -0,0 +1,39 @@
import mysql.connector.python
import requests
# The Lambda endpoint
URL = "https://7ft6zzxgc3fr34nmrd5t63cel40vzvhp.lambda-url.us-east-1.on.aws/"
# List of site IDs to check
site_ids = [
"48-01",
"48-02",
"48-03",
# add more here...
]
# Common headers
HEADERS = {
"Content-Type": "application/json"
}
MYSQL_USER = "MKK"
MYSQL_PW = "BoxPaxMint12"
MYSQL_HOST = "resensys-cloudbase.coxrusc9yue4.us-east-1.rds.amazonaws.com"
MYSQL_PORT = 3306
def get_sites():
print("This is where we get the site IDs...")
if __name__ == "__main__":
for site_id in site_ids:
payload = {"site_id": site_id}
try:
response = requests.post(URL, headers=HEADERS, json=payload, timeout=10)
response.raise_for_status() # raises for 4xx/5xx
print(f"[{site_id}] OK: {response.json()}")
except requests.exceptions.RequestException as e:
print(f"[{site_id}] ERROR: {e}")

11
check_ingest.py Normal file
View File

@@ -0,0 +1,11 @@
import redis
r = redis.Redis(host="172.31.25.139", port=6380, db=0, decode_responses=True)
site_id = "49-59" # replace with the SID inferred from filename
print("Devices:", r.smembers(f"site:{site_id}:devices"))
print("LocalAddressMap:", r.hgetall(f"site:{site_id}:localAddressMap"))
for did in r.smembers(f"site:{site_id}:devices"):
print(f"Device {did} latest:", r.hgetall(f"device:{did}:latest"))

4
requirements.txt Normal file
View File

@@ -0,0 +1,4 @@
Flask==3.0.3
redis==5.0.6
python-dotenv==1.0.1
pytest==8.3.2

BIN
static/logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 183 KiB

1067
templates/index copy.html Normal file

File diff suppressed because it is too large Load Diff

2852
templates/index.html Normal file

File diff suppressed because it is too large Load Diff