Owl整改O1-O4: 设备通道展开+AI事件IHasAlarms+PTZ预设位+IAcceptsControl

This commit is contained in:
2026-06-03 23:15:22 +08:00
parent 1ad76ae33b
commit 4eefb9ed67
2 changed files with 253 additions and 116 deletions

View File

@@ -10,37 +10,29 @@ namespace IntegrationGateway.Adapters.Owl;
/// Owl 视频监控子系统适配器。
///
/// 实现的能力接口:
/// - IHasFlatDevices设备列表NVR和通道列表
/// - IHasFlatDevicesGET /devices/channels → 设备+通道联合映射
/// - IHasStreams实时取流、录像回放、云台控制、截图
/// - IHasRecordings录像文件查询
/// - IAcceptsMetadataPush设备元数据回写如改名
/// - IHasAlarmsAI 事件映射
/// - IAcceptsControlAI 检测启停、远程控制
///
/// 限流5 QPSOwl 推荐值)
/// PTZ 限制:仅支持 continuous 方向移动 + stop不支持预设位
/// 限流5 QPS
/// </summary>
public class OwlAdapter : IHasFlatDevices, IHasStreams, IHasRecordings, IAcceptsMetadataPush
public class OwlAdapter : IHasFlatDevices, IHasStreams, IHasRecordings, IAcceptsMetadataPush, IHasAlarms, IAcceptsControl
{
private readonly HttpClient _http;
private readonly OwlAuthHelper _auth;
/// <summary>令牌桶限流器5 QPS</summary>
private readonly RateLimiter _limiter = new(5);
/// <summary>适配器编码,格式 "Owl:实例名"</summary>
public string AdapterCode { get; }
/// <summary>人类可读的适配器名称</summary>
public string DisplayName => $"Owl ({AdapterCode})";
/// <summary>适配器能力声明</summary>
public AdapterCapabilities Capabilities => new()
{
HasFlatDevices = true, HasStreams = true, HasPtz = true, HasRecordings = true, AcceptsMetadataPush = true
HasFlatDevices = true, HasStreams = true, HasPtz = true,
HasRecordings = true, AcceptsMetadataPush = true, HasAlarms = true
};
/// <summary>创建 OwlAdapter 实例</summary>
/// <param name="adapterCode">适配器编码</param>
/// <param name="http">HttpClient 实例</param>
/// <param name="baseUrl">Owl 服务地址</param>
/// <param name="username">登录用户名</param>
/// <param name="password">登录密码</param>
public OwlAdapter(string adapterCode, HttpClient http, string baseUrl, string username, string password)
{
AdapterCode = adapterCode;
@@ -48,10 +40,12 @@ public class OwlAdapter : IHasFlatDevices, IHasStreams, IHasRecordings, IAccepts
_auth = new OwlAuthHelper(http, baseUrl, username, password);
}
/// <summary>初始化适配器:获取 Owl JWT Token</summary>
public async Task InitializeAsync() => await _auth.GetTokenAsync();
/// <summary>健康检查:尝试访问 Owl /health 端点</summary>
// ═══════════════════════════════════════════
// IGatewayAdapter — 健康检查
// ═══════════════════════════════════════════
public async Task<bool> HealthCheckAsync()
{
try
@@ -64,30 +58,76 @@ public class OwlAdapter : IHasFlatDevices, IHasStreams, IHasRecordings, IAccepts
}
// ═══════════════════════════════════════════
// IHasFlatDevices 实现
// IHasFlatDevices — GET /devices/channels 设备+通道联合映射
// ═══════════════════════════════════════════
/// <summary>分页获取 NVR 设备列表</summary>
public async Task<PagedResult<StandardDevice>> GetDevicesAsync(int page, int size, string? keyword = null)
{
await _limiter.WaitAsync();
var client = await _auth.GetAuthenticatedClientAsync();
var url = $"/devices?page={page}&size={size}";
var url = $"/devices/channels?page={page}&size=1000";
if (!string.IsNullOrEmpty(keyword)) url += $"&key={Uri.EscapeDataString(keyword)}";
var json = await client.GetStringAsync(url);
var owl = JsonSerializer.Deserialize<OwlPagedResult<OwlDevice>>(json)!;
return new PagedResult<StandardDevice>
var result = JsonSerializer.Deserialize<OwlPagedResult<OwlDeviceChannel>>(json)!;
var devices = new List<StandardDevice>();
var deviceItems = result.Items.Where(x => x.Type == "DEVICE").ToList();
var channelItems = result.Items.Where(x => x.Type == "CHANNEL").ToList();
foreach (var d in deviceItems)
{
Items = owl.Items.Select(MapDevice).ToList(),
Total = owl.Total
};
var childChannels = channelItems.Where(c => c.Did == d.Id).ToList();
devices.Add(MapDevice(d, childChannels));
foreach (var ch in childChannels)
devices.Add(MapChannel(ch, d.Id));
}
return new PagedResult<StandardDevice> { Items = devices, Total = devices.Count };
}
private static StandardDevice MapDevice(OwlDeviceChannel d, List<OwlDeviceChannel> channels) => new()
{
SourceId = d.Id ?? "",
Name = d.Name ?? d.Id ?? "",
Category = "硬盘录像机",
Group = "视频设备",
IsOnline = d.IsOnline == "1",
IsParent = true,
IpAddress = d.Address,
Port = int.TryParse(d.Port, out var p) ? p : null,
Extra = new Dictionary<string, object?>
{
["manufacturer"] = d.Manufacturer,
["model"] = d.Model,
["firmware"] = d.Firmware,
["longitude"] = d.Longitude,
["latitude"] = d.Latitude,
["protocol"] = d.Protocol ?? "GB28181",
["transport"] = d.Transport,
["channelCount"] = d.ChannelCount ?? channels.Count
}
};
private static StandardDevice MapChannel(OwlDeviceChannel ch, string? parentDeviceId) => new()
{
SourceId = ch.Id ?? "",
Name = ch.Name ?? $"通道{ch.Id}",
Category = "摄像机",
Group = "视频设备",
IsOnline = ch.IsOnline?.ToLower() == "true" || ch.IsOnline == "1",
IsParent = false,
ParentSourceId = parentDeviceId,
Extra = new Dictionary<string, object?>
{
["hasPtz"] = (ch.Ptztype ?? 0) > 0 ? "1" : "0",
["app"] = ch.App,
["streamId"] = ch.StreamId
}
};
// ═══════════════════════════════════════════
// IHasStreams 实现
// IHasStreams
// ═══════════════════════════════════════════
/// <summary>获取通道实时视频流地址</summary>
public async Task<StreamUrls> GetLiveUrlAsync(string channelId)
{
await _limiter.WaitAsync();
@@ -99,39 +139,43 @@ public class OwlAdapter : IHasFlatDevices, IHasStreams, IHasRecordings, IAccepts
return MapStreamUrls(play);
}
/// <summary>获取历史录像回放地址HLS VOD 格式)</summary>
public async Task<StreamUrls> GetPlaybackUrlAsync(string channelId, DateTime start, DateTime end)
{
await _limiter.WaitAsync();
var client = await _auth.GetAuthenticatedClientAsync();
var token = await _auth.GetTokenAsync();
var startMs = new DateTimeOffset(start).ToUnixTimeMilliseconds();
var endMs = new DateTimeOffset(end).ToUnixTimeMilliseconds();
var token = await _auth.GetTokenAsync();
var baseUrl = (client.BaseAddress?.ToString() ?? "").TrimEnd('/');
return new StreamUrls
{
Hls = $"{client.BaseAddress}recordings/channels/{channelId}/index.m3u8?start_ms={startMs}&end_ms={endMs}&token={token}"
Hls = $"{baseUrl}/recordings/channels/{channelId}/index.m3u8?start_ms={startMs}&end_ms={endMs}&token={token}"
};
}
/// <summary>云台方向控制continuous 模式,仅方向移动)</summary>
public async Task PtzControlAsync(string channelId, string direction, float speed)
{
await _limiter.WaitAsync();
var client = await _auth.GetAuthenticatedClientAsync();
await client.PostAsJsonAsync($"/channels/{channelId}/ptz/control",
new { action = "continuous", direction, speed });
if (direction.StartsWith("preset_"))
{
var idx = int.Parse(direction.Replace("preset_", ""));
await client.PostAsJsonAsync($"/channels/{channelId}/ptz/control", new { action = "preset", preset = idx });
}
else
{
await client.PostAsJsonAsync($"/channels/{channelId}/ptz/control",
new { action = "continuous", direction, speed });
}
}
/// <summary>云台停止</summary>
public async Task PtzStopAsync(string channelId)
{
await _limiter.WaitAsync();
var client = await _auth.GetAuthenticatedClientAsync();
await client.PostAsJsonAsync($"/channels/{channelId}/ptz/control",
new { action = "stop" });
await client.PostAsJsonAsync($"/channels/{channelId}/ptz/control", new { action = "stop" });
}
/// <summary>获取通道实时截图</summary>
public async Task<StreamUrls> GetSnapshotAsync(string channelId)
{
await _limiter.WaitAsync();
@@ -144,10 +188,9 @@ public class OwlAdapter : IHasFlatDevices, IHasStreams, IHasRecordings, IAccepts
}
// ═══════════════════════════════════════════
// IHasRecordings 实现
// IHasRecordings
// ═══════════════════════════════════════════
/// <summary>分页查询录像文件记录</summary>
public async Task<PagedResult<StandardRecording>> GetRecordingsAsync(
string channelId, DateTime start, DateTime end, int page, int size)
{
@@ -171,10 +214,9 @@ public class OwlAdapter : IHasFlatDevices, IHasStreams, IHasRecordings, IAccepts
}
// ═══════════════════════════════════════════
// IAcceptsMetadataPush 实现
// IAcceptsMetadataPush
// ═══════════════════════════════════════════
/// <summary>回写设备元数据(如改名)到 Owl</summary>
public async Task<MetadataPushResult> PushMetadataAsync(string sourceDeviceId, MetadataChangeSet changes)
{
var client = await _auth.GetAuthenticatedClientAsync();
@@ -185,29 +227,75 @@ public class OwlAdapter : IHasFlatDevices, IHasStreams, IHasRecordings, IAccepts
}
// ═══════════════════════════════════════════
// 内部映射方法
// IHasAlarms — AI 事件
// ═══════════════════════════════════════════
/// <summary>Owl 设备 → StandardDevice 映射</summary>
private static StandardDevice MapDevice(OwlDevice d) => new()
public async Task<PagedResult<StandardAlarm>> GetAlarmsAsync(
int page, int size, DateTime from, DateTime to, string? level = null, string? state = null)
{
SourceId = d.Id ?? "",
Name = d.Name ?? d.Id ?? "",
Category = "硬盘录像机",
Group = "视频设备",
IsOnline = d.IsOnline == "1",
IsParent = true,
IpAddress = d.Address,
Port = int.TryParse(d.Port, out var port) ? port : null,
Extra = new Dictionary<string, object?>
await _limiter.WaitAsync();
var client = await _auth.GetAuthenticatedClientAsync();
var fromMs = new DateTimeOffset(from).ToUnixTimeMilliseconds();
var toMs = new DateTimeOffset(to).ToUnixTimeMilliseconds();
var json = await client.GetStringAsync(
$"/events?page={page}&size={size}&start_ms={fromMs}&end_ms={toMs}");
var result = JsonSerializer.Deserialize<OwlPagedResult<OwlAiEvent>>(json)!;
return new PagedResult<StandardAlarm>
{
["owlDeviceId"] = d.Id,
["protocol"] = d.Protocol ?? "GB28181",
["transport"] = d.Transport
}
Items = result.Items.Select(MapEventToAlarm).ToList(),
Total = result.Total
};
}
public Task ConfirmAlarmAsync(string alarmId) => Task.CompletedTask;
public Task EndAlarmAsync(string alarmId) => Task.CompletedTask;
private StandardAlarm MapEventToAlarm(OwlAiEvent e) => new()
{
AlarmId = $"owl-ai-{e.Id}",
AdapterCode = AdapterCode,
Level = e.Label switch { "person" => "重要", "car" => "重要", _ => "普通" },
Title = $"AI检测: {e.Label} (置信度 {e.Score:P0})",
Content = e.Zones ?? "",
OccurTime = e.StartedAt.HasValue
? DateTimeOffset.FromUnixTimeMilliseconds(e.StartedAt.Value).DateTime
: DateTime.MinValue,
Status = (e.EndedAt ?? 0) > 0 ? "已结束" : "未确认"
};
/// <summary>Owl 播放响应 → StreamUrls 映射(取第一个可用流)</summary>
// ═══════════════════════════════════════════
// IAcceptsControl — AI 启停 + 区域管理
// ═══════════════════════════════════════════
public async Task<ControlResult> SendControlAsync(string sourceDeviceId, string command, Dictionary<string, object?> parameters)
{
await _limiter.WaitAsync();
var client = await _auth.GetAuthenticatedClientAsync();
try
{
switch (command)
{
case "ai-enable":
await client.PostAsync($"/channels/{sourceDeviceId}/ai/enable", null);
break;
case "ai-disable":
await client.PostAsync($"/channels/{sourceDeviceId}/ai/disable", null);
break;
case "zone-add":
await client.PostAsJsonAsync($"/channels/{sourceDeviceId}/zones", parameters!);
break;
default:
return new ControlResult { Success = false, Message = $"不支持的指令: {command}" };
}
return new ControlResult { Success = true };
}
catch (Exception ex) { return new ControlResult { Success = false, Message = ex.Message }; }
}
// ═══════════════════════════════════════════
// 内部工具
// ═══════════════════════════════════════════
private static StreamUrls MapStreamUrls(OwlPlayResponse play)
{
var item = play.Items?.FirstOrDefault();
@@ -218,61 +306,3 @@ public class OwlAdapter : IHasFlatDevices, IHasStreams, IHasRecordings, IAccepts
};
}
}
// ═══════════════════════════════════════════
// Owl JSON 反序列化模型(内部使用)
// ═══════════════════════════════════════════
/// <summary>Owl API 分页响应</summary>
public class OwlPagedResult<T>
{
public List<T> Items { get; set; } = new();
public int Total { get; set; }
}
/// <summary>Owl 设备NVR</summary>
public class OwlDevice
{
public string? Id { get; set; }
public string? Name { get; set; }
public string? IsOnline { get; set; }
public string? Protocol { get; set; }
public string? Address { get; set; }
public string? Port { get; set; }
public string? Transport { get; set; }
}
/// <summary>Owl 播放响应</summary>
public class OwlPlayResponse
{
public List<OwlPlayItem>? Items { get; set; }
}
/// <summary>Owl 播放流条目</summary>
public class OwlPlayItem
{
public string? WsFlv { get; set; }
public string? HttpFlv { get; set; }
public string? Hls { get; set; }
public string? WebRtc { get; set; }
public string? Rtmp { get; set; }
public string? Rtsp { get; set; }
}
/// <summary>Owl 截图响应</summary>
public class OwlSnapshotResponse
{
public string? Link { get; set; }
}
/// <summary>Owl 录像记录</summary>
public class OwlRecording
{
public int Id { get; set; }
public string? Cid { get; set; }
public DateTime StartedAt { get; set; }
public DateTime EndedAt { get; set; }
public double Duration { get; set; }
public string? Path { get; set; }
public long Size { get; set; }
}

View File

@@ -0,0 +1,107 @@
/// <summary>
/// Owl/GoWVP API 响应模型。
/// 从 OwlAdapter.cs 分离,便于维护和扩展。
/// </summary>
namespace IntegrationGateway.Adapters.Owl;
// ═══════════════════════════════════════════
// 通用
// ═══════════════════════════════════════════
/// <summary>Owl API 分页响应</summary>
public class OwlPagedResult<T>
{
public List<T> Items { get; set; } = new();
public int Total { get; set; }
}
// ═══════════════════════════════════════════
// 设备+通道联合模型 (GET /devices/channels)
// ═══════════════════════════════════════════
/// <summary>Owl 设备或通道(联合接口返回)</summary>
public class OwlDeviceChannel
{
public string? Id { get; set; }
public string? Type { get; set; } // "DEVICE" | "CHANNEL"
public string? Did { get; set; } // 通道所属设备 ID
public string? Name { get; set; }
public string? IsOnline { get; set; } // DEVICE: "1"/"0", CHANNEL: true/false
public string? Manufacturer { get; set; }
public string? Model { get; set; }
public string? Firmware { get; set; }
public string? Longitude { get; set; }
public string? Latitude { get; set; }
public int? ChannelCount { get; set; }
public int? Ptztype { get; set; } // 0=无云台, 1=方向, 2=预置位
public string? Protocol { get; set; }
public string? Address { get; set; }
public string? Port { get; set; }
public string? Transport { get; set; }
public string? App { get; set; } // 流应用名
public string? StreamId { get; set; } // 流ID
public string? Status { get; set; }
public string? RegisterWay { get; set; }
}
// ═══════════════════════════════════════════
// 播放/流
// ═══════════════════════════════════════════
/// <summary>Owl 播放响应</summary>
public class OwlPlayResponse
{
public List<OwlPlayItem>? Items { get; set; }
}
/// <summary>Owl 播放流条目</summary>
public class OwlPlayItem
{
public string? WsFlv { get; set; }
public string? HttpFlv { get; set; }
public string? Hls { get; set; }
public string? WebRtc { get; set; }
public string? Rtmp { get; set; }
public string? Rtsp { get; set; }
}
/// <summary>Owl 截图响应</summary>
public class OwlSnapshotResponse
{
public string? Link { get; set; }
}
// ═══════════════════════════════════════════
// 录像
// ═══════════════════════════════════════════
/// <summary>Owl 录像记录</summary>
public class OwlRecording
{
public int Id { get; set; }
public string? Cid { get; set; }
public DateTime StartedAt { get; set; }
public DateTime EndedAt { get; set; }
public double Duration { get; set; }
public string? Path { get; set; }
public long Size { get; set; }
}
// ═══════════════════════════════════════════
// AI 事件
// ═══════════════════════════════════════════
/// <summary>Owl AI 检测事件</summary>
public class OwlAiEvent
{
public long? Id { get; set; }
public string? Did { get; set; }
public string? Cid { get; set; }
public long? StartedAt { get; set; } // 毫秒时间戳
public long? EndedAt { get; set; }
public string? Label { get; set; } // person / car / ...
public float? Score { get; set; }
public string? Zones { get; set; }
public string? ImagePath { get; set; }
public string? Model { get; set; }
}