Initial checkin
This commit is contained in:
commit
9566112760
|
@ -0,0 +1,9 @@
|
||||||
|
.DS_Store
|
||||||
|
.idea
|
||||||
|
*.log
|
||||||
|
tmp/
|
||||||
|
|
||||||
|
.cpcache/
|
||||||
|
.nrepl-port
|
||||||
|
target/
|
||||||
|
result
|
|
@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -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;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
|
@ -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"));
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
|
@ -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))))
|
|
@ -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)))
|
|
@ -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))
|
|
@ -0,0 +1,4 @@
|
||||||
|
(ns suanni.stoppable)
|
||||||
|
|
||||||
|
(defprotocol IStoppable
|
||||||
|
(stop! [_]))
|
|
@ -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)))))
|
Loading…
Reference in New Issue