diff --git a/gateway/src/IntegrationGateway.Adapters.Owl/OwlAdapter.cs b/gateway/src/IntegrationGateway.Adapters.Owl/OwlAdapter.cs index b8e3c26..68e747e 100644 --- a/gateway/src/IntegrationGateway.Adapters.Owl/OwlAdapter.cs +++ b/gateway/src/IntegrationGateway.Adapters.Owl/OwlAdapter.cs @@ -10,37 +10,29 @@ namespace IntegrationGateway.Adapters.Owl; /// Owl 视频监控子系统适配器。 /// /// 实现的能力接口: -/// - IHasFlatDevices:设备列表(NVR)和通道列表 +/// - IHasFlatDevices:GET /devices/channels → 设备+通道联合映射 /// - IHasStreams:实时取流、录像回放、云台控制、截图 /// - IHasRecordings:录像文件查询 /// - IAcceptsMetadataPush:设备元数据回写(如改名) +/// - IHasAlarms:AI 事件映射 +/// - IAcceptsControl:AI 检测启停、远程控制 /// -/// 限流:5 QPS(Owl 推荐值) -/// PTZ 限制:仅支持 continuous 方向移动 + stop,不支持预设位 +/// 限流:5 QPS /// -public class OwlAdapter : IHasFlatDevices, IHasStreams, IHasRecordings, IAcceptsMetadataPush +public class OwlAdapter : IHasFlatDevices, IHasStreams, IHasRecordings, IAcceptsMetadataPush, IHasAlarms, IAcceptsControl { private readonly HttpClient _http; private readonly OwlAuthHelper _auth; - /// 令牌桶限流器(5 QPS) private readonly RateLimiter _limiter = new(5); - /// 适配器编码,格式 "Owl:实例名" public string AdapterCode { get; } - /// 人类可读的适配器名称 public string DisplayName => $"Owl ({AdapterCode})"; - /// 适配器能力声明 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 }; - /// 创建 OwlAdapter 实例 - /// 适配器编码 - /// HttpClient 实例 - /// Owl 服务地址 - /// 登录用户名 - /// 登录密码 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); } - /// 初始化适配器:获取 Owl JWT Token public async Task InitializeAsync() => await _auth.GetTokenAsync(); - /// 健康检查:尝试访问 Owl /health 端点 + // ═══════════════════════════════════════════ + // IGatewayAdapter — 健康检查 + // ═══════════════════════════════════════════ + public async Task HealthCheckAsync() { try @@ -64,30 +58,76 @@ public class OwlAdapter : IHasFlatDevices, IHasStreams, IHasRecordings, IAccepts } // ═══════════════════════════════════════════ - // IHasFlatDevices 实现 + // IHasFlatDevices — GET /devices/channels 设备+通道联合映射 // ═══════════════════════════════════════════ - /// 分页获取 NVR 设备列表 public async Task> 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>(json)!; - return new PagedResult + var result = JsonSerializer.Deserialize>(json)!; + + var devices = new List(); + 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 { Items = devices, Total = devices.Count }; } + private static StandardDevice MapDevice(OwlDeviceChannel d, List 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 + { + ["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 + { + ["hasPtz"] = (ch.Ptztype ?? 0) > 0 ? "1" : "0", + ["app"] = ch.App, + ["streamId"] = ch.StreamId + } + }; + // ═══════════════════════════════════════════ - // IHasStreams 实现 + // IHasStreams // ═══════════════════════════════════════════ - /// 获取通道实时视频流地址 public async Task GetLiveUrlAsync(string channelId) { await _limiter.WaitAsync(); @@ -99,39 +139,43 @@ public class OwlAdapter : IHasFlatDevices, IHasStreams, IHasRecordings, IAccepts return MapStreamUrls(play); } - /// 获取历史录像回放地址(HLS VOD 格式) public async Task 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}" }; } - /// 云台方向控制(continuous 模式,仅方向移动) 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 }); + } } - /// 云台停止 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" }); } - /// 获取通道实时截图 public async Task GetSnapshotAsync(string channelId) { await _limiter.WaitAsync(); @@ -144,10 +188,9 @@ public class OwlAdapter : IHasFlatDevices, IHasStreams, IHasRecordings, IAccepts } // ═══════════════════════════════════════════ - // IHasRecordings 实现 + // IHasRecordings // ═══════════════════════════════════════════ - /// 分页查询录像文件记录 public async Task> GetRecordingsAsync( string channelId, DateTime start, DateTime end, int page, int size) { @@ -171,10 +214,9 @@ public class OwlAdapter : IHasFlatDevices, IHasStreams, IHasRecordings, IAccepts } // ═══════════════════════════════════════════ - // IAcceptsMetadataPush 实现 + // IAcceptsMetadataPush // ═══════════════════════════════════════════ - /// 回写设备元数据(如改名)到 Owl public async Task PushMetadataAsync(string sourceDeviceId, MetadataChangeSet changes) { var client = await _auth.GetAuthenticatedClientAsync(); @@ -185,29 +227,75 @@ public class OwlAdapter : IHasFlatDevices, IHasStreams, IHasRecordings, IAccepts } // ═══════════════════════════════════════════ - // 内部映射方法 + // IHasAlarms — AI 事件 // ═══════════════════════════════════════════ - /// Owl 设备 → StandardDevice 映射 - private static StandardDevice MapDevice(OwlDevice d) => new() + public async Task> 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 + 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>(json)!; + return new PagedResult { - ["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 ? "已结束" : "未确认" }; - /// Owl 播放响应 → StreamUrls 映射(取第一个可用流) + // ═══════════════════════════════════════════ + // IAcceptsControl — AI 启停 + 区域管理 + // ═══════════════════════════════════════════ + + public async Task SendControlAsync(string sourceDeviceId, string command, Dictionary 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 反序列化模型(内部使用) -// ═══════════════════════════════════════════ - -/// Owl API 分页响应 -public class OwlPagedResult -{ - public List Items { get; set; } = new(); - public int Total { get; set; } -} - -/// Owl 设备(NVR) -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; } -} - -/// Owl 播放响应 -public class OwlPlayResponse -{ - public List? Items { get; set; } -} - -/// Owl 播放流条目 -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; } -} - -/// Owl 截图响应 -public class OwlSnapshotResponse -{ - public string? Link { get; set; } -} - -/// Owl 录像记录 -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; } -} diff --git a/gateway/src/IntegrationGateway.Adapters.Owl/OwlModels.cs b/gateway/src/IntegrationGateway.Adapters.Owl/OwlModels.cs new file mode 100644 index 0000000..c4291c9 --- /dev/null +++ b/gateway/src/IntegrationGateway.Adapters.Owl/OwlModels.cs @@ -0,0 +1,107 @@ +/// +/// Owl/GoWVP API 响应模型。 +/// 从 OwlAdapter.cs 分离,便于维护和扩展。 +/// +namespace IntegrationGateway.Adapters.Owl; + +// ═══════════════════════════════════════════ +// 通用 +// ═══════════════════════════════════════════ + +/// Owl API 分页响应 +public class OwlPagedResult +{ + public List Items { get; set; } = new(); + public int Total { get; set; } +} + +// ═══════════════════════════════════════════ +// 设备+通道联合模型 (GET /devices/channels) +// ═══════════════════════════════════════════ + +/// Owl 设备或通道(联合接口返回) +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; } +} + +// ═══════════════════════════════════════════ +// 播放/流 +// ═══════════════════════════════════════════ + +/// Owl 播放响应 +public class OwlPlayResponse +{ + public List? Items { get; set; } +} + +/// Owl 播放流条目 +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; } +} + +/// Owl 截图响应 +public class OwlSnapshotResponse +{ + public string? Link { get; set; } +} + +// ═══════════════════════════════════════════ +// 录像 +// ═══════════════════════════════════════════ + +/// Owl 录像记录 +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 事件 +// ═══════════════════════════════════════════ + +/// Owl AI 检测事件 +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; } +}