diff --git a/.github/workflows/build-onvif-video-broker-container.yml b/.github/workflows/build-onvif-video-broker-container.yml
new file mode 100644
index 0000000..088861e
--- /dev/null
+++ b/.github/workflows/build-onvif-video-broker-container.yml
@@ -0,0 +1,64 @@
+name: Build ONVIF Broker (.NET)
+
+on:
+ push:
+ branches: [ main ]
+ paths:
+ - .github/workflows/build-onvif-video-broker-container.yml
+ - build/brokers/Dockerfile.onvif-video-broker
+ - brokers/onvif-video-broker/**
+ - version.txt
+ - Makefile
+ pull_request:
+ branches: [ main ]
+ paths:
+ - .github/workflows/build-onvif-video-broker-container.yml
+ - build/brokers/Dockerfile.onvif-video-broker
+ - brokers/onvif-video-broker/**
+ - version.txt
+ - Makefile
+ release:
+ types:
+ - published
+
+env:
+ AKRI_COMPONENT: onvif-video-broker
+ MAKEFILE_COMPONENT: onvif
+
+jobs:
+ build-broker:
+ runs-on: ubuntu-latest
+ timeout-minutes: 20
+
+ # Allow GITHUB_TOKEN to push to GHCR
+ permissions:
+ contents: read
+ packages: write
+
+ steps:
+ - name: Checkout the head commit of the branch
+ uses: actions/checkout@v4
+ with:
+ persist-credentials: false
+
+ - name: Get version.txt
+ id: version-string
+ run: |
+ echo "version=$(cat version.txt)" >> $GITHUB_OUTPUT
+
+ - name: Set up QEMU
+ uses: docker/setup-qemu-action@v3
+ - name: Set up Docker Buildx
+ uses: docker/setup-buildx-action@v3
+
+ - name: Login to GitHub Container Registry
+ if: github.event_name == 'push' || github.event_name == 'release'
+ uses: docker/login-action@v3
+ with:
+ registry: ghcr.io
+ username: ${{ github.repository_owner }}
+ password: ${{ secrets.GITHUB_TOKEN }}
+
+ - name: Build and push
+ run: |
+ make onvif-video-broker PREFIX=ghcr.io/project-akri/akri $(${{ github.event_name != 'pull_request' }} && echo "PUSH=1")
diff --git a/Makefile b/Makefile
index 86e7dfb..916fa5f 100644
--- a/Makefile
+++ b/Makefile
@@ -29,6 +29,11 @@ TIMESTAMP := $(shell date +"%Y%m%d_%H%M%S")
VERSION_LABEL=v$(VERSION)-$(TIMESTAMP)
LABEL_PREFIX ?= $(VERSION_LABEL)
+USE_OPENCV_BASE_VERSION = 0.0.11
+AMD64_SUFFIX = amd64
+ARM32V7_SUFFIX = arm32v7
+ARM64V8_SUFFIX = arm64v8
+
.PHONY: all
all: apps brokers
@@ -42,7 +47,7 @@ apps: anomaly-detection-app video-streaming-app
--file build/apps/Dockerfile.python-app .
.PHONY: brokers
-brokers: udev-video-broker
+brokers: udev-video-broker onvif-video-broker
udev-video-broker:
docker buildx build $(COMMON_DOCKER_BUILD_ARGS) \
@@ -50,3 +55,29 @@ udev-video-broker:
--build-arg EXTRA_CARGO_ARGS="$(if $(BUILD_RELEASE_FLAG), --release)" \
--file build/brokers/Dockerfile.rust \
brokers/$@
+
+# Still use old-ish style for onvif-video-broker as app uses .NET 3.1 that doesn't have multi-arch manifest
+onvif-video-broker: onvif-video-broker-multiarch
+
+onvif-video-broker-multiarch: onvif-video-broker-amd64 onvif-video-broker-arm64 onvif-video-broker-arm32
+ifeq (1, $(PUSH))
+ docker buildx imagetools create --tag "$(PREFIX)/onvif-video-broker:$(LABEL_PREFIX)"
+endif
+
+ONVIF_BUILDX_PUSH_OUTPUT = type=image,name=$(PREFIX)/onvif-video-broker,push-by-digest=true,name-canonical=true,push=true
+ONVIF_BUILDX_ARGS = $(if $(LOAD), --load --tag $(PREFIX)/onvif-video-broker:$(LABEL_PREFIX)) $(if $(PUSH), --output $(ONVIF_BUILDX_PUSH_OUTPUT)) -f build/brokers/Dockerfile.onvif-video-broker
+
+onvif-video-broker-amd64:
+ifneq (,or(findstring(amd64,$(PLATFORMS)), findstring(x86_64,$(PLATFORMS))))
+ docker buildx build $(ONVIF_BUILDX_ARGS) $(if $(PUSH), --iidfile onvif-video-broker.sha-amd64) --build-arg OUTPUT_PLATFORM_TAG=$(USE_OPENCV_BASE_VERSION)-$(AMD64_SUFFIX) --build-arg DOTNET_PUBLISH_RUNTIME=linux-x64 .
+endif
+
+onvif-video-broker-arm32:
+ifneq (,findstring(arm/v7,$(PLATFORMS)))
+ docker buildx build $(ONVIF_BUILDX_ARGS) $(if $(PUSH), --iidfile onvif-video-broker.sha-arm32) --build-arg OUTPUT_PLATFORM_TAG=$(USE_OPENCV_BASE_VERSION)-$(ARM32V7_SUFFIX) --build-arg DOTNET_PUBLISH_RUNTIME=linux-arm .
+endif
+
+onvif-video-broker-arm64:
+ifneq (,or(findstring(aarch64,$(PLATFORMS)),findstring(arm64,$(PLATFORMS))))
+ docker buildx build $(ONVIF_BUILDX_ARGS) $(if $(PUSH), --iidfile onvif-video-broker.sha-arm64) --build-arg OUTPUT_PLATFORM_TAG=$(USE_OPENCV_BASE_VERSION)-$(ARM64V8_SUFFIX) --build-arg DOTNET_PUBLISH_RUNTIME=linux-arm64 .
+endif
diff --git a/brokers/onvif-video-broker/Akri.cs b/brokers/onvif-video-broker/Akri.cs
new file mode 100644
index 0000000..2be51ee
--- /dev/null
+++ b/brokers/onvif-video-broker/Akri.cs
@@ -0,0 +1,198 @@
+using System;
+using System.Collections;
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
+using System.Net;
+using System.Net.Http;
+using System.Text;
+using System.Text.RegularExpressions;
+using System.Xml;
+using System.Xml.XPath;
+
+namespace Akri
+{
+ public static class Akri
+ {
+ private static string PostSoapRequest(String requestUri, String action, String soapMessage)
+ {
+ var request = (HttpWebRequest) WebRequest.CreateDefault(new Uri(requestUri));
+ request.ContentType = "application/soap+xml; charset=utf-8";
+ request.Method = HttpMethod.Post.ToString();
+ request.Headers.Add("SOAPAction", action);
+ using (var stream = new StreamWriter(request.GetRequestStream(), Encoding.UTF8))
+ {
+ stream.Write(soapMessage);
+ }
+
+ Console.WriteLine($"[Akri] ONVIF request {requestUri} {action}");
+ using (WebResponse requestResponse = request.GetResponse())
+ {
+ using (StreamReader responseReader = new StreamReader(requestResponse.GetResponseStream()))
+ {
+ return responseReader.ReadToEnd();
+ }
+ }
+ }
+
+ private const String MEDIA_WSDL = "http://www.onvif.org/ver10/media/wsdl";
+ private const String DEVICE_WSDL = "http://www.onvif.org/ver10/device/wsdl";
+ private const String GET_SERVICE_SOAP = @"";
+ private const String GET_PROFILES_SOAP_TEMPLATE = @"
+
+ {0}
+
+
+
+
+ ";
+ private const String GET_STREAMING_URI_SOAP_TEMPLATE = @"
+
+ {0}
+
+
+
+
+ RTP-Unicast
+
+ RTSP
+
+
+ {1}
+
+
+ ";
+
+ // Regular expression pattern of environment variables that hold OPC UA DiscoveryURL
+ // The pattern is ONVIF_DEVICE_SERVICE_URL_ followed by 6 digit digest. e.g.
+ // ONVIF_DEVICE_SERVICE_URL_123456, ONVIF_DEVICE_SERVICE_URL_ABCDEF
+ private const string OnvifDeviceServiceUrlLabelPattern = "^ONVIF_DEVICE_SERVICE_URL_[A-F0-9]{6,6}$";
+ private const string OnvifDeviceUuidLabelPattern = "^ONVIF_DEVICE_UUID_[A-F0-9]{6,6}$";
+
+ private static string GetMediaUrl(String device_service_url)
+ {
+ var servicesResult = PostSoapRequest(
+ device_service_url,
+ String.Format("{0}/{1}", DEVICE_WSDL, "GetService"),
+ GET_SERVICE_SOAP
+ );
+ var document = new XPathDocument(new XmlTextReader(new StringReader(servicesResult)));
+ var navigator = document.CreateNavigator();
+ var xpath = String.Format("//*[local-name()='GetServicesResponse']/*[local-name()='Service' and *[local-name()='Namespace']/text() ='{0}']/*[local-name()='XAddr']/text()", MEDIA_WSDL);
+ var media_url = navigator.SelectSingleNode(xpath).ToString();
+ Console.WriteLine($"[Akri] ONVIF media url {media_url}");
+ return media_url;
+ }
+
+ private static string GetProfile(String media_url, UsernameToken usernameToken)
+ {
+ var soapSecurityHeader = usernameToken.ToXml();
+ var soapMessage = String.Format(GET_PROFILES_SOAP_TEMPLATE, soapSecurityHeader);
+ var servicesResult = PostSoapRequest(
+ media_url,
+ String.Format("{0}/{1}", MEDIA_WSDL, "GetProfiles"),
+ soapMessage
+ );
+ var document = new XPathDocument(new XmlTextReader(new StringReader(servicesResult)));
+ var navigator = document.CreateNavigator();
+ var xpath = String.Format("//*[local-name()='GetProfilesResponse']/*[local-name()='Profiles']/@token");
+ var profileNodesIterator = navigator.Select(xpath);
+ var profiles = (from XPathNavigator @group in profileNodesIterator select @group.Value).ToList();
+ profiles.Sort();
+ foreach (var p in profiles) {
+ Console.WriteLine($"[Akri] ONVIF profile list contains: {p}");
+ }
+ // randomly choose first profile
+ var profile = profiles.First();
+ Console.WriteLine($"[Akri] ONVIF profile list {profile}");
+ return profile;
+ }
+
+ private static string GetStreamingUri(String media_url, String profile_token, UsernameToken usernameToken)
+ {
+ var soapSecurityHeader = usernameToken.ToXml();
+ var soapMessage = String.Format(GET_STREAMING_URI_SOAP_TEMPLATE, soapSecurityHeader, profile_token);
+ var servicesResult = PostSoapRequest(
+ media_url,
+ String.Format("{0}/{1}", MEDIA_WSDL, "GetStreamUri"),
+ soapMessage
+ );
+ var document = new XPathDocument(new XmlTextReader(new StringReader(servicesResult)));
+ var navigator = document.CreateNavigator();
+ var xpath = String.Format("//*[local-name()='GetStreamUriResponse']/*[local-name()='MediaUri']/*[local-name()='Uri']/text()");
+ var profileNodesIterator = navigator.Select(xpath);
+ var streaming_uri_list = (from XPathNavigator @group in profileNodesIterator select @group.Value).ToList();
+ foreach (var u in streaming_uri_list) {
+ Console.WriteLine($"[Akri] ONVIF streaming uri list contains: {u}");
+ }
+ // randomly choose first profile
+ var streaming_uri = streaming_uri_list.First();
+ Console.WriteLine($"[Akri] ONVIF streaming uri {streaming_uri}");
+
+ const string rtspPrefix = "rtsp://";
+ if (streaming_uri.StartsWith(rtspPrefix)) {
+ if (!String.IsNullOrEmpty(usernameToken.Username)) {
+ var password = usernameToken.Password ?? "";
+ var credential_string = String.Format("{0}:{1}@", usernameToken.Username, password);
+ streaming_uri = streaming_uri.Substring(rtspPrefix.Length);
+ streaming_uri = String.Format("{0}{1}{2}", rtspPrefix, credential_string, streaming_uri);
+ }
+ }
+ return streaming_uri;
+ }
+
+ private static List GetDeviceServiceUrls()
+ {
+ var values = new List();
+ foreach (DictionaryEntry de in Environment.GetEnvironmentVariables())
+ {
+ if (Regex.IsMatch(de.Key.ToString(), OnvifDeviceServiceUrlLabelPattern))
+ {
+ values.Add(de.Value.ToString());
+ }
+ }
+ return values;
+ }
+
+ private static List GetDeviceUuids()
+ {
+ var values = new List();
+ foreach (DictionaryEntry de in Environment.GetEnvironmentVariables())
+ {
+ if (Regex.IsMatch(de.Key.ToString(), OnvifDeviceUuidLabelPattern))
+ {
+ values.Add(de.Value.ToString());
+ }
+ }
+ return values;
+ }
+
+ public static string GetRtspUrl()
+ {
+ var device_uuids = GetDeviceUuids();
+ var device_uuid = (device_uuids.Count != 0) ? device_uuids[0] : "";
+ Credential credential = null;
+ if (!string.IsNullOrEmpty(device_uuid))
+ {
+ var credentialDirectory = Environment.GetEnvironmentVariable("CREDENTIAL_DIRECTORY");
+ var credentialConfigMapDirectory = Environment.GetEnvironmentVariable("CREDENTIAL_CONFIGMAP_DIRECTORY");
+ var credentialStore = new CredentialStore(credentialDirectory, credentialConfigMapDirectory);
+ credential = credentialStore.Get(device_uuid);
+ }
+ var userNameToken = new UsernameToken(credential?.Username, credential?.Password);
+
+ // Get the first found Onvif device service url and use it
+ var device_service_urls = GetDeviceServiceUrls();
+ var device_service_url = (device_service_urls.Count != 0) ? device_service_urls[0] : "";
+ if (string.IsNullOrEmpty(device_service_url))
+ {
+ throw new ArgumentNullException("ONVIF_DEVICE_SERVICE_URL undefined");
+ }
+
+ var media_url = GetMediaUrl(device_service_url);
+ var profile = GetProfile(media_url, userNameToken);
+ var streaming_url = GetStreamingUri(media_url, profile, userNameToken);
+ return streaming_url;
+ }
+ }
+}
diff --git a/brokers/onvif-video-broker/CredentialStore.cs b/brokers/onvif-video-broker/CredentialStore.cs
new file mode 100644
index 0000000..46d83b4
--- /dev/null
+++ b/brokers/onvif-video-broker/CredentialStore.cs
@@ -0,0 +1,198 @@
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
+using System.Text.Json;
+
+namespace Akri
+{
+ class Credential {
+ public string Username { get; set; }
+ public string Password { get; set; }
+ }
+
+ class CredentialData {
+ public string Username { get; set; }
+ public string Password { get; set; }
+ public bool Base64encoded { get; set; }
+ }
+
+ class CredentialRefData {
+ public string Username_ref { get; set; }
+ public string Password_ref { get; set; }
+ }
+
+ class CredentialStore {
+ private readonly string secretDirectory;
+ private readonly string configMapDirectory;
+
+ public CredentialStore(string secretDirectory, string configMapDirectory) {
+ this.secretDirectory = secretDirectory;
+ this.configMapDirectory = configMapDirectory;
+ }
+
+ public Credential Get(string id) {
+ if (String.IsNullOrEmpty(this.secretDirectory)) {
+ return null;
+ }
+
+ Credential defaultCredential = null;
+ // get credential from secrets
+ var (credential, isDefault) = GetCredentialFromSecret(id);
+ if ((credential != null) && !isDefault) {
+ return credential;
+ }
+ defaultCredential ??= credential;
+
+ // get credential from credential ref list
+ (credential, isDefault) = GetCredentialFromCredentialRefList(id);
+ if ((credential != null) && !isDefault) {
+ return credential;
+ }
+ defaultCredential ??= credential;
+
+ // get credential from credential list
+ (credential, isDefault) = GetCredentialFromCredentialList(id);
+ if ((credential != null) && !isDefault) {
+ return credential;
+ }
+ defaultCredential ??= credential;
+
+ return defaultCredential;
+ }
+
+ private (Credential, bool) GetCredentialFromCredentialRefList(string id) {
+ var credentialRefList = GetListContent>("device_credential_ref_list");
+ if (credentialRefList == null) {
+ return (null, false);
+ }
+
+ var allRefDictionary = new Dictionary();
+ foreach (var refList in credentialRefList) {
+ var refEntries = GetListContent>(refList);
+ if (refEntries != null) {
+ refEntries.ToList().ForEach(x => allRefDictionary[x.Key] = x.Value);
+ }
+ }
+
+ var isDefault = false;
+ if (!allRefDictionary.TryGetValue(id, out CredentialRefData credentialRefData)) {
+ if (!allRefDictionary.TryGetValue("default", out credentialRefData)) {
+ return (null, false);
+ }
+ isDefault = true;
+ }
+ Credential credential = null;
+ string username = ReadStringFromFile(this.secretDirectory, credentialRefData.Username_ref);
+ if (!String.IsNullOrEmpty(username)) {
+ string password = ReadStringFromFile(this.secretDirectory, credentialRefData.Password_ref);
+ credential = new Credential() {
+ Username = username,
+ Password = password
+ };
+ }
+
+ return (credential, isDefault);
+ }
+
+ private (Credential, bool) GetCredentialFromCredentialList(string id) {
+ var credentialList = GetListContent>("device_credential_list");
+ if (credentialList == null) {
+ return (null, false);
+ }
+
+ var allCredentialDictionary = new Dictionary();
+ foreach (var refList in credentialList) {
+ var credentialRef = ReadStringFromFile(this.secretDirectory, refList);
+ if (!String.IsNullOrEmpty(credentialRef)) {
+ var result = Deserialize>(credentialRef);
+ if (result != null) {
+ result.ToList().ForEach(x => allCredentialDictionary[x.Key] = x.Value);
+ }
+ }
+ }
+
+ var isDefault = false;
+ if (!allCredentialDictionary.TryGetValue(id, out CredentialData credentialData)) {
+ if (!allCredentialDictionary.TryGetValue("default", out credentialData)) {
+ return (null, false);
+ }
+ isDefault = true;
+ }
+ var decodedPassword = credentialData.Password;
+ if (credentialData.Base64encoded) {
+ byte[] data = Convert.FromBase64String(credentialData.Password);
+ decodedPassword = System.Text.Encoding.UTF8.GetString(data);
+ }
+
+ Credential credential = new Credential() {
+ Username = credentialData.Username,
+ Password = decodedPassword
+ };
+
+ return (credential, isDefault);
+ }
+
+ private (Credential, bool) GetCredentialFromSecret(string id) {
+ // Secret uses underscore as key name, replace all dashes with underscore
+ id = id.Replace('-', '_');
+ var credential = GetCredentialFromSecretById(id);
+ if (credential != null) {
+ return (credential, false);
+ }
+ var defaultCredential = GetCredentialFromSecretById("default");
+ return (defaultCredential, true);
+ }
+
+ private Credential GetCredentialFromSecretById(string id) {
+ string usernameFilename = String.Format("username_{0}", id);
+ string username = ReadStringFromFile(this.secretDirectory, usernameFilename);
+ if (String.IsNullOrEmpty(username)) {
+ return null;
+ }
+ string passwordFilename = String.Format("password_{0}", id);
+ string password = ReadStringFromFile(this.secretDirectory, passwordFilename);
+ return new Credential() {
+ Username = username,
+ Password = password
+ };
+ }
+
+ private TValue GetListContent(string listName) {
+ string listContent = null;
+ if (!String.IsNullOrEmpty(this.configMapDirectory)) {
+ listContent = ReadStringFromFile(this.configMapDirectory, listName);
+ }
+ if (String.IsNullOrEmpty(listContent)) {
+ listContent = ReadStringFromFile(this.secretDirectory, listName);
+ }
+ if (String.IsNullOrEmpty(listContent)) {
+ return default(TValue);
+ }
+
+ return Deserialize(listContent);
+ }
+
+ private string ReadStringFromFile(string path, string filename) {
+ string fileFullPath = Path.Combine(path, filename);
+ try {
+ return File.ReadAllText(fileFullPath);
+ } catch (Exception) {
+ return null;
+ }
+ }
+
+ private TValue Deserialize(string jsonString)
+ {
+ try {
+ var options = new JsonSerializerOptions()
+ {
+ PropertyNamingPolicy = JsonNamingPolicy.CamelCase
+ };
+ return JsonSerializer.Deserialize(jsonString, options);
+ } catch (Exception) {
+ return default(TValue);
+ }
+ }
+ }
+}
diff --git a/brokers/onvif-video-broker/Program.cs b/brokers/onvif-video-broker/Program.cs
new file mode 100644
index 0000000..18cb510
--- /dev/null
+++ b/brokers/onvif-video-broker/Program.cs
@@ -0,0 +1,179 @@
+using Camera;
+using Grpc.Core;
+using Microsoft.AspNetCore.Authentication;
+using Microsoft.AspNetCore.Hosting;
+using Microsoft.Extensions.Hosting;
+using OpenCvSharp;
+using Prometheus;
+using System;
+using System.Collections;
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
+using System.Text.RegularExpressions;
+using System.Threading;
+using System.Threading.Tasks;
+
+namespace FrameServer
+{
+ public class CameraService : Camera.Camera.CameraBase
+ {
+ public override Task GetFrame(
+ NotifyRequest request, ServerCallContext context)
+ {
+ // Mask credential information in Program.RtspUrl to prevent the credential info shown in console output
+ var rtspUrlWithMaskedCredential = RtspUrlHelper.GetMaskedCredentialUrl(Program.RtspUrl);
+ byte[] frame = null;
+ lock (Program.Frames)
+ {
+ if (Program.Frames.Any())
+ {
+ frame = Program.Frames.Pop();
+ Program.JobsInQueue.Set(Program.Frames.Count);
+ }
+
+ if (frame == null)
+ {
+ Console.WriteLine("No frame available for {0}", rtspUrlWithMaskedCredential);
+ }
+ else
+ {
+ Console.WriteLine("Sending frame for {0}, Q size: {1}", rtspUrlWithMaskedCredential, Program.Frames.Count);
+ }
+ }
+
+ return Task.FromResult(new NotifyResponse
+ {
+ Camera = Program.RtspUrl,
+ Frame = (frame == null ? Google.Protobuf.ByteString.Empty : Google.Protobuf.ByteString.CopyFrom(frame))
+ });
+ }
+ }
+
+ // based on https://stackoverflow.com/questions/14101310/limit-the-size-of-a-generic-collection
+ public class LimitedSizeStack : LinkedList
+ {
+ private readonly int _maxSize;
+ public LimitedSizeStack(int maxSize)
+ {
+ _maxSize = maxSize;
+ }
+
+ public void Push(T item)
+ {
+ this.AddFirst(item);
+
+ if (this.Count > _maxSize)
+ this.RemoveLast();
+ }
+
+ public T Pop()
+ {
+ var item = this.First.Value;
+ this.RemoveFirst();
+ return item;
+ }
+ }
+
+ public static class RtspUrlHelper {
+ public static string GetMaskedCredentialUrl(string rtspUrl) {
+ const string rtspPrefix = "rtsp://";
+ var maskedRtspUrl = rtspUrl;
+ if (rtspUrl.StartsWith(rtspPrefix)) {
+ var atPos = rtspUrl.IndexOf('@', rtspPrefix.Length);
+ if (atPos != -1) {
+ maskedRtspUrl = rtspUrl.Substring(atPos);
+ maskedRtspUrl = String.Format("{0}----:----{1}", rtspPrefix, maskedRtspUrl);
+ }
+ }
+ return maskedRtspUrl;
+ }
+ }
+
+ class Program
+ {
+ public static Task FrameTask;
+ public static string RtspUrl;
+ public static LimitedSizeStack Frames;
+
+ static void Main(string[] args)
+ {
+ var frameBufferSizeSetting = Environment.GetEnvironmentVariable("FRAME_BUFFER_SIZE");
+ int frameBufferSize =
+ string.IsNullOrEmpty(frameBufferSizeSetting) ? 2 : int.Parse(frameBufferSizeSetting);
+ Frames = new LimitedSizeStack(frameBufferSize);
+ if (Frames == null) {
+ throw new ArgumentNullException("Unable to create Frames");
+ }
+
+ RtspUrl = Environment.GetEnvironmentVariable("RTSP_URL");
+ if (string.IsNullOrEmpty(RtspUrl)) {
+ RtspUrl = Akri.Akri.GetRtspUrl();
+ }
+ if (string.IsNullOrEmpty(RtspUrl))
+ {
+ throw new ArgumentNullException("Unable to find RTSP URL");
+ }
+
+ CamerasCounter.Inc();
+
+ FrameTask = Task.Run(() => Process(RtspUrl));
+
+ var metricServer = new KestrelMetricServer(port: 8080);
+ metricServer.Start();
+
+ CreateHostBuilder(args).Build().Run();
+ }
+
+ public static IHostBuilder CreateHostBuilder(string[] args) =>
+ Host.CreateDefaultBuilder(args)
+ .ConfigureWebHostDefaults(webBuilder =>
+ {
+ webBuilder.UseStartup();
+ });
+
+ public static readonly Gauge JobsInQueue = Metrics.CreateGauge(
+ "cached_frames",
+ "Number of cached camera frames.");
+
+ private static readonly Counter CamerasCounter = Metrics.CreateCounter(
+ "cameras",
+ "Number of connected cameras.");
+
+ private static readonly Counter CameraDisconnectCounter = Metrics.CreateCounter(
+ "camera_disconnects",
+ "Number of times camera connection had to be restablished.");
+
+ static void Process(string videoPath)
+ {
+ // Mask credential information in videoPath to prevent the credential info shown in console output
+ var videoPathWithMaskedCredential = RtspUrlHelper.GetMaskedCredentialUrl(videoPath);
+ Console.WriteLine($"[VideoProcessor] Processing RTSP stream: {videoPathWithMaskedCredential}");
+
+ while (true)
+ {
+ var capture = new VideoCapture(videoPath);
+ Console.WriteLine("Ready " + capture.IsOpened());
+
+ using (var image = new Mat()) // Frame image buffer
+ {
+ // Loop while we can read an image (aka: image.Empty is not true)
+ while (capture.Read(image) && !image.Empty())
+ {
+ lock (Frames)
+ {
+ var imageBytes = image.ToBytes();
+ Frames.Push(imageBytes);
+ JobsInQueue.Set(Frames.Count);
+ Console.WriteLine("Adding frame from {0}, Q size: {1}, frame size: {2}", videoPathWithMaskedCredential, Program.Frames.Count, imageBytes.Length);
+ }
+ }
+ }
+
+ CameraDisconnectCounter.Inc();
+ Console.WriteLine($"[VideoProcessor] Reopening");
+ }
+ }
+ }
+}
+
diff --git a/brokers/onvif-video-broker/Properties/launchSettings.json b/brokers/onvif-video-broker/Properties/launchSettings.json
new file mode 100644
index 0000000..53ea44d
--- /dev/null
+++ b/brokers/onvif-video-broker/Properties/launchSettings.json
@@ -0,0 +1,12 @@
+{
+ "profiles": {
+ "onvif-video-broker": {
+ "commandName": "Project",
+ "launchBrowser": false,
+ "applicationUrl": "https://localhost:5001",
+ "environmentVariables": {
+ "ASPNETCORE_ENVIRONMENT": "Development"
+ }
+ }
+ }
+}
diff --git a/brokers/onvif-video-broker/README.md b/brokers/onvif-video-broker/README.md
new file mode 100644
index 0000000..e92968b
--- /dev/null
+++ b/brokers/onvif-video-broker/README.md
@@ -0,0 +1,23 @@
+# ONVIF Video Broker
+
+Sample broker for for Akri's [ONVIF Configuration](https://docs.akri.sh/discovery-handlers/onvif).
+Pulls video frames from the rtsp stream of the ONVIF camera at `ONVIF_DEVICE_SERVICE_URL`.
+Then, it serves these frames over a gRPC interface.
+
+## Running
+
+1. Install .NET according to [.NET instructions](https://docs.microsoft.com/dotnet/install/linux-ubuntu)
+1. Install [opencvsharp](https://github.com/shimat/opencvsharp), the OpenCV wrapper for .NET
+1. Build
+
+ ```sh
+ cd ./brokers/onvif-video-broker
+ dotnet build
+ ```
+
+1. Run the broker, passing in the ONVIF service URL for the camera it should pull frames from.
+
+ ```sh
+ ONVIF_DEVICE_SERVICE_URL_ABCDEF=http://10.1.2.3:1000/onvif/device_service dotnet run
+ ```
+
diff --git a/brokers/onvif-video-broker/Startup.cs b/brokers/onvif-video-broker/Startup.cs
new file mode 100644
index 0000000..4eb5544
--- /dev/null
+++ b/brokers/onvif-video-broker/Startup.cs
@@ -0,0 +1,48 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Threading.Tasks;
+using Microsoft.AspNetCore.Builder;
+using Microsoft.AspNetCore.Hosting;
+using Microsoft.AspNetCore.Http;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Hosting;
+using Prometheus;
+
+namespace FrameServer
+{
+ public class Startup
+ {
+ // This method gets called by the runtime. Use this method to add services to the container.
+ // For more information on how to configure your application, visit https://go.microsoft.com/fwlink/?LinkID=398940
+ public void ConfigureServices(IServiceCollection services)
+ {
+ services.AddGrpc();
+ }
+
+ // This method gets called by the runtime. Use this method to configure the HTTP request pipeline.
+ public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
+ {
+ if (env.IsDevelopment())
+ {
+ app.UseDeveloperExceptionPage();
+ }
+
+ app.UseRouting();
+ app.UseHttpMetrics();
+
+ app.UseEndpoints(endpoints =>
+ {
+ endpoints.MapGrpcService();
+
+ endpoints.MapMetrics();
+
+ endpoints.MapGet("/", async context =>
+ {
+ await context.Response.WriteAsync("Communication with gRPC endpoints must be made through a gRPC client. To learn how to create a client, visit: https://go.microsoft.com/fwlink/?linkid=2086909");
+ });
+ });
+ }
+ }
+}
+
diff --git a/brokers/onvif-video-broker/UsernameToken.cs b/brokers/onvif-video-broker/UsernameToken.cs
new file mode 100644
index 0000000..1cba080
--- /dev/null
+++ b/brokers/onvif-video-broker/UsernameToken.cs
@@ -0,0 +1,61 @@
+using System;
+using System.Text;
+using System.Security.Cryptography;
+
+namespace Akri
+{
+ class UsernameToken {
+ public string Username { get; }
+ public string Password { get; }
+
+ public UsernameToken(string username, string password)
+ {
+ this.Username = username;
+ this.Password = password;
+ }
+
+ public string ToXml() {
+ if (this.Username is null) {
+ return "";
+ }
+ var password = this.Password?? "";
+
+ var nonce = CalculateNonce();
+ var created = DateTime.UtcNow.ToString("yyyy-MM-ddTHH:mm:ss.fffZ");
+ var passwordDigest = CalculatePasswordDigest(nonce, created, password);
+ return String.Format(SOAP_SECURITY_HEADER_TEMPLATE, this.Username, passwordDigest, nonce, created);
+ }
+
+ private const String SOAP_SECURITY_HEADER_TEMPLATE = @"
+
+ {0}
+ {1}
+ {2}
+ {3}
+
+ ";
+
+ private string CalculateNonce()
+ {
+ var buffer = new byte[16];
+ using (var r = RandomNumberGenerator.Create())
+ {
+ r.GetBytes(buffer);
+ }
+ return Convert.ToBase64String(buffer);
+ }
+
+ private string CalculatePasswordDigest(string nonceStr, string created, string password)
+ {
+ var nonce = Convert.FromBase64String(nonceStr);
+ var createdBytes = Encoding.UTF8.GetBytes(created);
+ var passwordBytes = Encoding.UTF8.GetBytes(password);
+ var combined = new byte[createdBytes.Length + nonce.Length + passwordBytes.Length];
+ Buffer.BlockCopy(nonce, 0, combined, 0, nonce.Length);
+ Buffer.BlockCopy(createdBytes, 0, combined, nonce.Length, createdBytes.Length);
+ Buffer.BlockCopy(passwordBytes, 0, combined, nonce.Length + createdBytes.Length, passwordBytes.Length);
+
+ return Convert.ToBase64String(SHA1.Create().ComputeHash(combined));
+ }
+ }
+}
diff --git a/brokers/onvif-video-broker/appsettings.Development.json b/brokers/onvif-video-broker/appsettings.Development.json
new file mode 100644
index 0000000..eec2e4c
--- /dev/null
+++ b/brokers/onvif-video-broker/appsettings.Development.json
@@ -0,0 +1,10 @@
+{
+ "Logging": {
+ "LogLevel": {
+ "Default": "Debug",
+ "System": "Information",
+ "Grpc": "Information",
+ "Microsoft": "Information"
+ }
+ }
+}
diff --git a/brokers/onvif-video-broker/appsettings.json b/brokers/onvif-video-broker/appsettings.json
new file mode 100644
index 0000000..1f29241
--- /dev/null
+++ b/brokers/onvif-video-broker/appsettings.json
@@ -0,0 +1,15 @@
+{
+ "Logging": {
+ "LogLevel": {
+ "Default": "Information",
+ "Microsoft": "Warning",
+ "Microsoft.Hosting.Lifetime": "Information"
+ }
+ },
+ "AllowedHosts": "*",
+ "Kestrel": {
+ "EndpointDefaults": {
+ "Protocols": "Http2"
+ }
+ }
+}
diff --git a/brokers/onvif-video-broker/camera.proto b/brokers/onvif-video-broker/camera.proto
new file mode 100644
index 0000000..e3b7c44
--- /dev/null
+++ b/brokers/onvif-video-broker/camera.proto
@@ -0,0 +1,19 @@
+syntax = "proto3";
+
+option csharp_namespace = "Camera";
+
+package camera;
+
+service Camera {
+ rpc GetFrame (NotifyRequest) returns (NotifyResponse);
+}
+
+message NotifyRequest {
+}
+
+message NotifyResponse {
+ bytes frame = 1;
+ string camera = 2;
+}
+
+
diff --git a/brokers/onvif-video-broker/onvif-video-broker.csproj b/brokers/onvif-video-broker/onvif-video-broker.csproj
new file mode 100644
index 0000000..60cbd8a
--- /dev/null
+++ b/brokers/onvif-video-broker/onvif-video-broker.csproj
@@ -0,0 +1,29 @@
+
+
+
+ Exe
+ netcoreapp3.1
+ Linux
+ .
+
+
+
+
+
+
+
+
+ all
+ runtime; build; native; contentfiles; analyzers; buildtransitive
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/build/brokers/Dockerfile.onvif-video-broker b/build/brokers/Dockerfile.onvif-video-broker
new file mode 100644
index 0000000..2299f2f
--- /dev/null
+++ b/build/brokers/Dockerfile.onvif-video-broker
@@ -0,0 +1,33 @@
+ARG BUILD_PLATFORM_TAG=3.1-bullseye
+ARG OUTPUT_PLATFORM_TAG=3.1-bullseye-slim
+ARG DOTNET_PUBLISH_RUNTIME=linux-x64
+
+FROM mcr.microsoft.com/dotnet/sdk:${BUILD_PLATFORM_TAG} AS build
+ARG BUILD_PLATFORM_TAG
+RUN echo "Build base image: mcr.microsoft.com/dotnet/sdk:${BUILD_PLATFORM_TAG}"
+
+WORKDIR /src
+COPY ["brokers/onvif-video-broker/onvif-video-broker.csproj", "."]
+RUN find . && dotnet restore "onvif-video-broker.csproj"
+COPY ["brokers/onvif-video-broker", "."]
+RUN find . && dotnet build "onvif-video-broker.csproj" -c Release -o /app/build
+
+FROM build AS publish
+ARG DOTNET_PUBLISH_RUNTIME
+RUN echo "Publishing to: ${DOTNET_PUBLISH_RUNTIME}" && \
+ dotnet publish -r ${DOTNET_PUBLISH_RUNTIME} "onvif-video-broker.csproj" -c Release -o /app/publish
+
+FROM ghcr.io/project-akri/akri/opencvsharp-build:${OUTPUT_PLATFORM_TAG} AS final
+ARG OUTPUT_PLATFORM_TAG
+RUN echo "Output base image: ghcr.io/project-akri/akri/opencvsharp-build:${OUTPUT_PLATFORM_TAG}"
+
+WORKDIR /app
+COPY --from=publish /app/publish .
+
+# Link the container to the Akri repository
+LABEL org.opencontainers.image.source https://github.com/project-akri/akri
+
+EXPOSE 8083
+ENV ASPNETCORE_URLS=http://*:8083
+
+CMD dotnet onvif-video-broker.dll