本帖最后由 star_chen 于 2026-8-19 00:49 编辑
起因老人需要来家里帮忙照看孩子,但是这种指纹锁对老年人指纹识别率较低。又不想更换。所以想到了这个办法。前提是指纹锁支持遥控器控制开关,否则就要在门锁上进行飞线。还要有HA,docker。才能复现这些功能。
推荐使用AI进行辅助,全程AI其实也可以。
.查看指纹锁遥控器是否可以焊接飞线,可以实现短路开锁!遥控器的负极必须要和开关的一端共用G口。如果不能,就要想其他办法,比如查看遥控器是否是常见的频率,能否复制等。
微信小程序可以不用发布,开发版或者体验版都可以,这样可以后台授权谁能使用,等于多了一层防护。
如果对代码不熟悉,可以全程使用AI,方便快捷。强烈推荐!!!
一、整体方案
微信小程序 ↓ HTTPS 公网反向代理或 Cloudflare Tunnel ↓ 自建后端服务 ↓ Home Assistant REST API Home Assistant 实体 ↓ ESPHome 设备 ↓ 遥控器按键
小程序不直接连接 Home Assistant,而是先访问自己的后端。后端验证访问凭据后,再调用 Home Assistant 的服务。 这样做的好处: - 不需要把 Home Assistant 管理界面直接暴露到公网
- 可以单独限制访问
- 可以增加 PIN、限流和日志
- 小程序只需要调用简单的 HTTP 接口
准备工作
- 一台运行后端服务的设备,例如 NAS、服务器或小主机
- Docker 和 Docker Compose
- 一个 HTTPS 公网访问地址
- 微信小程序
- ESPHome 设备
- 继电器、PhotoMOS 或其他隔离器件
- Home Assistant 长期访问令牌、建议使用 Cloudflare Tunnel、反向代理或其他 HTTPS 方案,不建议直接开放 Home Assistant 的管理端口。
域名建议随便注册就好,Docker 随便,我自己的是搭建在飞牛中的,所以就直接用了。
建议步骤。
一、焊接遥控器;开关必须有一端和负极共用的。否则容易出现不触发的情况。
二、连接ESP32,并接入HA看一下能不能触发;
三、注册域名和小程序,我用的域名是腾讯云,大概20几块钱一年。
四、编写小程序并架构docker;
五、部署Cloudflare Tunnel进行内网穿透,如果你本身自己的公网IPV4开通了443端口就不需要。或者可以直接把docker架设在云服务器也不用这一步。
六,连接小程序和HA,建议ha建立自动化,不要直接触发控制该设备,方便前期调试(比如说先控制灯)后期改变的时候不需要更改前端和docker代码。直接在HA中就可以完成;
七、研究其他玩法。
代码部分。
微信小程序代码:
目录: pages/index/
├── index.js
├── index.wxml
├── index.wxss
└── index.json
1. index.js
const DEMO_MODE = false
const API_BASE_URL = 'https://<YOUR_PUBLIC_HTTPS_DOMAIN>'
const DEMO_PIN = '<DEMO_PIN>'
const DEMO_PIN = '<DEMO_PIN>'
Component({
data: {
authorizationId: '<YOUR_ACCESS_ID>',
pin: '',
pinCells: ['', '', '', '', '', ''],
pinFocused: false,
pinError: false,
verifying: false,
verified: false,
triggering: false,
triggered: false,
triggerError: false,
canControlAgain: false,
},
lifetimes: {
attached() {
const pages = getCurrentPages()
const currentPage = pages[pages.length - 1]
const authorizationId = currentPage && currentPage.options && currentPage.options.authorizationId
if (authorizationId) {
this.setData({ authorizationId })
}
},
},
methods: {
onShareAppMessage() {
return {
title: '邀请你使用家庭设备',
path: `/pages/index/index?authorizationId=${encodeURIComponent(this.data.authorizationId)}`,
}
},
onPinInput(event) {
const pin = String(event.detail.value).replace(/\D/g, '').slice(0, 6)
this.setData({
pin,
pinCells: Array.from({ length: 6 }, (_, index) => pin[index] || ''),
pinError: false,
})
if (pin.length === 6) {
this.setData({ pinFocused: false })
wx.hideKeyboard()
}
},
onPinFocus() {
this.setData({ pinFocused: true })
},
onPinBlur() {
this.setData({ pinFocused: false })
},
focusPin() {
this.setData({ pinFocused: true })
},
verifyPin() {
if (this.data.pin.length !== 6 || this.data.verifying) return
this.setData({ verifying: true, pinError: false })
if (DEMO_MODE) {
setTimeout(() => {
const verified = this.data.pin === DEMO_PIN
this.setData({ verifying: false, verified, pinError: !verified })
if (verified) this.triggerDevice()
else wx.vibrateShort({ type: 'medium' })
}, 550)
return
}
wx.request({
url: `${API_BASE_URL}/api/access/verify`,
method: 'POST',
data: {
authorizationId: this.data.authorizationId,
pin: this.data.pin,
},
success: (response) => {
const verified = response.statusCode === 200
this.setData({ verified, pinError: !verified })
if (verified) this.triggerDevice()
},
fail: () => wx.showToast({ title: '网络连接失败', icon: 'none' }),
complete: () => this.setData({ verifying: false }),
})
},
triggerDevice() {
if (this.data.triggering) return
this.setData({
triggering: true,
triggered: false,
triggerError: false,
canControlAgain: false,
})
if (DEMO_MODE) {
setTimeout(() => this.handleTriggerSuccess(), 900)
return
}
wx.request({
url: `${API_BASE_URL}/api/access/trigger`,
method: 'POST',
data: {
authorizationId: this.data.authorizationId,
pin: this.data.pin,
},
success: (response) => {
if (response.statusCode === 200) {
this.handleTriggerSuccess()
} else {
this.setData({ triggerError: true })
wx.showToast({ title: '设备执行失败', icon: 'none' })
}
},
fail: () => {
this.setData({ triggerError: true })
wx.showToast({ title: '网络连接失败', icon: 'none' })
},
complete: () => this.setData({ triggering: false }),
})
},
handleTriggerSuccess() {
this.setData({ triggering: false, triggered: true })
wx.showToast({ title: '设备指令已发送', icon: 'success', duration: 2200 })
wx.vibrateShort({ type: 'light' })
setTimeout(() => this.setData({ canControlAgain: true }), 3000)
},
controlAgain() {
this.triggerDevice()
},
},
})
index.wxml
<scroll-view class="page" scroll-y enhanced show-scrollbar="{{false}}">
<view class="shell">
<view class="topbar">
<view class="brand-mark"><view class="brand-dot"></view></view>
<view class="brand-copy">
<text class="brand-name">HOME ACCESS</text>
<text class="brand-subtitle">FAMILY DEVICE</text>
</view>
<view class="security-pill"><view class="shield">✓</view><text>加密连接</text></view>
</view>
<view class="intro">
<text class="eyebrow">临时设备授权</text>
<text class="title">欢迎到访</text>
<text class="description">屋主邀请你使用以下设备。验证访问 PIN 后,设备将立即执行。</text>
</view>
<view class="device-panel">
<view class="device-head">
<view class="device-icon">
<view class="garage-roof"></view>
<view class="garage-door"><view></view><view></view><view></view></view>
</view>
<view class="device-copy">
<text class="device-label">可用设备</text>
<text class="device-name">门锁</text>
</view>
<view class="status"><view class="status-dot"></view><text>在线</text></view>
</view>
</view>
<view wx:if="{{!verified}}" class="auth-section">
<text class="section-title">输入访问 PIN</text>
<text class="section-hint">请输入屋主提供的 6 位数字</text>
<view class="pin-wrap {{pinError ? 'pin-error' : ''}}">
<input class="pin-input" type="number" maxlength="6" value="{{pin}}" focus="{{pinFocused}}" bindinput="onPinInput" bindfocus="onPinFocus" bindblur="onPinBlur" />
<view class="pin-cells" bindtap="focusPin">
<view wx:for="{{pinCells}}" wx:key="index" class="pin-cell {{pin.length === index && pinFocused ? 'active' : ''}}">
<view wx:if="{{item}}" class="pin-dot"></view>
</view>
</view>
</view>
<text wx:if="{{pinError}}" class="error-text">PIN 不正确,请检查后重试</text>
<button class="primary-button" disabled="{{pin.length !== 6 || verifying}}" loading="{{verifying}}" bindtap="verifyPin">{{verifying ? '正在验证' : '验证并继续'}}</button>
</view>
<view wx:else class="control-section">
<view class="verified-row"><view class="verified-icon">✓</view><text>身份验证成功</text></view>
<view class="execution-status {{triggered ? 'success' : ''}} {{triggerError ? 'error' : ''}}">
<view class="execution-icon">{{triggered ? '✓' : (triggerError ? '!' : '···')}}</view>
<text>{{triggered ? '门锁已开' : (triggerError ? '设备执行失败' : '正在执行设备指令')}}</text>
</view>
<button wx:if="{{canControlAgain}}" class="primary-button control-again-button" disabled="{{triggering}}" bindtap="controlAgain">点击开锁</button>
<text class="control-note">{{triggerError ? '请联系屋主检查设备状态' : (canControlAgain ? '点击后将再次开锁' : '3 秒后可以继续控制设备')}}</text>
</view>
<view class="notice">
<view class="notice-icon">i</view>
<text>本次操作会记录时间和微信用户信息,并通知屋主。</text>
</view>
<view class="footer"><text>家庭设备安全服务</text><view class="footer-line"></view><text>授权编号 {{authorizationId}}</text></view>
</view>
</scroll-view>
index.wxss
page { height: 100%; }
.page { height: 100vh; background: #f3f1eb; }
.shell { min-height: 100%; padding: 38rpx 40rpx 54rpx; box-sizing: border-box; }
.topbar { display: flex; align-items: center; height: 72rpx; }
.brand-mark { width: 64rpx; height: 64rpx; background: #173e34; display: flex; align-items: center; justify-content: center; border-radius: 6rpx; }
.brand-dot { width: 20rpx; height: 20rpx; border: 5rpx solid #d7b861; border-top-color: transparent; border-radius: 50%; transform: rotate(45deg); }
.brand-copy { display: flex; flex-direction: column; margin-left: 18rpx; }
.brand-name { font-family: serif; font-weight: 700; font-size: 26rpx; line-height: 34rpx; }
.brand-subtitle { color: #757970; font-size: 14rpx; letter-spacing: 2rpx; }
.security-pill { margin-left: auto; display: flex; align-items: center; gap: 8rpx; color: #4d655c; font-size: 22rpx; }
.shield { width: 30rpx; height: 34rpx; line-height: 31rpx; text-align: center; background: #dce6df; color: #176b52; font-size: 18rpx; border-radius: 14rpx 14rpx 18rpx 18rpx; }
.intro { margin-top: 84rpx; display: flex; flex-direction: column; }
.eyebrow { color: #a17d2b; font-size: 22rpx; font-weight: 600; }
.title { margin-top: 12rpx; font-family: serif; font-size: 72rpx; line-height: 1.15; font-weight: 700; }
.description { margin-top: 20rpx; max-width: 580rpx; color: #656960; font-size: 27rpx; line-height: 1.75; }
.device-panel { margin-top: 54rpx; background: #fff; border: 1rpx solid #dddcd6; border-radius: 12rpx; padding: 34rpx; box-shadow: 0 14rpx 40rpx rgba(40, 47, 39, .06); }
.device-head { display: flex; align-items: center; }
.device-icon { width: 92rpx; height: 92rpx; background: #e1e9e3; border-radius: 8rpx; position: relative; display: flex; align-items: center; justify-content: center; }
.garage-roof { position: absolute; top: 19rpx; width: 48rpx; height: 23rpx; border: 5rpx solid #1b5a47; border-bottom: 0; transform: perspective(30rpx) rotateX(18deg); }
.garage-door { position: absolute; top: 40rpx; width: 44rpx; height: 34rpx; border: 4rpx solid #1b5a47; }
.garage-door view { height: 8rpx; border-bottom: 2rpx solid #1b5a47; }
.device-copy { display: flex; flex-direction: column; margin-left: 24rpx; }
.device-label { color: #85887f; font-size: 21rpx; }
.device-name { margin-top: 6rpx; font-size: 31rpx; font-weight: 600; }
.status { margin-left: auto; align-self: flex-start; display: flex; align-items: center; gap: 10rpx; color: #497063; font-size: 21rpx; }
.status-dot { width: 13rpx; height: 13rpx; border-radius: 50%; background: #39a376; box-shadow: 0 0 0 7rpx #e0f1e9; }
.auth-section, .control-section { margin-top: 52rpx; }
.section-title { display: block; font-size: 30rpx; font-weight: 600; }
.section-hint { display: block; margin-top: 9rpx; color: #7e8179; font-size: 23rpx; }
.pin-wrap { position: relative; margin-top: 28rpx; }
.pin-input { position: absolute; width: 1rpx; height: 1rpx; opacity: 0; }
.pin-cells { display: flex; justify-content: space-between; }
.pin-cell { width: 88rpx; height: 96rpx; background: #fff; border: 2rpx solid #d6d5cf; border-radius: 8rpx; display: flex; align-items: center; justify-content: center; box-sizing: border-box; transition: border-color .2s, box-shadow .2s; }
.pin-cell.active { border-color: #176b52; box-shadow: 0 0 0 5rpx rgba(23, 107, 82, .1); }
.pin-dot { width: 18rpx; height: 18rpx; border-radius: 50%; background: #213f36; }
.pin-error .pin-cell { border-color: #b64a42; background: #fffafa; }
.error-text { display: block; margin-top: 16rpx; color: #aa3f38; font-size: 22rpx; }
.primary-button { margin-top: 32rpx; height: 94rpx; padding: 0; line-height: normal; background: #173e34; color: #fff; border-radius: 8rpx; font-size: 28rpx; font-weight: 600; display: flex; align-items: center; justify-content: center; box-sizing: border-box; }
.primary-button[disabled] { background: #aeb6b0; color: #eef0ee; }
.primary-button::after { border: 0; }
.verified-row { display: flex; align-items: center; justify-content: center; gap: 12rpx; color: #176b52; font-size: 24rpx; margin-bottom: 24rpx; }
.verified-icon { width: 34rpx; height: 34rpx; line-height: 34rpx; text-align: center; color: #fff; background: #2f936e; border-radius: 50%; font-size: 20rpx; }
.execution-status { height: 112rpx; border: 2rpx solid #cfd5d1; background: #eef1ef; color: #52635c; border-radius: 10rpx; display: flex; align-items: center; justify-content: center; gap: 18rpx; font-size: 28rpx; font-weight: 600; }
.execution-status.success { border-color: #b9d8ca; background: #e4f1eb; color: #176b52; }
.execution-status.error { border-color: #dfbbb7; background: #fff0ef; color: #aa3f38; }
.execution-icon { width: 42rpx; height: 42rpx; line-height: 42rpx; text-align: center; border-radius: 50%; background: #fff; }
.control-again-button { width: 100%; margin-top: 24rpx; }
.control-note { display: block; margin-top: 20rpx; color: #777b73; text-align: center; font-size: 22rpx; }
.notice { margin-top: 40rpx; padding: 26rpx 28rpx; background: #e8e5da; border-left: 5rpx solid #c2a653; display: flex; align-items: flex-start; gap: 17rpx; color: #65655e; font-size: 22rpx; line-height: 1.65; }
.notice-icon { flex: none; width: 30rpx; height: 30rpx; line-height: 30rpx; text-align: center; border: 2rpx solid #8d7c45; color: #786a3e; border-radius: 50%; font-family: serif; font-weight: 700; }
.footer { margin-top: 58rpx; display: flex; align-items: center; justify-content: center; color: #999b94; font-size: 18rpx; }
.footer-line { width: 1rpx; height: 22rpx; margin: 0 20rpx; background: #c9c8c2; }
index.json
{
"navigationBarTitleText": "家庭设备",
"enableShareAppMessage": true
}
docker代码
Dockerfile
FROM public.ecr.aws/docker/library/node:22-alpine
WORKDIR /app
COPY package*.json ./
RUN npm ci --omit=dev
COPY server.js ./
ENV PORT=3000
EXPOSE 3000
CMD ["node", "server.js"]
docker-compose
services:
home-access:
build: .
container_name: home-access-server
restart: unless-stopped
environment:
PORT: "3000"
# Home Assistant 地址,例如:http://192.168.x.x:8123
HA_URL: "<YOUR_HA_URL>"
HA_DOMAIN: "input_button"
HA_SERVICE: "press"
HA_ENTITY_ID: "<YOUR_HA_ENTITY_ID>"
HA_TOKEN: "<YOUR_HA_TOKEN>"
ACCESS_ID: "<YOUR_ACCESS_ID>"
ACCESS_PINS: "<YOUR_PIN_1>,<YOUR_PIN_2>"
ports:
- "3000:3000"
package.json
{
"name": "home-access-server",
"version": "1.0.0",
"private": true,
"type": "module",
"scripts": {
"start": "node server.js"
},
"dependencies": {
"express": "^4.21.2"
}
}
server.js
import express from 'express'
const app = express()
app.disable('x-powered-by')
app.use(express.json({ limit: '10kb' }))
const port = Number(process.env.PORT || 3000)
const haUrl = String(process.env.HA_URL || '').replace(/\/+$/, '')
const haToken = String(process.env.HA_TOKEN || '')
const haDomain = String(process.env.HA_DOMAIN || 'input_button')
const haService = String(process.env.HA_SERVICE || 'press')
const haEntityId = String(process.env.HA_ENTITY_ID || '')
const accessId = String(process.env.ACCESS_ID || '')
const accessPins = String(
process.env.ACCESS_PINS || process.env.ACCESS_PIN || '',
)
.split(',')
.map((pin) => pin.trim())
.filter(Boolean)
function validAccess(id) {
return Boolean(accessId) && String(id || '') === accessId
}
function validPin(pin) {
return accessPins.includes(String(pin || ''))
}
function validRequest(req) {
return (
validAccess(req.body?.authorizationId) &&
validPin(req.body?.pin)
)
}
app.get('/health', (_req, res) => {
res.json({ ok: true })
})
app.post('/api/access/verify', (req, res) => {
if (!validAccess(req.body?.authorizationId)) {
return res.status(401).json({
ok: false,
error: 'invalid_authorization',
})
}
if (!validPin(req.body?.pin)) {
return res.status(401).json({
ok: false,
error: 'invalid_pin',
})
}
res.json({ ok: true })
})
app.post('/api/access/trigger', async (req, res) => {
if (!validRequest(req)) {
return res.status(401).json({
ok: false,
error: 'not_authorized',
})
}
if (!haUrl || !haToken || !haEntityId) {
return res.status(503).json({
ok: false,
error: 'ha_not_configured',
})
}
try {
const response = await fetch(
`${haUrl}/api/services/${encodeURIComponent(haDomain)}/${encodeURIComponent(haService)}`,
{
method: 'POST',
headers: {
Authorization: `Bearer ${haToken}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({ entity_id: haEntityId }),
signal: AbortSignal.timeout(10000),
},
)
if (!response.ok) {
return res.status(502).json({
ok: false,
error: 'home_assistant_error',
})
}
res.json({ ok: true })
} catch (error) {
console.error('Home Assistant request failed:', error.message)
res.status(502).json({
ok: false,
error: 'home_assistant_unreachable',
})
}
})
app.listen(port, '0.0.0.0', () => {
console.log(`Home access server listening on port ${port}`)
})
把上面4个文件打包放在docker目录里。把下面这几项改成自己的,然后执行。
<YOUR_HA_URL>
<YOUR_HA_ENTITY_ID>
<YOUR_HA_TOKEN>
<YOUR_ACCESS_ID>
<YOUR_PIN_1>
<YOUR_PIN_2>
sudo docker compose up -d --build
如果有什么不详细的,可以自己问AI或者回贴。
docker.zip
(2.48 KB, 下载次数: 1)
frontend.zip
(4.93 KB, 下载次数: 1)
代码打包发上来了,个人觉得只要有耐心一定没问题。都不是太难。
|