diff --git a/gateway/IntegrationGateway.slnx b/gateway/IntegrationGateway.slnx
index 160e8ab..9fea9e6 100644
--- a/gateway/IntegrationGateway.slnx
+++ b/gateway/IntegrationGateway.slnx
@@ -1,4 +1,7 @@
+
+
+
diff --git a/gateway/src/IntegrationGateway.Adapters.Owl/IntegrationGateway.Adapters.Owl.csproj b/gateway/src/IntegrationGateway.Adapters.Owl/IntegrationGateway.Adapters.Owl.csproj
new file mode 100644
index 0000000..e0c9f9c
--- /dev/null
+++ b/gateway/src/IntegrationGateway.Adapters.Owl/IntegrationGateway.Adapters.Owl.csproj
@@ -0,0 +1,13 @@
+
+
+
+
+
+
+
+ net8.0
+ enable
+ enable
+
+
+
diff --git a/gateway/src/IntegrationGateway.Adapters.Owl/OwlAdapter.cs b/gateway/src/IntegrationGateway.Adapters.Owl/OwlAdapter.cs
new file mode 100644
index 0000000..5b5e04a
--- /dev/null
+++ b/gateway/src/IntegrationGateway.Adapters.Owl/OwlAdapter.cs
@@ -0,0 +1,153 @@
+using IntegrationGateway.Core.Abstractions;
+using IntegrationGateway.Core.Infrastructure;
+using IntegrationGateway.Core.Models;
+using System.Text.Json;
+using System.Net.Http.Json;
+
+namespace IntegrationGateway.Adapters.Owl;
+
+public class OwlAdapter : IHasFlatDevices, IHasStreams, IHasRecordings, IAcceptsMetadataPush
+{
+ private readonly HttpClient _http;
+ private readonly OwlAuthHelper _auth;
+ private readonly RateLimiter _limiter = new(5);
+
+ public string AdapterCode { get; }
+ public string DisplayName => $"Owl ({AdapterCode})";
+ public AdapterCapabilities Capabilities => new()
+ {
+ HasFlatDevices = true, HasStreams = true, HasPtz = true, HasRecordings = true, AcceptsMetadataPush = true
+ };
+
+ public OwlAdapter(string adapterCode, HttpClient http, string baseUrl, string username, string password)
+ {
+ AdapterCode = adapterCode;
+ _http = http;
+ _auth = new OwlAuthHelper(http, baseUrl, username, password);
+ }
+
+ public async Task InitializeAsync() => await _auth.GetTokenAsync();
+
+ public async Task HealthCheckAsync()
+ {
+ try
+ {
+ var client = await _auth.GetAuthenticatedClientAsync();
+ var resp = await client.GetAsync("/health");
+ return resp.IsSuccessStatusCode;
+ }
+ catch { return false; }
+ }
+
+ // ─── IHasFlatDevices ───
+ 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}";
+ if (!string.IsNullOrEmpty(keyword)) url += $"&key={Uri.EscapeDataString(keyword)}";
+ var json = await client.GetStringAsync(url);
+ var owl = JsonSerializer.Deserialize>(json)!;
+ return new PagedResult
+ {
+ Items = owl.Items.Select(MapDevice).ToList(),
+ Total = owl.Total
+ };
+ }
+
+ // ─── IHasStreams ───
+ public async Task GetLiveUrlAsync(string channelId)
+ {
+ await _limiter.WaitAsync();
+ var client = await _auth.GetAuthenticatedClientAsync();
+ var resp = await client.PostAsync($"/channels/{channelId}/play", null);
+ resp.EnsureSuccessStatusCode();
+ var json = await resp.Content.ReadAsStringAsync();
+ var play = JsonSerializer.Deserialize(json)!;
+ return MapStreamUrls(play);
+ }
+
+ public async Task GetPlaybackUrlAsync(string channelId, DateTime start, DateTime end)
+ {
+ await _limiter.WaitAsync();
+ var client = await _auth.GetAuthenticatedClientAsync();
+ var startMs = new DateTimeOffset(start).ToUnixTimeMilliseconds();
+ var endMs = new DateTimeOffset(end).ToUnixTimeMilliseconds();
+ var token = await _auth.GetTokenAsync();
+ return new StreamUrls { Hls = $"{client.BaseAddress}recordings/channels/{channelId}/index.m3u8?start_ms={startMs}&end_ms={endMs}&token={token}" };
+ }
+
+ 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 });
+ }
+
+ public async Task PtzStopAsync(string channelId)
+ {
+ await _limiter.WaitAsync();
+ var client = await _auth.GetAuthenticatedClientAsync();
+ await client.PostAsJsonAsync($"/channels/{channelId}/ptz/control", new { action = "stop" });
+ }
+
+ public async Task GetSnapshotAsync(string channelId)
+ {
+ await _limiter.WaitAsync();
+ var client = await _auth.GetAuthenticatedClientAsync();
+ var resp = await client.PostAsync($"/channels/{channelId}/snapshot",
+ new StringContent("{}", System.Text.Encoding.UTF8, "application/json"));
+ var json = await resp.Content.ReadAsStringAsync();
+ var snap = JsonSerializer.Deserialize(json)!;
+ return new StreamUrls { Hls = snap.Link };
+ }
+
+ // ─── IHasRecordings ───
+ public async Task> GetRecordingsAsync(string channelId, DateTime start, DateTime end, int page, int size)
+ {
+ await _limiter.WaitAsync();
+ var client = await _auth.GetAuthenticatedClientAsync();
+ var startMs = new DateTimeOffset(start).ToUnixTimeMilliseconds();
+ var endMs = new DateTimeOffset(end).ToUnixTimeMilliseconds();
+ var json = await client.GetStringAsync($"/recordings?cid={channelId}&start_ms={startMs}&end_ms={endMs}&page={page}&size={size}");
+ var owl = JsonSerializer.Deserialize>(json)!;
+ return new PagedResult
+ {
+ Items = owl.Items.Select(r => new StandardRecording { Id = r.Id, ChannelId = r.Cid, StartedAt = r.StartedAt, EndedAt = r.EndedAt, Duration = r.Duration, FilePath = r.Path, Size = r.Size }).ToList(),
+ Total = owl.Total
+ };
+ }
+
+ // ─── IAcceptsMetadataPush ───
+ public async Task PushMetadataAsync(string sourceDeviceId, MetadataChangeSet changes)
+ {
+ var client = await _auth.GetAuthenticatedClientAsync();
+ var body = new Dictionary();
+ if (changes.Name != null) body["name"] = changes.Name;
+ await client.PutAsJsonAsync($"/devices/{sourceDeviceId}", body);
+ return new MetadataPushResult { Success = true };
+ }
+
+ // ─── Mapping ───
+ private static StandardDevice MapDevice(OwlDevice d) => 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 port) ? port : null,
+ Extra = new Dictionary { ["owlDeviceId"] = d.Id, ["protocol"] = d.Protocol ?? "GB28181", ["transport"] = d.Transport }
+ };
+
+ private static StreamUrls MapStreamUrls(OwlPlayResponse play)
+ {
+ var item = play.Items?.FirstOrDefault();
+ return new StreamUrls { WsFlv = item?.WsFlv, HttpFlv = item?.HttpFlv, Hls = item?.Hls, WebRtc = item?.WebRtc, Rtmp = item?.Rtmp, Rtsp = item?.Rtsp };
+ }
+}
+
+// ─── Owl JSON Models ───
+public class OwlPagedResult { public List Items { get; set; } = new(); public int Total { get; set; } }
+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; } }
+public class OwlPlayResponse { public List? Items { get; set; } }
+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; } }
+public class OwlSnapshotResponse { public string? Link { get; set; } }
+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/OwlAuthHelper.cs b/gateway/src/IntegrationGateway.Adapters.Owl/OwlAuthHelper.cs
new file mode 100644
index 0000000..7b7fc91
--- /dev/null
+++ b/gateway/src/IntegrationGateway.Adapters.Owl/OwlAuthHelper.cs
@@ -0,0 +1,58 @@
+using System.Security.Cryptography;
+using System.Text;
+using System.Text.Json;
+using System.Net.Http.Json;
+
+namespace IntegrationGateway.Adapters.Owl;
+
+public class OwlAuthHelper
+{
+ private readonly HttpClient _http;
+ private readonly string _baseUrl;
+ private readonly string _username;
+ private readonly string _password;
+ private string? _token;
+ private DateTime _tokenExpiry = DateTime.MinValue;
+
+ public OwlAuthHelper(HttpClient http, string baseUrl, string username, string password)
+ {
+ _http = http; _baseUrl = baseUrl.TrimEnd('/');
+ _username = username; _password = password;
+ }
+
+ public async Task GetTokenAsync()
+ {
+ if (!string.IsNullOrEmpty(_token) && DateTime.UtcNow < _tokenExpiry) return _token;
+
+ var keyResp = await _http.GetStringAsync($"{_baseUrl}/login/key");
+ var keyData = JsonSerializer.Deserialize(keyResp);
+ var publicKey = Encoding.UTF8.GetString(Convert.FromBase64String(keyData!.Key!));
+
+ using var rsa = RSA.Create();
+ rsa.ImportFromPem(publicKey);
+ var plain = JsonSerializer.Serialize(new { username = _username, password = _password });
+ var encrypted = rsa.Encrypt(Encoding.UTF8.GetBytes(plain), RSAEncryptionPadding.Pkcs1);
+ var payload = JsonSerializer.Serialize(new { data = Convert.ToBase64String(encrypted) });
+
+ var resp = await _http.PostAsync($"{_baseUrl}/login",
+ new StringContent(payload, Encoding.UTF8, "application/json"));
+ resp.EnsureSuccessStatusCode();
+ var loginResult = await resp.Content.ReadFromJsonAsync();
+ _token = loginResult!.Token;
+ _tokenExpiry = DateTime.UtcNow.AddDays(2.5);
+ return _token;
+ }
+
+ public void Invalidate() => _token = null;
+
+ public async Task GetAuthenticatedClientAsync()
+ {
+ var token = await GetTokenAsync();
+ var client = new HttpClient { BaseAddress = new Uri(_baseUrl) };
+ client.DefaultRequestHeaders.Add("Authorization", $"Bearer {token}");
+ return client;
+ }
+
+ public class LoginKeyResponse { public string? Key { get; set; } }
+ public class LoginResponse { public string Token { get; set; } = ""; public string? User { get; set; } }
+}
diff --git a/gateway/src/IntegrationGateway.Host/IntegrationGateway.Host.csproj b/gateway/src/IntegrationGateway.Host/IntegrationGateway.Host.csproj
index e928e5c..e6369c2 100644
--- a/gateway/src/IntegrationGateway.Host/IntegrationGateway.Host.csproj
+++ b/gateway/src/IntegrationGateway.Host/IntegrationGateway.Host.csproj
@@ -2,6 +2,7 @@
+