From 95661127602d3efd028ef9f3125818adeabe4bb4 Mon Sep 17 00:00:00 2001 From: niten Date: Sun, 19 Mar 2023 09:59:32 -0700 Subject: [PATCH] Initial checkin --- .gitignore | 9 ++ deps.edn | 24 ++++ flake.nix | 39 ++++++ module.nix | 131 ++++++++++++++++++++ src/suanni/cli.clj | 98 +++++++++++++++ src/suanni/client.clj | 110 +++++++++++++++++ src/suanni/event_listener.clj | 39 ++++++ src/suanni/stoppable.clj | 4 + src/suanni/syno_client.clj | 224 ++++++++++++++++++++++++++++++++++ 9 files changed, 678 insertions(+) create mode 100644 .gitignore create mode 100644 deps.edn create mode 100644 flake.nix create mode 100644 module.nix create mode 100644 src/suanni/cli.clj create mode 100644 src/suanni/client.clj create mode 100644 src/suanni/event_listener.clj create mode 100644 src/suanni/stoppable.clj create mode 100644 src/suanni/syno_client.clj diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..1b9f7c3 --- /dev/null +++ b/.gitignore @@ -0,0 +1,9 @@ +.DS_Store +.idea +*.log +tmp/ + +.cpcache/ +.nrepl-port +target/ +result diff --git a/deps.edn b/deps.edn new file mode 100644 index 0000000..a7194cd --- /dev/null +++ b/deps.edn @@ -0,0 +1,24 @@ +{ + :paths ["src"] + :deps { + org.clojure/clojure { :mvn/version "1.11.1" } + org.clojure/data.json { :mvn/version "2.4.0" } + org.clojure/core.async { :mvn/version "1.6.673" } + slingshot/slingshot { :mvn/version "0.12.2" } + ring/ring-jetty-adapter { :mvn/version "1.9.6" } + metosin/reitit { :mvn/version "0.5.5" } + + org.fudo/fudo-clojure { + :git/url "https://git.fudo.org/fudo-public/fudo-clojure.git" + :git/sha "c605ec9719600566f7635e452283629682cd4f76" + } + org.fudo/objectifier-client { + :git/url "https://git.fudo.org/fudo-public/objectifier-client.git" + :git/sha "d4b5ecc0f98aa8168ca3ef13e182c688591298ae" + } + org.fudo/milquetoast { + :git/url "https://git.fudo.org/fudo-public/milquetoast.git" + :git/sha "8f71590fd8d63a0a7ab45a46ae6a7a1eee99f16f" + } + } + } diff --git a/flake.nix b/flake.nix new file mode 100644 index 0000000..7f09589 --- /dev/null +++ b/flake.nix @@ -0,0 +1,39 @@ +{ + description = "Suan Ni Home Guard"; + + inputs = { + nixpkgs.url = "nixpkgs/nixos-22.05"; + utils.url = "github:numtide/flake-utils"; + helpers = { + url = "git+https://git.fudo.org/fudo-public/nix-helpers.git"; + inputs.nixpkgs.follows = "nixpkgs"; + }; + }; + + outputs = { self, nixpkgs, utils, helpers, ... }: + utils.lib.eachDefaultSystem (system: + let pkgs = import nixpkgs { inherit system; }; + in { + packages = rec { + default = suanni-server; + suanni-server = helpers.packages."${system}".mkClojureBin { + name = "org.fudo/suanni.server"; + primaryNamespace = "suanni.server.cli"; + src = ./.; + }; + }; + + devShells = rec { + default = updateDeps; + updateDeps = pkgs.mkShell { + buildInputs = with helpers.packages."${system}"; + [ updateClojureDeps ]; + }; + }; + }) // { + nixosModules = rec { + default = suanni-server; + suanni-server = import ./module.nix self.packages; + }; + }; +} diff --git a/module.nix b/module.nix new file mode 100644 index 0000000..d5cc5a3 --- /dev/null +++ b/module.nix @@ -0,0 +1,131 @@ +packages: + +{ config, lib, pkgs, ... }: + +with lib; +let + suanni-server = packages."${pkgs.system}".nexus-client; + cfg = config.suanni.server; + +in { + options.suanni.server = with types; { + enable = mkEnableOption "Enable Suan Ni guardian server."; + + verbose = mkEnableOption "Generate verbose logs and output."; + + event-listener = { + hostname = mkOption { + type = str; + description = "Hostname of the event listener server."; + default = "127.0.0.1"; + }; + + internal-port = mkOption { + type = port; + description = "Port on which to listen for incoming events."; + default = 5354; + }; + }; + + synology-client = { + host = mkOption { + type = str; + description = "Hostname of the Synology server."; + }; + + port = mkOption { + type = port; + description = + "Port on which to connect to the Synology server. Can be an SSL port."; + default = 5001; + }; + + username = mkOption { + type = str; + description = "User as which to connect to the Synology server."; + }; + + password-file = mkOption { + type = str; + description = + "File (on the local host) containing the password for the Synology server."; + }; + }; + + objectifier-client = { + host = mkOption { + type = str; + description = "Hostname of the Objectifier server."; + }; + + port = mkOption { + type = port; + description = "Port on which the Objectifier server is listening."; + default = 80; + }; + }; + + mqtt-server = { + host = mkOption { + type = str; + description = "Hostname of the MQTT server."; + }; + + port = mkOption { + type = port; + description = "Port on which the MQTT server is listening."; + default = 80; + }; + + username = mkOption { + type = str; + description = "User as which to connect to the MQTT server."; + }; + + password-file = mkOption { + type = str; + description = + "File (on the local host) containing the password for the MQTT server."; + }; + }; + }; + + config = mkIf cfg.enable { + services.nginx = { + enable = true; + recommendedOptimisations = true; + recommendedProxySettings = true; + recommendedGzipSettings = true; + + virtualHosts."${cfg.hostname}" = { + locations."/".proxyPass = "http://127.0.0.1:${toString cfg.port}"; + }; + }; + + systemd.suanni-server = { + path = [ suanni-server ]; + wantedBy = [ "network-online.target" ]; + serviceConfig = { + DynamicUser = true; + LoadCredential = [ + "syno.passwd:${cfg.synology.password-file}" + "mqtt.passwd:${cfg.mqtt-server.password-file}" + ]; + ExecStart = pkgs.writeShellScript "suanni-server.sh" + (concatStringsSep " " ([ + "suanni-server" + "--hostname=${cfg.event-listener.hostname}" + "--port=${toString cfg.event-listener.port}" + "--synology-host=${cfg.synology.host}" + "--synology-port=${toString cfg.synology.port}" + "--synology-user=${cfg.synology.username}" + "--synology-password-file=$CREDENTIALS_DIRECTORY/syno.passwd" + "--mqtt-host=${cfg.mqtt.host}" + "--mqtt-port=${toString cfg.mqtt.port}" + "--mqtt-user=${cfg.mqtt.username}" + "--mqtt-password-file=$CREDENTIALS_DIRECTORY/mqtt.passwd" + ]) ++ (optional cfg.verbose "--verbose")); + }; + }; + }; +} diff --git a/src/suanni/cli.clj b/src/suanni/cli.clj new file mode 100644 index 0000000..c226bea --- /dev/null +++ b/src/suanni/cli.clj @@ -0,0 +1,98 @@ +(ns suanni.cli + (:require [suanni.syno-client :as syno] + [suanni.stoppable :refer [stop!]] + [milquetoast.client :as mqtt] + [objectifier-client.core :as obj] + [suanni.client :as client] + [clojure.set :as set] + [clojure.tools.cli :as cli] + [clojure.string :as str] + [clojure.core.async :as async :refer [!!]]) + (:gen-class)) + +(def cli-opts + [["-v" "--verbose" "Provide verbose output."] + ["-H" "--hostname HOSTNAME" "Hostname on which to listen for incoming events."] + ["-p" "--port PORT" "Port on which to listen for incoming events." + :parse-fn #(Integer/parseInt %)] + + [nil "--synology-host HOSTNAME" "Hostname of Synology server."] + [nil "--synology-port PORT" "Port on which to connect to the Synology server." + :parse-fn #(Integer/parseInt %)] + [nil "--synology-user USER" "User as which to connect to Synology server."] + [nil "--synology-password-file PASSWD_FILE" "File containing password for Synology user."] + + [nil "--objectifier-host HOSTNAME" "Hostname of Objectifier server."] + [nil "--objectifier-port PORT" "Port on which to connect to the Objectifier server." + :parse-fn #(Integer/parseInt %) + :default 80] + + [nil "--mqtt-host HOSTNAME" "Hostname of MQTT server."] + [nil "--mqtt-port PORT" "Port on which to connect to the MQTT server." + :parse-fn #(Integer/parseInt %)] + [nil "--mqtt-user USER" "User as which to connect to MQTT server."] + [nil "--mqtt-password-file PASSWD_FILE" "File containing password for MQTT user."] + [nil "--mqtt-topic TOPIC" "MQTT topic to which events should be published."]]) + +(defn- msg-quit [status msg] + (println msg) + (System/exit status)) + +(defn- usage + ([summary] (usage summary [])) + ([summary errors] (->> (concat errors + ["usage: suanni-server [opts]" + "" + "Options:" + summary]) + (str/join \newline)))) + +(defn- parse-opts [args required cli-opts] + (let [{:keys [options]} :as result (cli/parse-opts args cli-opts) + missing (set/difference required (-> options (keys) (set))) + missing-errors (map #(format "missing required parameter: %s" %) + missing)] + (update result :errors concat missing-errors))) + +(defn -main [& args] + (let [required-args #{:hostname :port + :synology-host :synology-port :synology-user :synology-password-file + :objectifier-host + :mqtt-host :mqtt-port :mqtt-user :mqtt-password-file :mqtt-topic} + {:keys [options _ errors summary]} (parse-opts args required-args cli-opts)] + (when (seq errors) (msg-quit 1 (usage summary errors))) + (let [{:keys [hostname port + synology-host synology-port synology-user synology-password-file + objectifier-host objectifier-port + mqtt-host mqtt-port mqtt-user mqtt-password-file mqtt-topic + verbose]} options + catch-shutdown (async/chan) + syno-client (-> (syno/create-connection :host synology-host + :port synology-port + :verbose verbose) + (syno/initialize! synology-user (-> synology-password-file + (slurp) + (str/trim)))) + obj-client (obj/define-connection + :host objectifier-host + :port objectifier-port + :verbose verbose) + mqtt-client (mqtt/connect-json! :host mqtt-host + :port mqtt-port + :username mqtt-user + :password (-> mqtt-password-file + (slurp) + (str/trim)) + :verbose verbose) + suanni-client (client/start! :listen-host hostname + :listen-port port + :syno-client syno-client + :obj-client obj-client + :mqtt-client mqtt-client + :mqtt-topic mqtt-topic + :verbose verbose)] + (.addShutdownHook (Runtime/getRuntime) + (Thread. (fn [] (>!! catch-shutdown true)))) + (! go-loop]] + [clojure.string :as str]) + (:import java.time.Instant)) + +;; Let's see: +;; +;; - Take in a syno-client and an objectifier client. +;; +;; - Start an event listener, and wait for notifications to come in. +;; +;; - Take snapshots from all cameras via the syno client, and pass them to the +;; objectifier. +;; +;; - If anything is detected, send a notification to the callback. + +(defprotocol ISuanNiServer + (object-channel [_])) + +(defrecord SuanNiServer [event-chan image-chan obj-chan listener] + IStoppable + (stop! [_] + (stop! listener)) + ISuanNiServer + (object-channel [_] obj-chan)) + +(defn start! + [& {:keys [listen-host + listen-port + syno-client + obj-client + mqtt-client + mqtt-topic + verbose]}] + (let [event-chan (async/chan 5) + image-chan (async/chan 10) + obj-chan (async/chan 10) + mqtt-chan (mqtt/open-channel! mqtt-client + mqtt-topic + :buffer-size 10) + listener (listen/start! :host listen-host + :port listen-port + :event-chan event-chan + :verbose verbose)] + (go-loop [event ( event :type (= :motion-detected)) + (let [cam (syno/get-camera-by-location! syno-client (:location event))] + (>! image-chan + { + :location (syno/location cam) + :camera-id (syno/id cam) + :snapshot (syno/take-snapshot! cam) + :time (Instant/now) + :camera cam + })) + (recur (> summary + :objects + (keys) + (map name) + (str/join " ")))) + (println (str "highlights: " (:output summary)))) + (when (> (count (:objects summary)) 0) + (>! obj-chan + { + :location location + :camera-id camera-id + :detect-time time + :snapshot snapshot + :objects (:objects summary) + :detection-url (:output summary) + })) + (recur (! mqtt-chan + {:type :detection-event + :time (Instant/now) + :detection + (select-keys detection-event + [:location + :camera-id + :detect-time + :objects + :detection-url])}) + (recur (SuanNiServer event-chan image-chan obj-chan listener))) diff --git a/src/suanni/event_listener.clj b/src/suanni/event_listener.clj new file mode 100644 index 0000000..51b45c4 --- /dev/null +++ b/src/suanni/event_listener.clj @@ -0,0 +1,39 @@ +(ns suanni.event-listener + (:require [reitit.ring :as ring] + [clojure.core.async :as async :refer [>!!]] + [clojure.data.json :as json] + [ring.adapter.jetty :refer [run-jetty]] + [suanni.stoppable :refer [IStoppable]])) + +;; Maybe someday we'll actually know the camera... +(defn- motion-event + ([] (motion-event nil)) + ([camera] + {:type :motion-detected + :location camera})) + +(defn- handle-motion-event [verbose event-chan] + (fn [req] + (let [{:keys [camera]} (-> req :body (slurp) (json/read-str {:key-fn keyword}))] + (when verbose (println (str "motion reported on camera " camera))) + (>!! event-chan (motion-event (keyword camera)))) + { :status 200 :body "OK" })) + +(defn create-app [verbose event-chan] + (ring/ring-handler + (ring/router ["/event/motion" {:post {:handler (handle-motion-event verbose event-chan)}}]) + (ring/create-default-handler))) + +(defn- run-server! [{:keys [host port verbose event-chan] + :or {verbose false}}] + (when verbose (println (str "listening for events on " host ":" port))) + (run-jetty (create-app verbose event-chan) {:host host :port port :join? false})) + +(defrecord EventListenerServer [server event-chan] + IStoppable + (stop! [_] + (.stop server) + (async/close! event-chan))) + +(defn start! [& {:keys [event-chan] :as args}] + (->EventListenerServer (run-server! args) event-chan)) diff --git a/src/suanni/stoppable.clj b/src/suanni/stoppable.clj new file mode 100644 index 0000000..aa4d1ec --- /dev/null +++ b/src/suanni/stoppable.clj @@ -0,0 +1,4 @@ +(ns suanni.stoppable) + +(defprotocol IStoppable + (stop! [_])) diff --git a/src/suanni/syno_client.clj b/src/suanni/syno_client.clj new file mode 100644 index 0000000..d94ed35 --- /dev/null +++ b/src/suanni/syno_client.clj @@ -0,0 +1,224 @@ +;; # Syno Client +;; +;; Connect to Synology Surveillance Station API and allow snapshots from +;; cameras. + +(ns suanni.syno-client + (:require [fudo-clojure.http.client :as client] + [fudo-clojure.http.request :as req] + [fudo-clojure.result :as result] + [clojure.string :as str] + [slingshot.slingshot :refer [throw+]]) + (:import java.net.InetAddress + java.time.Instant)) + +;; ## Protocols + +;; ### BaseSynoClient +;; +;; In order to fully initialize the SynoClient, we need to be able to query the +;; Synology host to get path/version information and to authenticate. This base +;; client will have enough functionality to do that. Calling `initialize!` on +;; the base client will actually perform the queries necessary to initialize +;; full functionality. + +(defprotocol IBaseSynoClient + (get! [_ req]) + (initialize! [_ username passwd])) + +;; ### SynoClient +;; +;; The SynoClient is the client that is actually able to do things like list +;; cameras, detect motion, and take snapshots. + +(defprotocol ISynoClient + (disconnect! [_]) + (camera-snapshot! [_ camera-id]) + (get-cameras! [_]) + (get-camera-by-location! [_ loc])) + +(defprotocol ICamera + (id [_]) + (location [_]) + (vendor [_]) + (model [_]) + (host [_]) + (port [_]) + (take-snapshot! [_])) + +(defrecord Camera [conn data] + ICamera + (id [_] (:id data)) + (location [_] (-> data :newName keyword)) + (vendor [_] (-> data :vendor)) + (model [_] (-> data :model)) + (host [_] (-> data :ip)) + (port [_] (-> data :port)) + (take-snapshot! [self] (camera-snapshot! conn (id self)))) + +;; ## Helper Functions + +(defn- get-hostname [] + (-> (InetAddress/getLocalHost) + (.getHostName))) + +;; ## Initialization queries +;; +;; Queries which are necessary to fully initialize the client. Used in the +;; `initialize!` method of BaseSynoClient. + +;; ### get-api-info +;; +;; Grab information (maxVersion & path) about a specified API. + +(defn- get-api-info! [conn api] + (-> conn + (get! (-> (req/base-request) + (req/with-path "/webapi/query.cgi") + (req/withQueryParams + { + :api :SYNO.API.Info + :method :Query + :version 1 + :query api + }))) + api)) + +;; ### get-auth-tokens! +;; +;; Authenticate with the provided account and passwd, and get the :sid and :did +;; to use for subsequent requests. + +(defn- get-auth-tokens! [conn account passwd] + (let [{:keys [maxVersion path]} (get-api-info! conn :SYNO.API.Auth)] + (get! conn + (-> (req/base-request) + (req/with-path (format "/webapi/%s" path)) + (req/withQueryParams + { + :version maxVersion + :session :SurveillanceStation + :api :SYNO.API.Auth + :method :login + :account account + :passwd passwd + :format :sid + :enable_device_token true + :device_name (get-hostname) + }))))) + +(defn- perform-request! [http-client req] + (result/bind (client/execute-request! http-client req) + (fn [resp] + (if (:error resp) + (throw (ex-info "error performing request" + {:request req + :error (:error resp) + :response resp})) + (cond (:data resp) (:data resp) + (:body resp) (:body resp)))))) + +;; ## Requests + +(defn- make-list-cameras-request [{:keys [maxVersion path]}] + (-> (req/base-request) + (req/with-path (format "/webapi/%s" path)) + (req/withQueryParams + { + :version maxVersion + :session :SurveillanceStation + :api :SYNO.SurveillanceStation.Camera + :method :List + }))) + +(defn- make-snapshot-request [{:keys [maxVersion path]} camera-id] + (-> (req/base-request) + (req/with-path (format "/webapi/%s" path)) + (req/with-response-format :binary) + (req/with-option :as :byte-array) + (req/withQueryParams + { + :version maxVersion + :session :SurveillanceStation + :api :SYNO.SurveillanceStation.Camera + :method :GetSnapshot + :id camera-id + }))) + +(defn- make-logout-request [{:keys [maxVersion path]}] + (-> (req/base-request) + (req/with-path (format "/webapi/%s" path)) + (req/withQueryParams + { + :version maxVersion + :session :SurveillanceStation + :api :SYNO.API.Auth + :method :logout + }))) + +;; ## Actual client + +(defn- find-first [pred lst] + (loop [els lst] + (if (pred (first els)) + (first els) + (recur (rest els))))) + +(defrecord SynoConnection [conn auth-info api-info verbose] + IBaseSynoClient + (get! [_ req] + (get! conn + (-> req + (req/with-query-params + {:device_id (:device_id auth-info)}) + (req/withQueryParams + {:_sid (:sid auth-info)})))) + (initialize! [_ _ _] + (throw (ex-info "client already initialized!" {}))) + + ISynoClient + (disconnect! [self] + (get! self (make-logout-request (:SYNO.API.Auth api-info)))) + (camera-snapshot! [self camera-id] + (when verbose (println (str "fetching snapshot from camera " camera-id))) + (-> self + (get! (make-snapshot-request (:SYNO.SurveillanceStation.Camera api-info) camera-id)))) + (get-cameras! [self] + (when verbose (println "fetching camera list")) + (into [] + (map (partial ->Camera self)) + (-> (get! self (make-list-cameras-request (:SYNO.SurveillanceStation.Camera api-info))) + :cameras))) + (get-camera-by-location! [self loc] + (find-first #(= loc (location %)) (get-cameras! self)))) + +(defn- initialize-connection! [conn auth-info verbose] + (let [api-info (into {} (map (fn [api] [api (get-api-info! conn api)])) + [:SYNO.SurveillanceStation.Camera + :SYNO.SurveillanceStation.Camera.Event])] + (->SynoConnection conn auth-info api-info verbose))) + +(defn create-connection [& {:keys [host port verbose] + :or {verbose true}}] + (let [http-client (client/json-client)] + (reify + IBaseSynoClient + (get! [_ req] + (perform-request! http-client + (-> req + (req/with-host host) + (req/with-port port) + (req/with-option :insecure? true) + (req/as-get)))) + + (initialize! [self account passwd] + (let [auth-data (get-auth-tokens! self account passwd)] + (initialize-connection! self auth-data verbose)))))) + +(defn with-conn [host port user passwd-file f] + (let [passwd (-> passwd-file (slurp) (str/trim)) + conn (-> (create-connection {:host host :port port}) + (initialize! user passwd))] + (try + (f conn) + (finally (disconnect! conn)))))