目录
-
- [一、定位服务基础:location_get 获取当前位置 🌍](#一、定位服务基础:location_get 获取当前位置 🌍)
-
- [1.1 为什么节点需要定位能力?](#1.1 为什么节点需要定位能力?)
- [1.2 location_get 接口详解](#1.2 location_get 接口详解)
- [1.3 返回数据结构](#1.3 返回数据结构)
- [二、位置信息解析:经纬度、地址与精度 🗺️](#二、位置信息解析:经纬度、地址与精度 🗺️)
-
- [2.1 坐标系与转换](#2.1 坐标系与转换)
- [2.2 精度评估与可信度](#2.2 精度评估与可信度)
- [三、连续定位:实时位置追踪 🚀](#三、连续定位:实时位置追踪 🚀)
-
- [3.1 从单次快照到持续追踪](#3.1 从单次快照到持续追踪)
- [3.2 连续定位实现](#3.2 连续定位实现)
- [3.3 轨迹简化与存储](#3.3 轨迹简化与存储)
- [四、地理围栏:进入/离开区域告警 🔔](#四、地理围栏:进入/离开区域告警 🔔)
-
- [4.1 地理围栏原理](#4.1 地理围栏原理)
- [4.2 围栏类型与配置](#4.2 围栏类型与配置)
- [4.3 多围栏协作与优先级](#4.3 多围栏协作与优先级)
- [五、室内定位:WiFi/蓝牙辅助定位 🏢](#五、室内定位:WiFi/蓝牙辅助定位 🏢)
-
- [5.1 室内定位的挑战](#5.1 室内定位的挑战)
- [5.2 WiFi 指纹定位](#5.2 WiFi 指纹定位)
- [5.3 蓝牙 Beacon 定位](#5.3 蓝牙 Beacon 定位)
- [5.4 混合定位架构](#5.4 混合定位架构)
- [六、实战案例1:设备防丢提醒 🛡️](#六、实战案例1:设备防丢提醒 🛡️)
-
- [6.1 场景描述](#6.1 场景描述)
- [6.2 系统架构](#6.2 系统架构)
- [6.3 完整实现](#6.3 完整实现)
- [七、实战案例2:位置签到系统 ✅](#七、实战案例2:位置签到系统 ✅)
-
- [7.1 场景描述](#7.1 场景描述)
- [7.2 数据模型](#7.2 数据模型)
- [7.3 实现逻辑](#7.3 实现逻辑)
- [八、实战案例3:智能家居联动 🏠](#八、实战案例3:智能家居联动 🏠)
-
- [8.1 场景描述](#8.1 场景描述)
- [8.2 联动规则配置](#8.2 联动规则配置)
- [九、定位服务最佳实践与注意事项 ⚡](#九、定位服务最佳实践与注意事项 ⚡)
-
- [9.1 电池优化策略](#9.1 电池优化策略)
- [9.2 隐私与安全](#9.2 隐私与安全)
- [9.3 常见问题与排查](#9.3 常见问题与排查)
- [十、总结与展望 🎯](#十、总结与展望 🎯)
- 参考资料
📍 摘要 :本文深入讲解 OpenClaw 节点定位服务的完整技术体系,从基础的
location_get接口获取当前位置信息出发,逐步解析经纬度、地址编码、定位精度等核心数据结构,进而探讨连续定位与实时位置追踪的实现模式。在此基础上,重点剖析地理围栏的进入/离开区域告警机制,以及 WiFi/蓝牙辅助的室内定位方案。最后通过设备防丢提醒、位置签到系统、智能家居联动三个实战案例,展示定位服务在真实业务场景中的落地路径。全文结合代码示例、架构图与数据表,力求为开发者提供一份可操作的技术参考。
一、定位服务基础:location_get 获取当前位置 🌍
1.1 为什么节点需要定位能力?
OpenClaw 的节点(Node)是连接到 Gateway 的伴侣设备------macOS、iOS、Android 或无头主机。这些设备天然具备 GPS、WiFi、蓝牙等位置感知硬件,使得定位服务成为节点能力中最贴近物理世界的一环。
与传统的服务端定位 API 不同,OpenClaw 节点定位具有以下独特优势:
| 特性 | 传统服务端定位 | OpenClaw 节点定位 |
|---|---|---|
| 数据来源 | IP 地理位置估算 | GPS/WiFi/蓝牙多源融合 |
| 精度范围 | 城市级(千米级) | 街道级(米级)到室内级 |
| 实时性 | 请求时快照 | 连续追踪 + 事件驱动 |
| 隐私控制 | 服务端存储 | 本地节点处理,按需上报 |
| 离线能力 | 依赖网络 | 缓存 + 围栏本地判断 |
1.2 location_get 接口详解
location_get 是 OpenClaw 节点暴露的核心定位命令,通过 node.invoke 协议调用。它返回设备的当前位置快照,包含经纬度、地址、精度等完整信息。
python
import json
import websocket
def get_node_location(gateway_ws_url, node_id, gateway_token):
"""通过 WebSocket 调用节点的 location_get 命令"""
ws = websocket.create_connection(gateway_ws_url)
# 认证握手
auth_msg = {"type": "auth", "token": gateway_token}
ws.send(json.dumps(auth_msg))
ws.recv() # 确认认证
# 调用节点定位命令
invoke_msg = {
"type": "node.invoke",
"node": node_id,
"command": "location.get",
"params": {
"accuracy": "high", # high | balanced | low
"timeout": 10000, # 超时毫秒
"includeAddress": True # 是否包含逆地理编码
}
}
ws.send(json.dumps(invoke_msg))
response = json.loads(ws.recv())
ws.close()
return response.get("result", {})
上述代码展示了通过 WebSocket 协议向指定节点发起定位请求的完整流程。accuracy 参数控制定位精度与耗电的平衡------high 使用 GPS 硬件获得米级精度但耗电较高,balanced 优先使用 WiFi 辅助定位,low 则仅使用粗略的基站定位。includeAddress 为 True 时,节点会在获取坐标后额外执行逆地理编码,将经纬度转换为可读的街道地址。
1.3 返回数据结构
一次典型的 location_get 调用返回如下 JSON 结构:
json
{
"latitude": 31.2304,
"longitude": 121.4737,
"altitude": 12.5,
"accuracy": 8.3,
"altitudeAccuracy": 3.2,
"heading": 187.5,
"speed": 1.2,
"timestamp": "2026-06-12T15:00:00.000Z",
"address": {
"country": "中国",
"province": "上海市",
"city": "上海市",
"district": "黄浦区",
"street": "南京东路",
"streetNumber": "120号",
"formatted": "中国上海市黄浦区南京东路120号"
},
"source": "gps+wifi"
}
各字段含义如下表所示:
| 字段 | 类型 | 说明 |
|---|---|---|
latitude |
number | 纬度,WGS-84 坐标系 |
longitude |
number | 经度,WGS-84 坐标系 |
altitude |
number | 海拔高度(米),可能为 null |
accuracy |
number | 水平精度半径(米),68% 置信度 |
altitudeAccuracy |
number | 垂直精度(米) |
heading |
number | 运动方向(度),正北为 0 |
speed |
number | 运动速度(米/秒) |
timestamp |
ISO 8601 | 定位时刻的 UTC 时间戳 |
address |
object | 逆地理编码结果(可选) |
source |
string | 定位数据来源标识 |
二、位置信息解析:经纬度、地址与精度 🗺️
2.1 坐标系与转换
OpenClaw 节点定位默认使用 WGS-84 坐标系(GPS 原生坐标系)。在中国大陆使用时,需要注意与 GCJ-02 (火星坐标系)和 BD-09(百度坐标系)之间的转换关系。
#mermaid-svg-Wsb1ylB5XqCSCyKz{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-Wsb1ylB5XqCSCyKz .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-Wsb1ylB5XqCSCyKz .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-Wsb1ylB5XqCSCyKz .error-icon{fill:#552222;}#mermaid-svg-Wsb1ylB5XqCSCyKz .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-Wsb1ylB5XqCSCyKz .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-Wsb1ylB5XqCSCyKz .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-Wsb1ylB5XqCSCyKz .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-Wsb1ylB5XqCSCyKz .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-Wsb1ylB5XqCSCyKz .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-Wsb1ylB5XqCSCyKz .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-Wsb1ylB5XqCSCyKz .marker{fill:#333333;stroke:#333333;}#mermaid-svg-Wsb1ylB5XqCSCyKz .marker.cross{stroke:#333333;}#mermaid-svg-Wsb1ylB5XqCSCyKz svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-Wsb1ylB5XqCSCyKz p{margin:0;}#mermaid-svg-Wsb1ylB5XqCSCyKz .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-Wsb1ylB5XqCSCyKz .cluster-label text{fill:#333;}#mermaid-svg-Wsb1ylB5XqCSCyKz .cluster-label span{color:#333;}#mermaid-svg-Wsb1ylB5XqCSCyKz .cluster-label span p{background-color:transparent;}#mermaid-svg-Wsb1ylB5XqCSCyKz .label text,#mermaid-svg-Wsb1ylB5XqCSCyKz span{fill:#333;color:#333;}#mermaid-svg-Wsb1ylB5XqCSCyKz .node rect,#mermaid-svg-Wsb1ylB5XqCSCyKz .node circle,#mermaid-svg-Wsb1ylB5XqCSCyKz .node ellipse,#mermaid-svg-Wsb1ylB5XqCSCyKz .node polygon,#mermaid-svg-Wsb1ylB5XqCSCyKz .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-Wsb1ylB5XqCSCyKz .rough-node .label text,#mermaid-svg-Wsb1ylB5XqCSCyKz .node .label text,#mermaid-svg-Wsb1ylB5XqCSCyKz .image-shape .label,#mermaid-svg-Wsb1ylB5XqCSCyKz .icon-shape .label{text-anchor:middle;}#mermaid-svg-Wsb1ylB5XqCSCyKz .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-Wsb1ylB5XqCSCyKz .rough-node .label,#mermaid-svg-Wsb1ylB5XqCSCyKz .node .label,#mermaid-svg-Wsb1ylB5XqCSCyKz .image-shape .label,#mermaid-svg-Wsb1ylB5XqCSCyKz .icon-shape .label{text-align:center;}#mermaid-svg-Wsb1ylB5XqCSCyKz .node.clickable{cursor:pointer;}#mermaid-svg-Wsb1ylB5XqCSCyKz .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-Wsb1ylB5XqCSCyKz .arrowheadPath{fill:#333333;}#mermaid-svg-Wsb1ylB5XqCSCyKz .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-Wsb1ylB5XqCSCyKz .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-Wsb1ylB5XqCSCyKz .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-Wsb1ylB5XqCSCyKz .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-Wsb1ylB5XqCSCyKz .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-Wsb1ylB5XqCSCyKz .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-Wsb1ylB5XqCSCyKz .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-Wsb1ylB5XqCSCyKz .cluster text{fill:#333;}#mermaid-svg-Wsb1ylB5XqCSCyKz .cluster span{color:#333;}#mermaid-svg-Wsb1ylB5XqCSCyKz div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-Wsb1ylB5XqCSCyKz .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-Wsb1ylB5XqCSCyKz rect.text{fill:none;stroke-width:0;}#mermaid-svg-Wsb1ylB5XqCSCyKz .icon-shape,#mermaid-svg-Wsb1ylB5XqCSCyKz .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-Wsb1ylB5XqCSCyKz .icon-shape p,#mermaid-svg-Wsb1ylB5XqCSCyKz .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-Wsb1ylB5XqCSCyKz .icon-shape .label rect,#mermaid-svg-Wsb1ylB5XqCSCyKz .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-Wsb1ylB5XqCSCyKz .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-Wsb1ylB5XqCSCyKz .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-Wsb1ylB5XqCSCyKz :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 偏移算法
百度偏移
逆百度偏移
逆偏移算法
WGS-84
GPS原生
GCJ-02
国测局/高德
BD-09
百度地图
三种坐标系的适用场景:
- WGS-84:国际标准,GPS 芯片直接输出,OpenClaw 节点默认使用
- GCJ-02:中国法规要求,高德地图、腾讯地图、Google Maps 中国区使用
- BD-09:百度地图专用,在 GCJ-02 基础上再次偏移
在将节点定位数据接入国内地图服务时,必须进行坐标转换:
python
import math
def wgs84_to_gcj02(lng, lat):
"""WGS-84 转 GCJ-02(火星坐标系)"""
if _out_of_china(lng, lat):
return lng, lat # 中国境外不做偏移
a = 6378245.0 # 长半轴
ee = 0.00669342162296594323 # 偏心率平方
dlat = _transform_lat(lng - 105.0, lat - 35.0)
dlng = _transform_lng(lng - 105.0, lat - 35.0)
radlat = lat / 180.0 * math.pi
magic = math.sin(radlat)
magic = 1 - ee * magic * magic
sqrtmagic = math.sqrt(magic)
dlat = (dlat * 180.0) / ((a * (1 - ee)) / (magic * sqrtmagic) * math.pi)
dlng = (dlng * 180.0) / (a / sqrtmagic * math.cos(radlat) * math.pi)
return lng + dlng, lat + dlat
def _transform_lng(lng, lat):
ret = 300.0 + lng + 2.0 * lat + 0.1 * lng * lng \
+ 0.1 * lng * lat + 0.1 * math.sqrt(abs(lng))
ret += (20.0 * math.sin(6.0 * lng * math.pi) +
20.0 * math.sin(2.0 * lng * math.pi)) * 2.0 / 3.0
ret += (20.0 * math.sin(lng * math.pi) +
40.0 * math.sin(lng / 3.0 * math.pi)) * 2.0 / 3.0
ret += (150.0 * math.sin(lng / 12.0 * math.pi) +
300.0 * math.sin(lng / 30.0 * math.pi)) * 2.0 / 3.0
return ret
def _transform_lat(lng, lat):
ret = -100.0 + 2.0 * lng + 3.0 * lat + 0.2 * lat * lat \
+ 0.1 * lng * lat + 0.2 * math.sqrt(abs(lng))
ret += (20.0 * math.sin(6.0 * lng * math.pi) +
20.0 * math.sin(2.0 * lng * math.pi)) * 2.0 / 3.0
ret += (20.0 * math.sin(lat * math.pi) +
40.0 * math.sin(lat / 3.0 * math.pi)) * 2.0 / 3.0
ret += (160.0 * math.sin(lat / 12.0 * math.pi) +
320.0 * math.sin(lat * math.pi / 30.0)) * 2.0 / 3.0
return ret
def _out_of_china(lng, lat):
return not (72.004 <= lng <= 137.8347 and 0.8293 <= lat <= 55.8271)
这段代码实现了 WGS-84 到 GCJ-02 的标准转换算法。核心原理是通过一组非线性偏移函数对原始坐标施加国测局规定的加密偏移。_out_of_china 判断坐标是否在中国境外------境外坐标不做偏移,这是法规要求。实际项目中建议使用成熟的坐标转换库(如 coord-convert),此处展示算法原理便于理解。
2.2 精度评估与可信度
定位精度(accuracy 字段)是使用位置数据时必须关注的核心指标。不同定位源的典型精度范围:
| 定位源 | 典型精度 | 耗电 | 适用场景 |
|---|---|---|---|
| GPS(户外) | 3-10 米 | 高 | 导航、轨迹记录 |
| GPS + WiFi 辅助 | 5-15 米 | 中高 | 城市步行 |
| WiFi 定位 | 15-50 米 | 中 | 室内/城市 |
| 基站定位 | 100-3000 米 | 低 | 粗略位置 |
| 蓝牙 Beacon | 0.5-3 米 | 中 | 室内精准定位 |
在业务逻辑中,应根据精度值决定数据的可用性:
python
def evaluate_location_confidence(location):
"""评估定位数据的可信度等级"""
accuracy = location.get("accuracy", float("inf"))
if accuracy <= 10:
return "HIGH", "GPS 级精度,可用于导航和围栏判断"
elif accuracy <= 30:
return "MEDIUM", "WiFi 辅助精度,可用于区域判断"
elif accuracy <= 100:
return "LOW", "粗略精度,仅适合城市级判断"
else:
return "UNRELIABLE", "精度不足,建议等待更佳定位"
该函数根据精度值将定位结果分为四个可信度等级。HIGH 级别适合精确的地理围栏触发,MEDIUM 级别可用于判断用户是否在某个商圈附近,LOW 级别只能做城市级的粗略判断,UNRELIABLE 则建议不使用该数据或等待更精确的定位结果。
三、连续定位:实时位置追踪 🚀
3.1 从单次快照到持续追踪
单次 location_get 只能获取调用时刻的位置快照。对于移动追踪、轨迹记录、实时围栏判断等场景,需要建立连续定位机制。
OpenClaw 节点支持通过 location.watch 命令启动持续定位流,节点按指定间隔主动推送位置更新:
移动节点 Gateway AI Agent 移动节点 Gateway AI Agent #mermaid-svg-GYRoOcM4MFYM2Aep{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-GYRoOcM4MFYM2Aep .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-GYRoOcM4MFYM2Aep .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-GYRoOcM4MFYM2Aep .error-icon{fill:#552222;}#mermaid-svg-GYRoOcM4MFYM2Aep .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-GYRoOcM4MFYM2Aep .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-GYRoOcM4MFYM2Aep .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-GYRoOcM4MFYM2Aep .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-GYRoOcM4MFYM2Aep .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-GYRoOcM4MFYM2Aep .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-GYRoOcM4MFYM2Aep .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-GYRoOcM4MFYM2Aep .marker{fill:#333333;stroke:#333333;}#mermaid-svg-GYRoOcM4MFYM2Aep .marker.cross{stroke:#333333;}#mermaid-svg-GYRoOcM4MFYM2Aep svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-GYRoOcM4MFYM2Aep p{margin:0;}#mermaid-svg-GYRoOcM4MFYM2Aep .actor{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-GYRoOcM4MFYM2Aep text.actor>tspan{fill:black;stroke:none;}#mermaid-svg-GYRoOcM4MFYM2Aep .actor-line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-GYRoOcM4MFYM2Aep .innerArc{stroke-width:1.5;stroke-dasharray:none;}#mermaid-svg-GYRoOcM4MFYM2Aep .messageLine0{stroke-width:1.5;stroke-dasharray:none;stroke:#333;}#mermaid-svg-GYRoOcM4MFYM2Aep .messageLine1{stroke-width:1.5;stroke-dasharray:2,2;stroke:#333;}#mermaid-svg-GYRoOcM4MFYM2Aep #arrowhead path{fill:#333;stroke:#333;}#mermaid-svg-GYRoOcM4MFYM2Aep .sequenceNumber{fill:white;}#mermaid-svg-GYRoOcM4MFYM2Aep #sequencenumber{fill:#333;}#mermaid-svg-GYRoOcM4MFYM2Aep #crosshead path{fill:#333;stroke:#333;}#mermaid-svg-GYRoOcM4MFYM2Aep .messageText{fill:#333;stroke:none;}#mermaid-svg-GYRoOcM4MFYM2Aep .labelBox{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-GYRoOcM4MFYM2Aep .labelText,#mermaid-svg-GYRoOcM4MFYM2Aep .labelText>tspan{fill:black;stroke:none;}#mermaid-svg-GYRoOcM4MFYM2Aep .loopText,#mermaid-svg-GYRoOcM4MFYM2Aep .loopText>tspan{fill:black;stroke:none;}#mermaid-svg-GYRoOcM4MFYM2Aep .loopLine{stroke-width:2px;stroke-dasharray:2,2;stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-GYRoOcM4MFYM2Aep .note{stroke:#aaaa33;fill:#fff5ad;}#mermaid-svg-GYRoOcM4MFYM2Aep .noteText,#mermaid-svg-GYRoOcM4MFYM2Aep .noteText>tspan{fill:black;stroke:none;}#mermaid-svg-GYRoOcM4MFYM2Aep .activation0{fill:#f4f4f4;stroke:#666;}#mermaid-svg-GYRoOcM4MFYM2Aep .activation1{fill:#f4f4f4;stroke:#666;}#mermaid-svg-GYRoOcM4MFYM2Aep .activation2{fill:#f4f4f4;stroke:#666;}#mermaid-svg-GYRoOcM4MFYM2Aep .actorPopupMenu{position:absolute;}#mermaid-svg-GYRoOcM4MFYM2Aep .actorPopupMenuPanel{position:absolute;fill:#ECECFF;box-shadow:0px 8px 16px 0px rgba(0,0,0,0.2);filter:drop-shadow(3px 5px 2px rgb(0 0 0 / 0.4));}#mermaid-svg-GYRoOcM4MFYM2Aep .actor-man line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-GYRoOcM4MFYM2Aep .actor-man circle,#mermaid-svg-GYRoOcM4MFYM2Aep line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;stroke-width:2px;}#mermaid-svg-GYRoOcM4MFYM2Aep :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} loop 按间隔推送 node.invoke location.watch 启动持续定位 location.update (位置数据) 转发位置更新 node.invoke location.unwatch 停止持续定位
3.2 连续定位实现
python
import asyncio
import json
class LocationTracker:
"""OpenClaw 节点连续定位追踪器"""
def __init__(self, gateway, node_id):
self.gateway = gateway
self.node_id = node_id
self.watch_id = None
self.history = [] # 位置历史
self.max_history = 500 # 最大历史记录数
self.callbacks = [] # 位置更新回调
async def start_tracking(self, interval_ms=5000, accuracy="balanced"):
"""启动连续定位追踪"""
result = await self.gateway.node_invoke(
node=self.node_id,
command="location.watch",
params={
"interval": interval_ms,
"accuracy": accuracy,
"distanceFilter": 10, # 最小距离变化(米)
"includeAddress": False # 持续追踪不编码地址以省电
}
)
self.watch_id = result.get("watchId")
print(f"📍 追踪已启动,watchId={self.watch_id}")
async def on_location_update(self, location):
"""处理位置更新事件"""
# 记录历史
self.history.append(location)
if len(self.history) > self.max_history:
self.history = self.history[-self.max_history:]
# 触发回调
for cb in self.callbacks:
await cb(location)
async def stop_tracking(self):
"""停止连续定位追踪"""
if self.watch_id:
await self.gateway.node_invoke(
node=self.node_id,
command="location.unwatch",
params={"watchId": self.watch_id}
)
self.watch_id = None
print("📍 追踪已停止")
def on_update(self, callback):
"""注册位置更新回调"""
self.callbacks.append(callback)
return self
LocationTracker 封装了连续定位的完整生命周期管理。start_tracking 通过 location.watch 启动持续定位,distanceFilter 参数设置最小距离变化阈值------只有当设备移动超过该距离时才触发更新,有效减少冗余推送和耗电。on_location_update 在每次收到位置更新时记录历史并触发回调链。stop_tracking 通过 location.unwatch 停止追踪并释放定位硬件资源。
3.3 轨迹简化与存储
长时间连续追踪会产生大量位置点。使用 Douglas-Peucker 算法 对轨迹进行简化,在保留关键形状特征的同时大幅减少数据量:
python
def douglas_peucker(points, epsilon=5.0):
"""Douglas-Peucker 轨迹简化算法
points: [(lat, lng), ...] 坐标点列表
epsilon: 简化容差(米)
"""
if len(points) <= 2:
return points
# 找到距首尾连线最远的点
start, end = points[0], points[-1]
max_dist, max_idx = 0, 0
for i in range(1, len(points) - 1):
dist = _point_line_distance(points[i], start, end)
if dist > max_dist:
max_dist, max_idx = dist, i
# 递归简化
if max_dist > epsilon:
left = douglas_peucker(points[:max_idx + 1], epsilon)
right = douglas_peucker(points[max_idx:], epsilon)
return left[:-1] + right
else:
return [start, end]
def _point_line_distance(point, line_start, line_end):
"""计算点到线段的垂直距离(近似)"""
x0, y0 = point
x1, y1 = line_start
x2, y2 = line_end
num = abs((y2 - y1) * x0 - (x2 - x1) * y0 + x2 * y1 - y2 * x1)
den = math.sqrt((y2 - y1) ** 2 + (x2 - x1) ** 2)
# 经纬度转米(中纬度近似)
return (num / den) * 111000 if den > 0 else 0
Douglas-Peucker 算法通过递归地找到距首尾连线最远的点来决定哪些点必须保留。容差 epsilon 控制简化程度------5 米容差通常能将轨迹点数减少 60%-80% 而保持路径形状可辨识。_point_line_distance 计算点到线段的垂直距离,并乘以 111000(每度约 111 公里)将近似经纬度差转换为米。实际生产中应使用 Haversine 公式计算精确距离。
四、地理围栏:进入/离开区域告警 🔔
4.1 地理围栏原理
地理围栏(Geofencing)是定位服务中最具实用价值的能力之一。它在地图上划定虚拟边界,当设备进入、离开或停留在该区域时自动触发事件,无需持续轮询位置。
#mermaid-svg-1MB3OE6ouRVRZogN{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-1MB3OE6ouRVRZogN .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-1MB3OE6ouRVRZogN .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-1MB3OE6ouRVRZogN .error-icon{fill:#552222;}#mermaid-svg-1MB3OE6ouRVRZogN .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-1MB3OE6ouRVRZogN .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-1MB3OE6ouRVRZogN .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-1MB3OE6ouRVRZogN .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-1MB3OE6ouRVRZogN .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-1MB3OE6ouRVRZogN .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-1MB3OE6ouRVRZogN .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-1MB3OE6ouRVRZogN .marker{fill:#333333;stroke:#333333;}#mermaid-svg-1MB3OE6ouRVRZogN .marker.cross{stroke:#333333;}#mermaid-svg-1MB3OE6ouRVRZogN svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-1MB3OE6ouRVRZogN p{margin:0;}#mermaid-svg-1MB3OE6ouRVRZogN .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-1MB3OE6ouRVRZogN .cluster-label text{fill:#333;}#mermaid-svg-1MB3OE6ouRVRZogN .cluster-label span{color:#333;}#mermaid-svg-1MB3OE6ouRVRZogN .cluster-label span p{background-color:transparent;}#mermaid-svg-1MB3OE6ouRVRZogN .label text,#mermaid-svg-1MB3OE6ouRVRZogN span{fill:#333;color:#333;}#mermaid-svg-1MB3OE6ouRVRZogN .node rect,#mermaid-svg-1MB3OE6ouRVRZogN .node circle,#mermaid-svg-1MB3OE6ouRVRZogN .node ellipse,#mermaid-svg-1MB3OE6ouRVRZogN .node polygon,#mermaid-svg-1MB3OE6ouRVRZogN .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-1MB3OE6ouRVRZogN .rough-node .label text,#mermaid-svg-1MB3OE6ouRVRZogN .node .label text,#mermaid-svg-1MB3OE6ouRVRZogN .image-shape .label,#mermaid-svg-1MB3OE6ouRVRZogN .icon-shape .label{text-anchor:middle;}#mermaid-svg-1MB3OE6ouRVRZogN .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-1MB3OE6ouRVRZogN .rough-node .label,#mermaid-svg-1MB3OE6ouRVRZogN .node .label,#mermaid-svg-1MB3OE6ouRVRZogN .image-shape .label,#mermaid-svg-1MB3OE6ouRVRZogN .icon-shape .label{text-align:center;}#mermaid-svg-1MB3OE6ouRVRZogN .node.clickable{cursor:pointer;}#mermaid-svg-1MB3OE6ouRVRZogN .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-1MB3OE6ouRVRZogN .arrowheadPath{fill:#333333;}#mermaid-svg-1MB3OE6ouRVRZogN .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-1MB3OE6ouRVRZogN .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-1MB3OE6ouRVRZogN .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-1MB3OE6ouRVRZogN .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-1MB3OE6ouRVRZogN .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-1MB3OE6ouRVRZogN .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-1MB3OE6ouRVRZogN .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-1MB3OE6ouRVRZogN .cluster text{fill:#333;}#mermaid-svg-1MB3OE6ouRVRZogN .cluster span{color:#333;}#mermaid-svg-1MB3OE6ouRVRZogN div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-1MB3OE6ouRVRZogN .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-1MB3OE6ouRVRZogN rect.text{fill:none;stroke-width:0;}#mermaid-svg-1MB3OE6ouRVRZogN .icon-shape,#mermaid-svg-1MB3OE6ouRVRZogN .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-1MB3OE6ouRVRZogN .icon-shape p,#mermaid-svg-1MB3OE6ouRVRZogN .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-1MB3OE6ouRVRZogN .icon-shape .label rect,#mermaid-svg-1MB3OE6ouRVRZogN .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-1MB3OE6ouRVRZogN .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-1MB3OE6ouRVRZogN .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-1MB3OE6ouRVRZogN :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 之前在外,现在在内
之前在内,现在在外
状态未变
节点位置更新
在围栏内?
🟢 enter 事件
🔴 exit 事件
⏸️ 无事件
触发回调
通知/联动/记录
4.2 围栏类型与配置
OpenClaw 支持多种围栏几何形状:
| 围栏类型 | 参数 | 适用场景 |
|---|---|---|
| 圆形围栏 | 中心点 + 半径 | 家/公司周边、商圈 |
| 多边形围栏 | 顶点列表 | 行政区域、园区 |
| 走廊围栏 | 路径 + 宽度 | 道路沿线、地铁线路 |
围栏配置示例:
python
class GeofenceManager:
"""地理围栏管理器"""
def __init__(self, gateway, node_id):
self.gateway = gateway
self.node_id = node_id
self.fences = {}
async def add_circle_fence(self, fence_id, name,
center_lat, center_lng,
radius_meters, events=None):
"""添加圆形地理围栏"""
events = events or ["enter", "exit"]
result = await self.gateway.node_invoke(
node=self.node_id,
command="geofence.add",
params={
"id": fence_id,
"name": name,
"type": "circle",
"center": {
"latitude": center_lat,
"longitude": center_lng
},
"radius": radius_meters,
"events": events,
"loiteringDelay": 30000 # 停留30秒后触发
}
)
self.fences[fence_id] = result
return result
async def on_fence_event(self, event):
"""处理围栏事件"""
fence = self.fences.get(event["fenceId"])
event_type = event["type"] # enter | exit | dwell
if event_type == "enter":
print(f"🟢 进入围栏: {fence['name']}")
elif event_type == "exit":
print(f"🔴 离开围栏: {fence['name']}")
elif event_type == "dwell":
print(f"⏳ 在围栏内停留: {fence['name']}")
GeofenceManager 封装了围栏的创建与事件处理。add_circle_fence 创建以指定坐标为圆心、指定距离为半径的圆形围栏。events 参数控制触发哪些事件类型------enter 在进入时触发,exit 在离开时触发,dwell 在区域内停留超过 loiteringDelay 后触发。dwell 事件特别有用:它能区分"路过"和"真正到达",避免用户只是经过公司门口就被判定为到达。
4.3 多围栏协作与优先级
实际场景中,一个设备可能同时处于多个围栏内。例如用户从家到公司的路上,可能经过"家周边围栏 → 离开家围栏 → 进入商圈围栏 → 离开商圈围栏 → 进入公司围栏"的完整序列。
#mermaid-svg-ycoytgMEk8xsNXD7{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-ycoytgMEk8xsNXD7 .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-ycoytgMEk8xsNXD7 .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-ycoytgMEk8xsNXD7 .error-icon{fill:#552222;}#mermaid-svg-ycoytgMEk8xsNXD7 .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-ycoytgMEk8xsNXD7 .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-ycoytgMEk8xsNXD7 .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-ycoytgMEk8xsNXD7 .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-ycoytgMEk8xsNXD7 .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-ycoytgMEk8xsNXD7 .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-ycoytgMEk8xsNXD7 .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-ycoytgMEk8xsNXD7 .marker{fill:#333333;stroke:#333333;}#mermaid-svg-ycoytgMEk8xsNXD7 .marker.cross{stroke:#333333;}#mermaid-svg-ycoytgMEk8xsNXD7 svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-ycoytgMEk8xsNXD7 p{margin:0;}#mermaid-svg-ycoytgMEk8xsNXD7 defs #statediagram-barbEnd{fill:#333333;stroke:#333333;}#mermaid-svg-ycoytgMEk8xsNXD7 g.stateGroup text{fill:#9370DB;stroke:none;font-size:10px;}#mermaid-svg-ycoytgMEk8xsNXD7 g.stateGroup text{fill:#333;stroke:none;font-size:10px;}#mermaid-svg-ycoytgMEk8xsNXD7 g.stateGroup .state-title{font-weight:bolder;fill:#131300;}#mermaid-svg-ycoytgMEk8xsNXD7 g.stateGroup rect{fill:#ECECFF;stroke:#9370DB;}#mermaid-svg-ycoytgMEk8xsNXD7 g.stateGroup line{stroke:#333333;stroke-width:1;}#mermaid-svg-ycoytgMEk8xsNXD7 .transition{stroke:#333333;stroke-width:1;fill:none;}#mermaid-svg-ycoytgMEk8xsNXD7 .stateGroup .composit{fill:white;border-bottom:1px;}#mermaid-svg-ycoytgMEk8xsNXD7 .stateGroup .alt-composit{fill:#e0e0e0;border-bottom:1px;}#mermaid-svg-ycoytgMEk8xsNXD7 .state-note{stroke:#aaaa33;fill:#fff5ad;}#mermaid-svg-ycoytgMEk8xsNXD7 .state-note text{fill:black;stroke:none;font-size:10px;}#mermaid-svg-ycoytgMEk8xsNXD7 .stateLabel .box{stroke:none;stroke-width:0;fill:#ECECFF;opacity:0.5;}#mermaid-svg-ycoytgMEk8xsNXD7 .edgeLabel .label rect{fill:#ECECFF;opacity:0.5;}#mermaid-svg-ycoytgMEk8xsNXD7 .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-ycoytgMEk8xsNXD7 .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-ycoytgMEk8xsNXD7 .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-ycoytgMEk8xsNXD7 .edgeLabel .label text{fill:#333;}#mermaid-svg-ycoytgMEk8xsNXD7 .label div .edgeLabel{color:#333;}#mermaid-svg-ycoytgMEk8xsNXD7 .stateLabel text{fill:#131300;font-size:10px;font-weight:bold;}#mermaid-svg-ycoytgMEk8xsNXD7 .node circle.state-start{fill:#333333;stroke:#333333;}#mermaid-svg-ycoytgMEk8xsNXD7 .node .fork-join{fill:#333333;stroke:#333333;}#mermaid-svg-ycoytgMEk8xsNXD7 .node circle.state-end{fill:#9370DB;stroke:white;stroke-width:1.5;}#mermaid-svg-ycoytgMEk8xsNXD7 .end-state-inner{fill:white;stroke-width:1.5;}#mermaid-svg-ycoytgMEk8xsNXD7 .node rect{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-ycoytgMEk8xsNXD7 .node polygon{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-ycoytgMEk8xsNXD7 #statediagram-barbEnd{fill:#333333;}#mermaid-svg-ycoytgMEk8xsNXD7 .statediagram-cluster rect{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-ycoytgMEk8xsNXD7 .cluster-label,#mermaid-svg-ycoytgMEk8xsNXD7 .nodeLabel{color:#131300;}#mermaid-svg-ycoytgMEk8xsNXD7 .statediagram-cluster rect.outer{rx:5px;ry:5px;}#mermaid-svg-ycoytgMEk8xsNXD7 .statediagram-state .divider{stroke:#9370DB;}#mermaid-svg-ycoytgMEk8xsNXD7 .statediagram-state .title-state{rx:5px;ry:5px;}#mermaid-svg-ycoytgMEk8xsNXD7 .statediagram-cluster.statediagram-cluster .inner{fill:white;}#mermaid-svg-ycoytgMEk8xsNXD7 .statediagram-cluster.statediagram-cluster-alt .inner{fill:#f0f0f0;}#mermaid-svg-ycoytgMEk8xsNXD7 .statediagram-cluster .inner{rx:0;ry:0;}#mermaid-svg-ycoytgMEk8xsNXD7 .statediagram-state rect.basic{rx:5px;ry:5px;}#mermaid-svg-ycoytgMEk8xsNXD7 .statediagram-state rect.divider{stroke-dasharray:10,10;fill:#f0f0f0;}#mermaid-svg-ycoytgMEk8xsNXD7 .note-edge{stroke-dasharray:5;}#mermaid-svg-ycoytgMEk8xsNXD7 .statediagram-note rect{fill:#fff5ad;stroke:#aaaa33;stroke-width:1px;rx:0;ry:0;}#mermaid-svg-ycoytgMEk8xsNXD7 .statediagram-note rect{fill:#fff5ad;stroke:#aaaa33;stroke-width:1px;rx:0;ry:0;}#mermaid-svg-ycoytgMEk8xsNXD7 .statediagram-note text{fill:black;}#mermaid-svg-ycoytgMEk8xsNXD7 .statediagram-note .nodeLabel{color:black;}#mermaid-svg-ycoytgMEk8xsNXD7 .statediagram .edgeLabel{color:red;}#mermaid-svg-ycoytgMEk8xsNXD7 #dependencyStart,#mermaid-svg-ycoytgMEk8xsNXD7 #dependencyEnd{fill:#333333;stroke:#333333;stroke-width:1;}#mermaid-svg-ycoytgMEk8xsNXD7 .statediagramTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-ycoytgMEk8xsNXD7 :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 在家
离开家围栏
进入商圈围栏
离开商圈围栏
进入公司围栏
离开公司围栏
进入家围栏
Home
Commuting
Shopping
Office
围栏事件的处理需要考虑优先级和去重。当两个围栏重叠时,应按业务优先级处理------例如"公司围栏"优先于"商圈围栏",避免用户在公司内的商圈时收到不相关通知。
五、室内定位:WiFi/蓝牙辅助定位 🏢
5.1 室内定位的挑战
GPS 信号在室内严重衰减甚至完全不可用,这是定位服务在建筑内部面临的核心难题。OpenClaw 节点通过 WiFi 指纹定位和蓝牙 Beacon 两种互补方案解决室内定位问题。
| 方案 | 原理 | 精度 | 部署成本 | 适用场景 |
|---|---|---|---|---|
| WiFi 指纹 | 信号强度模式匹配 | 3-15 米 | 低(利用现有 AP) | 商场、办公楼 |
| 蓝牙 Beacon | RSSI 测距 + 三角定位 | 0.5-3 米 | 中(需部署 Beacon) | 展厅、仓库 |
| UWB | 超宽带脉冲测距 | 0.1-0.5 米 | 高(专用硬件) | 工厂AGV、医疗 |
5.2 WiFi 指纹定位
WiFi 指纹定位的核心思想是:每个位置的 WiFi 信号强度组合(指纹)是独特的。通过预先采集各位置的指纹数据库,运行时将实时扫描结果与数据库匹配即可推算位置。
python
import numpy as np
from collections import defaultdict
class WiFiFingerprintLocator:
"""WiFi 指纹室内定位引擎"""
def __init__(self):
self.fingerprint_db = [] # [(position, fingerprint), ...]
def build_fingerprint(self, scan_results):
"""从扫描结果构建指纹向量
scan_results: [{bssid, rssi, ssid}, ...]
"""
fingerprint = {}
for ap in scan_results:
fingerprint[ap["bssid"]] = ap["rssi"]
return fingerprint
def add_reference_point(self, position, fingerprint):
"""添加参考点(离线采集阶段)"""
self.fingerprint_db.append((position, fingerprint))
def locate(self, scan_results, k=5):
"""K近邻定位(在线定位阶段)"""
query = self.build_fingerprint(scan_results)
# 计算与所有参考点的距离
distances = []
for ref_pos, ref_fp in self.fingerprint_db:
dist = self._fp_distance(query, ref_fp)
distances.append((dist, ref_pos))
# 取最近的 K 个参考点加权平均
distances.sort(key=lambda x: x[0])
nearest = distances[:k]
if not nearest or nearest[0][0] == 0:
return nearest[0][1] if nearest else None
total_weight = sum(1.0 / d for d, _ in nearest if d > 0)
lat = sum((1.0/d) * p[0] for d, p in nearest if d > 0) / total_weight
lng = sum((1.0/d) * p[1] for d, p in nearest if d > 0) / total_weight
return (lat, lng)
def _fp_distance(self, fp1, fp2):
"""计算两个指纹的欧氏距离"""
all_bssids = set(fp1.keys()) | set(fp2.keys())
sum_sq = 0
for bssid in all_bssids:
r1 = fp1.get(bssid, -100) # 未检测到的AP设为-100
r2 = fp2.get(bssid, -100)
sum_sq += (r1 - r2) ** 2
return math.sqrt(sum_sq)
WiFiFingerprintLocator 实现了经典的 WiFi 指纹定位流程。离线阶段通过 add_reference_point 在已知位置采集 WiFi 指纹存入数据库。在线阶段 locate 将实时扫描结果与数据库中所有参考点比较,找到信号模式最相似的 K 个参考点,以距离倒数作为权重进行加权平均,得到最终位置估计。_fp_distance 计算两个指纹的欧氏距离,对于只在一方出现的 AP,缺失方 RSSI 设为 -100 dBm(极弱信号),这是指纹定位的标准处理方式。
5.3 蓝牙 Beacon 定位
蓝牙 Beacon 通过广播固定功率的信号,设备根据接收信号强度(RSSI)推算与 Beacon 的距离,再通过多个 Beacon 的三角定位确定位置。
python
class BeaconLocator:
"""蓝牙 Beacon 室内定位"""
BEACON_POSITIONS = {
"beacon-A": (31.23040, 121.47370, 2.5), # (lat, lng, 高度m)
"beacon-B": (31.23045, 121.47380, 2.5),
"beacon-C": (31.23035, 121.47375, 2.5),
}
def rssi_to_distance(self, rssi, tx_power=-59, n=2.0):
"""RSSI 转距离(对数距离路径损耗模型)
rssi: 接收信号强度 dBm
tx_power: 1米处参考RSSI
n: 路径损耗指数(2=自由空间,3=室内)
"""
return 10 ** ((tx_power - rssi) / (10 * n))
def trilaterate(self, beacon_distances):
"""三边测量定位
beacon_distances: {beacon_id: distance_meters}
"""
if len(beacon_distances) < 3:
return None # 至少需要3个Beacon
# 取信号最强的3个Beacon
sorted_beacons = sorted(beacon_distances.items(),
key=lambda x: x[1])[:3]
positions = []
distances = []
for bid, dist in sorted_beacons:
if bid in self.BEACON_POSITIONS:
pos = self.BEACON_POSITIONS[bid]
positions.append((pos[0], pos[1]))
distances.append(dist)
if len(positions) < 3:
return None
# 简化的加权质心(生产环境应使用最小二乘法)
weights = [1.0 / (d ** 2 + 0.01) for d in distances]
total_w = sum(weights)
lat = sum(w * p[0] for w, p in zip(weights, positions)) / total_w
lng = sum(w * p[1] for w, p in zip(weights, positions)) / total_w
return (lat, lng)
BeaconLocator 实现了蓝牙 Beacon 定位的两个关键步骤。rssi_to_distance 使用对数距离路径损耗模型将 RSSI 转换为物理距离,n 参数是路径损耗指数------自由空间取 2,室内环境因多径效应通常取 2.5-3.5。trilaterate 执行三边测量定位,取信号最强的三个 Beacon,以距离平方倒数作为权重计算加权质心。生产环境中应使用最小二乘法求解精确的三边测量方程组,此处加权质心为简化实现。
5.4 混合定位架构
实际部署中,WiFi 指纹和蓝牙 Beacon 通常组合使用,形成混合定位架构:
#mermaid-svg-ek9jQ720EmwbBt8F{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-ek9jQ720EmwbBt8F .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-ek9jQ720EmwbBt8F .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-ek9jQ720EmwbBt8F .error-icon{fill:#552222;}#mermaid-svg-ek9jQ720EmwbBt8F .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-ek9jQ720EmwbBt8F .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-ek9jQ720EmwbBt8F .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-ek9jQ720EmwbBt8F .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-ek9jQ720EmwbBt8F .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-ek9jQ720EmwbBt8F .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-ek9jQ720EmwbBt8F .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-ek9jQ720EmwbBt8F .marker{fill:#333333;stroke:#333333;}#mermaid-svg-ek9jQ720EmwbBt8F .marker.cross{stroke:#333333;}#mermaid-svg-ek9jQ720EmwbBt8F svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-ek9jQ720EmwbBt8F p{margin:0;}#mermaid-svg-ek9jQ720EmwbBt8F .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-ek9jQ720EmwbBt8F .cluster-label text{fill:#333;}#mermaid-svg-ek9jQ720EmwbBt8F .cluster-label span{color:#333;}#mermaid-svg-ek9jQ720EmwbBt8F .cluster-label span p{background-color:transparent;}#mermaid-svg-ek9jQ720EmwbBt8F .label text,#mermaid-svg-ek9jQ720EmwbBt8F span{fill:#333;color:#333;}#mermaid-svg-ek9jQ720EmwbBt8F .node rect,#mermaid-svg-ek9jQ720EmwbBt8F .node circle,#mermaid-svg-ek9jQ720EmwbBt8F .node ellipse,#mermaid-svg-ek9jQ720EmwbBt8F .node polygon,#mermaid-svg-ek9jQ720EmwbBt8F .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-ek9jQ720EmwbBt8F .rough-node .label text,#mermaid-svg-ek9jQ720EmwbBt8F .node .label text,#mermaid-svg-ek9jQ720EmwbBt8F .image-shape .label,#mermaid-svg-ek9jQ720EmwbBt8F .icon-shape .label{text-anchor:middle;}#mermaid-svg-ek9jQ720EmwbBt8F .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-ek9jQ720EmwbBt8F .rough-node .label,#mermaid-svg-ek9jQ720EmwbBt8F .node .label,#mermaid-svg-ek9jQ720EmwbBt8F .image-shape .label,#mermaid-svg-ek9jQ720EmwbBt8F .icon-shape .label{text-align:center;}#mermaid-svg-ek9jQ720EmwbBt8F .node.clickable{cursor:pointer;}#mermaid-svg-ek9jQ720EmwbBt8F .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-ek9jQ720EmwbBt8F .arrowheadPath{fill:#333333;}#mermaid-svg-ek9jQ720EmwbBt8F .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-ek9jQ720EmwbBt8F .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-ek9jQ720EmwbBt8F .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-ek9jQ720EmwbBt8F .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-ek9jQ720EmwbBt8F .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-ek9jQ720EmwbBt8F .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-ek9jQ720EmwbBt8F .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-ek9jQ720EmwbBt8F .cluster text{fill:#333;}#mermaid-svg-ek9jQ720EmwbBt8F .cluster span{color:#333;}#mermaid-svg-ek9jQ720EmwbBt8F div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-ek9jQ720EmwbBt8F .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-ek9jQ720EmwbBt8F rect.text{fill:none;stroke-width:0;}#mermaid-svg-ek9jQ720EmwbBt8F .icon-shape,#mermaid-svg-ek9jQ720EmwbBt8F .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-ek9jQ720EmwbBt8F .icon-shape p,#mermaid-svg-ek9jQ720EmwbBt8F .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-ek9jQ720EmwbBt8F .icon-shape .label rect,#mermaid-svg-ek9jQ720EmwbBt8F .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-ek9jQ720EmwbBt8F .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-ek9jQ720EmwbBt8F .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-ek9jQ720EmwbBt8F :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 是
否
是
否
是
否
是
否
节点位置请求
GPS 可用?
GPS 定位
WiFi 指纹匹配?
WiFi 指纹定位
Beacon 可用?
Beacon 定位
基站粗略定位
精度评估
精度满足要求?
返回位置
融合多源重试
该架构按精度从高到低依次尝试 GPS → WiFi 指纹 → Beacon → 基站定位,并在每一步评估精度是否满足业务要求。当单一源精度不足时,通过多源融合(如卡尔曼滤波)提升最终定位质量。
六、实战案例1:设备防丢提醒 🛡️
6.1 场景描述
将 OpenClaw 节点部署在贵重设备(笔记本电脑、行李箱、车辆)上,当设备离开预设安全区域时自动告警,并在地图上追踪设备位置。
6.2 系统架构
#mermaid-svg-4PCGa5TBAoZZPHo9{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-4PCGa5TBAoZZPHo9 .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-4PCGa5TBAoZZPHo9 .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-4PCGa5TBAoZZPHo9 .error-icon{fill:#552222;}#mermaid-svg-4PCGa5TBAoZZPHo9 .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-4PCGa5TBAoZZPHo9 .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-4PCGa5TBAoZZPHo9 .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-4PCGa5TBAoZZPHo9 .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-4PCGa5TBAoZZPHo9 .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-4PCGa5TBAoZZPHo9 .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-4PCGa5TBAoZZPHo9 .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-4PCGa5TBAoZZPHo9 .marker{fill:#333333;stroke:#333333;}#mermaid-svg-4PCGa5TBAoZZPHo9 .marker.cross{stroke:#333333;}#mermaid-svg-4PCGa5TBAoZZPHo9 svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-4PCGa5TBAoZZPHo9 p{margin:0;}#mermaid-svg-4PCGa5TBAoZZPHo9 .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-4PCGa5TBAoZZPHo9 .cluster-label text{fill:#333;}#mermaid-svg-4PCGa5TBAoZZPHo9 .cluster-label span{color:#333;}#mermaid-svg-4PCGa5TBAoZZPHo9 .cluster-label span p{background-color:transparent;}#mermaid-svg-4PCGa5TBAoZZPHo9 .label text,#mermaid-svg-4PCGa5TBAoZZPHo9 span{fill:#333;color:#333;}#mermaid-svg-4PCGa5TBAoZZPHo9 .node rect,#mermaid-svg-4PCGa5TBAoZZPHo9 .node circle,#mermaid-svg-4PCGa5TBAoZZPHo9 .node ellipse,#mermaid-svg-4PCGa5TBAoZZPHo9 .node polygon,#mermaid-svg-4PCGa5TBAoZZPHo9 .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-4PCGa5TBAoZZPHo9 .rough-node .label text,#mermaid-svg-4PCGa5TBAoZZPHo9 .node .label text,#mermaid-svg-4PCGa5TBAoZZPHo9 .image-shape .label,#mermaid-svg-4PCGa5TBAoZZPHo9 .icon-shape .label{text-anchor:middle;}#mermaid-svg-4PCGa5TBAoZZPHo9 .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-4PCGa5TBAoZZPHo9 .rough-node .label,#mermaid-svg-4PCGa5TBAoZZPHo9 .node .label,#mermaid-svg-4PCGa5TBAoZZPHo9 .image-shape .label,#mermaid-svg-4PCGa5TBAoZZPHo9 .icon-shape .label{text-align:center;}#mermaid-svg-4PCGa5TBAoZZPHo9 .node.clickable{cursor:pointer;}#mermaid-svg-4PCGa5TBAoZZPHo9 .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-4PCGa5TBAoZZPHo9 .arrowheadPath{fill:#333333;}#mermaid-svg-4PCGa5TBAoZZPHo9 .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-4PCGa5TBAoZZPHo9 .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-4PCGa5TBAoZZPHo9 .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-4PCGa5TBAoZZPHo9 .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-4PCGa5TBAoZZPHo9 .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-4PCGa5TBAoZZPHo9 .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-4PCGa5TBAoZZPHo9 .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-4PCGa5TBAoZZPHo9 .cluster text{fill:#333;}#mermaid-svg-4PCGa5TBAoZZPHo9 .cluster span{color:#333;}#mermaid-svg-4PCGa5TBAoZZPHo9 div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-4PCGa5TBAoZZPHo9 .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-4PCGa5TBAoZZPHo9 rect.text{fill:none;stroke-width:0;}#mermaid-svg-4PCGa5TBAoZZPHo9 .icon-shape,#mermaid-svg-4PCGa5TBAoZZPHo9 .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-4PCGa5TBAoZZPHo9 .icon-shape p,#mermaid-svg-4PCGa5TBAoZZPHo9 .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-4PCGa5TBAoZZPHo9 .icon-shape .label rect,#mermaid-svg-4PCGa5TBAoZZPHo9 .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-4PCGa5TBAoZZPHo9 .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-4PCGa5TBAoZZPHo9 .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-4PCGa5TBAoZZPHo9 :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 位置更新
围栏事件
离开安全区
长时间静止
设备节点
Gateway
告警引擎
推送通知
异常检测
手机/邮件/飞书
可能遗失告警
6.3 完整实现
python
class DeviceAntiLost:
"""设备防丢提醒系统"""
SAFE_ZONES = {
"home": {"lat": 31.2304, "lng": 121.4737, "radius": 200},
"office": {"lat": 31.2345, "lng": 121.4800, "radius": 100},
}
def __init__(self, gateway, node_id, notify_channel):
self.gateway = gateway
self.node_id = node_id
self.notify_channel = notify_channel
self.last_location = None
self.last_move_time = None
self.alert_cooldown = {} # 防止频繁告警
async def setup(self):
"""初始化防丢系统"""
tracker = LocationTracker(self.gateway, self.node_id)
tracker.on_update(self._on_location_update)
await tracker.start_tracking(interval_ms=10000, accuracy="balanced")
for zone_id, zone in self.SAFE_ZONES.items():
await self.gateway.node_invoke(
node=self.node_id,
command="geofence.add",
params={
"id": f"safe-{zone_id}",
"type": "circle",
"center": {"latitude": zone["lat"],
"longitude": zone["lng"]},
"radius": zone["radius"],
"events": ["exit"]
}
)
async def _on_location_update(self, location):
"""位置更新处理"""
self.last_location = location
# 检测异常静止(可能被遗忘在某处)
if location.get("speed", 0) < 0.1:
if self.last_move_time is None:
self.last_move_time = location["timestamp"]
else:
self.last_move_time = location["timestamp"]
async def on_fence_exit(self, event):
"""离开安全区域告警"""
fence_id = event["fenceId"]
zone_name = fence_id.replace("safe-", "")
# 冷却检查:同一区域5分钟内不重复告警
now = time.time()
if fence_id in self.alert_cooldown:
if now - self.alert_cooldown[fence_id] < 300:
return
self.alert_cooldown[fence_id] = now
loc = event["location"]
message = (
f"⚠️ 设备离开安全区域 [{zone_name}]\n"
f"当前位置: {loc['latitude']:.4f}, {loc['longitude']:.4f}\n"
f"精度: {loc['accuracy']:.1f}米\n"
f"时间: {event['timestamp']}"
)
await self._send_alert(message)
async def _send_alert(self, message):
"""发送告警通知"""
await self.gateway.send_message(
channel=self.notify_channel,
text=message
)
DeviceAntiLost 实现了完整的设备防丢系统。setup 方法同时启动连续追踪和地理围栏------离家/公司超过指定半径即触发告警。_on_location_update 在每次位置更新时检测异常静止:如果设备长时间速度为零,可能被遗忘在某处。on_fence_exit 处理围栏离开事件,包含冷却机制防止在围栏边界反复进出时产生告警风暴。告警通过 Gateway 消息通道发送到用户的手机、邮件或飞书。
七、实战案例2:位置签到系统 ✅
7.1 场景描述
基于位置的外勤签到系统。员工到达客户现场后,系统自动检测其位置是否在客户地址附近,确认后完成签到,无需手动操作。
7.2 数据模型
| 字段 | 类型 | 说明 |
|---|---|---|
checkin_id |
string | 签到记录 ID |
user_id |
string | 员工 ID |
customer_id |
string | 客户 ID |
customer_location |
geo | 客户坐标 |
checkin_location |
geo | 签到坐标 |
distance |
number | 签到距离(米) |
accuracy |
number | 定位精度(米) |
checkin_time |
datetime | 签到时间 |
status |
enum | valid/invalid/pending |
7.3 实现逻辑
python
class LocationCheckIn:
"""位置签到系统"""
VALID_DISTANCE = 200 # 签到有效距离(米)
MIN_ACCURACY = 50 # 最低可接受精度(米)
def __init__(self, gateway, node_id, db_client):
self.gateway = gateway
self.node_id = node_id
self.db = db_client
async def check_in(self, user_id, customer_id):
"""执行位置签到"""
# 获取当前位置
location = await self.gateway.node_invoke(
node=self.node_id,
command="location.get",
params={"accuracy": "high", "includeAddress": True}
)
# 获取客户位置
customer = await self.db.get_customer(customer_id)
customer_loc = customer["location"]
# 计算距离
distance = self._haversine_distance(
location["latitude"], location["longitude"],
customer_loc["lat"], customer_loc["lng"]
)
# 判断签到有效性
accuracy = location.get("accuracy", float("inf"))
if accuracy > self.MIN_ACCURACY:
status = "pending"
note = f"定位精度不足({accuracy:.0f}米),需人工确认"
elif distance <= self.VALID_DISTANCE:
status = "valid"
note = f"在客户{self.VALID_DISTANCE}米范围内"
else:
status = "invalid"
note = f"距客户{distance:.0f}米,超出有效范围"
# 保存签到记录
record = {
"user_id": user_id,
"customer_id": customer_id,
"checkin_location": {
"lat": location["latitude"],
"lng": location["longitude"]
},
"distance": distance,
"accuracy": accuracy,
"status": status,
"note": note,
"checkin_time": location["timestamp"]
}
await self.db.save_checkin(record)
return record
@staticmethod
def _haversine_distance(lat1, lng1, lat2, lng2):
"""Haversine 公式计算两点间距离(米)"""
R = 6371000 # 地球平均半径(米)
dlat = math.radians(lat2 - lat1)
dlng = math.radians(lng2 - lng1)
a = (math.sin(dlat / 2) ** 2 +
math.cos(math.radians(lat1)) *
math.cos(math.radians(lat2)) *
math.sin(dlng / 2) ** 2)
return R * 2 * math.atan2(math.sqrt(a), math.sqrt(1 - a))
LocationCheckIn 实现了位置签到的核心逻辑。check_in 方法首先获取高精度当前位置,然后通过 Haversine 公式计算与客户地址的距离。签到有效性判断考虑两个维度:定位精度是否满足要求,以及距离是否在有效范围内。精度不足时标记为 pending 状态,需人工确认------这是位置签到系统中最常见的边界情况处理。_haversine_distance 使用 Haversine 公式计算球面上两点间的大圆距离,是地理距离计算的标准方法。
八、实战案例3:智能家居联动 🏠
8.1 场景描述
根据家庭成员的位置自动控制智能家居设备:离家自动关灯关空调,回家提前开灯开空调,接近小区时开启车库门。
8.2 联动规则配置
python
class SmartHomeLocationAutomation:
"""基于位置的智能家居联动"""
RULES = [
{
"name": "离家模式",
"trigger": "exit",
"fence": "home",
"actions": [
{"device": "all_lights", "command": "turn_off"},
{"device": "ac", "command": "turn_off"},
{"device": "security_camera", "command": "enable"},
],
"delay": 300 # 离家5分钟后执行,防止短暂外出
},
{
"name": "回家模式",
"trigger": "enter",
"fence": "home",
"actions": [
{"device": "living_room_light", "command": "turn_on"},
{"device": "ac", "command": "set_temperature", "params": {"temp": 26}},
{"device": "security_camera", "command": "disable"},
],
"delay": 0
},
{
"name": "车库门",
"trigger": "enter",
"fence": "community_gate",
"actions": [
{"device": "garage_door", "command": "open"},
],
"delay": 0
},
]
def __init__(self, gateway, node_id, home_api):
self.gateway = gateway
self.node_id = node_id
self.home_api = home_api
self.pending_actions = {}
async def on_fence_event(self, event):
"""处理围栏事件并执行联动"""
event_type = event["type"] # enter / exit
fence_id = event["fenceId"]
for rule in self.RULES:
if rule["trigger"] != event_type:
continue
if rule["fence"] != fence_id:
continue
if rule["delay"] > 0:
# 延迟执行:先取消之前的待执行动作
rule_name = rule["name"]
if rule_name in self.pending_actions:
self.pending_actions[rule_name].cancel()
self.pending_actions[rule_name] = asyncio.create_task(
self._delayed_execute(rule, rule["delay"])
)
else:
await self._execute_actions(rule)
async def _delayed_execute(self, rule, delay_seconds):
"""延迟执行联动动作"""
await asyncio.sleep(delay_seconds)
await self._execute_actions(rule)
async def _execute_actions(self, rule):
"""执行联动动作"""
print(f"🏠 执行联动: {rule['name']}")
for action in rule["actions"]:
try:
await self.home_api.control(
device=action["device"],
command=action["command"],
params=action.get("params", {})
)
except Exception as e:
print(f"❌ 动作执行失败: {action['device']} - {e}")
SmartHomeLocationAutomation 实现了基于位置的智能家居联动。核心设计要点是延迟执行机制------离家模式设置了 5 分钟延迟,如果用户在 5 分钟内返回(如忘带东西),之前的离家动作会被取消,避免频繁开关设备。回家模式无需延迟,立即执行。车库门在进入小区门口围栏时触发,与家围栏分开设置以提前开启。_execute_actions 逐个执行联动动作并捕获异常,确保单个设备失败不影响其他动作。
九、定位服务最佳实践与注意事项 ⚡
9.1 电池优化策略
持续定位是移动设备最大的耗电来源之一。以下是 OpenClaw 节点定位的省电策略:
| 策略 | 实现 | 预期节电 |
|---|---|---|
| 精度分级 | 围栏判断用 balanced,导航用 high |
30%-50% |
| 距离过滤 | distanceFilter 只在移动超过阈值时更新 |
40%-60% |
| 自适应间隔 | 静止时降低更新频率,移动时提高 | 20%-40% |
| 围栏优先 | 使用硬件围栏而非软件轮询 | 50%-70% |
| 批量上报 | 缓存多个位置点后一次性上报 | 10%-20% |
9.2 隐私与安全
位置数据是最敏感的个人信息之一。OpenClaw 节点定位服务遵循以下隐私原则:
- 最小采集:只获取业务所需的最粗粒度位置,围栏判断不需要精确坐标
- 本地优先:围栏判断在节点本地执行,坐标不离开设备
- 用户知情:持续追踪必须在用户明确授权后启动
- 数据留存:位置历史设置自动过期清理策略
- 传输加密:节点与 Gateway 之间使用 WSS 加密传输
9.3 常见问题与排查
| 问题 | 可能原因 | 解决方案 |
|---|---|---|
| 定位超时 | GPS 冷启动、室内无信号 | 切换 balanced 模式,利用 WiFi 辅助 |
| 精度持续偏低 | WiFi 定位数据库未更新 | 重新采集 WiFi 指纹,更新参考点 |
| 围栏误触发 | 围栏半径过小、GPS 抖动 | 增大围栏半径,添加 loiteringDelay |
| 位置跳变 | GPS 多径效应 | 卡尔曼滤波平滑,忽略速度异常点 |
| 持续追踪耗电快 | 更新间隔过短 | 增大 distanceFilter,降低更新频率 |
十、总结与展望 🎯
OpenClaw 节点定位服务构建了从基础位置获取到智能场景联动的完整技术栈:
- 基础层 :
location_get/location.watch提供单次快照与持续追踪两种模式 - 解析层:坐标转换、精度评估、轨迹简化确保数据质量
- 围栏层:地理围栏将位置变化转化为业务事件,是连接定位与业务的桥梁
- 室内层:WiFi 指纹与蓝牙 Beacon 解决 GPS 不可用的室内场景
- 应用层:防丢提醒、位置签到、智能家居联动展示定位服务的业务价值
未来,随着 UWB(超宽带)技术在消费设备上的普及,室内定位精度有望达到厘米级。OpenClaw 节点架构天然支持新定位源的接入------只需实现对应的 node.invoke 命令处理,即可将 UWB、5G 定位等新源纳入统一的多源融合定位体系。