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`: diff --git a/apps/birdnet/app.py b/apps/birdnet/app.py index 2fcb8b3..c094d08 100644 --- a/apps/birdnet/app.py +++ b/apps/birdnet/app.py @@ -106,3 +106,70 @@ 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() + 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: + 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', '') + confidence = latest.get('confidence', 0) + + 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 + 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': + 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 230afe5..66bae22 100644 --- a/apps/birdnet/manifest.json +++ b/apps/birdnet/manifest.json @@ -7,6 +7,70 @@ "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": "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"} + ] + }, + { + "key": "species", + "label": "Species name (partial match)", + "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"} + }, + { + "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": [ { "key": "birdnet_host", 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/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..e27d7ca 100644 --- a/apps/crypto/app.py +++ b/apps/crypto/app.py @@ -33,3 +33,60 @@ 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 threshold or hits a price target.""" + import requests + + 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: + 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 cc8554a..392e3ba 100644 --- a/apps/crypto/manifest.json +++ b/apps/crypto/manifest.json @@ -6,6 +6,53 @@ "type": "functional", "refresh_interval": 60, "loop_delay": 5, + "trigger_interval": 120, + "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": "Move more than (%)", + "type": "number", + "default": "5", + "min": "1", + "max": "100", + "step": "1", + "visible_when": {"condition_type": "pct_change"} + }, + { + "key": "direction", + "label": "Direction", + "type": "toggle", + "default": "either", + "options": [ + {"value": "either", "label": "Either"}, + {"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": [ { "key": "crypto_list", @@ -29,4 +76,4 @@ "category": "finance", "min_rows": 1, "min_cols": 8 -} \ No newline at end of file +} diff --git a/apps/iss/app.py b/apps/iss/app.py index bf3297e..6447a7e 100644 --- a/apps/iss/app.py +++ b/apps/iss/app.py @@ -14,3 +14,76 @@ 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 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: + 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: + pass + return False diff --git a/apps/iss/manifest.json b/apps/iss/manifest.json index de55840..beca0ad 100644 --- a/apps/iss/manifest.json +++ b/apps/iss/manifest.json @@ -7,6 +7,22 @@ "refresh_interval": 5, "loop_delay": 5, "category": "data", + "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 -} \ No newline at end of file +} diff --git a/apps/metro/app.py b/apps/metro/app.py index 6413678..8620794 100644 --- a/apps/metro/app.py +++ b/apps/metro/app.py @@ -29,3 +29,64 @@ 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, 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: + 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 8f9ae02..ccb3188 100644 --- a/apps/metro/manifest.json +++ b/apps/metro/manifest.json @@ -7,6 +7,43 @@ "refresh_interval": 30, "loop_delay": 5, "category": "data", + "trigger_interval": 60, + "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": "Arriving within (minutes)", + "type": "number", + "default": "5", + "min": "1", + "max": "15", + "step": "1", + "visible_when": {"condition_type": "arriving"} + }, + { + "key": "direction", + "label": "Direction", + "type": "toggle", + "default": "either", + "options": [ + {"value": "either", "label": "Either"}, + {"value": "0", "label": "Direction 0"}, + {"value": "1", "label": "Direction 1"} + ], + "visible_when": {"condition_type": "arriving"} + } + ], "settings": [ { "key": "mbta_stop", @@ -35,4 +72,4 @@ ], "min_rows": 1, "min_cols": 10 -} \ 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/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..d73af73 100644 --- a/apps/planes_overhead/app.py +++ b/apps/planes_overhead/app.py @@ -602,4 +602,72 @@ 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 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()} + 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(), + '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', '') + if not cs: + 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 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 + + # 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..ffe3ed5 100644 --- a/apps/planes_overhead/manifest.json +++ b/apps/planes_overhead/manifest.json @@ -323,5 +323,39 @@ } ], "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"}, + {"value": "military", "label": "Military aircraft"}, + {"value": "altitude", "label": "Above altitude"} + ] + }, + { + "key": "keyword", + "label": "Callsign contains (e.g. UAL, N12)", + "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 4eef8f1..3ffa9e3 100644 --- a/apps/sports/app.py +++ b/apps/sports/app.py @@ -241,3 +241,147 @@ 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 + + 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 + + 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: + prev_diff = prev[0] - prev[1] + curr_diff = curr[0] - curr[1] + 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 + 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 91feb17..7d04395 100644 --- a/apps/sports/manifest.json +++ b/apps/sports/manifest.json @@ -6,6 +6,51 @@ "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"}, + {"value": "overtime", "label": "Overtime / extra time"}, + {"value": "playoff", "label": "Playoff / postseason game"}, + {"value": "comeback", "label": "Comeback alert"}, + {"value": "rival", "label": "Rival matchup"}, + {"value": "shutout", "label": "Shutout in progress"} + ] + }, + { + "key": "teams", + "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"} + }, + { + "key": "rivals", + "label": "Rival team abbreviations (comma-separated)", + "type": "text", + "default": "", + "visible_when": {"event": "rival"} + } + ], "settings": [ { "key": "loop_delay", @@ -21,4 +66,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..b81d3a5 100644 --- a/apps/stocks/app.py +++ b/apps/stocks/app.py @@ -26,3 +26,102 @@ 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 or hits a price target.""" + import yfinance as yf + + 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 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 + + 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 + + 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 953ab6c..a5d6214 100644 --- a/apps/stocks/manifest.json +++ b/apps/stocks/manifest.json @@ -6,6 +6,78 @@ "type": "functional", "refresh_interval": 60, "loop_delay": 5, + "trigger_interval": 120, + "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"}, + {"value": "52w_extreme", "label": "52-week high/low"}, + {"value": "market_hours", "label": "Market open/close"} + ] + }, + { + "key": "threshold", + "label": "Move more than (%)", + "type": "number", + "default": "3", + "min": "1", + "max": "50", + "step": "1", + "visible_when": {"condition_type": "pct_change"} + }, + { + "key": "direction", + "label": "Direction", + "type": "toggle", + "default": "either", + "options": [ + {"value": "either", "label": "Either"}, + {"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"} + }, + { + "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"} + }, + { + "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": [ { "key": "stocks_list", @@ -29,4 +101,4 @@ "category": "finance", "min_rows": 1, "min_cols": 8 -} \ No newline at end of file +} 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 +} diff --git a/apps/weather/app.py b/apps/weather/app.py index d39db60..388c152 100644 --- a/apps/weather/app.py +++ b/apps/weather/app.py @@ -594,3 +594,77 @@ 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, 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_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: + 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,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 = int(current.get('weather_code') or 0) + uv = current.get('uv_index') + wind = current.get('wind_speed_10m') + + if condition == 'severe': + 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 ec1eba1..b00db62 100644 --- a/apps/weather/manifest.json +++ b/apps/weather/manifest.json @@ -218,5 +218,55 @@ } ], "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"}, + {"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"} + ] + }, + { + "key": "temp_threshold", + "label": "Temperature threshold (°F)", + "type": "number", + "default": "90", + "min": "-40", + "max": "130", + "step": "1", + "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 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..e6e7d4e 100644 --- a/apps/youtube/app.py +++ b/apps/youtube/app.py @@ -15,3 +15,62 @@ 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 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, 'fired_milestones': set()} + 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_el = entries[0].find('yt:videoId', ns) + if latest_id_el is None: + return False + 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 40104a9..e236e70 100644 --- a/apps/youtube/manifest.json +++ b/apps/youtube/manifest.json @@ -7,6 +7,30 @@ "refresh_interval": 30, "loop_delay": 5, "category": "data", + "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", @@ -18,4 +42,4 @@ ], "min_rows": 3, "min_cols": 10 -} \ No newline at end of file +} 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 +} 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..1544772 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); div.innerHTML = ` - ${hasCfg && compatible ? `` : ''} + ${hasCfg && compatible ? `` : ''} ${removable ? `` : ''} + ${hasTrigger && compatible ? `` : ''} ${a.name} ${compatible ? a.desc : incompatibleReason}`; @@ -3083,6 +3088,187 @@ 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='
Apps watch for events and interrupt the display when something worth showing happens.
+