| name | substore-openclash |
| description | Manage the SubStore -> OpenClash config pipeline for WeChat image direct routing and mihomo config fixes, AND fix homebridge-miot Xiaomi login errors. Use this skill when the user says "wechat images not loading", "微信图片看不到", "fix tfo", "tfo string error", "sync SubStore config", "update AIO.yaml", "inject clash rules", or any request to modify the Clash/mihomo config generated by SubStore and consumed by the OpenClash gateway on 10.10.0.1. Also trigger when diagnosing fake-ip issues, proxy group "not found" errors, or subscription generation failures. ALSO trigger for homebridge-miot Xiaomi login failures: "Premature close", "Could not find identity session cookie", "2FA login failed", "MiCloud connect error", "account.xiaomi.com login failed", "微信小米登录失败" — these are caused by the node-fetch v2 / headers.raw() incompatibility with Node 18+ and fixed by patching the plugin, NOT by gateway changes. |
SubStore -> OpenClash Config Pipeline
Manage the Clash/mihomo configuration generated by SubStore (Docker on frad-nas) and consumed by OpenClash (OpenWrt gateway on 10.10.0.1). All persistent config changes go into SubStore at the source — the OpenClash gateway only pulls and runs the result. Do NOT edit gateway-side custom files for rules that must survive a subscription refresh; the gateway's config/AIO.yaml is a generated artifact and gets overwritten on update.
Topology
- frad-nas (Unraid, SSH host
frad-nas): runs the SubStore Docker container (xream/sub-store:latest, port 3001). State lives in /mnt/user/appdata/sub-store/data/sub-store.json (bind-mounted to /opt/app/data in the container). This is the ONLY place to make persistent config changes.
- 10.10.0.1 (OpenWrt, SSH
root@10.10.0.1): runs OpenClash. Current config is /etc/openclash/config/AIO.yaml; the actually-running config is /etc/openclash/AIO.yaml (OpenClash copies raw -> tmp -> final during startup). It subscribes to SubStore at http://<nas-ip>:3001/sub-api/api/file/AIO (no token needed for read). The NAS IP is whatever frad-nas resolves to on the gateway (host route on br-lan); confirm with ip route on the NAS.
- The pipeline: SubStore
组合订阅 collection merges subs VoyraCloud (local) + HXY (remote) + oixCloud (remote) -> the AIO file (type mihomoProfile, sourceType collection) runs its process operators -> final Clash YAML. OpenClash pulls that URL as one of its config_subscribe entries.
The AIO file and how its process operators work
The AIO file in files[] has sourceType: collection, type: mihomoProfile, and a process array. The first operator is powerfullz/override-rules convert.min.js (mode link) — it generates the full rules/dns/proxy-groups/sniffer from the merged nodes.
CRITICAL: file-type Script Operators are YAML-merge, NOT JavaScript
This is the single most important fact and the source of repeated failures. In the SubStore bundle, the Script Operator func for a file target (['mihomoConfig','mihomoProfile'].includes($file?.type)) is:
if (Si(a?.$file)) {
let u = yi.safeLoad(e);
let p = a?.$content ? yi.safeLoad(a?.$content) : {};
return a.$content = yaml.safeDump(x$(p || {}, u));
}
Consequences:
- The
content field of a file-type Script Operator is parsed as a YAML document and deep-merged into the generated config. The mode field (script vs link) is ignored for file operators.
- Writing JavaScript in
content does NOTHING useful — it gets parsed as YAML (usually producing {} or a parse error), so the merge is a no-op. This is why every JS attempt (shortcut style, operator($content,...) function, $content = yaml.dump(cfg) assignment) silently failed.
- To override the generated config, put a YAML document in
content.
- Merge semantics (
x$(p, u)): scalar keys are overwritten; ARRAY keys (like rules, fake-ip-filter) are REPLACED by default, NOT appended. This means writing rules: in an override will REPLACE powerfullz's entire rules array (thousands of proxy rules) with just your few entries — breaking ALL proxy routing (Google/YouTube/Telegram fall through to DIRECT and timeout). This is catastrophic and silent.
- To APPEND to an array instead of replacing it, prefix the key with
+ (prepend) or suffix with + (append):
+rules: → prepend your rules BEFORE the original rules (use this for direct-routing rules that should match first)
rules+: → append your rules AFTER the original rules
+fake-ip-filter: → prepend to the fake-ip-filter array (under dns:)
- Without the
+, the array is REPLACED entirely.
- Also keep all additions in ONE override operator (a later
rules: operator would still replace an earlier one's). The correct pattern is a single operator using +rules: and +fake-ip-filter:.
The correct override YAML (prepend rules + prepend fake-ip-filter, preserving powerfullz's full config):
+rules:
- DOMAIN-SUFFIX,qpic.cn,DIRECT
- DOMAIN-SUFFIX,miio.com,DIRECT
- DOMAIN-KEYWORD,xiaomi,DIRECT
dns:
+fake-ip-filter:
- "geosite:cn"
- "+.qpic.cn"
- "+.miio.com"
- A collection-type Script Operator (on
组合订阅.process) is different — it runs JS with signature async function operator(proxies, targetPlatform, context) and mutates the node proxies array. That is for node transforms only (e.g. the Quick Setting Operator), not for final YAML rules.
Generating / testing the AIO output
ssh frad-nas 'docker restart SubStore >/dev/null 2>&1 && sleep 9'
ssh frad-nas 'curl -s "http://127.0.0.1:3001/sub-api/api/file/AIO?token=<SUBSTORE_AIO_TOKEN>&target=ClashMeta" --max-time 90 -o /tmp/aio.yaml -w "HTTP %{http_code}, size %{size_download}\n"'
ssh frad-nas 'docker run --rm -v /tmp:/work metacubex/mihomo -t -f /work/aio.yaml 2>&1 | tail -3'
If generation returns HTTP 500 with "子订阅 HXY 发生错误", HXY's airport endpoint is returning 403/down — that is an airport-side problem, not a SubStore problem. To regenerate for testing while HXY is down, temporarily remove HXY from 组合订阅.subscriptions, restart SubStore, generate, then restore. Always restore subscriptions to ["VoyraCloud","HXY","oixCloud"] before finishing.
Editing sub-store.json
The file is JSON. Always back up first, then use node -e (or a heredoc) to mutate it — never hand-edit. Read/write via the host path /mnt/user/appdata/sub-store/data/sub-store.json (the container sees it at /opt/app/data/sub-store.json).
ssh frad-nas 'cp /mnt/user/appdata/sub-store/data/sub-store.json /mnt/user/appdata/sub-store/data/sub-store.json.bak.$(date +%Y%m%d_%H%M%S)'
To add/replace a YAML-override operator on the AIO file (write the YAML to a local file, then read it via node to avoid shell-escaping hell):
ssh frad-nas 'node << "NODEEOF"
const fs = require("fs");
const DB = "/mnt/user/appdata/sub-store/data/sub-store.json";
const yaml = fs.readFileSync("/tmp/override.yaml", "utf8");
const d = JSON.parse(fs.readFileSync(DB, "utf8"));
const f = d.files.find(x => x.name === "AIO");
const MARKER = "my-override";
f.process = f.process.filter(p => p.customName !== MARKER);
f.process.push({
type: "Script Operator",
args: { content: yaml, mode: "script", arguments: {} },
customName: MARKER,
id: String(Date.now()) + ".1",
disabled: false
});
fs.writeFileSync(DB, JSON.stringify(d, null, 2));
NODEEOF'
WeChat + Xiaomi direct routing fix
Symptom (WeChat): images, avatars, and Moments fail to load. Symptom (Xiaomi, network side): miio.com and miot-spec.com resolve to fake-ip 198.18.x.x because they are not in fake-ip-filter and have no DIRECT rule. Root cause: domestic CDN domains were not in fake-ip-filter and had no DIRECT rule, so fake-ip mode assigned them 198.18.x.x and/or they were routed through a proxy, which the domestic CDN rejects or drops.
NOTE: the WeChat image symptom is fixed by the routing override below. The Xiaomi account.xiaomi.com ... Premature close symptom is NOT fixed by routing — see the next section ("homebridge-miot Premature close") which is the real cause of that error.
The fix is a SINGLE YAML override on the AIO file (customName wechat-xiaomi-yaml-override) covering both. The override content (note the + prefix on rules and +fake-ip-filter — WITHOUT it, the arrays would be REPLACED, wiping powerfullz's entire ruleset and breaking all proxy routing):
+rules:
- DOMAIN-SUFFIX,qpic.cn,DIRECT
- DOMAIN-SUFFIX,qlogo.cn,DIRECT
- DOMAIN-SUFFIX,weixin.qq.com,DIRECT
- DOMAIN-SUFFIX,wx.qq.com,DIRECT
- DOMAIN-SUFFIX,servicewechat.com,DIRECT
- DOMAIN-SUFFIX,wechat.com,DIRECT
- DOMAIN-KEYWORD,wechat,DIRECT
- DOMAIN-SUFFIX,miio.com,DIRECT
- DOMAIN-SUFFIX,miot-spec.com,DIRECT
- DOMAIN-SUFFIX,xiaomi.com,DIRECT
- DOMAIN-SUFFIX,xiaomi.net,DIRECT
- DOMAIN-SUFFIX,mi.com,DIRECT
- DOMAIN-SUFFIX,io.mi.com,DIRECT
- DOMAIN-KEYWORD,xiaomi,DIRECT
dns:
+fake-ip-filter:
- "geosite:cn"
- "geosite:private"
- "geosite:connectivity-check"
- "+.qpic.cn"
- "+.qlogo.cn"
- "+.weixin.qq.com"
- "+.wx.qq.com"
- "+.servicewechat.com"
- "+.wechat.com"
- "+.mmpriv.com"
- "+.miio.com"
- "+.miot-spec.com"
- "+.xiaomi.com"
- "+.xiaomi.net"
- "+.io.mi.com"
Why DIRECT and not the 直连 proxy-group: DIRECT is a built-in mihomo proxy that always exists, so it works regardless of merge timing. Using a named group like 直连 can fail with "proxy [直连] not found" if the override is applied before the group is defined.
To diagnose which domestic domains are broken (same class of issue): resolve them locally and flag any 198.18.x.x result as fake-ip-vulnerable:
for d in miio.com account.xiaomi.com miot-spec.com; do
ip=$(dig +short $d | head -1)
echo "$ip" | grep -q "^198\.18\." && echo "$d FAKE-IP" || echo "$d ok"
done
After adding, regenerate and verify the rules appear at the top of rules: and the domains appear in fake-ip-filter:.
homebridge-miot "Premature close" (NOT a gateway problem)
If a Xiaomi client (the homebridge-miot plugin in the homebridge Docker container on frad-nas, host network) reports Invalid response body ... Premature close against account.xiaomi.com/pass/serviceLogin?sid=xiaomiio&_json=true, do NOT chase the SubStore/Clash config first. The routing override above will NOT fix this.
First verify the gateway is fine: from the gateway,
ssh root@10.10.0.1 'curl -s -o /dev/null -w "HTTP %{http_code} %{time_total}s\n" --max-time 12 "https://account.xiaomi.com/pass/serviceLogin?sid=xiaomiio&_json=true"'
Should return HTTP 200 in <1s. If it does, the gateway/DNS/fake-ip are all fine — the problem is the client's HTTP library.
Actual root cause: homebridge-miot depends on node-fetch@2.x (unmaintained), which has a bug reading response bodies over keep-alive connections on Node 18+ (the body stream ends prematurely even though Content-Length is present). Native fetch (undici, built into Node 18+) and curl both succeed against the same URL — only node-fetch v2 fails. The homebridge container runs Node 24.
Reproduce (confirms it is node-fetch, not the network):
ssh frad-nas 'docker exec homebridge node -e "require(\"/homebridge/node_modules/homebridge-miot/node_modules/node-fetch\")(\"https://account.xiaomi.com/pass/serviceLogin?sid=xiaomiio&_json=true\").then(r=>r.text()).then(t=>console.log(\"OK\",t.length)).catch(e=>console.log(\"FAIL\",e.message))"'
If that prints FAIL ... Premature close while a plain curl from the same container succeeds, it is confirmed.
Fix: patch homebridge-miot to use Node's native fetch. Replace const fetch = require('node-fetch'); with const fetch = (globalThis.fetch ? globalThis.fetch.bind(globalThis) : require('node-fetch')); in:
/homebridge/node_modules/homebridge-miot/lib/protocol/MiCloud.js
/homebridge/node_modules/homebridge-miot/lib/protocol/MiotDevice.js
/homebridge/node_modules/homebridge-miot/lib/protocol/MiotSpecFetcher.js
/homebridge/node_modules/homebridge-miot/lib/tools/MiotSpecClassGenerator.js
Use a node script (not sed) to avoid quote-escaping issues. This path is under the container bind mount /mnt/user/appdata/homebridge -> /homebridge, so the patch PERSISTS across container restarts — but is lost if homebridge-miot is reinstalled/upgraded, so re-apply after upgrades. Then docker restart homebridge.
Second patch needed after the fetch patch — cookie handling. homebridge-miot also calls res.headers.raw() (node-fetch v2 API) in two places to read set-cookie headers; native fetch's Headers has no raw(). This breaks cookie storage, so after switching to native fetch the 2FA flow fails with 2FA login failed with error: Invalid response. Could not find identity session cookie (and step 3 fails with headers.raw is not a function). Patch lib/protocol/MiCloud.js in two spots:
_storeSetCookies(res) — replace the const raw = res.headers && typeof res.headers.raw === 'function' ? res.headers.raw() : {}; const setCookies = raw && raw['set-cookie'] ? raw['set-cookie'] : []; with a branch that falls back to res.headers.getSetCookie() (native fetch API returning an array).
- Login step 3 (~line 617) — replace
const headers = res.headers.raw(); const cookies = headers['set-cookie']; with the same typeof res.headers.raw === 'function' ? ... : (typeof res.headers.getSetCookie === 'function' ? res.headers.getSetCookie() : []) fallback.
After BOTH patches, docker restart homebridge, then re-trigger 2FA login from the Homebridge UI (submit the 2FA ticket) — identity_session cookie is now captured and 2FA proceeds. The next error (if any) is account-side — e.g. Two factor authentication required (open the printed URL in a browser, complete 2FA, retry) or code: 70016 登录验证失败 (wrong credentials) — not a network/library issue.
One-shot patch script
Apply BOTH patches (fetch swap + cookie fallback) in one go. Run from a host with SSH to frad-nas:
cat > /tmp/patch_homebridge_miot.js << 'PATCHEOF'
const fs = require("fs");
const base = "/homebridge/node_modules/homebridge-miot";
// Patch 1: swap require('node-fetch') for native fetch in all files that use it.
const fetchFiles = [
"lib/protocol/MiCloud.js",
"lib/protocol/MiotDevice.js",
"lib/protocol/MiotSpecFetcher.js",
"lib/tools/MiotSpecClassGenerator.js",
];
const OLD_FETCH = "const fetch = require('node-fetch');";
const NEW_FETCH = "const fetch = (globalThis.fetch ? globalThis.fetch.bind(globalThis) : require('node-fetch'));";
let fetchPatched = 0;
for (const rel of fetchFiles) {
const f = base + "/" + rel;
let c = fs.readFileSync(f, "utf8");
if (c.includes(OLD_FETCH)) {
c = c.replace(OLD_FETCH, NEW_FETCH);
fs.writeFileSync(f, c);
fetchPatched++;
}
}
console.log("fetch swap patched in", fetchPatched, "files");
// Patch 2a: _storeSetCookies — add getSetCookie() fallback.
const mc = base + "/lib/protocol/MiCloud.js";
let m = fs.readFileSync(mc, "utf8");
const reStore = /const raw = res\.headers && typeof res\.headers\.raw === 'function' \? res\.headers\.raw\(\) : \{\};\s*const setCookies = raw && raw\['set-cookie'\] \? raw\['set-cookie'\] : \[\];/;
if (reStore.test(m)) {
m = m.replace(reStore, "let setCookies = [];\n if (res.headers && typeof res.headers.raw === 'function') {\n const raw = res.headers.raw();\n setCookies = raw && raw['set-cookie'] ? raw['set-cookie'] : [];\n } else if (res.headers && typeof res.headers.getSetCookie === 'function') {\n setCookies = res.headers.getSetCookie();\n }");
console.log("_storeSetCookies patched");
} else { console.log("_storeSetCookies: no match (already patched?)"); }
// Patch 2b: login step 3 — replace bare res.headers.raw() with a getSetCookie fallback.
const reStep3 = /const headers = res\.headers\.raw\(\);\s*const cookies = headers\['set-cookie'\];/;
if (reStep3.test(m)) {
m = m.replace(reStep3, "const cookies = (typeof res.headers.raw === 'function') ? (res.headers.raw()['set-cookie'] || []) : (typeof res.headers.getSetCookie === 'function' ? res.headers.getSetCookie() : []);");
console.log("step3 cookie parse patched");
} else { console.log("step3: no match (already patched?)"); }
fs.writeFileSync(mc, m);
PATCHEOF
scp /tmp/patch_homebridge_miot.js frad-nas:/tmp/
ssh frad-nas 'docker cp /tmp/patch_homebridge_miot.js homebridge:/tmp/ && docker exec homebridge node /tmp/patch_homebridge_miot.js && docker exec homebridge rm /tmp/patch_homebridge_miot.js'
Backup the originals first (the script does NOT; run cp <file> <file>.bak if you want a rollback). After patching, docker restart homebridge.
Success verification
After the user completes 2FA in the Homebridge UI with the patches applied, login succeeds and the session is cached. Confirm:
- The UI prints
MiCloud session successfully saved! Please restart homebridge and add 'useCachedSession' to your micloud config!
useCachedSession: true is set in the platform's micloud block of /mnt/user/appdata/homebridge/config.json.
- Session cached at
/mnt/user/appdata/homebridge/.miot_micloud/cachedSession.
- After
docker restart homebridge, logs show Using cached session for MiCloud connection! → Successfully connected to MiCloud! → Connected to device: <model> → Starting property polling.. No Premature close, no identity session cookie error, no 2FA prompt.
Why this matters / lesson
The user reported "WeChat images broken" AND "Xiaomi login fails" together, which looked like one gateway/fake-ip problem. They were NOT the same problem:
- WeChat images → genuinely a gateway fake-ip/routing issue → fixed in SubStore (the
+rules: override above).
- Xiaomi login "Premature close" → a client HTTP library bug (node-fetch v2) → fixed by patching homebridge-miot. The SubStore routing override did NOT fix it and could not.
Before touching SubStore/Clash/gateway for any "domestic service fails" report, first test from the client with curl and native fetch against the failing URL. If those succeed, the gateway is fine — stop chasing the network and look at the client's HTTP library. The hour spent moving rules into SubStore for the Xiaomi case was wasted because the real bug was in the plugin. (The WeChat and tfo fixes were still legitimate and kept.)
homebridge-miot 426 "Upgrade Required" = SERVICETOKEN_EXPIRED (recurring every ~7 days)
A SEPARATE failure mode from "Premature close" above. The user hits this repeatedly (~once a week), so recognize it fast and do NOT chase HTTP/2 / node-fetch / gateway.
Symptom: docker logs homebridge shows, every poll cycle:
[空调插座] Using cached session for MiCloud connection! Session login time: <date ~7 days ago>
[空调插座] Successfully connected to MiCloud! Setting up miot device from MiCloud connection!
[空调插座] MiCloud connect error: Error: Request error with status 426 Upgrade Required
The cached session still "logs in" fine (login time is old but the cached serviceToken is accepted at login), then the FIRST device data request (https://api.io.mi.com/app/home/device_list, encrypted POST with x-xiaomi-protocal-flag-cli: PROTOCAL-HTTP2) returns HTTP 426 with body {"code":0,"message":"SERVICETOKEN_EXPIRED"}.
Root cause: Xiaomi serviceTokens expire every ~7 days. useCachedSession: true means the plugin reuses the cached token forever and never auto-relogs. When the token expires, the device-list endpoint returns 426+SERVICETOKEN_EXPIRED. The 426 status code is misleading — it looks like an HTTP/2 protocol issue but is NOT (verified: HTTP/2 and HTTP/1.1 both return 401 when unauthenticated; 426 only appears when the expired-but-signed token is presented). Confirmed expiry cadence from cachedSession file timestamps: ~7-8 days.
Diagnose (confirms it is token expiry, not network):
ssh frad-nas 'docker exec homebridge ls -la /homebridge/.miot_micloud/'
Two resolution paths:
Path A (temporary, if you MUST keep MiCloud): re-run 2FA
ssh frad-nas 'docker exec homebridge mv /homebridge/.miot_micloud/cachedSession /homebridge/.miot_micloud/cachedSession.expired-$(date +%Y%m%d)'
ssh frad-nas 'docker restart homebridge'
This is why the user reports "又不能用了" every week. Use Path B to break the cycle.
Path B (permanent root fix): switch the device to LOCAL MiIO
The lumi.acpartner.mcn02 (空调插座) has a stable LAN IP 10.10.0.198 and a valid token 000662ac87cd9dc57bc36b5a9816f917 (verify with a raw miio miIO.info UDP request — device returns model + the same token). Local MiIO needs no MiCloud, no serviceToken, no 2FA — eliminates the 7-day cycle entirely.
Verify local MiIO works before switching (raw UDP, no plugin involved):
ssh frad-nas 'docker exec homebridge node -e "
const dgram=require(\"dgram\"),crypto=require(\"crypto\");
const ADDR=\"10.10.0.198\",TOKEN=\"000662ac87cd9dc57bc36b5a9816f917\";
const t=Buffer.from(TOKEN,\"hex\"),k=crypto.createHash(\"md5\").update(t).digest(),iv=crypto.createHash(\"md5\").update(k).update(t).digest();
const s=dgram.createSocket(\"udp4\");let did=0,stamp=0,st=0;
const id=Math.floor(Math.random()*1000)+1;
function cmd(){const j=JSON.stringify({id,method:\"miIO.info\",params:[]});const c=crypto.createCipheriv(\"aes-128-cbc\",k,iv);const e=Buffer.concat([c.update(Buffer.from(j)),c.final()]);const h=Buffer.alloc(32);h.writeInt16BE(0x2131,0);h.writeUInt16BE(32+e.length,2);h.writeUInt32BE(did,8);h.writeUInt32BE(stamp+Math.floor((Date.now()-st)/1000),12);crypto.createHash(\"md5\").update(h.slice(0,16)).update(t).update(e).digest().copy(h,16);s.send(Buffer.concat([h,e]),0,32+e.length,54321,ADDR);}
s.on(\"message\",m=>{if(m.length===32){did=m.readUInt32BE(8);stamp=m.readUInt32BE(12);st=Date.now();cmd();}else{const d=crypto.createDecipheriv(\"aes-128-cbc\",k,iv);console.log(Buffer.concat([d.update(m.slice(32)),d.final()]).toString().replace(/\\\\0+\$/,\"\")).slice(0,200));s.close();process.exit(0);}});
s.bind(()=>{const b=Buffer.from(\"21310020ffffffffffffffffffffffffffffffffffffffffffffffffffffffff\",\"hex\");s.send(b,0,b.length,54321,ADDR);});
setTimeout(()=>{console.log(\"timeout\");process.exit(1);},4000);
"'
Expect a JSON with "model":"lumi.acpartner.mcn02" and "token":"000662ac...". If miIO.info works, local mode will work.
Three changes to switch permanently:
- config.json (
/mnt/user/appdata/homebridge/config.json): in the miot platform, DELETE the device-level micloud block AND the global micloud block. With no credentials present, the plugin cannot attempt MiCloud. (forceMiCloud defaults to false once the block is gone — do NOT just set it false, remove the whole block, otherwise the plugin still tries to log in with the leftover username/password and throws Missing information required to connect to the MiCloud!.) Back up first: cp config.json config.json.bak.$(date +%Y%m%d_%H%M%S).
- Patch the device module's
requiresMiCloud() override. This is the catch: lib/modules/airconditioner/devices/lumi.acpartner.mcn02.js hardcodes requiresMiCloud() { return true; }. Even with no micloud config, shouldUseMiCloud() returns true via requiresMiCloud(), so the plugin throws MissingMiCloudCredentials and never polls locally. Patch: return true; -> return false; in that method. Backup: cp lumi.acpartner.mcn02.js lumi.acpartner.mcn02.js.bak.
docker restart homebridge.
ssh frad-nas 'docker exec homebridge sh -c "cp /homebridge/node_modules/homebridge-miot/lib/modules/airconditioner/devices/lumi.acpartner.mcn02.js /homebridge/node_modules/homebridge-miot/lib/modules/airconditioner/devices/lumi.acpartner.mcn02.js.bak"'
ssh frad-nas 'docker exec homebridge node -e "
const fs=require(\"fs\");
const f=\"/homebridge/node_modules/homebridge-miot/lib/modules/airconditioner/devices/lumi.acpartner.mcn02.js\";
let c=fs.readFileSync(f,\"utf8\");
c=c.replace(/requiresMiCloud\(\) \{\n return true;\n \}/, \"requiresMiCloud() {\n return false;\n }\");
fs.writeFileSync(f,c);
"'
config.json change (remove micloud blocks) — write a local node script and scp it (avoid heredoc escaping):
const fs = require("fs");
const F = "/homebridge/config.json";
const d = JSON.parse(fs.readFileSync(F, "utf8"));
for (const p of d.platforms || []) {
if (p.platform !== "miot") continue;
delete p.micloud;
for (const dev of p.devices || []) delete dev.micloud;
}
fs.writeFileSync(F, JSON.stringify(d, null, 2));
scp /tmp/rm_micloud.js frad-nas:/tmp/
ssh frad-nas 'docker cp /tmp/rm_micloud.js homebridge:/tmp/ && docker exec homebridge node /tmp/rm_micloud.js && docker exec homebridge rm /tmp/rm_micloud.js && docker restart homebridge'
Verify local mode is active:
ssh frad-nas 'docker logs --since 2m homebridge 2>&1 | grep 空调插座 | tail -6'
Should show: Device found! Setting up miot device from local connection! -> Connected to device: lumi.acpartner.mcn02 -> Doing initial property fetch. -> Starting property polling. with NO MiCloud connect error, NO 426, NO Two factor authentication required. If you see Missing information required to connect to the MiCloud!, the requiresMiCloud patch is missing (re-apply after plugin upgrade).
Persistence: config.json and the patched .js are both under the bind mount /mnt/user/appdata/homebridge -> /homebridge, so they survive container restarts. Both are LOST on homebridge-miot reinstall/upgrade — re-apply (same caveat as the node-fetch patch above). After an upgrade, re-check requiresMiCloud in the device module and re-patch if it returned to true, and re-delete the micloud blocks from config.json if the UI re-added them.
When NOT to use local mode: if the device has no stable LAN IP, or the token is unknown/unobtainable, or the device model genuinely requires MiCloud (some devices have cloud-only properties).
lumi.acpartner.mcn02 (空调插座) CANNOT use local mode — do not attempt. Tried 2026-07-01: after the config + requiresMiCloud patch, the accessory appeared in HomeKit but could NOT be controlled, and logs showed Poll failed 4 times in a row! ... Call to device timed out every poll. Root cause: homebridge-miot local mode uses miot-spec commands get_properties/set_properties (siid/piid), but this device's firmware 2.1.8_0016 (miio_ver 0.0.9, esp32) only supports the OLD get_prop/set_prop API. Verified with raw UDP: get_prop ["power"] → ["on"] (works); get_properties with siid/piid → timeout. This is exactly why the device module hardcodes requiresMiCloud() return true — MiCloud's gateway does miot-spec→old-prop protocol translation that the local device cannot. So for this device, stay on MiCloud and mitigate the 7-day expiry via a scheduled re-login reminder (Path A), not local mode (Path B).
If you tried local mode and need to revert: restore lumi.acpartner.mcn02.js from lumi.acpartner.mcn02.js.bak, restore config.json from the latest config.json.bak.* (the one made before removing micloud blocks), docker restart homebridge, then re-run 2FA (the cachedSession was deleted during the local-mode attempt) per Path A.
Lesson: the recurring weekly 426 was not a bug to fix repeatedly — it was the wrong connection mode for a device that supports local MiIO. When a miot device has a stable LAN IP and known token, prefer local mode. This is the root fix; Path A (re-run 2FA) is only a band-aid for the ~7-day cycle. See memory [[homebridge-miot-local-mode]].
tfo string type fix
Symptom: mihomo -t reports proxy N: 'tfo' expected type 'bool', got unconvertible type 'string'. Root cause: airports put tfo as string "0" in node data; the 组合订阅 collection's Quick Setting Operator had tfo: DEFAULT, and DEFAULT preserves the original value without type coercion, so the string "0" flowed into the generated YAML.
Fix: set tfo to DISABLED (forces false bool) on the Quick Setting Operator:
ssh frad-nas 'node << "NODEEOF"
const fs = require("fs");
const DB = "/mnt/user/appdata/sub-store/data/sub-store.json";
const d = JSON.parse(fs.readFileSync(DB, "utf8"));
const c = d.collections.find(x => x.name === "组合订阅");
const qs = c.process.find(p => p.type === "Quick Setting Operator");
qs.args.tfo = "DISABLED";
fs.writeFileSync(DB, JSON.stringify(d, null, 2));
console.log("tfo:", qs.args.tfo);
NODEEOF'
DEFAULT = keep node original (may be string), DISABLED = force false (bool), ENABLED = force true (bool). Same semantics for udp, scert, vmess aead. Verify with grep -oE "tfo:.*" /tmp/aio.yaml | sort | uniq -c — should show only tfo: false.
Gateway-side verification (read-only)
Do NOT modify gateway custom files. Only read to verify what's running.
ssh root@10.10.0.1 'grep -cE "qpic|qlogo|servicewechat" /etc/openclash/AIO.yaml'
ssh root@10.10.0.1 'sec=$(grep -oE "secret:.*" /etc/openclash/AIO.yaml | head -1 | sed "s/secret://;s/[\" ]//g"); curl -s "http://127.0.0.1:9090/rules" -H "Authorization: Bearer $sec" | grep -oE "\"payload\":\"(qpic|qlogo)[^\"]*\",\"proxy\":\"[^\"]*\""'
ssh root@10.10.0.1 'curl -s -o /dev/null -w "mmbiz.qpic.cn HTTP %{http_code}, %{time_total}s\n" --max-time 5 http://mmbiz.qpic.cn'
HTTP 400 from the CDN in <100ms = direct reach (CDN rejects bare requests, but the connection works). Timeouts or proxy-group names in the path = still broken.
Triggering the gateway to pull the new config
When the user is ready to apply the SubStore changes on the gateway, trigger an OpenClash subscription update. The clean way is the OpenClash update endpoint (action_update_config calls /usr/share/openclash/openclash.sh '<name>'), but invoking openclash.sh directly can fail with basename: missing operand because it expects a LuCI-provided env. The reliable approaches:
- Tell the user to click "Update" next to the AIO subscription in the OpenClash LuCI page (Server -> OpenClash -> Override / subscribe), then watch
/tmp/openclash.log.
- Or restart OpenClash and let it re-pull:
ssh root@10.10.0.1 '/etc/init.d/openclash restart' (takes ~35s; OpenClash only re-downloads the subscription on update, not on every restart, so this may keep the old config — confirm via the runtime rules check above).
After any gateway update, verify the running config /etc/openclash/AIO.yaml (NOT config/AIO.yaml, which is the raw download before OpenClash processing) contains the WeChat rules and tfo: false.
Common pitfalls (do not repeat)
- Do not put JavaScript in a file-type Script Operator's
content. It is parsed as YAML. Use YAML for config overrides; use JS only for collection-level node operators.
- Do NOT split rule/dns additions across multiple YAML override operators. Array keys (
rules, fake-ip-filter) are REPLACED on merge, so a later override's rules: array silently overwrites the rules added by an earlier one. Keep all additions in a single override operator's rules: and dns.fake-ip-filter: arrays.
- Do not edit
/etc/openclash/config/AIO.yaml or the gateway custom files to add rules. They are overwritten on subscription update. Put changes in SubStore.
- Do not use the
直连 proxy-group name in injected rules. Use DIRECT to avoid "proxy not found" at merge time.
- The
openclash_custom_rules.list mechanism does NOT merge when OpenClash pulls a complete Clash config (like SubStore's AIO) — it only applies when OpenClash generates a config from nodes+template. So custom rules there appear to do nothing for this pipeline. This is why the gateway-side approach failed and we moved everything to SubStore.
one_key_update upgrades the OpenClash package and mihomo core, not just the subscription. Do not run it to refresh a config — it will upgrade OpenClash itself.
enable_custom_clash_rules and the custom overwrite hook run during OpenClash's config-modify phase, before proxy-groups are finalized — rules referencing named groups can be skipped with "Group & Proxy Not Found" warnings. Keep the gateway side clean (these should be 0 / original) and do all work in SubStore.
- Restart SubStore after editing sub-store.json — it caches config in memory.
Reference: key paths and endpoints
| Item | Location |
|---|
| SubStore state | frad-nas:/mnt/user/appdata/sub-store/data/sub-store.json |
| SubStore container | SubStore (docker), port 3001 |
| SubStore AIO generate URL | http://<nas>:3001/sub-api/api/file/AIO?target=ClashMeta |
| SubStore AIO file token | (look it up in sub-store.json files[].token; reads also work without it) |
| Collection | 组合订阅 (subs: VoyraCloud, HXY, oixCloud) |
| AIO file | files[] name AIO, sourceType collection, type mihomoProfile |
| powerfullz override | https://cdn.jsdelivr.net/gh/powerfullz/override-rules/convert.min.js (first process op) |
| OpenClash raw config | 10.10.0.1:/etc/openclash/config/AIO.yaml |
| OpenClash running config | 10.10.0.1:/etc/openclash/AIO.yaml |
| OpenClash log | 10.10.0.1:/tmp/openclash.log |
| OpenClash API | http://127.0.0.1:9090 on gateway (secret in config secret:) |
| SubStore subscription on gateway | http://10.10.0.195:3001/sub-api/api/file/AIO |