@@ -0,0 +1,555 @@
"use strict" ;
/* UX-13 P2: Settings, the Installer view. It draws from /config/model's
`layout` (app/uilayout.py through app/uipage.py) and `ui` (the page model
from app/uimodel.py), so this file names no option key: keys, labels,
ranges and choices all arrive as data. It posts the page model back; the
add-on turns it into options and runs its own start checks, and nothing
they refuse is saved. Every word is from app/ui_text or the add-on, and
data is set as text, never as HTML. */
( function ( ) {
const ROOT = JSON . parse ( document . getElementById ( "ui-text" ) . textContent ) ;
const W = ROOT . settings ;
const { h , ask , toast , maint } = window . UX13 ;
const $ = ( s ) => document . querySelector ( s ) ;
const ws = ( path ) => path . split ( "." ) . reduce ( ( o , k ) => ( o == null ? o : o [ k ] ) , W ) ;
const clone = ( x ) => ( x === undefined ? undefined : JSON . parse ( JSON . stringify ( x ) ) ) ;
const same = ( a , b ) => JSON . stringify ( a ? ? null ) === JSON . stringify ( b ? ? null ) ;
document . querySelectorAll ( "[data-s]" ) . forEach ( ( e ) => { e . textContent = ws ( e . dataset . s ) || "" ; } ) ;
document . querySelectorAll ( "[data-s-ph]" ) . forEach ( ( e ) => { e . placeholder = ws ( e . dataset . sPh ) || "" ; } ) ;
document . querySelectorAll ( "[data-s-label]" ) . forEach ( ( e ) => { e . setAttribute ( "aria-label" , ws ( e . dataset . sLabel ) || "" ) ; } ) ;
$ ( "#savebar" ) . setAttribute ( "aria-label" , W . savebar . label ) ;
let M = null ; // the last /config/model
let V = null ; // the page model being edited
let ORIG = null ; // the page model as stored
let FP = "" ; // the stored blob's fingerprint (compare-and-swap)
let MSG = [ ] ; // [{field, card, tier, text}] from the add-on
let DEVICE = "" ;
let LIST = null ; // the inverter list field
let FILLED = { } ; // field key -> the preset field that fills it
let active = false ;
let uid = 0 ;
const ROWS = [ ] ;
const OPEN = new Set ( ) ;
const UNLOCKED = new Set ( ) ;
/* ---------------- the page model ---------------- */
const sub = ( f ) => f . key . slice ( LIST . key . length + 1 ) ;
function valueIn ( model , f , card ) {
if ( card == null ) return model [ f . key ] ;
const c = ( model [ LIST . key ] || [ ] ) [ card ] ;
return c ? c [ sub ( f ) ] : undefined ;
}
const get = ( f , card ) => valueIn ( V , f , card ) ;
function visible ( f ) {
return ( f . show || [ ] ) . every ( ( [ name , op , want ] ) => {
const have = V [ name ] ;
if ( op === "==" ) return same ( have , want ) ;
if ( op === "!=" ) return ! same ( have , want ) ;
if ( op === "in" ) return want . some ( ( x ) => same ( x , have ) ) ;
if ( have == null || have === "" || isNaN ( + have ) ) return false ;
return op === ">" ? + have > want : + have < want ;
} ) ;
}
function put ( f , card , v , redraw ) {
if ( card == null ) V [ f . key ] = v ;
else V [ LIST . key ] [ card ] [ sub ( f ) ] = v ;
if ( f . mirror ) {
const m = findField ( f . mirror ) ;
if ( m && ( m . choices || [ ] ) . some ( ( c ) => same ( c [ 0 ] , v ) ) ) V [ f . mirror ] = v ;
}
if ( f . fills && f . presets && f . presets [ v ] ) V [ f . fills ] = f . presets [ v ] . value ;
if ( redraw || f . fills ) render ( ) ; else paint ( ) ;
queueValidate ( ) ;
}
function findField ( key ) {
for ( const g of M . layout . groups ) for ( const p of g . parts ) for ( const f of p . fields ) {
if ( f . key === key ) return f ;
for ( const s of f . fields || [ ] ) if ( s . key === key ) return s ;
}
return null ;
}
/* ---------------- numbers: stored units <-> shown units ---------------- */
const decimals = ( x ) => { const s = String ( x ) ; return s . includes ( "." ) ? s . split ( "." ) [ 1 ] . length : 0 ; } ;
function shown ( f , v ) {
if ( v == null || v === "" ) return "" ;
return String ( + ( Number ( v ) / ( f . scale || 1 ) ) . toFixed ( 6 ) ) ;
}
function stored ( f , x ) {
const st = f . step || ( f . type === "integer" ? 1 : 0 ) ;
let v = x * ( f . scale || 1 ) ;
if ( st ) v = Math . round ( v / st ) * st ;
if ( f . min != null ) v = Math . max ( f . min , v ) ;
if ( f . max != null ) v = Math . min ( f . max , v ) ;
return f . type === "integer" ? Math . round ( v ) : + v . toFixed ( Math . min ( 10 , decimals ( st ) + 2 ) ) ;
}
/* ---------------- controls ---------------- */
function choicesOf ( f , cur ) {
const out = ( f . choices || [ ] ) . filter ( ( c ) => ! c [ 2 ] || same ( c [ 0 ] , cur ) ) ;
if ( cur != null && cur !== "" && ! out . some ( ( c ) => same ( c [ 0 ] , cur ) ) ) out . push ( [ cur , String ( cur ) ] ) ;
return out ;
}
function toggle ( f , card , lid ) {
const on = ! ! get ( f , card ) ;
const b = h ( "button" , { type : "button" , class : "switch" , role : "switch" , "aria-checked" : String ( on ) , "aria-labelledby" : lid } ) ;
const txt = h ( "span" , { class : "swtext" , "aria-hidden" : "true" } , on ? W . on : W . off ) ;
b . addEventListener ( "click" , ( ) => {
const n = b . getAttribute ( "aria-checked" ) !== "true" ;
b . setAttribute ( "aria-checked" , String ( n ) ) ; txt . textContent = n ? W . on : W . off ;
put ( f , card , n ) ;
} ) ;
return h ( "span" , { class : "swrap" } , b , txt ) ;
}
function radios ( f , card , lid , cards ) {
const cur = get ( f , card ) ;
const g = h ( "div" , { class : cards ? "choices" : "seg" , role : "radiogroup" , "aria-labelledby" : lid } ) ;
const items = choicesOf ( f , cur ) ;
const buttons = items . map ( ( [ v , label ] , i ) => {
const on = same ( v , cur ) ;
const hint = cards && f . hints ? f . hints [ v ] : "" ;
const b = h ( "button" , { type : "button" , role : "radio" , class : cards ? "choice" : null , "aria-checked" : String ( on ) ,
tabindex : on || ( ! items . some ( ( c ) => same ( c [ 0 ] , cur ) ) && i === 0 ) ? "0" : "-1" } ,
cards ? [ h ( "span" , { class : "rad" , "aria-hidden" : "true" } ) , h ( "span" , { } , h ( "div" , { } , label ) , hint ? h ( "div" , { class : "tiny muted" } , hint ) : null ) ] : label ) ;
b . addEventListener ( "click" , ( ) => put ( f , card , v , true ) ) ;
b . addEventListener ( "keydown" , ( e ) => {
const d = { ArrowRight : 1 , ArrowDown : 1 , ArrowLeft : - 1 , ArrowUp : - 1 } [ e . key ] ;
if ( ! d ) return ;
e . preventDefault ( ) ;
const j = ( i + d + items . length ) % items . length ;
put ( f , card , items [ j ] [ 0 ] , true ) ;
const again = document . querySelector ( ` [data-rk=" ${ CSS . escape ( g . dataset . rk ) } "] [role=radio]:nth-child( ${ j + 1 } ) ` ) ;
if ( again ) again . focus ( ) ;
} ) ;
return b ;
} ) ;
g . dataset . rk = f . key + "|" + card ;
g . append ( ... buttons ) ;
return g ;
}
function select ( f , card , lid ) {
const cur = get ( f , card ) ;
const s = h ( "select" , { class : "sel" , "aria-labelledby" : lid } ) ;
for ( const [ v , label ] of choicesOf ( f , cur ) ) s . append ( h ( "option" , { value : JSON . stringify ( v ) , selected : same ( v , cur ) } , label ) ) ;
for ( const [ v , label ] of f . later || [ ] ) s . append ( h ( "option" , { value : JSON . stringify ( v ) , disabled : true } , label ) ) ;
s . addEventListener ( "change" , ( ) => put ( f , card , JSON . parse ( s . value ) , true ) ) ;
const preset = f . presets && f . presets [ cur ] ;
return preset ? h ( "div" , { class : "row" } , s , h ( "span" , { class : "chip" } , W . region _year . replace ( "{year}" , preset . year ) ) ) : s ;
}
function slider ( f , card , lid ) {
if ( f . min == null || f . max == null ) return number ( f , card , lid ) ;
const sc = f . scale || 1 ;
const st = f . step || ( f . type === "integer" ? 1 : 0.01 ) ;
const v = get ( f , card ) ;
const r = h ( "input" , { type : "range" , min : f . min , max : f . max , step : st , value : v == null ? f . min : v , "aria-labelledby" : lid } ) ;
const n = h ( "input" , { class : "num" , type : "number" , inputmode : "decimal" , min : f . min / sc , max : f . max / sc , step : st / sc , value : shown ( f , v ) , "aria-labelledby" : lid } ) ;
r . addEventListener ( "input" , ( ) => { const x = stored ( f , + r . value / sc ) ; n . value = shown ( f , x ) ; put ( f , card , x ) ; } ) ;
n . addEventListener ( "change" , ( ) => {
if ( n . value === "" || isNaN ( + n . value ) ) { n . value = shown ( f , get ( f , card ) ) ; return ; }
const x = stored ( f , + n . value ) ; r . value = x ; n . value = shown ( f , x ) ; put ( f , card , x ) ;
} ) ;
return h ( "div" , { class : "slider" } , r , n , f . unit ? h ( "span" , { class : "unit" } , f . unit ) : null ) ;
}
function number ( f , card , lid ) {
const sc = f . scale || 1 ;
const by = FILLED [ f . key ] ;
const preset = by && by . presets [ V [ by . key ] ] ;
const n = h ( "input" , { class : "num" , type : "number" , inputmode : "decimal" , step : f . step ? f . step / sc : "any" ,
min : f . min == null ? null : f . min / sc , max : f . max == null ? null : f . max / sc ,
value : shown ( f , get ( f , card ) ) , disabled : ! ! preset , "aria-labelledby" : lid } ) ;
n . addEventListener ( "input" , ( ) => {
if ( n . value === "" ) { if ( f . optional ) put ( f , card , null ) ; return ; }
if ( ! isNaN ( + n . value ) ) put ( f , card , stored ( f , + n . value ) ) ;
} ) ;
n . addEventListener ( "change" , ( ) => { n . value = shown ( f , get ( f , card ) ) ; } ) ;
return h ( "div" , { } , h ( "div" , { class : "row" } , n , f . unit ? h ( "span" , { class : "unit" } , f . unit ) : null ) ,
preset ? h ( "div" , { class : "tiny muted" } , W . preset . description ) : null ) ;
}
function text ( f , card , lid ) {
const v = get ( f , card ) ;
const t = h ( "input" , { class : "txt" , type : "text" , value : v == null ? "" : v , autocapitalize : "none" , spellcheck : "false" , "aria-labelledby" : lid } ) ;
t . addEventListener ( "input" , ( ) => put ( f , card , t . value ) ) ;
return t ;
}
function locked ( f , card , lid ) {
const box = h ( "div" , { } ) ;
const id = f . key + "#" + card ;
const open = ( ) => { UNLOCKED . add ( id ) ; box . replaceChildren ( slider ( f , card , lid ) ) ; } ;
if ( UNLOCKED . has ( id ) ) { open ( ) ; return box ; }
const v = get ( f , card ) ;
box . append ( h ( "div" , { class : "row" } ,
h ( "strong" , { } , v == null ? W . not _set : shown ( f , v ) + ( f . unit ? " " + f . unit : "" ) ) ,
h ( "span" , { class : "chip" } , W . locked ) ,
h ( "button" , { type : "button" , class : "btn link" , "aria-label" : W . change + ": " + f . label , onclick : open } , W . change ) ) ) ;
return box ;
}
/* entity picker: search, the matching kind first, the live reading */
const ENT = { } ;
function entities ( kind ) {
const now = Date . now ( ) ;
const e = ENT [ kind ] ;
if ( e && now - e . at < 30000 ) return e . p ;
const p = fetch ( "ha/entities?kind=" + encodeURIComponent ( kind || "" ) , { cache : "no-store" } )
. then ( ( r ) => ( r . ok ? r . json ( ) : { available : false , entities : [ ] } ) )
. catch ( ( ) => ( { available : false , entities : [ ] } ) ) ;
ENT [ kind ] = { at : now , p } ;
return p ;
}
const reading = ( e ) => ( e && e . state != null && e . state !== "" ? String ( e . state ) + ( e . unit ? " " + e . unit : "" ) : "– " ) ;
function picker ( f , lid , value , pick ) {
let data = null , all = false ;
const btn = h ( "button" , { type : "button" , class : "ent-btn" , "aria-expanded" : "false" , "aria-haspopup" : "listbox" , "aria-labelledby" : lid } ) ;
const pop = h ( "div" , { class : "ent-pop" , hidden : true } ) ;
const q = h ( "input" , { type : "search" , placeholder : W . picker . search . description , "aria-label" : W . picker . search . description , autocomplete : "off" , spellcheck : "false" } ) ;
const note = h ( "div" , { class : "tiny muted ent-note" } ) ;
const list = h ( "div" , { class : "ent-list" , role : "listbox" , "aria-labelledby" : lid } ) ;
const allBox = h ( "label" , { class : "tiny muted ent-all" } ,
h ( "input" , { type : "checkbox" , onchange : ( e ) => { all = e . target . checked ; fill ( ) ; } } ) , W . picker . all ) ;
function drawBtn ( ) {
const id = value ( ) ;
const e = data && data . entities . find ( ( x ) => x . entity _id === id ) ;
btn . replaceChildren ( ) ;
if ( id ) btn . append ( h ( "span" , { class : "grow" } , h ( "div" , { } , e ? e . name : id ) , h ( "div" , { class : "key" } , id ) ) ,
data && data . available ? h ( "span" , { class : "chip " + ( e ? "now" : "rs" ) } , e ? reading ( e ) : W . picker . missing ) : null ) ;
else btn . append ( h ( "span" , { class : "grow muted" } , W . picker . choose ) ) ;
btn . append ( h ( "span" , { class : "muted" , "aria-hidden" : "true" } , "▾" ) ) ;
}
function opt ( id , name , val ) {
return h ( "button" , { type : "button" , class : "ent-opt" , role : "option" , "aria-selected" : String ( id === value ( ) ) ,
onclick : ( ) => { pick ( id ) ; close ( ) ; drawBtn ( ) ; btn . focus ( ) ; } } ,
h ( "span" , { class : "grow" } , h ( "div" , { } , name ) , h ( "div" , { class : "key" } , id ) ) , val ? h ( "span" , { class : "v" } , val ) : null ) ;
}
function fill ( ) {
const typed = q . value . trim ( ) ;
const t = typed . toLowerCase ( ) ;
const rows = ( data ? data . entities : [ ] ) . filter ( ( e ) => ( all || e . match ) && ( e . entity _id + " " + e . name ) . toLowerCase ( ) . includes ( t ) ) . slice ( 0 , 200 ) ;
list . replaceChildren ( ... rows . map ( ( e ) => opt ( e . entity _id , e . name , reading ( e ) ) ) ) ;
if ( typed . includes ( "." ) && ! rows . some ( ( e ) => e . entity _id === typed ) ) list . append ( opt ( typed , W . picker . use , "" ) ) ;
if ( ! list . children . length ) list . append ( h ( "div" , { class : "small muted" , style : "padding:10px" } , W . picker . none . description ) ) ;
note . textContent = data && data . text ? data . text : "" ;
note . hidden = ! note . textContent ;
}
function close ( ) { pop . hidden = true ; btn . setAttribute ( "aria-expanded" , "false" ) ; }
btn . addEventListener ( "click" , ( ) => {
if ( ! pop . hidden ) { close ( ) ; return ; }
pop . hidden = false ; btn . setAttribute ( "aria-expanded" , "true" ) ; q . value = "" ; fill ( ) ;
setTimeout ( ( ) => q . focus ( ) , 0 ) ;
} ) ;
q . addEventListener ( "input" , fill ) ;
pop . addEventListener ( "keydown" , ( e ) => {
if ( e . key === "Escape" ) { e . preventDefault ( ) ; close ( ) ; btn . focus ( ) ; return ; }
if ( e . key !== "ArrowDown" && e . key !== "ArrowUp" ) return ;
e . preventDefault ( ) ;
const opts = [ ... list . querySelectorAll ( ".ent-opt" ) ] ;
const i = opts . indexOf ( document . activeElement ) ;
const j = e . key === "ArrowDown" ? Math . min ( opts . length - 1 , i + 1 ) : i - 1 ;
if ( j < 0 ) q . focus ( ) ; else if ( opts [ j ] ) opts [ j ] . focus ( ) ;
} ) ;
pop . append ( q , note , list , allBox ) ;
drawBtn ( ) ;
entities ( f . kind ) . then ( ( d ) => { data = d ; drawBtn ( ) ; if ( ! pop . hidden ) fill ( ) ; } ) ;
return h ( "div" , { class : "ent" } , btn , pop ,
f . optional ? h ( "button" , { type : "button" , class : "btn link tiny" , onclick : ( ) => { pick ( "" ) ; drawBtn ( ) ; } } , W . picker . clear ) : null ) ;
}
function entity ( f , card , lid ) {
return picker ( f , lid , ( ) => get ( f , card ) || "" , ( v ) => put ( f , card , v ) ) ;
}
function entityList ( f , card , lid ) {
const arr = ( ) => ( Array . isArray ( get ( f , card ) ) ? get ( f , card ) : [ ] ) ;
const box = h ( "div" , { class : "elist" } ) ;
arr ( ) . forEach ( ( _id , i ) => {
box . append ( h ( "div" , { class : "erow" } ,
h ( "div" , { class : "tiny muted" } , W . phase + " " + ( i + 1 ) ) ,
picker ( f , lid , ( ) => arr ( ) [ i ] || "" , ( v ) => { const a = arr ( ) . slice ( ) ; a [ i ] = v ; put ( f , card , a ) ; } ) ,
h ( "button" , { type : "button" , class : "btn link tiny" , onclick : ( ) => { const a = arr ( ) . slice ( ) ; a . splice ( i , 1 ) ; put ( f , card , a , true ) ; } } , W . remove ) ) ) ;
} ) ;
box . append ( h ( "button" , { type : "button" , class : "btn" , onclick : ( ) => put ( f , card , arr ( ) . concat ( [ "" ] ) , true ) } , "+ " + W . add _row ) ) ;
return box ;
}
function solarList ( f , card , lid ) {
const arr = ( ) => ( Array . isArray ( get ( f , card ) ) ? get ( f , card ) : [ ] ) ;
const box = h ( "div" , { class : "elist" } ) ;
arr ( ) . forEach ( ( row , i ) => {
const flip = h ( "button" , { type : "button" , class : "switch" , role : "switch" , "aria-checked" : String ( ! ! row . reversed ) , "aria-label" : W . reversed } ) ;
flip . addEventListener ( "click" , ( ) => { const a = clone ( arr ( ) ) ; a [ i ] . reversed = ! a [ i ] . reversed ; put ( f , card , a , true ) ; } ) ;
box . append ( h ( "div" , { class : "erow" } ,
picker ( f , lid , ( ) => arr ( ) [ i ] . entity || "" , ( v ) => { const a = clone ( arr ( ) ) ; a [ i ] . entity = v ; put ( f , card , a ) ; } ) ,
h ( "div" , { class : "row" } , h ( "span" , { class : "swrap" } , flip , h ( "span" , { class : "swtext" } , W . reversed ) ) ,
h ( "button" , { type : "button" , class : "btn link tiny" , onclick : ( ) => { const a = clone ( arr ( ) ) ; a . splice ( i , 1 ) ; put ( f , card , a , true ) ; } } , W . remove ) ) ) ) ;
} ) ;
box . append ( h ( "button" , { type : "button" , class : "btn" , onclick : ( ) => put ( f , card , arr ( ) . concat ( [ { entity : "" , reversed : false } ] ) , true ) } , "+ " + W . add _solar ) ) ;
return box ;
}
const CONTROLS = { toggle , segmented : ( f , c , l ) => radios ( f , c , l , false ) , choice : ( f , c , l ) => radios ( f , c , l , true ) ,
select , slider , number , text , locked , entity , entity _list : entityList , solar _list : solarList } ;
/* ---------------- rows, cards and groups ---------------- */
function keyText ( f , card ) {
if ( card != null ) return V [ LIST . shape ] === "flat" && f . flat ? f . flat : LIST . key + "[" + card + "]." + sub ( f ) ;
return f . key . startsWith ( "ui." ) ? ( f . writes || [ ] ) . join ( ", " ) : f . key ;
}
function fieldRow ( f , card , gid ) {
if ( f . control === "inverter_list" ) return cardList ( f , gid ) ;
const lid = "sf" + ( ++ uid ) ;
const keys = keyText ( f , card ) ;
const msg = h ( "div" , { class : "msgs" } ) ;
const make = CONTROLS [ f . control ] || text ;
const row = h ( "div" , { class : "f" + ( f . control === "toggle" ? " inline" : "" ) , "data-key" : f . key , "data-card" : card } ,
h ( "div" , { class : "head" } , h ( "div" , { class : "lbl" , id : lid } , f . label , f . unit && f . control !== "slider" && f . control !== "number" && f . control !== "locked" ? " (" + f . unit + ")" : "" ) ,
keys ? h ( "div" , { class : "key" } , keys ) : null ) ,
h ( "div" , { class : "ctl" } , make ( f , card , lid ) ) ,
f . help ? h ( "div" , { class : "help" } , f . help ) : null , msg ) ;
ROWS . push ( { f , card , el : row , msg , gid , search : ( f . label + " " + keys + " " + f . key + " " + ( f . help || "" ) ) . toLowerCase ( ) } ) ;
return row ;
}
function blankCard ( f , first ) {
const out = { } ;
for ( const s of f . fields ) {
const k = sub ( s ) ;
out [ k ] = s . control === "slider" && first && first [ k ] != null ? first [ k ] : s . type === "boolean" ? false : s . control === "number" ? null : "" ;
}
return out ;
}
function cardList ( f , gid ) {
const wrap = h ( "div" , { class : "invs" , "data-key" : f . key } ) ;
const msg = h ( "div" , { class : "msgs" } ) ;
const cards = V [ f . key ] ;
if ( ! Array . isArray ( cards ) ) {
wrap . append ( h ( "div" , { class : "msg info" } , W . raw _roster . description ) , msg ) ;
} else {
cards . forEach ( ( c , i ) => {
const box = h ( "div" , { class : "inv" } , h ( "div" , { class : "hd" } ,
h ( "span" , { class : "grow" } , c . name || W . inverter + " " + ( i + 1 ) ) , h ( "span" , { class : "key" } , f . key + "[" + i + "]" ) ,
cards . length > 1 ? h ( "button" , { type : "button" , class : "btn link tiny" , onclick : ( ) => {
V [ f . key ] . splice ( i , 1 ) ; V [ f . count ] = V [ f . key ] . length ; put ( f , null , V [ f . key ] , true ) ;
} } , W . remove ) : null ) ) ;
for ( const s of f . fields ) box . append ( fieldRow ( s , i , gid ) ) ;
wrap . append ( box ) ;
} ) ;
wrap . append ( msg , h ( "button" , { type : "button" , class : "btn" , onclick : ( ) => {
V [ f . key ] . push ( blankCard ( f , V [ f . key ] [ 0 ] ) ) ; Object . assign ( V , f . grow ) ; V [ f . count ] = V [ f . key ] . length ;
put ( f , null , V [ f . key ] , true ) ;
} } , "+ " + W . add _inverter ) ) ;
}
ROWS . push ( { f , card : null , el : wrap , msg , gid , list : true , search : ( f . label + " " + f . key + " " + ( f . help || "" ) ) . toLowerCase ( ) } ) ;
return wrap ;
}
const chev = ( ) => h ( "span" , { class : "chev" , "aria-hidden" : "true" } , "› " ) ;
function render ( ) {
const host = $ ( "#groups" ) ;
const y = window . scrollY ;
host . replaceChildren ( ) ;
ROWS . length = 0 ;
for ( const g of M . layout . groups ) {
const d = h ( "details" , { class : "grp" , "data-g" : g . id } ,
h ( "summary" , { } , h ( "span" , { class : "grow" } , h ( "strong" , { } , g . name ) , h ( "span" , { class : "small muted gdesc" } , g . description ) ) ,
h ( "span" , { class : "gchips" } , h ( "span" , { class : "gbadge" } ) , ... g . applies . map ( ( a ) => h ( "span" , { class : "chip applies" } , a ) ) ) , chev ( ) ) ) ;
d . open = OPEN . has ( g . id ) ;
d . addEventListener ( "toggle" , ( ) => { if ( ! $ ( "#set-search" ) . value ) ( d . open ? OPEN . add ( g . id ) : OPEN . delete ( g . id ) ) ; } ) ;
const body = h ( "div" , { class : "body" } ) ;
if ( g . support _only ) body . append ( h ( "div" , { class : "msg warn" , role : "note" } , W . support . description ) ) ;
for ( const p of g . parts ) {
const pid = g . id + "." + p . id ;
let box ;
if ( p . collapsed ) {
box = h ( "details" , { class : "sub part" } , h ( "summary" , { } , p . name ) ) ;
box . open = OPEN . has ( pid ) ;
box . addEventListener ( "toggle" , ( ) => { if ( ! $ ( "#set-search" ) . value ) ( box . open ? OPEN . add ( pid ) : OPEN . delete ( pid ) ) ; } ) ;
} else {
box = h ( "div" , { class : "part" } , p . name ? h ( "div" , { class : "subhead" } , p . name ) : null ) ;
}
if ( p . description ) box . append ( h ( "p" , { class : "small muted" , style : "margin:4px 0" } , p . description ) ) ;
for ( const f of p . fields ) box . append ( fieldRow ( f , null , g . id ) ) ;
body . append ( box ) ;
}
if ( g . id === "maintenance" ) {
body . append ( h ( "div" , { class : "part" } , h ( "div" , { class : "subhead" } , W . cycle . name ) ,
h ( "p" , { class : "small muted" , style : "margin:4px 0" } , W . cycle . description ) ,
h ( "div" , { class : "row" } ,
h ( "button" , { type : "button" , class : "btn" , onclick : ( ) => maint ( "maint_start" ) } , ROOT . home . control . maint _start ) ,
h ( "button" , { type : "button" , class : "btn danger" , onclick : ( ) => maint ( "maint_abort" ) } , ROOT . home . control . maint _abort ) ) ) ) ;
}
d . append ( body ) ;
host . append ( d ) ;
}
paint ( ) ;
window . scrollTo ( 0 , y ) ;
}
/* ---------------- messages, visibility, search, badges ---------------- */
const ICON = { bad : "× " , warn : "!" , info : "i" } ;
const RANK = { bad : 0 , warn : 1 , info : 2 } ;
function mine ( r , m ) {
if ( m . field !== r . f . key ) return false ;
if ( r . list || r . card == null ) return true ;
return m . card == null ? r . card === 0 : m . card === r . card ;
}
function paint ( ) {
const q = $ ( "#set-search" ) . value . trim ( ) . toLowerCase ( ) ;
const placed = new Set ( ) ;
for ( const r of ROWS ) {
r . visible = visible ( r . f ) ;
const msgs = MSG . filter ( ( m , i ) => { const hit = mine ( r , m ) ; if ( hit && r . visible ) placed . add ( i ) ; return hit ; } )
. sort ( ( a , z ) => RANK [ a . tier ] - RANK [ z . tier ] ) ;
r . tiers = new Set ( msgs . map ( ( m ) => m . tier ) ) ;
r . msg . replaceChildren ( ... msgs . map ( ( m ) => h ( "div" , { class : "msg " + m . tier } ,
h ( "span" , { class : "mi" , "aria-hidden" : "true" } , ICON [ m . tier ] || "i" ) ,
h ( "span" , { } , h ( "span" , { class : "sr" } , W . tier [ m . tier ] + ": " ) , m . text ) ) ) ) ;
r . el . classList . toggle ( "has-bad" , r . tiers . has ( "bad" ) ) ;
// A card list counts as one change when cards were added or removed;
// an edit inside a card counts on its own row.
r . changed = r . list ? ! same ( ( get ( r . f ) || [ ] ) . length , ( valueIn ( ORIG , r . f ) || [ ] ) . length )
: ! same ( get ( r . f , r . card ) , valueIn ( ORIG , r . f , r . card ) ) ;
r . el . classList . toggle ( "changed" , r . changed && ! r . list ) ;
}
// Search: a row shows when it is visible and matches. A card list shows
// while any of its rows does.
for ( const r of ROWS ) if ( ! r . list ) r . el . hidden = ! ( r . visible && ( ! q || r . search . includes ( q ) ) ) ;
for ( const r of ROWS ) if ( r . list ) r . el . hidden = ! ! q && ! r . search . includes ( q ) && ! ROWS . some ( ( s ) => ! s . list && s . card != null && ! s . el . hidden ) ;
let any = false ;
document . querySelectorAll ( "#groups details.grp" ) . forEach ( ( d ) => {
const rows = ROWS . filter ( ( r ) => r . gid === d . dataset . g ) ;
d . querySelectorAll ( ".part" ) . forEach ( ( p ) => {
const inside = rows . filter ( ( r ) => p . contains ( r . el ) ) ;
if ( inside . length ) p . hidden = inside . every ( ( r ) => r . el . hidden ) ;
} ) ;
const hit = rows . some ( ( r ) => ! r . el . hidden ) ;
any = any || hit ;
d . hidden = ! ! q && ! hit ;
if ( q && hit ) { d . open = true ; d . querySelectorAll ( "details.sub" ) . forEach ( ( s ) => { s . open = true ; } ) ; }
const shownRows = rows . filter ( ( r ) => r . visible && ! r . list ) ;
const bad = shownRows . filter ( ( r ) => r . tiers . has ( "bad" ) ) . length + rows . filter ( ( r ) => r . list && r . tiers . has ( "bad" ) ) . length ;
const warn = shownRows . filter ( ( r ) => ! r . tiers . has ( "bad" ) && r . tiers . has ( "warn" ) ) . length ;
const changed = rows . filter ( ( r ) => r . changed ) . length ;
d . querySelector ( ".gbadge" ) . replaceChildren ( ... [
bad ? h ( "span" , { class : "chip bad" } , bad + " " + W . to _fix ) : null ,
warn ? h ( "span" , { class : "chip rs" } , warn + " " + W . to _check ) : null ,
changed ? h ( "span" , { class : "chip changed" } , changed + " " + W . changed ) : null ] . filter ( Boolean ) ) ;
} ) ;
$ ( "#set-nomatch" ) . hidden = ! q || any ;
// A sentence shown beside any field is not repeated here.
const shownText = new Set ( MSG . filter ( ( m , i ) => placed . has ( i ) ) . map ( ( m ) => m . text ) ) ;
const other = MSG . filter ( ( m , i ) => ! shownText . has ( m . text ) && MSG . findIndex ( ( x ) => x . text === m . text ) === i ) ;
if ( DEVICE ) other . push ( { tier : "info" , text : DEVICE } ) ;
$ ( "#set-other" ) . hidden = ! other . length ;
$ ( "#set-other-list" ) . replaceChildren ( ... other . map ( ( m ) => h ( "li" , { class : "c-" + ( { bad : "bad" , warn : "warn" } [ m . tier ] || "info" ) } ,
h ( "span" , { class : "sr" } , W . tier [ m . tier ] + ": " ) , ( ICON [ m . tier ] || "i" ) + " " + m . text ) ) ) ;
savebar ( ) ;
}
function dirtyCount ( ) {
const seen = new Set ( ) ;
for ( const r of ROWS ) if ( r . changed ) seen . add ( r . f . key + "#" + r . card ) ;
return seen . size ;
}
function savebar ( ) {
const n = V ? dirtyCount ( ) : 0 ;
$ ( "#savebar" ) . hidden = ! ( active && n ) ;
$ ( "#sb-text" ) . textContent = n + " " + ( n === 1 ? W . savebar . one : W . savebar . many ) ;
}
$ ( "#set-search" ) . addEventListener ( "input" , ( ) => { if ( V ) paint ( ) ; } ) ;
/* ---------------- talking to the add-on ---------------- */
let vtimer = null , vseq = 0 ;
function queueValidate ( ) { clearTimeout ( vtimer ) ; vtimer = setTimeout ( validate , 400 ) ; }
async function validate ( ) {
const seq = ++ vseq ;
try {
const r = await fetch ( "config/validate" , { method : "POST" , headers : { "Content-Type" : "application/json" } ,
body : JSON . stringify ( { model : V } ) } ) ;
const b = await r . json ( ) ;
if ( seq !== vseq ) return ;
MSG = b . messages || [ ] ;
DEVICE = b . device _note || "" ;
paint ( ) ;
} catch ( e ) { /* the next change tries again */ }
}
function banner ( level , title , sub , button , onClick ) {
const host = $ ( "#set-banner" ) ;
if ( ! title ) { host . replaceChildren ( ) ; return ; }
host . replaceChildren ( h ( "div" , { class : "banner " + ( { ok : "b-ok" , bad : "b-bad" } [ level ] || "b-warn" ) } ,
h ( "span" , { class : "ic" , "aria-hidden" : "true" } , { ok : "✓" , bad : "× " } [ level ] || "!" ) ,
h ( "span" , { } , title , h ( "small" , { } , sub , button ? [ " " , h ( "button" , { type : "button" , class : "btn link" , onclick : onClick } , button ) ] : null ) ) ) ) ;
}
function restart ( ) {
ask ( W . dialog , async ( ) => {
try {
await fetch ( "config/restart" , { method : "POST" , headers : { "Content-Type" : "application/json" } ,
body : JSON . stringify ( { confirm : "restart" } ) } ) ;
} catch ( e ) { /* the add-on stops answering while it restarts */ }
toast ( W . toast . restarting . description ) ;
} ) ;
}
async function load ( after ) {
let m ;
try {
const r = await fetch ( "config/model?lang=en" , { cache : "no-store" } ) ;
if ( ! r . ok ) throw new Error ( "model " + r . status ) ;
m = await r . json ( ) ;
if ( ! m . layout || ! m . ui ) throw new Error ( "no layout" ) ;
} catch ( e ) {
$ ( "#groups" ) . replaceChildren ( ) ;
$ ( "#set-search" ) . hidden = true ;
banner ( "bad" , W . load _failed . name , W . load _failed . description ) ;
return ;
}
M = m ; V = clone ( m . ui ) ; ORIG = clone ( m . ui ) ; FP = m . fingerprint ; MSG = [ ] ; DEVICE = "" ;
$ ( "#set-search" ) . hidden = false ;
FILLED = { } ;
LIST = null ;
for ( const g of M . layout . groups ) for ( const p of g . parts ) for ( const f of p . fields ) {
if ( f . fills ) FILLED [ f . fills ] = f ;
if ( f . control === "inverter_list" ) LIST = f ;
}
if ( ! OPEN . size && M . layout . groups . length ) OPEN . add ( M . layout . groups [ 0 ] . id ) ;
if ( after === "saved" ) banner ( "ok" , W . saved . name , W . saved . description , W . restart , restart ) ;
else if ( ( m . running _differs || [ ] ) . length ) banner ( "warn" , W . differs . name , W . differs . description , W . restart , restart ) ;
else banner ( ) ;
render ( ) ;
validate ( ) ;
}
function firstBad ( ) {
const r = ROWS . find ( ( x ) => x . tiers && x . tiers . has ( "bad" ) && x . visible ) ;
if ( ! r ) { if ( ! $ ( "#set-other" ) . hidden ) $ ( "#set-other" ) . scrollIntoView ( { block : "center" } ) ; return ; }
for ( let p = r . el . parentElement ; p ; p = p . parentElement ) if ( p . tagName === "DETAILS" ) p . open = true ;
r . el . scrollIntoView ( { block : "center" } ) ;
const input = r . el . querySelector ( "input,select,button" ) ;
if ( input ) input . focus ( { preventScroll : true } ) ;
}
async function save ( ) {
const btn = $ ( "#sb-save" ) ;
btn . disabled = true ; btn . textContent = W . savebar . saving ;
try {
clearTimeout ( vtimer ) ;
await validate ( ) ;
if ( MSG . some ( ( m ) => m . tier === "bad" ) ) { toast ( W . toast . blocked . description ) ; firstBad ( ) ; return ; }
const r = await fetch ( "config/save" , { method : "POST" , headers : { "Content-Type" : "application/json" } ,
body : JSON . stringify ( { model : V , fingerprint : FP } ) } ) ;
const b = await r . json ( ) . catch ( ( ) => ( { } ) ) ;
if ( r . ok ) { toast ( W . toast . saved ) ; await load ( "saved" ) ; return ; }
if ( r . status === 409 && b . kind === "stale" ) {
FP = b . fingerprint || FP ;
const names = ( b . changed _fields || [ ] ) . map ( ( k ) => { const f = findField ( k ) ; return f ? f . label : k ; } ) ;
banner ( "bad" , W . stale . name , W . stale . description + ( names . length ? " " + names . join ( ", " ) + "." : "" ) ) ;
return ;
}
if ( r . status === 422 ) { MSG = b . messages || [ ] ; paint ( ) ; toast ( W . toast . blocked . description ) ; firstBad ( ) ; return ; }
toast ( W . toast . failed ) ;
} catch ( e ) { toast ( W . toast . failed ) ; }
finally { btn . disabled = false ; btn . textContent = W . savebar . save ; }
}
$ ( "#sb-save" ) . addEventListener ( "click" , save ) ;
$ ( "#sb-discard" ) . addEventListener ( "click" , ( ) => {
V = clone ( ORIG ) ; UNLOCKED . clear ( ) ; render ( ) ; validate ( ) ; toast ( W . toast . discarded ) ;
if ( $ ( "#set-banner .b-bad" ) ) banner ( ) ;
} ) ;
addEventListener ( "beforeunload" , ( e ) => { if ( V && dirtyCount ( ) ) { e . preventDefault ( ) ; e . returnValue = "" ; } } ) ;
document . addEventListener ( "ux13:tab" , ( e ) => {
active = e . detail === "settings" ;
if ( active && ! M ) load ( ) ; else savebar ( ) ;
} ) ;
// app.js chose the first tab before this file ran.
if ( $ ( "#t-set" ) . getAttribute ( "aria-selected" ) === "true" ) { active = true ; load ( ) ; }
} ) ( ) ;