增加加密
This commit is contained in:
@@ -14,6 +14,17 @@ from fpioa_manager import fm
|
|||||||
import utime
|
import utime
|
||||||
from machine import UART
|
from machine import UART
|
||||||
import os
|
import os
|
||||||
|
try:
|
||||||
|
import ubinascii
|
||||||
|
except:
|
||||||
|
ubinascii = None
|
||||||
|
try:
|
||||||
|
import ucryptolib as crypto_lib
|
||||||
|
except:
|
||||||
|
try:
|
||||||
|
import cryptolib as crypto_lib
|
||||||
|
except:
|
||||||
|
crypto_lib = None
|
||||||
|
|
||||||
# --- 增加堆内存大小 ---
|
# --- 增加堆内存大小 ---
|
||||||
# K210有8MB SRAM,默认堆较小,增加到512KB用于图像压缩
|
# K210有8MB SRAM,默认堆较小,增加到512KB用于图像压缩
|
||||||
@@ -41,6 +52,125 @@ ESP_BAUD_RATES = (115200, 9600, 74880, 57600, 38400)
|
|||||||
ESP_UART_BAUD = 115200
|
ESP_UART_BAUD = 115200
|
||||||
ESP_BOOT_WAIT_MS = 6000 # 等待ESP8266上电启动和自动连网
|
ESP_BOOT_WAIT_MS = 6000 # 等待ESP8266上电启动和自动连网
|
||||||
LOCAL_FACE_PHOTO_DIR = "/sd/face_photos" # 本地人脸照片缓存目录
|
LOCAL_FACE_PHOTO_DIR = "/sd/face_photos" # 本地人脸照片缓存目录
|
||||||
|
AES_KEY = b"\x2b\x7e\x15\x16\x28\xae\xd2\xa6\xab\xf7\x15\x88\x09\xcf\x4f\x3c"
|
||||||
|
AES_IV = b"\x00\x01\x02\x03\x04\x05\x06\x07\x08\x09\x0a\x0b\x0c\x0d\x0e\x0f"
|
||||||
|
|
||||||
|
def crypto_ready():
|
||||||
|
return crypto_lib is not None and ubinascii is not None
|
||||||
|
|
||||||
|
def pkcs7_pad(data):
|
||||||
|
pad_len = 16 - (len(data) % 16)
|
||||||
|
return data + bytes([pad_len]) * pad_len
|
||||||
|
|
||||||
|
def pkcs7_unpad(data):
|
||||||
|
if not data:
|
||||||
|
return b""
|
||||||
|
pad_len = data[-1]
|
||||||
|
if pad_len < 1 or pad_len > 16 or pad_len > len(data):
|
||||||
|
return data
|
||||||
|
for b in data[-pad_len:]:
|
||||||
|
if b != pad_len:
|
||||||
|
return data
|
||||||
|
return data[:-pad_len]
|
||||||
|
|
||||||
|
def aes_cbc_crypt(data, encrypt=True):
|
||||||
|
if not crypto_ready():
|
||||||
|
print("[CRYPTO] ucryptolib/ubinascii unavailable, refuse plaintext HTTP")
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
cipher = crypto_lib.aes(AES_KEY, 2, AES_IV)
|
||||||
|
return cipher.encrypt(data) if encrypt else cipher.decrypt(data)
|
||||||
|
except Exception as e:
|
||||||
|
print("[CRYPTO] AES failed:", e)
|
||||||
|
return None
|
||||||
|
|
||||||
|
def encrypt_payload_base64(data):
|
||||||
|
if isinstance(data, str):
|
||||||
|
data = data.encode("utf-8")
|
||||||
|
encrypted = aes_cbc_crypt(pkcs7_pad(data), True)
|
||||||
|
if encrypted is None:
|
||||||
|
return None
|
||||||
|
encoded = ubinascii.b2a_base64(encrypted).strip()
|
||||||
|
try:
|
||||||
|
return encoded.decode("utf-8")
|
||||||
|
except:
|
||||||
|
return str(encoded, "utf-8")
|
||||||
|
|
||||||
|
def decrypt_payload_base64(data):
|
||||||
|
if data is None:
|
||||||
|
return None
|
||||||
|
if isinstance(data, str):
|
||||||
|
data = data.strip().encode("utf-8")
|
||||||
|
try:
|
||||||
|
encrypted = ubinascii.a2b_base64(data)
|
||||||
|
except Exception as e:
|
||||||
|
print("[CRYPTO] Base64 decode failed:", e)
|
||||||
|
return None
|
||||||
|
decrypted = aes_cbc_crypt(encrypted, False)
|
||||||
|
if decrypted is None:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return pkcs7_unpad(decrypted).decode("utf-8")
|
||||||
|
except Exception as e:
|
||||||
|
print("[CRYPTO] UTF-8 decode failed:", e)
|
||||||
|
return None
|
||||||
|
|
||||||
|
def extract_http_body(resp):
|
||||||
|
if not resp:
|
||||||
|
return ""
|
||||||
|
marker = "\r\n\r\n"
|
||||||
|
start = resp.find(marker)
|
||||||
|
if start == -1:
|
||||||
|
return ""
|
||||||
|
body = resp[start + len(marker):]
|
||||||
|
content_length = -1
|
||||||
|
cl_pos = resp.find("Content-Length:")
|
||||||
|
if cl_pos != -1:
|
||||||
|
cl_pos += len("Content-Length:")
|
||||||
|
while cl_pos < len(resp) and resp[cl_pos] == " ":
|
||||||
|
cl_pos += 1
|
||||||
|
cl_end = cl_pos
|
||||||
|
while cl_end < len(resp) and resp[cl_end].isdigit():
|
||||||
|
cl_end += 1
|
||||||
|
try:
|
||||||
|
content_length = int(resp[cl_pos:cl_end])
|
||||||
|
except:
|
||||||
|
content_length = -1
|
||||||
|
if content_length >= 0 and len(body) >= content_length:
|
||||||
|
return body[:content_length]
|
||||||
|
for marker in ("\r\nCLOSED", "CLOSED", "\r\nOK"):
|
||||||
|
pos = body.find(marker)
|
||||||
|
if pos != -1:
|
||||||
|
body = body[:pos]
|
||||||
|
return body.strip()
|
||||||
|
|
||||||
|
def http_body_complete(resp):
|
||||||
|
if not resp or "\r\n\r\n" not in resp:
|
||||||
|
return False
|
||||||
|
body = resp.split("\r\n\r\n", 1)[1]
|
||||||
|
cl_pos = resp.find("Content-Length:")
|
||||||
|
if cl_pos == -1:
|
||||||
|
return "CLOSED" in resp
|
||||||
|
cl_pos += len("Content-Length:")
|
||||||
|
while cl_pos < len(resp) and resp[cl_pos] == " ":
|
||||||
|
cl_pos += 1
|
||||||
|
cl_end = cl_pos
|
||||||
|
while cl_end < len(resp) and resp[cl_end].isdigit():
|
||||||
|
cl_end += 1
|
||||||
|
try:
|
||||||
|
return len(body) >= int(resp[cl_pos:cl_end])
|
||||||
|
except:
|
||||||
|
return "CLOSED" in resp
|
||||||
|
|
||||||
|
def decode_http_json_body(resp):
|
||||||
|
body = extract_http_body(resp)
|
||||||
|
if not body:
|
||||||
|
return None
|
||||||
|
if "X-Encrypted: AES128-CBC" in resp:
|
||||||
|
return decrypt_payload_base64(body)
|
||||||
|
if body.startswith("{") or body.startswith("["):
|
||||||
|
return body
|
||||||
|
return decrypt_payload_base64(body)
|
||||||
|
|
||||||
# 摄像头安装方向:当前实物画面上下左右都反,水平镜像+垂直翻转等价于旋转180度。
|
# 摄像头安装方向:当前实物画面上下左右都反,水平镜像+垂直翻转等价于旋转180度。
|
||||||
CAMERA_HMIRROR = 1
|
CAMERA_HMIRROR = 1
|
||||||
@@ -519,6 +649,10 @@ def update_face_record(face_id, new_student_id):
|
|||||||
|
|
||||||
face_id = str(face_id)
|
face_id = str(face_id)
|
||||||
new_student_id = str(new_student_id)
|
new_student_id = str(new_student_id)
|
||||||
|
if not local_face_id_exists(face_id):
|
||||||
|
print("[UPDATE] Ignore mapping for missing local face_id {} -> {}".format(face_id, new_student_id))
|
||||||
|
return False
|
||||||
|
|
||||||
face_id_mapping[face_id] = new_student_id
|
face_id_mapping[face_id] = new_student_id
|
||||||
save_local_mapping()
|
save_local_mapping()
|
||||||
|
|
||||||
@@ -560,7 +694,7 @@ def esp_http_get(url_path, timeout=8000):
|
|||||||
utime.sleep_ms(200)
|
utime.sleep_ms(200)
|
||||||
|
|
||||||
# 构建HTTP GET请求
|
# 构建HTTP GET请求
|
||||||
http_request = "GET {} HTTP/1.1\r\nHost: {}:{}\r\nX-Api-Token: {}\r\nConnection: close\r\n\r\n".format(
|
http_request = "GET {} HTTP/1.1\r\nHost: {}:{}\r\nX-Api-Token: {}\r\nX-Encrypted-Response: true\r\nConnection: close\r\n\r\n".format(
|
||||||
url_path, SERVER_IP, SERVER_PORT, API_TOKEN)
|
url_path, SERVER_IP, SERVER_PORT, API_TOKEN)
|
||||||
|
|
||||||
# 使用AT+CIPSEND发送
|
# 使用AT+CIPSEND发送
|
||||||
@@ -623,8 +757,8 @@ def esp_http_get(url_path, timeout=8000):
|
|||||||
print("[HTTP_GET] 累积数据: {} bytes".format(len(resp)))
|
print("[HTTP_GET] 累积数据: {} bytes".format(len(resp)))
|
||||||
except:
|
except:
|
||||||
pass
|
pass
|
||||||
# 检查是否收到完整响应(有JSON且连接关闭)
|
# 加密响应是Base64文本,按Content-Length或连接关闭判断完整性。
|
||||||
if "}" in resp and "{" in resp:
|
if http_body_complete(resp):
|
||||||
if "CLOSED" in resp:
|
if "CLOSED" in resp:
|
||||||
print("[HTTP_GET] 连接已关闭,数据完整")
|
print("[HTTP_GET] 连接已关闭,数据完整")
|
||||||
break
|
break
|
||||||
@@ -644,103 +778,27 @@ def esp_http_get(url_path, timeout=8000):
|
|||||||
if len(resp) > 0:
|
if len(resp) > 0:
|
||||||
print("[HTTP_GET] 响应片段: {}".format(resp[:300].replace('\r\n', '\\n')))
|
print("[HTTP_GET] 响应片段: {}".format(resp[:300].replace('\r\n', '\\n')))
|
||||||
|
|
||||||
# 提取JSON
|
result = decode_http_json_body(resp)
|
||||||
json_start = resp.find('{"')
|
if result:
|
||||||
if json_start != -1:
|
print("[HTTP_GET] 解密JSON成功: {}".format(result[:100]))
|
||||||
json_end = resp.rfind('}')
|
|
||||||
if json_end != -1 and json_end > json_start:
|
|
||||||
result = resp[json_start:json_end+1]
|
|
||||||
print("[HTTP_GET] 提取JSON成功: {}".format(result[:100]))
|
|
||||||
return result
|
return result
|
||||||
|
|
||||||
print("[HTTP_GET] 未找到JSON")
|
print("[HTTP_GET] 未找到可用JSON")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def sync_face_id_mapping():
|
|
||||||
"""从服务器同步人脸ID映射配置并更新本地记录"""
|
|
||||||
global face_id_mapping
|
|
||||||
|
|
||||||
if not wifi_connected:
|
|
||||||
print("[SYNC] WiFi not connected, skip sync")
|
|
||||||
return False
|
|
||||||
|
|
||||||
try:
|
|
||||||
print("[SYNC] ========== 开始同步映射配置 ==========")
|
|
||||||
print("[SYNC] 请求: GET /api/v1/faces/mappings")
|
|
||||||
response = esp_http_get("/api/v1/faces/mappings", 2000)
|
|
||||||
|
|
||||||
if response is None:
|
|
||||||
print("[SYNC] 服务器无响应")
|
|
||||||
return False
|
|
||||||
|
|
||||||
print("[SYNC] 收到响应: {}".format(response[:150] if len(response) > 150 else response))
|
|
||||||
|
|
||||||
# 简单解析JSON
|
|
||||||
mappings_start = response.find('"mappings":{')
|
|
||||||
if mappings_start == -1:
|
|
||||||
print("[SYNC] 响应中无mappings字段")
|
|
||||||
return False
|
|
||||||
|
|
||||||
mappings_start += len('"mappings":{')
|
|
||||||
mappings_end = response.find('}', mappings_start)
|
|
||||||
if mappings_end == -1:
|
|
||||||
return False
|
|
||||||
|
|
||||||
mappings_str = response[mappings_start:mappings_end]
|
|
||||||
print("[SYNC] 解析映射字符串: {}".format(mappings_str))
|
|
||||||
|
|
||||||
# 解析键值对
|
|
||||||
server_mappings = {}
|
|
||||||
parts = mappings_str.split(',')
|
|
||||||
for part in parts:
|
|
||||||
part = part.strip()
|
|
||||||
if ':' in part:
|
|
||||||
kv = part.split(':')
|
|
||||||
if len(kv) == 2:
|
|
||||||
key = kv[0].strip().strip('"')
|
|
||||||
value = kv[1].strip().strip('"')
|
|
||||||
if key and value:
|
|
||||||
server_mappings[key] = value
|
|
||||||
|
|
||||||
print("[SYNC] 服务器映射数量: {}".format(len(server_mappings)))
|
|
||||||
for k, v in server_mappings.items():
|
|
||||||
print("[SYNC] 人脸ID: {} -> 学号: {}".format(k, v))
|
|
||||||
|
|
||||||
if len(server_mappings) == 0:
|
|
||||||
print("[SYNC] 服务器无映射配置")
|
|
||||||
face_id_mapping = {}
|
|
||||||
save_local_mapping()
|
|
||||||
return True
|
|
||||||
|
|
||||||
# 对比本地映射
|
|
||||||
print("[SYNC] 本地映射数量: {}".format(len(face_id_mapping)))
|
|
||||||
for k, v in face_id_mapping.items():
|
|
||||||
print("[SYNC] 本地: {} -> {}".format(k, v))
|
|
||||||
|
|
||||||
# 只更新 face_id -> 学号映射,保持 recordftr.txt 里的原始 face_id 稳定。
|
|
||||||
updated_count = 0
|
|
||||||
for face_id, student_id in server_mappings.items():
|
|
||||||
print("[SYNC] 检查是否需要更新: ID:{} -> {}".format(face_id, student_id))
|
|
||||||
if update_face_record(face_id, student_id):
|
|
||||||
updated_count += 1
|
|
||||||
|
|
||||||
# 更新内存中的映射表
|
|
||||||
face_id_mapping = server_mappings
|
|
||||||
save_local_mapping()
|
|
||||||
|
|
||||||
print("[SYNC] ========== 同步完成,更新了 {} 条记录 ==========".format(updated_count))
|
|
||||||
return True
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
print("[SYNC] 同步出错:", e)
|
|
||||||
return False
|
|
||||||
|
|
||||||
def extract_face_id(face_name):
|
def extract_face_id(face_name):
|
||||||
face_name = str(face_name)
|
face_name = str(face_name)
|
||||||
if face_name.startswith("ID:"):
|
if face_name.startswith("ID:"):
|
||||||
return face_name.split(":", 1)[1]
|
return face_name.split(":", 1)[1]
|
||||||
return face_name
|
return face_name
|
||||||
|
|
||||||
|
def local_face_id_exists(face_id):
|
||||||
|
face_id = str(face_id)
|
||||||
|
for p in peoples:
|
||||||
|
if extract_face_id(p.name) == face_id:
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
def get_mapped_student_id(face_name):
|
def get_mapped_student_id(face_name):
|
||||||
face_id = extract_face_id(face_name)
|
face_id = extract_face_id(face_name)
|
||||||
return face_id_mapping.get(face_id, "")
|
return face_id_mapping.get(face_id, "")
|
||||||
@@ -783,9 +841,7 @@ def delete_face_by_id(face_id):
|
|||||||
deleted_name = peoples[deleted_index].name
|
deleted_name = peoples[deleted_index].name
|
||||||
peoples.pop(deleted_index)
|
peoples.pop(deleted_index)
|
||||||
try:
|
try:
|
||||||
with open(record_path, "w") as f:
|
write_face_record()
|
||||||
for p in peoples:
|
|
||||||
f.write(p.name + '#' + str(p.feature) + "\n")
|
|
||||||
print("[DELETE] 删除人脸成功: {}".format(deleted_name))
|
print("[DELETE] 删除人脸成功: {}".format(deleted_name))
|
||||||
|
|
||||||
# 同时删除本地照片,避免下次启动补传已删除的人脸照片
|
# 同时删除本地照片,避免下次启动补传已删除的人脸照片
|
||||||
@@ -820,6 +876,9 @@ def esp_http_post_json(url_path, json_data, timeout=2000):
|
|||||||
"""发送HTTP POST请求(优化版)"""
|
"""发送HTTP POST请求(优化版)"""
|
||||||
if not wifi_connected:
|
if not wifi_connected:
|
||||||
return None
|
return None
|
||||||
|
encrypted_data = encrypt_payload_base64(json_data)
|
||||||
|
if encrypted_data is None:
|
||||||
|
return None
|
||||||
|
|
||||||
esp_send_cmd("AT+CIPCLOSE", 300)
|
esp_send_cmd("AT+CIPCLOSE", 300)
|
||||||
utime.sleep_ms(50)
|
utime.sleep_ms(50)
|
||||||
@@ -830,8 +889,8 @@ def esp_http_post_json(url_path, json_data, timeout=2000):
|
|||||||
if "OK" not in resp and "CONNECT" not in resp:
|
if "OK" not in resp and "CONNECT" not in resp:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
http_request = "POST {} HTTP/1.1\r\nHost: {}:{}\r\nContent-Type: application/json\r\nX-Api-Token: {}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}".format(
|
http_request = "POST {} HTTP/1.1\r\nHost: {}:{}\r\nContent-Type: application/octet-stream\r\nX-Api-Token: {}\r\nX-Encrypted: AES128-CBC\r\nX-Encrypted-Response: true\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}".format(
|
||||||
url_path, SERVER_IP, SERVER_PORT, API_TOKEN, len(json_data), json_data)
|
url_path, SERVER_IP, SERVER_PORT, API_TOKEN, len(encrypted_data), encrypted_data)
|
||||||
|
|
||||||
if not esp_send_data(http_request, 1500):
|
if not esp_send_data(http_request, 1500):
|
||||||
esp_send_cmd("AT+CIPCLOSE", 300)
|
esp_send_cmd("AT+CIPCLOSE", 300)
|
||||||
@@ -844,164 +903,12 @@ def esp_http_post_json(url_path, json_data, timeout=2000):
|
|||||||
data = uart2.read()
|
data = uart2.read()
|
||||||
if data:
|
if data:
|
||||||
resp += data.decode('utf-8', 'ignore')
|
resp += data.decode('utf-8', 'ignore')
|
||||||
if "success" in resp or "}" in resp:
|
if http_body_complete(resp):
|
||||||
break
|
break
|
||||||
utime.sleep_ms(10)
|
utime.sleep_ms(10)
|
||||||
|
|
||||||
esp_send_cmd("AT+CIPCLOSE", 300)
|
esp_send_cmd("AT+CIPCLOSE", 300)
|
||||||
return resp
|
return decode_http_json_body(resp)
|
||||||
|
|
||||||
def sync_pending_delete():
|
|
||||||
"""从服务器同步待删除的人脸列表"""
|
|
||||||
if not wifi_connected:
|
|
||||||
print("[DELETE_SYNC] WiFi未连接,跳过")
|
|
||||||
return False
|
|
||||||
|
|
||||||
try:
|
|
||||||
print("[DELETE_SYNC] ========== 开始同步删除列表 ==========")
|
|
||||||
print("[DELETE_SYNC] 请求: GET /api/v1/faces/pending-delete")
|
|
||||||
response = esp_http_get("/api/v1/faces/pending-delete", 8000)
|
|
||||||
|
|
||||||
if response is None:
|
|
||||||
print("[DELETE_SYNC] 服务器无响应")
|
|
||||||
return False
|
|
||||||
|
|
||||||
print("[DELETE_SYNC] 收到响应: {}".format(response))
|
|
||||||
|
|
||||||
# 解析JSON
|
|
||||||
start = response.find('"pending_delete":[')
|
|
||||||
if start == -1:
|
|
||||||
print("[DELETE_SYNC] 响应中无pending_delete字段")
|
|
||||||
return True
|
|
||||||
|
|
||||||
start += len('"pending_delete":[')
|
|
||||||
end = response.find(']', start)
|
|
||||||
if end == -1:
|
|
||||||
return False
|
|
||||||
|
|
||||||
arr_str = response[start:end]
|
|
||||||
print("[DELETE_SYNC] 待删除列表字符串: '{}'".format(arr_str))
|
|
||||||
|
|
||||||
if not arr_str.strip():
|
|
||||||
print("[DELETE_SYNC] 无待删除的人脸")
|
|
||||||
return True
|
|
||||||
|
|
||||||
# 解析数组
|
|
||||||
ids_to_delete = []
|
|
||||||
parts = arr_str.split(',')
|
|
||||||
for part in parts:
|
|
||||||
part = part.strip().strip('"')
|
|
||||||
if part:
|
|
||||||
ids_to_delete.append(part)
|
|
||||||
|
|
||||||
print("[DELETE_SYNC] 待删除人脸ID列表: {}".format(ids_to_delete))
|
|
||||||
|
|
||||||
if not ids_to_delete:
|
|
||||||
print("[DELETE_SYNC] 解析后无待删除项")
|
|
||||||
return True
|
|
||||||
|
|
||||||
# 显示当前本地人脸列表
|
|
||||||
print("[DELETE_SYNC] 当前本地人脸数量: {}".format(len(peoples)))
|
|
||||||
for i, p in enumerate(peoples):
|
|
||||||
print("[DELETE_SYNC] [{}] {}".format(i, p.name))
|
|
||||||
|
|
||||||
# 执行删除(每次最多处理一个,避免长时间阻塞)
|
|
||||||
face_id = ids_to_delete[0]
|
|
||||||
print("[DELETE_SYNC] 正在删除: {}".format(face_id))
|
|
||||||
|
|
||||||
delete_result = delete_face_by_id(face_id)
|
|
||||||
print("[DELETE_SYNC] 删除结果: {}".format("成功" if delete_result else "失败"))
|
|
||||||
|
|
||||||
if delete_result:
|
|
||||||
# 只有本地特征真正删除成功后才确认,避免服务端过早清掉待删除任务
|
|
||||||
print("[DELETE_SYNC] 发送确认: POST /api/v1/faces/delete-ack")
|
|
||||||
confirm_result = esp_http_post_confirm(face_id)
|
|
||||||
print("[DELETE_SYNC] 确认结果: {}".format("成功" if confirm_result else "失败"))
|
|
||||||
else:
|
|
||||||
print("[DELETE_SYNC] 本地未删除成功,暂不确认,等待下次同步重试")
|
|
||||||
|
|
||||||
# 显示删除后的人脸列表
|
|
||||||
print("[DELETE_SYNC] 删除后本地人脸数量: {}".format(len(peoples)))
|
|
||||||
for i, p in enumerate(peoples):
|
|
||||||
print("[DELETE_SYNC] [{}] {}".format(i, p.name))
|
|
||||||
|
|
||||||
print("[DELETE_SYNC] ========== 删除同步完成 ==========")
|
|
||||||
return True
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
print("[DELETE_SYNC] 同步出错:", e)
|
|
||||||
return False
|
|
||||||
|
|
||||||
def esp_http_post_confirm(face_id):
|
|
||||||
"""发送删除确认请求"""
|
|
||||||
if not wifi_connected:
|
|
||||||
return False
|
|
||||||
|
|
||||||
try:
|
|
||||||
esp_send_cmd("AT+CIPCLOSE", 500)
|
|
||||||
utime.sleep_ms(200)
|
|
||||||
esp_send_cmd("AT+CIPMUX=0", 500)
|
|
||||||
|
|
||||||
# 建立TCP连接
|
|
||||||
cmd = 'AT+CIPSTART="TCP","{}",{}'.format(SERVER_IP, SERVER_PORT)
|
|
||||||
resp = esp_send_cmd(cmd, 3000)
|
|
||||||
if "OK" not in resp and "CONNECT" not in resp:
|
|
||||||
print("[CONFIRM] TCP连接失败")
|
|
||||||
return False
|
|
||||||
|
|
||||||
# 构建POST请求
|
|
||||||
json_data = '{{"face_id":"{}"}}'.format(face_id)
|
|
||||||
http_request = "POST /api/v1/faces/delete-ack HTTP/1.1\r\n"
|
|
||||||
http_request += "Host: {}:{}\r\n".format(SERVER_IP, SERVER_PORT)
|
|
||||||
http_request += "Content-Type: application/json\r\n"
|
|
||||||
http_request += "X-Api-Token: {}\r\n".format(API_TOKEN)
|
|
||||||
http_request += "Content-Length: {}\r\n".format(len(json_data))
|
|
||||||
http_request += "Connection: close\r\n\r\n"
|
|
||||||
http_request += json_data
|
|
||||||
|
|
||||||
# 发送请求
|
|
||||||
cmd = "AT+CIPSEND={}".format(len(http_request))
|
|
||||||
uart2.write(cmd + "\r\n")
|
|
||||||
utime.sleep_ms(100)
|
|
||||||
|
|
||||||
# 等待>提示符
|
|
||||||
start = utime.ticks_ms()
|
|
||||||
prompt = ""
|
|
||||||
while utime.ticks_diff(utime.ticks_ms(), start) < 2000:
|
|
||||||
if uart2.any():
|
|
||||||
prompt += uart2.read().decode('utf-8', 'ignore')
|
|
||||||
if ">" in prompt:
|
|
||||||
break
|
|
||||||
utime.sleep_ms(10)
|
|
||||||
|
|
||||||
if ">" not in prompt:
|
|
||||||
esp_send_cmd("AT+CIPCLOSE", 300)
|
|
||||||
return False
|
|
||||||
|
|
||||||
# 发送数据
|
|
||||||
uart2.write(http_request)
|
|
||||||
|
|
||||||
# 等待响应
|
|
||||||
resp = ""
|
|
||||||
start = utime.ticks_ms()
|
|
||||||
while utime.ticks_diff(utime.ticks_ms(), start) < 5000:
|
|
||||||
if uart2.any():
|
|
||||||
data = uart2.read()
|
|
||||||
if data:
|
|
||||||
resp += data.decode('utf-8', 'ignore')
|
|
||||||
if "success" in resp:
|
|
||||||
print("[CONFIRM] 服务器确认成功")
|
|
||||||
esp_send_cmd("AT+CIPCLOSE", 300)
|
|
||||||
return True
|
|
||||||
utime.sleep_ms(20)
|
|
||||||
|
|
||||||
esp_send_cmd("AT+CIPCLOSE", 300)
|
|
||||||
print("[CONFIRM] 响应: {}".format(resp[:100] if resp else "(空)"))
|
|
||||||
return "success" in resp
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
print("[CONFIRM] 错误:", e)
|
|
||||||
return False
|
|
||||||
|
|
||||||
def json_get_int(text, key, default=0):
|
def json_get_int(text, key, default=0):
|
||||||
marker = '"{}":'.format(key)
|
marker = '"{}":'.format(key)
|
||||||
@@ -1118,15 +1025,33 @@ def parse_mapping_object(obj_str):
|
|||||||
mappings[key] = value
|
mappings[key] = value
|
||||||
return mappings
|
return mappings
|
||||||
|
|
||||||
def apply_face_mappings(server_mappings):
|
def apply_face_profiles(server_mappings):
|
||||||
global face_id_mapping
|
global face_id_mapping
|
||||||
print("[SYNC_STATE] 服务器映射数量: {}".format(len(server_mappings)))
|
print("[SYNC_STATE] 服务器映射数量: {}".format(len(server_mappings)))
|
||||||
updated_count = 0
|
updated_count = 0
|
||||||
|
valid_mappings = {}
|
||||||
|
for face_id, student_id in face_id_mapping.items():
|
||||||
|
if local_face_id_exists(face_id) and student_id:
|
||||||
|
valid_mappings[face_id] = student_id
|
||||||
|
|
||||||
for face_id, student_id in server_mappings.items():
|
for face_id, student_id in server_mappings.items():
|
||||||
|
if not local_face_id_exists(face_id):
|
||||||
|
print("[SYNC_STATE] 跳过本地不存在的face_id映射: ID:{} -> {}".format(face_id, student_id))
|
||||||
|
continue
|
||||||
|
if student_id == "__PHOTO_ONLY__":
|
||||||
|
print("[SYNC_STATE] 服务端仅有照片占位,保留本地映射: ID:{}".format(face_id))
|
||||||
|
continue
|
||||||
|
if not student_id:
|
||||||
|
if face_id in valid_mappings:
|
||||||
|
del valid_mappings[face_id]
|
||||||
|
updated_count += 1
|
||||||
|
print("[SYNC_STATE] 解除本地face_id绑定: ID:{}".format(face_id))
|
||||||
|
continue
|
||||||
print("[SYNC_STATE] 映射: ID:{} -> {}".format(face_id, student_id))
|
print("[SYNC_STATE] 映射: ID:{} -> {}".format(face_id, student_id))
|
||||||
if update_face_record(face_id, student_id):
|
if update_face_record(face_id, student_id):
|
||||||
updated_count += 1
|
updated_count += 1
|
||||||
face_id_mapping = server_mappings
|
valid_mappings[face_id] = student_id
|
||||||
|
face_id_mapping = valid_mappings
|
||||||
save_local_mapping()
|
save_local_mapping()
|
||||||
print("[SYNC_STATE] 映射应用完成,更新 {} 条".format(updated_count))
|
print("[SYNC_STATE] 映射应用完成,更新 {} 条".format(updated_count))
|
||||||
|
|
||||||
@@ -1152,6 +1077,28 @@ def sync_unified_state():
|
|||||||
save_sync_version(server_version)
|
save_sync_version(server_version)
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
profiles_arr = extract_json_block(response, "face_profiles", '[', ']')
|
||||||
|
server_mappings = {}
|
||||||
|
server_face_ids = {}
|
||||||
|
pos = 0
|
||||||
|
while profiles_arr and pos < len(profiles_arr):
|
||||||
|
obj_start = profiles_arr.find('{', pos)
|
||||||
|
if obj_start == -1:
|
||||||
|
break
|
||||||
|
obj_end = profiles_arr.find('}', obj_start)
|
||||||
|
if obj_end == -1:
|
||||||
|
break
|
||||||
|
obj = profiles_arr[obj_start:obj_end + 1]
|
||||||
|
face_id = json_get_string(obj, "face_id", "")
|
||||||
|
student_id = json_get_string(obj, "student_id", "")
|
||||||
|
status = json_get_string(obj, "status", "")
|
||||||
|
if face_id:
|
||||||
|
server_face_ids[face_id] = 1
|
||||||
|
if status == "photo_only" and not student_id:
|
||||||
|
student_id = "__PHOTO_ONLY__"
|
||||||
|
server_mappings[face_id] = student_id
|
||||||
|
pos = obj_end + 1
|
||||||
|
|
||||||
tasks_arr = extract_json_block(response, "delete_tasks", '[', ']')
|
tasks_arr = extract_json_block(response, "delete_tasks", '[', ']')
|
||||||
delete_tasks = parse_delete_tasks(tasks_arr)
|
delete_tasks = parse_delete_tasks(tasks_arr)
|
||||||
pending_arr = extract_json_block(response, "pending_delete", '[', ']')
|
pending_arr = extract_json_block(response, "pending_delete", '[', ']')
|
||||||
@@ -1165,6 +1112,18 @@ def sync_unified_state():
|
|||||||
|
|
||||||
for task in delete_tasks:
|
for task in delete_tasks:
|
||||||
face_id = task["face_id"]
|
face_id = task["face_id"]
|
||||||
|
if face_id in server_face_ids:
|
||||||
|
print("[SYNC_STATE] 忽略旧删除任务,服务端仍有人脸记录: {}".format(face_id))
|
||||||
|
deleted_ids.append(face_id)
|
||||||
|
if task["task_id"]:
|
||||||
|
deleted_task_ids.append(task["task_id"])
|
||||||
|
continue
|
||||||
|
if not local_face_id_exists(face_id):
|
||||||
|
print("[SYNC_STATE] 删除任务face_id本地不存在,跳过但ACK: {}".format(face_id))
|
||||||
|
deleted_ids.append(face_id)
|
||||||
|
if task["task_id"]:
|
||||||
|
deleted_task_ids.append(task["task_id"])
|
||||||
|
continue
|
||||||
if delete_face_by_id(face_id):
|
if delete_face_by_id(face_id):
|
||||||
deleted_ids.append(face_id)
|
deleted_ids.append(face_id)
|
||||||
if task["task_id"]:
|
if task["task_id"]:
|
||||||
@@ -1184,9 +1143,7 @@ def sync_unified_state():
|
|||||||
delete_failed = True
|
delete_failed = True
|
||||||
print("[SYNC_STATE] 删除ACK失败,暂不推进本地版本,等待下次重试")
|
print("[SYNC_STATE] 删除ACK失败,暂不推进本地版本,等待下次重试")
|
||||||
|
|
||||||
mappings_obj = extract_json_block(response, "face_mappings", '{', '}')
|
apply_face_profiles(server_mappings)
|
||||||
server_mappings = parse_mapping_object(mappings_obj)
|
|
||||||
apply_face_mappings(server_mappings)
|
|
||||||
|
|
||||||
if not delete_failed:
|
if not delete_failed:
|
||||||
save_sync_version(server_version)
|
save_sync_version(server_version)
|
||||||
@@ -1301,45 +1258,205 @@ class People:
|
|||||||
peoples = []
|
peoples = []
|
||||||
record_path = "/sd/recordftr.txt"
|
record_path = "/sd/recordftr.txt"
|
||||||
|
|
||||||
def parse_feature_vector(text):
|
def hex_value(ch):
|
||||||
"""只解析浮点数组文本,避免执行SD卡中的任意代码。"""
|
if '0' <= ch <= '9':
|
||||||
allowed = "0123456789+-eE.,[] \t\r\n"
|
return ord(ch) - ord('0')
|
||||||
for ch in text:
|
if 'a' <= ch <= 'f':
|
||||||
if ch not in allowed:
|
return ord(ch) - ord('a') + 10
|
||||||
raise ValueError("invalid char in feature")
|
if 'A' <= ch <= 'F':
|
||||||
|
return ord(ch) - ord('A') + 10
|
||||||
|
return -1
|
||||||
|
|
||||||
text = text.strip()
|
def parse_bytes_literal(text):
|
||||||
if not text.startswith("[") or not text.endswith("]"):
|
"""安全解析 bytearray(b"...") / b"..." 文本,不使用 eval。"""
|
||||||
raise ValueError("feature must be list")
|
pos1 = text.find("b'")
|
||||||
|
pos2 = text.find('b"')
|
||||||
|
if pos1 < 0 and pos2 < 0:
|
||||||
|
raise ValueError("bytes literal not found")
|
||||||
|
if pos1 >= 0 and (pos2 < 0 or pos1 < pos2):
|
||||||
|
pos = pos1
|
||||||
|
quote = "'"
|
||||||
|
else:
|
||||||
|
pos = pos2
|
||||||
|
quote = '"'
|
||||||
|
|
||||||
inner = text[1:-1].strip()
|
i = pos + 2
|
||||||
|
values = []
|
||||||
|
while i < len(text):
|
||||||
|
ch = text[i]
|
||||||
|
if ch == quote:
|
||||||
|
if len(values) < 64:
|
||||||
|
raise ValueError("feature too short")
|
||||||
|
return bytearray(values)
|
||||||
|
|
||||||
|
if ch != "\\":
|
||||||
|
values.append(ord(ch))
|
||||||
|
i += 1
|
||||||
|
else:
|
||||||
|
i += 1
|
||||||
|
if i >= len(text):
|
||||||
|
raise ValueError("bad escape")
|
||||||
|
esc = text[i]
|
||||||
|
if esc == "x":
|
||||||
|
if i + 2 >= len(text):
|
||||||
|
raise ValueError("bad hex escape")
|
||||||
|
hi = hex_value(text[i + 1])
|
||||||
|
lo = hex_value(text[i + 2])
|
||||||
|
if hi < 0 or lo < 0:
|
||||||
|
raise ValueError("bad hex escape")
|
||||||
|
values.append(hi * 16 + lo)
|
||||||
|
i += 3
|
||||||
|
elif esc == "n":
|
||||||
|
values.append(10)
|
||||||
|
i += 1
|
||||||
|
elif esc == "r":
|
||||||
|
values.append(13)
|
||||||
|
i += 1
|
||||||
|
elif esc == "t":
|
||||||
|
values.append(9)
|
||||||
|
i += 1
|
||||||
|
elif esc == "b":
|
||||||
|
values.append(8)
|
||||||
|
i += 1
|
||||||
|
elif esc == "f":
|
||||||
|
values.append(12)
|
||||||
|
i += 1
|
||||||
|
elif esc == "v":
|
||||||
|
values.append(11)
|
||||||
|
i += 1
|
||||||
|
elif esc == "a":
|
||||||
|
values.append(7)
|
||||||
|
i += 1
|
||||||
|
elif esc == "\\":
|
||||||
|
values.append(92)
|
||||||
|
i += 1
|
||||||
|
elif esc == quote:
|
||||||
|
values.append(ord(quote))
|
||||||
|
i += 1
|
||||||
|
elif '0' <= esc <= '7':
|
||||||
|
val = ord(esc) - ord('0')
|
||||||
|
i += 1
|
||||||
|
count = 1
|
||||||
|
while count < 3 and i < len(text) and '0' <= text[i] <= '7':
|
||||||
|
val = val * 8 + ord(text[i]) - ord('0')
|
||||||
|
i += 1
|
||||||
|
count += 1
|
||||||
|
values.append(val & 0xff)
|
||||||
|
else:
|
||||||
|
values.append(ord(esc))
|
||||||
|
i += 1
|
||||||
|
|
||||||
|
if len(values) > 4096:
|
||||||
|
raise ValueError("feature too long")
|
||||||
|
|
||||||
|
raise ValueError("unterminated bytes literal")
|
||||||
|
|
||||||
|
def parse_number_list(text):
|
||||||
|
start = text.find("[")
|
||||||
|
end = text.rfind("]")
|
||||||
|
if start < 0 or end <= start:
|
||||||
|
raise ValueError("feature must be bytearray/list")
|
||||||
|
|
||||||
|
inner = text[start + 1:end].strip()
|
||||||
if not inner:
|
if not inner:
|
||||||
raise ValueError("empty feature")
|
raise ValueError("empty feature")
|
||||||
|
|
||||||
values = []
|
values = []
|
||||||
|
has_float = False
|
||||||
|
all_byte = True
|
||||||
for token in inner.split(","):
|
for token in inner.split(","):
|
||||||
token = token.strip()
|
token = token.strip()
|
||||||
if not token:
|
if not token:
|
||||||
raise ValueError("empty number")
|
raise ValueError("empty number")
|
||||||
values.append(float(token))
|
if "." in token or "e" in token or "E" in token:
|
||||||
if len(values) > 512:
|
value = float(token)
|
||||||
|
has_float = True
|
||||||
|
all_byte = False
|
||||||
|
else:
|
||||||
|
value = int(token)
|
||||||
|
if value < 0 or value > 255:
|
||||||
|
all_byte = False
|
||||||
|
values.append(value)
|
||||||
|
if len(values) > 4096:
|
||||||
raise ValueError("feature too long")
|
raise ValueError("feature too long")
|
||||||
|
|
||||||
if len(values) < 64:
|
if len(values) < 64:
|
||||||
raise ValueError("feature too short")
|
raise ValueError("feature too short")
|
||||||
|
if all_byte and not has_float:
|
||||||
|
return bytearray(values)
|
||||||
return values
|
return values
|
||||||
|
|
||||||
|
def parse_feature_vector(text):
|
||||||
|
text = text.strip()
|
||||||
|
if text.startswith("bytearray(") or text.startswith("b'") or text.startswith('b"'):
|
||||||
|
return parse_bytes_literal(text)
|
||||||
|
return parse_number_list(text)
|
||||||
|
|
||||||
|
HEX_CHARS = "0123456789abcdef"
|
||||||
|
|
||||||
|
def byte_to_hex(v):
|
||||||
|
return HEX_CHARS[(v >> 4) & 15] + HEX_CHARS[v & 15]
|
||||||
|
|
||||||
|
def escape_byte_values(values):
|
||||||
|
out = ""
|
||||||
|
for v in values:
|
||||||
|
v = int(v) & 0xff
|
||||||
|
if v == 92:
|
||||||
|
out += "\\\\"
|
||||||
|
elif v == 34:
|
||||||
|
out += '\\"'
|
||||||
|
elif v == 10:
|
||||||
|
out += "\\n"
|
||||||
|
elif v == 13:
|
||||||
|
out += "\\r"
|
||||||
|
elif v == 9:
|
||||||
|
out += "\\t"
|
||||||
|
elif 32 <= v <= 126:
|
||||||
|
out += chr(v)
|
||||||
|
else:
|
||||||
|
out += "\\x" + byte_to_hex(v)
|
||||||
|
return out
|
||||||
|
|
||||||
|
def feature_to_record_text(feature):
|
||||||
|
type_text = str(type(feature))
|
||||||
|
if type_text.find("bytearray") >= 0 or type_text.find("bytes") >= 0:
|
||||||
|
values = []
|
||||||
|
for v in feature:
|
||||||
|
values.append(int(v) & 0xff)
|
||||||
|
return 'bytearray(b"' + escape_byte_values(values) + '")'
|
||||||
|
return str(feature)
|
||||||
|
|
||||||
|
def write_face_record():
|
||||||
|
with open(record_path, "w") as f:
|
||||||
|
for p in peoples:
|
||||||
|
f.write(str(p.name) + '#' + feature_to_record_text(p.feature) + "\n")
|
||||||
|
|
||||||
def load_faces():
|
def load_faces():
|
||||||
global peoples; peoples = []
|
global peoples; peoples = []
|
||||||
|
total_count = 0
|
||||||
|
bad_count = 0
|
||||||
|
print("[FACE_DB] Loading {}".format(record_path))
|
||||||
try:
|
try:
|
||||||
with open(record_path, "r") as f:
|
with open(record_path, "r") as f:
|
||||||
for line in f:
|
for line in f:
|
||||||
|
line = line.strip()
|
||||||
|
if not line:
|
||||||
|
continue
|
||||||
|
total_count += 1
|
||||||
# 使用 maxsplit=1 参数,只按第一个 '#' 分割,防止特征数据中的'#'导致错误
|
# 使用 maxsplit=1 参数,只按第一个 '#' 分割,防止特征数据中的'#'导致错误
|
||||||
parts = line.strip().split('#', 1)
|
parts = line.split('#', 1)
|
||||||
if len(parts) == 2 and parts[1]:
|
if len(parts) == 2 and parts[1]:
|
||||||
try: peoples.append(People(parts[0], parse_feature_vector(parts[1])))
|
try:
|
||||||
except Exception as e_eval: print("Feature parse error on line for {}: {}".format(parts[0], e_eval))
|
peoples.append(People(parts[0], parse_feature_vector(parts[1])))
|
||||||
print("成功加载 %d 条人脸数据" % len(peoples))
|
except Exception as e_eval:
|
||||||
|
bad_count += 1
|
||||||
|
print("Feature parse error on line {} for {}: {}".format(total_count, parts[0], e_eval))
|
||||||
|
else:
|
||||||
|
bad_count += 1
|
||||||
|
print("[FACE_DB] Bad line {}: missing feature".format(total_count))
|
||||||
|
if total_count == 0:
|
||||||
|
print("[FACE_DB] recordftr.txt is empty")
|
||||||
|
print("成功加载 %d 条人脸数据 (总行:%d, 失败:%d)" % (len(peoples), total_count, bad_count))
|
||||||
except Exception as e: print("读取人脸数据库失败:", e)
|
except Exception as e: print("读取人脸数据库失败:", e)
|
||||||
|
|
||||||
def collect_used_face_ids():
|
def collect_used_face_ids():
|
||||||
@@ -1388,7 +1505,8 @@ def add_face(feature, face_img=None):
|
|||||||
peoples.pop()
|
peoples.pop()
|
||||||
return False
|
return False
|
||||||
|
|
||||||
with open(record_path, "a") as f: f.write(new_name + '#' + str(feature) + "\n")
|
with open(record_path, "a") as f:
|
||||||
|
f.write(new_name + '#' + feature_to_record_text(feature) + "\n")
|
||||||
print("添加成功:", new_name)
|
print("添加成功:", new_name)
|
||||||
|
|
||||||
return True
|
return True
|
||||||
@@ -1402,8 +1520,7 @@ def delete_face_by_index(idx):
|
|||||||
deleted_name = peoples[idx].name
|
deleted_name = peoples[idx].name
|
||||||
peoples.pop(idx)
|
peoples.pop(idx)
|
||||||
try:
|
try:
|
||||||
with open(record_path, "w") as f:
|
write_face_record()
|
||||||
for p in peoples: f.write(p.name + '#' + str(p.feature) + "\n")
|
|
||||||
if deleted_name.startswith("ID:"):
|
if deleted_name.startswith("ID:"):
|
||||||
delete_local_face_photo(deleted_name.split(":", 1)[1])
|
delete_local_face_photo(deleted_name.split(":", 1)[1])
|
||||||
else:
|
else:
|
||||||
@@ -1612,21 +1729,12 @@ while True:
|
|||||||
face_id = extract_face_id(best_face['state'])
|
face_id = extract_face_id(best_face['state'])
|
||||||
mapped_student_id = get_mapped_student_id(best_face['state'])
|
mapped_student_id = get_mapped_student_id(best_face['state'])
|
||||||
if not mapped_student_id:
|
if not mapped_student_id:
|
||||||
print("[UART SEND] Face {} has no student mapping, send UNKNOWN".format(face_id))
|
print("[UART SEND] Face {} has no student mapping, fallback to face_id".format(face_id))
|
||||||
msg_to_send = "UNKNOWN\r\n"
|
student_id = mapped_student_id if mapped_student_id else face_id
|
||||||
last_saved_id = ""
|
student_name = mapped_student_id if mapped_student_id else "FACE_{}".format(face_id)
|
||||||
if msg_to_send != last_sent_data:
|
|
||||||
uart.write(msg_to_send)
|
|
||||||
print("[UART SEND] -> {}".format(msg_to_send.strip()))
|
|
||||||
last_sent_data = msg_to_send
|
|
||||||
last_send_time = time_now
|
|
||||||
lcd.display(img)
|
|
||||||
continue
|
|
||||||
student_id = mapped_student_id
|
|
||||||
student_name = mapped_student_id
|
|
||||||
confidence = best_face['score'] / 100.0
|
confidence = best_face['score'] / 100.0
|
||||||
|
|
||||||
# 构造STM32期望的格式:ID:<学号>,NAME:<学号>,CONF:0.95
|
# 构造STM32期望的格式;未绑定学号时默认使用K210原始face_id。
|
||||||
msg_to_send = "ID:{},NAME:{},CONF:{:.2f}\r\n".format(
|
msg_to_send = "ID:{},NAME:{},CONF:{:.2f}\r\n".format(
|
||||||
student_id,
|
student_id,
|
||||||
student_name,
|
student_name,
|
||||||
|
|||||||
Reference in New Issue
Block a user