From 21d7988f3bb90acd86b567853a08cfee762bb0d1 Mon Sep 17 00:00:00 2001 From: Mark Mutti Date: Wed, 11 Feb 2026 12:47:52 -0800 Subject: [PATCH 1/4] Add CHP CAD feed integration for fire incident enrichment MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fetches CHP's public statewide XML feed (media.chp.ca.gov) and correlates incidents by GPS proximity to active fires in our DB. Sends change notifications (Telegram + SMS) for each new CHP dispatch detail line. Zero new pip dependencies — uses existing requests + lxml. Co-Authored-By: Claude Opus 4.6 --- .development/chp_mock_data.xml | 64 ++++++++ .gitignore | 3 +- README.md | 3 + firebot.py | 284 +++++++++++++++++++++++++++++++++ 4 files changed, 353 insertions(+), 1 deletion(-) create mode 100644 .development/chp_mock_data.xml diff --git a/.development/chp_mock_data.xml b/.development/chp_mock_data.xml new file mode 100644 index 0000000..606cca6 --- /dev/null +++ b/.development/chp_mock_data.xml @@ -0,0 +1,64 @@ + + +
+ + + "Feb 11 2026 2:30PM" + "1125-Traffic Hazard" + "I5 N / SR14 N" + "NB I5 AT SR14 CONNECTOR" + "Newhall" + "" + "34326000:118470000" + +
+ "Feb 11 2026 2:31PM" + "[1] SMOKE AND FLAMES VISIBLE FROM ROADWAY" +
+
+ "Feb 11 2026 2:35PM" + "[2] LANE 1 BLOCKED DUE TO FIRE ACTIVITY" +
+ + "Feb 11 2026 2:32PM" + "Unit Assigned" + + + "Feb 11 2026 2:40PM" + "Unit At Scene" + +
+
+ + "Feb 11 2026 1:15PM" + "1183-Trfc Collision-Unkn Inj" + "US101 S / Topanga Cyn Blvd" + "SB 101 JNO TOPANGA CYN" + "Malibu" + "" + "34040000:118600000" + +
+ "Feb 11 2026 1:16PM" + "[1] 2 VEH TC SB 101 BLOCKING LN 2" +
+
+
+ + "Feb 11 2026 12:00PM" + "SPINOUT" + "SR2 / Big Pines Hwy" + "" + "Wrightwood" + "" + "" + +
+ "Feb 11 2026 12:01PM" + "[1] SPINOUT IN SNOW NO BLOCKING" +
+
+
+
+
+
diff --git a/.gitignore b/.gitignore index 67d5e1c..402b0ae 100644 --- a/.gitignore +++ b/.gitignore @@ -2,4 +2,5 @@ db*.json .env *.DS_Store *log.json -__pycache__/ \ No newline at end of file +__pycache__/ +*env* \ No newline at end of file diff --git a/README.md b/README.md index 2930b4d..4bcc0bf 100644 --- a/README.md +++ b/README.md @@ -101,6 +101,9 @@ The only required key is `NF_IDENTIFIER`. It is also the only value that is not | `TWILIO_AUTH_TOKEN` | N | string | Your secret Twilio API Auth Token, found in your Twilio dashboard | N/A | | `TWILIO_NUMBER` | N | string | Your Twilio-registered phone number | `+18184567890` | | `URL_SHORT` | N | string | The domain name you want to use as a URL shortener in SMS | `lm7.us` | +| `CHP_ENABLED` | N | boolean | Enable CHP CAD feed integration to enrich fire incidents with nearby CHP dispatch details. Defaults to `False` | `True` | +| `CHP_PROXIMITY_MILES` | N | float | Radius in miles to match CHP incidents to fires. Defaults to `5` | `5` | +| `CHP_FEED_URL` | N | string | CHP XML feed URL (overridable for testing). Defaults to the public CHP statewide feed | `https://media.chp.ca.gov/sa_xml/sa.xml` | ### Setup: Telegram (Optional) Read about how to setup up a Telegram channel and bot/credentials: [Bots: An introduction for developers](https://core.telegram.org/bots/#3-how-do-i-create-a-bot) diff --git a/firebot.py b/firebot.py index a31201a..cd378a4 100755 --- a/firebot.py +++ b/firebot.py @@ -13,6 +13,8 @@ import os import sys import json +import math +import hashlib import re import json_log_formatter import requests @@ -20,6 +22,7 @@ from twilio.rest import Client from dotenv import dotenv_values from lxml import html +from lxml import etree # Initialize JSON logging formatter = json_log_formatter.JSONFormatter() @@ -40,6 +43,7 @@ db = tinydb.TinyDB(exec_path + '/db.json') db_contacts = tinydb.TinyDB(exec_path + '/db_contacts.json') db_urls = tinydb.TinyDB(exec_path + '/db_urls.json') +db_chp = tinydb.TinyDB(exec_path + '/db_chp.json') # ------------------------------------------------------------------------------ """ @@ -60,6 +64,13 @@ secrets['WILDWEB_E'] = False config['wildcad_url'] = "http://www.wildcad.net/WCCA-" + secrets['NF_IDENTIFIER'] + "recent.htm" +if 'CHP_ENABLED' in secrets and secrets['CHP_ENABLED'].strip().lower() == 'true': + config['chp_enabled'] = True + config['chp_proximity_miles'] = float(secrets.get('CHP_PROXIMITY_MILES', '5')) + config['chp_feed_url'] = secrets.get('CHP_FEED_URL', 'https://media.chp.ca.gov/sa_xml/sa.xml') +else: + config['chp_enabled'] = False + # ------------------------------------------------------------------------------ for arg in sys.argv: @@ -74,6 +85,7 @@ config['wildcad_url'] = '.development/wildweb-e_mock_data.json' else: config['wildcad_url'] = '.development/wildcad_mock_data.htm' + config['chp_mock_path'] = '.development/chp_mock_data.xml' logger.debug('Using mock data: %s', config['wildcad_url']) # ------------------------------------------------------------------------------ @@ -294,6 +306,16 @@ def empty_fill(input_str): # ------------------------------------------------------------------------------ +def strip_chp_quotes(input_str): + """ + Strips surrounding quote characters from CHP XML text values + """ + if input_str: + return input_str.strip().strip('"') + return '' + +# ------------------------------------------------------------------------------ + def event_has_changed(inci_dict, inci_db_entry_dict): """ Given a new and stored event dict, determines if any values have changed, @@ -723,6 +745,41 @@ def decimal_degrees(degrees, minutes): # ------------------------------------------------------------------------------ +def parse_chp_latlon(latlon_str): + """ + Converts CHP LATLON format to decimal degrees. + Input: "38825387:120028530" + Output: (38.825387, -120.028530) + Returns False if parsing fails. + """ + latlon_str = strip_chp_quotes(latlon_str) + if not latlon_str or ':' not in latlon_str: + return False + + try: + parts = latlon_str.split(':') + lat = float(parts[0]) / 1000000 + lon = -(float(parts[1]) / 1000000) + return (lat, lon) + except (ValueError, IndexError): + return False + +# ------------------------------------------------------------------------------ + +def haversine_distance_miles(lat1, lon1, lat2, lon2): + """ + Calculates the great-circle distance in miles between two lat/lon points + """ + R = 3959 + lat1_r, lat2_r = math.radians(lat1), math.radians(lat2) + dlat = math.radians(lat2 - lat1) + dlon = math.radians(lon2 - lon1) + a = math.sin(dlat / 2) ** 2 + \ + math.cos(lat1_r) * math.cos(lat2_r) * math.sin(dlon / 2) ** 2 + return R * 2 * math.atan2(math.sqrt(a), math.sqrt(1 - a)) + +# ------------------------------------------------------------------------------ + def process_alerts(inci_list): """ The heart of this script, this compares what we know with what @@ -895,9 +952,236 @@ def perform_cleanup(inci_list): # ------------------------------------------------------------------------------ +def fetch_chp_feed(): + """ + Data source: CHP CAD XML Feed + Fetches and parses all incidents from the CHP statewide XML feed. + Returns a list of dicts, one per CHP log entry. + """ + try: + if MOCK_DATA: + with open(config['chp_mock_path'], 'r', encoding='utf-8') as file: + page = file.read() + else: + response = requests.get(config['chp_feed_url'], timeout=15) + page = response.content + + tree = etree.fromstring(page if isinstance(page, bytes) else page.encode('utf-8')) + chp_incidents = [] + + for log in tree.xpath('//Log'): + latlon_raw = log.findtext('LATLON', default='') + coords = parse_chp_latlon(latlon_raw) + if coords is False: + continue + + details_list = [] + log_details = log.find('LogDetails') + if log_details is not None: + for detail in log_details.findall('details'): + details_list.append({ + 'time': strip_chp_quotes(detail.findtext('DetailTime', default='')), + 'text': strip_chp_quotes(detail.findtext('IncidentDetail', default='')), + 'type': 'incident' + }) + for unit in log_details.findall('units'): + details_list.append({ + 'time': strip_chp_quotes(unit.findtext('UnitTime', default='')), + 'text': strip_chp_quotes(unit.findtext('UnitDetail', default='')), + 'type': 'unit' + }) + + center = log.xpath('ancestor::Center') + dispatch = log.xpath('ancestor::Dispatch') + + chp_incidents.append({ + 'log_id': log.get('ID', ''), + 'log_time': strip_chp_quotes(log.findtext('LogTime', default='')), + 'log_type': strip_chp_quotes(log.findtext('LogType', default='')), + 'location': strip_chp_quotes(log.findtext('Location', default='')), + 'location_desc': strip_chp_quotes(log.findtext('LocationDesc', default='')), + 'area': strip_chp_quotes(log.findtext('Area', default='')), + 'lat': coords[0], + 'lon': coords[1], + 'center_id': center[0].get('ID', '') if center else '', + 'dispatch_id': dispatch[0].get('ID', '') if dispatch else '', + 'details': details_list + }) + + logger.debug('CHP feed: parsed %d incidents', len(chp_incidents)) + return chp_incidents + + except Exception as error: + logger.error('CHP feed fetch/parse error: %s', error) + return [] + +# ------------------------------------------------------------------------------ + +def find_nearby_fire(chp_lat, chp_lon): + """ + Searches the fire incident DB for the nearest fire within CHP_PROXIMITY_MILES. + Returns the fire DB entry dict, or False if none found. + """ + closest_fire = False + closest_distance = float('inf') + + for fire in db.all(): + if 'x' not in fire or 'y' not in fire: + continue + try: + fire_lat = float(fire['y']) + fire_lon = float(fire['x']) + except (ValueError, TypeError): + continue + + distance = haversine_distance_miles(chp_lat, chp_lon, fire_lat, fire_lon) + if distance <= config['chp_proximity_miles'] and distance < closest_distance: + closest_distance = distance + closest_fire = fire + + return closest_fire + +# ------------------------------------------------------------------------------ + +def chp_detail_already_sent(chp_log_id, detail_time, detail_text): + """ + Checks db_chp to see if this CHP detail line has already been sent. + """ + detail_hash = hashlib.md5( + (chp_log_id + detail_time + detail_text).encode() + ).hexdigest()[:8] + return len(db_chp.search(tinydb.Query().detail_hash == detail_hash)) > 0 + +# ------------------------------------------------------------------------------ + +def record_chp_detail_sent(chp_log_id, fire_inci_id, detail_time, detail_text, detail_type): + """ + Records a sent CHP detail line in db_chp for deduplication. + """ + detail_hash = hashlib.md5( + (chp_log_id + detail_time + detail_text).encode() + ).hexdigest()[:8] + db_chp.insert({ + 'chp_log_id': chp_log_id, + 'fire_inci_id': fire_inci_id, + 'detail_hash': detail_hash, + 'detail_time': detail_time, + 'detail_text': detail_text, + 'detail_type': detail_type, + 'sent_at': datetime.datetime.now().isoformat() + }) + +# ------------------------------------------------------------------------------ + +def generate_chp_rich_notif(fire_db_entry, chp_incident, detail): + """ + Generates a Telegram HTML notification for a CHP detail line, + referencing the matched fire incident. + """ + if 'TELEGRAM_CHAT_ID' in secrets and 'original_message_id' in fire_db_entry: + if '@' in secrets['TELEGRAM_CHAT_ID']: + telegram_chat_id_stripped = secrets['TELEGRAM_CHAT_ID'].replace('@', '') + else: + telegram_chat_id_stripped = secrets['TELEGRAM_CHAT_ID'] + notif_body = 'CHP Activity near ' + \ + fire_db_entry['id'] + '' + else: + notif_body = 'CHP Activity near ' + fire_db_entry['id'] + '' + + notif_body += '\n' + chp_incident['log_type'] + '' + notif_body += '\n' + chp_incident['location'] + if chp_incident['area']: + notif_body += ' (' + chp_incident['area'] + ')' + notif_body += '' + + prefix = '[Unit] ' if detail['type'] == 'unit' else '' + notif_body += '\n' + detail['time'] + ': ' + prefix + detail['text'] + + return notif_body + +# ------------------------------------------------------------------------------ + +def generate_chp_plain_notif(fire_db_entry, chp_incident, detail): + """ + Generates a plain-text SMS notification for a CHP detail line. + """ + notif_body = 'CHP near ' + fire_db_entry['id'] + ':\n' + notif_body += chp_incident['log_type'] + '\n' + notif_body += chp_incident['location'] + if chp_incident['area']: + notif_body += ' (' + chp_incident['area'] + ')' + notif_body += '\n' + + prefix = '[Unit] ' if detail['type'] == 'unit' else '' + notif_body += detail['time'] + ': ' + prefix + detail['text'] + + return notif_body + +# ------------------------------------------------------------------------------ + +def cleanup_chp_db(): + """ + Removes CHP tracking records older than 24 hours. + """ + cutoff = datetime.datetime.now() - datetime.timedelta(hours=24) + to_remove = [] + + for record in db_chp.all(): + try: + sent_at = datetime.datetime.fromisoformat(record['sent_at']) + if sent_at < cutoff: + to_remove.append(record.doc_id) + except (ValueError, KeyError): + to_remove.append(record.doc_id) + + if to_remove: + db_chp.remove(doc_ids=to_remove) + logger.debug('CHP cleanup: removed %d old records', len(to_remove)) + +# ------------------------------------------------------------------------------ + +def process_chp_alerts(): + """ + Main CHP processing pipeline: fetch feed, match to fires, notify on new details. + """ + if not config.get('chp_enabled', False): + return False + + chp_incidents = fetch_chp_feed() + if not chp_incidents: + return False + + for chp_incident in chp_incidents: + fire = find_nearby_fire(chp_incident['lat'], chp_incident['lon']) + if fire is False: + continue + + for detail in chp_incident['details']: + if not detail['text']: + continue + + if chp_detail_already_sent(chp_incident['log_id'], detail['time'], detail['text']): + continue + + logger.debug('CHP detail for %s: %s', fire['id'], detail['text']) + send_telegram(generate_chp_rich_notif(fire, chp_incident, detail), 'low') + send_sms(generate_chp_plain_notif(fire, chp_incident, detail)) + record_chp_detail_sent( + chp_incident['log_id'], fire['id'], + detail['time'], detail['text'], detail['type'] + ) + + cleanup_chp_db() + return True + +# ------------------------------------------------------------------------------ + logger.debug('Running from %s', exec_path) process_wildcad_inci_list = process_wildcad() process_alerts(process_wildcad_inci_list) process_major_alerts() +process_chp_alerts() process_daily_recap() From e2434696dd246ee61450efd56058850cda7571b1 Mon Sep 17 00:00:00 2001 From: Mark Mutti Date: Wed, 11 Feb 2026 12:52:14 -0800 Subject: [PATCH 2/4] CI: use requirements.txt instead of hardcoded package list Co-Authored-By: Claude Opus 4.6 --- .github/workflows/pull-request.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/pull-request.yml b/.github/workflows/pull-request.yml index 91590b4..ea78b6f 100644 --- a/.github/workflows/pull-request.yml +++ b/.github/workflows/pull-request.yml @@ -20,7 +20,8 @@ jobs: - name: Install dependencies run: | python -m pip install --upgrade pip - pip install pylint requests tinydb python-dotenv lxml json_log_formatter geopy twilio uvicorn + pip install pylint + pip install -r requirements.txt - name: Create dummy .env file run: | cat < .env From f734d9e982737721c0d7bce397fa3d416f5e76c9 Mon Sep 17 00:00:00 2001 From: Mark Mutti Date: Wed, 11 Feb 2026 15:01:34 -0800 Subject: [PATCH 3/4] CI: update deprecated action versions, broaden PR trigger actions/checkout v1 and actions/setup-python v1 use the retired Node 12 runtime, which likely prevents the workflow from running. Bumps to v4/v5. Also removes PR types filter so the workflow runs on all PR activity. Co-Authored-By: Claude Opus 4.6 --- .github/workflows/pull-request.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/pull-request.yml b/.github/workflows/pull-request.yml index ea78b6f..4e5d082 100644 --- a/.github/workflows/pull-request.yml +++ b/.github/workflows/pull-request.yml @@ -12,9 +12,9 @@ jobs: name: Pull Request Test runs-on: ubuntu-latest steps: - - uses: actions/checkout@v1 + - uses: actions/checkout@v4 - name: Set up Python 3.9 - uses: actions/setup-python@v1 + uses: actions/setup-python@v5 with: python-version: 3.9 - name: Install dependencies From 15eeb4be85892868c0fc7a926ef853bfa150bae9 Mon Sep 17 00:00:00 2001 From: Mark Mutti Date: Wed, 11 Feb 2026 15:29:04 -0800 Subject: [PATCH 4/4] Adds CHP CAD inference capability --- .github/workflows/pull-request.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/pull-request.yml b/.github/workflows/pull-request.yml index b1ae6d4..f7cda55 100644 --- a/.github/workflows/pull-request.yml +++ b/.github/workflows/pull-request.yml @@ -43,6 +43,7 @@ jobs: cat < .env NF_IDENTIFIER=${NF_IDENTIFIER} WILDWEB_E=True + CHP_ENABLED=True NF_WWE_IDENTIFIER=caancc TELEGRAM_BOT_ID=${TELEGRAM_BOT_ID} TELEGRAM_BOT_SECRET=${TELEGRAM_BOT_SECRET}