Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 5 additions & 2 deletions backend/api/routes/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,11 +44,12 @@ async def post_config(
)
modes = data.get("modes", [])
logger.info(
"[CONFIG SAVE REQUEST] source=%s mac=%s modes=%s refresh_strategy=%s",
"[CONFIG SAVE REQUEST] source=%s mac=%s modes=%s refresh_strategy=%s always_active=%s",
x_inksight_client or "unknown",
mac,
len(modes) if isinstance(modes, list) else 0,
data.get("refresh_strategy"),
data.get("always_active"),
)
config_id = await save_config(mac, data)
await set_pending_refresh(mac, True)
Expand All @@ -63,9 +64,11 @@ async def post_config(
saved_config = await get_active_config(mac)
if saved_config:
logger.info(
"[CONFIG VERIFY] Saved config id=%s refresh_strategy=%s",
"[CONFIG VERIFY] Saved config id=%s refresh_strategy=%s always_active=%s is_always_active=%s",
saved_config.get("id"),
saved_config.get("refresh_strategy"),
saved_config.get("always_active"),
saved_config.get("is_always_active"),
)

return ConfigSaveResponse(ok=True, config_id=config_id)
Expand Down
19 changes: 12 additions & 7 deletions backend/api/routes/device.py
Original file line number Diff line number Diff line change
Expand Up @@ -124,20 +124,25 @@ async def device_state(
if ota_url and "/firmware/download" in ota_url and "/api/firmware/download" not in ota_url:
state["ota_url"] = ota_url.replace("/firmware/download", "/api/firmware/download")

explicit_mode = str(state.get("runtime_mode") or "").lower()
if explicit_mode in ("active", "interval"):
state["runtime_mode"] = explicit_mode
return state

runtime_mode = "interval"
last_poll = state.get("last_state_poll_at", "")
recent_poll = False
if isinstance(last_poll, str) and last_poll:
try:
delta = (datetime.now() - datetime.fromisoformat(last_poll)).total_seconds()
runtime_mode = "active" if delta <= 8 else "interval"
recent_poll = delta <= 8
except ValueError:
logger.warning("[DEVICE] Invalid last_state_poll_at for %s: %s", mac, last_poll, exc_info=True)
runtime_mode = "interval"
recent_poll = False

explicit_mode = str(state.get("runtime_mode") or "").lower()
if explicit_mode == "active":
runtime_mode = "active" if recent_poll else "interval"
elif explicit_mode == "interval":
runtime_mode = "interval"
elif recent_poll:
runtime_mode = "active"

state["runtime_mode"] = runtime_mode
return state

Expand Down
11 changes: 8 additions & 3 deletions backend/core/config_store.py
Original file line number Diff line number Diff line change
Expand Up @@ -1473,8 +1473,13 @@ async def revoke_device_member(owner_user_id: int, mac: str, target_user_id: int
async def save_config(mac: str, data: dict) -> int:
now = datetime.now().isoformat()
refresh_strategy = data.get("refreshStrategy", "random")
always_active = 1 if bool(data.get("always_active", False)) else 0
logger.info(
f"[CONFIG SAVE] mac={mac}, refreshStrategy={refresh_strategy}, modes={data.get('modes')}"
"[CONFIG SAVE] mac=%s, refreshStrategy=%s, always_active=%s, modes=%s",
mac,
refresh_strategy,
always_active,
data.get("modes"),
)

db = await get_main_db()
Expand Down Expand Up @@ -1525,7 +1530,7 @@ async def save_config(mac: str, data: dict) -> int:
memo_text,
mode_overrides_json,
1 if bool(data.get("is_focus_listening", False)) else 0,
1 if bool(data.get("always_active", False)) else 0,
always_active,
now,
),
)
Expand All @@ -1544,7 +1549,7 @@ async def save_config(mac: str, data: dict) -> int:
)

await db.commit()
logger.info(f"[CONFIG SAVE] Saved as id={config_id}, is_active=1")
logger.info("[CONFIG SAVE] Saved as id=%s, is_active=1, always_active=%s", config_id, always_active)
return config_id


Expand Down
33 changes: 33 additions & 0 deletions backend/tests/test_integration.py
Original file line number Diff line number Diff line change
Expand Up @@ -395,6 +395,39 @@ async def test_config_save_preserves_active_runtime_mode(client):
assert state["pending_refresh"] == 1


@pytest.mark.asyncio
async def test_stale_active_runtime_mode_reports_interval_for_web_state(client):
mac = "AA:BB:CC:DD:EE:13"
headers = await provision_device_headers(client, mac)
from core.config_store import update_device_state

claim_resp = await client.post(f"/api/device/{mac}/claim-token", headers=headers)
assert claim_resp.status_code == 200

await register_user(client, "stale_runtime")
consume_resp = await client.post("/api/claim/consume", json={"token": claim_resp.json()["token"]})
assert consume_resp.status_code == 200

heartbeat_resp = await client.post(
f"/api/v1/device/{mac}/heartbeat",
json={"battery_voltage": 3.91, "wifi_rssi": -42},
headers=headers,
)
assert heartbeat_resp.status_code == 200

await update_device_state(
mac,
runtime_mode="active",
last_state_poll_at="2000-01-01T00:00:00",
)

state_resp = await client.get(f"/api/device/{mac}/state")
assert state_resp.status_code == 200
state = state_resp.json()
assert state["is_online"] is True
assert state["runtime_mode"] == "interval"


@pytest.mark.asyncio
async def test_config_save_persists_always_active_flag(client):
mac = "AA:BB:CC:DD:EE:12"
Expand Down
60 changes: 53 additions & 7 deletions firmware/data/portal_html.h
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,10 @@ body{font-family:var(--f);background:linear-gradient(135deg,#f5f5f0,#e8e8e0);col
.wi:hover{border-color:var(--bk);background:var(--bg)}
.wi.sel{border-color:var(--bk);background:var(--bg)}
.wn{font-size:.85rem;font-weight:500;display:flex;align-items:center;gap:6px}
.wa{display:flex;align-items:center;gap:6px;flex-shrink:0}
.wc{background:var(--bk);border:none;cursor:pointer;color:#fff;padding:5px 8px;border-radius:6px;font-size:.72rem;font-weight:600;line-height:1;flex-shrink:0}
.wc:hover{background:#333}
.wc:disabled,.wx:disabled{opacity:.6;cursor:not-allowed}
.wx{background:none;border:none;cursor:pointer;color:var(--gy);padding:4px 6px;border-radius:6px;font-size:1.1rem;line-height:1;flex-shrink:0}
.wx:hover{color:#dc2626;background:#fef2f2}
.si-ord{display:inline-flex;align-items:center;justify-content:center;width:18px;height:18px;border-radius:50%;background:var(--bg);border:1px solid var(--bd);font-size:.66rem;color:var(--gy);flex-shrink:0}
Expand Down Expand Up @@ -205,11 +209,14 @@ srvUrlPh:"例如: http://192.168.1.100:8080",
srvPortPh:"例如: 3000",
btnConn:"连接并保存",
btnAdd:"仅保存到列表",
btnSavedConn:"连接",
savedTitle:"已保存网络",
addHint:"连不上的网络(如办公室/手机热点)可仅保存,开机会按顺序尝试",
msgAddOk:"已保存到列表",
msgFull:"最多保存 5 个网络",
msgDelOk:"已删除",
msgSavedConnFail:"连接失败,密码可能已变更,请重新输入密码后连接并保存",
msgSavedNotFound:"未找到该已保存网络",
s2Title:"配网完成",
s2Next:"下一步:",
s2Auto:"设备将自动重启并联网,请使用配对码继续完成认领与配置。",
Expand Down Expand Up @@ -263,11 +270,14 @@ srvUrlPh:"e.g. http://192.168.1.100:8080",
srvPortPh:"e.g. 3000",
btnConn:"Connect & Save",
btnAdd:"Save to List",
btnSavedConn:"Connect",
savedTitle:"Saved Networks",
addHint:"Networks not reachable here (office / phone hotspot) can be saved only; tried in order on boot",
msgAddOk:"Saved to list",
msgFull:"Up to 5 networks allowed",
msgDelOk:"Removed",
msgSavedConnFail:"Connection failed. The password may have changed; enter it again and use Connect & Save.",
msgSavedNotFound:"Saved network not found",
s2Title:"Setup Complete",
s2Next:"Next Step: ",
s2Auto:"Device will restart and connect. Use the pairing code to bind.",
Expand Down Expand Up @@ -440,21 +450,26 @@ if(i.type==='password'){i.type='text';b.innerHTML='<svg width="14" height="14" v
else{i.type='password';b.innerHTML='<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg>';}
}

function setServerForConnect(st){
var sv=srvMode==='official'?OFFICIAL_SERVER:document.getElementById('srvIn').value.trim();
var fp=srvMode==='official'?LOCAL_DEFAULT_FRONTEND_PORT:document.getElementById('frontendPortIn').value.trim();
if(sv&&!sv.match(/^https?:\/\//)){st.className='st e';st.textContent=t('errUrl');return false;}
if(srvMode==='custom'&&!/^\d{2,5}$/.test(fp)){st.className='st e';st.textContent=t('errPort');return false;}
srvUrl=normalizeServerUrl(sv);
frontendPort=fp;
return true;
}

function doConnect(){
var s=ssid||document.getElementById('ssidIn').value.trim();
var p=document.getElementById('pwIn').value;
var sv=srvMode==='official'?OFFICIAL_SERVER:document.getElementById('srvIn').value.trim();
var fp=srvMode==='official'?LOCAL_DEFAULT_FRONTEND_PORT:document.getElementById('frontendPortIn').value.trim();
var st=document.getElementById('pSt'),btn=document.getElementById('cBtn');
if(!s){st.className='st e';st.textContent=t('errSsid');return;}
if(!p){st.className='st e';st.textContent=t('errPw');return;}
if(p.length<8){st.className='st e';st.textContent=t('errPwLen');return;}
if(sv&&!sv.match(/^https?:\/\//)){st.className='st e';st.textContent=t('errUrl');return;}
if(srvMode==='custom'&&!/^\d{2,5}$/.test(fp)){st.className='st e';st.textContent=t('errPort');return;}
if(!setServerForConnect(st))return;
btn.classList.add('ld');btn.disabled=true;
st.className='st c';st.textContent=t('msgConn')+s+' ...';
srvUrl=normalizeServerUrl(sv);
frontendPort=fp;

var fd=new FormData();fd.append('ssid',s);fd.append('pass',p);if(srvUrl)fd.append('server',srvUrl);
fetch('/save_wifi',{method:'POST',body:fd}).then(function(r){return r.json()}).then(function(d){
Expand Down Expand Up @@ -545,9 +560,13 @@ var left=document.createElement('span');left.className='wn';
var ord=document.createElement('span');ord.className='si-ord';ord.textContent=(idx+1);
var nm=document.createElement('span');nm.textContent=name;
left.appendChild(ord);left.appendChild(nm);
var actions=document.createElement('span');actions.className='wa';
var conn=document.createElement('button');conn.className='wc';conn.type='button';conn.textContent=t('btnSavedConn');
conn.onclick=function(){connectSaved(name,conn)};
var del=document.createElement('button');del.className='wx';del.type='button';del.innerHTML='&times;';
del.onclick=function(){delNet(name)};
li.appendChild(left);li.appendChild(del);
actions.appendChild(conn);actions.appendChild(del);
li.appendChild(left);li.appendChild(actions);
ul.appendChild(li);
});
cnt.textContent=names.length+'/'+savedMax;
Expand All @@ -569,6 +588,33 @@ st.className='st w';st.textContent=t('msgDelOk');
}).catch(function(){st.className='st e';st.textContent=t('msgReqFail');});
}

function connectSaved(name,btn){
var st=document.getElementById('pSt');
if(!setServerForConnect(st))return;
btn.disabled=true;
st.className='st c';st.textContent=t('msgConn')+name+' ...';
var fd=new FormData();fd.append('ssid',name);if(srvUrl)fd.append('server',srvUrl);
fetch('/connect_saved',{method:'POST',body:fd}).then(function(r){return r.json()}).then(function(d){
btn.disabled=false;
if(d&&d.list){renderSaved(d.list);}
if(d.ok){
st.className='st s';st.textContent=t('msgConnOk');
document.getElementById('cSSID').textContent=name;
pairCode=(d.pair_code||'').toUpperCase();
showSuccess();
}else if(d.msg==='SAVED_CONNECT_FAILED'){
st.className='st e';st.textContent=t('msgSavedConnFail');
}else if(d.msg==='NOT_FOUND'){
st.className='st e';st.textContent=t('msgSavedNotFound');
}else{
st.className='st e';st.textContent=d.msg||t('msgConnFail');
}
}).catch(function(){
btn.disabled=false;
st.className='st e';st.textContent=t('msgReqFail');
});
}

function doAddOnly(){
var s=ssid||document.getElementById('ssidIn').value.trim();
var p=document.getElementById('pwIn').value;
Expand Down
17 changes: 14 additions & 3 deletions firmware/src/audio_codec.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,16 @@
#define I2S_MIC_PORT I2S_NUM_1
#define I2S_SPK_PORT I2S_NUM_0

#if defined(BOARD_PROFILE_SMT_WROOM32E)
#define I2S_MIC_CHANNEL_FORMAT I2S_CHANNEL_FMT_ONLY_LEFT
#else
#define I2S_MIC_CHANNEL_FORMAT I2S_CHANNEL_FMT_ONLY_RIGHT
#endif

#ifndef I2S_MIC_SAMPLE_SHIFT
#define I2S_MIC_SAMPLE_SHIFT 16
#endif

// ── Inmp441Max98357Codec ─────────────────────────────────────

Inmp441Max98357Codec::Inmp441Max98357Codec(bool duplex) {
Expand All @@ -26,7 +36,7 @@ static bool installHalfDuplexInputDriver() {
.mode = (i2s_mode_t)(I2S_MODE_MASTER | I2S_MODE_RX),
.sample_rate = CODEC_SAMPLE_RATE,
.bits_per_sample = I2S_BITS_PER_SAMPLE_32BIT,
.channel_format = I2S_CHANNEL_FMT_ONLY_RIGHT,
.channel_format = I2S_MIC_CHANNEL_FORMAT,
.communication_format = I2S_COMM_FORMAT_STAND_I2S,
.intr_alloc_flags = ESP_INTR_FLAG_LEVEL1,
.dma_buf_count = 4,
Expand Down Expand Up @@ -102,7 +112,7 @@ static bool installDuplexMicDriver() {
.mode = (i2s_mode_t)(I2S_MODE_MASTER | I2S_MODE_RX),
.sample_rate = CODEC_SAMPLE_RATE,
.bits_per_sample = I2S_BITS_PER_SAMPLE_32BIT,
.channel_format = I2S_CHANNEL_FMT_ONLY_RIGHT,
.channel_format = I2S_MIC_CHANNEL_FORMAT,
.communication_format = I2S_COMM_FORMAT_STAND_I2S,
.intr_alloc_flags = ESP_INTR_FLAG_LEVEL1,
.dma_buf_count = 6,
Expand Down Expand Up @@ -242,8 +252,9 @@ int Inmp441Max98357Codec::Read(int16_t* dest, int maxSamples) {

int samplesRead = bytesRead / sizeof(int32_t);
if (samplesRead > maxSamples) samplesRead = maxSamples;

for (int i = 0; i < samplesRead; i++) {
dest[i] = (int16_t)(read_buf_[i] >> 16);
dest[i] = (int16_t)(read_buf_[i] >> I2S_MIC_SAMPLE_SHIFT);
}
return samplesRead;
}
Expand Down
Loading
Loading