- 网关管理:GatewayEntity/Service/Controller CRUD + 测试连接;设备 GatewayId 外键关联 - JWT 认证:登录签发 Token(权限编码写入 Claims)、RequirePermission 权限过滤器、CurrentUser 上下文 - RBAC:用户/角色/权限实体与 CRUD,预定义 4 角色 + 6 权限种子(admin/admin123) - 组织架构:sys_org 固定层级树(公司/实验室/部门/班组白夜班),层级校验,用户挂 OrgId/岗位/技能标签 - 数据权限:角色 DataScope(全部/本组织及下级),用户列表按组织子树过滤 - 防锁死保护:禁止删/禁自己,保证至少一名活跃管理员,角色摘除 user:manage 前校验 - 审计日志:AuditRecorder 接入设备增删改/指令下发/登录登出/组织变更,AuditController 查询 - 设备指令下发按网关表取连接参数;设备列表支持 gatewayId/productId 筛选 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
188 lines
8.9 KiB
C#
188 lines
8.9 KiB
C#
using DeviceCommand.Base;
|
||
using Model;
|
||
using Model.Dto.Config;
|
||
using Model.Entity.Config;
|
||
using ORM;
|
||
using Service.Interface;
|
||
using Service.Interface.Config;
|
||
using SqlSugar;
|
||
using System;
|
||
using System.Net;
|
||
using System.Threading.Tasks;
|
||
|
||
namespace Service.Implement.Config
|
||
{
|
||
/// <summary>
|
||
/// 设备指令下发 服务实现
|
||
/// 目前无网关运行时/采集引擎:每次调用先落一条设备日志(始终可验证),
|
||
/// 设备在线时才实际尝试 Modbus 写(连接参数从网关表按 GatewayId 加载)。
|
||
/// 每次下发都写审计日志(操作人/角色/时间/前后值),满足实验室审计要求。
|
||
/// </summary>
|
||
public class DeviceCommandService : IDeviceCommandService
|
||
{
|
||
private readonly IAuditRecorder _audit;
|
||
|
||
public DeviceCommandService(IAuditRecorder audit)
|
||
{
|
||
_audit = audit;
|
||
}
|
||
|
||
public async Task<Result> SendCommandAsync(DeviceCommandDto dto)
|
||
{
|
||
if (dto == null || !long.TryParse(dto.Id, out var deviceId) || deviceId <= 0)
|
||
return Result.Error("设备Id无效");
|
||
if (string.IsNullOrWhiteSpace(dto.PointId) || !long.TryParse(dto.PointId, out var pointId) || pointId <= 0)
|
||
return Result.Error("物模型点Id无效");
|
||
|
||
var now = DateTime.Now;
|
||
|
||
// 载入设备与点
|
||
var device = await SqlSugarContext.DbContext.Queryable<IotDeviceEntity>()
|
||
.Where(x => x.Id == deviceId && x.IsDel == 0).FirstAsync();
|
||
if (device == null)
|
||
return Result.Error("设备不存在或已被删除");
|
||
|
||
var point = await SqlSugarContext.DbContext.Queryable<ThingModelPointEntity>()
|
||
.Where(x => x.Id == pointId && x.OwnerType == ThingOwnerTypeEnum.Device && x.OwnerId == deviceId && x.IsDel == 0).FirstAsync();
|
||
if (point == null)
|
||
return Result.Error("物模型点不存在或不属于该设备");
|
||
|
||
// 校验可写
|
||
if (point.Rw == ThingRwEnum.ReadOnly)
|
||
return Result.Error($"点【{point.Name ?? point.Code}】为只读,无法下发指令");
|
||
if (device.ProtocolType != IotDeviceProtocolEnum.ModbusTcp)
|
||
return Result.Error("当前仅支持 Modbus TCP 协议设备下发指令");
|
||
|
||
try
|
||
{
|
||
// 模拟模式 / 设备离线 → 只记录、不发网络报文
|
||
if (dto.IsSimulated || device.OnlineStatus != IotDeviceOnlineStatusEnum.Online)
|
||
{
|
||
await WriteLogAsync(device, "Warn", "指令", dto.IsSimulated
|
||
? $"【模拟】点 {point.Name ?? point.Code} 下发值 {dto.Value}(未发送网络报文)"
|
||
: $"设备离线,指令已记录但未发送(点 {point.Name ?? point.Code} 值 {dto.Value})");
|
||
return dto.IsSimulated
|
||
? Result.Success()
|
||
: Result.Error("设备离线,指令已记录但未发送");
|
||
}
|
||
|
||
// 从网关表加载连接参数
|
||
var gateway = await SqlSugarContext.DbContext.Queryable<GatewayEntity>()
|
||
.Where(x => x.Id == device.GatewayId && x.IsDel == 0).FirstAsync();
|
||
if (gateway == null)
|
||
return Result.Error($"设备关联的网关不存在(GatewayId={device.GatewayId})");
|
||
if (string.IsNullOrWhiteSpace(gateway.Host) || !gateway.Port.HasValue)
|
||
return Result.Error($"网关「{gateway.Code}」未配置 IP 地址或端口号");
|
||
|
||
var host = gateway.Host!;
|
||
var port = gateway.Port!.Value;
|
||
|
||
// 实际 Modbus 写
|
||
using var modbus = new ModbusTcp();
|
||
modbus.ConfigureDevice(host, port, 3000, 3000);
|
||
bool connected = await modbus.ConnectAsync();
|
||
if (!connected)
|
||
{
|
||
await WriteLogAsync(device, "Error", "指令",
|
||
$"网关 {gateway.Code}({host}:{port}) 连接失败,指令未发送(点 {point.Name ?? point.Code} 值 {dto.Value})");
|
||
return Result.Error($"网关 {gateway.Code} 连接失败,指令未发送");
|
||
}
|
||
|
||
string desc;
|
||
if (point.RegisterType == ThingRegisterTypeEnum.Coil)
|
||
{
|
||
bool coil = dto.Value != 0;
|
||
await modbus.WriteSingleCoilAsync(device.SlaveId, point.Address, coil);
|
||
desc = $"写线圈 {point.Name ?? point.Code} 地址 {point.Address} = {coil}";
|
||
}
|
||
else if (point.DataType == ThingDataTypeEnum.Int32 || point.DataType == ThingDataTypeEnum.Float)
|
||
{
|
||
// 32 位类型占连续 2 个寄存器,走 FC16 连写(Int32 按有符号整数编码,Float 按 IEEE754 编码)
|
||
ushort[] words = ToWords(dto.Value, point);
|
||
await modbus.WriteMultipleRegistersAsync(device.SlaveId, point.Address, words);
|
||
desc = $"写双寄存器 {point.Name ?? point.Code} 地址 {point.Address}~{point.Address + 1} = [{words[0]}, {words[1]}](工程值 {dto.Value})";
|
||
}
|
||
else
|
||
{
|
||
ushort raw = ToRaw(dto.Value, point);
|
||
await modbus.WriteSingleRegisterAsync(device.SlaveId, point.Address, raw);
|
||
desc = $"写寄存器 {point.Name ?? point.Code} 地址 {point.Address} = {raw}(工程值 {dto.Value})";
|
||
}
|
||
|
||
await SqlSugarContext.DbContext.Updateable<IotDeviceEntity>()
|
||
.SetColumns(x => new IotDeviceEntity { LastCollectTime = now, LastError = null })
|
||
.Where(x => x.Id == device.Id).ExecuteCommandAsync();
|
||
|
||
await WriteLogAsync(device, "Info", "指令", $"下发成功:{desc}");
|
||
await _audit.RecordAsync("Command", "IotDevice", device.Id, device.Id,
|
||
oldValue: null,
|
||
newValue: $"点={point.Code}, 值={dto.Value}, 模拟={dto.IsSimulated}",
|
||
description: $"设备 {device.Code} 下发指令:{desc}");
|
||
return Result.Success();
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
await WriteLogAsync(device, "Error", "指令", $"下发失败:{ex.Message}");
|
||
return Result.Error($"指令下发异常:{ex.Message}", ex);
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// 工程值 → 寄存器原始值:raw = (value - Offset) / Scale,取整并夹到寄存器范围
|
||
/// Int16 按有符号处理(补码),其余类型按 0..65535
|
||
/// </summary>
|
||
private static ushort ToRaw(double value, ThingModelPointEntity point)
|
||
{
|
||
double raw = (value - point.Offset) / point.Scale;
|
||
if (point.DataType == ThingDataTypeEnum.Int16)
|
||
{
|
||
if (raw <= short.MinValue) return unchecked((ushort)short.MinValue);
|
||
if (raw >= short.MaxValue) return (ushort)short.MaxValue;
|
||
return unchecked((ushort)(short)Math.Round(raw));
|
||
}
|
||
if (raw <= 0) return 0;
|
||
if (raw >= 65535) return 65535;
|
||
return (ushort)Math.Round(raw);
|
||
}
|
||
|
||
/// <summary>
|
||
/// 工程值 → 32 位双寄存器字数组(raw32 = (value - Offset) / Scale)
|
||
/// Int32 按有符号整数编码,Float 按 IEEE754 编码;字序按 Modbus 常规高字在前(AB CD)
|
||
/// </summary>
|
||
private static ushort[] ToWords(double value, ThingModelPointEntity point)
|
||
{
|
||
uint bits;
|
||
if (point.DataType == ThingDataTypeEnum.Float)
|
||
{
|
||
bits = BitConverter.SingleToUInt32Bits((float)((value - point.Offset) / point.Scale));
|
||
}
|
||
else
|
||
{
|
||
double raw = Math.Round((value - point.Offset) / point.Scale);
|
||
if (raw < int.MinValue) raw = int.MinValue;
|
||
if (raw > int.MaxValue) raw = int.MaxValue;
|
||
bits = unchecked((uint)(int)raw);
|
||
}
|
||
return new[] { (ushort)(bits >> 16), (ushort)(bits & 0xFFFF) };
|
||
}
|
||
|
||
/// <summary>
|
||
/// 写一条设备日志(设备独享日志,前端设备日志抽屉可见)
|
||
/// </summary>
|
||
private static async Task WriteLogAsync(IotDeviceEntity device, string level, string logType, string message)
|
||
{
|
||
var now = DateTime.Now;
|
||
await SqlSugarContext.DbContext.Insertable(new DeviceLogEntity
|
||
{
|
||
DeviceId = device.Id,
|
||
DeviceCode = device.Code,
|
||
Level = level,
|
||
LogType = logType,
|
||
Message = message,
|
||
LogTime = now,
|
||
CreateTime = now
|
||
}).ExecuteCommandAsync();
|
||
}
|
||
}
|
||
}
|