- 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>
1034 lines
46 KiB
Markdown
1034 lines
46 KiB
Markdown
# 手机端-设备端 动态绑定方案设计文档
|
||
|
||
## 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
|