Initial checkin

This commit is contained in:
niten 2023-03-19 09:59:32 -07:00
commit 9566112760
9 changed files with 678 additions and 0 deletions

9
.gitignore vendored Normal file
View File

@ -0,0 +1,9 @@
.DS_Store
.idea
*.log
tmp/
.cpcache/
.nrepl-port
target/
result

24
deps.edn Normal file
View File

@ -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"
}
}
}

39
flake.nix Normal file
View File

@ -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;
};
};
}

131
module.nix Normal file
View File

@ -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"));
};
};
};
}

98
src/suanni/cli.clj Normal file
View File

@ -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))))
(<!! catch-shutdown)
(stop! suanni-client)
(System/exit 0))))

110
src/suanni/client.clj Normal file
View File

@ -0,0 +1,110 @@
(ns suanni.client
(:require [suanni.syno-client :as syno]
[suanni.event-listener :as listen]
[suanni.stoppable :refer [IStoppable stop!]]
[milquetoast.client :as mqtt]
[objectifier-client.core :as obj]
[clojure.core.async :as async :refer [<! >! 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-chan)]
(if (nil? event)
(when verbose
(println "stopping event listener")
(async/close! image-chan))
(when (-> 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 (<! event-chan)))))
(go-loop [image-data (<! image-chan)]
(if (nil? image-data)
(when verbose
(println "stopping image listener")
(async/close! obj-chan))
(let [{:keys [location camera-id snapshot time]} image-data
summary (obj/get-summary! obj-client snapshot)]
(when verbose
(println (str "detected "
(count (:objects summary))
" objects: "
(->> 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 (<! image-chan)))))
(go-loop [detection-event (<! obj-chan)]
(if (nil? detection-event)
(when verbose
(println "stopping object listener")
(async/close! mqtt-chan))
(do (>! mqtt-chan
{:type :detection-event
:time (Instant/now)
:detection
(select-keys detection-event
[:location
:camera-id
:detect-time
:objects
:detection-url])})
(recur (<! obj-chan)))))
(->SuanNiServer event-chan image-chan obj-chan listener)))

View File

@ -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))

4
src/suanni/stoppable.clj Normal file
View File

@ -0,0 +1,4 @@
(ns suanni.stoppable)
(defprotocol IStoppable
(stop! [_]))

224
src/suanni/syno_client.clj Normal file
View File

@ -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)))))