COLORCAST 0.1: tap-to-cast colors to phone companion over BLE

This commit is contained in:
2026-09-09 17:49:19 +01:00
commit 9abcf4ca0f
15 changed files with 11064 additions and 0 deletions
+11
View File
@@ -0,0 +1,11 @@
import zipfile, io, json
from PIL import Image
z = zipfile.ZipFile('/home/iliasmohuchine/vela-salah/dist/com.ilias.salah.release.0.1.rpk')
names = z.namelist()
print('entries:', names)
if 'common/icon.png' in names:
im = Image.open(io.BytesIO(z.read('common/icon.png')))
print('icon in rpk:', im.size, im.mode)
mf = json.loads(z.read('manifest.json'))
print('manifest:', mf['package'], mf['versionName'], mf['versionCode'])
+56
View File
@@ -0,0 +1,56 @@
// ColorCast logic tests — pure module (no Vela APIs)
'use strict'
const assert = require('assert/strict')
const cast = require('/tmp/vela-colorcast/src/common/colorcast.js')
let passed = 0
function test(name, fn) {
try { fn(); passed++; console.log(' ok -', name) }
catch (e) { console.error(' FAIL -', name, '\n ', e.message); process.exitCode = 1 }
}
console.log('colorcast tests')
test('nextColor cycles RED -> GREEN', () => {
const n = cast.nextColor(-1) // initial state: nothing cast yet
assert.equal(n.name, 'RED')
assert.equal(cast.nextColor(n.index).name, 'GREEN')
})
test('full cycle RED, GREEN, BLUE, YELLOW, wraps to RED', () => {
const names = []
let idx = -1
for (let i = 0; i < 5; i++) {
const n = cast.nextColor(idx)
idx = n.index
names.push(n.name)
}
assert.deepEqual(names, ['RED', 'GREEN', 'BLUE', 'YELLOW', 'RED'])
})
test('payload is 3 bytes RGB matching the color', () => {
const blue = cast.COLORS[2]
const p = cast.payload(blue)
assert.equal(p.byteLength, 3)
const v = new Uint8Array(p)
assert.deepEqual([...v], [blue.r, blue.g, blue.b])
})
test('UUIDs match the Android companion', () => {
assert.equal(cast.SERVICE_UUID, '0000fee7-0000-1000-8000-00805f9b34fb')
assert.equal(cast.CHAR_UUID, '0000fee8-0000-1000-8000-00805f9b34fb')
})
test('advertised name matches Android NAME constant', () => {
assert.equal(cast.ADVERTISED_NAME, 'ILIAS-COLOR')
})
test('every color has r/g/b in range', () => {
for (const c of cast.COLORS) {
for (const k of ['r', 'g', 'b']) {
assert.ok(c[k] >= 0 && c[k] <= 255, c.name + '.' + k)
}
}
})
console.log(process.exitCode ? 'FAILED' : 'ALL PASSED (' + passed + ')')
+65
View File
@@ -0,0 +1,65 @@
#!/usr/bin/env python3
"""Convert the user-supplied mosque icon art to a 192x192 RGB Vela app icon.
Proven pipeline (from the sundial v0.5 icon): detect the round disc bounds on the
mid row/column of the dark-square source, crop square around disc center, apply an
anti-aliased circular alpha mask (drawn at 4x, LANCZOS down), resize to 192x192,
flatten to RGB over the dark background color.
"""
from PIL import Image, ImageDraw
SRC = "/home/iliasmohuchine/Downloads/ChatGPT Image Sep 9, 2026, 04_42_51 PM.png"
OUT = "/home/iliasmohuchine/vela-salah/src/common/icon.png"
im = Image.open(SRC).convert("RGB")
w, h = im.size
print("source:", im.size)
# detect disc bounds on mid row / mid column (background ~ rgb(17,17,24) dark)
px = im.load()
midy, midx = h // 2, w // 2
BG = px[2, 2]
print("bg color:", BG)
def is_fg(p):
return abs(p[0] - BG[0]) + abs(p[1] - BG[1]) + abs(p[2] - BG[2]) > 40
row = [x for x in range(w) if is_fg(px[x, midy])]
col = [y for y in range(h) if is_fg(px[midx, y])]
left, right = min(row), max(row)
top, bottom = min(col), max(col)
print("disc bounds: x", left, right, " y", top, bottom)
# square crop around disc center, sized to the larger span
cx, cy = (left + right) / 2, (top + bottom) / 2
span = max(right - left, bottom - top)
half = span / 2 + 2
box = (int(cx - half), int(cy - half), int(cx + half), int(cy + half))
disc = im.crop(box)
print("crop box:", box, "disc size:", disc.size)
# circular alpha mask at 4x
size = disc.size[0]
mask = Image.new("L", (size * 4, size * 4), 0)
d = ImageDraw.Draw(mask)
d.ellipse((0, 0, size * 4 - 1, size * 4 - 1), fill=255)
mask = mask.resize((size, size), Image.LANCZOS)
out = disc.convert("RGBA")
out.putalpha(mask)
out = out.resize((192, 192), Image.LANCZOS)
# flatten to RGB over dark bg
bg = Image.new("RGB", (192, 192), BG)
bg.paste(out, (0, 0), out)
bg.save(OUT, "PNG")
print("saved", OUT, bg.size, bg.mode)
# side-by-side montage for preview: source | converted
src_small = im.copy()
src_small.thumbnail((192, 192))
mont = Image.new("RGB", (192 * 2 + 24, 192), (17, 17, 24))
mont.paste(src_small, (0, 0))
mont.paste(bg, (192 + 24, 0))
mont.save("/home/iliasmohuchine/vela-salah/icon-preview.png")
print("montage saved")
+205
View File
@@ -0,0 +1,205 @@
// Page wiring tests — extract the <script> from index.ux, run it in a VM, drive update().
'use strict'
const assert = require('assert/strict')
const fs = require('fs')
const vm = require('vm')
const src = fs.readFileSync(__dirname + '/../src/pages/index/index.ux', 'utf8')
const m = src.match(/<script>([\s\S]*?)<\/script>/)
assert.ok(m, 'index.ux has a <script> block')
let body = m[1]
// strip the import (prayer module provided via sandbox), replace `export default` with a spec capture
body = body.replace(/import\s+\w+\s+from\s+'[^']*';?/, '')
body = body.replace(/export\s+default/, 'globalThis.spec =')
// strip comments FIRST so trailing // comments don't pollute const spans
body = body.replace(/\/\/[^\n]*/g, '')
// strip module-level const declarations and re-inject them as sandbox globals
// (skill pitfall: a top-level const breaks the `(body)` wrap).
// replace() can only remove the MATCH span, so rebuild the string manually:
// skip [matchStart, exprEnd) for each `const NAME = <literal>` at line start.
const constDecls = {}
{
const re = /^const\s+(\w+)\s*=\s*/gm
const matches = [...body.matchAll(re)]
let out = ''
let pos = 0
for (const mt of matches) {
const start = mt.index
const name = mt[1]
// scan forward from the match end, balancing (), [], {} and strings
let i = start + mt[0].length
const opens = { '(': ')', '[': ']', '{': '}' }
const stack = []
let inStr = null
while (i < body.length) {
const c = body[i]
if (inStr) {
if (c === '\\') { i += 2; continue }
if (c === inStr) inStr = null
} else if (c === "'" || c === '"' || c === '`') {
inStr = c
} else if (opens[c]) {
stack.push(opens[c])
} else if (stack.length && c === stack[stack.length - 1]) {
stack.pop()
if (!stack.length) { i++; break }
} else if (!stack.length && (c === ';' || c === '\n')) {
break
}
i++
}
const expr = body.slice(start + mt[0].length, i).replace(/[;\n]+$/, '').trim()
try {
constDecls[name] = vm.runInNewContext('(' + expr + ')', {})
} catch (e) {
constDecls[name] = undefined
}
// also skip a trailing comment if the literal ended mid-line before one
let end = i
const semi = body.indexOf('//', i)
const nl = body.indexOf('\n', i)
if (semi !== -1 && nl !== -1 && semi < nl) end = nl
else if (end < body.length && body[end] !== '\n') end = nl === -1 ? body.length : nl
out += body.slice(pos, start)
pos = end
}
body = out + body.slice(pos)
}
const sandbox = {
require,
console,
prayer: require('../src/common/prayer.js'),
globalThis: {},
setInterval: () => 0,
clearInterval: () => {},
setTimeout: () => 0,
clearTimeout: () => {},
...constDecls
}
sandbox.globalThis = sandbox
vm.createContext(sandbox)
vm.runInContext('(' + body + ')', sandbox)
const spec = sandbox.spec
assert.ok(spec && spec.private, 'spec captured with private state')
// page() helper: clone spec + private state into a fresh object, wire method contexts
function page() {
const p = {}
Object.assign(p, JSON.parse(JSON.stringify(spec.private)))
p.onInit = spec.onInit
p.onShow = spec.onShow
p.onHide = spec.onHide
p.onDestroy = spec.onDestroy
p.update = spec.update
p.rows = []
return p
}
let passed = 0
function test(name, fn) {
try { fn(); passed++; console.log(' ok -', name) }
catch (e) { console.error(' FAIL -', name, '\n ', e.message); process.exitCode = 1 }
}
// Label map: labels must exist for every prayer key (sandbox global, captured at load)
const LABELS = sandbox.LABELS || spec.LABELS
console.log('page.cjs tests')
test('private state has all bound fields (clock, countdown, nextKey, nextName, rows)', () => {
for (const f of ['clock', 'countdown', 'nextKey', 'nextName', 'rows']) {
assert.ok(f in spec.private, f + ' in private')
}
})
test('LABELS covers every prayer key + no extras', () => {
assert.deepEqual(Object.keys(LABELS), ['fajr', 'sunrise', 'dhuhr', 'asr', 'maghrib', 'isha'])
})
test('template binds: every {{ }} expression field is in private or LABELS-adjacent fields', () => {
const tpl = fs.readFileSync(__dirname + '/../src/pages/index/index.ux', 'utf8').match(/<template>([\s\S]*?)<\/template>/)[1]
const binds = [...tpl.matchAll(/\{\{\s*([\w.]+)\s*\}\}/g)].map((x) => x[1])
for (const b of binds) {
const root = b.split('.')[0]
assert.ok(root in spec.private || ['isDay', 'row'].includes(root), 'template bind {{ ' + b + ' }} has a private root (' + root + ')')
}
})
test('update() populates clock as HH:MM', () => {
const p = page()
p.update()
assert.match(p.clock, /^\d{2}:\d{2}$/)
})
test('update() rows: six entries, key+label+time wired', () => {
const p = page()
p.update()
assert.equal(p.rows.length, 6)
const t = require('../src/common/prayer.js').times(new Date(), 33.5731, -7.5898, 1)
for (const row of p.rows) {
assert.ok(row.key in LABELS, 'row key ' + row.key)
assert.equal(row.label, LABELS[row.key])
assert.match(row.time, /^\d{2}:\d{2}$/)
assert.equal(typeof row.passed, 'boolean')
assert.ok(row.rowStyle && /^\d+px$/.test(row.rowStyle.width), 'rowStyle.width set (' + JSON.stringify(row.rowStyle) + ')')
}
})
test('row widths taper with the chord and fit the 466 circle at every row position', () => {
// rows: 6 x 40px + 5 x 4px margins, block starts after header+banner (top=130)
const ROW_WIDTHS = spec.ROW_WIDTHS || sandbox.ROW_WIDTHS
assert.equal(ROW_WIDTHS.length, 6)
const H = 40, MARGIN = 4, BLOCK_TOP = 130, R = 233, INSET = 10
for (let n = 0; n < 6; n++) {
const top = BLOCK_TOP + n * (H + MARGIN)
const dy = Math.max(Math.abs(233 - top), Math.abs(233 - (top + H)))
const chord = 2 * Math.sqrt(Math.max(0, R * R - dy * dy))
const safe = chord - 2 * INSET
assert.ok(ROW_WIDTHS[n] <= safe, `row ${n} width ${ROW_WIDTHS[n]} exceeds safe chord ${safe.toFixed(0)} at top ${top}`)
}
})
test('bound rowStyle width equals ROW_WIDTHS[i]', () => {
const ROW_WIDTHS = spec.ROW_WIDTHS || sandbox.ROW_WIDTHS
const p = page()
p.update()
p.rows.forEach((row, i) => {
assert.equal(row.rowStyle.width, ROW_WIDTHS[i] + 'px')
})
})
test('update() nextKey/nextName agree with nextPrayer', () => {
const p = page()
p.update()
const now = new Date()
const t = require('../src/common/prayer.js').times(now, 33.5731, -7.5898, 1)
const next = require('../src/common/prayer.js').nextPrayer(t, now.getHours() * 60 + now.getMinutes())
assert.equal(p.nextKey, next.key)
assert.equal(p.nextName, LABELS[next.key])
})
test('countdown is minutes/hours format or +1d wrap', () => {
const p = page()
p.update()
assert.match(p.countdown, /^(\+1d )?(\d+m|\d+h \d+m)$/)
})
test('rowNext highlight is set on exactly one row (the next prayer)', () => {
const p = page()
p.update()
const nextRows = p.rows.filter((r) => r.key === p.nextKey)
assert.equal(nextRows.length, 1, 'exactly one row matches nextKey')
})
test('update is idempotent-ish: two calls keep rows length 6 and consistent next', () => {
const p = page()
p.update()
p.update()
assert.equal(p.rows.length, 6)
assert.ok(p.nextKey in LABELS)
})
console.log(process.exitCode ? 'FAILED' : 'ALL PASSED (' + passed + ')')
+111
View File
@@ -0,0 +1,111 @@
// Prayer time math tests — reference: AlAdhan API method 21 (Morocco, Fajr 19, Isha 17, school STANDARD)
// Casablanca (33.5731, -7.5898) 2026-09-09 reference times:
// Fajr 05:40, Sunrise 07:10, Dhuhr 13:33, Asr 17:02, Maghrib 19:50, Isha 21:05
'use strict'
const assert = require('assert/strict')
const prayer = require('../src/common/prayer.js')
// Casablanca is UTC+1 in September 2026; emulate the watch by passing tz offset explicitly.
const TZ = 1
const CASA = { lat: 33.5731, lon: -7.5898 }
function timesFor(y, m, d) {
return prayer.times(new Date(Date.UTC(y, m - 1, d, 12, 0, 0)), CASA.lat, CASA.lon, TZ)
}
function assertClose(actualMin, refHM, label, tol = 2) {
const [rh, rm] = refHM.split(':').map(Number)
const ref = rh * 60 + rm
const diff = Math.abs(actualMin - ref)
assert.ok(diff <= tol, `${label}: got ${prayer.format(actualMin)} (${actualMin}), expected ${refHM} — off by ${diff.toFixed(1)} min`)
}
let passed = 0
function test(name, fn) {
try { fn(); passed++; console.log(' ok -', name) }
catch (e) { console.error(' FAIL -', name, '\n ', e.message); process.exitCode = 1 }
}
console.log('prayer.js tests')
test('times match AlAdhan Morocco method for Casablanca 2026-09-09', () => {
const t = timesFor(2026, 9, 9)
assertClose(t.fajr, '05:40', 'Fajr')
assertClose(t.sunrise, '07:10', 'Sunrise')
assertClose(t.dhuhr, '13:33', 'Dhuhr')
assertClose(t.asr, '17:02', 'Asr')
assertClose(t.maghrib, '19:50', 'Maghrib')
assertClose(t.isha, '21:05', 'Isha')
})
test('times match for a midwinter date 2026-01-15 (AlAdhan cross-check)', () => {
// AlAdhan method 21, Casablanca, 15-01-2026: Fajr 07:02, Sunrise 08:35, Dhuhr 13:45, Asr 16:26, Maghrib 18:50, Isha 20:08
const t = timesFor(2026, 1, 15)
assertClose(t.fajr, '07:02', 'Fajr')
assertClose(t.sunrise, '08:35', 'Sunrise')
assertClose(t.dhuhr, '13:45', 'Dhuhr')
assertClose(t.asr, '16:26', 'Asr')
assertClose(t.maghrib, '18:50', 'Maghrib')
assertClose(t.isha, '20:08', 'Isha')
})
test('times match for a summer date 2026-07-01 (AlAdhan cross-check)', () => {
// AlAdhan method 21, Casablanca, 01-07-2026: Fajr 04:34, Sunrise 06:24, Dhuhr 13:39, Asr 17:19, Maghrib 20:50, Isha 22:21
const t = timesFor(2026, 7, 1)
assertClose(t.fajr, '04:34', 'Fajr')
assertClose(t.sunrise, '06:24', 'Sunrise')
assertClose(t.dhuhr, '13:39', 'Dhuhr')
assertClose(t.asr, '17:19', 'Asr')
assertClose(t.maghrib, '20:50', 'Maghrib')
assertClose(t.isha, '22:21', 'Isha')
})
test('format pads minutes', () => {
assert.equal(prayer.format(9 * 60 + 5), '09:05')
assert.equal(prayer.format(13 * 60 + 33), '13:33')
})
test('format wraps past midnight (next-day fajr)', () => {
// 25h15 -> 01:15 next day
assert.equal(prayer.format(25 * 60 + 15), '01:15')
})
test('nextPrayer: before fajr -> next is fajr', () => {
const t = timesFor(2026, 9, 9)
const n = prayer.nextPrayer(t, 4 * 60)
assert.equal(n.key, 'fajr')
assert.ok(n.minutesLeft > 0 && n.minutesLeft < 1440, 'minutesLeft ' + n.minutesLeft)
// 04:00 -> fajr 05:40 ≈ 100 min
assert.ok(Math.abs(n.minutesLeft - 100) <= 2, 'minutesLeft ' + n.minutesLeft)
})
test('nextPrayer: between dhuhr and asr -> next is asr', () => {
const t = timesFor(2026, 9, 9)
const n = prayer.nextPrayer(t, 14 * 60)
assert.equal(n.key, 'asr')
assert.ok(n.current === 'dhuhr')
})
test('nextPrayer: after isha -> wraps to fajr (+1d) and minutesLeft crosses midnight', () => {
const t = timesFor(2026, 9, 9)
const n = prayer.nextPrayer(t, 23 * 60)
assert.equal(n.key, 'fajr')
assert.ok(n.nextDay === true, 'nextDay flag')
// 23:00 -> fajr 05:40 next day = 6h40 = 400 min
assert.ok(Math.abs(n.minutesLeft - 400) <= 2, 'minutesLeft ' + n.minutesLeft)
})
test('nextPrayer: exactly at dhuhr time -> next is asr (current dhuhr)', () => {
const t = timesFor(2026, 9, 9)
const n = prayer.nextPrayer(t, Math.round(t.dhuhr))
assert.equal(n.key, 'asr')
})
test('minutesLeft is exact', () => {
const t = timesFor(2026, 9, 9)
const n = prayer.nextPrayer(t, 17 * 60)
// 17:00 -> asr 17:02 = 2 min
assert.ok(Math.abs(n.minutesLeft - 2) <= 1, 'minutesLeft ' + n.minutesLeft)
})
console.log(process.exitCode ? 'FAILED' : 'ALL PASSED (' + passed + ')')