mirror of
https://github.com/Matysh/houseplan-card
synced 2026-09-29 03:09:36 +00:00
- scripts/no-new-private-writes.mjs: смоки и demo/helpers/** не добавляют записей в приватное состояние карточки (присваивание, ++/--, delete по цепочке с сегментом _x; от this — нет) и вызовов _setMode/_openRoomEdit/ _openMarkerDialog/_openSpaceDialog. Зачёт правки по полю, перенос блока — movedLinesByFile из no-new-any; исключение // private-ok: <причина>. --count — остаток на HEAD. Подключён в gate:small и в шаг frontend рядом с no-new-any, с той же базой. - demo/helpers/hp-test.mjs: 10 операций через контрактные хуки и события фикстуры (setMode, setTool, switchSpace, openRoomEdit, openMarkerDialog, openSpaceDialog, setServerConfig, setLayout, input, close); ставится launch*() из demo/serve.mjs. В бандле фасада нет. - demo/srv/demo.html: доставка houseplan_config_updated/_layout_updated, __pushServerConfig/__pushServerLayout; после доставки запись со старым expected_rev — conflict, как у настоящего сервера. - HP_SMOKE_CHECKS=1 печатает имена проверок в finish(). - smoke_area_relocation, smoke_glow, smoke_grid_snap переведены на фасад без потери утверждений; новый smoke_test_facade доказывает каждую операцию. - 7 мутантов, docs/TESTING.md (раздел + правило №6), PROCESS.md §2.7, AGENTS.md. Issue: #629 User-Visible: no
253 lines
14 KiB
HTML
253 lines
14 KiB
HTML
<!doctype html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="utf-8">
|
|
<title>House Plan demo</title>
|
|
<style>
|
|
:root{
|
|
--primary-color:#3ea6ff; --primary-text-color:#e1e1e1; --secondary-text-color:#9aa4ad;
|
|
--card-background-color:#1c2530; --ha-card-background:#1c2530;
|
|
--divider-color:#33404d; --text-primary-color:#fff; --mdc-icon-size:24px;
|
|
--header-height:0px;
|
|
}
|
|
html,body{margin:0;background:#0e1621;color:#e1e1e1;font-family:'Segoe UI',Roboto,Arial,sans-serif;}
|
|
#host{width:780px;margin:0 auto;padding:8px;}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div id="host"></div>
|
|
<script src="/assets/icons.js"></script>
|
|
<script>
|
|
class HaIcon extends HTMLElement{
|
|
static get observedAttributes(){return ['icon'];}
|
|
connectedCallback(){this._r();}
|
|
attributeChangedCallback(){this._r();}
|
|
set icon(v){this.setAttribute('icon',v);}
|
|
get icon(){return this.getAttribute('icon');}
|
|
_r(){
|
|
const p=(window.__ICONS||{})[this.getAttribute('icon')]||'';
|
|
if(!this.shadowRoot) this.attachShadow({mode:'open'});
|
|
// Faithful to HA's ha-icon: host is display:block with a large inherited line-height,
|
|
// which pushes the (smaller) svg off-centre unless the consumer neutralises it.
|
|
this.shadowRoot.innerHTML='<style>:host{display:block;line-height:1.8;'+
|
|
'width:var(--mdc-icon-size,24px);height:var(--mdc-icon-size,24px);'+
|
|
'fill:currentColor;vertical-align:middle}'+
|
|
'svg{width:var(--mdc-icon-size,24px);height:var(--mdc-icon-size,24px);fill:currentColor;'+
|
|
'display:inline-block;vertical-align:baseline}</style>'+
|
|
'<svg viewBox="0 0 24 24"><path d="'+p+'"></path></svg>';
|
|
}
|
|
}
|
|
customElements.define('ha-icon',HaIcon);
|
|
class HaCard extends HTMLElement{
|
|
connectedCallback(){
|
|
if(this.shadowRoot)return;
|
|
this.attachShadow({mode:'open'});
|
|
this.shadowRoot.innerHTML='<style>:host{display:block;background:var(--ha-card-background,#1c2530);'+
|
|
'border-radius:12px;box-shadow:0 2px 8px rgba(0,0,0,.4);overflow:visible;color:var(--primary-text-color)}</style><slot></slot>';
|
|
}
|
|
}
|
|
customElements.define('ha-card',HaCard);
|
|
</script>
|
|
<script type="module">
|
|
let CFG = {
|
|
spaces: [
|
|
{ id:'f1', title:'Ground floor', plan_url:'/assets/f1.svg', plan_aspect:1.25,
|
|
view_box:[0,0,1,1],
|
|
rooms:[
|
|
{id:'r1', name:'Living room', area:'living_room', poly:[[0.04,0.14],[0.55,0.14],[0.55,0.58],[0.04,0.58]]},
|
|
{id:'r2', name:'Kitchen', area:'kitchen', poly:[[0.55,0.14],[0.96,0.14],[0.96,0.46],[0.55,0.46]]},
|
|
{id:'r3', name:'Bedroom', area:'bedroom', poly:[[0.55,0.46],[0.96,0.46],[0.96,0.86],[0.55,0.86]]},
|
|
{id:'r4', name:'Hallway', area:'hallway', poly:[[0.04,0.58],[0.55,0.58],[0.55,0.86],[0.04,0.86]]}
|
|
], segments:[] },
|
|
{ id:'garden', title:'Garden', plan_url:'/assets/garden.svg', plan_aspect:1.4286,
|
|
view_box:[0,0,1,1],
|
|
rooms:[ {id:'g1', name:'Garden', area:'garden', poly:[[0.03,0.178],[0.97,0.178],[0.97,0.822],[0.03,0.822]]} ], segments:[] }
|
|
],
|
|
markers: [],
|
|
settings: {}
|
|
};
|
|
let LAYOUT = {
|
|
d_light1:{s:'f1',x:0.22,y:0.22}, d_lamp:{s:'f1',x:0.42,y:0.5}, d_tv:{s:'f1',x:0.13,y:0.5},
|
|
d_temp:{s:'f1',x:0.33,y:0.35}, d_kettle:{s:'f1',x:0.72,y:0.15}, d_leak:{s:'f1',x:0.88,y:0.34},
|
|
d_bedlight:{s:'f1',x:0.75,y:0.6}, d_window:{s:'f1',x:0.9,y:0.85}, d_lock:{s:'f1',x:0.1,y:0.88},
|
|
d_motion:{s:'f1',x:0.4,y:0.75}, d_mower:{s:'garden',x:0.25,y:0.35}, d_gate:{s:'garden',x:0.73,y:0.22}
|
|
};
|
|
let CFG_REV=1, LAYOUT_REV=1;
|
|
const AREAS={living_room:{area_id:'living_room',name:'Living room'},kitchen:{area_id:'kitchen',name:'Kitchen'},
|
|
bedroom:{area_id:'bedroom',name:'Bedroom'},hallway:{area_id:'hallway',name:'Hallway'},garden:{area_id:'garden',name:'Garden'}};
|
|
function dev(id,name,model,area){return [id,{id,name,model,area_id:area,identifiers:[['demo',id]],config_entries:['ce_demo'],entry_type:null,via_device_id:null,disabled_by:null}];}
|
|
const DEVICES=Object.fromEntries([
|
|
dev('d_light1','Ceiling light','Smart Bulb E27','living_room'),
|
|
dev('d_lamp','Floor lamp','Smart Bulb E14','living_room'),
|
|
dev('d_tv','TV','55U8HQ','living_room'),
|
|
dev('d_temp','Temperature sensor','TH01','living_room'),
|
|
dev('d_kettle','Kettle plug','Smart Plug','kitchen'),
|
|
dev('d_leak','Sink leak sensor','SJCGQ11LM','kitchen'),
|
|
dev('d_bedlight','Bedroom light','Smart Bulb E27','bedroom'),
|
|
dev('d_window','Window sensor','MCCGQ11LM','bedroom'),
|
|
dev('d_lock','Front door lock','Smart Lock S2','hallway'),
|
|
dev('d_motion','Motion sensor','RTCGQ11LM','hallway'),
|
|
dev('d_mower','Robot mower','Automower 305','garden'),
|
|
dev('d_gate','Gate','GDO-4','garden'),
|
|
]);
|
|
const ENTITIES={
|
|
'sun.sun':{entity_id:'sun.sun',device_id:null,platform:'sun',disabled_by:null},
|
|
'camera.static_robot_map':{entity_id:'camera.static_robot_map',device_id:null,platform:'demo',disabled_by:null},
|
|
'light.ceiling':{entity_id:'light.ceiling',device_id:'d_light1',platform:'demo',config_entry_id:'ce_demo',disabled_by:null},
|
|
'light.floor_lamp':{entity_id:'light.floor_lamp',device_id:'d_lamp',platform:'demo',disabled_by:null},
|
|
'media_player.tv':{entity_id:'media_player.tv',device_id:'d_tv',platform:'demo',disabled_by:null},
|
|
'sensor.living_temp':{entity_id:'sensor.living_temp',device_id:'d_temp',platform:'demo',disabled_by:null},
|
|
'switch.kettle':{entity_id:'switch.kettle',device_id:'d_kettle',platform:'demo',disabled_by:null},
|
|
'binary_sensor.sink_leak':{entity_id:'binary_sensor.sink_leak',device_id:'d_leak',platform:'demo',disabled_by:null},
|
|
'light.bedroom':{entity_id:'light.bedroom',device_id:'d_bedlight',platform:'demo',disabled_by:null},
|
|
'binary_sensor.window':{entity_id:'binary_sensor.window',device_id:'d_window',platform:'demo',disabled_by:null},
|
|
'lock.front_door':{entity_id:'lock.front_door',device_id:'d_lock',platform:'demo',disabled_by:null},
|
|
'binary_sensor.hall_motion':{entity_id:'binary_sensor.hall_motion',device_id:'d_motion',platform:'demo',disabled_by:null},
|
|
'vacuum.mower':{entity_id:'vacuum.mower',device_id:'d_mower',platform:'demo',disabled_by:null},
|
|
'cover.gate':{entity_id:'cover.gate',device_id:'d_gate',platform:'demo',disabled_by:null},
|
|
};
|
|
let REGISTRY_ACCESS='full';
|
|
const REGISTRY_LISTENERS={device_registry_updated:new Set(),entity_registry_updated:new Set()};
|
|
// #629: server events, delivered only when a smoke pushes a new revision
|
|
// through __pushServerConfig/__pushServerLayout (test facade setServerConfig/setLayout).
|
|
const SERVER_LISTENERS={houseplan_config_updated:new Set(),houseplan_layout_updated:new Set()};
|
|
const SERVED={config:0,layout:0};
|
|
let PUSHED_CFG_REV=0;
|
|
window.__servedRevision=SERVED;
|
|
const CONNECTION={subscribeEvents:async(cb,event)=>{
|
|
const listeners=REGISTRY_LISTENERS[event]||SERVER_LISTENERS[event];
|
|
if(!listeners)return()=>{};
|
|
listeners.add(cb);
|
|
return()=>listeners.delete(cb);
|
|
},subscribeMessage:async()=>()=>{}};
|
|
let STATES={
|
|
'light.ceiling':{entity_id:'light.ceiling',state:'on',attributes:{friendly_name:'Ceiling light'}},
|
|
'light.floor_lamp':{entity_id:'light.floor_lamp',state:'off',attributes:{friendly_name:'Floor lamp',linkquality:196}},
|
|
'media_player.tv':{entity_id:'media_player.tv',state:'playing',attributes:{friendly_name:'TV'}},
|
|
'sensor.living_temp':{entity_id:'sensor.living_temp',state:'22.4',attributes:{friendly_name:'Temperature',device_class:'temperature',unit_of_measurement:'°C',linkquality:154}},
|
|
'switch.kettle':{entity_id:'switch.kettle',state:'off',attributes:{friendly_name:'Kettle plug',linkquality:182}},
|
|
'binary_sensor.sink_leak':{entity_id:'binary_sensor.sink_leak',state:'off',attributes:{friendly_name:'Leak',device_class:'moisture',linkquality:117}},
|
|
'light.bedroom':{entity_id:'light.bedroom',state:'off',attributes:{friendly_name:'Bedroom light'}},
|
|
'binary_sensor.window':{entity_id:'binary_sensor.window',state:'on',attributes:{friendly_name:'Window',device_class:'window',linkquality:88}},
|
|
'lock.front_door':{entity_id:'lock.front_door',state:'locked',attributes:{friendly_name:'Front door',linkquality:143}},
|
|
'binary_sensor.hall_motion':{entity_id:'binary_sensor.hall_motion',state:'on',attributes:{friendly_name:'Motion',device_class:'motion',linkquality:64}},
|
|
'vacuum.mower':{entity_id:'vacuum.mower',state:'cleaning',attributes:{friendly_name:'Mower'}},
|
|
'cover.gate':{entity_id:'cover.gate',state:'closed',attributes:{friendly_name:'Gate'}},
|
|
};
|
|
// Mirror the service catalog exposed by a live Home Assistant frontend. Action
|
|
// resolution deliberately fails closed when a service is not registered, so
|
|
// the synthetic demo must declare every service it intends to exercise.
|
|
const SERVICES={
|
|
homeassistant:{turn_on:{},turn_off:{},toggle:{}},
|
|
light:{turn_on:{},turn_off:{},toggle:{}},
|
|
switch:{turn_on:{},turn_off:{},toggle:{}},
|
|
fan:{turn_on:{},turn_off:{},toggle:{}},
|
|
humidifier:{turn_on:{},turn_off:{},toggle:{}},
|
|
input_boolean:{turn_on:{},turn_off:{},toggle:{}},
|
|
automation:{turn_on:{},turn_off:{},toggle:{}},
|
|
remote:{turn_on:{},turn_off:{},toggle:{}},
|
|
climate:{turn_on:{},turn_off:{},toggle:{}},
|
|
media_player:{turn_on:{},turn_off:{},toggle:{}},
|
|
siren:{turn_on:{},turn_off:{},toggle:{}},
|
|
vacuum:{turn_on:{},turn_off:{},toggle:{}},
|
|
water_heater:{turn_on:{},turn_off:{},toggle:{}},
|
|
camera:{turn_on:{},turn_off:{}},
|
|
cover:{open_cover:{},close_cover:{},stop_cover:{},toggle:{}},
|
|
valve:{open_valve:{},close_valve:{},stop_valve:{},toggle:{}},
|
|
};
|
|
function mkHass(){
|
|
return {
|
|
language:'en', locale:{language:'en'},
|
|
// audit P0-4: the card fail-closes editors without an admin user /
|
|
// can_write flag — the demo admin mirrors a real HA admin session.
|
|
user:{id:'demo', name:'Demo', is_admin:true},
|
|
devices:DEVICES, entities:ENTITIES, areas:AREAS, states:STATES, services:SERVICES,
|
|
floors:{g:{floor_id:'g',name:'Ground floor',level:0},u:{floor_id:'u',name:'Upstairs',level:1}},
|
|
callWS:async (m)=>{
|
|
if(m.type==='config/device_registry/list'){
|
|
if(REGISTRY_ACCESS!=='full')throw new Error('unauthorized');
|
|
return Object.values(DEVICES);
|
|
}
|
|
if(m.type==='config/entity_registry/list'){
|
|
if(REGISTRY_ACCESS!=='full')throw new Error('unauthorized');
|
|
return Object.values(ENTITIES);
|
|
}
|
|
if(m.type==='config_entries/get')return[{entry_id:'ce_demo',domain:'demo',title:'Synthetic Home'}];
|
|
if(m.type==='manifest/list')return[{domain:'demo',name:'House Plan Demo'}];
|
|
if(m.type==='houseplan/config/get'){SERVED.config=CFG_REV;return{config:CFG,rev:CFG_REV,can_write:true};}
|
|
if(m.type==='houseplan/layout/get'){SERVED.layout=LAYOUT_REV;return{layout:LAYOUT,rev:LAYOUT_REV};}
|
|
if(m.type==='houseplan/config/set'){
|
|
// #629: like the real server, a write based on a revision older than
|
|
// one pushed by another client is a conflict. Only pushes arm this, so
|
|
// smokes that never push see the fixture exactly as before.
|
|
if(PUSHED_CFG_REV&&typeof m.expected_rev==='number'&&m.expected_rev<PUSHED_CFG_REV){
|
|
const error=new Error('config changed on the server');error.code='conflict';throw error;
|
|
}
|
|
return{ok:true,rev:++CFG_REV};
|
|
}
|
|
if(['houseplan/layout/set','houseplan/layout/update','houseplan/layout/delete'].includes(m.type))
|
|
return{ok:true,rev:++LAYOUT_REV};
|
|
if(m.type==='houseplan/plan/optimize')return{
|
|
ok:true,config_rev:++CFG_REV,layout_rev:++LAYOUT_REV,can_undo:true};
|
|
if(m.type==='houseplan/plan/optimize_undo')return{
|
|
ok:true,config_rev:++CFG_REV,layout_rev:++LAYOUT_REV,can_undo:false};
|
|
return {ok:true};
|
|
},
|
|
callService:async (d,s,data)=>{
|
|
const eid=data.entity_id, st=STATES[eid];
|
|
if(st){const on=['on','playing','unlocked','open'].includes(st.state);
|
|
STATES={...STATES,[eid]:{...st,state:on?'off':'on'}};
|
|
card.hass=mkHass();}
|
|
},
|
|
connection:CONNECTION,
|
|
localize:(key)=>key==='component.demo.title'?'Demo integration':null,
|
|
formatEntityState:(st)=>st.state,
|
|
};
|
|
}
|
|
window.__mkHass=mkHass;
|
|
let card=null;
|
|
if(!new URLSearchParams(location.search).has('panel-cold')){
|
|
await import('/assets/houseplan-card.js');
|
|
card=document.createElement('houseplan-card');
|
|
card.setConfig({type:'custom:houseplan-card', title:'House Plan', icon_size:3.4});
|
|
document.getElementById('host').appendChild(card);
|
|
card.hass=mkHass();
|
|
window.__card=card;
|
|
}
|
|
window.__setRegistryAccess=(mode)=>{REGISTRY_ACCESS=mode==='full'?'full':'limited';};
|
|
window.__setRegistryDisabled=(kind,id,disabledBy='user')=>{
|
|
const row=(kind==='device'?DEVICES:ENTITIES)[id];
|
|
if(row){
|
|
row.disabled_by=disabledBy;
|
|
const event=kind==='device'?'device_registry_updated':'entity_registry_updated';
|
|
for(const cb of REGISTRY_LISTENERS[event])cb({action:'update',id});
|
|
}
|
|
};
|
|
window.__setRegistryArea=(kind,id,areaId)=>{
|
|
const row=(kind==='device'?DEVICES:ENTITIES)[id];
|
|
if(row){
|
|
row.area_id=areaId;
|
|
const event=kind==='device'?'device_registry_updated':'entity_registry_updated';
|
|
for(const cb of REGISTRY_LISTENERS[event])cb({action:'update',id});
|
|
}
|
|
};
|
|
// #629: "the config changed on the server" — another client wrote it. The card
|
|
// re-reads houseplan/config/get (or layout/get) on its own, as in production.
|
|
window.__pushServerConfig=(cfg)=>{
|
|
CFG=cfg; CFG_REV+=1; PUSHED_CFG_REV=CFG_REV;
|
|
for(const cb of SERVER_LISTENERS.houseplan_config_updated)cb({event_type:'houseplan_config_updated',data:{rev:CFG_REV}});
|
|
return CFG_REV;
|
|
};
|
|
window.__pushServerLayout=(layout)=>{
|
|
LAYOUT=layout; LAYOUT_REV+=1;
|
|
for(const cb of SERVER_LISTENERS.houseplan_layout_updated)cb({event_type:'houseplan_layout_updated',data:{rev:LAYOUT_REV}});
|
|
return LAYOUT_REV;
|
|
};
|
|
window.__addRegistryEntity=(id,areaId,state='on',platform='demo')=>{
|
|
ENTITIES[id]={entity_id:id,device_id:null,area_id:areaId,platform,disabled_by:null};
|
|
STATES[id]={entity_id:id,state,attributes:{friendly_name:id}};
|
|
for(const cb of REGISTRY_LISTENERS.entity_registry_updated)cb({action:'create',id});
|
|
};
|
|
</script>
|
|
</body></html>
|