Initial commit
This commit is contained in:
@@ -0,0 +1,326 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
WZDX Field Device Monitor
|
||||||
|
=========================
|
||||||
|
|
||||||
|
Polls a WZDX field-device feed on a fixed interval, detects field devices that
|
||||||
|
are no longer communicating, and emails an alert to one or more recipients.
|
||||||
|
|
||||||
|
A device is considered NOT COMMUNICATING when EITHER of these is true:
|
||||||
|
* its `device_status` is one of DOWN_STATUSES (e.g. "error" / "unknown"), OR
|
||||||
|
* its `update_date` (last report time) is older than STALE_THRESHOLD_MINUTES.
|
||||||
|
|
||||||
|
Behavior:
|
||||||
|
* One alert email per poll cycle listing every device that has *newly* gone
|
||||||
|
down since the previous cycle (no repeated spam while a device stays down).
|
||||||
|
* A recovery email when a previously-down device starts reporting normally
|
||||||
|
again.
|
||||||
|
|
||||||
|
Only dependency beyond the standard library is `requests`:
|
||||||
|
pip install requests
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
import smtplib
|
||||||
|
import logging
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from email.message import EmailMessage
|
||||||
|
|
||||||
|
import requests
|
||||||
|
from requests.auth import HTTPBasicAuth
|
||||||
|
|
||||||
|
# ============================ CONFIGURATION ============================
|
||||||
|
# --- WZDX feed ---
|
||||||
|
WZDX_FEED_URL = "https://projects.slndrtech.com/Salander/api/wzdx/v42/deviceFeed/5tjm0tva5is76sd33mp85gpj4s"
|
||||||
|
FEED_USERNAME = "JP Story"
|
||||||
|
FEED_PASSWORD = "l3tmeslndr"
|
||||||
|
FEED_TIMEOUT_SECONDS = 30
|
||||||
|
|
||||||
|
# --- Alert recipients ---
|
||||||
|
ALERT_RECIPIENTS = ["jp@slndrtech.com"]
|
||||||
|
|
||||||
|
# --- Outbound email (SMTP) ---
|
||||||
|
SMTP_HOST = "smtp-relay.brevo.com"
|
||||||
|
SMTP_PORT = 587
|
||||||
|
SMTP_USERNAME = "a7bfc2001@smtp-brevo.com"
|
||||||
|
SMTP_PASSWORD = "bskWFnIXIvjIr67"
|
||||||
|
SMTP_USE_TLS = True # True for STARTTLS (port 587)
|
||||||
|
EMAIL_FROM = "wzdx-monitor@example.com"
|
||||||
|
|
||||||
|
# --- Detection settings ---
|
||||||
|
POLL_INTERVAL_SECONDS = 60 # how often to poll the feed
|
||||||
|
STALE_THRESHOLD_MINUTES = 5 # no update in this many minutes = down
|
||||||
|
DOWN_STATUSES = {"error", "unknown"} # device_status values treated as down
|
||||||
|
SEND_RECOVERY_ALERTS = True
|
||||||
|
|
||||||
|
# Alert if the feed itself becomes unreachable (won't repeat while it stays down)
|
||||||
|
ALERT_ON_FEED_FAILURE = True
|
||||||
|
# ======================================================================
|
||||||
|
|
||||||
|
|
||||||
|
logging.basicConfig(
|
||||||
|
level=logging.INFO,
|
||||||
|
format="%(asctime)s %(levelname)-7s %(message)s",
|
||||||
|
datefmt="%Y-%m-%d %H:%M:%S",
|
||||||
|
)
|
||||||
|
log = logging.getLogger("wzdx-monitor")
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Feed fetching
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
def fetch_feed():
|
||||||
|
"""Fetch and JSON-decode the WZDX feed. Returns a dict, or raises on error."""
|
||||||
|
auth = None
|
||||||
|
if FEED_USERNAME or FEED_PASSWORD:
|
||||||
|
auth = HTTPBasicAuth(FEED_USERNAME, FEED_PASSWORD)
|
||||||
|
resp = requests.get(WZDX_FEED_URL, auth=auth, timeout=FEED_TIMEOUT_SECONDS)
|
||||||
|
resp.raise_for_status()
|
||||||
|
return resp.json()
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Feed parsing
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
def _first(props, *keys):
|
||||||
|
"""Return the first present, non-None value among the given keys."""
|
||||||
|
for k in keys:
|
||||||
|
if isinstance(props, dict) and props.get(k) is not None:
|
||||||
|
return props[k]
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def extract_devices(feed):
|
||||||
|
"""
|
||||||
|
Normalize the WZDX FeatureCollection into a list of simple device dicts:
|
||||||
|
{id, name, type, status, update_date (datetime|None)}
|
||||||
|
|
||||||
|
Field-device details usually live under properties.core_details, but this
|
||||||
|
reads flexibly so it also works if the producer flattens them.
|
||||||
|
"""
|
||||||
|
devices = []
|
||||||
|
for feature in feed.get("features", []) or []:
|
||||||
|
props = feature.get("properties", {}) or {}
|
||||||
|
core = props.get("core_details", props) # fall back to flat properties
|
||||||
|
|
||||||
|
device_id = (
|
||||||
|
feature.get("id")
|
||||||
|
or _first(core, "id", "name")
|
||||||
|
or _first(props, "id", "name")
|
||||||
|
)
|
||||||
|
name = _first(core, "name", "description") or device_id or "(unnamed device)"
|
||||||
|
dev_type = _first(core, "device_type") or _first(props, "device_type")
|
||||||
|
status = _first(core, "device_status", "status") or _first(props, "device_status", "status")
|
||||||
|
raw_update = _first(core, "update_date", "updated_timestamp") or _first(
|
||||||
|
props, "update_date", "updated_timestamp"
|
||||||
|
)
|
||||||
|
|
||||||
|
devices.append(
|
||||||
|
{
|
||||||
|
"id": str(device_id) if device_id is not None else name,
|
||||||
|
"name": name,
|
||||||
|
"type": dev_type,
|
||||||
|
"status": (status or "").lower() if isinstance(status, str) else status,
|
||||||
|
"update_date": _parse_timestamp(raw_update),
|
||||||
|
"raw_update": raw_update,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return devices
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_timestamp(value):
|
||||||
|
"""Parse an ISO-8601 timestamp (WZDX style) into an aware UTC datetime."""
|
||||||
|
if not value or not isinstance(value, str):
|
||||||
|
return None
|
||||||
|
text = value.strip().replace("Z", "+00:00")
|
||||||
|
try:
|
||||||
|
dt = datetime.fromisoformat(text)
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
if dt.tzinfo is None:
|
||||||
|
dt = dt.replace(tzinfo=timezone.utc)
|
||||||
|
return dt.astimezone(timezone.utc)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Detection
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
def evaluate_devices(devices, now=None):
|
||||||
|
"""
|
||||||
|
Return {device_id: {"device": <device>, "reason": <str>}} for every device
|
||||||
|
currently considered NOT communicating.
|
||||||
|
"""
|
||||||
|
now = now or datetime.now(timezone.utc)
|
||||||
|
down = {}
|
||||||
|
for d in devices:
|
||||||
|
reason = None
|
||||||
|
|
||||||
|
if d["status"] in DOWN_STATUSES:
|
||||||
|
reason = f"device_status = '{d['status']}'"
|
||||||
|
|
||||||
|
elif d["update_date"] is not None:
|
||||||
|
age_min = (now - d["update_date"]).total_seconds() / 60.0
|
||||||
|
if age_min > STALE_THRESHOLD_MINUTES:
|
||||||
|
reason = (
|
||||||
|
f"no update in {age_min:.1f} min "
|
||||||
|
f"(last update {d['raw_update']}, threshold "
|
||||||
|
f"{STALE_THRESHOLD_MINUTES} min)"
|
||||||
|
)
|
||||||
|
|
||||||
|
if reason:
|
||||||
|
down[d["id"]] = {"device": d, "reason": reason}
|
||||||
|
return down
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Email
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
def send_email(subject, body):
|
||||||
|
msg = EmailMessage()
|
||||||
|
msg["Subject"] = subject
|
||||||
|
msg["From"] = EMAIL_FROM
|
||||||
|
msg["To"] = ", ".join(ALERT_RECIPIENTS)
|
||||||
|
msg.set_content(body)
|
||||||
|
|
||||||
|
with smtplib.SMTP(SMTP_HOST, SMTP_PORT, timeout=30) as server:
|
||||||
|
if SMTP_USE_TLS:
|
||||||
|
server.starttls()
|
||||||
|
if SMTP_USERNAME or SMTP_PASSWORD:
|
||||||
|
server.login(SMTP_USERNAME, SMTP_PASSWORD)
|
||||||
|
server.send_message(msg)
|
||||||
|
log.info("Alert email sent to %s | %s", ", ".join(ALERT_RECIPIENTS), subject)
|
||||||
|
|
||||||
|
|
||||||
|
def _device_line(entry):
|
||||||
|
d = entry["device"]
|
||||||
|
dtype = f" [{d['type']}]" if d.get("type") else ""
|
||||||
|
return f" - {d['name']}{dtype} (id: {d['id']})\n reason: {entry['reason']}"
|
||||||
|
|
||||||
|
|
||||||
|
def send_down_alert(newly_down):
|
||||||
|
ts = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S UTC")
|
||||||
|
count = len(newly_down)
|
||||||
|
subject = f"[WZDX ALERT] {count} device(s) not communicating"
|
||||||
|
lines = [
|
||||||
|
f"{count} WZDX field device(s) stopped communicating as of {ts}.",
|
||||||
|
f"Feed: {WZDX_FEED_URL}",
|
||||||
|
"",
|
||||||
|
"Affected devices:",
|
||||||
|
]
|
||||||
|
lines += [_device_line(e) for e in newly_down.values()]
|
||||||
|
send_email(subject, "\n".join(lines))
|
||||||
|
|
||||||
|
|
||||||
|
def send_recovery_alert(recovered):
|
||||||
|
ts = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S UTC")
|
||||||
|
count = len(recovered)
|
||||||
|
subject = f"[WZDX RECOVERED] {count} device(s) communicating again"
|
||||||
|
lines = [
|
||||||
|
f"{count} WZDX field device(s) resumed communicating as of {ts}.",
|
||||||
|
f"Feed: {WZDX_FEED_URL}",
|
||||||
|
"",
|
||||||
|
"Recovered devices:",
|
||||||
|
]
|
||||||
|
for d in recovered.values():
|
||||||
|
dtype = f" [{d['type']}]" if d.get("type") else ""
|
||||||
|
lines.append(f" - {d['name']}{dtype} (id: {d['id']})")
|
||||||
|
send_email(subject, "\n".join(lines))
|
||||||
|
|
||||||
|
|
||||||
|
def send_feed_failure_alert(error):
|
||||||
|
subject = "[WZDX ALERT] Feed unreachable"
|
||||||
|
body = (
|
||||||
|
f"The WZDX monitor could not retrieve the feed as of "
|
||||||
|
f"{datetime.now(timezone.utc).strftime('%Y-%m-%d %H:%M:%S UTC')}.\n\n"
|
||||||
|
f"Feed: {WZDX_FEED_URL}\nError: {error}"
|
||||||
|
)
|
||||||
|
send_email(subject, body)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Main loop
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
def run_once(state):
|
||||||
|
"""
|
||||||
|
Execute a single poll cycle, mutating `state` in place.
|
||||||
|
|
||||||
|
state = {
|
||||||
|
"down": {id: {"device", "reason"}}, # devices currently considered down
|
||||||
|
"feed_failed": bool, # whether last cycle failed to fetch
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
feed = fetch_feed()
|
||||||
|
except Exception as exc: # network / auth / decode errors
|
||||||
|
log.error("Failed to fetch feed: %s", exc)
|
||||||
|
if ALERT_ON_FEED_FAILURE and not state["feed_failed"]:
|
||||||
|
try:
|
||||||
|
send_feed_failure_alert(exc)
|
||||||
|
except Exception as mail_exc:
|
||||||
|
log.error("Could not send feed-failure email: %s", mail_exc)
|
||||||
|
state["feed_failed"] = True
|
||||||
|
return
|
||||||
|
|
||||||
|
if state["feed_failed"]:
|
||||||
|
log.info("Feed reachable again.")
|
||||||
|
state["feed_failed"] = False
|
||||||
|
|
||||||
|
devices = extract_devices(feed)
|
||||||
|
present_ids = {d["id"] for d in devices}
|
||||||
|
down_now = evaluate_devices(devices)
|
||||||
|
down_before = state["down"]
|
||||||
|
|
||||||
|
# Newly down = down now but not previously flagged.
|
||||||
|
newly_down = {k: v for k, v in down_now.items() if k not in down_before}
|
||||||
|
|
||||||
|
# Recovered = was down, is still present in the feed, and is now healthy.
|
||||||
|
recovered = {
|
||||||
|
k: v["device"]
|
||||||
|
for k, v in down_before.items()
|
||||||
|
if k in present_ids and k not in down_now
|
||||||
|
}
|
||||||
|
# Devices that were down and have vanished from the feed are dropped quietly
|
||||||
|
# (a work zone ending legitimately removes its devices).
|
||||||
|
vanished = [k for k in down_before if k not in present_ids and k not in down_now]
|
||||||
|
|
||||||
|
log.info(
|
||||||
|
"Polled %d device(s): %d down (%d new), %d recovered, %d vanished.",
|
||||||
|
len(devices), len(down_now), len(newly_down), len(recovered), len(vanished),
|
||||||
|
)
|
||||||
|
|
||||||
|
if newly_down:
|
||||||
|
try:
|
||||||
|
send_down_alert(newly_down)
|
||||||
|
except Exception as exc:
|
||||||
|
log.error("Could not send down alert: %s", exc)
|
||||||
|
|
||||||
|
if recovered and SEND_RECOVERY_ALERTS:
|
||||||
|
try:
|
||||||
|
send_recovery_alert(recovered)
|
||||||
|
except Exception as exc:
|
||||||
|
log.error("Could not send recovery alert: %s", exc)
|
||||||
|
|
||||||
|
state["down"] = down_now
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
log.info("Starting WZDX device monitor (poll every %ss).", POLL_INTERVAL_SECONDS)
|
||||||
|
log.info("Feed: %s", WZDX_FEED_URL)
|
||||||
|
state = {"down": {}, "feed_failed": False}
|
||||||
|
while True:
|
||||||
|
start = time.monotonic()
|
||||||
|
try:
|
||||||
|
run_once(state)
|
||||||
|
except Exception as exc: # never let the loop die
|
||||||
|
log.exception("Unexpected error in poll cycle: %s", exc)
|
||||||
|
elapsed = time.monotonic() - start
|
||||||
|
time.sleep(max(0, POLL_INTERVAL_SECONDS - elapsed))
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
try:
|
||||||
|
main()
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
log.info("Stopped by user.")
|
||||||
|
sys.exit(0)
|
||||||
Reference in New Issue
Block a user