2026-03-27 10:19:34 +00:00
|
|
|
"""
|
|
|
|
|
Beacon Service - 蓝牙信标管理服务
|
|
|
|
|
Provides CRUD operations for Bluetooth beacon configuration.
|
|
|
|
|
"""
|
|
|
|
|
|
2026-04-01 07:06:37 +00:00
|
|
|
import asyncio
|
|
|
|
|
import logging
|
|
|
|
|
import re
|
2026-03-27 10:19:34 +00:00
|
|
|
from datetime import datetime, timezone
|
|
|
|
|
|
2026-04-01 07:06:37 +00:00
|
|
|
from sqlalchemy import delete as sa_delete, func, select, or_
|
2026-03-27 10:19:34 +00:00
|
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
|
|
2026-04-01 07:06:37 +00:00
|
|
|
from app.models import BeaconConfig, CommandLog, Device, DeviceBeaconBinding
|
2026-03-27 10:19:34 +00:00
|
|
|
from app.schemas import BeaconConfigCreate, BeaconConfigUpdate
|
2026-04-01 07:06:37 +00:00
|
|
|
from app.services import tcp_command_service
|
|
|
|
|
|
|
|
|
|
logger = logging.getLogger(__name__)
|
2026-03-27 10:19:34 +00:00
|
|
|
|
|
|
|
|
|
|
|
|
|
async def get_beacons(
|
|
|
|
|
db: AsyncSession,
|
|
|
|
|
page: int = 1,
|
|
|
|
|
page_size: int = 20,
|
|
|
|
|
status_filter: str | None = None,
|
|
|
|
|
search: str | None = None,
|
|
|
|
|
) -> tuple[list[BeaconConfig], int]:
|
|
|
|
|
"""Get paginated beacon list with optional filters."""
|
|
|
|
|
query = select(BeaconConfig)
|
|
|
|
|
count_query = select(func.count(BeaconConfig.id))
|
|
|
|
|
|
|
|
|
|
if status_filter:
|
|
|
|
|
query = query.where(BeaconConfig.status == status_filter)
|
|
|
|
|
count_query = count_query.where(BeaconConfig.status == status_filter)
|
|
|
|
|
|
|
|
|
|
if search:
|
|
|
|
|
like = f"%{search}%"
|
|
|
|
|
cond = or_(
|
|
|
|
|
BeaconConfig.beacon_mac.ilike(like),
|
|
|
|
|
BeaconConfig.name.ilike(like),
|
|
|
|
|
BeaconConfig.area.ilike(like),
|
|
|
|
|
)
|
|
|
|
|
query = query.where(cond)
|
|
|
|
|
count_query = count_query.where(cond)
|
|
|
|
|
|
|
|
|
|
total_result = await db.execute(count_query)
|
|
|
|
|
total = total_result.scalar() or 0
|
|
|
|
|
|
|
|
|
|
offset = (page - 1) * page_size
|
|
|
|
|
query = query.order_by(BeaconConfig.created_at.desc()).offset(offset).limit(page_size)
|
|
|
|
|
result = await db.execute(query)
|
|
|
|
|
return list(result.scalars().all()), total
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async def get_beacon(db: AsyncSession, beacon_id: int) -> BeaconConfig | None:
|
|
|
|
|
result = await db.execute(
|
|
|
|
|
select(BeaconConfig).where(BeaconConfig.id == beacon_id)
|
|
|
|
|
)
|
|
|
|
|
return result.scalar_one_or_none()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async def get_beacon_by_mac(db: AsyncSession, mac: str) -> BeaconConfig | None:
|
|
|
|
|
result = await db.execute(
|
|
|
|
|
select(BeaconConfig).where(BeaconConfig.beacon_mac == mac)
|
|
|
|
|
)
|
|
|
|
|
return result.scalar_one_or_none()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async def create_beacon(db: AsyncSession, data: BeaconConfigCreate) -> BeaconConfig:
|
|
|
|
|
beacon = BeaconConfig(**data.model_dump())
|
|
|
|
|
db.add(beacon)
|
|
|
|
|
await db.flush()
|
|
|
|
|
await db.refresh(beacon)
|
|
|
|
|
return beacon
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async def update_beacon(
|
|
|
|
|
db: AsyncSession, beacon_id: int, data: BeaconConfigUpdate
|
|
|
|
|
) -> BeaconConfig | None:
|
|
|
|
|
beacon = await get_beacon(db, beacon_id)
|
|
|
|
|
if beacon is None:
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
update_data = data.model_dump(exclude_unset=True)
|
|
|
|
|
for key, value in update_data.items():
|
|
|
|
|
setattr(beacon, key, value)
|
2026-03-27 13:04:11 +00:00
|
|
|
from app.config import now_cst
|
|
|
|
|
beacon.updated_at = now_cst()
|
2026-03-27 10:19:34 +00:00
|
|
|
|
|
|
|
|
await db.flush()
|
|
|
|
|
await db.refresh(beacon)
|
|
|
|
|
return beacon
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async def delete_beacon(db: AsyncSession, beacon_id: int) -> bool:
|
|
|
|
|
beacon = await get_beacon(db, beacon_id)
|
|
|
|
|
if beacon is None:
|
|
|
|
|
return False
|
|
|
|
|
await db.delete(beacon)
|
|
|
|
|
await db.flush()
|
|
|
|
|
return True
|
2026-04-01 07:06:37 +00:00
|
|
|
|
|
|
|
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
# Device-Beacon Binding
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async def get_beacon_devices(db: AsyncSession, beacon_id: int) -> list[dict]:
|
|
|
|
|
"""Get devices bound to a beacon."""
|
|
|
|
|
result = await db.execute(
|
|
|
|
|
select(
|
|
|
|
|
DeviceBeaconBinding.id.label("binding_id"),
|
|
|
|
|
DeviceBeaconBinding.device_id,
|
|
|
|
|
Device.name.label("device_name"),
|
|
|
|
|
Device.imei,
|
|
|
|
|
)
|
|
|
|
|
.join(Device, Device.id == DeviceBeaconBinding.device_id)
|
|
|
|
|
.where(DeviceBeaconBinding.beacon_id == beacon_id)
|
|
|
|
|
.order_by(Device.name)
|
|
|
|
|
)
|
|
|
|
|
return [row._asdict() for row in result.all()]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async def bind_devices_to_beacon(
|
|
|
|
|
db: AsyncSession, beacon_id: int, device_ids: list[int],
|
|
|
|
|
) -> dict:
|
|
|
|
|
"""Bind multiple devices to a beacon. Idempotent."""
|
|
|
|
|
beacon = await get_beacon(db, beacon_id)
|
|
|
|
|
if beacon is None:
|
|
|
|
|
return {"created": 0, "already_bound": 0, "not_found": len(device_ids), "error": "Beacon not found"}
|
|
|
|
|
|
|
|
|
|
result = await db.execute(
|
|
|
|
|
select(Device.id).where(Device.id.in_(device_ids))
|
|
|
|
|
)
|
|
|
|
|
existing_device_ids = set(row[0] for row in result.all())
|
|
|
|
|
|
|
|
|
|
result = await db.execute(
|
|
|
|
|
select(DeviceBeaconBinding.device_id).where(
|
|
|
|
|
DeviceBeaconBinding.beacon_id == beacon_id,
|
|
|
|
|
DeviceBeaconBinding.device_id.in_(device_ids),
|
|
|
|
|
)
|
|
|
|
|
)
|
|
|
|
|
already_bound_ids = set(row[0] for row in result.all())
|
|
|
|
|
|
|
|
|
|
created = 0
|
|
|
|
|
for did in device_ids:
|
|
|
|
|
if did not in existing_device_ids or did in already_bound_ids:
|
|
|
|
|
continue
|
|
|
|
|
db.add(DeviceBeaconBinding(device_id=did, beacon_id=beacon_id))
|
|
|
|
|
created += 1
|
|
|
|
|
|
|
|
|
|
await db.flush()
|
|
|
|
|
return {
|
|
|
|
|
"created": created,
|
|
|
|
|
"already_bound": len(already_bound_ids & existing_device_ids),
|
|
|
|
|
"not_found": len(set(device_ids) - existing_device_ids),
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async def unbind_devices_from_beacon(
|
|
|
|
|
db: AsyncSession, beacon_id: int, device_ids: list[int],
|
|
|
|
|
) -> int:
|
|
|
|
|
"""Unbind devices from a beacon."""
|
|
|
|
|
result = await db.execute(
|
|
|
|
|
sa_delete(DeviceBeaconBinding).where(
|
|
|
|
|
DeviceBeaconBinding.beacon_id == beacon_id,
|
|
|
|
|
DeviceBeaconBinding.device_id.in_(device_ids),
|
|
|
|
|
)
|
|
|
|
|
)
|
|
|
|
|
await db.flush()
|
|
|
|
|
return result.rowcount
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async def sync_device_beacons(db: AsyncSession, device_id: int) -> dict:
|
|
|
|
|
"""Query all beacons bound to a device and send BTMACSET commands via TCP.
|
|
|
|
|
|
|
|
|
|
BTMACSET supports up to 10 MACs per slot, 5 slots total (default + 1-4).
|
|
|
|
|
Returns {"sent": bool, "mac_count": int, "commands": [...], "error": str|None}.
|
|
|
|
|
"""
|
|
|
|
|
# Get device IMEI
|
|
|
|
|
result = await db.execute(select(Device).where(Device.id == device_id))
|
|
|
|
|
device = result.scalar_one_or_none()
|
|
|
|
|
if device is None:
|
|
|
|
|
return {"sent": False, "mac_count": 0, "commands": [], "error": "设备不存在"}
|
|
|
|
|
|
|
|
|
|
# Get all beacons bound to this device
|
|
|
|
|
result = await db.execute(
|
|
|
|
|
select(BeaconConfig.beacon_mac)
|
|
|
|
|
.join(DeviceBeaconBinding, DeviceBeaconBinding.beacon_id == BeaconConfig.id)
|
|
|
|
|
.where(DeviceBeaconBinding.device_id == device_id)
|
|
|
|
|
.order_by(BeaconConfig.id)
|
|
|
|
|
)
|
|
|
|
|
macs = [row[0] for row in result.all()]
|
|
|
|
|
|
|
|
|
|
if not tcp_command_service.is_device_online(device.imei):
|
|
|
|
|
return {"sent": False, "mac_count": len(macs), "commands": [], "error": "设备离线,无法发送指令"}
|
|
|
|
|
|
|
|
|
|
# Build BTMACSET commands: up to 10 MACs per slot
|
|
|
|
|
# Slot names: BTMACSET (default), BTMACSET1, BTMACSET2, BTMACSET3, BTMACSET4
|
|
|
|
|
slot_names = ["BTMACSET", "BTMACSET1", "BTMACSET2", "BTMACSET3", "BTMACSET4"]
|
|
|
|
|
commands_sent = []
|
|
|
|
|
|
|
|
|
|
if not macs:
|
|
|
|
|
# Clear default slot
|
|
|
|
|
cmd = "BTMACSET,#"
|
|
|
|
|
await tcp_command_service.send_command(device.imei, "online_cmd", cmd)
|
|
|
|
|
commands_sent.append(cmd)
|
|
|
|
|
else:
|
|
|
|
|
for i in range(0, min(len(macs), 50), 10):
|
|
|
|
|
slot_idx = i // 10
|
|
|
|
|
chunk = macs[i:i + 10]
|
|
|
|
|
cmd = f"{slot_names[slot_idx]},{','.join(chunk)}#"
|
|
|
|
|
await tcp_command_service.send_command(device.imei, "online_cmd", cmd)
|
|
|
|
|
commands_sent.append(cmd)
|
|
|
|
|
|
|
|
|
|
return {"sent": True, "mac_count": len(macs), "commands": commands_sent, "error": None}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
# Reverse sync: query devices → update DB bindings
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
_MAC_PATTERN = re.compile(r"([0-9A-Fa-f]{2}(?::[0-9A-Fa-f]{2}){5})")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _parse_btmacset_response(text: str) -> list[str]:
|
|
|
|
|
"""Extract MAC addresses from BTMACSET query response.
|
|
|
|
|
|
|
|
|
|
Example responses:
|
|
|
|
|
'setting OK.bt mac address:1,C3:00:00:34:43:5E;'
|
|
|
|
|
'bt mac address:1,C3:00:00:34:43:5E,AA:BB:CC:DD:EE:FF;'
|
|
|
|
|
"""
|
|
|
|
|
return [m.upper() for m in _MAC_PATTERN.findall(text)]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async def reverse_sync_from_devices(db: AsyncSession) -> dict:
|
|
|
|
|
"""Send BTMACSET# query to all online devices, parse responses, update bindings.
|
|
|
|
|
|
|
|
|
|
Uses separate DB sessions for command creation and polling to avoid
|
|
|
|
|
transaction isolation issues with the TCP handler's independent session.
|
|
|
|
|
"""
|
|
|
|
|
from app.database import async_session as make_session
|
|
|
|
|
from app.services import command_service
|
|
|
|
|
from app.config import now_cst
|
|
|
|
|
|
|
|
|
|
# Get all online devices
|
|
|
|
|
result = await db.execute(
|
|
|
|
|
select(Device).where(Device.status == "online")
|
|
|
|
|
)
|
|
|
|
|
devices = list(result.scalars().all())
|
|
|
|
|
|
|
|
|
|
if not devices:
|
|
|
|
|
return {"queried": 0, "responded": 0, "updated": 0, "details": [], "error": "没有在线设备"}
|
|
|
|
|
|
|
|
|
|
# Build beacon MAC → id lookup
|
|
|
|
|
result = await db.execute(select(BeaconConfig.id, BeaconConfig.beacon_mac))
|
|
|
|
|
mac_to_beacon_id = {row[1].upper(): row[0] for row in result.all()}
|
|
|
|
|
|
|
|
|
|
# --- Phase 1: Create CommandLogs and send commands (committed session) ---
|
|
|
|
|
sent_devices: list[tuple[int, str, str | None, int]] = [] # (dev_id, imei, name, cmd_log_id)
|
|
|
|
|
|
|
|
|
|
async with make_session() as cmd_session:
|
|
|
|
|
async with cmd_session.begin():
|
|
|
|
|
for dev in devices:
|
|
|
|
|
if not tcp_command_service.is_device_online(dev.imei):
|
|
|
|
|
continue
|
|
|
|
|
cmd_log = await command_service.create_command(
|
|
|
|
|
cmd_session, device_id=dev.id,
|
|
|
|
|
command_type="online_cmd", command_content="BTMACSET#",
|
|
|
|
|
)
|
|
|
|
|
try:
|
|
|
|
|
ok = await tcp_command_service.send_command(dev.imei, "online_cmd", "BTMACSET#")
|
|
|
|
|
if ok:
|
|
|
|
|
cmd_log.status = "sent"
|
|
|
|
|
cmd_log.sent_at = now_cst()
|
|
|
|
|
sent_devices.append((dev.id, dev.imei, dev.name, cmd_log.id))
|
|
|
|
|
else:
|
|
|
|
|
cmd_log.status = "failed"
|
|
|
|
|
except Exception:
|
|
|
|
|
cmd_log.status = "failed"
|
|
|
|
|
# Transaction committed here — TCP handler can now see these CommandLogs
|
|
|
|
|
|
|
|
|
|
if not sent_devices:
|
|
|
|
|
return {"queried": 0, "responded": 0, "updated": 0, "details": [], "error": "无法发送指令到任何设备"}
|
|
|
|
|
|
|
|
|
|
# --- Phase 2: Poll for responses (fresh session each iteration) ---
|
|
|
|
|
responded: dict[int, str] = {}
|
|
|
|
|
for attempt in range(10):
|
|
|
|
|
await asyncio.sleep(1)
|
|
|
|
|
pending_ids = [cid for _, _, _, cid in sent_devices if _ not in responded]
|
|
|
|
|
# Rebuild pending from device IDs not yet responded
|
|
|
|
|
pending_cmd_ids = [cid for did, _, _, cid in sent_devices if did not in responded]
|
|
|
|
|
if not pending_cmd_ids:
|
|
|
|
|
break
|
|
|
|
|
async with make_session() as poll_session:
|
|
|
|
|
result = await poll_session.execute(
|
|
|
|
|
select(CommandLog.device_id, CommandLog.response_content).where(
|
|
|
|
|
CommandLog.id.in_(pending_cmd_ids),
|
|
|
|
|
CommandLog.status == "success",
|
|
|
|
|
)
|
|
|
|
|
)
|
|
|
|
|
for row in result.all():
|
|
|
|
|
responded[row[0]] = row[1] or ""
|
|
|
|
|
|
|
|
|
|
# --- Phase 3: Parse responses and update bindings ---
|
|
|
|
|
details = []
|
|
|
|
|
updated_count = 0
|
|
|
|
|
for dev_id, imei, name, cmd_id in sent_devices:
|
|
|
|
|
resp_text = responded.get(dev_id)
|
|
|
|
|
if resp_text is None:
|
|
|
|
|
details.append({"device_id": dev_id, "imei": imei, "name": name, "status": "无响应"})
|
|
|
|
|
continue
|
|
|
|
|
|
|
|
|
|
found_macs = _parse_btmacset_response(resp_text)
|
|
|
|
|
matched_beacon_ids = set()
|
|
|
|
|
for mac in found_macs:
|
|
|
|
|
bid = mac_to_beacon_id.get(mac)
|
|
|
|
|
if bid:
|
|
|
|
|
matched_beacon_ids.add(bid)
|
|
|
|
|
|
|
|
|
|
# Get current bindings for this device
|
|
|
|
|
result = await db.execute(
|
|
|
|
|
select(DeviceBeaconBinding.beacon_id).where(
|
|
|
|
|
DeviceBeaconBinding.device_id == dev_id
|
|
|
|
|
)
|
|
|
|
|
)
|
|
|
|
|
current_bindings = set(row[0] for row in result.all())
|
|
|
|
|
|
|
|
|
|
to_add = matched_beacon_ids - current_bindings
|
|
|
|
|
for bid in to_add:
|
|
|
|
|
db.add(DeviceBeaconBinding(device_id=dev_id, beacon_id=bid))
|
|
|
|
|
|
|
|
|
|
to_remove = current_bindings - matched_beacon_ids
|
|
|
|
|
if to_remove:
|
|
|
|
|
await db.execute(
|
|
|
|
|
sa_delete(DeviceBeaconBinding).where(
|
|
|
|
|
DeviceBeaconBinding.device_id == dev_id,
|
|
|
|
|
DeviceBeaconBinding.beacon_id.in_(to_remove),
|
|
|
|
|
)
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
changes = len(to_add) + len(to_remove)
|
|
|
|
|
updated_count += 1 if changes else 0
|
|
|
|
|
details.append({
|
|
|
|
|
"device_id": dev_id, "imei": imei, "name": name,
|
|
|
|
|
"status": "已同步",
|
|
|
|
|
"device_macs": found_macs,
|
|
|
|
|
"matched_beacons": len(matched_beacon_ids),
|
|
|
|
|
"added": len(to_add), "removed": len(to_remove),
|
|
|
|
|
"response": resp_text,
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
await db.flush()
|
|
|
|
|
return {
|
|
|
|
|
"queried": len(sent_devices),
|
|
|
|
|
"responded": len(responded),
|
|
|
|
|
"updated": updated_count,
|
|
|
|
|
"details": details,
|
|
|
|
|
"error": None,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
# Setup Bluetooth clock-in mode for devices
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
# Full config sequence per P241 docs:
|
|
|
|
|
# CLOCKWAY,3# → manual + Bluetooth clock
|
|
|
|
|
# MODE,2# → Bluetooth positioning mode
|
|
|
|
|
# BTMACSET,...# → write bound beacon MACs
|
|
|
|
|
# BTMP3SW,1# → enable voice broadcast
|
|
|
|
|
|
|
|
|
|
_BT_SETUP_STEPS = [
|
|
|
|
|
("CLOCKWAY,3#", "设置打卡方式: 手动+蓝牙"),
|
|
|
|
|
# MODE,2# inserted dynamically
|
|
|
|
|
# BTMACSET,...# inserted dynamically
|
|
|
|
|
("BTMP3SW,1#", "开启语音播报"),
|
|
|
|
|
]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async def setup_bluetooth_mode(
|
|
|
|
|
db: AsyncSession,
|
|
|
|
|
device_ids: list[int] | None = None,
|
|
|
|
|
) -> dict:
|
|
|
|
|
"""Configure devices for Bluetooth beacon clock-in mode.
|
|
|
|
|
|
|
|
|
|
Sends the full command sequence to each device:
|
|
|
|
|
1. CLOCKWAY,3# (manual + BT clock)
|
|
|
|
|
2. MODE,2# (BT positioning)
|
|
|
|
|
3. BTMACSET,... (bound beacon MACs)
|
|
|
|
|
4. BTMP3SW,1# (voice broadcast on)
|
|
|
|
|
|
|
|
|
|
If device_ids is None, targets all online devices.
|
|
|
|
|
"""
|
|
|
|
|
if device_ids:
|
|
|
|
|
result = await db.execute(
|
|
|
|
|
select(Device).where(Device.id.in_(device_ids))
|
|
|
|
|
)
|
|
|
|
|
else:
|
|
|
|
|
result = await db.execute(
|
|
|
|
|
select(Device).where(Device.status == "online")
|
|
|
|
|
)
|
|
|
|
|
devices = list(result.scalars().all())
|
|
|
|
|
|
|
|
|
|
if not devices:
|
|
|
|
|
return {"total": 0, "sent": 0, "failed": 0, "details": [], "error": "没有可操作的设备"}
|
|
|
|
|
|
|
|
|
|
# Pre-load all beacon bindings: device_id → [mac1, mac2, ...]
|
|
|
|
|
all_device_ids = [d.id for d in devices]
|
|
|
|
|
result = await db.execute(
|
|
|
|
|
select(DeviceBeaconBinding.device_id, BeaconConfig.beacon_mac)
|
|
|
|
|
.join(BeaconConfig, BeaconConfig.id == DeviceBeaconBinding.beacon_id)
|
|
|
|
|
.where(DeviceBeaconBinding.device_id.in_(all_device_ids))
|
|
|
|
|
.order_by(DeviceBeaconBinding.device_id, BeaconConfig.id)
|
|
|
|
|
)
|
|
|
|
|
device_macs: dict[int, list[str]] = {}
|
|
|
|
|
for row in result.all():
|
|
|
|
|
device_macs.setdefault(row[0], []).append(row[1])
|
|
|
|
|
|
|
|
|
|
details = []
|
|
|
|
|
sent_count = 0
|
|
|
|
|
fail_count = 0
|
|
|
|
|
|
|
|
|
|
for dev in devices:
|
|
|
|
|
if not tcp_command_service.is_device_online(dev.imei):
|
|
|
|
|
details.append({
|
|
|
|
|
"device_id": dev.id, "imei": dev.imei, "name": dev.name,
|
|
|
|
|
"status": "离线", "commands": [],
|
|
|
|
|
})
|
|
|
|
|
fail_count += 1
|
|
|
|
|
continue
|
|
|
|
|
|
|
|
|
|
macs = device_macs.get(dev.id, [])
|
|
|
|
|
# Build command sequence
|
|
|
|
|
commands = [
|
|
|
|
|
"CLOCKWAY,3#",
|
|
|
|
|
"MODE,2#",
|
|
|
|
|
]
|
|
|
|
|
# BTMACSET: split into slots of 10
|
|
|
|
|
slot_names = ["BTMACSET", "BTMACSET1", "BTMACSET2", "BTMACSET3", "BTMACSET4"]
|
|
|
|
|
if macs:
|
|
|
|
|
for i in range(0, min(len(macs), 50), 10):
|
|
|
|
|
slot_idx = i // 10
|
|
|
|
|
chunk = macs[i:i + 10]
|
|
|
|
|
commands.append(f"{slot_names[slot_idx]},{','.join(chunk)}#")
|
|
|
|
|
commands.append("BTMP3SW,1#")
|
|
|
|
|
|
|
|
|
|
# Send commands sequentially with small delay
|
|
|
|
|
sent_cmds = []
|
|
|
|
|
has_error = False
|
|
|
|
|
for cmd in commands:
|
|
|
|
|
try:
|
|
|
|
|
ok = await tcp_command_service.send_command(dev.imei, "online_cmd", cmd)
|
|
|
|
|
sent_cmds.append({"cmd": cmd, "ok": ok})
|
|
|
|
|
if not ok:
|
|
|
|
|
has_error = True
|
|
|
|
|
# Small delay between commands to avoid overwhelming device
|
|
|
|
|
await asyncio.sleep(0.3)
|
|
|
|
|
except Exception as e:
|
|
|
|
|
sent_cmds.append({"cmd": cmd, "ok": False, "error": str(e)})
|
|
|
|
|
has_error = True
|
|
|
|
|
|
|
|
|
|
if has_error:
|
|
|
|
|
fail_count += 1
|
|
|
|
|
else:
|
|
|
|
|
sent_count += 1
|
|
|
|
|
|
|
|
|
|
details.append({
|
|
|
|
|
"device_id": dev.id, "imei": dev.imei, "name": dev.name,
|
|
|
|
|
"status": "部分失败" if has_error else "已配置",
|
|
|
|
|
"beacon_count": len(macs),
|
|
|
|
|
"commands": sent_cmds,
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
"total": len(devices),
|
|
|
|
|
"sent": sent_count,
|
|
|
|
|
"failed": fail_count,
|
|
|
|
|
"details": details,
|
|
|
|
|
"error": None,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async def restore_normal_mode(
|
|
|
|
|
db: AsyncSession,
|
|
|
|
|
device_ids: list[int] | None = None,
|
|
|
|
|
) -> dict:
|
|
|
|
|
"""Restore devices from Bluetooth clock-in mode to normal (smart) mode.
|
|
|
|
|
|
|
|
|
|
Sends:
|
|
|
|
|
1. CLOCKWAY,1# (manual clock only)
|
|
|
|
|
2. MODE,3# (smart positioning)
|
|
|
|
|
3. BTMP3SW,0# (voice broadcast off)
|
|
|
|
|
"""
|
|
|
|
|
if device_ids:
|
|
|
|
|
result = await db.execute(
|
|
|
|
|
select(Device).where(Device.id.in_(device_ids))
|
|
|
|
|
)
|
|
|
|
|
else:
|
|
|
|
|
result = await db.execute(
|
|
|
|
|
select(Device).where(Device.status == "online")
|
|
|
|
|
)
|
|
|
|
|
devices = list(result.scalars().all())
|
|
|
|
|
|
|
|
|
|
if not devices:
|
|
|
|
|
return {"total": 0, "sent": 0, "failed": 0, "details": [], "error": "没有可操作的设备"}
|
|
|
|
|
|
|
|
|
|
commands = ["CLOCKWAY,1#", "MODE,3#", "BTMP3SW,0#"]
|
|
|
|
|
details = []
|
|
|
|
|
sent_count = 0
|
|
|
|
|
fail_count = 0
|
|
|
|
|
|
|
|
|
|
for dev in devices:
|
|
|
|
|
if not tcp_command_service.is_device_online(dev.imei):
|
|
|
|
|
details.append({
|
|
|
|
|
"device_id": dev.id, "imei": dev.imei, "name": dev.name,
|
|
|
|
|
"status": "离线", "commands": [],
|
|
|
|
|
})
|
|
|
|
|
fail_count += 1
|
|
|
|
|
continue
|
|
|
|
|
|
|
|
|
|
sent_cmds = []
|
|
|
|
|
has_error = False
|
|
|
|
|
for cmd in commands:
|
|
|
|
|
try:
|
|
|
|
|
ok = await tcp_command_service.send_command(dev.imei, "online_cmd", cmd)
|
|
|
|
|
sent_cmds.append({"cmd": cmd, "ok": ok})
|
|
|
|
|
if not ok:
|
|
|
|
|
has_error = True
|
|
|
|
|
await asyncio.sleep(0.3)
|
|
|
|
|
except Exception as e:
|
|
|
|
|
sent_cmds.append({"cmd": cmd, "ok": False, "error": str(e)})
|
|
|
|
|
has_error = True
|
|
|
|
|
|
|
|
|
|
if has_error:
|
|
|
|
|
fail_count += 1
|
|
|
|
|
else:
|
|
|
|
|
sent_count += 1
|
|
|
|
|
|
|
|
|
|
details.append({
|
|
|
|
|
"device_id": dev.id, "imei": dev.imei, "name": dev.name,
|
|
|
|
|
"status": "部分失败" if has_error else "已恢复",
|
|
|
|
|
"commands": sent_cmds,
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
"total": len(devices),
|
|
|
|
|
"sent": sent_count,
|
|
|
|
|
"failed": fail_count,
|
|
|
|
|
"details": details,
|
|
|
|
|
"error": None,
|
|
|
|
|
}
|