Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
174 changes: 174 additions & 0 deletions APPS_README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`:
Expand Down
67 changes: 67 additions & 0 deletions apps/birdnet/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
64 changes: 64 additions & 0 deletions apps/birdnet/manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
37 changes: 37 additions & 0 deletions apps/bitcoin-fear-greed/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
25 changes: 25 additions & 0 deletions apps/bitcoin-fear-greed/manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": []
}
Loading