From d1054ec91ebf0b4eadc2360b47a012704ab3972e Mon Sep 17 00:00:00 2001 From: Chris Date: Thu, 14 May 2026 20:59:18 -0500 Subject: [PATCH 01/14] Document app trigger contract in APPS_README.md Full trigger developer guide including: - manifest.json trigger_conditions schema - trigger(settings, conditions) function contract - State persistence pattern between calls - BirdNET rare species example end-to-end - Triggers without conditions (ISS-style) Co-Authored-By: Claude Sonnet 4.6 --- APPS_README.md | 174 +++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 174 insertions(+) diff --git a/APPS_README.md b/APPS_README.md index a7739aa..c835ab7 100644 --- a/APPS_README.md +++ b/APPS_README.md @@ -211,6 +211,180 @@ Use these keys to synchronize related fields: - Use `visible_when` and sync rules only when they improve UX. - Keep `fetch()` fast and resilient to network/API errors. +--- + +## App Triggers + +Triggers let your app interrupt the display when something worth showing happens — a live game, a rare bird, a weather alert. Users configure triggers in the **Triggers** page and can create multiple triggers from the same app with different conditions. + +### How it works + +1. You declare a `trigger_conditions` schema in `manifest.json` — the fields a user fills in when creating a trigger from your app. +2. You implement a `trigger(settings, conditions)` function in `app.py` that returns `True` when the condition is met. +3. The OS calls `trigger()` on a background thread at `trigger_interval` seconds. When it returns `True`, the app's pages are fetched and shown as an interrupt. + +### manifest.json additions + +```json +{ + "trigger_interval": 60, + "trigger_display_seconds": 30, + "trigger_cooldown": 300, + "trigger_conditions": [ + { + "key": "events", + "label": "Fire when", + "type": "toggle", + "options": [ + {"value": "game_start", "label": "Game starts"}, + {"value": "score_change", "label": "Score changes"}, + {"value": "close_game", "label": "Close game (within 5)"}, + {"value": "final", "label": "Game ends"} + ], + "default": "game_start" + }, + { + "key": "teams", + "label": "Specific teams (empty = all followed)", + "type": "text", + "default": "" + } + ] +} +``` + +| Field | Description | +|-------|-------------| +| `trigger_interval` | How often (seconds) the OS calls `trigger()`. Keep this reasonable — 30–300s for most apps. | +| `trigger_display_seconds` | How long to show the interrupt. Default shown in trigger UI; user can override. | +| `trigger_cooldown` | Minimum seconds between fires for the same trigger. Prevents spam. | +| `trigger_conditions` | Schema for per-trigger condition fields. Same field types as `settings`. | + +`trigger_conditions` supports all the same field types as `settings`: `text`, `number`, `toggle`, `select`, `search_chips`, `visible_when`, etc. + +### app.py trigger function + +```python +def trigger(settings, conditions): + """ + Called periodically by the OS. Return True to interrupt the display. + + Args: + settings: dict — full app settings (same as fetch() receives) + conditions: dict — the user-configured condition values for this trigger + + Returns: + bool — True to fire, False to skip + """ + event_type = conditions.get('events', 'game_start') + teams = conditions.get('teams', '').split(',') if conditions.get('teams') else [] + # ... check your data source ... + return False +``` + +The function must be **fast and non-blocking** — it runs on the trigger thread and blocks other trigger checks. If you need to make a network call, cache the result and return quickly. + +### State between calls + +Use the same `setattr` pattern as `fetch()` to persist state across calls: + +```python +def trigger(settings, conditions): + state = getattr(trigger, '_state', None) + if state is None: + state = {'last_seen': set()} + setattr(trigger, '_state', state) + + # check for new items not in last_seen + new_items = fetch_new_items(settings) + fired = bool(new_items - state['last_seen']) + state['last_seen'] = new_items + return fired +``` + +### Example: BirdNET rare species trigger + +```json +// manifest.json trigger_conditions +[ + { + "key": "species_filter", + "label": "Fire for", + "type": "toggle", + "options": [ + {"value": "any", "label": "Any detection"}, + {"value": "new", "label": "New species (not seen today)"}, + {"value": "specific", "label": "Specific species"} + ], + "default": "any" + }, + { + "key": "species_name", + "label": "Species name", + "type": "text", + "default": "", + "visible_when": {"species_filter": "specific"} + } +] +``` + +```python +# app.py +def trigger(settings, conditions): + import requests + host = settings.get('birdnet_host', '192.168.86.139') + port = settings.get('birdnet_port', '80') + min_conf = int(settings.get('min_confidence', '70')) / 100 + species_filter = conditions.get('species_filter', 'any') + species_name = conditions.get('species_name', '').lower() + + state = getattr(trigger, '_state', None) + if state is None: + state = {'seen_today': set(), 'last_id': None} + setattr(trigger, '_state', state) + + try: + r = requests.get(f"http://{host}:{port}/api/v1/detections/recent?limit=5", timeout=5) + detections = [d for d in r.json() if d['confidence'] >= min_conf] + if not detections: + return False + latest = detections[0] + if latest.get('id') == state['last_id']: + return False # nothing new + state['last_id'] = latest.get('id') + + if species_filter == 'any': + return True + if species_filter == 'specific': + return species_name in latest['species'].lower() + if species_filter == 'new': + sp = latest['species'] + if sp not in state['seen_today']: + state['seen_today'].add(sp) + return True + return False + except Exception: + return False +``` + +### Triggers that don't need conditions + +If your app has a single obvious trigger condition (e.g. "ISS is overhead"), you can omit `trigger_conditions` entirely. The trigger UI will show just the display duration and cooldown controls. + +```json +{ + "trigger_interval": 60, + "trigger_display_seconds": 30, + "trigger_cooldown": 600 +} +``` + +```python +def trigger(settings, conditions): + return is_iss_overhead(settings) +``` + + ## Optional: Lucide Icon in Settings Modal The settings modal title uses the emoji from `manifest.json` by default. If you want a Lucide icon instead, add your app to the `LUCIDE_APP_ICONS` map in `server/static/app.js`: From b16e96a24073ec89026f192ef7552fb37e0d7052 Mon Sep 17 00:00:00 2001 From: Chris Date: Thu, 14 May 2026 21:13:25 -0500 Subject: [PATCH 02/14] Add Triggers feature (#37) - Backend: trigger() function loading alongside fetch() in plugin system - _plugin_triggers dict, _trigger_cooldowns, _trigger_last_check tracking - _trigger_loop() daemon thread checks every 10s, respects quiet hours - GET/POST /triggers routes; /installed_apps now includes has_trigger + trigger_conditions - Triggers page in hamburger menu with enable/disable master switch - Bell icon on app cards for trigger-capable apps (links to add trigger) - Add/edit trigger modal: name, app picker, dynamic condition fields from manifest, display duration, cooldown - Trigger list shows last fired time, enable toggle, edit/delete Co-Authored-By: Claude Sonnet 4.6 --- server/app.py | 114 ++++++++++++++++++++++- server/static/app.js | 180 +++++++++++++++++++++++++++++++++++- server/templates/index.html | 19 ++++ 3 files changed, 310 insertions(+), 3 deletions(-) diff --git a/server/app.py b/server/app.py index 1784c46..d02788c 100644 --- a/server/app.py +++ b/server/app.py @@ -122,6 +122,8 @@ def load_settings(): "notify_enabled": False, "notify_display_seconds": 10, "notify_sources": {}, + "triggers_enabled": True, + "triggers": [], "installed_apps": [ "time", "date", "weather", "stocks", "sports", "countdown", "world_clock", "crypto", "iss", "metro", "youtube", "yt_comments", @@ -700,16 +702,18 @@ def format_lines(*lines, cols=None): _plugin_registry = {} _plugin_modules = {} +_plugin_triggers = {} _plugin_data = {} _plugin_caches = {} _registry_cache = {'data': None, 'fetched_at': 0} def load_installed_plugins(): - global _plugin_registry, _plugin_modules, _plugin_data + global _plugin_registry, _plugin_modules, _plugin_data, _plugin_triggers _plugin_registry.clear() _plugin_modules.clear() _plugin_data.clear() + _plugin_triggers.clear() if not os.path.isdir(APPS_PATH): return enabled = settings.get('installed_apps', []) @@ -764,6 +768,9 @@ def _load_functional_module(app_id, app_dir): _plugin_modules[app_id] = mod else: logging.error(f"Plugin {app_id}: app.py has no fetch() function") + if hasattr(mod, "trigger") and callable(mod.trigger): + _plugin_triggers[app_id] = mod.trigger + logging.info(f"Plugin {app_id}: trigger() loaded") except Exception as e: logging.error(f"Plugin {app_id}: error importing app.py: {e}") @@ -1243,6 +1250,79 @@ def playlist_loop(): threading.Thread(target=playlist_loop, daemon=True).start() +# ============================================================ +# TRIGGER RUNTIME +# ============================================================ + +_trigger_cooldowns = {} # trigger_id → last_fired timestamp +_trigger_last_check = {} # trigger_id → last_checked timestamp + + +def _check_triggers(): + if not settings.get('triggers_enabled', True): + return + if _quiet_hours_active: + return + now = time.time() + for trig in settings.get('triggers', []): + if not trig.get('enabled', True): + continue + trig_id = trig.get('id', '') + app_id = trig.get('app', '') + trigger_fn = _plugin_triggers.get(app_id) + if not trigger_fn: + continue + manifest = _plugin_registry.get(app_id, {}) + interval = float(manifest.get('trigger_interval', 60)) + cooldown = float(trig.get('cooldown', manifest.get('trigger_cooldown', 300))) + # Check interval + if now - _trigger_last_check.get(trig_id, 0) < interval: + continue + _trigger_last_check[trig_id] = now + # Check cooldown + if now - _trigger_cooldowns.get(trig_id, 0) < cooldown: + continue + try: + plugin_settings = dict(settings) + for s in manifest.get('settings', []): + if not s.get('global_key'): + key = f"plugin_{app_id}_{s['key']}" + plugin_settings[s['key']] = settings.get(key, s.get('default', '')) + conditions = trig.get('conditions', {}) + fired = trigger_fn(plugin_settings, conditions) + except Exception as e: + logging.error(f"Trigger {trig_id} ({app_id}) error: {e}") + continue + if fired: + _trigger_cooldowns[trig_id] = now + display_seconds = float(trig.get('display_seconds', + manifest.get('trigger_display_seconds', 30))) + pages = get_plugin_pages(app_id) + if pages: + text = pages[0] if isinstance(pages[0], str) else pages[0].get('text', '') + msg = { + 'id': f"trig_{trig_id}_{int(now*1000)}", + 'text': text, + 'source': f"trigger:{app_id}", + 'display_seconds': display_seconds, + 'animation': 'ltr', + 'created_at': now, + 'expires_at': now + 300, + } + with _notify_lock: + _notify_queue.append(msg) + logging.info(f"Trigger fired: {trig.get('name',trig_id)} ({app_id})") + + +def _trigger_loop(): + while True: + time.sleep(10) + _check_triggers() + + +threading.Thread(target=_trigger_loop, daemon=True).start() + + # ============================================================ # FLASK ROUTES # ============================================================ @@ -2152,12 +2232,42 @@ def notify_clear(): @app.route('/installed_apps') def installed_apps(): + # Include trigger capability info + apps = get_plugin_app_list() + for a in apps: + app_id = a.get('plugin_id', '') + manifest = _plugin_registry.get(app_id, {}) + a['has_trigger'] = app_id in _plugin_triggers + a['trigger_conditions'] = manifest.get('trigger_conditions', []) return jsonify( - apps=get_plugin_app_list(), + apps=apps, settings_config=get_plugin_settings_config(), ) +@app.route('/triggers', methods=['GET', 'POST']) +def triggers_route(): + if request.method == 'GET': + trigs = settings.get('triggers', []) + # Annotate with last_fired info + now = time.time() + result = [] + for t in trigs: + entry = dict(t) + last = _trigger_cooldowns.get(t.get('id', '')) + entry['last_fired'] = last + result.append(entry) + return jsonify(triggers=result, + triggers_enabled=settings.get('triggers_enabled', True)) + data = request.json + if 'triggers' in data: + settings['triggers'] = data['triggers'] + if 'triggers_enabled' in data: + settings['triggers_enabled'] = bool(data['triggers_enabled']) + save_settings(settings) + return jsonify(status="saved") + + # ============================================================ # UPDATE CHECK # ============================================================ diff --git a/server/static/app.js b/server/static/app.js index c22afcd..84618ce 100644 --- a/server/static/app.js +++ b/server/static/app.js @@ -302,6 +302,8 @@ function openMenuPage(name){ if(name==='calibration') loadSettingsData(); if(name==='settings') loadSettingsData(); if(name==='library') loadAppLibrary(); + if(name==='schedules') loadSchedules(); + if(name==='triggers') loadTriggers(); if(typeof lucide!=='undefined') lucide.createIcons(); } @@ -899,9 +901,12 @@ function buildAppCard(a, isPlugin) { if(!compatible) div.title = incompatibleReason; const icon = appLucideIcon(a.key) || appLucideIcon(a.plugin_id||'') || `${a.icon}`; + const hasTrigger = !!(a.has_trigger); + const gearRight = (removable ? 28 : 8) + (hasTrigger ? 20 : 0); div.innerHTML = ` - ${hasCfg && compatible ? `` : ''} + ${hasCfg && compatible ? `` : ''} ${removable ? `` : ''} + ${hasTrigger && compatible ? `` : ''} ${icon} ${a.name} ${compatible ? a.desc : incompatibleReason}`; @@ -3083,6 +3088,179 @@ function saveHotspotConfig(){ .then(()=>showToast('Hotspot config saved')); } +// ============================================================ +// TRIGGERS +// ============================================================ +let _triggers = []; +let _triggerApps = []; // apps with has_trigger=true + +function loadTriggers(){ + fetch('/installed_apps').then(r=>r.json()).then(data=>{ + _triggerApps = (data.apps||[]).filter(a=>a.has_trigger); + }); + fetch('/triggers').then(r=>r.json()).then(data=>{ + _triggers = data.triggers || []; + document.getElementById('triggersEnabled').checked = !!data.triggers_enabled; + _renderTriggerList(); + if(typeof lucide!=='undefined') lucide.createIcons(); + }); +} + +function saveTriggersMaster(){ + fetch('/triggers',{method:'POST',headers:{'Content-Type':'application/json'}, + body:JSON.stringify({triggers_enabled: document.getElementById('triggersEnabled').checked})}); +} + +function _renderTriggerList(){ + const el = document.getElementById('triggerList'); + if(!el) return; + if(!_triggers.length){ + el.innerHTML='
No triggers yet. Add one to watch for events.
'; + return; + } + el.innerHTML=''; + _triggers.forEach((t, i) => { + const app = _triggerApps.find(a=>(a.plugin_id||a.key.replace('plugin_',''))===t.app) || {name:t.app, icon:'🔔'}; + const lastFired = t.last_fired ? _timeAgo(t.last_fired) : 'Never'; + const row = document.createElement('div'); + row.style.cssText='display:flex;align-items:center;gap:8px;padding:10px 0;border-bottom:1px solid #2a2a2a'; + row.innerHTML=` + +
+
${t.name||'Unnamed'}
+
${app.icon} ${app.name} · Last fired: ${lastFired}
+
+ + `; + el.appendChild(row); + }); +} + +function _timeAgo(ts){ + const diff = Math.floor(Date.now()/1000 - ts); + if(diff < 60) return `${diff}s ago`; + if(diff < 3600) return `${Math.floor(diff/60)}m ago`; + if(diff < 86400) return `${Math.floor(diff/3600)}h ago`; + return `${Math.floor(diff/86400)}d ago`; +} + +function _toggleTrigger(idx, enabled){ + _triggers[idx].enabled = enabled; + fetch('/triggers',{method:'POST',headers:{'Content-Type':'application/json'}, + body:JSON.stringify({triggers:_triggers})}); +} + +function _deleteTrigger(idx){ + _triggers.splice(idx,1); + fetch('/triggers',{method:'POST',headers:{'Content-Type':'application/json'}, + body:JSON.stringify({triggers:_triggers})}) + .then(()=>_renderTriggerList()); +} + +function openAddTrigger(preselectedApp){ + _openTriggerModal(null, preselectedApp); +} + +function openEditTrigger(idx){ + _openTriggerModal(idx); +} + +function _openTriggerModal(idx, preselectedApp){ + const isEdit = idx !== null && idx !== undefined; + const t = isEdit ? JSON.parse(JSON.stringify(_triggers[idx])) : { + id: 'trig_'+Date.now(), name:'', enabled:true, + app: preselectedApp||'', conditions:{}, + display_seconds:30, cooldown:300 + }; + + const modal = document.createElement('div'); + modal.className='modal-overlay'; + modal.style.display='flex'; + + const appOptions = _triggerApps.map(a=>{ + const id = a.plugin_id||a.key.replace('plugin_',''); + return ``; + }).join(''); + + modal.innerHTML=` + `; + document.body.appendChild(modal); + if(t.app) _loadTriggerConditions(t.conditions); + setTimeout(()=>document.getElementById('trigModalName').focus(),50); +} + +function _loadTriggerConditions(existingConditions){ + const appId = document.getElementById('trigModalApp')?.value; + const el = document.getElementById('trigModalConditions'); + if(!el) return; + if(!appId){ el.innerHTML=''; return; } + const app = _triggerApps.find(a=>(a.plugin_id||a.key.replace('plugin_',''))===appId); + const conditions = app?.trigger_conditions || []; + if(!conditions.length){ el.innerHTML=''; return; } + el.innerHTML = conditions.map(c => { + const val = existingConditions?.[c.key] ?? c.default ?? ''; + if(c.type==='toggle'){ + const opts = (c.options||[]).map(o=>{ + const v = typeof o==='string'?o:o.value; + const l = typeof o==='string'?o:o.label; + return ``; + }).join(''); + return `
${opts}
`; + } + return `
+
`; + }).join(''); +} + +function _saveTriggerModal(idx, id){ + const modal = document.querySelector('.modal-overlay'); + const name = document.getElementById('trigModalName').value.trim(); + const app = document.getElementById('trigModalApp').value; + const display_seconds = parseInt(document.getElementById('trigModalDisplay').value)||30; + const cooldown = parseInt(document.getElementById('trigModalCooldown').value)||300; + const conditions = {}; + document.querySelectorAll('[data-condkey]').forEach(el => { + const key = el.dataset.condkey; + if(el.tagName==='BUTTON' && el.style.background.includes('accent')) conditions[key]=el.dataset.condval; + else if(el.tagName==='INPUT') conditions[key]=el.value; + }); + const trig = {id, name, enabled:true, app, conditions, display_seconds, cooldown}; + if(idx===null||idx==='null') _triggers.push(trig); + else _triggers[idx]=trig; + fetch('/triggers',{method:'POST',headers:{'Content-Type':'application/json'}, + body:JSON.stringify({triggers:_triggers})}) + .then(()=>{ _renderTriggerList(); modal.remove(); showToast('Trigger saved'); }); +} + // ============================================================ // SOFTWARE UPDATE // ============================================================ diff --git a/server/templates/index.html b/server/templates/index.html index 7fc2201..414e869 100644 --- a/server/templates/index.html +++ b/server/templates/index.html @@ -117,6 +117,24 @@

🛠 Create Your Own App

+ +
+ +
+
+

Triggers

+
+ + +
+
+

Apps watch for events and interrupt the display when something worth showing happens.

+
No triggers yet.
+
+
+
@@ -281,6 +299,7 @@

Backup & Restore

+
From 3a3d40ded8c6ad106ee7ac41623a0e3d4a679a2a Mon Sep 17 00:00:00 2001 From: Chris Date: Thu, 14 May 2026 21:25:44 -0500 Subject: [PATCH 03/14] Add trigger() to sports, birdnet, stocks, weather, and ISS apps Sports: fires on game_start, score_change, close_game (within 5), or final. BirdNET: fires on any detection, new species today, or specific species. Stocks: fires when any ticker moves beyond a % threshold (up/down/either). Weather: fires on severe weather or temp crossing a threshold (Open-Meteo). ISS: fires when ISS ground track is within 500km of user's zip code. All manifests updated with trigger schema. Bell icon moved to left side of app tiles for visual separation from settings/remove buttons. Co-Authored-By: Claude Sonnet 4.6 --- apps/birdnet/app.py | 40 +++++++++++++++++ apps/birdnet/manifest.json | 23 ++++++++++ apps/iss/app.py | 32 ++++++++++++++ apps/iss/manifest.json | 5 ++- apps/sports/app.py | 88 ++++++++++++++++++++++++++++++++++++++ apps/sports/manifest.json | 25 ++++++++++- apps/stocks/app.py | 30 +++++++++++++ apps/stocks/manifest.json | 27 +++++++++++- apps/weather/app.py | 41 ++++++++++++++++++ apps/weather/manifest.json | 28 +++++++++++- server/static/app.js | 4 +- 11 files changed, 337 insertions(+), 6 deletions(-) diff --git a/apps/birdnet/app.py b/apps/birdnet/app.py index 2fcb8b3..50f5843 100644 --- a/apps/birdnet/app.py +++ b/apps/birdnet/app.py @@ -106,3 +106,43 @@ def vcenter(text, rows): if state['last_pages']: return state['last_pages'] return [format_lines('BIRDNET', 'ERROR', str(e)[:cols] if cols > 10 else 'ERR')] + + +def trigger(settings, conditions): + """Fire when a new bird detection matches the configured filter.""" + import requests + + host = settings.get('birdnet_host', '192.168.86.139') + port = settings.get('birdnet_port', '80') + min_conf = int(settings.get('min_confidence', '70')) / 100 + filt = conditions.get('filter', 'any') + species_query = conditions.get('species', '').lower().strip() + + state = getattr(trigger, '_state', None) + if state is None: + state = {'last_id': None, 'seen_today': set()} + setattr(trigger, '_state', state) + + try: + r = requests.get(f"http://{host}:{port}/api/v1/detections/recent?limit=5", timeout=5) + detections = [d for d in r.json() if d.get('confidence', 0) >= min_conf] + if not detections: + return False + latest = detections[0] + det_id = latest.get('id') or latest.get('timestamp') or latest.get('time') + if det_id == state['last_id']: + return False # nothing new + state['last_id'] = det_id + species = latest.get('species', '') + + if filt == 'any': + return True + if filt == 'specific': + return bool(species_query) and species_query in species.lower() + if filt == 'new_today': + if species not in state['seen_today']: + state['seen_today'].add(species) + return True + return False + except Exception: + return False diff --git a/apps/birdnet/manifest.json b/apps/birdnet/manifest.json index 230afe5..32d7fe6 100644 --- a/apps/birdnet/manifest.json +++ b/apps/birdnet/manifest.json @@ -7,6 +7,29 @@ "refresh_interval": 1, "loop_delay": 5, "category": "data", + "trigger_interval": 30, + "trigger_display_seconds": 30, + "trigger_cooldown": 120, + "trigger_conditions": [ + { + "key": "filter", + "label": "Fire for", + "type": "toggle", + "default": "any", + "options": [ + {"value": "any", "label": "Any detection"}, + {"value": "new_today", "label": "New species today"}, + {"value": "specific", "label": "Specific species"} + ] + }, + { + "key": "species", + "label": "Species name (partial match)", + "type": "text", + "default": "", + "visible_when": {"filter": "specific"} + } + ], "settings": [ { "key": "birdnet_host", diff --git a/apps/iss/app.py b/apps/iss/app.py index bf3297e..17adad7 100644 --- a/apps/iss/app.py +++ b/apps/iss/app.py @@ -14,3 +14,35 @@ def fetch(settings, format_lines, get_rows, get_cols): return [format_lines('ISS TRACKER', f'LAT {lat} LON {lon}', f'{num} IN SPACE')] except Exception: return [format_lines('ISS TRACKER', 'ERROR', 'API FAIL')] + + +def trigger(settings, conditions): + """Fire when the ISS is passing overhead (within ~500km).""" + import requests, math + + try: + # Get user's approximate location from zip code via geocoding + zip_code = settings.get('zip_code', '02118') + geo = requests.get( + f'https://nominatim.openstreetmap.org/search?postalcode={zip_code}&country=US&format=json&limit=1', + timeout=5, headers={'User-Agent': 'SplitFlapOS/1.0'} + ).json() + if not geo: + return False + user_lat = float(geo[0]['lat']) + user_lon = float(geo[0]['lon']) + + pos = requests.get('http://api.open-notify.org/iss-now.json', timeout=5).json() + iss_lat = float(pos['iss_position']['latitude']) + iss_lon = float(pos['iss_position']['longitude']) + + # Haversine distance in km + R = 6371 + dlat = math.radians(iss_lat - user_lat) + dlon = math.radians(iss_lon - user_lon) + a = math.sin(dlat/2)**2 + math.cos(math.radians(user_lat)) * math.cos(math.radians(iss_lat)) * math.sin(dlon/2)**2 + dist = R * 2 * math.asin(math.sqrt(a)) + + return dist < 500 # within ~500km ground track + except Exception: + return False diff --git a/apps/iss/manifest.json b/apps/iss/manifest.json index de55840..c21276f 100644 --- a/apps/iss/manifest.json +++ b/apps/iss/manifest.json @@ -7,6 +7,9 @@ "refresh_interval": 5, "loop_delay": 5, "category": "data", + "trigger_interval": 60, + "trigger_display_seconds": 30, + "trigger_cooldown": 3600, "min_rows": 1, "min_cols": 8 -} \ No newline at end of file +} diff --git a/apps/sports/app.py b/apps/sports/app.py index 4eef8f1..72898a7 100644 --- a/apps/sports/app.py +++ b/apps/sports/app.py @@ -241,3 +241,91 @@ def _mma(events, info, format_lines): r3 = detail.upper()[:15] if detail else ("LIVE" if state == 'in' else "UPCOMING") pages.append(format_lines("UFC", f"{n1} V {n2}", r3)) return pages or [format_lines("UFC", "NO FIGHTS", "SCHEDULED")] + + +def trigger(settings, conditions): + """Fire when a followed team's game matches the configured event condition.""" + import requests + from datetime import datetime, timedelta + + event_type = conditions.get('event', 'game_start') + teams_str = conditions.get('teams', '').strip() + trigger_teams = {t.strip().upper() for t in teams_str.split(',') if t.strip()} if teams_str else None + + # Build set of all followed teams if no specific teams configured + if not trigger_teams: + trigger_teams = set() + for key in LEAGUES: + val = settings.get(f'sports_{key}', '').strip() + if val and val != '*': + trigger_teams.update(t.strip().upper() for t in val.split(',') if t.strip()) + elif val == '*': + trigger_teams.add('*') # follow-all league + + if not trigger_teams: + return False + + state_obj = getattr(trigger, '_state', None) + if state_obj is None: + state_obj = {'seen_game_ids': set(), 'last_scores': {}} + setattr(trigger, '_state', state_obj) + + try: + start = (datetime.now() - timedelta(days=1)).strftime('%Y%m%d') + end = (datetime.now() + timedelta(days=1)).strftime('%Y%m%d') + for key, info in LEAGUES.items(): + if key in ('pga', 'ufc'): + continue + teams_setting = settings.get(f'sports_{key}', '').strip() + if not teams_setting: + continue + url = f"https://site.api.espn.com/apis/site/v2/sports/{info['path']}/scoreboard?dates={start}-{end}&limit=50" + data = requests.get(url, timeout=8).json() + for event in data.get('events', []): + comp = event.get('competitions', [{}])[0] + competitors = comp.get('competitors', []) + if len(competitors) < 2: + continue + away = home = None + for c in competitors: + if c.get('homeAway') == 'home': home = c + else: away = c + if not away or not home: + continue + aa = away['team'].get('abbreviation', '').upper() + ha = home['team'].get('abbreviation', '').upper() + if '*' not in trigger_teams and aa not in trigger_teams and ha not in trigger_teams: + continue + game_id = event.get('id', '') + state = event.get('status', {}).get('type', {}).get('state', 'pre') + a_score = int(away.get('score', 0) or 0) + h_score = int(home.get('score', 0) or 0) + score_key = f"{game_id}" + + if event_type == 'game_start': + if state == 'in' and game_id not in state_obj['seen_game_ids']: + state_obj['seen_game_ids'].add(game_id) + return True + + elif event_type == 'score_change': + if state == 'in': + prev = state_obj['last_scores'].get(score_key) + curr = (a_score, h_score) + state_obj['last_scores'][score_key] = curr + if prev and prev != curr: + return True + + elif event_type == 'close_game': + if state == 'in' and abs(a_score - h_score) <= 5: + if score_key not in state_obj['seen_game_ids']: + state_obj['seen_game_ids'].add(score_key) + return True + + elif event_type == 'final': + if state == 'post' and game_id not in state_obj['seen_game_ids']: + state_obj['seen_game_ids'].add(game_id) + return True + + except Exception: + pass + return False diff --git a/apps/sports/manifest.json b/apps/sports/manifest.json index 91feb17..19980a3 100644 --- a/apps/sports/manifest.json +++ b/apps/sports/manifest.json @@ -6,6 +6,29 @@ "type": "functional", "refresh_interval": 15, "loop_delay": 5, + "trigger_interval": 60, + "trigger_display_seconds": 45, + "trigger_cooldown": 300, + "trigger_conditions": [ + { + "key": "event", + "label": "Fire when", + "type": "toggle", + "default": "game_start", + "options": [ + {"value": "game_start", "label": "Game starts"}, + {"value": "score_change", "label": "Score changes"}, + {"value": "close_game", "label": "Close game (within 5)"}, + {"value": "final", "label": "Game ends"} + ] + }, + { + "key": "teams", + "label": "Specific teams (empty = all followed)", + "type": "text", + "default": "" + } + ], "settings": [ { "key": "loop_delay", @@ -21,4 +44,4 @@ "category": "sports", "min_rows": 1, "min_cols": 8 -} \ No newline at end of file +} diff --git a/apps/stocks/app.py b/apps/stocks/app.py index 5e4d83a..5f8fe87 100644 --- a/apps/stocks/app.py +++ b/apps/stocks/app.py @@ -26,3 +26,33 @@ def fetch(settings, format_lines, get_rows, get_cols): pages.append(format_lines(*(price_lines + pad))) pages.append(format_lines(*(change_lines + pad))) return pages or [format_lines('STOCKS', 'NO DATA', '')] + + +def trigger(settings, conditions): + """Fire when any followed ticker moves beyond the configured threshold.""" + import yfinance as yf + + threshold = float(conditions.get('threshold', 3)) + direction = conditions.get('direction', 'either') + tickers = [s.strip() for s in settings.get('stocks_list', '').split(',') if s.strip()] + if not tickers: + return False + + try: + for sym in tickers: + t = yf.Ticker(sym) + info = t.fast_info + price = info['lastPrice'] + prev = info['previousClose'] + if not prev: + continue + chg = ((price - prev) / prev) * 100 + if direction == 'up' and chg >= threshold: + return True + if direction == 'down' and chg <= -threshold: + return True + if direction == 'either' and abs(chg) >= threshold: + return True + except Exception: + pass + return False diff --git a/apps/stocks/manifest.json b/apps/stocks/manifest.json index 953ab6c..74a6e53 100644 --- a/apps/stocks/manifest.json +++ b/apps/stocks/manifest.json @@ -6,6 +6,31 @@ "type": "functional", "refresh_interval": 60, "loop_delay": 5, + "trigger_interval": 120, + "trigger_display_seconds": 30, + "trigger_cooldown": 600, + "trigger_conditions": [ + { + "key": "threshold", + "label": "Fire when any ticker moves more than", + "type": "number", + "default": "3", + "min": "1", + "max": "50", + "step": "1" + }, + { + "key": "direction", + "label": "Direction", + "type": "toggle", + "default": "either", + "options": [ + {"value": "either", "label": "Either"}, + {"value": "up", "label": "Up only"}, + {"value": "down", "label": "Down only"} + ] + } + ], "settings": [ { "key": "stocks_list", @@ -29,4 +54,4 @@ "category": "finance", "min_rows": 1, "min_cols": 8 -} \ No newline at end of file +} diff --git a/apps/weather/app.py b/apps/weather/app.py index d39db60..442ba43 100644 --- a/apps/weather/app.py +++ b/apps/weather/app.py @@ -594,3 +594,44 @@ def _get_openmeteo_air(lat, lon): if state['last_pages'] is not None: return state['last_pages'] return [format_lines('WEATHER', 'ERROR', 'CHECK API KEY')] + + +def trigger(settings, conditions): + """Fire on severe weather or when temperature crosses a threshold.""" + import requests + + condition = conditions.get('condition', 'severe') + threshold_f = float(conditions.get('temp_threshold', 90)) + zip_code = settings.get('zip_code', '02118') + + # Severe weather codes (WMO): thunderstorm, heavy rain, snow, hail + SEVERE_CODES = {65, 67, 75, 77, 82, 86, 95, 96, 99} + + try: + # Geocode zip to lat/lon + geo = requests.get( + f'https://nominatim.openstreetmap.org/search?postalcode={zip_code}&country=US&format=json&limit=1', + timeout=5, headers={'User-Agent': 'SplitFlapOS/1.0'} + ).json() + if not geo: + return False + lat, lon = geo[0]['lat'], geo[0]['lon'] + + data = requests.get( + f'https://api.open-meteo.com/v1/forecast?latitude={lat}&longitude={lon}' + '¤t=temperature_2m,weather_code&temperature_unit=fahrenheit', + timeout=8 + ).json() + current = data.get('current', {}) + temp_f = current.get('temperature_2m') + code = current.get('weather_code') + + if condition == 'severe': + return int(code or 0) in SEVERE_CODES + if condition == 'temp_above' and temp_f is not None: + return float(temp_f) >= threshold_f + if condition == 'temp_below' and temp_f is not None: + return float(temp_f) <= threshold_f + except Exception: + pass + return False diff --git a/apps/weather/manifest.json b/apps/weather/manifest.json index ec1eba1..d7c1cdb 100644 --- a/apps/weather/manifest.json +++ b/apps/weather/manifest.json @@ -218,5 +218,31 @@ } ], "min_rows": 1, - "min_cols": 8 + "min_cols": 8, + "trigger_interval": 300, + "trigger_display_seconds": 30, + "trigger_cooldown": 1800, + "trigger_conditions": [ + { + "key": "condition", + "label": "Fire when", + "type": "toggle", + "default": "severe", + "options": [ + {"value": "severe", "label": "Severe weather"}, + {"value": "temp_above", "label": "Temp above threshold"}, + {"value": "temp_below", "label": "Temp below threshold"} + ] + }, + { + "key": "temp_threshold", + "label": "Temperature threshold (°F)", + "type": "number", + "default": "90", + "min": "-40", + "max": "130", + "step": "1", + "visible_when": {"condition": ["temp_above", "temp_below"]} + } + ] } \ No newline at end of file diff --git a/server/static/app.js b/server/static/app.js index 84618ce..5fdc991 100644 --- a/server/static/app.js +++ b/server/static/app.js @@ -902,11 +902,11 @@ function buildAppCard(a, isPlugin) { const icon = appLucideIcon(a.key) || appLucideIcon(a.plugin_id||'') || `${a.icon}`; const hasTrigger = !!(a.has_trigger); - const gearRight = (removable ? 28 : 8) + (hasTrigger ? 20 : 0); + const gearRight = (removable ? 28 : 8); div.innerHTML = ` ${hasCfg && compatible ? `` : ''} ${removable ? `` : ''} - ${hasTrigger && compatible ? `` : ''} + ${hasTrigger && compatible ? `` : ''} ${icon} ${a.name} ${compatible ? a.desc : incompatibleReason}`; From 22da20b0cc3886dbb2404ceb755c6f47a24bc3e6 Mon Sep 17 00:00:00 2001 From: Chris Date: Fri, 15 May 2026 16:12:50 -0500 Subject: [PATCH 04/14] Add trigger() to crypto, youtube, countdown, moon-phase, world_clock (#38) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Crypto: fires when any followed coin moves beyond a % threshold (up/down/either). YouTube: fires when a new video is posted to the followed channel. Seeds last_video_id on first run to avoid false fire on install. Countdown: fires at configurable milestones — 30d, 7d, 1d, 1h, or arrival. Uses state to fire once per milestone per target date. Moon Phase: fires on full moon or new moon (configurable). Resets state between phases so each occurrence fires once. World Clock: fires when business hours open (9am) or close (5pm) in any followed timezone. Fires once per event per day. Co-Authored-By: Claude Sonnet 4.6 --- apps/countdown/app.py | 46 ++++++++++++++++++++++++++++++++++ apps/countdown/manifest.json | 40 +++++++++++++++++++++++++++-- apps/crypto/app.py | 31 +++++++++++++++++++++++ apps/crypto/manifest.json | 27 +++++++++++++++++++- apps/moon-phase/app.py | 34 +++++++++++++++++++++++++ apps/moon-phase/manifest.json | 17 ++++++++++++- apps/world_clock/app.py | 40 +++++++++++++++++++++++++++++ apps/world_clock/manifest.json | 17 ++++++++++++- apps/youtube/app.py | 38 ++++++++++++++++++++++++++++ apps/youtube/manifest.json | 5 +++- 10 files changed, 289 insertions(+), 6 deletions(-) diff --git a/apps/countdown/app.py b/apps/countdown/app.py index cfdc122..25e811b 100644 --- a/apps/countdown/app.py +++ b/apps/countdown/app.py @@ -189,3 +189,49 @@ def parse_target(target_str, tz, now, *, allow_default=False): if rows == 2: return [format_lines('COUNTDOWN', 'CHECK CONFIG')] return [format_lines('COUNTDOWN', 'CHECK CONFIG', '')] + + +def trigger(settings, conditions): + """Fire when the countdown reaches a configured milestone.""" + from datetime import datetime + import pytz + + milestone = conditions.get('milestone', '1d') + target_str = settings.get('countdown_target', '') + tz = pytz.timezone(settings.get('timezone', 'US/Eastern')) + now = datetime.now(tz) + + if not target_str: + return False + + try: + target = datetime.fromisoformat(target_str) + if target.tzinfo is None: + target = tz.localize(target) + diff = target - now + total_secs = diff.total_seconds() + if total_secs <= 0: + return False + + windows = { + '30d': (30 * 86400, 29 * 86400), + '7d': (7 * 86400, 6 * 86400), + '1d': (86400, 82800), + '1h': (3600, 3540), + '0': (60, 0), + } + lo, hi = windows.get(milestone, (86400, 82800)) + in_window = hi <= total_secs <= lo + + state = getattr(trigger, '_state', None) + if state is None: + state = {'fired_milestone': None} + setattr(trigger, '_state', state) + + key = f"{milestone}:{target_str}" + if in_window and state['fired_milestone'] != key: + state['fired_milestone'] = key + return True + except Exception: + pass + return False diff --git a/apps/countdown/manifest.json b/apps/countdown/manifest.json index f12c3d6..e7ec470 100644 --- a/apps/countdown/manifest.json +++ b/apps/countdown/manifest.json @@ -7,6 +7,24 @@ "refresh_interval": 1, "loop_delay": 1, "category": "time", + "trigger_interval": 60, + "trigger_display_seconds": 30, + "trigger_cooldown": 86400, + "trigger_conditions": [ + { + "key": "milestone", + "label": "Fire when", + "type": "toggle", + "default": "1d", + "options": [ + {"value": "30d", "label": "30 days left"}, + {"value": "7d", "label": "7 days left"}, + {"value": "1d", "label": "1 day left"}, + {"value": "1h", "label": "1 hour left"}, + {"value": "0", "label": "Event arrives"} + ] + } + ], "settings": [ { "key": "countdown_enabled", @@ -214,5 +232,23 @@ } ], "min_rows": 1, - "min_cols": 6 -} \ No newline at end of file + "min_cols": 6, + "trigger_interval": 60, + "trigger_display_seconds": 30, + "trigger_cooldown": 86400, + "trigger_conditions": [ + { + "key": "milestone", + "label": "Fire when", + "type": "toggle", + "default": "1d", + "options": [ + {"value": "30d", "label": "30 days left"}, + {"value": "7d", "label": "7 days left"}, + {"value": "1d", "label": "1 day left"}, + {"value": "1h", "label": "1 hour left"}, + {"value": "0", "label": "Event arrives"} + ] + } + ] +} diff --git a/apps/crypto/app.py b/apps/crypto/app.py index 041eb71..5df4335 100644 --- a/apps/crypto/app.py +++ b/apps/crypto/app.py @@ -33,3 +33,34 @@ def fetch(settings, format_lines, get_rows, get_cols): pages.append(format_lines(*(price_lines + pad))) pages.append(format_lines(*(change_lines + pad))) return pages or [format_lines('CRYPTO', 'NO DATA', '')] + + +def trigger(settings, conditions): + """Fire when any followed coin moves beyond the configured threshold.""" + import requests + + threshold = float(conditions.get('threshold', 5)) + direction = conditions.get('direction', 'either') + coins = [s.strip() for s in settings.get('crypto_list', '').split(',') if s.strip()] + if not coins: + return False + + try: + r = requests.get( + 'https://api.coingecko.com/api/v3/simple/price', + params={'ids': ','.join(coins), 'vs_currencies': 'usd', 'include_24hr_change': 'true'}, + timeout=10 + ).json() + for c in coins: + chg = r.get(c, {}).get('usd_24h_change') + if chg is None: + continue + if direction == 'up' and chg >= threshold: + return True + if direction == 'down' and chg <= -threshold: + return True + if direction == 'either' and abs(chg) >= threshold: + return True + except Exception: + pass + return False diff --git a/apps/crypto/manifest.json b/apps/crypto/manifest.json index cc8554a..4efeef6 100644 --- a/apps/crypto/manifest.json +++ b/apps/crypto/manifest.json @@ -6,6 +6,31 @@ "type": "functional", "refresh_interval": 60, "loop_delay": 5, + "trigger_interval": 120, + "trigger_display_seconds": 30, + "trigger_cooldown": 600, + "trigger_conditions": [ + { + "key": "threshold", + "label": "Fire when any coin moves more than", + "type": "number", + "default": "5", + "min": "1", + "max": "100", + "step": "1" + }, + { + "key": "direction", + "label": "Direction", + "type": "toggle", + "default": "either", + "options": [ + {"value": "either", "label": "Either"}, + {"value": "up", "label": "Up only"}, + {"value": "down", "label": "Down only"} + ] + } + ], "settings": [ { "key": "crypto_list", @@ -29,4 +54,4 @@ "category": "finance", "min_rows": 1, "min_cols": 8 -} \ No newline at end of file +} diff --git a/apps/moon-phase/app.py b/apps/moon-phase/app.py index b0569ea..1fed80d 100644 --- a/apps/moon-phase/app.py +++ b/apps/moon-phase/app.py @@ -43,3 +43,37 @@ def fetch(settings, format_lines, get_rows, get_cols): format_lines(phase_name, bar, f'NEW IN {int(days_to_new)}D'), ] return pages + + +def trigger(settings, conditions): + """Fire on full moon or new moon.""" + from datetime import datetime + import pytz, math + + phase_type = conditions.get('phase', 'full') + tz = pytz.timezone(settings.get('timezone', 'US/Eastern')) + now = datetime.now(tz) + + ref = datetime(2000, 1, 6, 18, 14, 0, tzinfo=pytz.utc) + diff = (now.astimezone(pytz.utc) - ref).total_seconds() + synodic = 29.53058867 + days_into_cycle = (diff / 86400) % synodic + + state = getattr(trigger, '_state', None) + if state is None: + state = {'fired_phase': None} + setattr(trigger, '_state', state) + + # Full moon: days 13.5–15.5 into cycle; new moon: days 0–1 or 28.5–29.5 + if phase_type == 'full': + in_phase = 13.5 <= days_into_cycle <= 15.5 + else: # new + in_phase = days_into_cycle <= 1.0 or days_into_cycle >= 28.5 + + phase_key = f"{phase_type}:{int(days_into_cycle)}" + if in_phase and state['fired_phase'] != phase_key: + state['fired_phase'] = phase_key + return True + if not in_phase: + state['fired_phase'] = None # reset so next occurrence fires + return False diff --git a/apps/moon-phase/manifest.json b/apps/moon-phase/manifest.json index 525949f..39c6ae2 100644 --- a/apps/moon-phase/manifest.json +++ b/apps/moon-phase/manifest.json @@ -8,7 +8,22 @@ "version": "1.0", "refresh_interval": 3600, "loop_delay": 10, + "trigger_interval": 3600, + "trigger_display_seconds": 30, + "trigger_cooldown": 86400, + "trigger_conditions": [ + { + "key": "phase", + "label": "Fire on", + "type": "toggle", + "default": "full", + "options": [ + {"value": "full", "label": "Full moon"}, + {"value": "new", "label": "New moon"} + ] + } + ], "settings": [], "min_rows": 3, "min_cols": 10 -} \ No newline at end of file +} diff --git a/apps/world_clock/app.py b/apps/world_clock/app.py index 1bcf371..8fb9128 100644 --- a/apps/world_clock/app.py +++ b/apps/world_clock/app.py @@ -10,3 +10,43 @@ def fetch(settings, format_lines, get_rows, get_cols): lines.append(f'{city} {now.strftime("%I:%M %p")}') lines += [''] * (get_rows() - len(lines)) return [format_lines(*lines)] + + +def trigger(settings, conditions): + """Fire when business hours start or end in a followed timezone.""" + from datetime import datetime + import pytz + + event = conditions.get('event', 'open') + zones = [s.strip() for s in settings.get('world_clock_zones', 'US/Eastern,US/Pacific,Europe/London').split(',')] + + state = getattr(trigger, '_state', None) + if state is None: + state = {'fired_today': set()} + setattr(trigger, '_state', state) + + # Reset daily + today = datetime.utcnow().strftime('%Y-%m-%d') + if state.get('date') != today: + state['fired_today'] = set() + state['date'] = today + + for z in zones: + try: + tz = pytz.timezone(z) + now = datetime.now(tz) + hour, minute = now.hour, now.minute + city = z.split('/')[-1].replace('_', ' ') + key = f"{event}:{z}:{today}" + + if event == 'open' and hour == 9 and minute < 5: + if key not in state['fired_today']: + state['fired_today'].add(key) + return True + elif event == 'close' and hour == 17 and minute < 5: + if key not in state['fired_today']: + state['fired_today'].add(key) + return True + except Exception: + continue + return False diff --git a/apps/world_clock/manifest.json b/apps/world_clock/manifest.json index e530722..3f762f8 100644 --- a/apps/world_clock/manifest.json +++ b/apps/world_clock/manifest.json @@ -7,6 +7,21 @@ "refresh_interval": 1, "loop_delay": 1, "category": "time", + "trigger_interval": 60, + "trigger_display_seconds": 20, + "trigger_cooldown": 43200, + "trigger_conditions": [ + { + "key": "event", + "label": "Fire when", + "type": "toggle", + "default": "open", + "options": [ + {"value": "open", "label": "Business opens (9am)"}, + {"value": "close", "label": "Business closes (5pm)"} + ] + } + ], "settings": [ { "key": "world_clock_zones", @@ -20,4 +35,4 @@ ], "min_rows": 1, "min_cols": 10 -} \ No newline at end of file +} diff --git a/apps/youtube/app.py b/apps/youtube/app.py index 6cc105b..29551d5 100644 --- a/apps/youtube/app.py +++ b/apps/youtube/app.py @@ -15,3 +15,41 @@ def fetch(settings, format_lines, get_rows, get_cols): return [format_lines('YOUTUBE', name, count)] except Exception: return [format_lines('YOUTUBE', 'ERROR', 'CHECK ID')] + + +def trigger(settings, conditions): + """Fire when a new video is posted to the followed channel.""" + import requests + import xml.etree.ElementTree as ET + + channel_id = settings.get('yt_channel_id', '') + if not channel_id: + return False + + state = getattr(trigger, '_state', None) + if state is None: + state = {'last_video_id': None} + setattr(trigger, '_state', state) + + try: + url = f'https://www.youtube.com/feeds/videos.xml?channel_id={channel_id}' + r = requests.get(url, timeout=10) + root = ET.fromstring(r.content) + ns = {'a': 'http://www.w3.org/2005/Atom', 'yt': 'http://www.youtube.com/xml/schemas/2015'} + entries = root.findall('a:entry', ns) + if not entries: + return False + latest_id = entries[0].find('yt:videoId', ns) + if latest_id is None: + return False + vid_id = latest_id.text + if state['last_video_id'] is None: + # First run — seed without firing + state['last_video_id'] = vid_id + return False + if vid_id != state['last_video_id']: + state['last_video_id'] = vid_id + return True + except Exception: + pass + return False diff --git a/apps/youtube/manifest.json b/apps/youtube/manifest.json index 40104a9..5f0c731 100644 --- a/apps/youtube/manifest.json +++ b/apps/youtube/manifest.json @@ -7,6 +7,9 @@ "refresh_interval": 30, "loop_delay": 5, "category": "data", + "trigger_interval": 300, + "trigger_display_seconds": 30, + "trigger_cooldown": 3600, "settings": [ { "key": "yt_channel_id", @@ -18,4 +21,4 @@ ], "min_rows": 3, "min_cols": 10 -} \ No newline at end of file +} From f444af6b09d6d896b56a6c7e312dba7510af7c41 Mon Sep 17 00:00:00 2001 From: Chris Date: Fri, 15 May 2026 16:19:52 -0500 Subject: [PATCH 05/14] Add trigger() to metro, planes_overhead, news-headlines (#38) Metro: fires when the next train on the followed route/stop is arriving within a configurable number of minutes. Supports direction filter. Planes Overhead: fires when a new aircraft appears in the search radius. Reuses fetch() cached flight data to avoid extra API calls. Supports callsign keyword filter (e.g. UAL, N12). News Headlines: fires when a new headline containing configured keywords appears in the RSS feed. Empty keywords = any new headline. Tracks seen titles to avoid re-firing on the same story. Co-Authored-By: Claude Sonnet 4.6 --- apps/metro/app.py | 35 ++++++++++++++++++++ apps/metro/manifest.json | 27 +++++++++++++++- apps/news-headlines/app.py | 45 ++++++++++++++++++++++++++ apps/news-headlines/manifest.json | 13 +++++++- apps/planes_overhead/app.py | 52 +++++++++++++++++++++++++++++- apps/planes_overhead/manifest.json | 24 +++++++++++++- 6 files changed, 192 insertions(+), 4 deletions(-) diff --git a/apps/metro/app.py b/apps/metro/app.py index 6413678..341141b 100644 --- a/apps/metro/app.py +++ b/apps/metro/app.py @@ -29,3 +29,38 @@ def fetch(settings, format_lines, get_rows, get_cols): return [format_lines(header, f'DIR0 {line0}', f'DIR1 {line1}')] except Exception: return [format_lines('METRO', 'ERROR', 'CHECK CONFIG')] + + +def trigger(settings, conditions): + """Fire when the next train is arriving within the configured window.""" + import requests + from datetime import datetime, timezone + + minutes = int(conditions.get('minutes', 5)) + direction = conditions.get('direction', 'either') + stop = settings.get('mbta_stop', 'place-bbsta') + route = settings.get('mbta_route', 'Orange') + + try: + r = requests.get( + 'https://api-v3.mbta.com/predictions', + params={'filter[stop]': stop, 'filter[route]': route, 'sort': 'arrival_time'}, + timeout=10 + ).json() + now = datetime.now(timezone.utc) + for p in r.get('data', []): + arr = p['attributes'].get('arrival_time') + d_id = p['attributes'].get('direction_id', 0) + if not arr: + continue + if direction == '0' and d_id != 0: + continue + if direction == '1' and d_id != 1: + continue + dt = datetime.fromisoformat(arr) + mins_away = (dt - now).total_seconds() / 60 + if 0 <= mins_away <= minutes: + return True + except Exception: + pass + return False diff --git a/apps/metro/manifest.json b/apps/metro/manifest.json index 8f9ae02..51174e9 100644 --- a/apps/metro/manifest.json +++ b/apps/metro/manifest.json @@ -7,6 +7,31 @@ "refresh_interval": 30, "loop_delay": 5, "category": "data", + "trigger_interval": 60, + "trigger_display_seconds": 20, + "trigger_cooldown": 300, + "trigger_conditions": [ + { + "key": "minutes", + "label": "Fire when next train is within", + "type": "number", + "default": "5", + "min": "1", + "max": "15", + "step": "1" + }, + { + "key": "direction", + "label": "Direction", + "type": "toggle", + "default": "either", + "options": [ + {"value": "either", "label": "Either"}, + {"value": "0", "label": "Direction 0"}, + {"value": "1", "label": "Direction 1"} + ] + } + ], "settings": [ { "key": "mbta_stop", @@ -35,4 +60,4 @@ ], "min_rows": 1, "min_cols": 10 -} \ No newline at end of file +} diff --git a/apps/news-headlines/app.py b/apps/news-headlines/app.py index 953297d..d6a25f6 100644 --- a/apps/news-headlines/app.py +++ b/apps/news-headlines/app.py @@ -53,3 +53,48 @@ def split_text(text, width): pages.append(format_lines(*chunk)) return pages or [format_lines('NEWS', 'NO HEADLINES', '')] + + +def trigger(settings, conditions): + """Fire when a headline containing the configured keyword appears.""" + import urllib.request + import xml.etree.ElementTree as ET + + keywords_str = conditions.get('keywords', '').upper().strip() + keywords = [k.strip() for k in keywords_str.split(',') if k.strip()] + feed_url = settings.get('feed_url', 'https://feeds.bbci.co.uk/news/rss.xml') + + state = getattr(trigger, '_state', None) + if state is None: + state = {'seen_titles': set()} + setattr(trigger, '_state', state) + + try: + req = urllib.request.Request(feed_url, headers={"User-Agent": "SplitFlap/1.0"}) + with urllib.request.urlopen(req, timeout=10) as resp: + raw = resp.read() + root = ET.fromstring(raw) + items = root.findall('.//item') + if not items: + items = root.findall('.//{http://www.w3.org/2005/Atom}entry') + + for item in items[:10]: + title_el = item.find('title') or item.find('{http://www.w3.org/2005/Atom}title') + if title_el is None or not title_el.text: + continue + title = title_el.text.strip().upper() + if title in state['seen_titles']: + continue + state['seen_titles'].add(title) + # If no keywords configured, fire on any new headline + if not keywords: + return True + if any(kw in title for kw in keywords): + return True + + # Prune seen set + if len(state['seen_titles']) > 200: + state['seen_titles'] = set(list(state['seen_titles'])[-100:]) + except Exception: + pass + return False diff --git a/apps/news-headlines/manifest.json b/apps/news-headlines/manifest.json index b997140..6bed479 100644 --- a/apps/news-headlines/manifest.json +++ b/apps/news-headlines/manifest.json @@ -27,5 +27,16 @@ } ], "min_rows": 3, - "min_cols": 10 + "min_cols": 10, + "trigger_interval": 300, + "trigger_display_seconds": 30, + "trigger_cooldown": 600, + "trigger_conditions": [ + { + "key": "keywords", + "label": "Keywords (comma-separated, empty = any new headline)", + "type": "text", + "default": "" + } + ] } \ No newline at end of file diff --git a/apps/planes_overhead/app.py b/apps/planes_overhead/app.py index 8cacd1d..0c00f80 100644 --- a/apps/planes_overhead/app.py +++ b/apps/planes_overhead/app.py @@ -602,4 +602,54 @@ def _fetch_provider_flights(provider, lamin, lomin, lamax, lomax): page = format_lines(flight["callsign"], distance, f"{alt} {speed}") pages.extend([page] * dwell_repeat) - return pages \ No newline at end of file + return pages + + +def trigger(settings, conditions): + """Fire when aircraft matching the configured filter appear overhead.""" + import math, requests + + filter_type = conditions.get('filter', 'any') + keyword = conditions.get('keyword', '').upper().strip() + + state = getattr(trigger, '_state', None) + if state is None: + state = {'seen_callsigns': set()} + setattr(trigger, '_state', state) + + # Reuse fetch state's cached flights if available (avoids extra API calls) + fetch_state = getattr(fetch, '_state', None) + flights = fetch_state['flights'] if fetch_state and fetch_state.get('flights') else [] + + if not flights: + # No cached data — do a quick OpenSky poll + try: + loc = settings.get('location', '41.97,-87.90') + lat, lon = [float(x.strip()) for x in loc.split(',')] + radius_km = 50 + d = radius_km / 111.0 + r = requests.get( + 'https://opensky-network.org/api/states/all', + params={'lamin': lat-d, 'lomin': lon-d, 'lamax': lat+d, 'lomax': lon+d}, + timeout=8 + ).json() + flights = [{'callsign': (s[1] or '').strip().upper()} for s in (r.get('states') or [])] + except Exception: + return False + + new_found = False + for f in flights: + cs = f.get('callsign', '') + if not cs: + continue + if filter_type == 'keyword' and keyword and keyword not in cs: + continue + if cs not in state['seen_callsigns']: + state['seen_callsigns'].add(cs) + new_found = True + + # Prune seen set to avoid unbounded growth + if len(state['seen_callsigns']) > 500: + state['seen_callsigns'] = set(list(state['seen_callsigns'])[-200:]) + + return new_found \ No newline at end of file diff --git a/apps/planes_overhead/manifest.json b/apps/planes_overhead/manifest.json index 77ee285..3e54966 100644 --- a/apps/planes_overhead/manifest.json +++ b/apps/planes_overhead/manifest.json @@ -323,5 +323,27 @@ } ], "min_rows": 3, - "min_cols": 12 + "min_cols": 12, + "trigger_interval": 120, + "trigger_display_seconds": 30, + "trigger_cooldown": 60, + "trigger_conditions": [ + { + "key": "filter", + "label": "Fire for", + "type": "toggle", + "default": "any", + "options": [ + {"value": "any", "label": "Any new aircraft"}, + {"value": "keyword", "label": "Callsign keyword"} + ] + }, + { + "key": "keyword", + "label": "Callsign contains (e.g. UAL, N12)", + "type": "text", + "default": "", + "visible_when": {"filter": "keyword"} + } + ] } \ No newline at end of file From 6f7f1b2bd6ab09396725c174d931e314d5f111c9 Mon Sep 17 00:00:00 2001 From: Chris Date: Fri, 15 May 2026 16:24:46 -0500 Subject: [PATCH 06/14] Add trigger() to bitcoin-fear-greed and yt_comments (#38) BTC Fear & Greed: fires when the index enters extreme fear or extreme greed territory. Configurable zone and threshold. Fires once per zone entry, resets when index leaves the zone. YT Comments: fires when a new comment appears on the followed video. Supports optional keyword filter. Seeds seen IDs on first run. Co-Authored-By: Claude Sonnet 4.6 --- apps/bitcoin-fear-greed/app.py | 37 +++++++++++++++++++++++++ apps/bitcoin-fear-greed/manifest.json | 25 +++++++++++++++++ apps/yt_comments/app.py | 39 +++++++++++++++++++++++++++ apps/yt_comments/manifest.json | 13 ++++++++- 4 files changed, 113 insertions(+), 1 deletion(-) diff --git a/apps/bitcoin-fear-greed/app.py b/apps/bitcoin-fear-greed/app.py index 85b2696..7824173 100644 --- a/apps/bitcoin-fear-greed/app.py +++ b/apps/bitcoin-fear-greed/app.py @@ -14,3 +14,40 @@ def fetch(settings, format_lines, get_rows, get_cols): return [format_lines("BTC FEAR&GREED", f"INDEX: {value}/100", label)] except Exception: return [format_lines("FEAR & GREED", "FETCH ERROR", "")] + + +def trigger(settings, conditions): + """Fire when the Fear & Greed index crosses into extreme territory.""" + import urllib.request, json + + zone = conditions.get('zone', 'extreme_fear') + threshold = int(conditions.get('threshold', 20)) + + state = getattr(trigger, '_state', None) + if state is None: + state = {'last_zone': None} + setattr(trigger, '_state', state) + + try: + url = "https://api.alternative.me/fng/?limit=1" + req = urllib.request.Request(url, headers={"User-Agent": "SplitFlap/1.0"}) + with urllib.request.urlopen(req, timeout=8) as resp: + data = json.loads(resp.read().decode()) + value = int(data["data"][0]["value"]) + + if zone == 'extreme_fear': + in_zone = value <= threshold + elif zone == 'extreme_greed': + in_zone = value >= (100 - threshold) + else: # either + in_zone = value <= threshold or value >= (100 - threshold) + + current_zone = zone if in_zone else None + if in_zone and state['last_zone'] != current_zone: + state['last_zone'] = current_zone + return True + if not in_zone: + state['last_zone'] = None + except Exception: + pass + return False diff --git a/apps/bitcoin-fear-greed/manifest.json b/apps/bitcoin-fear-greed/manifest.json index da9590d..7e539fd 100644 --- a/apps/bitcoin-fear-greed/manifest.json +++ b/apps/bitcoin-fear-greed/manifest.json @@ -8,5 +8,30 @@ "version": "1.0", "refresh_interval": 300, "loop_delay": 10, + "trigger_interval": 3600, + "trigger_display_seconds": 30, + "trigger_cooldown": 14400, + "trigger_conditions": [ + { + "key": "zone", + "label": "Fire when index enters", + "type": "toggle", + "default": "extreme_fear", + "options": [ + {"value": "extreme_fear", "label": "Extreme Fear"}, + {"value": "extreme_greed", "label": "Extreme Greed"}, + {"value": "either", "label": "Either extreme"} + ] + }, + { + "key": "threshold", + "label": "Threshold (distance from 0 or 100)", + "type": "number", + "default": "20", + "min": "5", + "max": "40", + "step": "5" + } + ], "settings": [] } diff --git a/apps/yt_comments/app.py b/apps/yt_comments/app.py index 624d1be..5dd1737 100644 --- a/apps/yt_comments/app.py +++ b/apps/yt_comments/app.py @@ -26,3 +26,42 @@ def fetch(settings, format_lines, get_rows, get_cols): return pages or [format_lines('COMMENTS', 'NONE FOUND', '')] except Exception: return [format_lines('COMMENTS', 'ERROR', 'CHECK CONFIG')] + + +def trigger(settings, conditions): + """Fire when a new comment appears on the followed video.""" + import requests + + video_id = settings.get('yt_video_id', '') + api_key = settings.get('yt_api_key', '') + keyword = conditions.get('keyword', '').upper().strip() + if not video_id or not api_key: + return False + + state = getattr(trigger, '_state', None) + if state is None: + state = {'seen_ids': set()} + setattr(trigger, '_state', state) + + try: + r = requests.get( + 'https://www.googleapis.com/youtube/v3/commentThreads', + params={'part': 'snippet', 'videoId': video_id, 'key': api_key, + 'maxResults': 5, 'order': 'time'}, + timeout=10 + ).json() + for item in r.get('items', []): + cid = item.get('id', '') + if cid in state['seen_ids']: + continue + state['seen_ids'].add(cid) + if not keyword: + return True + text = item['snippet']['topLevelComment']['snippet'].get('textDisplay', '').upper() + if keyword in text: + return True + if len(state['seen_ids']) > 500: + state['seen_ids'] = set(list(state['seen_ids'])[-200:]) + except Exception: + pass + return False diff --git a/apps/yt_comments/manifest.json b/apps/yt_comments/manifest.json index 9c5e974..0876fa5 100644 --- a/apps/yt_comments/manifest.json +++ b/apps/yt_comments/manifest.json @@ -6,6 +6,17 @@ "type": "functional", "refresh_interval": 60, "loop_delay": 8, + "trigger_interval": 120, + "trigger_display_seconds": 30, + "trigger_cooldown": 300, + "trigger_conditions": [ + { + "key": "keyword", + "label": "Keyword in comment (empty = any new comment)", + "type": "text", + "default": "" + } + ], "settings": [ { "key": "yt_video_id", @@ -32,4 +43,4 @@ } ], "category": "data" -} \ No newline at end of file +} From 96aa461b7d9116ffa4a46a4edb42e45f55ec3307 Mon Sep 17 00:00:00 2001 From: Chris Date: Fri, 15 May 2026 16:26:17 -0500 Subject: [PATCH 07/14] Add trigger() to time-since (#38) Fires when elapsed time hits a round milestone: 100 days, 1 year, 2 years, 5 years, or 10 years. Fires once per milestone per event date. Co-Authored-By: Claude Sonnet 4.6 --- apps/time-since/app.py | 45 +++++++++++++++++++++++++++++++++++ apps/time-since/manifest.json | 20 +++++++++++++++- 2 files changed, 64 insertions(+), 1 deletion(-) diff --git a/apps/time-since/app.py b/apps/time-since/app.py index b9ca703..ec8d80b 100644 --- a/apps/time-since/app.py +++ b/apps/time-since/app.py @@ -23,3 +23,48 @@ def fetch(settings, format_lines, get_rows, get_cols): else: elapsed = f'{days}D {hrs}H {mins}M {secs}S' return [format_lines(event, elapsed, 'TIME SINCE')] + + +def trigger(settings, conditions): + """Fire when the elapsed time hits a round milestone.""" + from datetime import datetime + import pytz + + milestone = conditions.get('milestone', '1y') + tz = pytz.timezone(settings.get('timezone', 'US/Eastern')) + now = datetime.now(tz) + date_str = settings.get('event_date', '2024-01-01') + + state = getattr(trigger, '_state', None) + if state is None: + state = {'fired_milestone': None} + setattr(trigger, '_state', state) + + try: + start = tz.localize(datetime.strptime(date_str, '%Y-%m-%d')) + diff = now - start + if diff.total_seconds() < 0: + return False + days = diff.days + + # Map milestone to day windows + windows = { + '100d': (100, 101), + '365d': (365, 366), + '1y': (365, 366), + '2y': (730, 731), + '5y': (1825, 1826), + '10y': (3650, 3651), + } + lo, hi = windows.get(milestone, (365, 366)) + in_window = lo <= days < hi + key = f"{milestone}:{date_str}:{lo}" + + if in_window and state['fired_milestone'] != key: + state['fired_milestone'] = key + return True + if not in_window and state['fired_milestone'] == key: + state['fired_milestone'] = None + except Exception: + pass + return False diff --git a/apps/time-since/manifest.json b/apps/time-since/manifest.json index 2040dd0..3920a65 100644 --- a/apps/time-since/manifest.json +++ b/apps/time-since/manifest.json @@ -8,6 +8,24 @@ "version": "1.0", "refresh_interval": 1, "loop_delay": 1, + "trigger_interval": 3600, + "trigger_display_seconds": 30, + "trigger_cooldown": 86400, + "trigger_conditions": [ + { + "key": "milestone", + "label": "Fire when elapsed time reaches", + "type": "toggle", + "default": "1y", + "options": [ + {"value": "100d", "label": "100 days"}, + {"value": "365d", "label": "1 year"}, + {"value": "2y", "label": "2 years"}, + {"value": "5y", "label": "5 years"}, + {"value": "10y", "label": "10 years"} + ] + } + ], "settings": [ { "key": "event_name", @@ -24,4 +42,4 @@ ], "min_rows": 3, "min_cols": 10 -} \ No newline at end of file +} From dfd7a535e0240bd11760c4799c1041603d7ebb3a Mon Sep 17 00:00:00 2001 From: Chris Date: Fri, 15 May 2026 16:31:36 -0500 Subject: [PATCH 08/14] Expand trigger conditions for sports, stocks, birdnet, weather (#38) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sports: add overtime/extra time and playoff/postseason event types. Stocks: add price target condition (fires when ticker crosses a specific price, resets when price moves away). Condition type toggle selects between % move and price target. BirdNET: add watchlist (comma-separated species list) and high_confidence (configurable % threshold) filter options. Weather: add rain_starting (dry→rain transition), rapid_temp_change (configurable °F delta), uv_high, and wind_high conditions. Now fetches UV index and wind speed from Open-Meteo alongside temp/code. Co-Authored-By: Claude Sonnet 4.6 --- apps/birdnet/app.py | 8 +++++++ apps/birdnet/manifest.json | 25 +++++++++++++++++--- apps/sports/app.py | 19 +++++++++++++++ apps/sports/manifest.json | 4 +++- apps/stocks/app.py | 47 ++++++++++++++++++++++++++++---------- apps/stocks/manifest.json | 30 ++++++++++++++++++++---- apps/weather/app.py | 45 +++++++++++++++++++++++++++++++----- apps/weather/manifest.json | 32 ++++++++++++++++++++++---- 8 files changed, 180 insertions(+), 30 deletions(-) diff --git a/apps/birdnet/app.py b/apps/birdnet/app.py index 50f5843..277f6a0 100644 --- a/apps/birdnet/app.py +++ b/apps/birdnet/app.py @@ -117,6 +117,9 @@ def trigger(settings, conditions): min_conf = int(settings.get('min_confidence', '70')) / 100 filt = conditions.get('filter', 'any') species_query = conditions.get('species', '').lower().strip() + watchlist_str = conditions.get('watchlist', '').lower() + watchlist = [s.strip() for s in watchlist_str.split(',') if s.strip()] + high_conf_threshold = float(conditions.get('high_confidence', 95)) / 100 state = getattr(trigger, '_state', None) if state is None: @@ -134,6 +137,7 @@ def trigger(settings, conditions): return False # nothing new state['last_id'] = det_id species = latest.get('species', '') + confidence = latest.get('confidence', 0) if filt == 'any': return True @@ -143,6 +147,10 @@ def trigger(settings, conditions): if species not in state['seen_today']: state['seen_today'].add(species) return True + if filt == 'watchlist': + return bool(watchlist) and any(w in species.lower() for w in watchlist) + if filt == 'high_confidence': + return confidence >= high_conf_threshold return False except Exception: return False diff --git a/apps/birdnet/manifest.json b/apps/birdnet/manifest.json index 32d7fe6..8f93994 100644 --- a/apps/birdnet/manifest.json +++ b/apps/birdnet/manifest.json @@ -17,9 +17,11 @@ "type": "toggle", "default": "any", "options": [ - {"value": "any", "label": "Any detection"}, - {"value": "new_today", "label": "New species today"}, - {"value": "specific", "label": "Specific species"} + {"value": "any", "label": "Any detection"}, + {"value": "new_today", "label": "New species today"}, + {"value": "specific", "label": "Specific species"}, + {"value": "watchlist", "label": "Watchlist species"}, + {"value": "high_confidence", "label": "High confidence"} ] }, { @@ -28,6 +30,23 @@ "type": "text", "default": "", "visible_when": {"filter": "specific"} + }, + { + "key": "watchlist", + "label": "Watchlist (comma-separated species names)", + "type": "text", + "default": "", + "visible_when": {"filter": "watchlist"} + }, + { + "key": "high_confidence", + "label": "Minimum confidence %", + "type": "number", + "default": "95", + "min": "80", + "max": "99", + "step": "5", + "visible_when": {"filter": "high_confidence"} } ], "settings": [ diff --git a/apps/sports/app.py b/apps/sports/app.py index 72898a7..0787f78 100644 --- a/apps/sports/app.py +++ b/apps/sports/app.py @@ -326,6 +326,25 @@ def trigger(settings, conditions): state_obj['seen_game_ids'].add(game_id) return True + elif event_type == 'overtime': + detail = event.get('status', {}).get('type', {}).get('shortDetail', '').upper() + ot_keywords = ('OT', 'OVERTIME', 'EXTRA', 'SHOOTOUT', 'PENALTY') + if state == 'in' and any(k in detail for k in ot_keywords): + ot_key = f"ot_{game_id}" + if ot_key not in state_obj['seen_game_ids']: + state_obj['seen_game_ids'].add(ot_key) + return True + + elif event_type == 'playoff': + notes = comp.get('notes', []) + is_playoff = any('playoff' in str(n).lower() or 'postseason' in str(n).lower() for n in notes) + if not is_playoff: + season_type = event.get('season', {}).get('type', 2) + is_playoff = season_type in (3, 4) # 3=postseason, 4=offseason + if is_playoff and state == 'in' and game_id not in state_obj['seen_game_ids']: + state_obj['seen_game_ids'].add(game_id) + return True + except Exception: pass return False diff --git a/apps/sports/manifest.json b/apps/sports/manifest.json index 19980a3..af09a40 100644 --- a/apps/sports/manifest.json +++ b/apps/sports/manifest.json @@ -19,7 +19,9 @@ {"value": "game_start", "label": "Game starts"}, {"value": "score_change", "label": "Score changes"}, {"value": "close_game", "label": "Close game (within 5)"}, - {"value": "final", "label": "Game ends"} + {"value": "final", "label": "Game ends"}, + {"value": "overtime", "label": "Overtime / extra time"}, + {"value": "playoff", "label": "Playoff / postseason game"} ] }, { diff --git a/apps/stocks/app.py b/apps/stocks/app.py index 5f8fe87..2089835 100644 --- a/apps/stocks/app.py +++ b/apps/stocks/app.py @@ -29,30 +29,53 @@ def fetch(settings, format_lines, get_rows, get_cols): def trigger(settings, conditions): - """Fire when any followed ticker moves beyond the configured threshold.""" + """Fire when any followed ticker moves beyond the configured threshold or hits a price target.""" import yfinance as yf - threshold = float(conditions.get('threshold', 3)) - direction = conditions.get('direction', 'either') + condition_type = conditions.get('condition_type', 'pct_change') tickers = [s.strip() for s in settings.get('stocks_list', '').split(',') if s.strip()] if not tickers: return False + state = getattr(trigger, '_state', None) + if state is None: + state = {'fired_targets': set()} + setattr(trigger, '_state', state) + try: for sym in tickers: t = yf.Ticker(sym) info = t.fast_info price = info['lastPrice'] prev = info['previousClose'] - if not prev: - continue - chg = ((price - prev) / prev) * 100 - if direction == 'up' and chg >= threshold: - return True - if direction == 'down' and chg <= -threshold: - return True - if direction == 'either' and abs(chg) >= threshold: - return True + + if condition_type == 'pct_change': + threshold = float(conditions.get('threshold', 3)) + direction = conditions.get('direction', 'either') + if not prev: + continue + chg = ((price - prev) / prev) * 100 + if direction == 'up' and chg >= threshold: + return True + if direction == 'down' and chg <= -threshold: + return True + if direction == 'either' and abs(chg) >= threshold: + return True + + elif condition_type == 'price_target': + target = float(conditions.get('price_target', 0)) + direction = conditions.get('direction', 'above') + if not target: + continue + key = f"{sym}:{direction}:{target}" + crossed = (direction == 'above' and price >= target) or \ + (direction == 'below' and price <= target) + if crossed and key not in state['fired_targets']: + state['fired_targets'].add(key) + return True + if not crossed and key in state['fired_targets']: + state['fired_targets'].discard(key) # reset when price moves away + except Exception: pass return False diff --git a/apps/stocks/manifest.json b/apps/stocks/manifest.json index 74a6e53..95aa6c7 100644 --- a/apps/stocks/manifest.json +++ b/apps/stocks/manifest.json @@ -10,14 +10,25 @@ "trigger_display_seconds": 30, "trigger_cooldown": 600, "trigger_conditions": [ + { + "key": "condition_type", + "label": "Condition", + "type": "toggle", + "default": "pct_change", + "options": [ + {"value": "pct_change", "label": "% Move"}, + {"value": "price_target", "label": "Price target"} + ] + }, { "key": "threshold", - "label": "Fire when any ticker moves more than", + "label": "Move more than (%) ", "type": "number", "default": "3", "min": "1", "max": "50", - "step": "1" + "step": "1", + "visible_when": {"condition_type": "pct_change"} }, { "key": "direction", @@ -26,9 +37,20 @@ "default": "either", "options": [ {"value": "either", "label": "Either"}, - {"value": "up", "label": "Up only"}, - {"value": "down", "label": "Down only"} + {"value": "up", "label": "Up only"}, + {"value": "down", "label": "Down only"}, + {"value": "above", "label": "Above target"}, + {"value": "below", "label": "Below target"} ] + }, + { + "key": "price_target", + "label": "Price target ($)", + "type": "number", + "default": "0", + "min": "0", + "step": "1", + "visible_when": {"condition_type": "price_target"} } ], "settings": [ diff --git a/apps/weather/app.py b/apps/weather/app.py index 442ba43..388c152 100644 --- a/apps/weather/app.py +++ b/apps/weather/app.py @@ -597,18 +597,25 @@ def _get_openmeteo_air(lat, lon): def trigger(settings, conditions): - """Fire on severe weather or when temperature crosses a threshold.""" + """Fire on severe weather, temperature threshold, rain starting, rapid temp change, UV, or wind.""" import requests condition = conditions.get('condition', 'severe') threshold_f = float(conditions.get('temp_threshold', 90)) + uv_threshold = float(conditions.get('uv_threshold', 7)) + wind_threshold = float(conditions.get('wind_threshold', 25)) zip_code = settings.get('zip_code', '02118') - # Severe weather codes (WMO): thunderstorm, heavy rain, snow, hail SEVERE_CODES = {65, 67, 75, 77, 82, 86, 95, 96, 99} + RAIN_CODES = {51, 53, 55, 61, 63, 65, 66, 67, 80, 81, 82} + DRY_CODES = {0, 1, 2, 3, 45, 48} + + state = getattr(trigger, '_state', None) + if state is None: + state = {'last_code': None, 'last_temp': None} + setattr(trigger, '_state', state) try: - # Geocode zip to lat/lon geo = requests.get( f'https://nominatim.openstreetmap.org/search?postalcode={zip_code}&country=US&format=json&limit=1', timeout=5, headers={'User-Agent': 'SplitFlapOS/1.0'} @@ -619,19 +626,45 @@ def trigger(settings, conditions): data = requests.get( f'https://api.open-meteo.com/v1/forecast?latitude={lat}&longitude={lon}' - '¤t=temperature_2m,weather_code&temperature_unit=fahrenheit', + '¤t=temperature_2m,weather_code,uv_index,wind_speed_10m' + '&temperature_unit=fahrenheit&wind_speed_unit=mph', timeout=8 ).json() current = data.get('current', {}) temp_f = current.get('temperature_2m') - code = current.get('weather_code') + code = int(current.get('weather_code') or 0) + uv = current.get('uv_index') + wind = current.get('wind_speed_10m') if condition == 'severe': - return int(code or 0) in SEVERE_CODES + return code in SEVERE_CODES + if condition == 'temp_above' and temp_f is not None: return float(temp_f) >= threshold_f + if condition == 'temp_below' and temp_f is not None: return float(temp_f) <= threshold_f + + if condition == 'rain_starting': + prev_code = state['last_code'] + state['last_code'] = code + was_dry = prev_code is not None and prev_code in DRY_CODES + now_rain = code in RAIN_CODES + return was_dry and now_rain + + if condition == 'rapid_temp_change' and temp_f is not None: + prev_temp = state['last_temp'] + state['last_temp'] = float(temp_f) + if prev_temp is not None: + return abs(float(temp_f) - prev_temp) >= threshold_f + return False + + if condition == 'uv_high' and uv is not None: + return float(uv) >= uv_threshold + + if condition == 'wind_high' and wind is not None: + return float(wind) >= wind_threshold + except Exception: pass return False diff --git a/apps/weather/manifest.json b/apps/weather/manifest.json index d7c1cdb..b00db62 100644 --- a/apps/weather/manifest.json +++ b/apps/weather/manifest.json @@ -229,9 +229,13 @@ "type": "toggle", "default": "severe", "options": [ - {"value": "severe", "label": "Severe weather"}, - {"value": "temp_above", "label": "Temp above threshold"}, - {"value": "temp_below", "label": "Temp below threshold"} + {"value": "severe", "label": "Severe weather"}, + {"value": "temp_above", "label": "Temp above threshold"}, + {"value": "temp_below", "label": "Temp below threshold"}, + {"value": "rain_starting", "label": "Rain starting"}, + {"value": "rapid_temp_change", "label": "Rapid temp change"}, + {"value": "uv_high", "label": "UV index high"}, + {"value": "wind_high", "label": "Wind speed high"} ] }, { @@ -242,7 +246,27 @@ "min": "-40", "max": "130", "step": "1", - "visible_when": {"condition": ["temp_above", "temp_below"]} + "visible_when": {"condition": ["temp_above", "temp_below", "rapid_temp_change"]} + }, + { + "key": "uv_threshold", + "label": "UV index threshold", + "type": "number", + "default": "7", + "min": "1", + "max": "11", + "step": "1", + "visible_when": {"condition": "uv_high"} + }, + { + "key": "wind_threshold", + "label": "Wind speed threshold (mph)", + "type": "number", + "default": "25", + "min": "5", + "max": "100", + "step": "5", + "visible_when": {"condition": "wind_high"} } ] } \ No newline at end of file From 2a8eb3df9dd2fe477dec34868764bb9779b4b66f Mon Sep 17 00:00:00 2001 From: Chris Date: Fri, 15 May 2026 16:39:33 -0500 Subject: [PATCH 09/14] Expand trigger conditions across all apps (#38) Sports: add comeback alert (was down by N, now within 3). Stocks: add 52-week high/low condition. Condition type toggle now includes pct_change, price_target, and 52w_extreme. Crypto: add price target condition (mirrors stocks pattern). Condition type toggle selects between % move and price target. YouTube: add view milestone condition (requires API key). Condition type toggle selects between new_video and view_milestone. Metro: add service alert condition (fires on DELAY, SUSPENSION, SHUTTLE, STOP_CLOSURE, DETOUR). Condition type toggle selects between arriving and alert. Planes Overhead: add military aircraft filter using common US military callsign prefixes (RCH, SPAR, SAM, VENUS, etc.). BirdNET: add busy feeder condition (N detections within M minutes). Co-Authored-By: Claude Sonnet 4.6 --- apps/birdnet/app.py | 13 ++++++ apps/birdnet/manifest.json | 23 ++++++++++- apps/crypto/app.py | 50 ++++++++++++++++------ apps/crypto/manifest.json | 30 ++++++++++++-- apps/metro/app.py | 66 +++++++++++++++++++++--------- apps/metro/manifest.json | 18 ++++++-- apps/planes_overhead/app.py | 11 ++++- apps/planes_overhead/manifest.json | 5 ++- apps/sports/app.py | 23 +++++++++++ apps/sports/manifest.json | 13 +++++- apps/stocks/app.py | 25 +++++++++++ apps/stocks/manifest.json | 17 +++++++- apps/youtube/app.py | 45 ++++++++++++++------ apps/youtube/manifest.json | 21 ++++++++++ 14 files changed, 302 insertions(+), 58 deletions(-) diff --git a/apps/birdnet/app.py b/apps/birdnet/app.py index 277f6a0..a11c4a0 100644 --- a/apps/birdnet/app.py +++ b/apps/birdnet/app.py @@ -151,6 +151,19 @@ def trigger(settings, conditions): return bool(watchlist) and any(w in species.lower() for w in watchlist) if filt == 'high_confidence': return confidence >= high_conf_threshold + if filt == 'busy_feeder': + count = int(conditions.get('busy_count', 5)) + window_mins = int(conditions.get('busy_window', 10)) + import time as _time + now_ts = _time.time() + # Store recent detection timestamps + if 'recent_times' not in state: + state['recent_times'] = [] + state['recent_times'].append(now_ts) + # Prune to window + cutoff = now_ts - (window_mins * 60) + state['recent_times'] = [t for t in state['recent_times'] if t >= cutoff] + return len(state['recent_times']) >= count return False except Exception: return False diff --git a/apps/birdnet/manifest.json b/apps/birdnet/manifest.json index 8f93994..131fc6f 100644 --- a/apps/birdnet/manifest.json +++ b/apps/birdnet/manifest.json @@ -21,7 +21,8 @@ {"value": "new_today", "label": "New species today"}, {"value": "specific", "label": "Specific species"}, {"value": "watchlist", "label": "Watchlist species"}, - {"value": "high_confidence", "label": "High confidence"} + {"value": "high_confidence", "label": "High confidence"}, + {"value": "busy_feeder", "label": "Busy feeder"} ] }, { @@ -47,6 +48,26 @@ "max": "99", "step": "5", "visible_when": {"filter": "high_confidence"} + }, + { + "key": "busy_count", + "label": "Detections within window", + "type": "number", + "default": "5", + "min": "2", + "max": "20", + "step": "1", + "visible_when": {"filter": "busy_feeder"} + }, + { + "key": "busy_window", + "label": "Window (minutes)", + "type": "number", + "default": "10", + "min": "1", + "max": "60", + "step": "1", + "visible_when": {"filter": "busy_feeder"} } ], "settings": [ diff --git a/apps/crypto/app.py b/apps/crypto/app.py index 5df4335..e27d7ca 100644 --- a/apps/crypto/app.py +++ b/apps/crypto/app.py @@ -36,31 +36,57 @@ def fetch(settings, format_lines, get_rows, get_cols): def trigger(settings, conditions): - """Fire when any followed coin moves beyond the configured threshold.""" + """Fire when any followed coin moves beyond threshold or hits a price target.""" import requests - threshold = float(conditions.get('threshold', 5)) - direction = conditions.get('direction', 'either') + condition_type = conditions.get('condition_type', 'pct_change') coins = [s.strip() for s in settings.get('crypto_list', '').split(',') if s.strip()] if not coins: return False + state = getattr(trigger, '_state', None) + if state is None: + state = {'fired_targets': set()} + setattr(trigger, '_state', state) + try: r = requests.get( 'https://api.coingecko.com/api/v3/simple/price', params={'ids': ','.join(coins), 'vs_currencies': 'usd', 'include_24hr_change': 'true'}, timeout=10 ).json() + for c in coins: - chg = r.get(c, {}).get('usd_24h_change') - if chg is None: - continue - if direction == 'up' and chg >= threshold: - return True - if direction == 'down' and chg <= -threshold: - return True - if direction == 'either' and abs(chg) >= threshold: - return True + d = r.get(c, {}) + price = d.get('usd') + chg = d.get('usd_24h_change') + + if condition_type == 'pct_change': + threshold = float(conditions.get('threshold', 5)) + direction = conditions.get('direction', 'either') + if chg is None: + continue + if direction == 'up' and chg >= threshold: + return True + if direction == 'down' and chg <= -threshold: + return True + if direction == 'either' and abs(chg) >= threshold: + return True + + elif condition_type == 'price_target' and price is not None: + target = float(conditions.get('price_target', 0)) + direction = conditions.get('direction', 'above') + if not target: + continue + key = f"{c}:{direction}:{target}" + crossed = (direction == 'above' and price >= target) or \ + (direction == 'below' and price <= target) + if crossed and key not in state['fired_targets']: + state['fired_targets'].add(key) + return True + if not crossed: + state['fired_targets'].discard(key) + except Exception: pass return False diff --git a/apps/crypto/manifest.json b/apps/crypto/manifest.json index 4efeef6..392e3ba 100644 --- a/apps/crypto/manifest.json +++ b/apps/crypto/manifest.json @@ -10,14 +10,25 @@ "trigger_display_seconds": 30, "trigger_cooldown": 600, "trigger_conditions": [ + { + "key": "condition_type", + "label": "Condition", + "type": "toggle", + "default": "pct_change", + "options": [ + {"value": "pct_change", "label": "% Move"}, + {"value": "price_target", "label": "Price target"} + ] + }, { "key": "threshold", - "label": "Fire when any coin moves more than", + "label": "Move more than (%)", "type": "number", "default": "5", "min": "1", "max": "100", - "step": "1" + "step": "1", + "visible_when": {"condition_type": "pct_change"} }, { "key": "direction", @@ -26,9 +37,20 @@ "default": "either", "options": [ {"value": "either", "label": "Either"}, - {"value": "up", "label": "Up only"}, - {"value": "down", "label": "Down only"} + {"value": "up", "label": "Up only"}, + {"value": "down", "label": "Down only"}, + {"value": "above", "label": "Above target"}, + {"value": "below", "label": "Below target"} ] + }, + { + "key": "price_target", + "label": "Price target ($)", + "type": "number", + "default": "0", + "min": "0", + "step": "1", + "visible_when": {"condition_type": "price_target"} } ], "settings": [ diff --git a/apps/metro/app.py b/apps/metro/app.py index 341141b..8620794 100644 --- a/apps/metro/app.py +++ b/apps/metro/app.py @@ -32,35 +32,61 @@ def fetch(settings, format_lines, get_rows, get_cols): def trigger(settings, conditions): - """Fire when the next train is arriving within the configured window.""" + """Fire when the next train is arriving within the configured window, or on service alerts.""" import requests from datetime import datetime, timezone + condition_type = conditions.get('condition_type', 'arriving') minutes = int(conditions.get('minutes', 5)) direction = conditions.get('direction', 'either') stop = settings.get('mbta_stop', 'place-bbsta') route = settings.get('mbta_route', 'Orange') + state = getattr(trigger, '_state', None) + if state is None: + state = {'seen_alert_ids': set()} + setattr(trigger, '_state', state) + try: - r = requests.get( - 'https://api-v3.mbta.com/predictions', - params={'filter[stop]': stop, 'filter[route]': route, 'sort': 'arrival_time'}, - timeout=10 - ).json() - now = datetime.now(timezone.utc) - for p in r.get('data', []): - arr = p['attributes'].get('arrival_time') - d_id = p['attributes'].get('direction_id', 0) - if not arr: - continue - if direction == '0' and d_id != 0: - continue - if direction == '1' and d_id != 1: - continue - dt = datetime.fromisoformat(arr) - mins_away = (dt - now).total_seconds() / 60 - if 0 <= mins_away <= minutes: - return True + if condition_type == 'arriving': + r = requests.get( + 'https://api-v3.mbta.com/predictions', + params={'filter[stop]': stop, 'filter[route]': route, 'sort': 'arrival_time'}, + timeout=10 + ).json() + now = datetime.now(timezone.utc) + for p in r.get('data', []): + arr = p['attributes'].get('arrival_time') + d_id = p['attributes'].get('direction_id', 0) + if not arr: + continue + if direction == '0' and d_id != 0: + continue + if direction == '1' and d_id != 1: + continue + dt = datetime.fromisoformat(arr) + mins_away = (dt - now).total_seconds() / 60 + if 0 <= mins_away <= minutes: + return True + + elif condition_type == 'alert': + r = requests.get( + 'https://api-v3.mbta.com/alerts', + params={'filter[route]': route, 'filter[stop]': stop}, + timeout=10 + ).json() + for alert in r.get('data', []): + aid = alert.get('id', '') + effect = alert.get('attributes', {}).get('effect', '') + # Only fire for service-affecting alerts + if effect in ('DELAY', 'SUSPENSION', 'SHUTTLE', 'STOP_CLOSURE', 'DETOUR'): + if aid not in state['seen_alert_ids']: + state['seen_alert_ids'].add(aid) + return True + # Prune old alert IDs + if len(state['seen_alert_ids']) > 200: + state['seen_alert_ids'] = set(list(state['seen_alert_ids'])[-100:]) + except Exception: pass return False diff --git a/apps/metro/manifest.json b/apps/metro/manifest.json index 51174e9..ccb3188 100644 --- a/apps/metro/manifest.json +++ b/apps/metro/manifest.json @@ -11,14 +11,25 @@ "trigger_display_seconds": 20, "trigger_cooldown": 300, "trigger_conditions": [ + { + "key": "condition_type", + "label": "Fire when", + "type": "toggle", + "default": "arriving", + "options": [ + {"value": "arriving", "label": "Train arriving"}, + {"value": "alert", "label": "Service alert"} + ] + }, { "key": "minutes", - "label": "Fire when next train is within", + "label": "Arriving within (minutes)", "type": "number", "default": "5", "min": "1", "max": "15", - "step": "1" + "step": "1", + "visible_when": {"condition_type": "arriving"} }, { "key": "direction", @@ -29,7 +40,8 @@ {"value": "either", "label": "Either"}, {"value": "0", "label": "Direction 0"}, {"value": "1", "label": "Direction 1"} - ] + ], + "visible_when": {"condition_type": "arriving"} } ], "settings": [ diff --git a/apps/planes_overhead/app.py b/apps/planes_overhead/app.py index 0c00f80..95cbb82 100644 --- a/apps/planes_overhead/app.py +++ b/apps/planes_overhead/app.py @@ -607,11 +607,18 @@ def _fetch_provider_flights(provider, lamin, lomin, lamax, lomax): def trigger(settings, conditions): """Fire when aircraft matching the configured filter appear overhead.""" - import math, requests + import requests filter_type = conditions.get('filter', 'any') keyword = conditions.get('keyword', '').upper().strip() + # Common US military callsign prefixes + MILITARY_PREFIXES = ( + 'RCH', 'REACH', 'SPAR', 'SAM', 'VENUS', 'EVAC', 'JAKE', + 'TOPGUN', 'VIPER', 'MAGMA', 'IRON', 'DOOM', 'SKULL', 'GHOST', + 'ARMY', 'NAVY', 'USMC', 'USCG', 'AFSOC', 'DUKE', 'BOXER', + ) + state = getattr(trigger, '_state', None) if state is None: state = {'seen_callsigns': set()} @@ -644,6 +651,8 @@ def trigger(settings, conditions): continue if filter_type == 'keyword' and keyword and keyword not in cs: continue + if filter_type == 'military' and not any(cs.startswith(p) for p in MILITARY_PREFIXES): + continue if cs not in state['seen_callsigns']: state['seen_callsigns'].add(cs) new_found = True diff --git a/apps/planes_overhead/manifest.json b/apps/planes_overhead/manifest.json index 3e54966..6add529 100644 --- a/apps/planes_overhead/manifest.json +++ b/apps/planes_overhead/manifest.json @@ -334,8 +334,9 @@ "type": "toggle", "default": "any", "options": [ - {"value": "any", "label": "Any new aircraft"}, - {"value": "keyword", "label": "Callsign keyword"} + {"value": "any", "label": "Any new aircraft"}, + {"value": "keyword", "label": "Callsign keyword"}, + {"value": "military", "label": "Military aircraft"} ] }, { diff --git a/apps/sports/app.py b/apps/sports/app.py index 0787f78..1729747 100644 --- a/apps/sports/app.py +++ b/apps/sports/app.py @@ -345,6 +345,29 @@ def trigger(settings, conditions): state_obj['seen_game_ids'].add(game_id) return True + elif event_type == 'comeback': + if state == 'in': + margin = int(conditions.get('comeback_margin', 10)) + prev = state_obj['last_scores'].get(score_key) + curr = (a_score, h_score) + state_obj['last_scores'][score_key] = curr + if prev: + # Check if a team was down by margin and is now within 3 + prev_diff = prev[0] - prev[1] + curr_diff = curr[0] - curr[1] + # Away was down big, now close + if prev_diff <= -margin and abs(curr_diff) <= 3: + comeback_key = f"comeback_a_{game_id}" + if comeback_key not in state_obj['seen_game_ids']: + state_obj['seen_game_ids'].add(comeback_key) + return True + # Home was down big, now close + if prev_diff >= margin and abs(curr_diff) <= 3: + comeback_key = f"comeback_h_{game_id}" + if comeback_key not in state_obj['seen_game_ids']: + state_obj['seen_game_ids'].add(comeback_key) + return True + except Exception: pass return False diff --git a/apps/sports/manifest.json b/apps/sports/manifest.json index af09a40..2d6f34e 100644 --- a/apps/sports/manifest.json +++ b/apps/sports/manifest.json @@ -21,7 +21,8 @@ {"value": "close_game", "label": "Close game (within 5)"}, {"value": "final", "label": "Game ends"}, {"value": "overtime", "label": "Overtime / extra time"}, - {"value": "playoff", "label": "Playoff / postseason game"} + {"value": "playoff", "label": "Playoff / postseason game"}, + {"value": "comeback", "label": "Comeback alert"} ] }, { @@ -29,6 +30,16 @@ "label": "Specific teams (empty = all followed)", "type": "text", "default": "" + }, + { + "key": "comeback_margin", + "label": "Was down by at least (points)", + "type": "number", + "default": "10", + "min": "3", + "max": "30", + "step": "1", + "visible_when": {"event": "comeback"} } ], "settings": [ diff --git a/apps/stocks/app.py b/apps/stocks/app.py index 2089835..fcac4b7 100644 --- a/apps/stocks/app.py +++ b/apps/stocks/app.py @@ -76,6 +76,31 @@ def trigger(settings, conditions): if not crossed and key in state['fired_targets']: state['fired_targets'].discard(key) # reset when price moves away + elif condition_type == '52w_extreme': + extreme = conditions.get('extreme', 'high') + try: + hist = yf.Ticker(sym).history(period='1y') + if hist.empty: + continue + week52_high = hist['High'].max() + week52_low = hist['Low'].min() + key_h = f"{sym}:52wh" + key_l = f"{sym}:52wl" + if extreme in ('high', 'either') and price >= week52_high * 0.995: + if key_h not in state['fired_targets']: + state['fired_targets'].add(key_h) + return True + else: + state['fired_targets'].discard(key_h) + if extreme in ('low', 'either') and price <= week52_low * 1.005: + if key_l not in state['fired_targets']: + state['fired_targets'].add(key_l) + return True + else: + state['fired_targets'].discard(key_l) + except Exception: + continue + except Exception: pass return False diff --git a/apps/stocks/manifest.json b/apps/stocks/manifest.json index 95aa6c7..0ebdeb4 100644 --- a/apps/stocks/manifest.json +++ b/apps/stocks/manifest.json @@ -17,12 +17,13 @@ "default": "pct_change", "options": [ {"value": "pct_change", "label": "% Move"}, - {"value": "price_target", "label": "Price target"} + {"value": "price_target", "label": "Price target"}, + {"value": "52w_extreme", "label": "52-week high/low"} ] }, { "key": "threshold", - "label": "Move more than (%) ", + "label": "Move more than (%)", "type": "number", "default": "3", "min": "1", @@ -51,6 +52,18 @@ "min": "0", "step": "1", "visible_when": {"condition_type": "price_target"} + }, + { + "key": "extreme", + "label": "Extreme", + "type": "toggle", + "default": "high", + "options": [ + {"value": "high", "label": "52-week high"}, + {"value": "low", "label": "52-week low"}, + {"value": "either", "label": "Either"} + ], + "visible_when": {"condition_type": "52w_extreme"} } ], "settings": [ diff --git a/apps/youtube/app.py b/apps/youtube/app.py index 29551d5..e6e7d4e 100644 --- a/apps/youtube/app.py +++ b/apps/youtube/app.py @@ -18,17 +18,19 @@ def fetch(settings, format_lines, get_rows, get_cols): def trigger(settings, conditions): - """Fire when a new video is posted to the followed channel.""" + """Fire when a new video is posted or a video crosses a view milestone.""" import requests import xml.etree.ElementTree as ET channel_id = settings.get('yt_channel_id', '') + api_key = settings.get('yt_api_key', '') + condition_type = conditions.get('condition_type', 'new_video') if not channel_id: return False state = getattr(trigger, '_state', None) if state is None: - state = {'last_video_id': None} + state = {'last_video_id': None, 'fired_milestones': set()} setattr(trigger, '_state', state) try: @@ -39,17 +41,36 @@ def trigger(settings, conditions): entries = root.findall('a:entry', ns) if not entries: return False - latest_id = entries[0].find('yt:videoId', ns) - if latest_id is None: + latest_id_el = entries[0].find('yt:videoId', ns) + if latest_id_el is None: return False - vid_id = latest_id.text - if state['last_video_id'] is None: - # First run — seed without firing - state['last_video_id'] = vid_id - return False - if vid_id != state['last_video_id']: - state['last_video_id'] = vid_id - return True + vid_id = latest_id_el.text + + if condition_type == 'new_video': + if state['last_video_id'] is None: + state['last_video_id'] = vid_id + return False + if vid_id != state['last_video_id']: + state['last_video_id'] = vid_id + return True + + elif condition_type == 'view_milestone' and api_key: + milestone = int(conditions.get('view_milestone', 1000000)) + # Check view count via YouTube Data API + vr = requests.get( + 'https://www.googleapis.com/youtube/v3/videos', + params={'part': 'statistics', 'id': vid_id, 'key': api_key}, + timeout=8 + ).json() + items = vr.get('items', []) + if not items: + return False + views = int(items[0].get('statistics', {}).get('viewCount', 0)) + key = f"{vid_id}:{milestone}" + if views >= milestone and key not in state['fired_milestones']: + state['fired_milestones'].add(key) + return True + except Exception: pass return False diff --git a/apps/youtube/manifest.json b/apps/youtube/manifest.json index 5f0c731..e236e70 100644 --- a/apps/youtube/manifest.json +++ b/apps/youtube/manifest.json @@ -10,6 +10,27 @@ "trigger_interval": 300, "trigger_display_seconds": 30, "trigger_cooldown": 3600, + "trigger_conditions": [ + { + "key": "condition_type", + "label": "Fire when", + "type": "toggle", + "default": "new_video", + "options": [ + {"value": "new_video", "label": "New video posted"}, + {"value": "view_milestone", "label": "View milestone hit"} + ] + }, + { + "key": "view_milestone", + "label": "View milestone", + "type": "number", + "default": "1000000", + "min": "1000", + "step": "1000", + "visible_when": {"condition_type": "view_milestone"} + } + ], "settings": [ { "key": "yt_channel_id", From cb8747fc37534da9a841b8a4dc5ab82cfb80f098 Mon Sep 17 00:00:00 2001 From: Chris Date: Fri, 15 May 2026 16:43:25 -0500 Subject: [PATCH 10/14] Expand ISS trigger: visible pass and crew change conditions (#38) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - overhead: existing behavior (within ~500km ground track) - visible_pass: overhead + nighttime (8pm–5am local) + cloud cover ≤30% Uses Open-Meteo for cloud cover (keyless) - crew_change: fires when ISS crew roster changes (arrival or departure) Seeds on first run to avoid false fire on install Co-Authored-By: Claude Sonnet 4.6 --- apps/iss/app.py | 91 ++++++++++++++++++++++++++++++------------ apps/iss/manifest.json | 13 ++++++ 2 files changed, 79 insertions(+), 25 deletions(-) diff --git a/apps/iss/app.py b/apps/iss/app.py index 17adad7..6447a7e 100644 --- a/apps/iss/app.py +++ b/apps/iss/app.py @@ -17,32 +17,73 @@ def fetch(settings, format_lines, get_rows, get_cols): def trigger(settings, conditions): - """Fire when the ISS is passing overhead (within ~500km).""" + """Fire when ISS is overhead, or on crew milestone.""" import requests, math + from datetime import datetime + import pytz + + condition_type = conditions.get('condition_type', 'overhead') + zip_code = settings.get('zip_code', '02118') + + state = getattr(trigger, '_state', None) + if state is None: + state = {'last_crew_count': None, 'last_crew_names': None} + setattr(trigger, '_state', state) try: - # Get user's approximate location from zip code via geocoding - zip_code = settings.get('zip_code', '02118') - geo = requests.get( - f'https://nominatim.openstreetmap.org/search?postalcode={zip_code}&country=US&format=json&limit=1', - timeout=5, headers={'User-Agent': 'SplitFlapOS/1.0'} - ).json() - if not geo: - return False - user_lat = float(geo[0]['lat']) - user_lon = float(geo[0]['lon']) - - pos = requests.get('http://api.open-notify.org/iss-now.json', timeout=5).json() - iss_lat = float(pos['iss_position']['latitude']) - iss_lon = float(pos['iss_position']['longitude']) - - # Haversine distance in km - R = 6371 - dlat = math.radians(iss_lat - user_lat) - dlon = math.radians(iss_lon - user_lon) - a = math.sin(dlat/2)**2 + math.cos(math.radians(user_lat)) * math.cos(math.radians(iss_lat)) * math.sin(dlon/2)**2 - dist = R * 2 * math.asin(math.sqrt(a)) - - return dist < 500 # within ~500km ground track + if condition_type in ('overhead', 'visible_pass'): + geo = requests.get( + f'https://nominatim.openstreetmap.org/search?postalcode={zip_code}&country=US&format=json&limit=1', + timeout=5, headers={'User-Agent': 'SplitFlapOS/1.0'} + ).json() + if not geo: + return False + user_lat = float(geo[0]['lat']) + user_lon = float(geo[0]['lon']) + + pos = requests.get('http://api.open-notify.org/iss-now.json', timeout=5).json() + iss_lat = float(pos['iss_position']['latitude']) + iss_lon = float(pos['iss_position']['longitude']) + + R = 6371 + dlat = math.radians(iss_lat - user_lat) + dlon = math.radians(iss_lon - user_lon) + a = math.sin(dlat/2)**2 + math.cos(math.radians(user_lat)) * math.cos(math.radians(iss_lat)) * math.sin(dlon/2)**2 + dist = R * 2 * math.asin(math.sqrt(a)) + + if dist >= 500: + return False + + if condition_type == 'visible_pass': + # Check nighttime and clear sky + tz = pytz.timezone(settings.get('timezone', 'US/Eastern')) + hour = datetime.now(tz).hour + is_night = hour >= 20 or hour <= 5 + + weather = requests.get( + f'https://api.open-meteo.com/v1/forecast?latitude={user_lat}&longitude={user_lon}' + '¤t=cloud_cover', + timeout=5 + ).json() + cloud_cover = weather.get('current', {}).get('cloud_cover', 100) + is_clear = cloud_cover <= 30 + + return is_night and is_clear + + return True # overhead, no visibility check + + elif condition_type == 'crew_change': + ppl = requests.get('http://api.open-notify.org/astros.json', timeout=5).json() + iss_crew = [p['name'] for p in ppl.get('people', []) if p.get('craft') == 'ISS'] + crew_set = frozenset(iss_crew) + + if state['last_crew_names'] is None: + state['last_crew_names'] = crew_set + return False + if crew_set != state['last_crew_names']: + state['last_crew_names'] = crew_set + return True + except Exception: - return False + pass + return False diff --git a/apps/iss/manifest.json b/apps/iss/manifest.json index c21276f..beca0ad 100644 --- a/apps/iss/manifest.json +++ b/apps/iss/manifest.json @@ -10,6 +10,19 @@ "trigger_interval": 60, "trigger_display_seconds": 30, "trigger_cooldown": 3600, + "trigger_conditions": [ + { + "key": "condition_type", + "label": "Fire when", + "type": "toggle", + "default": "overhead", + "options": [ + {"value": "overhead", "label": "ISS overhead"}, + {"value": "visible_pass", "label": "Visible pass (night + clear)"}, + {"value": "crew_change", "label": "Crew change"} + ] + } + ], "min_rows": 1, "min_cols": 8 } From e5fa175118062143028d3c88d1fa4b58792c8f0a Mon Sep 17 00:00:00 2001 From: Chris Date: Fri, 15 May 2026 16:49:22 -0500 Subject: [PATCH 11/14] Add final trigger conditions: rival, shutout, market hours, altitude, first_today (#38) Sports: rival matchup (fire when followed team plays a configured rival) and shutout in progress (one team has 0 after scoring begins). Stocks: market open/close condition (9:30am and 4:00pm ET on weekdays, no API needed). Condition type toggle now has 4 options. Planes Overhead: altitude threshold filter (fire only for aircraft above a configurable altitude in feet). OpenSky poll now captures altitude_m from state vector. BirdNET: first detection of the day (fires once per day on the first qualifying detection regardless of species). Co-Authored-By: Claude Sonnet 4.6 --- apps/birdnet/app.py | 6 ++++++ apps/birdnet/manifest.json | 3 ++- apps/planes_overhead/app.py | 11 ++++++++++- apps/planes_overhead/manifest.json | 13 ++++++++++++- apps/sports/app.py | 20 +++++++++++++++++--- apps/sports/manifest.json | 11 ++++++++++- apps/stocks/app.py | 21 +++++++++++++++++++++ apps/stocks/manifest.json | 14 +++++++++++++- 8 files changed, 91 insertions(+), 8 deletions(-) diff --git a/apps/birdnet/app.py b/apps/birdnet/app.py index a11c4a0..c094d08 100644 --- a/apps/birdnet/app.py +++ b/apps/birdnet/app.py @@ -147,6 +147,12 @@ def trigger(settings, conditions): if species not in state['seen_today']: state['seen_today'].add(species) return True + if filt == 'first_today': + import time as _time + today = int(_time.time() // 86400) + if state.get('last_fired_day') != today: + state['last_fired_day'] = today + return True if filt == 'watchlist': return bool(watchlist) and any(w in species.lower() for w in watchlist) if filt == 'high_confidence': diff --git a/apps/birdnet/manifest.json b/apps/birdnet/manifest.json index 131fc6f..66bae22 100644 --- a/apps/birdnet/manifest.json +++ b/apps/birdnet/manifest.json @@ -19,10 +19,11 @@ "options": [ {"value": "any", "label": "Any detection"}, {"value": "new_today", "label": "New species today"}, + {"value": "first_today", "label": "First detection of the day"}, {"value": "specific", "label": "Specific species"}, {"value": "watchlist", "label": "Watchlist species"}, {"value": "high_confidence", "label": "High confidence"}, - {"value": "busy_feeder", "label": "Busy feeder"} + {"value": "busy_feeder", "label": "Busy feeder"} ] }, { diff --git a/apps/planes_overhead/app.py b/apps/planes_overhead/app.py index 95cbb82..d73af73 100644 --- a/apps/planes_overhead/app.py +++ b/apps/planes_overhead/app.py @@ -640,10 +640,12 @@ def trigger(settings, conditions): params={'lamin': lat-d, 'lomin': lon-d, 'lamax': lat+d, 'lomax': lon+d}, timeout=8 ).json() - flights = [{'callsign': (s[1] or '').strip().upper()} for s in (r.get('states') or [])] + flights = [{'callsign': (s[1] or '').strip().upper(), + 'altitude_m': s[7]} for s in (r.get('states') or [])] except Exception: return False + alt_threshold_ft = float(conditions.get('altitude_ft', 0)) new_found = False for f in flights: cs = f.get('callsign', '') @@ -653,6 +655,13 @@ def trigger(settings, conditions): continue if filter_type == 'military' and not any(cs.startswith(p) for p in MILITARY_PREFIXES): continue + if filter_type == 'altitude' and alt_threshold_ft > 0: + alt_m = f.get('altitude_m') + if alt_m is None: + continue + alt_ft = float(alt_m) * 3.28084 + if alt_ft < alt_threshold_ft: + continue if cs not in state['seen_callsigns']: state['seen_callsigns'].add(cs) new_found = True diff --git a/apps/planes_overhead/manifest.json b/apps/planes_overhead/manifest.json index 6add529..ffe3ed5 100644 --- a/apps/planes_overhead/manifest.json +++ b/apps/planes_overhead/manifest.json @@ -336,7 +336,8 @@ "options": [ {"value": "any", "label": "Any new aircraft"}, {"value": "keyword", "label": "Callsign keyword"}, - {"value": "military", "label": "Military aircraft"} + {"value": "military", "label": "Military aircraft"}, + {"value": "altitude", "label": "Above altitude"} ] }, { @@ -345,6 +346,16 @@ "type": "text", "default": "", "visible_when": {"filter": "keyword"} + }, + { + "key": "altitude_ft", + "label": "Minimum altitude (feet)", + "type": "number", + "default": "30000", + "min": "1000", + "max": "60000", + "step": "1000", + "visible_when": {"filter": "altitude"} } ] } \ No newline at end of file diff --git a/apps/sports/app.py b/apps/sports/app.py index 1729747..3ffa9e3 100644 --- a/apps/sports/app.py +++ b/apps/sports/app.py @@ -352,22 +352,36 @@ def trigger(settings, conditions): curr = (a_score, h_score) state_obj['last_scores'][score_key] = curr if prev: - # Check if a team was down by margin and is now within 3 prev_diff = prev[0] - prev[1] curr_diff = curr[0] - curr[1] - # Away was down big, now close if prev_diff <= -margin and abs(curr_diff) <= 3: comeback_key = f"comeback_a_{game_id}" if comeback_key not in state_obj['seen_game_ids']: state_obj['seen_game_ids'].add(comeback_key) return True - # Home was down big, now close if prev_diff >= margin and abs(curr_diff) <= 3: comeback_key = f"comeback_h_{game_id}" if comeback_key not in state_obj['seen_game_ids']: state_obj['seen_game_ids'].add(comeback_key) return True + elif event_type == 'rival': + rivals_str = conditions.get('rivals', '').strip() + rivals = {r.strip().upper() for r in rivals_str.split(',') if r.strip()} + if rivals and state == 'in' and game_id not in state_obj['seen_game_ids']: + if (aa in trigger_teams and ha in rivals) or (ha in trigger_teams and aa in rivals): + state_obj['seen_game_ids'].add(game_id) + return True + + elif event_type == 'shutout': + if state == 'in': + # Fire when one team has 0 and the other has scored + if (a_score == 0 and h_score > 0) or (h_score == 0 and a_score > 0): + shutout_key = f"shutout_{game_id}" + if shutout_key not in state_obj['seen_game_ids']: + state_obj['seen_game_ids'].add(shutout_key) + return True + except Exception: pass return False diff --git a/apps/sports/manifest.json b/apps/sports/manifest.json index 2d6f34e..7d04395 100644 --- a/apps/sports/manifest.json +++ b/apps/sports/manifest.json @@ -22,7 +22,9 @@ {"value": "final", "label": "Game ends"}, {"value": "overtime", "label": "Overtime / extra time"}, {"value": "playoff", "label": "Playoff / postseason game"}, - {"value": "comeback", "label": "Comeback alert"} + {"value": "comeback", "label": "Comeback alert"}, + {"value": "rival", "label": "Rival matchup"}, + {"value": "shutout", "label": "Shutout in progress"} ] }, { @@ -40,6 +42,13 @@ "max": "30", "step": "1", "visible_when": {"event": "comeback"} + }, + { + "key": "rivals", + "label": "Rival team abbreviations (comma-separated)", + "type": "text", + "default": "", + "visible_when": {"event": "rival"} } ], "settings": [ diff --git a/apps/stocks/app.py b/apps/stocks/app.py index fcac4b7..b81d3a5 100644 --- a/apps/stocks/app.py +++ b/apps/stocks/app.py @@ -101,6 +101,27 @@ def trigger(settings, conditions): except Exception: continue + elif condition_type == 'market_hours': + from datetime import datetime + import pytz + event = conditions.get('market_event', 'open') + et = pytz.timezone('US/Eastern') + now = datetime.now(et) + # Skip weekends + if now.weekday() >= 5: + return False + hour, minute = now.hour, now.minute + key = f"market:{event}:{now.strftime('%Y-%m-%d')}" + if event == 'open' and hour == 9 and 30 <= minute < 35: + if key not in state['fired_targets']: + state['fired_targets'].add(key) + return True + elif event == 'close' and hour == 16 and minute < 5: + if key not in state['fired_targets']: + state['fired_targets'].add(key) + return True + return False + except Exception: pass return False diff --git a/apps/stocks/manifest.json b/apps/stocks/manifest.json index 0ebdeb4..a5d6214 100644 --- a/apps/stocks/manifest.json +++ b/apps/stocks/manifest.json @@ -18,7 +18,8 @@ "options": [ {"value": "pct_change", "label": "% Move"}, {"value": "price_target", "label": "Price target"}, - {"value": "52w_extreme", "label": "52-week high/low"} + {"value": "52w_extreme", "label": "52-week high/low"}, + {"value": "market_hours", "label": "Market open/close"} ] }, { @@ -64,6 +65,17 @@ {"value": "either", "label": "Either"} ], "visible_when": {"condition_type": "52w_extreme"} + }, + { + "key": "market_event", + "label": "Event", + "type": "toggle", + "default": "open", + "options": [ + {"value": "open", "label": "Market opens (9:30am ET)"}, + {"value": "close", "label": "Market closes (4:00pm ET)"} + ], + "visible_when": {"condition_type": "market_hours"} } ], "settings": [ From 723e929443bc8717b3b10b5ccfa750133209b55f Mon Sep 17 00:00:00 2001 From: Chris Date: Fri, 15 May 2026 16:57:32 -0500 Subject: [PATCH 12/14] Fix trigger modal: app list empty from bell icon, save doesn't close - openAddTrigger() now lazily fetches installed_apps if _triggerApps is empty (happens when called from app card before Triggers page loads) - _saveTriggerModal() now receives button reference and uses btn.closest('.modal-overlay') instead of querySelector which could grab a stale/wrong overlay Co-Authored-By: Claude Sonnet 4.6 --- server/static/app.js | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/server/static/app.js b/server/static/app.js index 5fdc991..8091bf5 100644 --- a/server/static/app.js +++ b/server/static/app.js @@ -3158,7 +3158,15 @@ function _deleteTrigger(idx){ } function openAddTrigger(preselectedApp){ - _openTriggerModal(null, preselectedApp); + // Ensure trigger apps are loaded before opening modal (may be called from app card) + if(!_triggerApps.length){ + fetch('/installed_apps').then(r=>r.json()).then(data=>{ + _triggerApps = (data.apps||[]).filter(a=>a.has_trigger); + _openTriggerModal(null, preselectedApp); + }); + } else { + _openTriggerModal(null, preselectedApp); + } } function openEditTrigger(idx){ @@ -3207,7 +3215,7 @@ function _openTriggerModal(idx, preselectedApp){
- +
`; @@ -3241,8 +3249,8 @@ function _loadTriggerConditions(existingConditions){ }).join(''); } -function _saveTriggerModal(idx, id){ - const modal = document.querySelector('.modal-overlay'); +function _saveTriggerModal(btn, idx, id){ + const modal = btn.closest('.modal-overlay'); const name = document.getElementById('trigModalName').value.trim(); const app = document.getElementById('trigModalApp').value; const display_seconds = parseInt(document.getElementById('trigModalDisplay').value)||30; From 0bc9f9c78bd1ea25341dcf7675ef17009453fe68 Mon Sep 17 00:00:00 2001 From: Chris Date: Fri, 15 May 2026 16:59:54 -0500 Subject: [PATCH 13/14] Fix trigger condition toggles overflowing modal width - flex-wrap:wrap so buttons flow to next row when there are many options - min-width:0 and white-space:normal so buttons shrink and wrap text - Slightly smaller font (.75rem) for dense option sets like BirdNET Co-Authored-By: Claude Sonnet 4.6 --- server/static/app.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/server/static/app.js b/server/static/app.js index 8091bf5..9c1c53b 100644 --- a/server/static/app.js +++ b/server/static/app.js @@ -3239,10 +3239,10 @@ function _loadTriggerConditions(existingConditions){ const v = typeof o==='string'?o:o.value; const l = typeof o==='string'?o:o.label; return ``; }).join(''); - return `
${opts}
`; + return `
${opts}
`; } return `
`; From a98d30986a35fb521929472d7902a8b1923074e1 Mon Sep 17 00:00:00 2001 From: Chris Date: Fri, 15 May 2026 17:02:12 -0500 Subject: [PATCH 14/14] Make trigger modal width dynamic to fit condition toggles Modal now uses width:max-content with min 320px and max 600px (or 90vw), so it expands to fit wide toggle sets like BirdNET without overflow. Co-Authored-By: Claude Sonnet 4.6 --- server/static/app.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/static/app.js b/server/static/app.js index 9c1c53b..1544772 100644 --- a/server/static/app.js +++ b/server/static/app.js @@ -3191,7 +3191,7 @@ function _openTriggerModal(idx, preselectedApp){ }).join(''); modal.innerHTML=` -