@@ -1926,6 +1926,8 @@
}
await cache . loading ;
const list = el . querySelector ( ".cal" ) ;
// the phone's small versions show the next event from the cache: draw them again once it's there
if ( el . isConnected && ! list && cache . drawnAt !== cache . at ) { cache . drawnAt = cache . at ; ctx . rerender ( ) ; }
if ( ! el . isConnected || ! list ) return ;
if ( cache . error ) { list . innerHTML = ` <div class="empty"> ${ t ( "Couldn’ t load events. {0}" , esc ( cache . error ) ) } </div> ` ; return ; }
@@ -2213,6 +2215,350 @@
} ,
} ;
// ================================================================ phone versions
// Phone dashboards give every card four sizes, like widgets on a phone: Small (half width, square),
// Medium and Large (full width, two and four rows high) and Row (full width, one line).
// Medium and Large draw the card itself (tuned for phones in style.css). Small and Row draw the card's
// summary: an icon, a name, the one figure that matters and at most one control. A card can ask for its
// own drawing at a size instead ("card"), or leave a size out when it has no sensible form there.
//
// A summary is { ico, name, value, unit, word, sub, tone, on, tint, meter, ctl, tap }:
// value + unit a figure, drawn large (word: the same, but words, such as "Open" or "Cleaning…")
// tone "ok" | "warn" | "crit" | "accent" colours the figure; on lights the card like a lit tile
// meter 0– 100, a thin bar under the figure (brightness, battery, a gauge)
// ctl HTML for one control (a switch, a round button, two at most)
// tap { act, e, v } for a tap anywhere else on the card
const ico = ( name ) => Icons . svg ( name ) ;
const numOf = ( s , decimals ) => ( s && s . state !== "" && isFinite ( Number ( s . state ) ) ? fmtNum ( s . state , decimals ) : null ) ;
const sw = ( eid , on , label ) => ` <button class="light-switch wbtn" role="switch" aria-checked=" ${ ! ! on } " data-act="toggle" data-e=" ${ esc ( eid ) } " aria-label=" ${ esc ( label ) } "><span class="switch"></span></button> ` ;
const rnd = ( act , eid , icn , label , v , extra = "" ) => ` <button class="phc-btn wbtn" data-act=" ${ act } " ${ eid ? ` data-e=" ${ esc ( eid ) } " ` : "" } ${ v != null ? ` data-v=" ${ esc ( v ) } " ` : "" } ${ extra } aria-label=" ${ esc ( label ) } " title=" ${ esc ( label ) } "> ${ Icons . svg ( icn ) } </button> ` ;
const more = ( eid ) => ( eid ? { act : "more" , e : eid } : null ) ;
const SUMMARY = {
entity ( c , { ha } ) {
const s = ha . states [ c . entity ] ;
if ( ! s ) return null ;
const d = domainOf ( c . entity ) , tap = c . tap === "auto" || ! c . tap ? ( TOGGLE _DOMAINS . has ( d ) ? "toggle" : "more-info" ) : c . tap ;
const n = numOf ( s ) ;
return { ico : icon ( s , c . icon ) , name : friendly ( s , c . name ) , value : n , unit : n != null ? s . attributes . unit _of _measurement || "" : "" ,
word : n == null ? fmtState ( s ) : "" , sub : c . showChanged ? relTime ( s . last _changed ) : "" ,
on : isOn ( s ) , tint : domainOf ( c . entity ) === "light" ? lightTint ( s ) : "" ,
tap : tap === "none" ? null : { act : tap === "toggle" ? "toggle" : "more" , e : c . entity } } ;
} ,
value ( c , { ha } ) {
const s = ha . states [ c . entity ] ;
if ( ! s ) return null ;
const v = Number ( s . state ) , lvl = isFinite ( v ) && has ( c . crit ) && v > Number ( c . crit ) ? "crit" : isFinite ( v ) && has ( c . warn ) && v > Number ( c . warn ) ? "warn" : "" ;
const n = numOf ( s , c . decimals ) ;
return { ico : icon ( s , c . icon ) , name : friendly ( s , c . name ) , value : n , word : n == null ? cap ( s . state ) : "" , unit : c . unit || s . attributes . unit _of _measurement || "" ,
tone : lvl , sub : lvl ? ( lvl === "crit" ? t ( "Critical" ) : t ( "High" ) ) : "" , tap : more ( c . entity ) } ;
} ,
gauge ( c , { ha } ) {
const s = ha . states [ c . entity ] ;
if ( ! s ) return null ;
const v = Number ( s . state ) , min = Number ( c . min ) || 0 , max = Number ( c . max ) || 100 ;
const tone = ! isFinite ( v ) ? "" : has ( c . red ) && v >= Number ( c . red ) ? "crit" : has ( c . yellow ) && v >= Number ( c . yellow ) ? "warn" : has ( c . green ) && v >= Number ( c . green ) ? "ok" : "" ;
return { ico : icon ( s ) , name : friendly ( s , c . name ) , value : numOf ( s ) ? ? "– " , unit : s . attributes . unit _of _measurement || "" , tone ,
meter : isFinite ( v ) ? Math . max ( 0 , Math . min ( 100 , ( ( v - min ) / ( max - min || 1 ) ) * 100 ) ) : null , tap : more ( c . entity ) } ;
} ,
climate ( c , { ha } ) {
const s = ha . states [ c . entity ] ;
if ( ! s ) return null ;
const a = s . attributes , step = Number ( c . step ) || 0.5 ;
return { ico : icon ( s ) , name : friendly ( s , c . name ) , value : a . temperature != null ? fmtNum ( a . temperature ) : "– " , unit : "°" ,
sub : [ a . current _temperature != null && t ( "Now {0}°" , fmtNum ( a . current _temperature ) ) , st ( s . state ) ] . filter ( Boolean ) . join ( " · " ) ,
on : s . state !== "off" , tap : more ( c . entity ) ,
ctl : rnd ( "temp" , c . entity , "minus" , t ( "Lower to {0}°" , ( a . temperature ? ? 0 ) - step ) , - step ) + rnd ( "temp" , c . entity , "plus" , t ( "Raise to {0}°" , ( a . temperature ? ? 0 ) + step ) , step ) } ;
} ,
graph ( c , { ha } ) {
const s = ha . states [ ( c . entities || [ ] ) [ 0 ] ] ;
if ( ! s ) return null ;
const n = numOf ( s ) ;
return { ico : ico ( "chart" ) , name : c . title || friendly ( s ) , value : n , word : n == null ? fmtState ( s ) : "" , unit : n != null ? s . attributes . unit _of _measurement || "" : "" ,
sub : ( c . entities || [ ] ) . length > 1 ? ( c . entities . slice ( 1 ) . map ( ( e ) => ha . states [ e ] && fmtState ( ha . states [ e ] ) ) . filter ( Boolean ) . join ( " · " ) ) : "" , tap : more ( c . entities [ 0 ] ) } ;
} ,
clock ( c , { ha } ) {
const s = c . weather && ha . states [ c . weather ] ;
return { ico : ico ( "clock" ) , name : s ? ` ${ st ( s . state ) } · ${ s . attributes . temperature ? ? "– " } ° ` : "" ,
value : ` <span class="phc-clock" data-clock data-h24=" ${ c . h24 ? 1 : 0 } " data-sec="0" data-date=" ${ c . date ? 1 : 0 } "> ${ clockHTML ( c . h24 , false , c . date ) } </span> ` ,
tap : more ( c . weather ) } ;
} ,
weather ( c , { ha } ) {
const s = ha . states [ c . entity ] ;
if ( ! s ) return null ;
return { ico : ico ( WX [ s . state ] || "cloud" ) , name : friendly ( s , c . name ) , value : s . attributes . temperature ? ? "– " , unit : "°" , sub : st ( s . state ) , tap : more ( c . entity ) } ;
} ,
forecast ( c , { ha , cache } ) {
const s = ha . states [ c . entity ] ;
if ( ! s ) return null ;
const d = cache . daily ? . [ 0 ] ;
return { ico : ico ( WX [ s . state ] || "cloud" ) , name : friendly ( s ) , value : s . attributes . temperature ? ? "– " , unit : "°" ,
sub : d ? t ( "High {0}° · Low {1}°" , Math . round ( d . temperature ) , Math . round ( d . templow ? ? d . temperature ) ) : st ( s . state ) , tap : more ( c . entity ) } ;
} ,
list ( c , { ha } ) {
const ids = ( c . entities || [ ] ) . filter ( ( e ) => ha . states [ e ] ) ;
if ( ! ids . length ) return null ;
const on = ids . filter ( ( e ) => isOn ( ha . states [ e ] ) ) ;
return { ico : ico ( "list" ) , name : c . title || t ( "Devices" ) , value : on . length , unit : ` / ${ ids . length } ` , sub : on . length ? on . map ( ( e ) => friendly ( ha . states [ e ] ) ) . join ( ", " ) : t ( "All off" ) , on : on . length > 0 } ;
} ,
button : ( c ) => ( { ico : icon ( null , c . icon || "sparkles" ) , name : c . label || t ( "Run" ) , tap : { act : "button" } } ) ,
media ( c , { ha , cache } ) {
const ids = mediaIds ( c ) . filter ( ( e ) => ha . states [ e ] ) ;
if ( ! ids . length ) return null ;
const sel = mediaSelected ( ids , ha , cache ) , s = ha . states [ sel ] , a = s . attributes ;
const off = [ "off" , "standby" , "unavailable" ] . includes ( s . state ) , playing = s . state === "playing" ;
return { ico : icon ( s ) , name : friendly ( s ) , sub : off ? st ( s . state ) : [ a . media _title , a . media _artist ] . filter ( Boolean ) . join ( " · " ) || st ( s . state ) ,
on : playing , tap : more ( sel ) , art : c . art !== false && ! off ? a . entity _picture : "" ,
ctl : off ? rnd ( "svc" , sel , "power" , t ( "Turn on" ) , "media_player.turn_on" ) : rnd ( "svc" , sel , playing ? "pause" : "play" , playing ? t ( "Pause" ) : t ( "Play" ) , "media_player.media_play_pause" ) } ;
} ,
todo ( c , { ha , cache } ) {
const s = ha . states [ c . entity ] ;
if ( ! s ) return null ;
const open = ( cache . items || [ ] ) . filter ( ( i ) => i . status !== "completed" ) ;
return { ico : ico ( "checkbox" ) , name : c . title || friendly ( s ) , value : numOf ( s ) ? ? open . length , sub : open . slice ( 0 , 3 ) . map ( ( i ) => i . summary ) . join ( ", " ) || t ( "Nothing on the list." ) , tap : more ( c . entity ) } ;
} ,
people ( c , { ha } ) {
const ids = W . people . entities ( c , ha ) . filter ( ( e ) => ha . states [ e ] ) ;
if ( ! ids . length ) return null ;
const home = ids . filter ( ( e ) => ha . states [ e ] . state === "home" ) ;
return { ico : ico ( "people" ) , name : t ( "Home" ) , value : home . length , unit : ` / ${ ids . length } ` , on : home . length > 0 ,
sub : home . length ? home . map ( ( e ) => friendly ( ha . states [ e ] ) . split ( " " ) [ 0 ] ) . join ( ", " ) : t ( "Nobody home" ) } ;
} ,
timer ( c , { ha } ) {
const s = ha . states [ c . entity ] ;
if ( ! s ) return null ;
const a = s . attributes , dur = toSecs ( a . duration ) , active = s . state === "active" ;
const left = active && a . finishes _at ? ( new Date ( a . finishes _at ) - Date . now ( ) ) / 1000 : s . state === "paused" ? toSecs ( a . remaining ) : dur ;
const first = String ( c . presets || "" ) . split ( /[,\s]+/ ) . map ( Number ) . find ( ( m ) => m > 0 ) ;
return { ico : ico ( "timer" ) , name : friendly ( s , c . name ) , on : active , sub : st ( s . state ) ,
value : ` <span ${ active && a . finishes _at ? ` data-countdown=" ${ esc ( a . finishes _at ) } " data-dur=" ${ dur } " ` : "" } > ${ hms ( left ) } </span> ` ,
ctl : active ? rnd ( "timer" , c . entity , "pause" , t ( "Pause" ) , "pause" ) : s . state === "paused" ? rnd ( "timer" , c . entity , "play" , t ( "Resume" ) , "start" )
: dur ? rnd ( "timer" , c . entity , "play" , t ( "Start" ) , "start" ) : first ? rnd ( "timer" , c . entity , "play" , ` ${ first } ${ t ( "min" ) } ` , "start" , ` data-d=" ${ first } " ` ) : "" } ;
} ,
covers ( c , { ha } ) {
const ids = ( c . entities || [ ] ) . filter ( ( e ) => ha . states [ e ] ) ;
if ( ! ids . length ) return null ;
const pos = ids . map ( ( e ) => coverPos ( ha . states [ e ] ) ) , avg = Math . round ( pos . reduce ( ( x , y ) => x + y , 0 ) / pos . length ) ;
const one = ids . length === 1 , s = ha . states [ ids [ 0 ] ] , all = esc ( ids . join ( "," ) ) ;
const moving = ids . find ( ( e ) => [ "opening" , "closing" ] . includes ( ha . states [ e ] . state ) ) ;
return { ico : ico ( "blinds" ) , name : c . title || ( one ? friendly ( s ) : t ( "Blinds" ) ) , value : avg , unit : "%" , meter : avg , on : avg > 0 ,
sub : moving ? st ( ha . states [ moving ] . state ) : one ? ( avg >= 100 ? t ( "Open" ) : avg <= 0 ? t ( "Closed" ) : t ( "Partly open" ) ) : t ( "{0} of {1} open" , pos . filter ( ( p ) => p > 0 ) . length , ids . length ) ,
tap : one ? more ( ids [ 0 ] ) : null ,
ctl : ` <button class="phc-btn wbtn" data-act="svc-many" data-v="cover.open_cover" data-ids=" ${ all } " aria-label=" ${ t ( "Open" ) } "> ${ Icons . svg ( "arrow-up" ) } </button><button class="phc-btn wbtn" data-act="svc-many" data-v="cover.close_cover" data-ids=" ${ all } " aria-label=" ${ t ( "Close" ) } "> ${ Icons . svg ( "arrow-down" ) } </button> ` } ;
} ,
alarm ( c , { ha } ) {
const s = ha . states [ c . entity ] ;
if ( ! s ) return null ;
const armed = s . state . startsWith ( "armed" ) , word = { disarmed : t ( "Off" ) , armed _home : t ( "On · home" ) , armed _away : t ( "On · away" ) , armed _night : t ( "On · night" ) , armed _vacation : t ( "On · vacation" ) , arming : t ( "Turning on…" ) , pending : t ( "Entry delay…" ) , triggered : t ( "Alarm!" ) } [ s . state ] || st ( s . state ) ;
return { ico : ico ( "shield" ) , name : friendly ( s ) , word , tone : s . state === "triggered" ? "crit" : armed ? "ok" : s . state === "pending" ? "warn" : "" , on : armed , tap : more ( c . entity ) } ;
} ,
notifications ( c , { ha } ) {
const notes = c . notes !== false ? Object . values ( ha . notifications || { } ) : [ ] ;
const ups = c . updates !== false ? Object . values ( ha . states ) . filter ( ( s ) => s . entity _id . startsWith ( "update." ) && s . state === "on" ) : [ ] ;
const n = notes . length + ups . length ;
return { ico : ico ( "bell" ) , name : t ( "Notifications" ) , value : n , tone : n ? "accent" : "" ,
sub : n ? [ ... ups . map ( ( u ) => u . attributes . title || friendly ( u ) ) , ... notes . map ( ( x ) => x . title || x . message ) ] . slice ( 0 , 2 ) . join ( " · " ) : t ( "All caught up" ) } ;
} ,
energy ( c , { ha } ) {
if ( ! c . solar && ! c . grid && ! c . battery ) return null ;
const S = ( k ) => ( c [ k ] ? ha . states [ c [ k ] ] : null ) ;
const solar = Math . max ( 0 , watts ( S ( "solar" ) ) ? ? 0 ) ;
let grid = watts ( S ( "grid" ) ) ? ? 0 ; if ( c . invertGrid ) grid = - grid ;
let batt = watts ( S ( "battery" ) ) ? ? 0 ; if ( c . invertBattery ) batt = - batt ;
const home = Math . max ( 0 , watts ( S ( "home" ) ) ? ? solar + Math . max ( 0 , grid ) + Math . max ( 0 , - batt ) - Math . max ( 0 , - grid ) - Math . max ( 0 , batt ) ) ;
const [ v , u ] = fmtW ( home ) . split ( " " ) ;
return { ico : ico ( "bolt" ) , name : t ( "House" ) , value : v , unit : u ,
sub : [ c . solar && t ( "Sun {0}" , fmtW ( solar ) ) , c . grid && ( grid < - 1 ? t ( "Sending back {0}" , fmtW ( - grid ) ) : t ( "Grid {0}" , fmtW ( Math . max ( 0 , grid ) ) ) ) ] . filter ( Boolean ) . join ( " · " ) } ;
} ,
room ( c , { ha } ) {
const area = ha . reg ? . areas ? . find ( ( a ) => a . area _id === c . area ) , ids = roomEntities ( c , ha ) ;
if ( ! area && ! ids . length ) return null ;
const S = ( e ) => ha . states [ e ] , name = c . name || area ? . name || t ( "Room" ) ;
const temp = [ c . temperature , area ? . temperature _entity _id ] . find ( ( e ) => e && S ( e ) ) || ids . find ( ( e ) => domainOf ( e ) === "sensor" && S ( e ) . attributes . device _class === "temperature" ) ;
const lights = ids . filter ( ( e ) => domainOf ( e ) === "light" && ! Array . isArray ( S ( e ) . attributes . entity _id ) ) , on = lights . filter ( ( e ) => S ( e ) . state === "on" ) ;
const open = ids . filter ( ( e ) => domainOf ( e ) === "binary_sensor" && [ "door" , "window" , "opening" , "garage_door" ] . includes ( S ( e ) . attributes . device _class ) && S ( e ) . state === "on" ) ;
return { ico : icon ( null , c . icon || "sofa" ) , name , value : temp ? numOf ( S ( temp ) ) : null , unit : temp ? "°" : "" , on : on . length > 0 , tone : open . length ? "warn" : "" ,
sub : open . length ? t ( "{0} open" , open . length ) : lights . length ? ( on . length ? t ( "{0} of {1} lights on" , on . length , lights . length ) : t ( "Lights off" ) ) : "" ,
ctl : lights . length ? ` <button class="light-switch wbtn" role="switch" aria-checked=" ${ on . length > 0 } " data-act="svc-many" data-v="light. ${ on . length ? "turn_off" : "turn_on" } " data-ids=" ${ esc ( ( on . length ? on : lights ) . join ( "," ) ) } " aria-label=" ${ esc ( on . length ? t ( "Turn off all lights in {0}" , name ) : t ( "Turn on all lights in {0}" , name ) ) } "><span class="switch"></span></button> ` : "" } ;
} ,
vacuum ( c , { ha } ) {
const s = ha . states [ c . entity ] ;
if ( ! s ) return null ;
const a = s . attributes , cleaning = s . state === "cleaning" , batt = Number ( c . battery ? ha . states [ c . battery ] ? . state : a . battery _level ) ;
return { ico : ico ( "vacuum" ) , name : friendly ( s , c . name ) , word : cleaning ? t ( "Cleaning…" ) : s . state === "returning" ? t ( "Returning" ) : st ( s . state ) ,
sub : isFinite ( batt ) ? t ( "Battery {0}%" , Math . round ( batt ) ) : "" , meter : isFinite ( batt ) ? batt : null , on : cleaning , tone : s . state === "error" ? "crit" : "" , tap : more ( c . entity ) ,
ctl : rnd ( "svc" , c . entity , cleaning ? "pause" : "play" , cleaning ? t ( "Pause" ) : t ( "Start" ) , ` vacuum. ${ cleaning ? "pause" : "start" } ` ) } ;
} ,
appliance ( c , { ha } ) {
if ( c . kind === "litterbox" ) {
if ( ! c . status ) return null ;
const { phase , word } = litterPhase ( c , ha ) , v = ha . states [ c . visits ] ;
return { ico : ico ( phase === "attention" ? "shield" : "paw" ) , name : c . name || t ( "Litter box" ) , word , tone : phase === "attention" ? "warn" : phase === "cleaning" ? "accent" : "" ,
sub : v && isFinite ( Number ( v . state ) ) ? ` ${ Math . round ( Number ( v . state ) ) } ${ Number ( v . state ) === 1 ? t ( "visit" ) : t ( "visits" ) } ` : "" ,
ctl : c . cleanBtn && ha . states [ c . cleanBtn ] ? rnd ( "svc" , c . cleanBtn , "sparkles" , t ( "Clean now" ) , domainOf ( c . cleanBtn ) === "script" ? "script.turn_on" : "button.press" , phase === "cleaning" ? "disabled" : "" ) : "" } ;
}
if ( ! c . status && ! c . power && ! c . remaining ) return null ;
const phase = appliancePhase ( c , ha ) , left = [ "running" , "paused" ] . includes ( phase ) ? remainingMinutes ( c , ha ) : null ;
const label = { running : t ( "In progress" ) , done : t ( "Finished" ) , paused : t ( "Paused" ) , off : t ( "Off" ) , scheduled : t ( "Starts later" ) , attention : t ( "Action needed" ) , error : t ( "Error" ) , stopping : t ( "Stopping" ) } [ phase ] ;
const ii = { washer : "washer" , dryer : "dryer" , dishwasher : "dishwasher" , other : "plug" } [ c . kind ] || "washer" ;
return { ico : ico ( phase === "done" ? "check" : ii ) , name : c . name || t ( "Appliance" ) , word : left != null ? fmtDuration ( left ) : label , sub : left != null ? label : "" ,
on : [ "running" , "done" ] . includes ( phase ) , tone : phase === "done" ? "ok" : phase === "attention" ? "warn" : phase === "error" ? "crit" : phase === "running" ? "accent" : "" ,
meter : Number . isFinite ( Number ( ha . states [ c . progress ] ? . state ) ) && phase === "running" ? Number ( ha . states [ c . progress ] . state ) : null } ;
} ,
camera ( c , { ha } ) {
const s = ha . states [ c . entity ] ;
return s ? { ico : ico ( "camera" ) , name : friendly ( s ) , sub : t ( "Tap to watch" ) , tap : more ( c . entity ) } : null ;
} ,
car ( c , { ha } ) {
const S = ( k ) => ( c [ k ] ? ha . states [ c [ k ] ] : null ) , level = S ( "level" ) , range = S ( "range" ) , lock = S ( "lock" ) ;
if ( ! level && ! range && ! lock ) return null ;
const pct = numOf ( level ) , charging = c . kind !== "fuel" && ( truthy ( S ( "charging" ) ) || ( S ( "chargeSwitch" ) ? . state === "on" && domainOf ( c . chargeSwitch ) === "switch" ) ) ;
const locked = lock ? . state === "locked" ;
return { ico : ico ( "car" ) , name : c . name || t ( "Car" ) , value : pct != null ? Math . round ( Number ( level . state ) ) : null , unit : pct != null ? "%" : "" ,
meter : pct != null ? Number ( level . state ) : null , tone : pct != null && Number ( level . state ) <= 20 ? "warn" : charging ? "ok" : "" , on : charging ,
sub : [ charging && t ( "Charging" ) , range && ` ${ fmtNum ( range . state ) } ${ range . attributes . unit _of _measurement || "km" } ` ] . filter ( Boolean ) . join ( " · " ) ,
ctl : lock ? rnd ( "toggle" , c . lock , locked ? "lock" : "unlock" , locked ? t ( "Locked, tap to unlock" ) : t ( "Unlocked, tap to lock" ) ) : "" } ;
} ,
parcels ( c , { ha } ) {
const list = ( c . entities || [ ] ) . map ( ( e ) => ha . states [ e ] ) . filter ( Boolean ) ;
if ( ! list . length ) return null ;
const coming = list . flatMap ( ( s ) => parcelsOf ( s ) ) . filter ( ( p ) => ! p . done ) . sort ( ( a , b ) => ( a . date ? . getTime ( ) ? ? 9e15 ) - ( b . date ? . getTime ( ) ? ? 9e15 ) ) ;
const today = coming . filter ( ( p ) => p . date && dayLabel ( p . date ) === t ( "Today" ) ) . length ;
return { ico : ico ( "package" ) , name : c . title || t ( "Parcels" ) , value : coming . length , tone : today ? "accent" : "" ,
sub : today ? t ( "{0} today" , today ) : coming [ 0 ] ? ` ${ coming [ 0 ] . name } ${ coming [ 0 ] . date ? ` · ${ dayLabel ( coming [ 0 ] . date ) } ` : "" } ` : t ( "Nothing on the way" ) , tap : more ( c . entities [ 0 ] ) } ;
} ,
air ( c , { ha } ) {
const sensors = ( c . entities ? . length ? c . entities : W . air . entities ( c , ha ) ) . map ( ( e ) => ha . states [ e ] ) . filter ( ( s ) => s && AIR [ s . attributes . device _class ] && isFinite ( Number ( s . state ) ) ) ;
if ( ! sensors . length ) return null ;
const lv = sensors . map ( ( s ) => ( { s , lvl : AIR [ s . attributes . device _class ] . lv ( Number ( s . state ) ) } ) ) . sort ( ( a , b ) => b . lvl - a . lvl ) , worst = lv [ 0 ] ;
const co2 = sensors . find ( ( s ) => AIR [ s . attributes . device _class ] . k === "co2" ) ;
return { ico : ico ( "air" ) , name : c . title || t ( "Air quality" ) , word : t ( [ "Good" , "Fair" , "Poor" ] [ worst . lvl ] ) , tone : [ "ok" , "warn" , "crit" ] [ worst . lvl ] ,
sub : worst . lvl ? friendly ( worst . s ) : co2 ? ` CO₂ ${ fmtState ( co2 ) } ` : "" , tap : more ( worst . s . entity _id ) } ;
} ,
usage ( c , { ha , cache } ) {
const s = ha . states [ c . entity ] ;
if ( ! s ) return null ;
const liters = ( c . kind || "water" ) === "water" && s . attributes . unit _of _measurement === "m³" ;
const days = ( cache . days || [ ] ) . map ( ( d ) => ( liters ? d . v * 1000 : d . v ) ) , today = days [ days . length - 1 ] , yest = days [ days . length - 2 ] ;
const unit = liters ? "L" : s . attributes . unit _of _measurement || "" ;
return { ico : ico ( { water : "droplet" , gas : "flame" , electricity : "bolt" } [ c . kind ] || "droplet" ) , name : c . title || friendly ( s ) ,
value : today != null ? fmtNum ( today ) : "– " , unit , sub : yest != null ? t ( "yesterday {0}" , ` ${ fmtNum ( yest ) } ${ unit } ` ) : t ( "Today so far" ) , tap : more ( c . entity ) } ;
} ,
solar ( c , { ha } ) {
const today = ha . states [ c . today ] , tomorrow = ha . states [ c . tomorrow ] ;
if ( ! today && ! tomorrow ) return null ;
const s = today || tomorrow ;
return { ico : ico ( "solar" ) , name : c . title || t ( "Solar forecast" ) , value : numOf ( s ) ? ? "– " , unit : s . attributes . unit _of _measurement || "kWh" ,
sub : today && tomorrow ? ` ${ t ( "Tomorrow" ) } ${ fmtNum ( tomorrow . state ) } ${ tomorrow . attributes . unit _of _measurement || "kWh" } ` : today ? t ( "Today" ) : t ( "Tomorrow" ) , tap : more ( c . today || c . tomorrow ) } ;
} ,
gate ( c , { ha } ) {
const s = ha . states [ c . entity ] ;
if ( ! s ) return null ;
const d = domainOf ( c . entity ) , open = [ "open" , "opening" , "unlocked" , "unlocking" , "on" ] . includes ( s . state ) , moving = [ "opening" , "closing" , "locking" , "unlocking" ] . includes ( s . state ) ;
const name = c . name || friendly ( s ) , label = d === "button" ? t ( "Press" ) : moving ? st ( s . state ) : open ? ( d === "lock" ? t ( "Lock" ) : t ( "Close" ) ) : d === "lock" ? t ( "Unlock" ) : t ( "Open" ) ;
const k = { garage : "garage" , gate : "gate" , door : "door" } [ c . kind ] || "garage" ;
return { ico : ico ( open && c . kind === "door" ? "door-open" : k ) , name , word : d === "button" ? "" : open ? t ( "Open" ) : st ( s . state ) , tone : open ? "warn" : "" , on : open , sub : relTime ( s . last _changed ) ,
// a round button with an arrow on a Small card, the word beside it in a Row
ctl : ` <button class="phc-btn wide wbtn" data-act="gate" data-e=" ${ esc ( c . entity ) } " data-v=" ${ c . confirm === false ? "" : "1" } " aria-label=" ${ esc ( ` ${ label } : ${ name } ` ) } "> ${ Icons . svg ( d === "button" ? "play" : d === "lock" ? ( open ? "lock" : "unlock" ) : open ? "arrow-down" : "arrow-up" ) } <span> ${ esc ( label ) } </span></button> ` } ;
} ,
sports ( c , { ha } ) {
const s = ha . states [ c . entity ] ;
if ( ! s ) return null ;
const a = s . attributes , state = String ( s . state ) . toUpperCase ( ) ;
if ( ! a . opponent _name ) return { ico : ico ( "trophy" ) , name : a . team _name || friendly ( s ) , sub : t ( "No match planned." ) } ;
const home = a . team _homeaway === "away" ? "opponent" : "team" , away = home === "team" ? "opponent" : "team" ;
const ab = ( p ) => a [ ` ${ p } _abbr ` ] || String ( a [ ` ${ p } _name ` ] || "" ) . slice ( 0 , 3 ) . toUpperCase ( ) ;
const kick = a . date ? new Date ( a . date ) : null ;
return { ico : ico ( "trophy" ) , name : ` ${ ab ( home ) } – ${ ab ( away ) } ` , on : state === "IN" , tone : state === "IN" ? "accent" : "" ,
value : state === "PRE" ? ( kick ? kick . toLocaleTimeString ( LOCALE , { hour : "2-digit" , minute : "2-digit" } ) : "– " ) : ` ${ a [ ` ${ home } _score ` ] ? ? 0 } – ${ a [ ` ${ away } _score ` ] ? ? 0 } ` ,
sub : state === "PRE" ? ( kick ? dayLabel ( kick ) : "" ) : state === "IN" ? a . clock || t ( "Live" ) : t ( "Full time" ) , tap : more ( c . entity ) } ;
} ,
f1 ( c , { ha } ) {
const next = ha . states [ c . next ] ;
if ( ! next ) return null ;
const a = next . attributes , iso = a . race _start || a . race _date || a . race _start _local , at = iso ? new Date ( iso ) : null ;
let when = at ? dayLabel ( at ) : "" ;
if ( at && at > Date . now ( ) ) { const m = Math . round ( ( at - Date . now ( ) ) / 60000 ) , dd = Math . floor ( m / 1440 ) ; when = dd ? t ( "in {0} d {1} h" , dd , Math . floor ( ( m % 1440 ) / 60 ) ) : t ( "in {0} h {1} min" , Math . floor ( m / 60 ) , m % 60 ) ; }
return { ico : ico ( "flag" ) , name : a . race _name || a . raceName || t ( "Next race" ) , word : when , sub : [ a . circuit _locality || a . Circuit ? . Location ? . locality , a . circuit _country || a . Circuit ? . Location ? . country ] . filter ( Boolean ) . join ( " · " ) , tap : more ( c . next ) } ;
} ,
calendar ( c , { ha , cache } ) {
if ( ! c . entities ? . length ) return null ;
const now = new Date ( ) , events = [ ] ;
for ( const eid of c . entities ) for ( const ev of cache . res ? . [ eid ] ? . events || [ ] ) { const s = parseWhen ( ev . start ) , e = parseWhen ( ev . end ) ; if ( e >= now ) events . push ( { ... ev , s , allDay : typeof ev . start === "string" ? ! ev . start . includes ( "T" ) : ! ! ev . start ? . date } ) ; }
events . sort ( ( x , y ) => x . s - y . s ) ;
const ev = events [ 0 ] ;
// the next event as the name; its time today as the figure, else the day
const today = ev && dayLabel ( ev . s ) === t ( "Today" ) ;
return { ico : ico ( "calendar" ) , name : ev ? ev . summary : c . title || t ( "Upcoming" ) , tap : more ( c . entities [ 0 ] ) ,
value : ev && today && ! ev . allDay ? ev . s . toLocaleTimeString ( LOCALE , { hour : "2-digit" , minute : "2-digit" } ) : null ,
word : ev ? ( today && ! ev . allDay ? "" : dayLabel ( ev . s ) ) : cache . res ? t ( "Nothing planned" ) : t ( "Loading…" ) , sub : c . title || "" } ;
} ,
waste ( c , { ha } ) {
const items = wasteItems ( c , ha ) . filter ( ( i ) => i . days <= ( Number ( c . within ) || 31 ) ) ;
if ( ! items . length ) return null ;
const first = items . filter ( ( i ) => i . days === items [ 0 ] . days ) ;
return { ico : ` <span class="phc-bin" style="color: ${ first [ 0 ] . color } "> ${ Icons . svg ( "bin" ) } </span> ` , name : c . title || t ( "Waste collection" ) , word : whenLabel ( first [ 0 ] . days ) ,
sub : first . map ( ( i ) => i . name ) . join ( " · " ) , tone : first [ 0 ] . days <= 1 ? "warn" : "" , tap : more ( first [ 0 ] . eid ) } ;
} ,
} ;
/** Draw a summary as a Small, Medium or Large card, or a Row. */
function phoneCard ( sum , size ) {
const tap = sum . tap ? ` <button class="phc-tap wbtn" data-act=" ${ esc ( sum . tap . act ) } " ${ sum . tap . e ? ` data-e=" ${ esc ( sum . tap . e ) } " ` : "" } ${ sum . tap . v != null ? ` data-v=" ${ esc ( sum . tap . v ) } " ` : "" } aria-label=" ${ esc ( sum . name ) } "></button> ` : "" ;
const fig = sum . value != null && sum . value !== ""
? ` <span class="phc-val num"> ${ typeof sum . value === "string" && sum . value . startsWith ( "<" ) ? sum . value : esc ( sum . value ) } ${ sum . unit === "°" ? "°" : sum . unit ? ` <small> ${ esc ( sum . unit ) } </small> ` : "" } </span> `
: sum . word ? ` <span class="phc-val phc-word"> ${ esc ( sum . word ) } </span> ` : "" ;
const meter = sum . meter != null ? ` <span class="phc-meter" aria-hidden="true"><i style="width: ${ Math . max ( 0 , Math . min ( 100 , sum . meter ) ) . toFixed ( 0 ) } %"></i></span> ` : "" ;
const art = sum . art ? ` <img class="phc-art" src=" ${ esc ( sum . art ) } " alt=""> ` : ` <span class="phc-ico"> ${ sum . ico || "" } </span> ` ;
const cls = ` phc phc- ${ size } ${ sum . on ? "on" : "" } ${ sum . tone ? ` tone- ${ sum . tone } ` : "" } ` ;
const style = sum . tint ? ` style="--phc-tint: ${ esc ( sum . tint ) } " ` : "" ;
if ( size === "row" ) {
return ` <div class=" ${ cls } " ${ style } > ${ tap } ${ art } <span class="phc-text"><span class="phc-name"> ${ esc ( sum . name ) } </span> ${ sum . sub ? ` <span class="phc-sub"> ${ esc ( sum . sub ) } </span> ` : "" } </span>
$ { fig } $ { sum . ctl ? ` <span class="phc-ctl"> ${ sum . ctl } </span> ` : "" } < / d i v > ` ;
}
if ( size === "m" ) {
return ` <div class=" ${ cls } " ${ style } > ${ tap } <div class="phc-top"> ${ art } <span class="phc-text"><span class="phc-name"> ${ esc ( sum . name ) } </span> ${ sum . sub ? ` <span class="phc-sub"> ${ esc ( sum . sub ) } </span> ` : "" } </span> ${ sum . ctl ? ` <span class="phc-ctl"> ${ sum . ctl } </span> ` : "" } </div>
$ { fig || meter ? ` <div class="phc-bottom"> ${ fig } ${ meter } </div> ` : "" } < / d i v > ` ;
}
return ` <div class=" ${ cls } " ${ style } > ${ tap } <div class="phc-top"> ${ art } ${ sum . ctl ? ` <span class="phc-ctl"> ${ sum . ctl } </span> ` : "" } </div>
< div class = "phc-bottom" > $ { fig } $ { meter } < span class = "phc-name" > $ { esc ( sum . name ) } < / s p a n > $ { s u m . s u b ? ` < s p a n c l a s s = " p h c - s u b " > $ { e s c ( s u m . s u b ) } < / s p a n > ` : " " } < / d i v > < / d i v > ` ;
}
// Which sizes each card has on a phone, and which it draws itself instead of as a summary.
const PHONE = {
heading : { sizes : [ "row" , "m" ] , row : "card" , s : "card" } ,
entity : { m : "sum" , l : "sum" } ,
button : { m : "sum" , l : "sum" } ,
timer : { m : "sum" } ,
light : { s : "card" } ,
text : { s : "card" , row : "card" } ,
image : { sizes : [ "s" , "m" , "l" ] , s : "card" } ,
iframe : { sizes : [ "s" , "m" , "l" ] , s : "card" } ,
camera : { s : "card" } ,
hscroll : { sizes : [ "m" , "l" ] } ,
swipe : { sizes : [ "s" , "m" , "l" ] , s : "card" } ,
} ;
for ( const [ k , def ] of Object . entries ( W ) ) def . phone = { sizes : [ "s" , "m" , "l" , "row" ] , ... ( PHONE [ k ] || { } ) } ;
// A light as one row: the fader the group card uses for each lamp, so you can still dim it.
function lightRow ( c , ha ) {
const s = ha . states [ c . entity ] ;
if ( ! s ) return null ;
const name = friendly ( s , c . name ) ;
return ` <div class="lt phc-light ${ isOn ( s ) ? "on" : "" } "> ${ fader ( c . entity , s , name , ` <button class="lt-ico wbtn" data-act="light-popup" data-e=" ${ esc ( c . entity ) } " aria-label=" ${ esc ( t ( "Settings for {0}" , name ) ) } "> ${ icon ( s ) } </button>
< span class = "lf-name" > $ { esc ( name ) } < / s p a n > < s p a n c l a s s = " l f - p c t n u m " > $ { e s c ( b i g T e x t ( s , t r u e ) ) } < / s p a n > $ { s w ( c . e n t i t y , i s O n ( s ) , n a m e ) } ` , " l f - r o w " ) } < / d i v > ` ;
}
/** The phone version of a card at a size, or null to draw the card as it is. */
W . _ _phoneRender = ( type , c , ctx , size ) => {
const def = W [ type ] ;
if ( ! def ) return null ;
if ( ( size === "m" || size === "l" ) ? def . phone [ size ] !== "sum" : def . phone [ size ] === "card" ) return null ;
if ( type === "light" && size === "row" ) return lightRow ( c , ctx . ha ) ;
const sum = SUMMARY [ type ] ? . ( c , ctx ) ;
return sum ? phoneCard ( sum , size ) : null ;
} ;
window . Widgets = W ;
function beep ( ) {
try {