+
+
+尚未設定管理密碼
請連接插座的 CommandKit-Setup 設定熱點,開啟 http://192.168.1.254/ 完成初始設定。
+
+
+
+
+
+電壓—V
交流有效值
+
電流—A
交流有效值
+
累積用電—kWh
功率估算 · 約 30 秒保存一次
+
+裝置資訊
+
+
訪客可以查看狀態。登入後可調整燈號、配對、更新韌體與重設設定。
-韌體更新
讓插座保持更新,原有設定與配對會保留。
-使用已下載的更新包
更新會暫停控制並重新啟動插座,請保持供電。
-可檢查新版本,或使用已下載的更新包。
-
-
-
diff --git a/firmware/matter/src/update/update.js b/firmware/matter/src/update/update.js
index d94a8ac..47ff187 100644
--- a/firmware/matter/src/update/update.js
+++ b/firmware/matter/src/update/update.js
@@ -19,10 +19,15 @@ function sha256(input) {
const out=new Uint8Array(32), dv=new DataView(out.buffer);h.forEach((v,i)=>dv.setUint32(i*4,v));return out;
}
const hex = bytes => Array.from(bytes,b=>b.toString(16).padStart(2,'0')).join('');
+function hmac256(key,input) {
+ const inner=new Uint8Array(64+input.length),outer=new Uint8Array(96);
+ for(let i=0;i<64;i++){inner[i]=(key[i]||0)^0x36;outer[i]=(key[i]||0)^0x5c;}
+ inner.set(input,64);outer.set(sha256(inner),64);return sha256(outer);
+}
function meterView(data) {
const reading=(n,max,digits)=>Number.isInteger(n)&&n>=0&&n<=max?(n/1000).toFixed(digits):'—';
const live=!!(data&&data.available===true&&Number.isInteger(data.sample_age_ms)&&data.sample_age_ms>=0&&data.sample_age_ms<=5000);
- const d=live?data:{};
+ const d=live?data:{energy_mwh:data?.saved_energy_mwh};
let energy='—';
if(typeof d.energy_mwh==='string'&&/^(0|[1-9][0-9]{0,18})$/.test(d.energy_mwh)&&
(d.energy_mwh.length<19||d.energy_mwh<='4611686018427387904')){
@@ -34,45 +39,203 @@ function meterView(data) {
state:complete?'量測資料正常':live?'部分讀值尚未就緒':'暫無即時資料',
note:live?'讀值於 '+new Date().toLocaleTimeString('zh-TW',{hour12:false})+' 更新':'等待裝置提供新的量測資料'};
}
-if (typeof module !== 'undefined') module.exports={sha256,hex,meterView};
-if (typeof document !== 'undefined') (async () => {
- const $=id=>document.getElementById(id), message=text=>{$('message').textContent=text;};
- let status=null, latest=null, busy=false;
- function drawMeter(data,offline=false){const view=meterView(data);
- for(const [id,value] of [['voltage',view.voltage],['current-amps',view.current],['power',view.power],['energy',view.energy],['relay',view.relay],['meter-updated',view.note]])$(id).textContent=value;
- $('meter-state').textContent=offline?'連線中斷,正在重試':view.state;
- $('meter-state').dataset.state=offline?'offline':view.complete?'live':'waiting';}
- async function monitor(){
- if(!busy&&!document.hidden){const controller=new AbortController(),timeout=setTimeout(()=>controller.abort(),4000);
- try{const r=await fetch('/measurements',{cache:'no-store',signal:controller.signal});if(!r.ok)throw Error('measurements');const data=await r.json();if(!busy&&!document.hidden)drawMeter(data);}
- catch{if(!busy&&!document.hidden)drawMeter(null,true);}finally{clearTimeout(timeout);}}
- setTimeout(monitor,2000);
+/* Watts for the gauge and trend; null whenever meterView would show a dash. */
+function powerWatts(data){const v=meterView(data);return v.live&&v.power!=='—'?Number(v.power):null;}
+/* Logarithmic 0–2 kW arc so a phone charger and a heater are both visible. */
+const gaugeFraction=w=>Math.min(1,Math.max(0,Math.log10(1+w)/Math.log10(2001)));
+/* Same compatibility rules every earlier release used to read the catalog. */
+function newestUpdate(updates,current){
+ return updates.filter(x=>x&&x.model==='CMNDKT-004'&&x.variant==='matter'&&Number.isSafeInteger(x.software_version)&&x.software_version>current).sort((a,b)=>b.software_version-a.software_version)[0]||null;
+}
+/* SVG paths for the trend chart; samples are [ms, watts] pairs. */
+function trendPaths(samples,now,span=120000,width=300,height=120){
+ const points=samples.filter(([t])=>now-t<=span);if(points.length<2)return null;
+ const values=points.map(p=>p[1]),max=Math.max(...values),min=Math.min(...values),top=Math.max(max*1.15,5);
+ const xy=points.map(([t,w])=>[(width*(1-(now-t)/span)).toFixed(1),(height-6-(height-12)*w/top).toFixed(1)]);
+ const line='M'+xy.map(p=>p.join(' ')).join('L');
+ return {line,area:line+'L'+xy[xy.length-1][0]+' '+height+'L'+xy[0][0]+' '+height+'Z',min,max,top,avg:values.reduce((a,b)=>a+b,0)/values.length};
+}
+if (typeof module !== 'undefined') module.exports={sha256,hex,hmac256,meterView,powerWatts,gaugeFraction,newestUpdate,trendPaths};
+if(typeof document!=='undefined')(async()=>{
+ const $=id=>document.getElementById(id),enc=s=>new TextEncoder().encode(s),sleep=ms=>new Promise(r=>setTimeout(r,ms));
+ let auth={authenticated:false},csrf='',status=null,protocol=null,busy=false,latest=null,catalog={state:'idle',error:''},installing=false,
+ protocolDirty=false,ledDirty=false,current='overview',pending=null,toastTimer=0;
+ const samples=[],fmtW=w=>w>=1000?(w/1000).toFixed(2)+' kW':w.toFixed(1)+' W';
+ $('device-address').textContent=location.hostname;
+ document.querySelectorAll('input[name=protocol]').forEach(input=>input.onchange=()=>{protocolDirty=true;});
+ function toast(text,kind='info'){const box=$('toast');$('toast-text').textContent=text;box.dataset.kind=kind;
+ $('toast-icon').setAttribute('href',kind==='error'?'#i-alert':kind==='success'?'#i-check':'#i-info');
+ box.hidden=false;clearTimeout(toastTimer);toastTimer=setTimeout(()=>{box.hidden=true;},kind==='error'?8000:5000);}
+ function show(name){current=name;if(catalog.state==='available')$('update-banner').hidden=name==='update'||installing;document.querySelectorAll('.view').forEach(s=>s.hidden=s.id!=='view-'+name);
+ document.querySelectorAll('.tabs button').forEach(b=>b.setAttribute('aria-current',b.dataset.view===name?'page':'false'));}
+ function view(name){if(name!=='overview'&&!auth.authenticated){pending=name;openLogin();return;}
+ show(name);window.scrollTo(0,0);if(name!=='overview')settingsRefresh().catch(()=>{});}
+ document.querySelectorAll('.tabs button').forEach(b=>b.onclick=()=>view(b.dataset.view));
+ $('update-banner').onclick=()=>view('update');
+ function openLogin(note=''){if(auth.configured===false){toast('尚未設定管理密碼,請先完成初始設定','error');return;}
+ const n=$('login-note');n.textContent=note;n.dataset.tone='';if(!$('login-box').open)$('login-box').showModal();$('login-password').focus();}
+ $('login-open').onclick=()=>openLogin();
+ $('login-cancel').onclick=()=>{pending=null;$('login-box').close();};
+ $('login-box').addEventListener('close',()=>{$('login-password').value='';});
+ function drawAuth(data){auth=data;csrf=data.csrf||'';const admin=!!data.authenticated;
+ document.body.classList.toggle('is-admin',admin);$('login-open').hidden=admin;$('account').hidden=!admin;$('guest-note').hidden=admin;
+ $('setup-notice').hidden=data.configured!==false;$('login-open').disabled=data.configured===false;
+ if(!admin&¤t!=='overview')show('overview');drawUpdate();}
+ async function authRefresh(){const r=await fetch('/auth/session',{cache:'no-store'});if(!r.ok)throw Error('管理服務尚未就緒');const data=await r.json();
+ if(data.storage_ready===false){$('login-open').disabled=true;$('login-open').title='管理設定準備中';return;}$('login-open').title='';drawAuth(data);}
+ async function api(path,body='',digest=null){
+ const headers={'Content-Type':'text/plain','X-CK-CSRF':csrf};if(digest)headers['X-CK-SHA256']=digest;
+ const r=await fetch(path,{method:'POST',credentials:'same-origin',headers,body});
+ const data=await r.json().catch(()=>({}));
+ if(!r.ok){if(r.status===401){drawAuth({authenticated:false,configured:true});openLogin('登入已逾時,請重新登入後再操作');throw Error('請先登入,或重新登入後操作');}
+ throw Error(r.status===409?'裝置正在處理其他操作,請稍候再試':r.status===503?'裝置或儲存尚未準備好,請稍候再試':'操作遭拒絕,請確認輸入與更新檔案');}return data;
}
+ function ask(text,title='確認操作',yes='確認'){return new Promise(resolve=>{const dialog=$('confirm-box');$('confirm-title').textContent=title;$('confirm-text').textContent=text;$('confirm-yes').textContent=yes;
+ $('confirm-yes').onclick=()=>{dialog.close();resolve(true);};$('confirm-no').onclick=()=>{dialog.close();resolve(false);};
+ dialog.oncancel=()=>resolve(false);dialog.showModal();});}
+ function setBusy(value){busy=value;if(value)drawMeter(null);document.querySelectorAll('.admin-view button').forEach(b=>b.disabled=value);}
+ async function run(job,button){if(busy)return;setBusy(true);button?.classList.add('loading');try{await job();}catch(e){toast(e.message,'error');}finally{button?.classList.remove('loading');setBusy(false);}}
+ document.querySelectorAll('.pw-toggle').forEach(b=>b.onclick=()=>{const input=$(b.dataset.for),reveal=input.type==='password';input.type=reveal?'text':'password';
+ b.setAttribute('aria-label',reveal?'隱藏密碼':'顯示密碼');b.setAttribute('aria-pressed',String(reveal));});
+ $('logout').onclick=async()=>{try{await api('/auth/logout');drawAuth({authenticated:false,configured:true});toast('已登出');}catch(e){toast(e.message,'error');}};
+ $('login-form').onsubmit=async event=>{event.preventDefault();const button=$('login-submit'),note=$('login-note');button.disabled=true;button.classList.add('loading');
+ note.dataset.tone='';note.textContent='正在驗證密碼,約需數秒…';
+ try{const r=await fetch('/auth/challenge',{cache:'no-store'});if(!r.ok)throw Error('請先完成初始管理密碼設定');const c=await r.json();
+ if(!/^[a-f0-9]{64}$/.test(c.nonce))throw Error('驗證資料無效');
+ const keyText=await CKPassword.derive($('login-password').value,c.salt,c.iterations);$('login-password').value='';
+ const key=Uint8Array.from(keyText.match(/../g),x=>parseInt(x,16));const proof=c.nonce+':'+hex(hmac256(key,enc('ck-admin-login-v1\n'+c.nonce)));key.fill(0);
+ const login=await fetch('/auth/login',{method:'POST',headers:{'X-CK-Login':proof},body:''});
+ if(!login.ok)throw Error(login.status===429?'嘗試次數較多,請稍候再登入':'密碼不正確,或驗證已逾時');
+ drawAuth({...await login.json(),configured:true});note.textContent='';$('login-box').close();toast('已登入管理員','success');
+ const next=pending;pending=null;if(next)view(next);else settingsRefresh().catch(()=>{});
+ }catch(e){note.dataset.tone='error';note.textContent=e.message;}finally{button.disabled=false;button.classList.remove('loading');}};
+ function drawTrend(){const t=trendPaths(samples,Date.now());$('trend-empty').style.display=t?'none':'';
+ $('trend-line').setAttribute('d',t?t.line:'');$('trend-area').setAttribute('d',t?t.area:'');
+ for(const [id,k]of[['trend-avg','avg'],['trend-max','max'],['trend-min','min']])$(id).textContent=t?fmtW(t[k]):'—';
+ $('trend-range').textContent=t?'0 – '+fmtW(t.top):'';}
+ function drawMeter(data,offline=false){const v=meterView(data),pill=$('live-state'),fine=v.energy!=='—';
+ for(const [id,value]of[['voltage',v.voltage],['current-amps',v.current],['power',v.power],['relay-text',v.relay],['meter-updated',v.note]])$(id).textContent=value;
+ $('energy').textContent=fine?v.energy.slice(0,-3):v.energy;$('energy-fine').textContent=fine?v.energy.slice(-3):'';
+ $('relay').dataset.on=v.relay==='插座已開啟'?'true':v.relay==='插座已關閉'?'false':'';
+ $('meter-state').textContent=offline?'連線中斷,正在重試':v.state;
+ const w=offline?null:powerWatts(data);$('gauge-arc').style.strokeDasharray=(w===null?0:395.84*gaugeFraction(w)).toFixed(2)+' 527.79';
+ if(w!==null){const now=Date.now();samples.push([now,w]);while(samples.length&&now-samples[0][0]>120000)samples.shift();}drawTrend();
+ pill.dataset.state=offline?'offline':v.complete?'live':'waiting';pill.lastElementChild.textContent=offline?'連線中斷':v.complete?'即時連線':'等待量測';
+ if(protocol?.active==='homekit'){$('meter-state').textContent='HomeKit 模式';$('meter-updated').textContent='自訂即時量測及 kWh 累計暫停;顯示已保存的用電紀錄';if(!offline){pill.lastElementChild.textContent='管理連線正常';pill.dataset.state='live';}}}
+ const protocolName=p=>p==='homekit'?'HomeKit':p==='matter'?'Matter':'維修模式';
+ async function refresh(){const r=await fetch('/status',{cache:'no-store'});if(!r.ok)throw Error('裝置尚未準備好');status=await r.json();
+ $('current').textContent=status.version;$('version-big').textContent=status.version||'—';const health=$('health-chip');
+ health.textContent=status.rescue?'維修模式':status.healthy?'運作正常 · 軟體版本 '+status.software_version:'正在確認啟動狀態';health.dataset.tone=status.rescue?'warn':status.healthy?'ok':'';
+ const p=await fetch('/protocol',{cache:'no-store'});if(p.ok){const previous=protocol?.selected;protocol=await p.json();const name=protocolName(protocol.active);
+ $('active-protocol').textContent=name;$('protocol-status').textContent='目前:'+name+(protocol.restart_pending?' · 等待重啟':'');
+ const choice=document.querySelector('input[name="protocol"][value="'+protocol.selected+'"]');if(choice&&!protocolDirty&&previous!==protocol.selected)choice.checked=true;
+ $('matter-onboarding').hidden=protocol.active!=='matter';$('commissioning-open').hidden=protocol.active!=='matter';}}
+ const ledReasons={normal:'依平常模式顯示',night:'夜間熄燈中',pairing:'等待家庭配對',joining:'正在連接 Wi-Fi',setup:'等待初次設定',error:'服務或 MCU 通訊異常',identify:'尋找插座中'};
+ const ledMinutes=s=>{if(!/^\d{2}:\d{2}$/.test(s))return NaN;const [h,m]=s.split(':').map(Number);return h<24&&m<60?h*60+m:NaN;};
+ const ledTime=m=>String(Math.floor(m/60)).padStart(2,'0')+':'+String(m%60).padStart(2,'0');
+ function drawTimeline(){const start=ledMinutes($('led-start').value),end=ledMinutes($('led-end').value),on=$('led-schedule').checked;
+ const seg=(el,a,b)=>{el.hidden=!(on&&b>a);el.style.left=a/14.4+'%';el.style.width=(b-a)/14.4+'%';};
+ const valid=Number.isInteger(start)&&Number.isInteger(end)&&start!==end;
+ seg($('night-a'),valid?start:0,valid?(start
end?end:0);$('led-timeline').style.opacity=on?1:.5;}
+ async function ledRefresh(){if(!auth.authenticated)return;const r=await fetch('/admin/led',{cache:'no-store'});if(!r.ok){$('led-status').textContent='燈號設定尚未就緒,請稍候再試';return;}const d=await r.json();
+ $('led-status').textContent=d.service_ready?(ledReasons[d.reason]||'正在讀取狀態'):'指示燈服務正在準備中';$('led-lamp').classList.toggle('lit',d.on===true);
+ $('led-clock').textContent=d.time_valid?'時間已校準 · '+(d.time_source==='ntp'?'時間伺服器':'手機時間')+(d.night?' · 目前為熄燈時段':''):'排程等待校時,可在「時區與校時」用手機時間校準';
+ const now=$('night-now');now.hidden=!d.time_valid;if(d.time_valid)now.style.left=((Math.floor(d.utc_s/60)+d.utc_offset)%1440+1440)%1440/14.4+'%';
+ if(!ledDirty){const mode=document.querySelector('input[name="led-mode"][value="'+d.mode+'"]');if(mode)mode.checked=true;
+ $('led-schedule').checked=d.schedule;$('led-start').value=ledTime(d.start);$('led-end').value=ledTime(d.end);$('led-offset').value=d.utc_offset/60;$('led-ntp').value=d.ntp;}
+ drawTimeline();}
+ $('led-settings').addEventListener('input',()=>{ledDirty=true;drawTimeline();});
+ $('led-settings').onsubmit=e=>{e.preventDefault();run(async()=>{const mode=document.querySelector('input[name="led-mode"]:checked')?.value||'smart',
+ start=ledMinutes($('led-start').value),end=ledMinutes($('led-end').value),offset=Number($('led-offset').value)*60,ntp=$('led-ntp').value.trim();
+ if(!Number.isInteger(start)||!Number.isInteger(end)||start===end||!Number.isInteger(offset)||offset<-720||offset>840||offset%15||!/^([A-Za-z0-9][A-Za-z0-9.-]{0,126})$/.test(ntp)||/[.-]$/.test(ntp))throw Error('請檢查熄燈時間、UTC 時差及時間伺服器');
+ await api('/admin/led','CKLED1 '+mode+' '+Number($('led-schedule').checked)+' '+start+' '+end+' '+offset+' '+ntp+'\n');ledDirty=false;toast('燈號設定已保存,會立即套用。','success');await ledRefresh();},$('led-save'));};
+ $('led-find').onclick=()=>run(async()=>{await api('/admin/led-find');toast('插座將閃燈 15 秒,之後恢復原本燈號。','success');},$('led-find'));
+ $('led-phone-time').onclick=()=>run(async()=>{await api('/admin/led-time',String(Math.floor(Date.now()/1000)));toast('已送出手機時間,本次開機的排程可離線繼續計時。','success');await ledRefresh();},$('led-phone-time'));
+ const formatCode=c=>/^\d{11}$/.test(c)?c.slice(0,4)+'-'+c.slice(4,7)+'-'+c.slice(7):c;
+ async function settingsRefresh(){if(!auth.authenticated)return;const r=await fetch('/admin/state',{cache:'no-store'});if(r.status===401){await authRefresh();return;}if(!r.ok)return;
+ const m=(await r.json()).matter,line=$('pairing-state');
+ if(protocol?.active==='homekit'){$('pairing-text').textContent='HomeKit 模式:請使用插座原廠標籤上的配對碼。';line.dataset.tone='ok';}
+ else if(m){$('pairing-text').textContent='已配對 '+m.fabrics+' 個家庭 · '+(m.window_open?'配對視窗開啟中':'配對視窗已關閉');line.dataset.tone=m.window_open?'open':m.fabrics?'ok':'';}
+ else{$('pairing-text').textContent='Matter 正在準備中';line.dataset.tone='';}
+ const onboarding=await fetch('/admin/onboarding',{cache:'no-store'});if(onboarding.ok){const d=await onboarding.json();$('matter-code').textContent=d.manual_code?formatCode(d.manual_code):'尚未就緒';$('matter-code').dataset.raw=d.manual_code||'';}
+ await ledRefresh();}
+ $('code-copy').onclick=async()=>{const code=$('matter-code'),text=code.dataset.raw;if(!text)return;
+ try{await navigator.clipboard.writeText(text);}catch{const range=document.createRange(),selection=getSelection();range.selectNodeContents(code);selection.removeAllRanges();selection.addRange(range);
+ const copied=document.execCommand('copy');selection.removeAllRanges();if(!copied){toast('無法自動複製,請手動抄寫設定碼','error');return;}}
+ toast('已複製 Matter 設定碼','success');};
+ async function waitReceipt(id){const until=Date.now()+60000;while(Date.now()run(async()=>{const mode=document.querySelector('input[name="protocol"]:checked').value;const data=await api('/protocol',mode);protocolDirty=false;
+ if(data.status==='unchanged'){toast('目前已使用此協議。');return;}restarting('協議選擇已保存,正在重新啟動');},$('protocol-save'));
+ $('energy-reset').onclick=()=>run(async()=>{if(!await ask('累積用電會歸零並立即保存,之後從零重新累計。此動作無法復原。','將累積用電歸零?','歸零'))return;
+ const result=await api('/admin/energy-reset');if(result.request_id)await waitReceipt(result.request_id);toast('累積用電已歸零並保存。','success');samples.length=0;drawMeter(null);await settingsRefresh();},$('energy-reset'));
+ $('commissioning-open').onclick=()=>run(async()=>{const r=await api('/admin/open-commissioning');await waitReceipt(r.request_id);toast('Matter 配對視窗已重新開啟。','success');await settingsRefresh();},$('commissioning-open'));
+ $('pairing-reset').onclick=()=>run(async()=>{if(!await ask('目前協議的家庭配對會被清除,Wi-Fi、管理密碼和累積用電會保留。','清除家庭配對?','清除配對'))return;
+ const r=await api('/admin/pairing-reset');if(r.status==='restarting')restarting('正在重設配對並重新啟動');else toast('正在重設配對,請稍候再查看狀態。');},$('pairing-reset'));
+ $('reboot').onclick=()=>run(async()=>{await api('/admin/reboot');restarting('正在重新啟動');},$('reboot'));
+ $('factory-reset').onclick=()=>run(async()=>{if(!await ask('會清除管理密碼、Wi-Fi、HomeKit/Matter 配對與全部累積用電,之後需要重新設定。','恢復出廠設定?','清除並恢復出廠'))return;
+ await api('/admin/factory-reset');toast('正在恢復出廠。重啟後請連接 CommandKit-Setup 設定熱點重新設定。','success');drawAuth({authenticated:false,configured:false});},$('factory-reset'));
+ $('wifi-settings').onsubmit=event=>{event.preventDefault();run(async()=>{const ssid=enc($('wifi-ssid').value),password=enc($('wifi-password').value);
+ if(ssid.length<1||ssid.length>32||password.length<8||password.length>64||password.some(v=>v<32||v>126))throw Error('請檢查 Wi-Fi 名稱與密碼長度');
+ const b=new Uint8Array(104);b.set(enc('CKW1'));b[4]=ssid.length;b[5]=password.length;b[6]=1;b.set(ssid,8);b.set(password,40);
+ await api('/admin/wifi',hex(b));b.fill(0);$('wifi-password').value='';toast('已送出 Wi-Fi 設定。請切回相同網路,並用插座的新 IP 開啟管理頁。','success');},event.submitter);};
+ $('password-settings').onsubmit=event=>{event.preventDefault();run(async()=>{const password=$('new-password').value;if(password!==$('confirm-password').value)throw Error('兩次密碼不相同');
+ const salt=CKPassword.salt(),key=await CKPassword.derive(password,salt);
+ await api('/auth/password',CKPassword.record(salt,key));$('new-password').value='';$('confirm-password').value='';drawAuth({authenticated:false,configured:true});openLogin('密碼已更新,請使用新密碼登入。');},event.submitter);};
+ function drawUpdate(){const s=catalog.state,newer=s==='available'&&latest,tone={checking:'busy',available:'new',current:'ok',error:'warn'}[s]||'';
+ $('update-state').dataset.tone=tone;$('update-icon').firstElementChild.setAttribute('href',newer?'#i-download':s==='current'?'#i-check':s==='error'?'#i-alert':'#i-update');
+ $('update-title').textContent=s==='checking'?'正在檢查線上版本…':newer?'有新版本 '+latest.version:s==='current'?'目前已是最新版本':s==='error'?'無法檢查線上版本':'尚未檢查線上版本';
+ $('message').textContent=s==='checking'?'正在連線到更新來源':newer?'軟體版本 '+latest.software_version+(Number.isFinite(latest.size)?' · '+(latest.size/1048576).toFixed(1)+' MB':'')+' · 驗證簽章後安裝':
+ s==='current'?'已安裝 '+(status?.version||'')+' · '+catalog.at.toLocaleTimeString('zh-TW',{hour12:false})+' 檢查':s==='error'?catalog.error+'。仍可使用手動更新。':'開啟頁面時會自動檢查一次';
+ const chip=$('update-chip');chip.dataset.tone=newer?'new':tone==='busy'?'':tone;chip.textContent=s==='checking'?'檢查中…':newer?'有新版本':s==='current'?'已是最新':s==='error'?'無法檢查':'尚未檢查';
+ $('update-banner').hidden=!newer||installing||current==='update';$('update-dot').hidden=!newer;$('online').hidden=!newer||!auth.authenticated;
+ if(newer){$('banner-title').textContent='有新版本 '+latest.version+' 可以安裝';$('banner-text').textContent=auth.authenticated?'前往更新頁面安裝,更新時請保持供電':'登入管理員後即可更新';$('online-label').textContent='下載並更新至 '+latest.version;}}
+ async function checkUpdates(manual=false){if(!status||catalog.state==='checking')return;catalog={state:'checking',error:''};drawUpdate();
+ try{const r=await fetch(CK_CATALOG_URL,{cache:'no-store'});if(!r.ok)throw Error('更新來源尚未發布');const c=await r.json();if(c.format!=='commandkit-catalog-v1'||!Array.isArray(c.updates))throw Error('更新索引格式不符');
+ latest=newestUpdate(c.updates,status.software_version);catalog={state:latest?'available':'current',error:'',at:new Date()};
+ if(manual)toast(latest?'有新版本 '+latest.version:'目前已是最新版本',latest?'info':'success');
+ }catch(e){latest=null;catalog={state:'error',error:e instanceof TypeError?'無法連線到更新來源':e.message};if(manual)toast('無法檢查線上版本:'+catalog.error,'error');}
+ drawUpdate();}
+ $('check').onclick=()=>run(()=>checkUpdates(true),$('check'));
+ function steps(active,online){const order=['download','upload','apply','reboot','done'],at=order.indexOf(active);
+ document.querySelectorAll('#install-steps li').forEach((li,i)=>{li.dataset.state=!online&&!i?'skip':active==='done'||isize)throw Error('下載不完整');out.set(value,at);at+=value.length;
+ const p=Math.floor(at*100/size);if(p!==shown){shown=p;progress(p);}}
+ return at===size?out:out.subarray(0,at);}
+ $('online').onclick=()=>run(()=>installJob(async()=>{const item=latest;if(!item?.url.startsWith('https://')||!Number.isSafeInteger(item.size)||item.size<256||item.size>16777216)throw Error('更新來源不符');
+ $('install-note').textContent='正在下載 '+item.version+'…';const r=await fetch(item.url,{cache:'no-store'});if(!r.ok)throw Error('下載失敗');
+ const b=await readAll(r,item.size,p=>{$('install-note').textContent='正在下載 '+item.version+' · '+p+'%';});
+ if(b.length!==item.size||hex(sha256(b))!==item.sha256)throw Error('下載不完整');return [b,item.software_version];},true),$('online'));
+ function drawPackage(){const f=$('package').files[0];$('package-name').textContent=f?f.name:'點選或拖放 .ota 更新包';
+ $('package-info').textContent=f?(f.size/1048576).toFixed(2)+' MB · 按「驗證並更新」開始':'只接受本專案簽章的 matter.ota';}
+ $('package').onchange=drawPackage;const drop=$('drop');
+ ['dragenter','dragover'].forEach(n=>drop.addEventListener(n,e=>{e.preventDefault();drop.classList.add('over');}));
+ ['dragleave','drop'].forEach(n=>drop.addEventListener(n,e=>{e.preventDefault();drop.classList.remove('over');}));
+ drop.addEventListener('drop',e=>{if(e.dataTransfer?.files?.length){$('package').files=e.dataTransfer.files;drawPackage();}});
+ $('manual').onclick=()=>{const file=$('package').files[0];if(!file){toast('請先選擇 .ota 更新包','error');return;}
+ run(()=>installJob(async()=>{const b=new Uint8Array(await file.arrayBuffer());if(b.length<256)throw Error('更新檔案不完整');return [b,new DataView(b.buffer).getUint32(20)];},false),$('manual'));};
+ async function monitor(){if(!document.hidden&&!busy){const controller=new AbortController(),timer=setTimeout(()=>controller.abort(),4000);try{
+ const r=await fetch('/measurements',{cache:'no-store',signal:controller.signal});if(!r.ok)throw Error();drawMeter(await r.json());
+ }catch{drawMeter(null,true);}finally{clearTimeout(timer);}}
+ setTimeout(monitor,2000);}
+ async function statePoll(){if(!document.hidden&&!busy){try{await refresh();await authRefresh();await settingsRefresh();if(catalog.state==='idle')checkUpdates();}catch{}}setTimeout(statePoll,10000);}
document.addEventListener('visibilitychange',()=>{if(document.hidden)drawMeter(null);});
- monitor();
- async function refresh(){const r=await fetch('/status');if(!r.ok)throw Error('裝置尚未準備好');status=await r.json();$('current').textContent=status.version;
- if(status.rescue)message('裝置目前在維修模式,可安裝較新的修正版。');}
- async function post(path,bytes,imageHash=null){const digest=imageHash||hex(sha256(bytes));
- const response=await fetch(path,{method:'POST',headers:{'Content-Type':'application/octet-stream','X-CK-SHA256':digest},body:bytes});
- if(!response.ok){const problem=await response.json();throw Error(problem.error==='busy_or_low_memory'?'插座正在更新或記憶體不足,請稍候再試':'更新遭拒絕:請確認機型、版本與簽章,或下載完整檔案後重試');}
- return response.json();}
- async function install(bytes,expected){if(busy)return;busy=true;document.querySelectorAll('button').forEach(b=>b.disabled=true);
- drawMeter(null);$('meter-state').textContent='更新期間暫停量測';
- try{if(bytes.length!==status.max_size)throw Error('請選擇 .ota 更新包;8 MiB 燒錄映像不適用');
- message('正在驗證並上傳,請保持此頁開啟…');await post('/upload',bytes);
- message('簽章驗證完成,正在安裝。請保持插座供電…');await post('/apply',new Uint8Array(),hex(sha256(bytes)));
- const until=Date.now()+240000;let found=false;
- while(Date.now()setTimeout(r,4000));try{await refresh();if(status.software_version===expected){if(status.rescue||status.healthy){found=true;message(status.rescue?'新版已啟動,但進入維修模式,請安裝修正版。':'更新完成,Matter 與裝置控制已就緒。原有設定與配對已保留。');break;}message('新版已啟動,正在驗證網路與裝置控制…');}}catch{}}
- if(!found)message('尚未收到新版回應。請稍候,並確認插座的網路位址;勿重複上傳。');
- }catch(e){message(e.message);}finally{busy=false;document.querySelectorAll('button').forEach(b=>b.disabled=false);}}
- $('manual').onclick=async()=>{try{const f=$('package').files[0];if(!f)throw Error('請選擇 .ota 更新包');const b=new Uint8Array(await f.arrayBuffer());if(b.length<256)throw Error('檔案不完整');await install(b,new DataView(b.buffer).getUint32(20));}catch(e){message(e.message);}};
- $('check').onclick=async()=>{try{message('正在檢查新版本…');const response=await fetch(CK_CATALOG_URL,{cache:'no-store'});if(!response.ok)throw Error('更新來源尚未發布');const catalog=await response.json();
- if(catalog.format!=='commandkit-catalog-v1'||!Array.isArray(catalog.updates))throw Error('更新索引格式不符');
- const name=status.variant===1?'matter':'exec-test';latest=catalog.updates.filter(x=>x.model==='CMNDKT-004'&&x.variant===name&&Number.isSafeInteger(x.software_version)&&x.software_version>status.software_version).sort((a,b)=>b.software_version-a.software_version)[0];
- $('online').hidden=!latest;message(latest?'有新版本 '+latest.version+',可按「下載並更新」。':'目前已是最新版本。');
- }catch(e){message('無法檢查線上版本:'+e.message+'。仍可使用下方手動更新。');}};
- $('online').onclick=async()=>{try{if(!latest||!latest.url.startsWith('https://'))throw Error('更新來源不符');message('正在下載 '+latest.version+'…');const r=await fetch(latest.url,{cache:'no-store'});if(!r.ok)throw Error('下載失敗');
- const b=new Uint8Array(await r.arrayBuffer());if(b.length!==latest.size||hex(sha256(b))!==latest.sha256)throw Error('下載不完整');await install(b,latest.software_version);
- }catch(e){message(e.message+'。可改用手動更新,或電腦上的 update.py。');}};
- try{await refresh();}catch(e){message(e.message);}
+ try{await refresh();await authRefresh();}catch{const pill=$('live-state');pill.dataset.state='offline';pill.lastElementChild.textContent='連線中斷';}
+ monitor();statePoll();checkUpdates();setInterval(()=>{if(!busy&&!installing)checkUpdates();},6*3600000);
})();
diff --git a/firmware/matter/src/update/update_page.h b/firmware/matter/src/update/update_page.h
index f18dd95..8e005fe 100644
--- a/firmware/matter/src/update/update_page.h
+++ b/firmware/matter/src/update/update_page.h
@@ -1,2 +1,2 @@
/* Generated by tools/generate_update.py. */
-static const char ck_update_page[] = "\n\nCommandKit 插座用電與更新\n\n\n\n\n正在連線…插座狀態讀取中
\n\n\n\n\n\n
\n\n\n韌體更新
讓插座保持更新,原有設定與配對會保留。
\n使用已下載的更新包
更新會暫停控制並重新啟動插座,請保持供電。
\n可檢查新版本,或使用已下載的更新包。
\n\n\n\n";
+static const char ck_update_page[] = "\n\nCommandKit 插座管理\n\n\n\n\n
\n
\n尚未設定管理密碼
請連接插座的 CommandKit-Setup 設定熱點,開啟 http://192.168.1.254/ 完成初始設定。
\n\n\n\n\n\n電壓—V
交流有效值\n
電流—A
交流有效值\n
累積用電—kWh
功率估算 · 約 30 秒保存一次 \n\n裝置資訊
\n
\n
訪客可以查看狀態。登入後可調整燈號、配對、更新韌體與重設設定。
\n\n\n\n指示燈
HomeKit 與 Matter 共用同一組燈號設定
\n\n\n\n\n智慧家庭與配對
一次啟用一種協議,管理後台兩種模式都能使用
\n智慧家庭協議
\n
\n
切換會重新啟動,保留 Wi-Fi、配對、管理密碼與累積用電。HomeKit 使用插座原廠配對碼;此模式暫停自訂網頁即時量測與 kWh 累計。
\n
\n家庭配對
\n
正在讀取配對狀態…
\n
在 Apple 家庭、Google Home 或 Home Assistant 選擇「加入配件」,輸入此設定碼。
\n
清除配對會保留 Wi-Fi、管理密碼及累積用電。
\n\n\n\n\n\n手動更新
沒有網際網路時,可先在電腦下載 Release 的 matter.ota,再從這裡上傳。
\n
\n
\n正在更新
\n
- 下載
- 上傳驗證
- 安裝
- 重新啟動
- 完成
請保持插座供電,不要關閉此頁。
\n\n\n\n\nWi-Fi 連線
支援 2.4 GHz WPA2 個人網路。儲存後插座可能換 IP,請讓手機與插座使用同一網路。
\n
\n
管理密碼
變更後需要用新密碼重新登入。至少 8 個字元。
\n
\n維護
\n
重新啟動插座不清除任何設定,約一分鐘後恢復連線
\n危險區
\n
累積用電歸零立即保存,之後從零重新累計,無法復原
\n
恢復出廠設定清除管理密碼、Wi-Fi、兩套配對與用電紀錄,重新開啟設定熱點
\n\n\n \n\n\n
\n\n";
diff --git a/firmware/matter/tests/relay_engine_tests.cpp b/firmware/matter/tests/relay_engine_tests.cpp
index 7373ad8..eb89c16 100644
--- a/firmware/matter/tests/relay_engine_tests.cpp
+++ b/firmware/matter/tests/relay_engine_tests.cpp
@@ -58,7 +58,7 @@ int main() {
power(e,6,65000); power(e,6,70000); check(e.LoadBlink(70003),"load reentry");
state(e,true,84000); check(!e.LoadBlink(85002),"stale metering stops blink");
uint8_t reset[]={0xaa,0xff,0,0x55,0xaa,0xff,0xff,0x55};for(auto b:reset)e.Feed(b,86000);
- check(e.Snapshot().resetEvents==2,"reset events recorded without erasing");
+ check(e.Snapshot().resetEvents==1,"only confirmed long press dispatches factory reset");
check(e.Request(203,false,87000),"disconnect command");e.Disconnect();
check(e.Snapshot().completedId==203&&e.Snapshot().result==RelayResult::TransportError&&!e.Snapshot().known,"transport failure never succeeds");
RelayEngine late; check(late.Request(1,false,0),"late fixture");late.NextTx(0,tx);late.NextTx(100,tx);state(late,false,3000);late.NextTx(3000,tx);
diff --git a/firmware/matter/tests/test_device_portal.py b/firmware/matter/tests/test_device_portal.py
index 5a5cb87..e38abbb 100644
--- a/firmware/matter/tests/test_device_portal.py
+++ b/firmware/matter/tests/test_device_portal.py
@@ -9,6 +9,7 @@ def call(port,method,path,body=None,headers=None):
def main():
parser=argparse.ArgumentParser();parser.add_argument('--output-tag',choices=['softap-device-code'],default='softap-device-code');options=parser.parse_args();ART=OUT/'artifacts'/options.output_tag;EV=OUT/'evidence'/options.output_tag
name='incipio-softap-http-test';checks=[]
+ ram=OUT/'private/device-test-A';(ram/'ck-admin-state').write_text('0');(ram/'ck-admin-enroll-ok').unlink(missing_ok=True)
p=subprocess.Popen(['docker','run','--rm','--name',name,'-p','127.0.0.1:18082:18082',
'-p','127.0.0.1:18083:18083/udp','-p','127.0.0.1:18084:18084','-v',f'{OUT}:/work:ro',
'-v',f'{OUT}/private/device-test-A:/var/run',
@@ -32,10 +33,29 @@ def main():
assert s==302 and hs['Location']=='http://127.0.0.1:18082/';checks.append('captive HTTP probe redirect')
assert call(18084,'POST','/enter-setup')[0]==403;checks.append('unauthenticated setup rejected')
key=bytes.fromhex((OUT/'private/diag.key').read_text().strip())
+ download_headers={'Content-Type':'application/x-www-form-urlencoded'}
+ assert b'id="admin-password"' in page and b'download-management-key' not in page
+ assert call(18082,'POST','/management-key','token='+token,download_headers)[0]==403
+ assert key.hex().encode() not in page
+ state=json.loads(call(18082,'GET','/admin/status')[1]);assert state['ready'] and not state['configured']
+ valid_wifi=urllib.parse.urlencode({'ssid':'Lab Network','password':'ExamplePass42','security':'wpa2','token':token})
+ assert call(18082,'POST','/wifi',valid_wifi,download_headers)[0]==403
+ credential=(b'CKAUTH1\n'+struct.pack('>I',600000)+bytes(range(16))+bytes(range(32))).hex()
+ assert call(18082,'POST','/admin/enroll','token='+'0'*64+'&credential='+credential,download_headers)[0]==403
+ # This qemu-user portal has no journal/backend helper. Actual enrollment
+ # commits are covered by test_updates; the portal must not claim success.
+ assert call(18082,'POST','/admin/enroll','token='+token+'&credential='+credential,download_headers)[0]==409
+ assert not (ram/'ck-admin-enroll').exists()
+ (ram/'ck-admin-state').write_text('1');(ram/'ck-admin-enroll-ok').write_text('1')
+ checks.append('password inputs replace key export; valid setup token required; enrollment failure never permits Wi-Fi; journal success is a separate fixture')
def proof(scope):
_,nonce,_=call(18084,'GET','/challenge');return nonce.decode()+':'+hmac.new(key,scope.encode()+nonce,hashlib.sha256).hexdigest()
header={'X-Setup-Proof':proof('GET /state\n')};s,b,_=call(18084,'GET','/state',headers=header)
assert s==200 and b'state=setup' in b;assert call(18084,'GET','/state',headers=header)[0]==403;checks.append('HMAC state and replay rejection')
+ for action in ['reset-matter','open-commissioning','enter-setup','ram-probe']:
+ assert call(18084,'POST','/'+action,headers={'X-Setup-Proof':proof('POST /'+action+'\n')})[0]==403
+ assert not (ram/'ck-matter-request').exists()
+ checks.append('legacy diagnostic HMAC cannot bypass password login for mutations')
qname=b''.join(bytes([len(x)])+x.encode() for x in 'captive.apple.com'.split('.'))+b'\0'
sock=socket.socket(socket.AF_INET,socket.SOCK_DGRAM);sock.settimeout(3)
for ty in [1,28]:
@@ -94,7 +114,7 @@ def scan(h=None,body=scan_body):
if options.output_tag in ('softap-device-code',):
assert manual in confirmation and b"src='/matter-qr'" in confirmation
checks.append('Wi-Fi submission confirmation retains same manual code and QR')
- assert post(v)==409;time.sleep(4)
+ assert post(v)==403;time.sleep(4)
s,b,_=call(18084,'GET','/state',headers={'X-Setup-Proof':proof('GET /state\n')})
assert s==200 and b'state=online' in b;checks.append('duplicate guard and fixture transition')
r={'status':'pass','checks':checks,'binary_sha256':hashlib.sha256((ART/'ck-setupd').read_bytes()).hexdigest(),
diff --git a/firmware/matter/tools/build_matter_device_fixture.py b/firmware/matter/tools/build_matter_device_fixture.py
index 717b919..8de0204 100644
--- a/firmware/matter/tools/build_matter_device_fixture.py
+++ b/firmware/matter/tools/build_matter_device_fixture.py
@@ -23,6 +23,9 @@ def entry(name,data=b'',mode=stat.S_IFREG|0o755,rdev=(0,0)):
entry('dev/ttyS1',mode=stat.S_IFCHR|0o600,rdev=(4,65));entry('tmp/gpio',b' '*32,mode=stat.S_IFREG|0o600)
entry('etc/matter-device.key',(OUT/'private/device-identity.key').read_bytes(),mode=stat.S_IFREG|0o600)
if persistent:
+ salt=bytes(range(16));key=hashlib.pbkdf2_hmac('sha256',b'FixtureAdmin42',salt,600000,32)
+ entry('test-admin.bin',b'CKAUTH1\n'+struct.pack('>I',600000)+salt+key,mode=stat.S_IFREG|0o600)
+ entry('bin/ck-setupd',(OUT/'artifacts/softap-device-code/ck-setupd').read_bytes())
entry('bin/ck-updated',(OUT/'artifacts/update-test/ck-updated').read_bytes())
entry('dev/ttyS0',mode=stat.S_IFCHR|0o600,rdev=(4,64))
entry('etc/ck-update.pub',(Path('/repo/third_party/ota-signing.pub')).read_bytes(),mode=stat.S_IFREG|0o444)
diff --git a/firmware/matter/tools/build_updates.sh b/firmware/matter/tools/build_updates.sh
index d93724c..b0dc2bc 100644
--- a/firmware/matter/tools/build_updates.sh
+++ b/firmware/matter/tools/build_updates.sh
@@ -8,15 +8,17 @@ if [ "${CK_TESTING:-0}" = 1 ]; then dest=artifacts/update-test; fi
mkdir -p "$dest"
flags='-static -Os -std=c11 -Wall -Wextra -Werror -Wno-misleading-indentation -Wno-unused-function -mno-llsc -mno-load-store-pairs -mmemcpy -fno-builtin-memcpy -fno-builtin-memmove -ffunction-sections -fdata-sections -Wl,--gc-sections'
if [ "${CK_TESTING:-0}" = 1 ]; then flags="$flags -DCK_TESTING"; fi
-common='src/update/ck_common.c src/update/ck_flash.c src/update/ck_store.c src/update/ck_ota.c src/vendor/monocypher/monocypher.c src/vendor/monocypher/monocypher-ed25519.c'
+common='src/update/ck_common.c src/update/ck_flash.c src/update/ck_store.c src/update/ck_energy.c src/update/ck_led.c src/update/ck_ota.c src/vendor/monocypher/monocypher.c src/vendor/monocypher/monocypher-ed25519.c'
${cross}gcc $flags $common src/update/ck_updater.c -o "$dest/ck-updater"
${cross}gcc $flags $common src/update/ck_meter.c src/update/ck_updated.c -o "$dest/ck-updated"
+${cross}gcc $flags src/setup/boot_guard.c -o "$dest/ck-boot-guard"
if [ "${CK_TESTING:-0}" = 1 ]; then
${cross}gcc $flags -DCK_TEST_PID1 $common src/update/ck_updater.c -o "$dest/ck-updater-vm"
${cross}strip "$dest/ck-updater-vm"
fi
${cross}strip "$dest/ck-updater" "$dest/ck-updated"
-${cross}objdump -d "$dest/ck-updater" "$dest/ck-updated" > "$dest/disassembly.txt"
+${cross}strip "$dest/ck-boot-guard"
+${cross}objdump -d "$dest/ck-updater" "$dest/ck-updated" "$dest/ck-boot-guard" > "$dest/disassembly.txt"
CK_BUILD_DEST="$dest" python3 - <<'PY'
import re,os
from pathlib import Path
diff --git a/reverse/tools/audit_wifi_startup.py b/reverse/tools/audit_wifi_startup.py
new file mode 100644
index 0000000..b3775fd
--- /dev/null
+++ b/reverse/tools/audit_wifi_startup.py
@@ -0,0 +1,187 @@
+#!/usr/bin/env python3
+"""Replay extracted startup scripts with injected faults; no device/network I/O.
+
+Run inside a disposable Linux container with --network none. The actual image's
+BusyBox ash is emulated; updater, identity helper and network commands are mocks.
+Only temporary regular files are modified. A DHCP marker means its launch was
+reached, not that a real DHCP transaction succeeded.
+"""
+import argparse
+import hashlib
+import json
+import os
+from pathlib import Path
+import re
+import resource
+import shlex
+import signal
+import subprocess
+import tempfile
+import time
+
+
+def digest(path):
+ return hashlib.sha256(path.read_bytes()).hexdigest()
+
+
+def wait_for(predicate, timeout=3):
+ deadline = time.monotonic() + timeout
+ while time.monotonic() < deadline:
+ if predicate():
+ return True
+ time.sleep(.01)
+ return bool(predicate())
+
+
+def run_case(tree, runner, scenario, public=True):
+ with tempfile.TemporaryDirectory(prefix='ck-wifi-audit-') as name:
+ root = Path(name)
+ for folder in ('etc', 'bin', 'var/log', 'var/run', 'var/lib/ck-state'):
+ (root / folder).mkdir(parents=True, exist_ok=True)
+ events = root / 'events'
+ processes = []
+ logs = []
+
+ def executable(name, body):
+ path = root / 'bin' / name
+ path.write_text('#!/bin/sh\n' + body + '\n')
+ path.chmod(0o700)
+ return path
+
+ def record(event):
+ return 'echo ' + shlex.quote(event) + ' >> ' + shlex.quote(str(events)) + '\n'
+
+ def rewrite(path, output):
+ # Preserve shell logic; redirect absolute device paths into fixtures.
+ text = path.read_text()
+ text = re.sub(r'/(?:bin|var|etc)/', lambda m: str(root) + m[0], text)
+ output.write_text(text)
+ return output
+
+ def launch(script, *args):
+ log = (root / ('process-%d.log' % len(processes))).open('wb')
+ logs.append(log)
+ p = subprocess.Popen(runner + [str(script), *args], stdout=log,
+ stderr=log, start_new_session=True,
+ env={**os.environ, 'PATH': str(root / 'bin') + ':/usr/bin:/bin'})
+ processes.append(p)
+ return p
+
+ if public:
+ (root / 'etc/ck-identity-policy').write_text('auto-v1\n')
+ else:
+ (root / 'etc/matter-device.key').write_bytes(b'D' * 32)
+ (root / 'etc/matter-diag.key').write_bytes(b'12' * 32 + b'\n')
+ (root / 'etc/ck-update.pub').write_bytes(b'P' * 32)
+ executable('sleep', 'exec /bin/sleep .02')
+ executable('ck-updated', 'exec /bin/sleep 30')
+ executable('killall', 'exit 0')
+ executable('ifconfig', record('ifconfig-called') + 'exit 0')
+ executable('udhcpc', record('dhcp-launch-reached') + 'exec /bin/sleep 30')
+ updater = record('updater-$1').replace("'updater-$1'", '"updater-$1"')
+ updater += 'if [ "$1" != --import ]; then exit 0; fi\n'
+ updater += 'echo "$PPID" > ' + shlex.quote(str(root / 'initializer.pid')) + '\n'
+ if scenario in ('import_hang', 'initializer_killed'):
+ updater += 'exec /bin/sleep 30\n'
+ elif scenario == 'import_crash':
+ updater += 'kill -SEGV $$\n'
+ elif scenario == 'import_error':
+ updater += 'exit 1\n'
+ else:
+ updater += 'printf %s DDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDD > ' + shlex.quote(str(root / 'var/lib/ck-state/device.key')) + '\n'
+ executable('ck-updater', updater)
+ identity = record('identity-helper-called')
+ if scenario == 'identity_crash':
+ identity += 'kill -SEGV $$\n'
+ else:
+ identity += 'test -f ' + shlex.quote(str(root / 'var/lib/ck-state/device.key')) + ' || exit 5\n'
+ identity += 'printf 12345678901 > ' + shlex.quote(str(root / 'var/run/matter-manual-code')) + '\n'
+ executable('matterd-test', identity)
+ executable('ck-wificfg', 'exit 9' if scenario == 'mib_error' else 'echo configured=1')
+ backend = rewrite(tree / 'bin/ck-setup-backend', root / 'backend.sh')
+ controller = record('setup-controller-called')
+ if scenario == 'controller_reject':
+ controller += 'exit 11\n'
+ else:
+ controller += '/bin/sh ' + shlex.quote(str(backend)) + ' resume\nexec /bin/sleep 30\n'
+ executable('ck-setupd', controller)
+ ota_script = rewrite(tree / 'bin/ck-ota-start', root / 'ota-start.sh')
+ setup_script = rewrite(tree / 'bin/ck-setup-start', root / 'setup-start.sh')
+ pending = root / 'var/run/ck-identity-pending'
+
+ def reached(event):
+ return events.exists() and event in events.read_text().splitlines()
+
+ try:
+ ota = launch(ota_script)
+ assert wait_for(lambda: (root / 'initializer.pid').exists()), 'import not started'
+ if scenario == 'initializer_killed':
+ initializer = int((root / 'initializer.pid').read_text())
+ # In the public branch the initializer is a background ash.
+ assert initializer != ota.pid, 'expected independent background initializer'
+ os.kill(initializer, signal.SIGKILL)
+ setup = launch(setup_script)
+ expected_dhcp = scenario == 'success' or (not public and scenario == 'import_error')
+ blocked = scenario in ('import_hang', 'initializer_killed')
+ if expected_dhcp:
+ assert wait_for(lambda: reached('dhcp-launch-reached')), 'DHCP launch not reached'
+ elif blocked:
+ # Accelerated sleep allows many poll iterations, with no cleanup.
+ time.sleep(.4)
+ assert pending.exists() and setup.poll() is None, 'pending gate did not persist'
+ assert not reached('identity-helper-called'), 'identity unexpectedly started'
+ else:
+ assert wait_for(lambda: setup.poll() is not None), 'setup wrapper did not exit'
+ assert setup.returncode != 0, 'fault did not propagate'
+ assert reached('dhcp-launch-reached') == expected_dhcp
+ return {
+ 'scenario': scenario, 'public_image': public,
+ 'expected_observation_confirmed': True,
+ 'identity_pending': pending.exists(),
+ 'rescue_marker': (root / 'var/run/ck-rescue').exists(),
+ 'identity_helper_reached': reached('identity-helper-called'),
+ 'controller_reached': reached('setup-controller-called'),
+ 'dhcp_launch_reached': reached('dhcp-launch-reached'),
+ 'setup_exit_status': setup.poll(),
+ }
+ finally:
+ for p in processes:
+ try:
+ os.killpg(p.pid, signal.SIGKILL)
+ except ProcessLookupError:
+ pass
+ for p in processes:
+ p.wait(timeout=3)
+ for log in logs:
+ log.close()
+
+
+def main():
+ parser = argparse.ArgumentParser(description=__doc__)
+ parser.add_argument('--tree', required=True, type=Path)
+ parser.add_argument('--old-tree', type=Path)
+ parser.add_argument('--output', required=True, type=Path)
+ args = parser.parse_args()
+ resource.setrlimit(resource.RLIMIT_CORE, (0, 0))
+ runner = ['qemu-mips', '-L', str(args.tree), str(args.tree / 'bin/busybox'), 'sh']
+ cases = [run_case(args.tree, runner, name) for name in (
+ 'success', 'import_error', 'import_crash', 'import_hang',
+ 'initializer_killed', 'identity_crash', 'controller_reject', 'mib_error')]
+ if args.old_tree:
+ cases.append(run_case(args.old_tree, runner, 'import_error', public=False))
+ report = {
+ 'status': 'pass', 'environment': 'actual v0.2.6 BusyBox ash under qemu-user in offline Linux container',
+ 'actual_scripts_with_fixture_path_rewrites': True,
+ 'mocks': ['updater', 'identity helper', 'Wi-Fi MIB helper', 'setup controller', 'network commands'],
+ 'no_physical_device_or_network_access': True,
+ 'limitation': 'Injected faults establish startup consequences, not which fault occurred on hardware. DHCP launch marker is not a DHCP exchange.',
+ 'sha256': {path: digest(args.tree / path) for path in (
+ 'bin/busybox', 'bin/ck-ota-start', 'bin/ck-setup-start', 'bin/ck-setup-backend')},
+ 'cases': cases,
+ }
+ args.output.write_text(json.dumps(report, indent=2) + '\n')
+ print(json.dumps(report, indent=2))
+
+
+if __name__ == '__main__':
+ main()
diff --git a/reverse/tools/verify_boa_factory.py b/reverse/tools/verify_boa_factory.py
new file mode 100644
index 0000000..739e44e
--- /dev/null
+++ b/reverse/tools/verify_boa_factory.py
@@ -0,0 +1,254 @@
+#!/usr/bin/env python3
+"""Execute original Boa MIPS validation/planning in Unicorn, without an OS.
+
+Requires unicorn==2.1.4. Every external operation is a stub; MTD writes are
+represented only by a plan, then applied to an in-memory bytearray.
+"""
+import argparse
+import hashlib
+import json
+import lzma
+import struct
+import sys
+from pathlib import Path
+from unicorn import Uc, UC_ARCH_MIPS, UC_MODE_MIPS32, UC_MODE_BIG_ENDIAN, UC_HOOK_CODE
+from unicorn.mips_const import (UC_MIPS_REG_A0, UC_MIPS_REG_A1, UC_MIPS_REG_A2,
+ UC_MIPS_REG_A3, UC_MIPS_REG_V0, UC_MIPS_REG_RA, UC_MIPS_REG_SP, UC_MIPS_REG_PC,
+ UC_MIPS_REG_T9)
+from analyze_elf import dynsym
+
+ROOT = Path(__file__).resolve().parents[2]
+sys.path.insert(0, str(ROOT/'tools'))
+from factory_image import inspect, CANONICAL_MARK, journal_initializer
+from fwlib import sha
+
+
+def boot_checks(loader, flash):
+ """Run the vendor selector/checksums with only SPI copy/console/poll stubbed."""
+ u = Uc(UC_ARCH_MIPS, UC_MODE_MIPS32 | UC_MODE_BIG_ENDIAN)
+ u.mem_map(0,0x1000000)
+ u.mem_map(0x1d000000,0x800000)
+ u.mem_write(0x400000,loader)
+ u.mem_write(0x1d000000,bytes(flash))
+ def stub(name):
+ def hook(u,address,size,data):
+ if name == 'spi_copy':
+ dest,offset,n=[u.reg_read(r) for r in (UC_MIPS_REG_A0,UC_MIPS_REG_A1,UC_MIPS_REG_A2)]
+ assert offset+n <= len(flash)
+ u.mem_write(dest & 0x1fffffff,bytes(flash[offset:offset+n]))
+ u.reg_write(UC_MIPS_REG_V0,0)
+ u.reg_write(UC_MIPS_REG_PC,u.reg_read(UC_MIPS_REG_RA))
+ return hook
+ for address,name in [(0x80404a94,'spi_copy'),(0x804079cc,'console'),
+ (0x804077a4,'console'),(0x80407de0,'poll')]:
+ u.hook_add(UC_HOOK_CODE,stub(name),begin=address,end=address)
+ u.hook_add(UC_HOOK_CODE,lambda u,a,n,d:u.emu_stop(),begin=0x80900000,end=0x80900000)
+ u.reg_write(UC_MIPS_REG_SP,0x808ff000)
+ u.reg_write(UC_MIPS_REG_RA,0x80900000)
+ u.reg_write(UC_MIPS_REG_A0,1)
+ u.emu_start(0x804086b0,0x80900004,timeout=30_000_000,count=150_000_000)
+ assert u.reg_read(UC_MIPS_REG_V0) != 0
+ return struct.unpack('>I',u.mem_read(0x40fdb0,4))[0]
+
+
+class Boa:
+ def __init__(self, binary, flash, image, dual=True):
+ self.u = Uc(UC_ARCH_MIPS, UC_MODE_MIPS32 | UC_MODE_BIG_ENDIAN)
+ self.flash, self.image, self.dual = flash, image, dual
+ self.messages, self.lines, self.fds = [], [], {}
+ self.redirect, self.exited, self.next_fd = None, False, 20
+ self.alloc_next = 0x1800000
+ symbols, deps, ph, tags = dynsym(binary)
+ self.u.mem_map(0x400000, 0x400000)
+ for t, o, v, _, fs, ms, _, _ in ph:
+ if t == 1:
+ self.u.mem_write(v, binary[o:o+fs])
+ self.u.mem_map(0x1000000, 0x1000000)
+ self.u.mem_map(0x3000000, 0x1000)
+ self.u.mem_map(0x70000000, 0x100000)
+ # Dynamic imports in the vendor image are pointers to its MIPS stubs.
+ imports = json.loads(args.imports.read_text())['imports']
+ for row in imports:
+ if 'plt' in row and row.get('type') == 'FUNC':
+ self.hook(row['plt'], row['name'])
+ for address, name in [(0x4132b4, 'req_get_cstream_var'),
+ (0x412160, 'req_format_write'), (0x40e6f0, 'send_redirect_perm'),
+ (0x441988, 'page_header'), (0x41e0ec, 'write_line_to_file'),
+ (0x41f66c, 'kill_processes'), (0x40b4d0, 'clear_fwupload_shm')]:
+ self.hook(address, name)
+ self.u.hook_add(UC_HOOK_CODE, lambda u, a, n, d: u.emu_stop(), begin=0x3000000, end=0x3000000)
+ self.request, self.upload = 0x1000000, 0x1100000
+ prefix = b'Content-Disposition: form-data; name="binary"; filename="first-install.bin"\r\n'
+ prefix += b'X-Padding: ' + b'a' * (256-len(prefix)-11-4-42)
+ prefix += b'\r\nContent-Type: application/octet-stream\r\n\r\n'
+ # Keep the uploaded kernel aligned for the original MIPS FirmwareUpgrade.
+ if len(prefix) % 4:
+ prefix = b'X:' + b'a' * ((-len(prefix)-4) % 4) + b'\r\n' + prefix
+ assert len(prefix) % 4 == 0
+ self.body = prefix + image + b'\r\n--end--\r\n'
+ self.u.mem_write(self.upload, self.body)
+ self.put(self.request+0x98, self.upload)
+ self.put(self.request+0x9c, len(self.body))
+ self.offset = len(prefix)
+
+ def hook(self, address, name):
+ self.u.hook_add(UC_HOOK_CODE, lambda u, a, n, d: self.external(name), begin=address, end=address)
+
+ def read(self, p, n): return bytes(self.u.mem_read(p, n))
+ def string(self, p):
+ if not p: return ''
+ b = bytearray()
+ while len(b) < 20000:
+ c = self.read(p+len(b), 1)
+ if c == b'\0': return b.decode('latin1')
+ b += c
+ raise ValueError('unterminated string')
+ def put(self, p, n): self.u.mem_write(p, struct.pack('>I', n & 0xffffffff))
+ def alloc(self, data):
+ p = self.alloc_next
+ self.u.mem_write(p, data+b'\0'); self.alloc_next += (len(data)+16) & ~7
+ return p
+ def external(self, name):
+ u = self.u
+ a, b, c, d = [u.reg_read(r) for r in [UC_MIPS_REG_A0, UC_MIPS_REG_A1, UC_MIPS_REG_A2, UC_MIPS_REG_A3]]
+ result = 0
+ if name == 'memcmp': result = (self.read(a,c) > self.read(b,c)) - (self.read(a,c) < self.read(b,c))
+ elif name == 'strcmp': result = (self.string(a) > self.string(b)) - (self.string(a) < self.string(b))
+ elif name in ('strcpy', 'memcpy', 'memmove'):
+ data = self.string(b).encode('latin1')+b'\0' if name == 'strcpy' else self.read(b,c)
+ u.mem_write(a,data); result = a
+ elif name == 'memset': u.mem_write(a, bytes([b & 255])*c); result = a
+ elif name == 'strlen': result = len(self.string(a))
+ elif name == 'strstr':
+ pos = self.string(a).find(self.string(b)); result = a+pos if pos >= 0 else 0
+ elif name == 'strchr':
+ pos = self.string(a).find(chr(b)); result = a+pos if pos >= 0 else 0
+ elif name == 'req_get_cstream_var': result = self.alloc(b'/upload.htm')
+ elif name == 'req_format_write': self.messages.append(self.string(b)); result = 1
+ elif name == 'send_redirect_perm': self.redirect = self.string(b)
+ elif name == 'sprintf':
+ fmt = self.string(b)
+ # The planner formats decimal offsets, lengths and shm IDs only.
+ if fmt == '%d': text = str(c if c < 2**31 else c-2**32)
+ elif fmt == '%d\n': text = str(c)+'\n'
+ else: text = fmt.replace('%d',str(c),1).replace('%x',format(c,'x'),1)
+ u.mem_write(a,text.encode()+b'\0'); result = len(text)
+ elif name in ('printf','puts','fprintf'): result = 1
+ elif name == 'apmib_get':
+ assert a == 0x514
+ self.put(b, int(self.dual)); result = 1
+ elif name == 'fopen':
+ assert self.string(a) == '/proc/bootbank'
+ result = 9
+ elif name == 'fgets': u.mem_write(a,b'2\0'); result = a
+ elif name in ('fclose','page_header','clear_fwupload_shm','kill_processes','system','sleep','usleep','sync'): pass
+ elif name == 'open':
+ path = self.string(a)
+ assert path in ('/dev/mtdblock0','/dev/mtdblock1','/dev/mtdblock3','/dev/mtdblock4'), path
+ result = self.next_fd; self.next_fd += 1; self.fds[result] = [path,0]
+ elif name == 'lseek': self.fds[a][1] = b; result = b
+ elif name == 'read':
+ path, offset = self.fds[a]
+ base = {'/dev/mtdblock0':0,'/dev/mtdblock1':0x130000,'/dev/mtdblock3':0x400000,'/dev/mtdblock4':0x530000}[path]
+ u.mem_write(b,self.flash[base+offset:base+offset+c]); self.fds[a][1] += c; result = c
+ elif name == 'close': pass
+ elif name == 'write_line_to_file':
+ path, text = self.string(a), self.string(c)
+ if path == '/var/fwd.conf':
+ if b == 1: self.lines = []
+ self.lines.append(text)
+ else: assert path == '/var/fwd.ready', path
+ result = 1
+ elif name == 'exit': self.exited = True; u.emu_stop(); return
+ else: raise RuntimeError('unstubbed external: '+name)
+ u.reg_write(UC_MIPS_REG_V0, result & 0xffffffff)
+ u.reg_write(UC_MIPS_REG_PC, u.reg_read(UC_MIPS_REG_RA))
+
+ def call(self, address, values):
+ self.u.reg_write(UC_MIPS_REG_SP, 0x700ff000)
+ self.u.reg_write(UC_MIPS_REG_RA, 0x3000000)
+ self.u.reg_write(UC_MIPS_REG_T9, address)
+ for r,v in zip([UC_MIPS_REG_A0,UC_MIPS_REG_A1,UC_MIPS_REG_A2,UC_MIPS_REG_A3],values): self.u.reg_write(r,v)
+ self.u.emu_start(address, 0x3000004, timeout=30_000_000, count=100_000_000)
+
+ def validate(self):
+ self.call(0x442f14,[self.request])
+ return self.redirect == '/countDownPage.htm'
+
+ def plan(self):
+ self.call(0x4438f8,[self.upload,len(self.body),0,0x1700000])
+ assert self.exited and len(self.lines) == 8, self.lines
+ plans = []
+ for i in range(0,len(self.lines),4):
+ path, seek, length, offset = self.lines[i:i+4]
+ seek,length,offset = map(int,(seek,length,offset))
+ plans.append({'device':path,'seek':seek,'length':length,'upload_offset':offset})
+ return plans
+
+
+def main():
+ global args
+ p = argparse.ArgumentParser(description=__doc__)
+ p.add_argument('--boa',type=Path,required=True)
+ p.add_argument('--imports',type=Path,required=True)
+ p.add_argument('--reference',type=Path,required=True)
+ p.add_argument('--image',type=Path,required=True)
+ p.add_argument('--output',type=Path,required=True)
+ p.add_argument('--bootloader',type=Path)
+ args = p.parse_args()
+ binary, flash, image = args.boa.read_bytes(), args.reference.read_bytes(), args.image.read_bytes()
+ assert sha(binary) in ('2e4b024ca170468c58ace898807f0364c8b23adc6ef234ebc64f9589a464a65a',
+ '175c66efa68eabda38c8ca6990e824ddcc41dfec0219bc739bce7b8ffe0a587a')
+ inspect(image)
+ boa = Boa(binary,flash,image)
+ assert boa.validate(), boa.messages
+ plans = boa.plan()
+ assert [(p['device'],p['seek'],p['length']) for p in plans] == [
+ ('/dev/mtdblock0',0x10000,0x120000),('/dev/mtdblock1',0,0x2b0000)], plans
+ result = bytearray(flash)
+ for item in plans:
+ base = 0 if item['device'] == '/dev/mtdblock0' else 0x130000
+ n = item['length']; offset = item['upload_offset']
+ result[base+item['seek']:base+item['seek']+n] = boa.read(boa.upload+offset,n)
+ assert result[:0x10000] == flash[:0x10000] and result[0x3e0000:] == flash[0x3e0000:]
+ assert result[0x3c0000:0x3e0000] in (journal_initializer(False),journal_initializer(True))
+ # The compressed kernel and embedded loader stay byte-for-byte intact.
+ original_n=struct.unpack_from('>I',flash,0x41000c)[0]
+ assert result[0x10010:0x10010+original_n] == flash[0x410010:0x410010+original_n]
+ original_decoder=lzma.LZMADecompressor(format=lzma.FORMAT_ALONE)
+ original_kernel=original_decoder.decompress(flash[0x412818:0x410010+original_n])
+ factory_decoder=lzma.LZMADecompressor(format=lzma.FORMAT_ALONE)
+ factory_kernel=factory_decoder.decompress(result[0x12818:0x130000])
+ assert original_decoder.eof and factory_decoder.eof
+ assert original_kernel == factory_kernel
+ active_mark = struct.unpack_from('>I',flash,0x410008)[0]
+ mark = struct.unpack_from('>I',result,0x10008)[0]
+ assert mark == active_mark+1 and mark > active_mark
+ normalized = bytearray(result[0x10000:0x130000]);struct.pack_into('>I',normalized,8,CANONICAL_MARK)
+ for offset in (32,0x120000+16+100,-16):
+ damaged = bytearray(image);damaged[offset] ^= 1
+ assert not Boa(binary,flash,bytes(damaged)).validate(), offset
+ report = {'status':'pass','method':'original MIPS Boa executed in Unicorn 2.1.4; every external operation stubbed',
+ 'boa_sha256':sha(binary),'image_sha256':sha(image),'physical_flash_access':False,
+ 'actual_mtd_driver_tested':False,'factory_boot_tested':False,
+ 'stock_bootbank':2,'dual_firmware':True,'writes':plans,
+ 'kernel_bankmark':hex(mark),'canonical_factory_kernel_sha256':sha(normalized),
+ 'preserved_ranges':[[0,0x10000],[0x3e0000,0x800000]],
+ 'journal_initializer':'auto-v1' if result[0x3d0000:0x3d000f] == b'CKAUTOINIT-V1\r\n' else 'erased',
+ 'damaged_kernel_rootfs_trailer_rejected':True}
+ report['decompressed_kernel_unchanged']=True
+ report['decompressed_kernel_sha256']=sha(factory_kernel)
+ if args.bootloader:
+ loader=args.bootloader.read_bytes()
+ assert boot_checks(loader,result) == 1
+ broken=bytearray(result);broken[0x130100]^=1
+ assert boot_checks(loader,broken) == 2
+ report.update({'bootloader_sha256':sha(loader),
+ 'original_boot_selector_and_checksums_passed':True,
+ 'corrupt_bank1_rootfs_selects_bank2_in_emulator':True})
+ args.output.parent.mkdir(parents=True,exist_ok=True)
+ args.output.write_text(json.dumps(report,indent=2)+'\n')
+ print(json.dumps(report,indent=2))
+
+
+if __name__ == '__main__': main()
diff --git a/reverse/tools/verify_protocol_release.py b/reverse/tools/verify_protocol_release.py
new file mode 100644
index 0000000..ea3a8cb
--- /dev/null
+++ b/reverse/tools/verify_protocol_release.py
@@ -0,0 +1,98 @@
+#!/usr/bin/env python3
+"""Inspect the actual signed-payload filesystem; never access an outlet."""
+import argparse
+import hashlib
+import json
+import re
+import sys
+from pathlib import Path
+
+ROOT=Path(__file__).resolve().parents[2]
+sys.path.insert(0,str(ROOT/'tools'))
+from fwlib import read_file,file_inventory,rootfs_check
+
+def digest(data):return hashlib.sha256(data).hexdigest()
+
+def main():
+ p=argparse.ArgumentParser(description=__doc__)
+ p.add_argument('--release-dir',type=Path,required=True)
+ p.add_argument('--test-logs',type=Path)
+ p.add_argument('--output',type=Path,required=True)
+ a=p.parse_args()
+ package=(a.release_dir/'matter.ota').read_bytes();root=package[256:]
+ assert package[:8]==b'CKOTA1\r\n' and int.from_bytes(package[20:24],'big')>=10
+ version=int.from_bytes(package[20:24],"big")
+ hap=read_file(root,'bin/homekitserver');original=bytearray(hap)
+ for offset,command in ((0x1ae5b8,b'flash default-sw\0'),(0x1ae5e0,b'homekitserver -c\0')):
+ action=b'/bin/ck-reset' if version>=11 and offset==0x1ae5b8 else b'true'
+ replacement=action.ljust(len(command)-1,b' ')+b'\0'
+ assert original[offset:offset+len(command)]==replacement
+ original[offset:offset+len(command)]=command
+ if version>=11:
+ assert original[0x1b8e4:0x1b8e8]==bytes.fromhex("24040000")
+ original[0x1b8e4:0x1b8e8]=bytes.fromhex("24040001")
+ if version>=13:
+ from homekit_led import restore
+ original=bytearray(restore(original,json.loads(read_file(root,"etc/ck-homekit-led.json"))))
+ else:assert len(hap)==2116412
+ assert digest(original)=='2d50339436759f3d602c150fb4f1a42f0fdf122fc18e9a4f54f6e6b07d7a0c62'
+ inv=file_inventory(root)
+ for lib in ['libgcc_s.so.1','libapmib.so','libpthread.so.0','libdl.so.0','librt.so.0','libm.so.0','libc.so.0','ld-uClibc.so.0']:
+ assert 'lib/'+lib in inv
+ assert 'bin/boa' not in inv and not any(n.startswith('web/') for n in inv)
+ rc=read_file(root,'etc/init.d/rcS');inittab=read_file(root,'etc/inittab')
+ assert inittab.count(b'::respawn:/bin/ck-protocol-start\n')==1
+ for old in (b'\nhomekitserver\n',b'/bin/matter-test-start &',b'\nboa\n'):assert old not in rc
+ sources={'bin/ck-updater':'firmware/matter/artifacts/update/ck-updater',
+ 'bin/ck-updated':'firmware/matter/artifacts/update/ck-updated',
+ 'bin/ck-boot-guard':'firmware/matter/artifacts/update/ck-boot-guard',
+ 'bin/matterd-test':'firmware/matter/artifacts/softap-device-code/matterd-test',
+ 'bin/ck-setupd':'firmware/matter/artifacts/softap-device-code/ck-setupd',
+ 'bin/ck-protocol-start':'firmware/matter/src/setup/ck-protocol-start',
+ 'bin/matter-test-start':'firmware/matter/src/setup/matter-relay-start',
+ 'bin/ck-ota-start':'firmware/matter/src/update/ck-ota-start'}
+ if version>=11:
+ assert inittab.count(b"::respawn:/bin/ck-management-start\n")==1
+ assert read_file(root,"etc/ck-admin-policy")==b"password-v1\n"
+ for n in ("ck-management-start","ck-reset"):sources["bin/"+n]="firmware/matter/src/setup/"+n
+ hashes={}
+ for dest,source in sources.items():
+ data=read_file(root,dest);assert data==(ROOT/source).read_bytes(),dest
+ hashes[dest]=digest(data)
+ report={'status':'pass','version':read_file(root,'etc/ck-version').decode().strip(),
+ 'software_version':int.from_bytes(package[20:24],'big'),'real_outlet':False,
+ 'checks':['exact production binaries and scripts match payload',
+ 'one supervised protocol owner; no direct rcS protocol launch',
+ 'HomeKit original segments restored byte-for-byte; reset patches and LED observer verified',
+ 'original HAP shared libraries retained; unused Boa/router GUI removed',
+ 'version 13: hash-bound LED observer segment and exact reversible patch manifest' if version>=13 else 'legacy HAP layout'],
+ 'rootfs':rootfs_check(root),'ota_sha256':digest(package),
+ 'original_homekit_sha256':digest(original),'homekit_sha256':digest(hap),
+ 'homekit_elf_bytes':len(hap),'production_sha256':hashes,
+ 'limitations':['No physical HomeKit/MFi, RF or iPhone pairing validation',
+ 'HomeKit pauses custom web measurements and kWh accumulation; saved checkpoints remain',
+ 'HomeKit daemon lifecycle tests use a fixture',
+ 'Network VM uses 64 MiB/Linux 4.19 and MIB/import fixtures, not actual 32 MiB hardware',
+ 'No CH341A write, live reset/switch, git push or publication performed']}
+ report['rootfs_free_bytes']=0x290000-report['rootfs']['checksum_extent']
+ if a.test_logs:
+ if version>=11:
+ names=['led-v0.2.11/full-combined.log','led-v0.2.11/deployment.log'] if version>=13 else ['v0.2.10-recovery-update-tests.log','v0.2.10-recovery-deployment-tests.log'] if version>=12 else ['v0.2.9-update-tests.log','v0.2.9-deployment-tests.log','v0.2.9-portal-tests.log','v0.2.9-password-tests.log']
+ count=55 if version>=13 else 50 if version>=12 else 48
+ log=(a.test_logs/names[0]).read_text()
+ assert len(re.findall(r'Ran '+str(count)+r' tests[^\n]*\n\nOK',log))==2
+ deploy=(a.test_logs/names[1]).read_text()
+ assert re.search(r'Ran 13 tests[^\n]*\n\nOK \(skipped=1\)',deploy)
+ report['tests']={'native_storage_ota_http_admin_protocol':count,'mips_storage_ota_http_admin_protocol':count,
+ 'deployment_and_hap_layout_passed':12,'diagnostic_variant_not_shipped_skipped':1}
+ else:
+ names=['v0.2.8-update-tests.log','v0.2.8-deployment-tests.log','v0.2.8-portal-tests.log']
+ log=(a.test_logs/names[0]).read_text()
+ assert len(re.findall(r'Ran 39 tests[^\n]*\n\nOK',log))==2
+ deploy=(a.test_logs/names[1]).read_text();assert re.search(r'Ran 13 tests[^\n]*\n\nOK',deploy)
+ report['tests']={'native_storage_ota_http_protocol':39,'mips_storage_ota_http_protocol':39,'deployment_and_hap_layout':13}
+ report['tests']['test_log_sha256']={name:digest((a.test_logs/name).read_bytes()) for name in names}
+ a.output.write_text(json.dumps(report,ensure_ascii=False,indent=2)+'\n')
+ print(json.dumps({k:report[k] for k in ('status','ota_sha256','rootfs_free_bytes')},indent=2))
+
+if __name__=='__main__':main()
diff --git a/tests/hardware_fallback_init.c b/tests/hardware_fallback_init.c
index ddce6dd..a328726 100644
--- a/tests/hardware_fallback_init.c
+++ b/tests/hardware_fallback_init.c
@@ -16,6 +16,8 @@ int main(void) {
mount("ramfs", "/var", "ramfs", 0, NULL);
mkdir("/var/run", 0700);
mkdir("/var/log", 0700);
+ int pending = open("/var/run/ck-identity-pending", O_WRONLY | O_CREAT, 0600);
+ if (pending >= 0) close(pending);
puts("MCU_ONLY_VM_READY_NO_IDENTITY");
sleep(2);
int phase = 0;
@@ -32,7 +34,7 @@ int main(void) {
_exit(127);
}
int control = open("/dev/ttyS0", O_RDONLY | O_NONBLOCK), status = -1;
- for (unsigned tick = 0; tick < 200; tick++) {
+ for (unsigned tick = 0; tick < 400; tick++) {
if (waitpid(child, &status, WNOHANG) == child) break;
char command[64] = {0};
if (control >= 0 && read(control, command, sizeof(command)-1) > 0 && strstr(command, "stop")) {
diff --git a/tests/network_boot_init.c b/tests/network_boot_init.c
new file mode 100644
index 0000000..25d0b83
--- /dev/null
+++ b/tests/network_boot_init.c
@@ -0,0 +1,32 @@
+/* Offline Malta fixture: real vendor BusyBox PID1 + real DHCP, no MTD/RF. */
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+static int module(const char *path) {
+ int fd=open(path,O_RDONLY); if(fd<0)return -1;
+ int n=lseek(fd,0,SEEK_END);lseek(fd,0,SEEK_SET);
+ void *data=malloc(n);if(!data)return -1;
+ int result=read(fd,data,n)==n && !syscall(SYS_init_module,data,n,"");
+ free(data);close(fd);return result?0:-1;
+}
+int main(void) {
+ mount("proc","/proc","proc",0,NULL);mount("ramfs","/var","ramfs",0,NULL);
+ for(const char **p=(const char*[]){"/var/run","/var/log","/var/lock","/var/lib","/var/lib/ck-state",NULL};*p;p++)mkdir(*p,0700);
+ if(module("/mii.ko")||module("/pcnet32.ko"))return 3;
+ int fd=socket(AF_INET,SOCK_DGRAM,0);struct ifreq r;
+ memset(&r,0,sizeof(r));strcpy(r.ifr_name,"eth0");strcpy(r.ifr_newname,"br0");
+ if(ioctl(fd,SIOCSIFNAME,&r))return 4;
+ memset(&r,0,sizeof(r));strcpy(r.ifr_name,"br0");
+ ioctl(fd,SIOCGIFFLAGS,&r);r.ifr_flags|=IFF_UP;
+ if(ioctl(fd,SIOCSIFFLAGS,&r))return 5;
+ close(fd);setenv("PATH","/bin",1);
+ execl("/bin/busybox","busybox","init",NULL);return 127;
+}
diff --git a/tests/serve_admin_fixture.py b/tests/serve_admin_fixture.py
new file mode 100644
index 0000000..a714e3e
--- /dev/null
+++ b/tests/serve_admin_fixture.py
@@ -0,0 +1,63 @@
+#!/usr/bin/env python3
+"""Disposable browser fixture using the real C HTTP service and file-only NOR."""
+import hashlib,json,os,signal,socket,socketserver,struct,subprocess,sys,threading,time
+from pathlib import Path
+from test_updates import Updates
+Updates.setUpClass();fixture=Updates('test_persistence_and_noop_wear');fixture.setUp()
+salt=bytes(range(16));key=hashlib.pbkdf2_hmac('sha256',b'FixtureAdmin42',salt,600000,32)
+record=b'CKAUTH1\n'+(600000).to_bytes(4,'big')+salt+key
+assert fixture.put('admin/credential',record)==0
+assert fixture.lib.ck_energy_save(1234567,0)==0
+(fixture.work/'etc/ck-version').write_text('0.2.11-preview\n')
+initial=os.environ.get('CK_FIXTURE_PROTOCOL','matter');assert initial in ('matter','homekit')
+assert fixture.put('config/protocol',initial.encode())==0
+(fixture.work/'var/run/ck-protocol-active').write_text(initial)
+(fixture.work/'var/run/matter-manual-code').write_text('12345678901')
+fixture.port=18983
+def start_server():return subprocess.Popen([str(fixture.server_binary),'18983','lo'],stdout=subprocess.DEVNULL,stderr=subprocess.DEVNULL)
+server=start_server()
+led=None
+if os.environ.get('CK_FIXTURE_LED')=='1':
+ for path in ['proc/wlan0','var/log']:(fixture.work/path).mkdir(parents=True,exist_ok=True)
+ (fixture.work/'proc/wlan0/gpio_ctrl').write_bytes(b' '*32)
+ (fixture.work/'var/run/ck-setup-state').write_text('state=online\n')
+ led=subprocess.Popen([str(fixture.updater),'--run-led'],stdout=subprocess.DEVNULL,stderr=subprocess.DEVNULL)
+
+class Proxy(socketserver.BaseRequestHandler):
+ def handle(self):
+ remote=socket.create_connection(('127.0.0.1',18983),timeout=5)
+ def transfer(source,dest):
+ try:
+ while True:
+ data=source.recv(65536)
+ if not data:break
+ dest.sendall(data)
+ except OSError:pass
+ try:dest.shutdown(socket.SHUT_WR)
+ except OSError:pass
+ thread=threading.Thread(target=transfer,args=(self.request,remote),daemon=True);thread.start();transfer(remote,self.request);remote.close()
+class TCP(socketserver.ThreadingTCPServer):allow_reuse_address=True;daemon_threads=True
+proxy=TCP((socket.gethostbyname(socket.gethostname()),18983),Proxy)
+threading.Thread(target=proxy.serve_forever,daemon=True).start()
+try:
+ print('ADMIN_FIXTURE_READY port=18983; synthetic credentials and NOR only',flush=True)
+ while True:
+ pending=fixture.work/'var/run/ck-protocol-pending'
+ if pending.exists():
+ mode=pending.read_text()
+ if mode in ('matter','homekit'):
+ # Simulate only the RAM/service restart; no HAP, network or real reboot.
+ server.terminate();server.wait(timeout=5)
+ (fixture.work/'var/run/ck-protocol-active').write_text(mode)
+ pending.unlink();(fixture.work/'var/run/ck-matter-request.lock').rmdir()
+ server=start_server()
+ if led:
+ now=int(time.monotonic()*1000);mode=(fixture.work/'var/run/ck-protocol-active').read_text()
+ (fixture.work/('var/run/ck-led-'+mode+'.bin')).write_bytes(b'CKLEDHW1'+struct.pack('>QQQQIIII',now,now,now,0,6000,31,now//5000,0))
+ if (fixture.work/'var/run/ck-protocol-active').read_text()=='matter':fixture.meter_snapshot()
+ else:(fixture.work/'var/run/ck-meter.bin').unlink(missing_ok=True)
+ (fixture.work/'var/run/ck-matter-state.json').write_text(json.dumps({'updated_s':int(time.monotonic()),'fabrics':1,'window_open':False,'result':'idle','request_id':''},separators=(',',':')))
+ time.sleep(1)
+finally:
+ if led:led.terminate();led.wait()
+ proxy.shutdown();server.terminate();server.wait();fixture.tearDown();Updates.tearDownClass()
diff --git a/tests/test_boot_guard.py b/tests/test_boot_guard.py
new file mode 100644
index 0000000..6fe5e12
--- /dev/null
+++ b/tests/test_boot_guard.py
@@ -0,0 +1,105 @@
+#!/usr/bin/env python3
+"""Fixed-job watchdog faults in regular-file fixtures; no RF/MTD access."""
+import os
+from pathlib import Path
+import shlex
+import signal
+import subprocess
+import sys
+import tempfile
+import time
+import unittest
+
+if sys.platform.startswith('linux'):
+ import resource
+ resource.setrlimit(resource.RLIMIT_CORE,(0,0))
+
+ROOT=Path(__file__).resolve().parents[1]
+
+
+@unittest.skipUnless(sys.platform.startswith('linux'),'Linux prctl/process groups')
+class BootGuard(unittest.TestCase):
+ @classmethod
+ def setUpClass(cls):
+ cls.build=tempfile.TemporaryDirectory(prefix='ck-boot-guard-build-')
+ cls.binary=Path(os.environ.get('CK_BOOT_BIN',str(Path(cls.build.name)/'guard')))
+ cls.runner=shlex.split(os.environ.get('CK_BOOT_RUNNER',''))
+ if not cls.runner:
+ subprocess.run(['cc','-DCK_TESTING','-O2','-Wall','-Wextra','-Werror',
+ str(ROOT/'firmware/matter/src/setup/boot_guard.c'),'-o',str(cls.binary)],check=True)
+
+ @classmethod
+ def tearDownClass(cls):cls.build.cleanup()
+
+ def setUp(self):
+ self.temp=tempfile.TemporaryDirectory(prefix='ck-boot-guard-test-')
+ self.root=Path(self.temp.name)
+ for folder in ['bin','var/run','var/log','var/lib/ck-state']:(self.root/folder).mkdir(parents=True)
+ self.env={**os.environ,'CK_BOOT_TEST_ROOT':str(self.root),'CK_BOOT_TEST_SECONDS':'1'}
+
+ def tearDown(self):self.temp.cleanup()
+
+ def stub(self,name,body):
+ p=self.root/'bin'/name;p.write_text('#!/bin/sh\n'+body+'\n');p.chmod(0o700)
+
+ def command(self,mode):return [*self.runner,str(self.binary),mode]
+
+ def run_guard(self,mode,**kwargs):
+ return subprocess.run(self.command(mode),capture_output=True,env=self.env,timeout=6,**kwargs)
+
+ def test_fixed_modes_and_child_exit(self):
+ self.stub('ck-wificfg','echo configured=1; exit 0')
+ self.assertEqual(self.run_guard('--mib').stdout,b'configured=1\n')
+ self.stub('matterd-test','test "$1" = --init-identity && test "$2" = wlan0; exit 7')
+ self.assertEqual(self.run_guard('--identity').returncode,7)
+ self.assertEqual(self.run_guard('/bin/sh').returncode,2)
+
+ def test_crash_is_reported(self):
+ self.stub('ck-updater','kill -SEGV $$')
+ self.assertEqual(self.run_guard('--import').returncode,139)
+
+ def test_hanging_import_is_killed_and_can_retry(self):
+ self.stub('ck-updater','exec /bin/sleep 30')
+ result=self.run_guard('--import')
+ self.assertEqual(result.returncode,124)
+ self.assertIn(b'timeout',result.stderr)
+ self.stub('ck-updater','exit 0')
+ self.assertEqual(self.run_guard('--import').returncode,0)
+
+ def test_setup_requires_fresh_heartbeat(self):
+ self.stub('ck-setupd','exec /bin/sleep 30')
+ self.assertEqual(self.run_guard('--setup').returncode,124)
+ self.env['CK_BOOT_TEST_SECONDS']='2'
+ heartbeat=shlex.quote(str(self.root/'var/run/ck-setup-heartbeat'))
+ self.stub('ck-setupd','while :; do read up rest < /proc/uptime; echo "${up%%.*}" > '+heartbeat+'; sleep .1; done')
+ with tempfile.TemporaryFile() as output:
+ p=subprocess.Popen(self.command('--setup'),env=self.env,stdout=output,stderr=output)
+ try:
+ time.sleep(3)
+ self.assertIsNone(p.poll())
+ p.terminate();self.assertEqual(p.wait(timeout=4),143)
+ finally:
+ if p.poll() is None:p.kill();p.wait()
+
+ def test_dhcp_log_is_capped_and_retains_tail(self):
+ data=bytes(range(256))*100
+ self.assertEqual(self.run_guard('--dhcp-log',input=data).returncode,0)
+ self.assertEqual((self.root/'var/log/ck-dhcp.log').read_bytes(),data[-8192:])
+
+ def test_partial_heartbeat_keeps_last_valid_sample(self):
+ self.env['CK_BOOT_TEST_SECONDS']='3'
+ heartbeat=shlex.quote(str(self.root/'var/run/ck-setup-heartbeat'))
+ self.stub('ck-setupd','i=0; while [ "$i" -lt 20 ]; do '
+ 'read up rest < /proc/uptime; echo "${up%%.*}" > '+heartbeat+'; '
+ 'sleep .1; i=$((i+1)); done; : > '+heartbeat+'; exec sleep 30')
+ with tempfile.TemporaryFile() as output:
+ p=subprocess.Popen(self.command('--setup'),env=self.env,stdout=output,stderr=output)
+ try:
+ time.sleep(3.5)
+ self.assertIsNone(p.poll())
+ p.terminate();self.assertEqual(p.wait(timeout=4),143)
+ finally:
+ if p.poll() is None:p.kill();p.wait()
+
+
+if __name__=='__main__':unittest.main(verbosity=2)
diff --git a/tests/test_deployment.py b/tests/test_deployment.py
index 4d064e9..85afc74 100644
--- a/tests/test_deployment.py
+++ b/tests/test_deployment.py
@@ -76,6 +76,37 @@ def test_protected_bytes_and_stable_keys(self):
self.assertEqual((self.profile / "diag.key").stat().st_mode & 0o777, 0o600)
self.assertEqual(self.output.stat().st_mode & 0o777, 0o600)
+ def test_homekit_patch_preserves_original_elf_and_one_boot_owner(self):
+ root=self.release.read_bytes()[fw.START:fw.END]
+ meta=fw.read_file(root,'etc/ck-update.meta')
+ if struct.unpack('>III',meta)[2]<10:self.skipTest('protocol selection requires software version 10')
+ hap=fw.read_file(root,'bin/homekitserver')
+ original=bytearray(hap)
+ for offset,command in ((0x1ae5b8,b'flash default-sw\0'),(0x1ae5e0,b'homekitserver -c\0')):
+ action=b'/bin/ck-reset' if struct.unpack('>III',meta)[2]>=11 and command.startswith(b'flash') else b'true'
+ replacement=action.ljust(len(command)-1,b' ')+b'\0'
+ self.assertEqual(original[offset:offset+len(command)],replacement)
+ original[offset:offset+len(command)]=command
+ if struct.unpack('>III',meta)[2]>=11:
+ self.assertEqual(original[0x1b8e4:0x1b8e8],bytes.fromhex('24040000'))
+ original[0x1b8e4:0x1b8e8]=bytes.fromhex('24040001')
+ if struct.unpack('>III',meta)[2]>=13:
+ from homekit_led import restore
+ record=json.loads(fw.read_file(root,'etc/ck-homekit-led.json'))
+ original=restore(original,record)
+ self.assertEqual(fw.read_file(root,'etc/inittab').count(b'::respawn:/bin/ck-led-start\n'),1)
+ self.assertEqual(hashlib.sha256(original).hexdigest(),'2d50339436759f3d602c150fb4f1a42f0fdf122fc18e9a4f54f6e6b07d7a0c62')
+ inventory=fw.file_inventory(root)
+ self.assertNotIn('bin/boa',inventory)
+ self.assertFalse(any(n.startswith('web/') for n in inventory))
+ for name in ['libgcc_s.so.1','libapmib.so','libpthread.so.0','libdl.so.0','librt.so.0','libm.so.0','libc.so.0','ld-uClibc.so.0']:
+ self.assertIn('lib/'+name,inventory)
+ inittab=fw.read_file(root,'etc/inittab')
+ self.assertEqual(inittab.count(b'::respawn:/bin/ck-protocol-start\n'),1)
+ rc=fw.read_file(root,'etc/init.d/rcS')
+ for old in [b'\nhomekitserver\n',b'/bin/matter-test-start &',b'\nboa\n']:
+ self.assertNotIn(old,rc)
+
def test_different_devices_get_different_keys(self):
self.prepare()
other = bytearray(self.before)
@@ -99,6 +130,7 @@ def test_lost_profile_key_not_silently_rotated(self):
def test_diagnostic_profile_uses_private_wpa2_credentials(self):
release = Path(os.environ["CK_RELEASE_DIR"])
image = release / "exec-test-8m-region.bin"
+ if not image.exists():self.skipTest("本版只交付 HomeKit/Matter 韌體")
meta = json.loads((release / "exec-test-8m-region.json").read_text())
meta["compatibility"] = fw.compatibility(self.before)
manifest = self.work / "exec.json"
diff --git a/tests/test_factory_host_compat.py b/tests/test_factory_host_compat.py
new file mode 100644
index 0000000..b8a6830
--- /dev/null
+++ b/tests/test_factory_host_compat.py
@@ -0,0 +1,47 @@
+#!/usr/bin/env python3
+"""Strict host recovery checks for the known Boa kernel wrapper; no hardware."""
+import argparse,json,struct,sys
+from pathlib import Path
+ROOT=Path(__file__).resolve().parents[1]
+sys.path.insert(0,str(ROOT/'tools'))
+from fwlib import START,compatible,compatibility,image_checks,sha
+from factory_image import factory_kernel,identities
+
+def main():
+ p=argparse.ArgumentParser(description=__doc__)
+ p.add_argument('--reference',type=Path,required=True)
+ p.add_argument('--output',type=Path,required=True)
+ a=p.parse_args();legacy=a.reference.read_bytes()
+ expected=json.loads((ROOT/'third_party/platform.json').read_text());compatible(legacy,expected)
+ wrapped=bytearray(legacy);kernel=bytearray(factory_kernel(legacy,expected));struct.pack_into('>I',kernel,8,0x8000004d)
+ wrapped[0x10000:START]=kernel
+ assert compatibility(wrapped)['bank1_kernel_sha256']==identities()['FACTORY']
+ accepted=0;rejected=0
+ for mark in [0x8000004d,0x80000050,0xfffffff0]:
+ changed=bytearray(wrapped);struct.pack_into('>I',changed,0x10008,mark)
+ before=bytes(changed);compatible(changed,expected);assert changed==before;accepted+=1
+ for offset in [0x10004,0x1000c,0x10010,0x120000,0x12ffff,0x410010,0x530100]:
+ changed=bytearray(wrapped);changed[offset]^=1
+ before=bytes(changed)
+ try:compatible(changed,expected)
+ except ValueError:rejected+=1
+ else:raise AssertionError('accepted a modified executable/header/padding')
+ assert changed==before
+ for mark in [0,0x10000,0x80000001]:
+ changed=bytearray(wrapped);struct.pack_into('>I',changed,0x10008,mark)
+ try:compatible(changed,expected)
+ except ValueError:rejected+=1
+ else:raise AssertionError('accepted an invalid boot mark')
+ # A malicious kernel with a freshly repaired checksum must still fail hash matching.
+ changed=bytearray(wrapped);changed[0x10080]^=1;changed[START-2:START]=b'\0\0'
+ total=sum(x[0] for x in struct.iter_unpack('>H',changed[0x10010:START]))
+ struct.pack_into('>H',changed,START-2,(-total)&65535);image_checks(changed)
+ try:compatible(changed,expected)
+ except ValueError:rejected+=1
+ else:raise AssertionError('accepted modified code with a valid checksum')
+ report={'status':'pass','physical_device_access':False,'accepted_known_wrapper_marks':accepted,
+ 'rejected_code_header_padding_bank2_or_invalid_mark':rejected,
+ 'legacy_kernel_sha256':identities()['LEGACY'],'factory_kernel_sha256':identities()['FACTORY'],
+ 'input_preserved':True,'checks':['same strict kernel alternative as production OTA verifier','only bank mark normalized; full code/padding/checksum hashed','Bank 2 identity retained','mutated code rejected even with repaired checksum']}
+ a.output.write_text(json.dumps(report,indent=2)+'\n');print(json.dumps(report,indent=2))
+if __name__=='__main__':main()
diff --git a/tests/test_factory_upgrade.py b/tests/test_factory_upgrade.py
new file mode 100644
index 0000000..0769997
--- /dev/null
+++ b/tests/test_factory_upgrade.py
@@ -0,0 +1,113 @@
+#!/usr/bin/env python3
+"""Test a private factory image -> signed OTA using the actual MIPS updater.
+
+Only regular files inside a temporary Docker fixture are written. The next
+version is a synthetic test package, signed with a fresh throwaway key.
+"""
+import argparse
+import json
+import os
+import struct
+import subprocess
+import sys
+import tempfile
+from pathlib import Path
+from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
+from cryptography.hazmat.primitives import serialization
+
+ROOT=Path(__file__).resolve().parents[1]
+sys.path.insert(0,str(ROOT/'tools'))
+from factory_image import inspect, CANONICAL_MARK, KERNEL_SIZE
+from fwlib import read_file, sha, START, END, file_inventory
+
+
+def main():
+ p=argparse.ArgumentParser(description=__doc__)
+ p.add_argument('--image',type=Path,required=True)
+ p.add_argument('--reference',type=Path,required=True)
+ p.add_argument('--release-dir',type=Path,required=True)
+ p.add_argument('--output',type=Path,required=True)
+ a=p.parse_args()
+ image=a.image.read_bytes();root=inspect(image);reference=a.reference.read_bytes()
+ flash=bytearray(reference)
+ kernel=bytearray(image[:KERNEL_SIZE]);struct.pack_into('>I',kernel,8,CANONICAL_MARK)
+ flash[0x10000:START]=kernel;flash[START:0x3e0000]=image[KERNEL_SIZE+16:-16]
+ next_package=bytearray((a.release_dir/'matter.ota').read_bytes())
+ with tempfile.TemporaryDirectory(prefix='ck-factory-ota-') as name:
+ work=Path(name);os.chmod(work,0o700)
+ for path in ['etc','var/lock','var/lib/ck-state','var/run/ck-ota']:(work/path).mkdir(parents=True,exist_ok=True)
+ inventory=file_inventory(root)
+ public_image='etc/matter-device.key' not in inventory
+ paths=['etc/ck-update.meta']+[path for path in ['etc/matter-device.key','etc/matter-diag.key','etc/ck-identity-policy'] if path in inventory]
+ for path in paths:
+ (work/path).write_bytes(read_file(root,path));(work/path).chmod(0o600)
+ # Test identity and trust are local; the production signing key is never read.
+ key=Ed25519PrivateKey.generate()
+ public=key.public_key().public_bytes(serialization.Encoding.Raw,serialization.PublicFormat.Raw)
+ (work/'etc/ck-update.pub').write_bytes(public)
+ next_version=struct.unpack_from('>I',next_package,20)[0]+1
+ struct.pack_into('>I',next_package,20,next_version)
+ next_package[128:160]=public;next_package[192:256]=key.sign(bytes(next_package[:192]))
+ (work/'next.ota').write_bytes(next_package)
+ (work/'seed.bin').write_bytes(os.urandom(32))
+ target=work/'flash.bin';target.write_bytes(flash);target.chmod(0o600)
+ prefix=['docker','run','--rm','--network','none','-e','CK_TEST_ROOT=/fixture',
+ '-e','CK_TEST_SEED_FILE=/fixture/seed.bin',
+ '-e','CK_FLASH_FILE=/fixture/flash.bin','-v',str(work)+':/fixture',
+ '-v',str(ROOT)+':/repo:ro','incipio-public-build:v0.1.0-preview',
+ 'qemu-mips','/repo/firmware/matter/artifacts/update-test/ck-updater']
+ def run(*args,success=True):
+ cp=subprocess.run(prefix+list(args),capture_output=True,timeout=30)
+ assert (cp.returncode==0)==success,(args,cp.returncode,cp.stderr[-500:])
+ run('--import');run('--boot');run('--confirm');run('--verify','/fixture/next.ota')
+ if public_image:
+ assert (work/'var/run/ck-first-setup').exists()
+ run('--onboarded');run('--import')
+ assert not (work/'var/run/ck-first-setup').exists()
+ (work/'seed.bin').unlink()
+ def onboarding():
+ command=['docker','run','--rm','--mac-address','02:11:22:33:44:55',
+ '-v',str(work/'etc/ck-update.meta')+':/etc/ck-update.meta:ro',
+ '-v',str(work/'var/lib/ck-state')+':/var/lib/ck-state:ro',
+ '-v',str(work/'var/run')+':/var/run',
+ '-v',str(ROOT)+':/repo:ro','incipio-public-build:v0.1.0-preview',
+ 'qemu-mips','/repo/firmware/matter/artifacts/softap-device-code/matterd-test',
+ '--init-identity','eth0']
+ subprocess.run(command,capture_output=True,check=True,timeout=30)
+ return {name:(work/'var/run'/name).read_bytes() for name in ['matter-device-identity','matter-manual-code','matter-qr.svg']}
+ codes=onboarding()
+ assert len(codes['matter-manual-code'])==11 and b'