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
94 changes: 94 additions & 0 deletions APPS_README.md
Original file line number Diff line number Diff line change
Expand Up @@ -204,6 +204,100 @@ Use these keys to synchronize related fields:
}
```

## Optional: Localization & Shared Helpers

`fetch()` can opt into runtime-injected helpers by **declaring extra parameters**.
The runtime inspects your signature and only passes a helper if you name it, so the
classic four-argument `fetch(settings, format_lines, get_rows, get_cols)` keeps
working unchanged — every helper below is purely opt-in and backward compatible.

### Localization (`i18n`)

If your app shows words (day/month names, status labels), add an `i18n=None`
parameter. The runtime binds it to the global **Language** setting; on a host
without it (or with `babel` not installed) `i18n` is `None` and you fall back to
English.

```python
def fetch(settings, format_lines, get_rows, get_cols, i18n=None):
from datetime import datetime
now = datetime.now()
weekday = i18n.weekday(now) if i18n else now.strftime("%A").upper()
label = i18n.t("SUNRISE") if i18n else "SUNRISE"
return [format_lines(weekday, label)]
```

- `i18n.weekday(dt, short=False)` / `i18n.month(dt, short=False)` — CLDR-correct,
UPPERCASE day/month names for every language.
- `i18n.date(dt, short=False, year=False)` — day + month (and optional year) in the
locale's **own order and wording**: `JULY 9` in English but `9 JUILLET` (fr),
`9. JULI` (de). Don't hand-assemble `month + " " + day` — order is language-specific.
- `i18n.time(dt, seconds=False)` — wall-clock time: `3:48 PM` in English, `15:48`
elsewhere. `i18n.is_24h` exposes the same decision if you branch yourself.
- `i18n.unit("D")` — localized compact duration suffix for `D`/`H`/`M`/`S`, so `175D`
becomes `175J` in French (jour), `175T` in German (Tag).
- `i18n.number(value, decimals=2, grouping=True)` — a number with the locale's own
separators: `1,234.50` (en) vs `1.234,50` (de) vs `1 234,50` (fr). Use it for any
price/rate/percent — never hardcode `f"{v:,.2f}"`.
- `i18n.base_currency()` / `i18n.country()` — the currency / country a language-region
implies (a sensible default; prefer `get_location` below for geography).
- `i18n.t("ENGLISH LABEL")` — a translated UI word; with no translation it returns the
English key, so nothing ever breaks. All data lives in `server/i18n_data.json` (the
language list, translations, and per-language default currency/country). Add keys or
languages there rather than in your app, keep them short (modules are narrow), and
give each key a generic `context` note so translators know what the word means. A
regional variant like `pt-BR` automatically inherits every `pt` translation.

Once your app adapts to the language, add `"i18n": true` to its `manifest.json`. When
internationalization is enabled the Apps grid shows a 🌐 badge on those cards, and the
app automatically gets a per-app **Language** override in its settings (blank = follow
the global Language). Only Western-European (Windows-1252) languages are offered — the
modules can't display Greek, Cyrillic, CJK, etc.

Internationalization is **off by default**: unless the user turns on *Enable
internationalization* in Settings, `i18n` is not injected and your app runs its
English `i18n=None` fallback — so always keep that fallback correct. The toggle exists
because accented glyphs only render on modules whose firmware/character map supports
them.

### Shared current weather (`get_weather`)

If your app shows the weather, don't hardcode a provider — add a `get_weather=None`
parameter and call it:

```python
def fetch(settings, format_lines, get_rows, get_cols, get_weather=None):
if get_weather is None: # host without the helper
return [format_lines("NO WEATHER")]
w = get_weather() # uses the *global* provider + key + location
if not w["ok"]:
return [format_lines("WEATHER", "UNAVAILABLE")]
return [format_lines(w["city"], f'{w["temp_f"]}F {w["desc"]}')]
```

`get_weather()` returns a dict: `ok`, `city`, `temp_f`, `feels_like_f`, `hi_f`,
`lo_f`, `desc`, `humidity`, `wind_mph`, `cloud_cover`, `provider`, `lat`, `lon`
(temps in °F; `ok` is `False` with an `error` key on failure). The default provider
is keyless Open-Meteo, so weather works with **no API key**.

### Location → country / currency (`get_location`)

Anything tied to *geography* — which currency, which country's holidays — should key
off the configured **Location**, not the language. Declare a `get_location`
parameter for the shared resolver:

```python
def fetch(settings, format_lines, get_rows, get_cols, get_location=None):
loc = get_location() if get_location else {}
country = loc.get("country") # ISO 3166-1 alpha-2, e.g. "CA"
subdivision = loc.get("subdivision") # ISO 3166-2, e.g. "CA-QC"; may be None
currency = loc.get("currency") # ISO 4217, e.g. "CAD" (None if unknown)
```

It reverse-geocodes the global Location once (cached) and is keyless. Declaring
`get_location` also gives the app an automatic per-app **Location** override in its
settings. Helpers compose: `def fetch(..., get_weather=None, get_location=None, i18n=None)`.

## Final Checklist

- Confirm your app directory contains `manifest.json` and `app.py`.
Expand Down
105 changes: 105 additions & 0 deletions apps/advice/app.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
"""A random piece of advice (keyless: Advice Slip)."""


def _need_lines(lens, cols):
need, cur = 1, 0
for wl in lens:
add = wl if cur == 0 else cur + 1 + wl
if add <= cols:
cur = add
else:
need += 1
cur = wl
return need


def _balance(words, lens, cols, k):
"""Split words into exactly k lines (each <= cols) minimizing raggedness, so
lines fill evenly instead of orphaning the last word; prefers ending a line at
sentence punctuation. A tiny DP."""
n = len(words)
pre = [0]
for wl in lens:
pre.append(pre[-1] + wl)

def linelen(i, j):
return pre[j + 1] - pre[i] + (j - i)

INF = float("inf")
dp = [[INF] * (n + 1) for _ in range(k + 1)]
nxt = [[0] * (n + 1) for _ in range(k + 1)]
dp[0][n] = 0.0
for kk in range(1, k + 1):
for i in range(n - 1, -1, -1):
j = i
while j < n:
ll = linelen(i, j)
if ll > cols and j > i:
break
rest = dp[kk - 1][j + 1]
if rest < INF:
slack = cols - ll
cost = slack * slack + rest
if words[j][-1:] in ".!?":
cost -= cols
if cost < dp[kk][i]:
dp[kk][i] = cost
nxt[kk][i] = j + 1
j += 1
if dp[k][0] >= INF:
return None
out, i, kk = [], 0, k
while kk > 0:
j = nxt[kk][i]
out.append(" ".join(words[i:j]))
i, kk = j, kk - 1
return out


def _greedy(words, cols):
lines, cur = [], ''
for w in words:
w = w if len(w) <= cols else w[:cols]
if len(cur) + len(w) + (1 if cur else 0) <= cols:
cur = f'{cur} {w}'.strip()
else:
lines.append(cur)
cur = w
if cur:
lines.append(cur)
return lines or ['']


def _pages(format_lines, title, text, rows, cols):
"""Lay the text out under a title. When it fits on one page the words are
balanced evenly across the lines it needs; longer text word-wraps and
paginates."""
words = text.split() or ['']
lens = [len(w) for w in words]
if rows == 1:
return [ln.center(cols)[:cols] for ln in _greedy(words, cols)]
body = rows - 1 if title else rows
if max(lens) <= cols:
need = _need_lines(lens, cols)
if need <= body:
bal = _balance(words, lens, cols, need)
if bal is not None:
return [format_lines(title, *bal)] if title else [format_lines(*bal)]
lines = _greedy(words, cols)
pages, i = ([format_lines(title, *lines[:body])], body) if title else ([], 0)
while i < len(lines):
pages.append(format_lines(*lines[i:i + rows]))
i += rows
return pages

def fetch(settings, format_lines, get_rows, get_cols):
import requests
rows, cols = get_rows(), get_cols()
try:
d = requests.get('https://api.adviceslip.com/advice', timeout=8).json()
text = str((d.get('slip') or {}).get('advice', '') or '').strip().upper()
if not text:
return [format_lines('ADVICE', 'NO DATA', '')]
return _pages(format_lines, '', f'ADVICE: {text}', rows, cols)
except Exception:
return [format_lines('ADVICE', 'OFFLINE', '')]
34 changes: 34 additions & 0 deletions apps/advice/manifest.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
{
"id": "advice",
"name": "Advice",
"icon": "🧭",
"description": "A random piece of advice",
"type": "functional",
"refresh_interval": 1800,
"loop_delay": 7,
"category": "entertainment",
"min_rows": 2,
"min_cols": 15,
"settings": [
{
"key": "refresh_minutes",
"label": "Fetch new advice every (minutes)",
"type": "number",
"default": "30",
"min": "1",
"max": "1440",
"step": "1",
"stepper": true
},
{
"key": "loop_delay",
"label": "Delay Between Pages (seconds)",
"type": "number",
"default": "7",
"min": "2",
"max": "30",
"step": "1",
"stepper": true
}
]
}
49 changes: 49 additions & 0 deletions apps/aurora/app.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
"""Aurora / geomagnetic activity — planetary K-index (NOAA SWPC, keyless)."""


def _label(kp):
if kp < 3:
return 'QUIET'
if kp < 5:
return 'UNSETTLED'
if kp < 6:
return 'MINOR STORM'
if kp < 7:
return 'MODERATE'
if kp < 8:
return 'STRONG STORM'
if kp < 9:
return 'SEVERE STORM'
return 'EXTREME'


def fetch(settings, format_lines, get_rows, get_cols, i18n=None):
import requests
rows = get_rows()

def t(s):
return i18n.t(s, "aurora") if i18n is not None else s

def num(kp): # integer Kp shows no decimal
whole = (kp == int(kp))
if i18n is not None:
return i18n.number(kp, decimals=0 if whole else 1, grouping=False)
return f'{kp:.0f}' if whole else f'{kp:.1f}'

try:
data = requests.get('https://services.swpc.noaa.gov/products/noaa-planetary-k-index.json',
timeout=8).json()
if not isinstance(data, list) or not data:
return [format_lines(t('AURORA'), t('NO DATA'), '')]
latest = data[-1]
# The feed is a list of {time_tag, Kp, ...} records (newest last).
kp = float(latest.get('Kp') if isinstance(latest, dict) else latest[1])
kps = num(kp)
label = t(_label(kp))
if rows == 1:
return [format_lines(f'{t("AURORA")} KP {kps}')]
if rows == 2:
return [format_lines(f'{t("AURORA")} KP {kps}', label)]
return [format_lines(t('AURORA'), f'KP INDEX {kps}', label)]
except Exception:
return [format_lines(t('AURORA'), t('OFFLINE'), '')]
25 changes: 25 additions & 0 deletions apps/aurora/manifest.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
{
"id": "aurora",
"name": "Aurora Watch",
"icon": "🌌",
"description": "Geomagnetic activity (Kp index)",
"type": "functional",
"i18n": true,
"refresh_interval": 900,
"loop_delay": 6,
"category": "data",
"min_rows": 1,
"min_cols": 12,
"settings": [
{
"key": "loop_delay",
"label": "Delay Between Pages (seconds)",
"type": "number",
"default": "6",
"min": "2",
"max": "30",
"step": "1",
"stepper": true
}
]
}
11 changes: 8 additions & 3 deletions apps/bitcoin-fear-greed/app.py
Original file line number Diff line number Diff line change
@@ -1,19 +1,24 @@
"""Bitcoin Fear & Greed Index plugin for Split-Flap Display."""

def fetch(settings, format_lines, get_rows, get_cols):
def fetch(settings, format_lines, get_rows, get_cols, i18n=None):
import urllib.request
import json

def t(s):
return i18n.t(s, "sentiment") if i18n is not None else s

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())
entry = data["data"][0]
value = entry["value"]
label = entry["value_classification"].upper()
# "Extreme Fear" / "Fear" / "Neutral" / "Greed" / "Extreme Greed" -> localized.
label = t(entry["value_classification"].upper())
return [format_lines("BTC FEAR&GREED", f"INDEX: {value}/100", label)]
except Exception:
return [format_lines("FEAR & GREED", "FETCH ERROR", "")]
return [format_lines("BTC FEAR&GREED", t("OFFLINE"), "")]


def trigger(settings, conditions):
Expand Down
1 change: 1 addition & 0 deletions apps/bitcoin-fear-greed/manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
"description": "Bitcoin Fear and Greed Index",
"category": "finance",
"type": "functional",
"i18n": true,
"version": "1.0",
"refresh_interval": 300,
"loop_delay": 10,
Expand Down
Loading