@@ -5,7 +5,9 @@
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. */
data is set as text, never as HTML.
P4: the Setup tab's wizard is the same page model, drawn one step at a
time from each field's `wizard` step, and saved through the same routes. */
( function ( ) {
const ROOT = JSON . parse ( document . getElementById ( "ui-text" ) . textContent ) ;
const W = ROOT . settings ;
@@ -28,7 +30,8 @@
let DEVICE = "" ;
let LIST = null ; // the inverter list field
let FILLED = { } ; // field key -> the preset field that fills it
let active = false ;
let view = null ; // "settings" or "setup": the tab drawing V
let STEPS = { } ; // wizard step -> refusals that block it
let uid = 0 ;
const ROWS = [ ] ;
const OPEN = new Set ( ) ;
@@ -60,7 +63,7 @@
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 ( ) ;
if ( redraw || f . fills ) redrawView ( ) ; else paint ( ) ;
queueValidate ( ) ;
}
function findField ( key ) {
@@ -290,6 +293,7 @@
/* ---------------- rows, cards and groups ---------------- */
function keyText ( f , card ) {
if ( view === "setup" ) return "" ; // the wizard shows no keys
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 ;
}
@@ -324,7 +328,7 @@
} 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 + "]" ) ,
h ( "span" , { class : "grow" } , c . name || W . inverter + " " + ( i + 1 ) ) , view === "setup" ? null : 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 ;
// Back to one card, the stored shape returns: a flat inverter stays flat.
@@ -393,7 +397,7 @@
return m . card == null ? r . card === 0 : m . card === r . card ;
}
function paint ( ) {
const q = $ ( "#set-search" ) . value . trim ( ) . toLowerCase ( ) ;
const q = view === "setup" ? "" : $ ( "#set-search" ) . value . trim ( ) . toLowerCase ( ) ;
const placed = new Set ( ) ;
for ( const r of ROWS ) {
r . visible = visible ( r . f ) ;
@@ -451,7 +455,7 @@
}
function savebar ( ) {
const n = V ? dirtyCount ( ) : 0 ;
$ ( "#savebar" ) . hidden = ! ( active && n ) ;
$ ( "#savebar" ) . hidden = ! ( view === "settings" && n ) ;
$ ( "#sb-text" ) . textContent = n + " " + ( n === 1 ? W . savebar . one : W . savebar . many ) ;
}
$ ( "#set-search" ) . addEventListener ( "input" , ( ) => { if ( V ) paint ( ) ; } ) ;
@@ -459,17 +463,21 @@
/* ---------------- talking to the add-on ---------------- */
let vtimer = null , vseq = 0 ;
function queueValidate ( ) { clearTimeout ( vtimer ) ; vtimer = setTimeout ( validate , 400 ) ; }
async function validate ( ) {
// `extra` asks for more, e.g. the wizard's live checks. Returns the answer,
// or null when it failed or a newer check overtook it.
async function validate ( extra ) {
const seq = ++ vseq ;
try {
const r = await fetch ( "config/validate" , { method : "POST" , headers : { "Content-Type" : "application/json" } ,
body : JSON . stringify ( { model : V } ) } ) ;
body : JSON . stringify ( Object . assign ( { model : V } , extra || { } ) ) } ) ;
const b = await r . json ( ) ;
if ( seq !== vseq ) return ;
if ( seq !== vseq ) return null ;
MSG = b . messages || [ ] ;
DEVICE = b . device _note || "" ;
STEPS = b . steps || { } ;
paint ( ) ;
} catch ( e ) { /* the next change tries again */ }
return b ;
} catch ( e ) { return null ; /* the next change tries again */ }
}
function banner ( level , title , sub , button , onClick ) {
const host = $ ( "#set-banner" ) ;
@@ -513,7 +521,7 @@
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 ( ) ;
if ( view === "setup" ) drawStep ( ) ; else render ( ) ;
validate ( ) ;
}
function firstBad ( ) {
@@ -524,6 +532,20 @@
const input = r . el . querySelector ( "input,select,button" ) ;
if ( input ) input . focus ( { preventScroll : true } ) ;
}
// The one save path, for the Installer view and the wizard. After a stale
// refusal the page holds the new token, so a second press saves.
async function postSave ( extra ) {
const r = await fetch ( "config/save" , { method : "POST" , headers : { "Content-Type" : "application/json" } ,
body : JSON . stringify ( Object . assign ( { model : V , fingerprint : FP } , extra || { } ) ) } ) ;
const b = await r . json ( ) . catch ( ( ) => ( { } ) ) ;
if ( r . status === 409 && b . kind === "stale" ) FP = b . fingerprint || FP ;
return { r , b } ;
}
// The settings another surface changed (not this page's own edits).
function staleText ( b ) {
const names = ( b . changed _fields || [ ] ) . map ( ( k ) => { const f = findField ( k ) ; return f ? f . label : k ; } ) ;
return W . stale . description + ( names . length ? " " + names . join ( ", " ) + "." : "" ) ;
}
async function save ( ) {
const btn = $ ( "#sb-save" ) ;
btn . disabled = true ; btn . textContent = W . savebar . saving ;
@@ -531,14 +553,10 @@
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 ( ( ) => ( { } ) ) ;
const { r , b } = await postSave ( ) ;
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 ( ", " ) + "." : "" ) ) ;
banner ( "bad" , W . stale . name , staleText ( b ) ) ;
return ;
}
if ( r . status === 422 ) { MSG = b . messages || [ ] ; paint ( ) ; toast ( W . toast . blocked . description ) ; firstBad ( ) ; return ; }
@@ -552,10 +570,234 @@
await load ( ) ; toast ( W . toast . discarded ) ;
} ) ;
addEventListener ( "beforeunload" , ( e ) => { if ( V && dirtyCount ( ) ) { e . preventDefault ( ) ; e . returnValue = "" ; } } ) ;
/* ---------------- the setup wizard (P4) ---------------- */
const WS = W . setup ;
const WZ = { step : 1 , kettle : null , shadowOn : true , done : "" , checked : null } ;
function redrawView ( ) { if ( view === "setup" ) drawStep ( ) ; else render ( ) ; }
function allFields ( ) {
const out = [ ] ;
for ( const g of M . layout . groups ) for ( const p of g . parts ) for ( const f of p . fields ) out . push ( f ) ;
return out ;
}
const say = ( text , vars ) => Object . keys ( vars || { } ) . reduce ( ( t , k ) => t . replace ( "{" + k + "}" , vars [ k ] ) , text ) ;
function watts ( w , signed ) {
const n = String ( Math . round ( Math . abs ( w ) ) ) . replace ( /\B(?=(\d{3})+(?!\d))/g , " " ) ;
return ( signed && w < 0 ? "-" : "" ) + n + " W" ;
}
function line ( tier , text ) {
const icon = { ok : "✓" , bad : "× " , warn : "!" , info : "i" } [ tier ] || "i" ;
return h ( "li" , { } , h ( "span" , { class : "sev " + tier , "aria-hidden" : "true" } , icon ) ,
h ( "span" , { } , h ( "span" , { class : "sr" } , ( W . tier [ tier ] || "" ) + ": " ) , text ) ) ;
}
// Step 1's kettle test. The table is app/uilayout.py's KETTLE; this is
// uipage.kettle() (test_setup.py grades that one) on the page.
function kettleSource ( ) { const K = M . setup . kettle ; return K . sources [ V [ K . source ] ] || null ; }
function kettleSig ( src ) { return JSON . stringify ( [ V [ M . setup . kettle . source ] , src . read . map ( ( [ k ] ) => V [ k ] || "" ) ] ) ; }
function kettleOutcome ( src , before , after ) {
const d = after - before ;
if ( Math . abs ( d ) < M . setup . kettle . min _w ) return { say : "none" , sets : null , d } ;
const o = src [ d > 0 ? "up" : "down" ] ;
return { say : o . say , sets : o . sets , d } ;
}
function kettleOk ( ) {
const src = kettleSource ( ) ;
return ! src || ! ! ( WZ . kettle && WZ . kettle . sig === kettleSig ( src ) && WZ . kettle . ok ) ;
}
async function readMeter ( src ) {
let total = 0 ;
for ( const [ key , sign ] of src . read ) {
if ( ! V [ key ] ) return null ;
const r = await fetch ( "ha/entities?entity=" + encodeURIComponent ( V [ key ] ) , { cache : "no-store" } )
. then ( ( x ) => ( x . ok ? x . json ( ) : null ) ) . catch ( ( ) => null ) ;
if ( ! r || r . watts == null ) return null ;
total += sign * r . watts ;
}
return total ;
}
function kettleBox ( ) {
const src = kettleSource ( ) ;
const box = h ( "div" , { class : "f kettle" } , h ( "div" , { class : "lbl" } , WS . kettle . name ) ) ;
if ( ! src ) { box . append ( h ( "div" , { class : "help" } , WS . kettle . self . description ) ) ; return box ; }
const sig = kettleSig ( src ) ;
if ( WZ . kettle && WZ . kettle . sig !== sig ) WZ . kettle = null ; // another meter: test again
const k = WZ . kettle || { sig } ;
box . append ( h ( "div" , { class : "help" } , WS . kettle . description ) ) ;
const tone = { ok : "ok" , flip : "info" , none : "warn" , unread : "bad" , skipped : "warn" } [ k . say ] || "bad" ;
const words = k . say ? WS . kettle [ k . say ] : k . before != null ? WS . kettle . before : null ;
if ( words ) box . append ( h ( "div" , { class : "msg " + ( k . say ? tone : "info" ) , role : "status" } ,
say ( words . description , { w : k . before != null ? watts ( k . before , true ) : "" , d : k . d != null ? watts ( k . d ) : "" } ) ) ) ;
const busy = ( b ) => { b . disabled = true ; } ;
const read = h ( "button" , { type : "button" , class : "btn" } , k . before != null && ! k . ok ? WS . kettle . again : WS . kettle . read ) ;
read . addEventListener ( "click" , async ( ) => {
busy ( read ) ;
const sig = kettleSig ( src ) ; // the entity may have changed since drawing
if ( k . sig !== sig ) { k . before = null ; k . ok = false ; k . sig = sig ; }
const w = await readMeter ( src ) ;
if ( w == null ) WZ . kettle = Object . assign ( { } , k , { sig , say : "unread" } ) ;
else if ( k . before == null || k . ok ) WZ . kettle = { sig , before : w } ;
else {
const o = kettleOutcome ( src , k . before , w ) ;
WZ . kettle = { sig , before : o . say === "none" ? k . before : null , say : o . say , d : o . d , ok : o . sets != null } ;
if ( o . sets ) { Object . assign ( V , o . sets ) ; queueValidate ( ) ; }
}
drawStep ( ) ;
} ) ;
const row = h ( "div" , { class : "row" } , read ) ;
if ( ! k . ok ) row . append ( h ( "button" , { type : "button" , class : "btn link" , onclick : ( ) => {
WZ . kettle = { sig : kettleSig ( src ) , say : "skipped" , ok : true , skipped : true } ; drawStep ( ) ;
} } , WS . kettle . skip ) ) ;
box . append ( row ) ;
return box ;
}
// Step 6: the shadow switch, the checks, and Save and restart.
function applyShadow ( ) {
const s = M . setup . shadow ;
V [ s . field ] = WZ . shadowOn ? s . on : ( ORIG [ s . field ] !== s . on ? ORIG [ s . field ] : s . off ) ;
}
function shadowRow ( ) {
const lid = "wzs" ;
const b = h ( "button" , { type : "button" , class : "switch" , role : "switch" , "aria-checked" : String ( WZ . shadowOn ) , "aria-labelledby" : lid } ) ;
b . addEventListener ( "click" , ( ) => { WZ . shadowOn = ! WZ . shadowOn ; applyShadow ( ) ; queueValidate ( ) ; drawStep ( ) ; } ) ;
return h ( "div" , { class : "f inline" } ,
h ( "div" , { } , h ( "div" , { class : "lbl" , id : lid } , WS . shadow . name ) , h ( "div" , { class : "help" } , WS . shadow . description ) ) ,
h ( "div" , { class : "ctl" } , b ) ) ;
}
async function runChecks ( ) {
const list = $ ( "#wz-checks" ) ;
if ( list ) list . replaceChildren ( line ( "info" , WS . check . running . description ) ) ;
clearTimeout ( vtimer ) ;
const b = await validate ( { setup : true } ) ;
WZ . checked = b ;
if ( ! list || ! list . isConnected ) return b ;
if ( ! b ) { list . replaceChildren ( line ( "bad" , WS . check . failed . description ) ) ; return b ; }
const items = [ ] , seen = new Set ( ) ;
const add = ( tier , text ) => { if ( ! seen . has ( text ) ) { seen . add ( text ) ; items . push ( [ tier , text ] ) ; } } ;
const bad = ( b . messages || [ ] ) . filter ( ( m ) => m . tier === "bad" ) ;
if ( ! bad . length ) add ( "ok" , WS . check . pass . description ) ;
for ( const m of bad ) add ( "bad" , m . text ) ;
for ( const m of b . setup || [ ] ) add ( m . tier , m . text ) ;
if ( b . device _note ) add ( "info" , b . device _note ) ;
const src = kettleSource ( ) ;
if ( ! src ) add ( "info" , WS . kettle . self . description ) ;
else if ( WZ . kettle && WZ . kettle . skipped ) add ( "warn" , WS . kettle . skipped . description ) ;
else if ( kettleOk ( ) ) add ( "ok" , WS . kettle . tested . description ) ;
const rank = { bad : 0 , warn : 1 , ok : 2 , info : 3 } ;
items . sort ( ( a , z ) => rank [ a [ 0 ] ] - rank [ z [ 0 ] ] ) ;
list . replaceChildren ( ... items . map ( ( [ t , x ] ) => line ( t , x ) ) ) ;
WZ . blocked = items . some ( ( [ t ] ) => t === "bad" ) ;
return b ;
}
function finalBox ( ) {
const again = h ( "button" , { type : "button" , class : "btn" , onclick : runChecks } , WS . check . again ) ;
setTimeout ( runChecks , 0 ) ;
return [ h ( "div" , { class : "subhead" } , WS . check . name ) ,
h ( "ul" , { class : "plain checklist small" , id : "wz-checks" , "aria-live" : "polite" } ) , h ( "div" , { class : "row" } , again ) ,
shadowRow ( ) ] ;
}
function wzBanner ( level , title , sub , button , onClick ) {
const host = $ ( "#wz-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 ) ) ) ) ;
}
async function finish ( ) {
const b = await runChecks ( ) ;
if ( ! b ) { toast ( WS . check . failed . description ) ; return ; }
if ( WZ . blocked ) { toast ( WS . check . blocked . description ) ; return ; }
const shadow = V [ M . setup . shadow . field ] === M . setup . shadow . on ;
ask ( shadow ? WS . dialog : WS . dialog _live , async ( ) => {
wzBanner ( ) ;
let res ;
try { res = await postSave ( { setup : "done" } ) ; } catch ( e ) { toast ( W . toast . failed ) ; return ; }
const { r , b : body } = res ;
if ( r . status === 409 && body . kind === "stale" ) { wzBanner ( "bad" , W . stale . name , staleText ( body ) ) ; return ; }
if ( r . status === 422 ) {
MSG = body . messages || [ ] ; STEPS = body . steps || { } ;
const first = Object . keys ( STEPS ) . map ( Number ) . sort ( ( a , z ) => a - z ) . find ( ( n ) => STEPS [ n ] > 0 ) ;
if ( first ) WZ . step = first ;
drawStep ( ) ; toast ( W . toast . blocked . description ) ; firstBad ( ) ;
return ;
}
if ( ! r . ok ) { toast ( W . toast . failed ) ; return ; }
let restarted = true ;
try {
const rr = await fetch ( "config/restart" , { method : "POST" , headers : { "Content-Type" : "application/json" } ,
body : JSON . stringify ( { confirm : "restart" } ) } ) ;
restarted = rr . ok ;
} catch ( e ) { /* the add-on stops answering while it restarts */ }
WZ . done = restarted ? ( shadow ? "done" : "done_live" ) : "restart_failed" ;
drawStep ( ) ;
if ( ! restarted ) await load ( "saved" ) ; // still running: read what is stored
} ) ;
}
function drawDone ( ) {
const w = WS [ WZ . done ] ;
$ ( "#wz-count" ) . textContent = "" ;
$ ( "#wz-steps" ) . replaceChildren ( ) ;
$ ( "#wz-desc" ) . textContent = "" ;
$ ( "#wz-nav" ) . hidden = true ;
ROWS . length = 0 ;
if ( WZ . done === "restart_failed" ) wzBanner ( "warn" , w . name , w . description , W . restart , restart ) ;
else wzBanner ( "ok" , w . name , w . description ) ;
$ ( "#wz-body" ) . replaceChildren ( h ( "a" , { class : "btn primary" , href : "#home" } , WS . home ) ) ;
}
function drawStep ( ) {
if ( ! M ) return ;
if ( WZ . done ) { drawDone ( ) ; return ; }
const S = M . setup , n = WZ . step , words = WS . steps [ n ] ;
$ ( "#wz-nav" ) . hidden = false ;
$ ( "#wz-title" ) . textContent = words . name ;
$ ( "#wz-count" ) . textContent = say ( WS . step , { n , total : S . steps } ) ;
$ ( "#wz-steps" ) . replaceChildren ( ... Array . from ( { length : S . steps } , ( _x , i ) =>
h ( "i" , { class : i + 1 < n ? "done" : i + 1 === n ? "cur" : null } ) ) ) ;
$ ( "#wz-desc" ) . textContent = words . description ;
ROWS . length = 0 ;
const body = [ ] ;
if ( n === 1 && ! M . setup _needed ) body . push ( h ( "div" , { class : "msg info" } , WS . done _before . description ) ) ;
if ( n === S . final ) applyShadow ( ) ;
for ( const f of allFields ( ) ) if ( f . wizard === n && f . key !== S . shadow . field ) body . push ( fieldRow ( f , null , "wz" ) ) ;
if ( n === 1 ) body . push ( kettleBox ( ) ) ;
if ( n === 5 ) body . push ( h ( "p" , { class : "small muted" } , WS . modes _later . description ) ) ;
if ( n === S . final ) body . push ( ... finalBox ( ) ) ;
$ ( "#wz-body" ) . replaceChildren ( ... body ) ;
$ ( "#wz-back" ) . textContent = n === 1 ? WS . not _now : WS . back ;
$ ( "#wz-next" ) . textContent = n === S . final ? WS . finish : S . optional . includes ( n ) ? WS . skip : WS . next ;
paint ( ) ;
}
$ ( "#wz-back" ) . addEventListener ( "click" , ( ) => {
if ( WZ . step === 1 ) { location . hash = "#home" ; return ; }
WZ . step -= 1 ; drawStep ( ) ; window . scrollTo ( 0 , 0 ) ;
} ) ;
$ ( "#wz-next" ) . addEventListener ( "click" , async ( ) => {
if ( ! M ) return ;
const btn = $ ( "#wz-next" ) ;
btn . disabled = true ;
try {
const n = WZ . step ;
if ( n === M . setup . final ) { await finish ( ) ; return ; }
clearTimeout ( vtimer ) ;
const b = await validate ( ) ;
if ( ! b ) { toast ( W . toast . failed ) ; return ; }
if ( n === 1 && ! kettleOk ( ) ) { toast ( WS . kettle . needed . description ) ; return ; }
if ( ( STEPS [ n ] || 0 ) > 0 ) { toast ( W . toast . blocked . description ) ; firstBad ( ) ; return ; }
WZ . step = n + 1 ; drawStep ( ) ; window . scrollTo ( 0 , 0 ) ;
} finally { btn . disabled = false ; }
} ) ;
document . addEventListener ( "ux13:tab" , ( e ) => {
active = e . detail === "settings" ;
if ( active && ! M ) load ( ) ; else savebar ( ) ;
view = e . detail === "settings" || e . detail === "setup" ? e . detail : null ;
if ( view && ! M ) load ( ) ; else if ( view ) { redrawView ( ) ; validate ( ) ; } else savebar ( ) ;
} ) ;
// app.js chose the first tab before this file ran.
if ( $ ( "#t-set" ) . getAttribute ( "aria-selected" ) === " true") { active = true ; load ( ) ; }
const sel = document . querySelector ( ".tab[aria-selected= true] ") ;
if ( sel && ( sel . dataset . hash === "settings" || sel . dataset . hash === "setup" ) ) { view = sel . dataset . hash ; load ( ) ; }
// First run: opened with no route, on a site the add-on says is new
// (uipage.setup_needed), the page goes to Setup by itself.
else if ( window . UX13 . first ) {
load ( ) . then ( ( ) => { if ( M && M . setup _needed && location . hash === "#home" ) location . hash = "#setup" ; } ) ;
}
} ) ( ) ;