Release Vulpes OS 2.7-preview.22
This commit is contained in:
parent
200d1fb235
commit
a138f97877
294 changed files with 515786 additions and 187 deletions
223
tundra/devices/beatles/cover-display.py
Normal file
223
tundra/devices/beatles/cover-display.py
Normal file
|
|
@ -0,0 +1,223 @@
|
|||
#!/usr/bin/env python3
|
||||
# SPDX-License-Identifier: MPL-2.0
|
||||
"""Small, independent Fira clock for the Nokia's RGB565 SPI cover panel."""
|
||||
import ctypes as C
|
||||
import bisect
|
||||
import datetime
|
||||
import fcntl
|
||||
import json
|
||||
import mmap
|
||||
import re
|
||||
from pathlib import Path
|
||||
import signal
|
||||
import struct
|
||||
import time
|
||||
|
||||
|
||||
def read_json(path):
|
||||
try:
|
||||
value=json.loads(Path(path).read_text())
|
||||
return value if isinstance(value,dict) else {}
|
||||
except (OSError,ValueError):return {}
|
||||
|
||||
|
||||
SETTINGS=Path('/work/gaia-profile/vulpes-settings-v1.json')
|
||||
|
||||
|
||||
class Animation:
|
||||
"""Opt-in private test; decode happens on the PC, not in the phone loop."""
|
||||
FRAME_BYTES=240*240*2
|
||||
def __init__(self,path):
|
||||
self.file=Path(path).open('rb');self.data=None
|
||||
try:
|
||||
size=self.file.seek(0,2)
|
||||
if not 12<=size<=12+240*(4+self.FRAME_BYTES):raise ValueError('Animation size')
|
||||
self.data=mmap.mmap(self.file.fileno(),0,access=mmap.ACCESS_READ)
|
||||
magic,count=struct.unpack_from('<8sI',self.data)
|
||||
if magic!=b'VULPANM1' or not 1<=count<=240 or size!=12+count*(4+self.FRAME_BYTES):
|
||||
raise ValueError('Animation header')
|
||||
self.ends=[];self.offsets=[];total=0
|
||||
for index in range(count):
|
||||
offset=12+index*(4+self.FRAME_BYTES)
|
||||
duration,=struct.unpack_from('<I',self.data,offset)
|
||||
if not 50<=duration<=1000:raise ValueError('Animation timing')
|
||||
total+=duration;self.ends.append(total);self.offsets.append(offset+4)
|
||||
except Exception:
|
||||
self.close();raise
|
||||
|
||||
def frame(self,elapsed,stride):
|
||||
phase=int(elapsed*1000)%self.ends[-1]
|
||||
index=bisect.bisect_right(self.ends,phase);offset=self.offsets[index]
|
||||
raw=self.data[offset:offset+self.FRAME_BYTES]
|
||||
if stride!=480:
|
||||
raw=b''.join(raw[y*480:(y+1)*480]+bytes(stride-480) for y in range(240))
|
||||
return raw,(self.ends[index]-phase)/1000,index
|
||||
|
||||
def close(self):
|
||||
if self.data is not None:self.data.close()
|
||||
self.file.close()
|
||||
|
||||
|
||||
def preferences(values):
|
||||
title=values.get('vulpes.cover.title','VULPES')
|
||||
title=''.join(c for c in title if c.isprintable()).strip()[:24] if isinstance(title,str) else ''
|
||||
def color(key):
|
||||
value=values.get(key,'#33a6ff')
|
||||
if not isinstance(value,str) or not re.fullmatch(r'#[0-9a-fA-F]{6}',value):value='#33a6ff'
|
||||
return tuple(int(value[i:i+2],16)/255 for i in (1,3,5))
|
||||
return title or 'VULPES',color('vulpes.cover.titleColor'),color('vulpes.cover.batteryColor')
|
||||
|
||||
|
||||
def labels(state,now):
|
||||
if abs(now-state.get('recordedAt',0))>20:return ('—','—')
|
||||
battery=state.get('battery') or {}
|
||||
level=battery.get('level')
|
||||
percent=str(round(level*100))+' %' if isinstance(level,(int,float)) and 0<=level<=1 else '—'
|
||||
radios=state.get('radio') or []
|
||||
network=next((r.get('operator',{}).get('longName','') for r in radios if r.get('available')), '')
|
||||
return percent,network[:24] or '—'
|
||||
|
||||
|
||||
def present(panel, variable):
|
||||
# The display driver may reject a pan during a panel power transition.
|
||||
# Record the failure and retry later; keep Gaia and the keypad running.
|
||||
try:
|
||||
fcntl.ioctl(panel,0x4606,variable,True)
|
||||
return {'ok':True}
|
||||
except OSError as error:
|
||||
return {'ok':False,'errno':error.errno}
|
||||
|
||||
|
||||
class Drawing:
|
||||
def __init__(self,width,height,stride,font_path='/opt/vulpes/gaia/shared/elements/vulpes-fonts/FiraSans-Regular.otf'):
|
||||
self.c=C.CDLL('libcairo.so.2');self.ft=C.CDLL('libfreetype.so.6')
|
||||
p=C.c_void_p; i=C.c_int; d=C.c_double
|
||||
signatures={
|
||||
'cairo_image_surface_create_for_data':(p,[p,i,i,i,i]),
|
||||
'cairo_create':(p,[p]), 'cairo_destroy':(None,[p]),
|
||||
'cairo_surface_destroy':(None,[p]), 'cairo_surface_flush':(None,[p]),
|
||||
'cairo_surface_write_to_png':(i,[p,C.c_char_p]),
|
||||
'cairo_set_source_rgb':(None,[p,d,d,d]), 'cairo_paint':(None,[p]),
|
||||
'cairo_rectangle':(None,[p,d,d,d,d]), 'cairo_fill':(None,[p]),
|
||||
'cairo_move_to':(None,[p,d,d]), 'cairo_set_font_size':(None,[p,d]),
|
||||
'cairo_show_text':(None,[p,C.c_char_p]), 'cairo_text_extents':(None,[p,C.c_char_p,p]),
|
||||
'cairo_ft_font_face_create_for_ft_face':(p,[p,i]),
|
||||
'cairo_set_font_face':(None,[p,p]),'cairo_font_face_destroy':(None,[p]),
|
||||
'cairo_status':(i,[p]),
|
||||
}
|
||||
for name,(result,args) in signatures.items():
|
||||
fn=getattr(self.c,name);fn.restype=result;fn.argtypes=args
|
||||
self.ft.FT_Init_FreeType.argtypes=[C.POINTER(p)]
|
||||
self.ft.FT_New_Face.argtypes=[p,C.c_char_p,C.c_long,C.POINTER(p)]
|
||||
self.ft.FT_Done_Face.argtypes=[p];self.ft.FT_Done_FreeType.argtypes=[p]
|
||||
self.library=p();self.face=p()
|
||||
if self.ft.FT_Init_FreeType(C.byref(self.library)):raise RuntimeError('FreeType initialization')
|
||||
font=str(font_path).encode()
|
||||
if self.ft.FT_New_Face(self.library,font,0,C.byref(self.face)):raise RuntimeError('Gaia Fira font unavailable')
|
||||
self.buffer=C.create_string_buffer(stride*height)
|
||||
self.surface=self.c.cairo_image_surface_create_for_data(self.buffer,4,width,height,stride)
|
||||
self.context=self.c.cairo_create(self.surface)
|
||||
self.font=self.c.cairo_ft_font_face_create_for_ft_face(self.face,0)
|
||||
self.c.cairo_set_font_face(self.context,self.font)
|
||||
if self.c.cairo_status(self.context):raise RuntimeError('Cairo RGB565 context unavailable')
|
||||
|
||||
def text(self,text,size,y,color=(.92,.96,1)):
|
||||
c=self.c;ctx=self.context;encoded=text.encode()
|
||||
c.cairo_set_font_size(ctx,size)
|
||||
extents=(C.c_double*6)();c.cairo_text_extents(ctx,encoded,extents)
|
||||
# Fit custom titles without cropping wide letters or accented text.
|
||||
if extents[2]>216:
|
||||
c.cairo_set_font_size(ctx,size*216/extents[2])
|
||||
c.cairo_text_extents(ctx,encoded,extents)
|
||||
c.cairo_set_source_rgb(ctx,*color)
|
||||
c.cairo_move_to(ctx,120-extents[2]/2-extents[0],y)
|
||||
c.cairo_show_text(ctx,encoded)
|
||||
|
||||
def render(self,state):
|
||||
c=self.c;ctx=self.context;now=datetime.datetime.now()
|
||||
c.cairo_set_source_rgb(ctx,.015,.025,.045);c.cairo_paint(ctx)
|
||||
title,title_color,battery_color=preferences(read_json(SETTINGS))
|
||||
self.text(title,14,30,title_color)
|
||||
self.text(now.strftime('%H:%M'),56,110)
|
||||
self.text(now.strftime('%d / %m / %Y'),16,143,(.6,.7,.8))
|
||||
battery,network=labels(state,time.time())
|
||||
self.text(network,15,185)
|
||||
self.text(battery,17,218,battery_color)
|
||||
c.cairo_surface_flush(self.surface)
|
||||
if c.cairo_status(ctx):raise RuntimeError('Cover rendering failed')
|
||||
return self.buffer.raw
|
||||
|
||||
def close(self):
|
||||
self.c.cairo_destroy(self.context);self.c.cairo_surface_destroy(self.surface)
|
||||
self.c.cairo_font_face_destroy(self.font);self.ft.FT_Done_Face(self.face)
|
||||
self.ft.FT_Done_FreeType(self.library)
|
||||
|
||||
|
||||
def main():
|
||||
if C.sizeof(C.c_void_p)!=4:raise RuntimeError('Nokia ARM32 framebuffer layout required')
|
||||
running=True
|
||||
def stop(*_):
|
||||
nonlocal running
|
||||
running=False
|
||||
signal.signal(signal.SIGTERM,stop);signal.signal(signal.SIGINT,stop)
|
||||
with open('/dev/fb1','r+b',buffering=0) as panel:
|
||||
variable=bytearray(160);fixed=bytearray(68)
|
||||
fcntl.ioctl(panel,0x4600,variable,True);fcntl.ioctl(panel,0x4602,fixed,True)
|
||||
v=struct.unpack('=40I',variable);stride=struct.unpack_from('=I',fixed,44)[0]
|
||||
length=struct.unpack_from('=I',fixed,20)[0]
|
||||
if (v[0:2]!=(240,240) or v[6]!=16 or v[8:10]!=(11,5) or
|
||||
v[11:13]!=(5,6) or v[14:16]!=(0,5) or v[4]!=0):
|
||||
raise RuntimeError('Unsupported external panel format: '+repr(v[:17]))
|
||||
offset=v[5]*stride;size=240*stride
|
||||
if stride<480 or offset+size>length:raise RuntimeError('Framebuffer bounds invalid')
|
||||
with mmap.mmap(panel.fileno(),length) as pixels:
|
||||
original=pixels[offset:offset+size];drawing=Drawing(240,240,stride)
|
||||
animation=None
|
||||
try:animation=Animation('/work/cover-animation.rgb565')
|
||||
except (OSError,ValueError) as error:
|
||||
if not isinstance(error,FileNotFoundError):print('Cover animation unavailable:',type(error).__name__,flush=True)
|
||||
try:
|
||||
pixels[offset:offset+size]=drawing.render(read_json('/run/tundra-hardware/status.json'))
|
||||
drawing.c.cairo_surface_write_to_png(drawing.surface,b'/work/cover-preview.png')
|
||||
Path('/work/cover-ready').write_text('ready\n')
|
||||
print('Cover ready: 240x240 RGB565, Gaia Fira Sans',flush=True)
|
||||
last=0;was_on=False;last_error=None;animation_start=None;submitted=0;failed=0;pan_seconds=0
|
||||
while running:
|
||||
on=read_json('/work/lid-state.json').get('externalOn') is True
|
||||
now=time.monotonic()
|
||||
if on and not was_on and animation:
|
||||
animation_start=now;submitted=0;failed=0;pan_seconds=0
|
||||
if on and animation_start is not None:
|
||||
elapsed=now-animation_start
|
||||
if elapsed<8:
|
||||
frame,delay,_=animation.frame(elapsed,stride)
|
||||
pixels[offset:offset+size]=frame
|
||||
before=time.monotonic();result=present(panel,variable)
|
||||
pan_seconds+=time.monotonic()-before
|
||||
if result['ok']:submitted+=1
|
||||
else:failed+=1
|
||||
was_on=on;last=0
|
||||
time.sleep(max(.005,delay-(time.monotonic()-now)))
|
||||
continue
|
||||
Path('/work/cover-animation-report.json').write_text(json.dumps({
|
||||
'seconds':elapsed,'submissions':submitted,'failed':failed,
|
||||
'submissionsPerSecond':submitted/elapsed,
|
||||
'meanPanMs':1000*pan_seconds/max(1,submitted+failed)}))
|
||||
animation_start=None;last=0
|
||||
if not on:animation_start=None
|
||||
if on and (not was_on or time.monotonic()-last>=5):
|
||||
pixels[offset:offset+size]=drawing.render(read_json('/run/tundra-hardware/status.json'))
|
||||
result=present(panel,variable)
|
||||
Path('/work/cover-refresh.json').write_text(json.dumps({**result,'recordedAt':time.time()}))
|
||||
if not result['ok'] and result['errno']!=last_error:
|
||||
print('Cover refresh deferred, errno:',result['errno'],flush=True)
|
||||
if result['ok'] and last_error is not None:print('Cover refresh recovered',flush=True)
|
||||
last_error=result.get('errno')
|
||||
last=time.monotonic()
|
||||
was_on=on;time.sleep(.2)
|
||||
finally:
|
||||
Path('/work/cover-ready').unlink(missing_ok=True)
|
||||
pixels[offset:offset+size]=original;drawing.close()
|
||||
if animation:animation.close()
|
||||
|
||||
if __name__=='__main__':main()
|
||||
Loading…
Reference in a new issue