Initial commit: OpenClaw ops workspace

This commit is contained in:
2026-03-28 00:15:47 +00:00
commit f1aeaeefb5
42 changed files with 4297 additions and 0 deletions

View File

@@ -0,0 +1,63 @@
#!/bin/bash
# Check CapMetro service changes for Route 5 and Route 500
# Returns JSON with new changes since last check
set -e
STATE_FILE="${STATE_FILE:-memory/capmetro-check-state.json}"
WORKSPACE="${WORKSPACE:-/home/node/.openclaw/workspace}"
cd "$WORKSPACE"
# Initialize state if missing
if [ ! -f "$STATE_FILE" ]; then
mkdir -p "$(dirname "$STATE_FILE")"
echo '{"lastCheck":"1970-01-01T00:00:00Z","seenChanges":[]}' > "$STATE_FILE"
fi
NOW=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
# Fetch current service changes page
CHANGES=$(curl -s "https://www.capmetro.org/servicechange" | \
grep -oP 'href="/servicechange/[^"]+' | \
sed 's/href="//' | \
sort -u)
# Check each change period for Route 5 or Route 500
RELEVANT_CHANGES='[]'
for change_url in $CHANGES; do
FULL_URL="https://www.capmetro.org$change_url"
CONTENT=$(curl -s "$FULL_URL")
# Check if Route 5 or Route 500 mentioned
if echo "$CONTENT" | grep -qiE "(Route 5[^0-9]|Route 500)"; then
TITLE=$(echo "$CONTENT" | grep -oP '<title>\K[^<]+' | head -1)
RELEVANT_CHANGES=$(echo "$RELEVANT_CHANGES" | jq --arg url "$FULL_URL" --arg title "$TITLE" \
'. += [{"url":$url, "title":$title, "id":$url}]')
fi
done
# Load seen changes
SEEN=$(jq -r '.seenChanges // []' "$STATE_FILE")
# Find new changes
NEW_CHANGES=$(echo "$RELEVANT_CHANGES" | jq --argjson seen "$SEEN" '[
.[] | select(.id as $id | $seen | index($id) | not)
]')
NEW_COUNT=$(echo "$NEW_CHANGES" | jq 'length')
# Update state
ALL_IDS=$(echo "$RELEVANT_CHANGES" | jq -r '[.[].id]')
jq -n \
--arg now "$NOW" \
--argjson ids "$ALL_IDS" \
'{lastCheck:$now, seenChanges:$ids}' > "$STATE_FILE"
# Output
if [ "$NEW_COUNT" -eq 0 ]; then
echo '{"hasNew":false}'
exit 0
fi
jq -n --argjson changes "$NEW_CHANGES" '{hasNew:true, newChanges:$changes}'

View File

@@ -0,0 +1,182 @@
// Route 5 bus monitor — parses GTFS-RT feed for real-time vehicle positions
// Usage: node monitor-route5.js
const https = require('https');
const fs = require('fs');
const protobuf = require('/tmp/gtfs-rt/node_modules/protobufjs');
const FEED_URL = 'https://data.texas.gov/download/i5qp-g5fd/application/octet-stream';
const ROUTE_ID = '5';
const FIRST_STOP = '5854'; // Anderson/Northcross
const USER_STOP = '964'; // Woodrow/Choquette
const TRAVEL_SECS = 446; // 7 min 26 sec from first stop to user stop
// GTFS-RT proto definition (minimal, inline)
const PROTO = `
syntax = "proto2";
package transit_realtime;
message FeedMessage {
required FeedHeader header = 1;
repeated FeedEntity entity = 2;
}
message FeedHeader {
required string gtfs_realtime_version = 1;
optional uint64 timestamp = 2;
}
message FeedEntity {
required string id = 1;
optional TripUpdate trip_update = 3;
optional VehiclePosition vehicle = 4;
optional Alert alert = 5;
}
message TripUpdate {
optional TripDescriptor trip = 1;
optional VehicleDescriptor vehicle = 3;
repeated StopTimeUpdate stop_time_update = 2;
optional uint64 timestamp = 4;
message StopTimeUpdate {
optional uint32 stop_sequence = 1;
optional string stop_id = 4;
optional StopTimeEvent arrival = 2;
optional StopTimeEvent departure = 3;
enum ScheduleRelationship { SCHEDULED = 0; SKIPPED = 1; NO_DATA = 2; }
optional ScheduleRelationship schedule_relationship = 5;
}
}
message StopTimeEvent {
optional int32 delay = 1;
optional int64 time = 2;
optional int32 uncertainty = 3;
}
message VehiclePosition {
optional TripDescriptor trip = 1;
optional VehicleDescriptor vehicle = 8;
optional Position position = 2;
optional uint32 current_stop_sequence = 3;
optional string stop_id = 7;
enum VehicleStopStatus { INCOMING_AT = 0; STOPPED_AT = 1; IN_TRANSIT_TO = 2; }
optional VehicleStopStatus current_status = 4;
optional uint64 timestamp = 5;
enum CongestionLevel { UNKNOWN = 0; RUNNING_SMOOTHLY = 1; STOP_AND_GO = 2; CONGESTION = 3; SEVERE_CONGESTION = 4; }
optional CongestionLevel congestion_level = 6;
enum OccupancyStatus { EMPTY = 0; MANY_SEATS = 1; FEW_SEATS = 2; STANDING_ROOM = 3; CRUSHED = 4; FULL = 5; NOT_ACCEPTING = 6; }
optional OccupancyStatus occupancy_status = 9;
}
message TripDescriptor {
optional string trip_id = 1;
optional string route_id = 5;
optional uint32 direction_id = 6;
optional string start_time = 2;
optional string start_date = 3;
enum ScheduleRelationship { SCHEDULED = 0; ADDED = 1; UNSCHEDULED = 2; CANCELED = 3; }
optional ScheduleRelationship schedule_relationship = 4;
}
message VehicleDescriptor {
optional string id = 1;
optional string label = 2;
optional string license_plate = 3;
}
message Position {
required float latitude = 1;
required float longitude = 2;
optional float bearing = 3;
optional double odometer = 4;
optional float speed = 5;
}
message Alert {
repeated TimeRange active_period = 1;
repeated EntitySelector informed_entity = 5;
optional TranslatedString header_text = 10;
optional TranslatedString description_text = 11;
}
message TimeRange { optional uint64 start = 1; optional uint64 end = 2; }
message EntitySelector { optional string agency_id = 1; optional string route_id = 3; optional TripDescriptor trip = 4; optional string stop_id = 6; }
message TranslatedString { repeated Translation translation = 1; message Translation { required string text = 1; optional string language = 2; } }
`;
function fetch(url) {
return new Promise((resolve, reject) => {
https.get(url, (res) => {
const chunks = [];
res.on('data', c => chunks.push(c));
res.on('end', () => resolve(Buffer.concat(chunks)));
res.on('error', reject);
}).on('error', reject);
});
}
async function main() {
const root = protobuf.parse(PROTO, { keepCase: true }).root;
const FeedMessage = root.lookupType('transit_realtime.FeedMessage');
const buf = await fetch(FEED_URL);
const feed = FeedMessage.decode(buf);
const now = Math.floor(Date.now() / 1000);
const cst = new Date((now - 6*3600) * 1000); // CST offset
const timeStr = cst.toISOString().replace('T', ' ').substring(0, 19) + ' CST';
// Filter Route 5 vehicles
const vehicles = feed.entity
.filter(e => e.vehicle && e.vehicle.trip && e.vehicle.trip.route_id === ROUTE_ID)
.map(e => {
const v = e.vehicle;
const age = now - (v.timestamp?.low || v.timestamp || 0);
const status = ['INCOMING_AT', 'STOPPED_AT', 'IN_TRANSIT_TO'][v.current_status] || 'UNKNOWN';
return {
vehicleId: v.vehicle?.label || v.vehicle?.id || 'unknown',
tripId: v.trip?.trip_id,
directionId: v.trip?.direction_id,
stopId: v.stop_id,
stopSequence: v.current_stop_sequence,
status,
lat: v.position?.latitude,
lon: v.position?.longitude,
speed: v.position?.speed,
bearing: v.position?.bearing,
ageSec: age,
timestamp: v.timestamp
};
});
// Filter Route 5 trip updates
const tripUpdates = feed.entity
.filter(e => e.trip_update && e.trip_update.trip && e.trip_update.trip.route_id === ROUTE_ID)
.map(e => {
const tu = e.trip_update;
const userStopUpdate = tu.stop_time_update?.find(s => s.stop_id === USER_STOP);
const firstStopUpdate = tu.stop_time_update?.find(s => s.stop_id === FIRST_STOP);
return {
tripId: tu.trip?.trip_id,
directionId: tu.trip?.direction_id,
vehicleId: tu.vehicle?.label || tu.vehicle?.id,
userStopDelay: userStopUpdate?.departure?.delay || userStopUpdate?.arrival?.delay || null,
userStopTime: userStopUpdate?.arrival?.time || userStopUpdate?.departure?.time || null,
firstStopDelay: firstStopUpdate?.departure?.delay || null,
firstStopTime: firstStopUpdate?.departure?.time || null,
totalStops: tu.stop_time_update?.length || 0
};
});
// Eastbound (direction 0) only
const ebVehicles = vehicles.filter(v => v.directionId === 0);
const ebUpdates = tripUpdates.filter(t => t.directionId === 0);
const result = {
timestamp: timeStr,
feedTimestamp: feed.header?.timestamp?.toString(),
route5_eastbound: {
activeVehicles: ebVehicles.length,
vehicles: ebVehicles,
tripUpdates: ebUpdates.filter(t => t.userStopDelay !== null || t.userStopTime !== null)
},
route5_all: {
totalVehicles: vehicles.length,
totalTripUpdates: tripUpdates.length
}
};
console.log(JSON.stringify(result, null, 2));
}
main().catch(e => console.error(JSON.stringify({ error: e.message })));

View File

@@ -0,0 +1,89 @@
#!/bin/bash
# Route 5 smart monitor — determines direction by time of day, launches background watcher
# Usage: bash monitor.sh [channel_id]
# Before 11 AM CST → Eastbound (morning commute)
# After 11 AM CST → Westbound (evening commute)
set -eo pipefail
CHANNEL="${1:-1467247377743347953}"
TU_URL="https://data.texas.gov/download/mqtr-wwpy/application%2Fjson"
STATE_DIR="/home/node/.openclaw/workspace/skills/capmetro-monitor/memory"
mkdir -p "$STATE_DIR"
NOW=$(date +%s)
CST_HOUR=$(TZ=America/Chicago date +%H)
if [ "$CST_HOUR" -lt 11 ]; then
DIRECTION=0
DIR_NAME="Eastbound"
FIRST_STOP="5854"
FIRST_STOP_NAME="Anderson/Northcross"
USER_STOP="964"
USER_STOP_NAME="Woodrow/Choquette"
TRAVEL_FIRST_TO_USER=446 # 7m26s
WALK_LEAD=0 # already near stop
else
DIRECTION=1
DIR_NAME="Westbound"
FIRST_STOP="4606"
FIRST_STOP_NAME="Tannehill/Webberville"
USER_STOP="5499"
USER_STOP_NAME="6th/West"
TRAVEL_FIRST_TO_USER=2384 # 39m44s
WALK_LEAD=900 # 15 min walk from office
HOME_STOP="1072"
HOME_STOP_NAME="Woodrow/Dwyce"
TRAVEL_USER_TO_HOME=1344 # 22m24s
fi
# Find next departure from first stop
TU=$(curl -sL --max-time 10 "$TU_URL" 2>/dev/null)
NEXT=$(echo "$TU" | jq --arg dir "$DIRECTION" --arg fs "$FIRST_STOP" --arg now "$NOW" '
[.entity[] |
select(.tripUpdate.trip.routeId == "5" and (.tripUpdate.trip.directionId | tostring) == $dir) |
{
tripId: .tripUpdate.trip.tripId,
depart: ([.tripUpdate.stopTimeUpdate[] | select(.stopId == $fs) | (.departure.time // .arrival.time)] | .[0])
}
] | [.[] | select(.depart != null and (.depart | tonumber) > ($now | tonumber))]
| sort_by(.depart | tonumber) | .[0]' 2>/dev/null)
TRIP_ID=$(echo "$NEXT" | jq -r '.tripId // empty')
DEPART_TS=$(echo "$NEXT" | jq -r '.depart // empty')
if [ -z "$TRIP_ID" ] || [ -z "$DEPART_TS" ]; then
echo '{"ok":false,"error":"No upcoming Route 5 departures found"}'
exit 1
fi
DEPART_CST=$(TZ=America/Chicago date -d "@$DEPART_TS" +"%I:%M %p" 2>/dev/null)
MINS_AWAY=$(( (DEPART_TS - NOW) / 60 ))
# Export config for the watcher
export DIRECTION DIR_NAME FIRST_STOP FIRST_STOP_NAME USER_STOP USER_STOP_NAME
export TRAVEL_FIRST_TO_USER WALK_LEAD TRIP_ID DEPART_TS CHANNEL
export HOME_STOP HOME_STOP_NAME TRAVEL_USER_TO_HOME
echo "{\"ok\":true,\"direction\":\"$DIR_NAME\",\"tripId\":\"$TRIP_ID\",\"firstStopDepart\":\"$DEPART_CST\",\"minsUntilDepart\":$MINS_AWAY,\"userStop\":\"$USER_STOP_NAME\"}"
# Kill any existing watcher
PID_FILE="$STATE_DIR/watcher.pid"
if [ -f "$PID_FILE" ]; then
OLD_PID=$(cat "$PID_FILE" 2>/dev/null)
if [ -n "$OLD_PID" ] && kill -0 "$OLD_PID" 2>/dev/null; then
kill "$OLD_PID" 2>/dev/null || true
fi
rm -f "$PID_FILE"
fi
# Dynamic timeout: time until departure + 5 min buffer for delays
WAIT_SECS=$(( DEPART_TS - NOW + 300 ))
[ "$WAIT_SECS" -lt 900 ] && WAIT_SECS=900 # minimum 15 min
# Calculate MAX_POLLS for the watcher (poll every 20s)
export MAX_POLLS=$(( WAIT_SECS / 20 ))
SCRIPT_DIR="$(dirname "$0")"
nohup timeout "$WAIT_SECS" bash "$SCRIPT_DIR/watch-departure-v2.sh" > /dev/null 2>&1 &
echo $! > "$PID_FILE"

View File

@@ -0,0 +1,58 @@
#!/bin/bash
# Route 5 on-demand monitor — checks real-time bus status
# Usage: bash route5-status.sh
set -eo pipefail
VP_URL="https://data.texas.gov/download/cuc7-ywmd/application%2Fjson"
TU_URL="https://data.texas.gov/download/mqtr-wwpy/application%2Fjson"
# Fetch both feeds in parallel
VP=$(curl -sL --max-time 10 "$VP_URL") &
TU=$(curl -sL --max-time 10 "$TU_URL") &
VP=$(curl -sL --max-time 10 "$VP_URL")
TU=$(curl -sL --max-time 10 "$TU_URL")
NOW=$(date +%s)
# Route 5 eastbound vehicles
VEHICLES=$(echo "$VP" | jq -c '[.entity[] | select(.vehicle.trip.routeId == "5" and .vehicle.trip.directionId == 0) | {
vehicleId: .vehicle.vehicle.label,
tripId: .vehicle.trip.tripId,
stopId: .vehicle.stopId,
status: .vehicle.currentStatus,
lat: .vehicle.position.latitude,
lon: .vehicle.position.longitude,
speed: .vehicle.position.speed
}]')
# Route 5 eastbound trip updates
TRIPS=$(echo "$TU" | jq -c --arg now "$NOW" '[.entity[] | select(.tripUpdate.trip.routeId == "5" and .tripUpdate.trip.directionId == 0) | {
tripId: .tripUpdate.trip.tripId,
firstStopDepart: ([.tripUpdate.stopTimeUpdate[] | select(.stopId == "5854") | (.departure.time // .arrival.time)] | .[0]),
userStopArrive: ([.tripUpdate.stopTimeUpdate[] | select(.stopId == "964") | (.arrival.time // .departure.time)] | .[0]),
userStopDelay: ([.tripUpdate.stopTimeUpdate[] | select(.stopId == "964") | (.arrival.delay // .departure.delay)] | .[0])
}] | [.[] | select(.firstStopDepart != null)] | sort_by(.firstStopDepart)')
# Format output
jq -n \
--argjson vehicles "$VEHICLES" \
--argjson trips "$TRIPS" \
--arg now "$NOW" '{
timestampUTC: ($now | tonumber | todate),
timestampCST: (($now | tonumber - 21600) | todate),
route: "5 - Woodrow/Lamar",
direction: "Eastbound → Downtown",
firstStop: "Anderson/Northcross (5854)",
userStop: "Woodrow/Choquette (964)",
scheduledTravel: "7m 26s",
activeVehicles: ($vehicles | length),
vehicles: $vehicles,
nextDepartures: [$trips[] | {
tripId,
firstStopDepart: (if .firstStopDepart then (.firstStopDepart | tonumber | todate) else null end),
userStopArrive: (if .userStopArrive then (.userStopArrive | tonumber | todate) else null end),
delaySec: .userStopDelay,
minsUntilDepart: (if .firstStopDepart then (((.firstStopDepart | tonumber) - ($now | tonumber)) / 60 | floor) else null end),
minsUntilArrive: (if .userStopArrive then (((.userStopArrive | tonumber) - ($now | tonumber)) / 60 | floor) else null end)
}]
}'

View File

@@ -0,0 +1,84 @@
#!/bin/bash
# Route 5 departure watcher v2 — direction-aware, runs in background
# Called by monitor.sh with env vars set
set -eo pipefail
VP_URL="https://data.texas.gov/download/cuc7-ywmd/application%2Fjson"
TOKEN=$(printenv DISCORD_BOT_TOKEN)
MAX_POLLS=${MAX_POLLS:-40}
POLL_INTERVAL=20
PID_FILE="/home/node/.openclaw/workspace/skills/capmetro-monitor/memory/watcher.pid"
# Clean up PID file on exit (success, timeout, or signal)
cleanup() { rm -f "$PID_FILE" 2>/dev/null; }
trap cleanup EXIT INT TERM
for i in $(seq 1 $MAX_POLLS); do
VP=$(curl -sL --max-time 8 "$VP_URL" 2>/dev/null)
VEHICLE=$(echo "$VP" | jq -c ".entity[] | select(.vehicle.trip.tripId == \"$TRIP_ID\")" 2>/dev/null)
if [ -z "$VEHICLE" ]; then
sleep $POLL_INTERVAL
continue
fi
STOP_ID=$(echo "$VEHICLE" | jq -r '.vehicle.stopId')
STATUS=$(echo "$VEHICLE" | jq -r '.vehicle.currentStatus')
VEH_ID=$(echo "$VEHICLE" | jq -r '.vehicle.vehicle.label')
# Bus has left the first stop
if [ "$STOP_ID" != "$FIRST_STOP" ] || { [ "$STATUS" = "IN_TRANSIT_TO" ] && [ "$STOP_ID" != "$FIRST_STOP" ]; }; then
ACTUAL_TS=$(date +%s)
ACTUAL_CST=$(TZ=America/Chicago date +"%I:%M %p")
DELAY=$((ACTUAL_TS - DEPART_TS))
DELAY_MIN=$((DELAY / 60))
if [ "$DELAY_MIN" -le 0 ]; then
STATUS_ICON="🟢"
STATUS_TEXT="On time"
elif [ "$DELAY_MIN" -le 2 ]; then
STATUS_ICON="🟡"
STATUS_TEXT="~${DELAY_MIN}min late"
else
STATUS_ICON="🔴"
STATUS_TEXT="${DELAY_MIN}min late"
fi
# Calculate ETAs
ETA_USER=$((ACTUAL_TS + TRAVEL_FIRST_TO_USER))
ETA_USER_CST=$(TZ=America/Chicago date -d "@$ETA_USER" +"%I:%M %p")
if [ "$DIRECTION" = "0" ]; then
# EASTBOUND: simple alert
MSG=$(printf '🚌 **Route 5 %s Departed!**\nBus %s left %s at %s\n%s %s\n📍 ETA at %s: **%s**' \
"$DIR_NAME" "$VEH_ID" "$FIRST_STOP_NAME" "$ACTUAL_CST" \
"$STATUS_ICON" "$STATUS_TEXT" "$USER_STOP_NAME" "$ETA_USER_CST")
else
# WESTBOUND: include leave-office time and home ETA
LEAVE_TS=$((ETA_USER - WALK_LEAD))
LEAVE_CST=$(TZ=America/Chicago date -d "@$LEAVE_TS" +"%I:%M %p")
ETA_HOME=$((ACTUAL_TS + TRAVEL_FIRST_TO_USER + TRAVEL_USER_TO_HOME))
ETA_HOME_CST=$(TZ=America/Chicago date -d "@$ETA_HOME" +"%I:%M %p")
MSG=$(printf '🚌 **Route 5 %s Departed!**\nBus %s left %s at %s\n%s %s\n\n🚶 **Leave office by %s** (15 min walk)\n📍 Bus arrives %s: **%s**\n🏠 Home (%s): **%s**' \
"$DIR_NAME" "$VEH_ID" "$FIRST_STOP_NAME" "$ACTUAL_CST" \
"$STATUS_ICON" "$STATUS_TEXT" \
"$LEAVE_CST" "$USER_STOP_NAME" "$ETA_USER_CST" \
"$HOME_STOP_NAME" "$ETA_HOME_CST")
fi
curl -s -X POST -H "Authorization: Bot $TOKEN" -H "Content-Type: application/json" \
-d "$(jq -n --arg c "$MSG" '{content: $c}')" \
"https://discord.com/api/v10/channels/$CHANNEL/messages" > /dev/null
exit 0
fi
sleep $POLL_INTERVAL
done
# Timeout
SCHED_CST=$(TZ=America/Chicago date -d "@$DEPART_TS" +"%I:%M %p" 2>/dev/null)
curl -s -X POST -H "Authorization: Bot $TOKEN" -H "Content-Type: application/json" \
-d "{\"content\":\"⚠️ Route 5 $DIR_NAME watcher timed out — could not confirm $SCHED_CST departure.\"}" \
"https://discord.com/api/v10/channels/$CHANNEL/messages" > /dev/null

View File

@@ -0,0 +1,103 @@
#!/bin/bash
# Route 5 departure watcher — runs in background, posts to Discord when bus departs
# Usage: bash watch-departure.sh <scheduled_time_UTC> [channel_id]
# Example: bash watch-departure.sh "2026-02-12T13:30:00Z" 1467247377743347953
set -eo pipefail
SCHED_DEPART="$1"
CHANNEL="${2:-1467247377743347953}" # Default: DM channel
VP_URL="https://data.texas.gov/download/cuc7-ywmd/application%2Fjson"
FIRST_STOP="5854"
USER_STOP="964"
TOKEN=$(printenv DISCORD_BOT_TOKEN)
MAX_POLLS=40 # ~13 minutes max watch time
POLL_INTERVAL=20 # seconds between polls
# Find the trip matching this scheduled departure
find_trip() {
local TU=$(curl -sL --max-time 10 "https://data.texas.gov/download/mqtr-wwpy/application%2Fjson" 2>/dev/null)
echo "$TU" | jq -r --arg sched "$SCHED_DEPART" '.entity[] |
select(.tripUpdate.trip.routeId == "5" and .tripUpdate.trip.directionId == 0) |
select([.tripUpdate.stopTimeUpdate[] | select(.stopId == "5854") |
((.departure.time // .arrival.time) | tonumber)] | .[0] == ($sched | sub("Z$";"") | strptime("%Y-%m-%dT%H:%M:%S") | mktime)) |
.tripUpdate.trip.tripId' 2>/dev/null | head -1
}
# Convert ISO to epoch
sched_epoch() {
date -d "$SCHED_DEPART" +%s 2>/dev/null || date -u -d "${SCHED_DEPART%Z}" +%s 2>/dev/null
}
SCHED_TS=$(sched_epoch)
SCHED_CST=$(TZ=America/Chicago date -d "@$SCHED_TS" +"%I:%M %p" 2>/dev/null)
# Find the trip ID
TRIP_ID=$(find_trip)
if [ -z "$TRIP_ID" ]; then
# Fallback: find closest eastbound trip
TU=$(curl -sL --max-time 10 "https://data.texas.gov/download/mqtr-wwpy/application%2Fjson" 2>/dev/null)
TRIP_ID=$(echo "$TU" | jq -r --arg ts "$SCHED_TS" '[.entity[] |
select(.tripUpdate.trip.routeId == "5" and .tripUpdate.trip.directionId == 0) |
{tripId: .tripUpdate.trip.tripId, depart: ([.tripUpdate.stopTimeUpdate[] | select(.stopId == "5854") | (.departure.time // .arrival.time)] | .[0] | tonumber)}] |
sort_by((. .depart - ($ts | tonumber)) | fabs) | .[0].tripId' 2>/dev/null)
fi
if [ -z "$TRIP_ID" ]; then
# Post error
curl -s -X POST -H "Authorization: Bot $TOKEN" -H "Content-Type: application/json" \
-d "{\"content\":\"⚠️ Could not find Route 5 trip for $SCHED_CST departure.\"}" \
"https://discord.com/api/v10/channels/$CHANNEL/messages" > /dev/null
exit 1
fi
# Poll until departure
for i in $(seq 1 $MAX_POLLS); do
VP=$(curl -sL --max-time 8 "https://data.texas.gov/download/cuc7-ywmd/application%2Fjson" 2>/dev/null)
VEHICLE=$(echo "$VP" | jq -c ".entity[] | select(.vehicle.trip.tripId == \"$TRIP_ID\")" 2>/dev/null)
if [ -z "$VEHICLE" ]; then
sleep $POLL_INTERVAL
continue
fi
STOP_ID=$(echo "$VEHICLE" | jq -r '.vehicle.stopId')
STATUS=$(echo "$VEHICLE" | jq -r '.vehicle.currentStatus')
SPEED=$(echo "$VEHICLE" | jq -r '.vehicle.position.speed')
VEH_ID=$(echo "$VEHICLE" | jq -r '.vehicle.vehicle.label')
# Bus has left the first stop
if [ "$STOP_ID" != "$FIRST_STOP" ] || ([ "$STATUS" = "IN_TRANSIT_TO" ] && [ "$STOP_ID" != "$FIRST_STOP" ]); then
DEPART_TS=$(date +%s)
DEPART_CST=$(TZ=America/Chicago date +"%I:%M:%S %p")
DELAY=$((DEPART_TS - SCHED_TS))
DELAY_MIN=$((DELAY / 60))
# Calculate ETA at user stop (7m26s from first stop)
ETA_TS=$((DEPART_TS + 446))
ETA_CST=$(TZ=America/Chicago date -d "@$ETA_TS" +"%I:%M %p" 2>/dev/null)
if [ "$DELAY_MIN" -le 0 ]; then
STATUS_MSG="🟢 On time"
elif [ "$DELAY_MIN" -le 2 ]; then
STATUS_MSG="🟡 ~${DELAY_MIN}min late"
else
STATUS_MSG="🔴 ${DELAY_MIN}min late"
fi
MSG="🚌 **Route 5 Departed!**\nBus ${VEH_ID} left Anderson/Northcross at ${DEPART_CST}\nScheduled: ${SCHED_CST} | ${STATUS_MSG}\n📍 ETA at Woodrow/Choquette: **${ETA_CST}**"
CONTENT=$(printf "$MSG")
curl -s -X POST -H "Authorization: Bot $TOKEN" -H "Content-Type: application/json" \
-d "$(jq -n --arg c "$CONTENT" '{content: $c}')" \
"https://discord.com/api/v10/channels/$CHANNEL/messages" > /dev/null
exit 0
fi
sleep $POLL_INTERVAL
done
# Timeout — bus never departed (or we missed it)
curl -s -X POST -H "Authorization: Bot $TOKEN" -H "Content-Type: application/json" \
-d "{\"content\":\"⚠️ Route 5 watcher timed out — could not confirm $SCHED_CST departure from Anderson/Northcross.\"}" \
"https://discord.com/api/v10/channels/$CHANNEL/messages" > /dev/null