lty/qy_lty/docs/设备动态绑定方案.md
pmc bd95ba470c feat: update admin panel, API modules, and add migrations
- Update food, outfits, props, home-decor pages and components
- Add permissions page and sidebar updates
- Update API client and all API modules (auth, food, dances, etc.)
- Add card model migrations for optional fields
- Update Django views, serializers, and authentication
- Add affinity level migrations and user app updates
- Add project documentation

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-20 13:06:50 +08:00

1034 lines
46 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 手机端-设备端 动态绑定方案设计文档
## 1. 现状分析
### 1.1 当前架构
系统由三个项目组成:
| 项目 | 路径 | 角色 |
|------|------|------|
| Django 服务器 | `qy_lty-main/` | 后端 API + WebSocket 中转 |
| Unity 手机端 | `LTY_App_Project_URP/` | 用户操控 App |
| Unity 设备端 | `LTY_Project/` | 实体硬件设备程序 |
### 1.2 当前通信流程
```
手机端 WebSocketNetworking.cs 服务器 设备端 WebSocketConnection.cs
│ │ │
│ POST /api/user/auth/mac/login/│ │
│ mac_address="00:55:11:44:f3:22" │
│──────────────────────────────>│ │
│ token_A │ │
│<──────────────────────────────│ │
│ │ POST /api/user/auth/mac/login/
│ │ mac_address="00:55:11:44:f3:22"
│ │<──────────────────────────────│
│ │ token_B │
│ │──────────────────────────────>│
│ │ │
│ WSS /ws/device/token/{token_A}│ │
│──────────────────────────────>│ WSS /ws/device/token/{token_B}
│ 加入 group: device_{uid} │<──────────────────────────────│
│ │ 加入 group: device_{uid}
│ │ │
│ ← 同一个 group消息互通 → │
```
### 1.3 核心问题
1. **MAC 地址硬编码**:手机端 `WebSocketNetworking.cs` 和设备端 `WebSocketConnection.cs` 都写死了 `macAddress = "00:55:11:44:f3:22"`
2. **手机端不应该用 MAC 登录**:手机端是用户操作的 App应使用手机号/账号登录,而非设备 MAC 地址登录
3. **绑定关系是假的**:两端碰巧用同一个 MAC → 查到同一个 User → 进入同一个 WebSocket group并非真正的动态绑定
4. **无法支持多设备**:所有设备都用同一个 MAC无法区分
5. **手机端绑定 UI 是空壳**`DeviceDetailsViewMediator.cs` 中解绑按钮只有 `Debug.Log("解绑设备")`
---
## 2. 目标设计
### 2.1 设计原则
- **手机端用用户身份登录**,不再用 MAC 地址登录
- **设备端用自身真实 MAC 地址登录**,每台设备有唯一身份
- **通过服务器绑定 API 建立用户-设备关联**
- **WebSocket 消息基于绑定关系精准路由**
- **支持一个用户绑定多台设备,一台设备只能绑定一个用户**
### 2.2 目标流程总览
```
┌─────────────┐ ┌──────────────┐ ┌─────────────┐
│ 手机端 │ │ 服务器 │ │ 设备端 │
│ (用户 App) │ │ (Django) │ │ (实体硬件) │
└──────┬──────┘ └──────┬───────┘ └──────┬──────┘
│ │ │
①用户登录(手机号) │ │
│──────────────────>│ │
│ user_token │ │
│<──────────────────│ │
│ │ ②设备开机用真实MAC登录
│ │<────────────────────│
│ │ device_token │
│ │────────────────────>│
│ │ │
③蓝牙扫描发现设备 │ ④设备连WebSocket
│ │<════════════════════│
│ │ ws/device/token/{device_token}
⑤调用绑定API │ │
(传入扫描到的MAC) │ │
│──────────────────>│ │
│ 绑定成功 │ │
│<──────────────────│ │
│ │ │
⑥手机连WebSocket │ │
│═══════════════════>│ │
│ ws/device/token/{user_token} │
│ │ │
⑦发送指令(touch/dance等) │ │
│──────────────────>│ 转发到设备 │
│ │────────────────────>│
│ │ 设备回报状态 │
│ 转发到手机 │<────────────────────│
│<──────────────────│ │
```
---
## 3. 服务器端改动
### 3.1 现有可复用部分(无需改动)
服务器已有完整的数据模型和绑定 API以下部分 **可直接使用**
| 组件 | 文件 | 说明 |
|------|------|------|
| Device 模型 | `device_interaction/models.py` | `mac_address` 唯一字段已存在 |
| UserDevice 模型 | `device_interaction/models.py` | 用户-设备绑定关系已存在 |
| 绑定 API | `device_interaction/views.py``UserDeviceViewSet.bind()` | `POST /api/device/user-devices/bind/` 已实现 |
| 解绑 API | `device_interaction/views.py``UserDeviceViewSet.destroy()` | `DELETE /api/device/user-devices/{id}/` 已实现 |
| 设备列表 | `device_interaction/views.py``UserDeviceViewSet.list()` | 已能查询用户绑定的设备 |
| MAC 登录 | `userapp/views.py``MacAddressLoginView` | 设备端 MAC 登录已实现 |
| 用户登录 | `userapp/views.py``PhoneLoginView` 等 | 手机号/邮箱/用户名登录已实现 |
### 3.2 需要修改WebSocket Consumer 消息路由
**问题**:当前 `DeviceConsumer` 使用 `device_{user_id}` 作为 group name手机端和设备端如果是不同 user无法进入同一个 group。
**改动思路**:设备端用 MAC 登录后获得的 token 对应一个"设备用户",需要改为通过绑定关系找到实际用户,用实际用户的 group 通信。
#### 方案 A设备连接时查绑定关系加入绑定用户的 group推荐
修改 `device_interaction/consumers.py``connect()` 方法:
```python
class DeviceConsumer(AsyncWebsocketConsumer):
async def connect(self):
try:
# ... 现有认证逻辑保持不变 ...
user = self.scope['user']
self.user_id = str(user.id)
# 新增:判断连接来源(设备端 vs 手机端)
self.device_mac = self.scope.get('device_mac', None)
self.is_device = False
self.groups_joined = []
if self.device_mac:
# 设备端连接:通过绑定关系找到关联用户
bound_user_id = await self.get_bound_user_id(self.device_mac)
if bound_user_id:
self.is_device = True
group_name = f"device_{bound_user_id}"
self.groups_joined.append(group_name)
else:
# 设备未绑定,加入自己的 group 等待绑定
group_name = f"device_unbound_{self.user_id}"
self.groups_joined.append(group_name)
else:
# 手机端连接:使用自己的 user_id
group_name = f"device_{self.user_id}"
self.groups_joined.append(group_name)
self.group_name = group_name
await self.channel_layer.group_add(
self.group_name,
self.channel_name
)
await self.accept()
except Exception as e:
logger.error(f"Error in WebSocket connect: {str(e)}")
await self.close(code=4002)
@database_sync_to_async
def get_bound_user_id(self, mac_address):
"""通过 MAC 地址查找绑定的用户 ID"""
try:
from .models import Device, UserDevice
device = Device.objects.get(mac_address=mac_address)
user_device = UserDevice.objects.filter(device=device).first()
if user_device:
return str(user_device.user.id)
return None
except Device.DoesNotExist:
return None
```
#### 方案 B更简单MAC 登录时直接返回绑定用户的 token
修改 `MacAddressLoginView`,当设备 MAC 已绑定用户时,返回 **绑定用户的 token** 而非设备自身的 token。这样设备端用该 token 连 WebSocket 就自动进入正确的 group。
```python
# userapp/views.py - MacAddressLoginView.post()
class MacAddressLoginView(APIView):
def post(self, request):
mac_address = request.data.get('mac_address')
# 查找设备
device = Device.objects.filter(mac_address=mac_address).first()
if not device:
return error_response("设备不存在", code=404)
# 查找绑定的用户
user_device = UserDevice.objects.filter(device=device).first()
if not user_device:
return error_response("设备未绑定用户", code=400)
# 用绑定用户的身份生成 token
bound_user = user_device.user
token = generate_token(bound_user)
return success_response({
"token": token,
"user_id": bound_user.id,
"device_code": device.device_code,
"mac_address": mac_address,
"bound": True
})
```
> **推荐方案 B**,因为改动最小——只改 MAC 登录接口的返回逻辑,手机端和设备端的 WebSocket 连接代码无需改变Consumer 也不用改。
### 3.3 需要新增:设备注册接口(可选)
当前设备必须预先在后台管理系统录入数据库。如果希望设备即插即用,可新增自动注册接口:
```python
# POST /api/device/devices/register/
# 设备首次开机时调用,如果 MAC 不存在则自动创建 Device 记录
{
"mac_address": "AA:BB:CC:DD:EE:FF",
"device_type_code": "T01", # 设备类型代码
"firmware_version": "1.0.0"
}
```
### 3.4 需要新增:绑定状态查询接口
供设备端查询自己是否已被绑定:
```python
# GET /api/device/devices/bind_status/?mac_address=AA:BB:CC:DD:EE:FF
# 返回:
{
"bound": true,
"user_id": 123,
"nickname": "客厅的莉拉"
}
```
---
## 4. 设备端LTY_Project改动
### 4.1 获取真实 MAC 地址
替换硬编码 MAC改为从设备硬件读取
```csharp
// WebSocketConnection.cs
// 旧代码(删除):
// private string macAddress = "00:55:11:44:f3:22";
// 新代码:
private string macAddress;
void Awake()
{
macAddress = GetDeviceMacAddress();
Debug.Log($"设备 MAC 地址: {macAddress}");
}
/// <summary>
/// 获取设备真实 MAC 地址
/// 优先级:蓝牙 MAC > WiFi MAC > 设备唯一标识
/// </summary>
private string GetDeviceMacAddress()
{
// 方案1从 Android 系统获取蓝牙 MAC需要 Android 插件)
#if UNITY_ANDROID && !UNITY_EDITOR
try
{
using (var bluetoothManager = new AndroidJavaObject("android.bluetooth.BluetoothAdapter"))
{
var adapter = bluetoothManager.CallStatic<AndroidJavaObject>("getDefaultAdapter");
if (adapter != null)
{
string mac = adapter.Call<string>("getAddress");
if (!string.IsNullOrEmpty(mac) && mac != "02:00:00:00:00:00")
return mac.ToUpper();
}
}
}
catch (System.Exception e)
{
Debug.LogWarning($"获取蓝牙 MAC 失败: {e.Message}");
}
#endif
// 方案2从配置文件读取工厂烧录时写入
string configPath = System.IO.Path.Combine(Application.persistentDataPath, "device_config.json");
if (System.IO.File.Exists(configPath))
{
string json = System.IO.File.ReadAllText(configPath);
var config = JsonUtility.FromJson<DeviceConfig>(json);
if (!string.IsNullOrEmpty(config.mac_address))
return config.mac_address;
}
// 方案3使用设备唯一标识符生成伪 MAC最后兜底方案
string deviceId = SystemInfo.deviceUniqueIdentifier;
return GenerateMacFromId(deviceId);
}
private string GenerateMacFromId(string id)
{
// 用设备 ID 的哈希值生成固定的 MAC 格式
byte[] hash = System.Security.Cryptography.MD5.Create()
.ComputeHash(System.Text.Encoding.UTF8.GetBytes(id));
return string.Format("{0:X2}:{1:X2}:{2:X2}:{3:X2}:{4:X2}:{5:X2}",
hash[0], hash[1], hash[2], hash[3], hash[4], hash[5]);
}
[System.Serializable]
private class DeviceConfig
{
public string mac_address;
public string device_type;
}
```
### 4.2 设备端启动流程改造
```csharp
// WebSocketConnection.cs 启动流程
private bool isBound = false;
IEnumerator Start()
{
macAddress = GetDeviceMacAddress();
// 步骤1用 MAC 地址登录
yield return StartCoroutine(LoginWithMac());
if (string.IsNullOrEmpty(token))
{
Debug.LogError("MAC 登录失败,设备未注册或未绑定");
// 进入"等待绑定"状态,显示设备 MAC / 二维码供手机扫描
ShowWaitingForBind();
yield break;
}
isBound = true;
// 步骤2连接 WebSocket
ConnectWebSocket();
}
private void ShowWaitingForBind()
{
// 在设备屏幕上显示 MAC 地址或二维码,供手机端扫描绑定
Debug.Log($"等待绑定,设备 MAC: {macAddress}");
// 可定时轮询绑定状态
StartCoroutine(PollBindStatus());
}
IEnumerator PollBindStatus()
{
while (!isBound)
{
string url = $"{httpBaseUrl}/api/device/devices/bind_status/?mac_address={macAddress}";
using (UnityWebRequest request = UnityWebRequest.Get(url))
{
yield return request.SendWebRequest();
if (request.result == UnityWebRequest.Result.Success)
{
var response = JsonUtility.FromJson<BindStatusResponse>(request.downloadHandler.text);
if (response.bound)
{
isBound = true;
// 重新登录获取绑定用户的 token
yield return StartCoroutine(LoginWithMac());
ConnectWebSocket();
yield break;
}
}
}
yield return new WaitForSeconds(5f); // 每5秒查一次
}
}
[System.Serializable]
private class BindStatusResponse
{
public bool bound;
public int user_id;
public string nickname;
}
```
### 4.3 getJson.cs MAC 地址改造
```csharp
// getJson.cs
// 旧代码(删除):
// string url = "https://qy-lty.airlabs.art/api/device/rtc-token/get_by_mac/?mac_address=00:55:11:44:f3:22";
// 新代码:从 WebSocketConnection 获取动态 MAC
string mac = WebSocketConnection.Instance.MacAddress; // 需要暴露为公共属性
string url = $"https://qy-lty.airlabs.art/api/device/rtc-token/get_by_mac/?mac_address={mac}";
```
---
## 5. 手机端LTY_App_Project_URP改动
### 5.1 登录方式变更
手机端不再使用 MAC 地址登录,改为用户账号登录:
```csharp
// WebSocketNetworking.cs
// 旧代码(删除):
// private string macAddress = "00:55:11:44:f3:22";
// LoginRequest loginRequest = new LoginRequest { mac_address = macAddress };
// 调用 /api/user/auth/mac/login/
// 新代码:使用手机号/账号登录获取的 token
// token 来自登录页面PhoneLoginView / UsernameLoginView
// 存储在全局管理器中
public class UserSession
{
public static UserSession Instance { get; private set; }
public string Token { get; private set; }
public int UserId { get; private set; }
public string Username { get; private set; }
public void SetLogin(string token, int userId, string username)
{
Token = token;
UserId = userId;
Username = username;
}
public void Clear()
{
Token = null;
UserId = 0;
Username = null;
}
}
```
### 5.2 WebSocket 连接改造
```csharp
// WebSocketNetworking.cs
// 使用用户 token 连接 WebSocket不再需要先 MAC 登录)
public void ConnectWebSocket()
{
string token = UserSession.Instance.Token;
if (string.IsNullOrEmpty(token))
{
Debug.LogError("用户未登录,无法连接 WebSocket");
return;
}
string wsUrl = $"{wsBaseUrl}/ws/device/token/{token}/";
Debug.Log($"连接 WebSocket: {wsUrl}");
webSocket = new WebSocket(new Uri(wsUrl));
webSocket.OnOpen += OnWebSocketOpen;
webSocket.OnMessage += OnMessageReceived;
webSocket.OnError += OnError;
webSocket.OnClosed += OnClosed;
webSocket.Open();
}
```
### 5.3 蓝牙扫描 → 获取 MAC → 调用绑定 API
> **绑定入口**:绑定逻辑将在 **JarConnection_Panel**"添加新罐子"按钮)中实现,参考 `BluetoothCentral.cs` 的蓝牙扫描逻辑,但不直接修改 `BluetoothCentral.cs`。
绑定 API 调用封装如下:
```csharp
// 新增DeviceBindManager.cs
using UnityEngine;
using UnityEngine.Networking;
using System.Collections;
public class DeviceBindManager : MonoBehaviour
{
public static DeviceBindManager Instance { get; private set; }
private string baseUrl = "https://qy-lty.airlabs.art/api";
void Awake()
{
Instance = this;
}
/// <summary>
/// 绑定设备(蓝牙扫描到 MAC 后调用)
/// </summary>
public void BindDevice(string macAddress, string nickname = null,
System.Action<UserDeviceInfo> onSuccess = null,
System.Action<string> onError = null)
{
StartCoroutine(BindDeviceCoroutine(macAddress, nickname, onSuccess, onError));
}
private IEnumerator BindDeviceCoroutine(string macAddress, string nickname,
System.Action<UserDeviceInfo> onSuccess, System.Action<string> onError)
{
string url = $"{baseUrl}/device/user-devices/bind/";
string token = UserSession.Instance.Token;
// 构建请求体
var bindData = new BindRequest
{
mac_address = macAddress,
nickname = nickname ?? "我的莉拉",
is_primary = true
};
string jsonBody = JsonUtility.ToJson(bindData);
using (UnityWebRequest request = new UnityWebRequest(url, "POST"))
{
byte[] bodyRaw = System.Text.Encoding.UTF8.GetBytes(jsonBody);
request.uploadHandler = new UploadHandlerRaw(bodyRaw);
request.downloadHandler = new DownloadHandlerBuffer();
request.SetRequestHeader("Content-Type", "application/json");
request.SetRequestHeader("Authorization", $"Bearer {token}");
yield return request.SendWebRequest();
if (request.result == UnityWebRequest.Result.Success)
{
var response = JsonUtility.FromJson<BindResponse>(request.downloadHandler.text);
Debug.Log($"设备绑定成功: {response.data.device_code}");
onSuccess?.Invoke(response.data);
}
else
{
string error = request.downloadHandler.text;
Debug.LogError($"设备绑定失败: {error}");
onError?.Invoke(error);
}
}
}
/// <summary>
/// 解绑设备
/// </summary>
public void UnbindDevice(int userDeviceId,
System.Action onSuccess = null,
System.Action<string> onError = null)
{
StartCoroutine(UnbindDeviceCoroutine(userDeviceId, onSuccess, onError));
}
private IEnumerator UnbindDeviceCoroutine(int userDeviceId,
System.Action onSuccess, System.Action<string> onError)
{
string url = $"{baseUrl}/device/user-devices/{userDeviceId}/";
string token = UserSession.Instance.Token;
using (UnityWebRequest request = UnityWebRequest.Delete(url))
{
request.downloadHandler = new DownloadHandlerBuffer();
request.SetRequestHeader("Authorization", $"Bearer {token}");
yield return request.SendWebRequest();
if (request.result == UnityWebRequest.Result.Success)
{
Debug.Log("设备解绑成功");
onSuccess?.Invoke();
}
else
{
onError?.Invoke(request.downloadHandler.text);
}
}
}
/// <summary>
/// 获取已绑定设备列表
/// </summary>
public void GetBoundDevices(System.Action<UserDeviceInfo[]> onSuccess)
{
StartCoroutine(GetBoundDevicesCoroutine(onSuccess));
}
private IEnumerator GetBoundDevicesCoroutine(System.Action<UserDeviceInfo[]> onSuccess)
{
string url = $"{baseUrl}/device/user-devices/";
string token = UserSession.Instance.Token;
using (UnityWebRequest request = UnityWebRequest.Get(url))
{
request.SetRequestHeader("Authorization", $"Bearer {token}");
yield return request.SendWebRequest();
if (request.result == UnityWebRequest.Result.Success)
{
var response = JsonUtility.FromJson<DeviceListResponse>(request.downloadHandler.text);
onSuccess?.Invoke(response.results);
}
}
}
// ---- 数据类 ----
[System.Serializable]
public class BindRequest
{
public string mac_address;
public string nickname;
public bool is_primary;
}
[System.Serializable]
public class BindResponse
{
public bool success;
public UserDeviceInfo data;
}
[System.Serializable]
public class UserDeviceInfo
{
public int id;
public string device_code;
public string device_type;
public string device_status;
public int battery_level;
public string nickname;
public bool is_primary;
public string mac_address;
}
[System.Serializable]
public class DeviceListResponse
{
public UserDeviceInfo[] results;
}
}
```
### 5.4 绑定入口JarConnection_Panel
绑定逻辑将在 JarConnection_Panel"添加新罐子")中实现,核心调用模式:
```csharp
// JarConnection_Panel 中,蓝牙连接设备成功后调用绑定 API
void OnDeviceConnectedForBind(string deviceMacAddress)
{
string formattedMac = FormatMacAddress(deviceMacAddress);
DeviceBindManager.Instance.BindDevice(
macAddress: formattedMac,
nickname: "我的莉拉",
onSuccess: (deviceInfo) =>
{
Debug.Log($"绑定成功,设备码: {deviceInfo.device_code}");
// 绑定成功后连接 WebSocket
_ = WebSocketNetworking.instance.ConnectWebSocket();
},
onError: (error) => Debug.LogError($"绑定失败: {error}")
);
}
```
### 5.5 DeviceDetailsViewMediator 对接解绑
```csharp
// DeviceDetailsViewMediator.cs
// 替换空壳的解绑按钮:
view.btnUnbind.onClick.AddListener(() =>
{
// 确认弹窗(根据项目 UI 框架实现)
// 确认后调用解绑
int userDeviceId = currentDeviceId; // 从设备详情数据中获取
DeviceBindManager.Instance.UnbindDevice(
userDeviceId,
onSuccess: () =>
{
Debug.Log("解绑成功");
// 断开 WebSocket
WebSocketNetworking.Instance.Disconnect();
// 返回设备列表页
SendNotification(PureNotification.HIDE_PANEL, this);
},
onError: (error) =>
{
Debug.LogError($"解绑失败: {error}");
}
);
});
```
---
## 6. 完整绑定流程时序图
### 6.1 首次绑定流程
```
用户 手机端App 服务器 设备端 蓝牙
│ │ │ │ │
│ 打开App │ │ │ │
│──────────────────>│ │ │ │
│ │ 手机号+验证码登录 │ │ │
│ │─────────────────────>│ │ │
│ │ user_token │ │ │
│ │<─────────────────────│ │ │
│ │ │ │ │
│ │ │ 设备开机 │ │
│ │ │ MAC登录 │ │
│ │ │<───────────────────│ │
│ │ │ 返回"未绑定"错误 │ │
│ │ │───────────────────>│ │
│ │ │ │ 显示等待绑定界面 │
│ │ │ │ (显示MAC/二维码) │
│ │ │ │ │
│ 点击"添加设备" │ │ │ │
│──────────────────>│ │ │ │
│ │ 开始蓝牙扫描 │ │ │
│ │────────────────────────────────────────────────────────────>│
│ │ │ │ │
│ │ 发现 LiLa 设备列表 │ │ │
│ │<────────────────────────────────────────────────────────────│
│ │ │ │ │
│ 选择设备 │ │ │ │
│──────────────────>│ │ │ │
│ │ 蓝牙连接设备 │ │ │
│ │────────────────────────────────────────────────────────────>│
│ │ 连接成功获得MAC │ │ │
│ │<────────────────────────────────────────────────────────────│
│ │ │ │ │
│ │ POST /bind/ │ │ │
│ │ {mac_address: MAC} │ │ │
│ │─────────────────────>│ │ │
│ │ │ 创建 UserDevice │ │
│ │ │ 激活 Device │ │
│ │ 绑定成功响应 │ │ │
│ │<─────────────────────│ │ │
│ │ │ │ │
│ │ 连接 WebSocket │ │ │
│ │ (user_token) │ │ │
│ │═════════════════════>│ │ │
│ │ 加入 device_{uid} │ │ │
│ │ │ │ │
│ │ │ 设备轮询发现已绑定 │ │
│ │ │<───────────────────│ │
│ │ │ 重新MAC登录 │ │
│ │ │<───────────────────│ │
│ │ │ 返回绑定用户token │ │
│ │ │───────────────────>│ │
│ │ │ │ │
│ │ │ 设备连接WebSocket │ │
│ │ │<═══════════════════│ │
│ │ │ 加入 device_{uid} │ │
│ │ │ │ │
│ │ ══════ 双向通信建立 ══════ │ │
│ │ │ │ │
│ 点击"摸头" │ │ │ │
│──────────────────>│ 发送 touch:head │ │ │
│ │─────────────────────>│ 转发到 group │ │
│ │ │───────────────────>│ │
│ │ │ │ 播放摸头动画 │
```
### 6.2 再次启动流程(已绑定)
```
手机端App 服务器 设备端
│ │ │
│ 用户登录(token缓存) │ │
│─────────────────────────>│ │
│ user_token │ │
│<─────────────────────────│ │
│ │ │
│ 查询已绑定设备列表 │ 设备开机 │
│ GET /user-devices/ │ MAC登录 │
│─────────────────────────>│<─────────────────────────│
│ 设备列表 │ 返回绑定用户token │
│<─────────────────────────│─────────────────────────>│
│ │ │
│ 连接WebSocket │ 连接WebSocket │
│ (user_token) │ (bound_user_token) │
│═════════════════════════>│<═════════════════════════│
│ 加入 device_{uid} │ 加入 device_{uid} │
│ │ │
│ ══════ 立即可以双向通信 ══════ │
```
---
## 7. API 接口清单
### 7.1 现有接口(可直接使用)
| 方法 | 路径 | 调用方 | 说明 |
|------|------|--------|------|
| `POST` | `/api/user/auth/phone/login/` | 手机端 | 手机号验证码登录 |
| `POST` | `/api/user/auth/phone/verify/` | 手机端 | 发送验证码 |
| `POST` | `/api/user/auth/mac/login/` | 设备端 | MAC 地址登录 |
| `POST` | `/api/device/user-devices/bind/` | 手机端 | 绑定设备(传 mac_address |
| `DELETE` | `/api/device/user-devices/{id}/` | 手机端 | 解绑设备 |
| `GET` | `/api/device/user-devices/` | 手机端 | 获取已绑定设备列表 |
| `POST` | `/api/device/user-devices/{id}/set_primary/` | 手机端 | 设为主设备 |
| `GET` | `/api/device/rtc-token/get_by_mac/` | 设备端 | 获取 RTC token |
### 7.2 需要新增的接口
| 方法 | 路径 | 调用方 | 说明 |
|------|------|--------|------|
| `GET` | `/api/device/devices/bind_status/` | 设备端 | 查询绑定状态 |
| `POST` | `/api/device/devices/register/` | 设备端 | 设备自注册(可选) |
### 7.3 需要修改的接口
| 方法 | 路径 | 改动说明 |
|------|------|---------|
| `POST` | `/api/user/auth/mac/login/` | 已绑定时返回绑定用户的 token未绑定时返回明确的未绑定状态 |
---
## 8. 数据模型关系
```
┌─────────────────┐ ┌──────────────────┐ ┌─────────────────┐
│ ParadiseUser │ │ UserDevice │ │ Device │
│─────────────────│ │──────────────────│ │─────────────────│
│ id │1─────*│ user_id (FK) │*─────1│ id │
│ username │ │ device_id (FK) │ │ device_code │
│ phone │ │ nickname │ │ mac_address (U) │
│ ... │ │ is_primary │ │ is_active │
│ │ │ bound_at │ │ status │
│ │ │ │ │ battery_level │
│ │ │ unique(user, │ │ device_type (FK)│
│ │ │ device) │ │ batch (FK) │
└─────────────────┘ └──────────────────┘ └─────────────────┘
约束:
- 一个用户可绑定多个设备UserDevice 多条记录)
- 一个设备同时只能绑定一个用户DeviceBindSerializer 中校验)
- 绑定时自动激活设备is_active=True
```
---
## 9. 改动文件清单
### 9.1 服务器qy_lty-main
| 文件 | 改动类型 | 说明 |
|------|---------|------|
| `userapp/views.py` | **修改** | `MacAddressLoginView`:已绑定时返回绑定用户的 token |
| `device_interaction/views.py` | **新增方法** | `DeviceViewSet.bind_status()`:查询设备绑定状态 |
| `device_interaction/views.py` | **新增方法(可选)** | `DeviceViewSet.register()`:设备自注册 |
| `device_interaction/consumers.py` | **修改** | 新增 `device_info` 消息类型处理 + `update_device_status` 方法 |
| `device_interaction/serializers.py` | **新增** | `DeviceRegisterSerializer` |
### 9.2 设备端LTY_Project
| 文件 | 改动类型 | 说明 |
|------|---------|------|
| `Assets/Scripts/WebSocketConnection.cs` | **修改** | 动态获取 MAC绑定状态处理WiFi连接后上报 `device_info` |
| `Assets/Scripts/getJson.cs` | **修改** | MAC 地址改为从 WebSocketConnection 动态获取 |
### 9.3 手机端LTY_App_Project_URP
| 文件 | 改动类型 | 说明 |
|------|---------|------|
| `Assets/Scripts/Manager/WebSocketNetworking.cs` | **修改** | 移除 MAC 登录,改用用户 token处理 `device_info` 消息 |
| `Assets/Scripts/Manager/DeviceBindManager.cs` | **新增** | 绑定/解绑/设备列表 API 调用封装 |
| `Assets/Scripts/Manager/UserSession.cs` | **新增** | 用户登录态全局管理 |
| `JarConnection_Panel`(新面板) | **新增** | 绑定入口:"添加新罐子",参考 BluetoothCentral 蓝牙扫描逻辑 |
| `Assets/Scripts/PureMVC/View/DeviceDetailsViewMediator.cs` | **修改** | 解绑按钮对接真实解绑 API |
| `Assets/Scripts/getJson.cs` | **修改** | MAC 地址改为动态获取(从 device_info 缓存) |
---
## 10. 实施步骤(建议顺序)
### 阶段一:服务器端准备
1. 修改 `MacAddressLoginView`,支持返回绑定用户 token
2. 新增 `bind_status` 接口
3. 部署并测试 API
### 阶段二:设备端改造
4. 修改 `WebSocketConnection.cs`,动态获取 MAC
5. 增加未绑定状态处理和轮询逻辑
6. 修改 `getJson.cs`MAC 动态化
7. 设备端联调测试
### 阶段三:手机端改造
8. 新增 `UserSession.cs``DeviceBindManager.cs`
9. 改造 `WebSocketNetworking.cs`,移除 MAC 登录
10. 打通蓝牙扫描 → 绑定流程
11. 实现解绑功能
12. 手机端联调测试
### 阶段四:三端联调
13. 手机端登录 → 蓝牙扫描 → 绑定 → WebSocket 互通
14. 设备端开机 → 等待绑定 → 被绑定后自动连接
15. 指令双向通信测试touch/dance/weather 等)
16. 解绑后设备回到等待绑定状态
---
## 11. 关键设计问答
### Q1手机端 MAC 地址不固定(切换网络),设备端怎么给手机端发指令?
**手机端不需要 MAC 地址**。整个通信架构中,手机端的身份标识是 `user_id`(从登录 token 中获取),而非 MAC 地址。
通信原理:
```
手机端登录 → 获得 user_token → 连接 WebSocket → 加入 group: device_{user_id}
设备端MAC登录 → 获得绑定用户的 token → 连接 WebSocket → 加入 group: device_{user_id}
↑ 同一个 group
```
- **手机→设备**:手机端发消息到 WebSocket → 服务器广播到 `device_{user_id}` group → 设备端收到
- **设备→手机**:设备端发消息到 WebSocket → 服务器广播到 `device_{user_id}` group → 手机端收到
两端都在同一个 group 里,**双向通信天然支持**,不需要知道对方的 MAC/IP 地址。手机随便切换 WiFi/4G/5G只要 WebSocket 连着(或重连成功),消息就能到达。
### Q2设备端连上 WiFi 后如何把 MAC 地址传给手机端和服务器?
设备端连接 WiFi 后的 MAC 上报流程:
```
设备端 服务器 手机端
│ │ │
│ 连WiFi成功 │ │
│ ↓ │ │
│ POST /devices/register/ │ │
│ {mac_address, device_type} │ │
│─────────────────────────────>│ 创建/更新 Device 记录 │
│ 返回 device_code │ │
│<─────────────────────────────│ │
│ │ │
│ (若已绑定) MAC登录 │ │
│─────────────────────────────>│ │
│ 绑定用户 token │ │
│<─────────────────────────────│ │
│ │ │
│ 连接 WebSocket │ │
│══════════════════════════════>│ │
│ │ │
│ 发送 device_info 消息 │ 转发到 group │
│ {type:"device_info", │─────────────────────────>│
│ message:{mac, device_code, │ │
│ firmware, battery...}} │ │ 手机端收到设备信息
│ │ │ 可显示在UI上
```
具体实现分三层:
1. **服务器记录**:设备调用 `POST /devices/register/` 自注册MAC 地址写入 Device 表
2. **WebSocket 上报**:设备连接 WebSocket 后,发送一条 `device_info` 类型消息,把 MAC、电量、固件版本等信息推给手机端
3. **手机端接收**:手机端在 WebSocket 收到 `device_info` 消息后,更新本地设备信息 UI
### Q3手机端绑定入口在哪里
绑定逻辑将在 **JarConnection_Panel**"添加新罐子"按钮)中执行,参考 `BluetoothCentral.cs` 的蓝牙扫描逻辑,但不直接修改 `BluetoothCentral.cs`。流程:
```
JarConnection_Panel
└─ "添加新罐子"按钮
└─ 蓝牙扫描 LiLa 设备(参考 BluetoothCentral 逻辑)
└─ 用户选择设备
└─ 蓝牙连接成功,获得设备蓝牙 MAC
└─ 调用 POST /user-devices/bind/ (传 MAC)
└─ 绑定成功 → 连接 WebSocket
```
---
## 12. 新增 WebSocket 消息类型device_info
### 目的
设备端连接 WebSocket 后主动上报自身信息MAC地址、电量、固件版本等让手机端实时获知设备状态。
### 消息格式
```json
// 设备端 → 服务器 → 手机端
{
"type": "device_info",
"message": {
"mac_address": "AA:BB:CC:DD:EE:FF",
"device_code": "T01-AUTO-T01-00001",
"firmware_version": "1.0.0",
"battery_level": 85,
"wifi_name": "HomeWiFi",
"brightness": 50
}
}
```
### 需要改动
| 项目 | 文件 | 改动 |
|------|------|------|
| 服务器 | `consumers.py` | 新增 `device_info` 消息类型处理(广播到 group |
| 设备端 | `WebSocketConnection.cs` | WebSocket 连接成功后发送 `device_info` 消息 |
| 手机端 | `WebSocketNetworking.cs` / `PhoneManager.cs` | 收到 `device_info` 时更新设备信息 UI |
---
## 13. 注意事项
1. **MAC 地址格式统一**:蓝牙扫描得到的 MAC 可能是 `AA-BB-CC-DD-EE-FF``AABBCCDDEEFF`,需统一转换为 `AA:BB:CC:DD:EE:FF` 格式
2. **Android 蓝牙权限**Android 12+ 需要 `BLUETOOTH_SCAN``BLUETOOTH_CONNECT` 权限
3. **Token 过期处理**:设备端长期运行,需要处理 token 过期后的自动刷新
4. **断线重连**设备端已有重连逻辑最多5次需确保重连时 token 仍有效
5. **并发绑定**:服务器端 `unique_together = ['user', 'device']` 约束可防止重复绑定
6. **安全性**:绑定 API 需要用户认证(已配置 `RedisTokenAuthentication`),防止未授权绑定
7. **手机端无需 MAC 标识**:手机端通过 user_id 标识,切换网络不影响 WebSocket group 归属
8. **设备信息实时上报**:设备连接 WebSocket 后应立即发送 `device_info`,手机端可据此更新 UI