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