找回密码
 立即注册

微信扫码登录

搜索
查看: 782|回复: 2

[硬件DIY] 传统指纹锁接入微信小程序过程分享

[复制链接]

1

主题

4

回帖

63

积分

注册会员

积分
63
金钱
58
HASS币
0
发表于 2026-8-19 00:39:56 | 显示全部楼层 |阅读模式
本帖最后由 star_chen 于 2026-8-19 00:49 编辑

起因老人需要来家里帮忙照看孩子,但是这种指纹锁对老年人指纹识别率较低。又不想更换。所以想到了这个办法。前提是指纹锁支持遥控器控制开关,否则就要在门锁上进行飞线。还要有HA,docker。才能复现这些功能。


屏幕截图 2026-08-18 233132.png 屏幕截图 2026-08-18 235236.png 屏幕截图 2026-08-19 000426.png 屏幕截图 2026-08-19 000432.png
推荐使用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)

代码打包发上来了,个人觉得只要有耐心一定没问题。都不是太难。

回复

使用道具 举报

1

主题

210

回帖

1536

积分

金牌会员

积分
1536
金钱
1325
HASS币
0
发表于 2026-8-25 13:27:45 | 显示全部楼层
感谢分享
回复

使用道具 举报

4

主题

192

回帖

2296

积分

金牌会员

积分
2296
金钱
2100
HASS币
0
发表于 2026-8-25 17:59:24 | 显示全部楼层
感谢分享
回复

使用道具 举报

您需要登录后才可以回帖 登录 | 立即注册

本版积分规则

Archiver|手机版|小黑屋|Hassbian ( 晋ICP备17001384号-1 )|网站地图

GMT+8, 2026-9-16 04:52 , Processed in 0.015501 second(s), 4 queries , Redis On.

Powered by Discuz! X3.5

© 2001-2026 Discuz! Team.

快速回复 返回顶部 返回列表