├── .gitignore ├── LICENSE ├── README.md ├── config └── config.exs ├── lib ├── blackvue.ex └── blackvue │ ├── application.ex │ └── cloud │ ├── auth.ex │ ├── client.ex │ ├── device.ex │ ├── s3.ex │ ├── structs │ ├── device.ex │ ├── s3_file.ex │ ├── vod.ex │ └── vod_token.ex │ └── vod.ex ├── mix.exs ├── mix.lock └── test ├── blackvue_test.exs └── test_helper.exs /.gitignore: -------------------------------------------------------------------------------- 1 | # The directory Mix will write compiled artifacts to. 2 | /_build 3 | 4 | # If you run "mix test --cover", coverage assets end up here. 5 | /cover 6 | 7 | # The directory Mix downloads your dependencies sources to. 8 | /deps 9 | 10 | # Where 3rd-party dependencies like ExDoc output generated docs. 11 | /doc 12 | 13 | # Ignore .fetch files in case you like to edit your project deps locally. 14 | /.fetch 15 | 16 | # If the VM crashes, it generates a dump, let's ignore it too. 17 | erl_crash.dump 18 | 19 | # Also ignore archive artifacts (built via "mix archive.build"). 20 | *.ez 21 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2017 John Hamelink 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Blackvue 2 | 3 | **TODO: Add description** 4 | 5 | ## Installation 6 | 7 | If [available in Hex](https://hex.pm/docs/publish), the package can be installed 8 | by adding `blackvue` to your list of dependencies in `mix.exs`: 9 | 10 | ```elixir 11 | def deps do 12 | [{:blackvue, "~> 0.1.0"}] 13 | end 14 | ``` 15 | 16 | Documentation can be generated with [ExDoc](https://github.com/elixir-lang/ex_doc) 17 | and published on [HexDocs](https://hexdocs.pm). Once published, the docs can 18 | be found at [https://hexdocs.pm/blackvue](https://hexdocs.pm/blackvue). 19 | 20 | -------------------------------------------------------------------------------- /config/config.exs: -------------------------------------------------------------------------------- 1 | # This file is responsible for configuring your application 2 | # and its dependencies with the aid of the Mix.Config module. 3 | use Mix.Config 4 | 5 | # This configuration is loaded before any dependency and is restricted 6 | # to this project. If another project depends on this project, this 7 | # file won't be loaded nor affect the parent project. For this reason, 8 | # if you want to provide default values for your application for 9 | # 3rd-party users, it should be done in your "mix.exs" file. 10 | 11 | # You can configure for your application as: 12 | # 13 | # config :blackvue, key: :value 14 | # 15 | # And access this configuration in your application as: 16 | # 17 | # Application.get_env(:blackvue, :key) 18 | # 19 | # Or configure a 3rd-party app: 20 | # 21 | # config :logger, level: :info 22 | # 23 | config :blackvue, 24 | email: "lorem@ipsum.com", 25 | password: "password" 26 | 27 | # It is also possible to import configuration files, relative to this 28 | # directory. For example, you can emulate configuration per environment 29 | # by uncommenting the line below and defining dev.exs, test.exs and such. 30 | # Configuration from the imported file will override the ones defined 31 | # here (which is why it is important to import them last). 32 | # 33 | # import_config "#{Mix.env}.exs" 34 | -------------------------------------------------------------------------------- /lib/blackvue.ex: -------------------------------------------------------------------------------- 1 | defmodule Blackvue do 2 | @moduledoc """ 3 | Blackvue is an unofficial API Client library for Pittasoft Ltd's Blackvue API. 4 | """ 5 | end 6 | -------------------------------------------------------------------------------- /lib/blackvue/application.ex: -------------------------------------------------------------------------------- 1 | defmodule Blackvue.Application do 2 | # See http://elixir-lang.org/docs/stable/elixir/Application.html 3 | # for more information on OTP Applications 4 | @moduledoc false 5 | 6 | use Application 7 | 8 | def start(_type, _args) do 9 | import Supervisor.Spec, warn: false 10 | 11 | # Define workers and child supervisors to be supervised 12 | children = [ 13 | # Starts a worker by calling: Blackvue.Worker.start_link(arg1, arg2, arg3) 14 | # worker(Blackvue.Worker, [arg1, arg2, arg3]), 15 | worker(Blackvue.Cloud.Auth, []) 16 | ] 17 | 18 | # See http://elixir-lang.org/docs/stable/elixir/Supervisor.html 19 | # for other strategies and supported options 20 | opts = [strategy: :one_for_one, name: Blackvue.Supervisor] 21 | Supervisor.start_link(children, opts) 22 | end 23 | end 24 | -------------------------------------------------------------------------------- /lib/blackvue/cloud/auth.ex: -------------------------------------------------------------------------------- 1 | defmodule Blackvue.Cloud.Auth do 2 | 3 | require Logger 4 | 5 | def start_link do 6 | Logger.info "Booting up Client Agent" 7 | Agent.start_link(fn -> %{} end, name: __MODULE__) 8 | end 9 | 10 | def user_token, 11 | do: state()[:user_token] 12 | 13 | def email, 14 | do: state()[:email] 15 | 16 | def was_server do 17 | %URI{ 18 | scheme: "https", 19 | host: state()[:was_server], 20 | port: state()[:was_port] 21 | } 22 | end 23 | 24 | def session_params do 25 | [ 26 | email: state()[:email], 27 | user_token: state()[:user_token] 28 | ] 29 | end 30 | 31 | def state, 32 | do: Agent.get(__MODULE__, & &1) 33 | 34 | def should_request? do 35 | !(Map.has_key?(state(), :user_token) && state()[:user_token] != nil) 36 | end 37 | 38 | def auth(), 39 | do: auth(Application.get_env(:blackvue, :email), Application.get_env(:blackvue, :password)) 40 | 41 | def auth(email, password) do 42 | if should_request?() do 43 | reauth(email, password) 44 | end 45 | 46 | state() 47 | end 48 | 49 | def reauth(email, password) do 50 | password_hash = :crypto.hash(:sha256, password) |> Base.encode16 51 | url = "https://pitta.blackvuecloud.com/app/user_login.php" 52 | 53 | payload = [ 54 | email: email, 55 | passwd: password_hash, 56 | mobile_name: "iPhone", 57 | app_ver: "2.56", 58 | mobile_uuid: "A241C1BD-E0F9-4B56-B3FD-AD3BAE06DC02", 59 | mobile_os_type: "ios", 60 | time_interval: 0 61 | ] |> URI.encode_query 62 | 63 | case HTTPoison.post(url, payload, Blackvue.Cloud.Client.default_headers) do 64 | {:ok, %{body: body, status_code: 200}} -> 65 | json = Poison.decode!(body) 66 | 67 | state = %{ 68 | gps_port: String.to_integer(json["gps_port"]), 69 | gps_server: json["gps_server"], 70 | user_token: json["user_token"], 71 | was_port: String.to_integer(json["was_port"]), 72 | was_server: json["was_server"], 73 | email: email, 74 | password: password_hash 75 | } 76 | 77 | Agent.update(__MODULE__, fn _ -> state end) 78 | Logger.info("Updated Client state") 79 | err -> 80 | Logger.error("Bad response from server: #{err}") 81 | end 82 | end 83 | 84 | end 85 | -------------------------------------------------------------------------------- /lib/blackvue/cloud/client.ex: -------------------------------------------------------------------------------- 1 | defmodule Blackvue.Cloud.Client do 2 | use HTTPoison.Base 3 | require Logger 4 | 5 | @options [recv_timeout: 50_000] 6 | 7 | def default_headers() do 8 | [ 9 | {"Accept", "*/*"}, 10 | {"Accept-Language", "en-gb"}, 11 | {"Content-Type", "application/x-www-form-urlencoded"}, 12 | {"User-Agent", "BlackVueC/2126 CFNetwork/811.4.16 Darwin/16.5.0"} 13 | ] 14 | end 15 | 16 | def add_auth(body), 17 | do: body ++ Blackvue.Cloud.Auth.session_params 18 | 19 | def get(uri = %URI{}, params, custom_headers \\ []) when is_list(params) do 20 | payload = 21 | params 22 | |> add_auth 23 | |> URI.encode_query 24 | headers = default_headers() ++ custom_headers 25 | uri = %{uri | query: payload} 26 | 27 | Logger.debug("GET request to: #{uri}") 28 | case uri.path == "/proc/vod_file" do 29 | true -> 30 | Logger.warn("TODO: Make it possible to download large files from VOD") 31 | Logger.warn("Not executing request") 32 | false -> 33 | handle_response(request(:get, uri, [], headers, @options)) 34 | end 35 | end 36 | 37 | def call_api(verb, uri = %URI{}, body, custom_headers \\ []) when is_list(body) do 38 | payload = 39 | body 40 | |> add_auth 41 | |> URI.encode_query 42 | headers = default_headers() ++ custom_headers 43 | 44 | Logger.debug("Sending #{verb} request: #{payload} to #{uri}") 45 | handle_response(request(verb, uri, payload, headers, @options)) 46 | end 47 | 48 | def handle_response({:ok, %HTTPoison.Response{body: nil}}), 49 | do: nil 50 | 51 | def handle_response({:ok, %HTTPoison.Response{body: body}}) do 52 | case Poison.decode(body) do 53 | {:ok, json} -> {:ok, json} 54 | _other -> body 55 | end 56 | end 57 | 58 | end 59 | -------------------------------------------------------------------------------- /lib/blackvue/cloud/device.ex: -------------------------------------------------------------------------------- 1 | defmodule Blackvue.Cloud.Device do 2 | alias Blackvue.Cloud.{Client, Auth} 3 | alias Blackvue.Cloud.Structs.Device, as: DeviceStruct 4 | 5 | def list do 6 | uri = %{Auth.was_server | path: "/app/device_list.php"} 7 | Client.get(uri, []) 8 | |> DeviceStruct.into 9 | end 10 | 11 | def get_config(device = %DeviceStruct{}) do 12 | uri = %{DeviceStruct.lb_server(device) | path: "/proc/get_config"} 13 | Client.get(uri, [filename: "config.ini", psn: device.psn]) 14 | end 15 | 16 | def set_config(device = %DeviceStruct{}, config) do 17 | uri = %{DeviceStruct.lb_server(device) | path: "/proc/set_config"} 18 | Client.get(uri, [filename: "config.ini", psn: device.psn, data: Base.encode64(config)]) 19 | end 20 | 21 | end 22 | -------------------------------------------------------------------------------- /lib/blackvue/cloud/s3.ex: -------------------------------------------------------------------------------- 1 | defmodule Blackvue.Cloud.S3 do 2 | alias Blackvue.Cloud.{Client, Auth} 3 | alias Blackvue.Cloud.Structs.Device, as: DeviceStruct 4 | alias Blackvue.Cloud.Structs.S3File 5 | 6 | def list(device = %DeviceStruct{}) do 7 | uri = %{Auth.was_server | path: "/app/s3_filelist2.php"} 8 | Client.get(uri, [psn: device.psn]) 9 | |> S3File.into 10 | end 11 | 12 | def download_url(device = %DeviceStruct{}, %S3File{filename: filename}) do 13 | uri = %{DeviceStruct.lb_server(device) | path: "/app/user_s3_presigned_url.php"} 14 | Client.get(uri, [psn: device.psn, filename: filename]) 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /lib/blackvue/cloud/structs/device.ex: -------------------------------------------------------------------------------- 1 | defmodule Blackvue.Cloud.Structs.Device do 2 | 3 | defstruct valid: nil, 4 | model: nil, 5 | psn: nil, 6 | active: false, 7 | dev_name: nil, 8 | lb_server_name: nil, 9 | lb_http_port: 0, 10 | lb_rtmp_port: 0, 11 | share_video: false, 12 | share_audio: false, 13 | share_dev_name: false, 14 | share_gps: true, 15 | fw_ver: nil, 16 | dev_shared_cnt: 0 17 | 18 | def lb_server(device = %Blackvue.Cloud.Structs.Device{}) do 19 | %URI{ 20 | scheme: "https", 21 | port: device.lb_http_port, 22 | host: device.lb_server_name 23 | } 24 | end 25 | 26 | def into({:ok, %{"device list" => %{"info" => list}}}) do 27 | Enum.map(list, fn(data) -> 28 | Blackvue.Cloud.Structs.Device.into(:device, data) 29 | end) 30 | end 31 | 32 | def into(:device, device = %{}) do 33 | %Blackvue.Cloud.Structs.Device{ 34 | active: (device["active"] == "on"), 35 | valid: (device["valid"] == "valid"), 36 | dev_name: device["dev_name"], 37 | dev_shared_cnt: device["dev_shared_cnt"], 38 | fw_ver: device["fw_ver"], 39 | lb_http_port: String.to_integer(device["lb_http_port"]), 40 | lb_rtmp_port: String.to_integer(device["lb_rtmp_port"]), 41 | lb_server_name: device["lb_server_name"], 42 | model: device["model"], 43 | psn: device["psn"], 44 | share_audio: (device["share_audio"] == "on"), 45 | share_dev_name: (device["share_dev_name"] == "on"), 46 | share_gps: (device["share_gps"] == "on"), 47 | share_video: (device["share_video"] == "on") 48 | } 49 | end 50 | 51 | end 52 | -------------------------------------------------------------------------------- /lib/blackvue/cloud/structs/s3_file.ex: -------------------------------------------------------------------------------- 1 | defmodule Blackvue.Cloud.Structs.S3File do 2 | 3 | defstruct filename: nil, 4 | expires: nil 5 | 6 | def into({:ok, %{"filelist" => files}}) do 7 | Enum.map(files, fn(file) -> 8 | Blackvue.Cloud.Structs.S3File.into(:file, file) 9 | end) 10 | end 11 | 12 | def into(:file, file = %{}) do 13 | %Blackvue.Cloud.Structs.S3File{ 14 | filename: file["filename"], 15 | expires: NaiveDateTime.from_iso8601!(file["exp"]) 16 | } 17 | end 18 | 19 | end 20 | -------------------------------------------------------------------------------- /lib/blackvue/cloud/structs/vod.ex: -------------------------------------------------------------------------------- 1 | defmodule Blackvue.Cloud.Structs.VOD do 2 | 3 | defstruct time: nil, 4 | mode: nil, 5 | camera: nil, 6 | extension: nil, 7 | file: nil 8 | 9 | def modes do 10 | %{ 11 | "P" => :parked, 12 | "M" => :manual, 13 | "E" => :event, 14 | "N" => :normal 15 | } 16 | end 17 | 18 | def modes(mode) when is_bitstring(mode), 19 | do: modes[mode] 20 | 21 | def modes(nil), do: nil 22 | 23 | def cameras do 24 | %{ 25 | "F" => :front, 26 | "R" => :rear 27 | } 28 | end 29 | 30 | def cameras(cam) when is_bitstring(cam), 31 | do: cameras[cam] 32 | 33 | def cameras(nil), do: nil 34 | 35 | def into({:ok, %{"filelist" => files}}) do 36 | Enum.map(files, fn(file) -> 37 | into(:vod, file) 38 | end) 39 | end 40 | 41 | def into(:vod, file) do 42 | vod_parse_regex = ~r/^(?\d{4})(?\d{2})(?\d{2})_(?\d{2})(?\d{2})(?\d{2})_(?\w{1})(?\w{0,1})\.(?\w+)$/ 43 | 44 | vod = Regex.named_captures(vod_parse_regex, file) 45 | vod = 46 | case vod["camera"] == "" do 47 | true -> %{vod | "camera" => nil} 48 | false -> vod 49 | end 50 | 51 | {:ok, timestamp} = NaiveDateTime.new( 52 | String.to_integer(vod["year"]), 53 | String.to_integer(vod["month"]), 54 | String.to_integer(vod["day"]), 55 | String.to_integer(vod["hour"]), 56 | String.to_integer(vod["minute"]), 57 | String.to_integer(vod["second"])) 58 | 59 | %Blackvue.Cloud.Structs.VOD{ 60 | time: timestamp, 61 | mode: modes(vod["mode"]), 62 | camera: cameras(vod["camera"]), 63 | extension: vod["extension"], 64 | file: file 65 | } 66 | end 67 | 68 | end 69 | -------------------------------------------------------------------------------- /lib/blackvue/cloud/structs/vod_token.ex: -------------------------------------------------------------------------------- 1 | defmodule Blackvue.Cloud.Structs.VODToken do 2 | 3 | defstruct token: nil, 4 | limit: 0, 5 | usage: 0 6 | 7 | def into({:ok, data}) do 8 | %Blackvue.Cloud.Structs.VODToken{ 9 | token: data["vod_token"], 10 | limit: String.to_integer(data["vod_limit"]), 11 | usage: String.to_integer(data["vod_usage"]) 12 | } 13 | end 14 | 15 | def into("Invalid Parameter") do 16 | {:error, "Invalid Parameter in request"} 17 | end 18 | 19 | end 20 | -------------------------------------------------------------------------------- /lib/blackvue/cloud/vod.ex: -------------------------------------------------------------------------------- 1 | defmodule Blackvue.Cloud.VOD do 2 | alias Blackvue.Cloud.Client 3 | alias Blackvue.Cloud.Structs.Device, as: DeviceStruct 4 | alias Blackvue.Cloud.Structs.VOD, as: VODStruct 5 | alias Blackvue.Cloud.Structs.VODToken 6 | 7 | def list(device = %DeviceStruct{}) do 8 | uri = %{DeviceStruct.lb_server(device) | path: "/proc/vod_list"} 9 | Client.get(uri, [psn: device.psn]) 10 | |> VODStruct.into 11 | end 12 | 13 | # TODO: Use an Agent to manage the API backpressure here. 14 | defp request_vod_token(device = %DeviceStruct{}, file) do 15 | uri = %{DeviceStruct.lb_server(device) | path: "/app/vod_play_req.php"} 16 | Client.get(uri, [psn: device.psn, filename: file]) 17 | |> VODToken.into 18 | end 19 | 20 | # TODO: Make this into a GenServer 21 | # TODO: Make this async because it can be very slow. 22 | # TODO: Maybe use Stream somehow? 23 | def retrieve_file(device = %DeviceStruct{}, %VODStruct{file: file}) do 24 | # Retrieve permission to download file 25 | %VODToken{token: token} = request_vod_token(device, file) 26 | # Actually download the file 27 | uri = %{DeviceStruct.lb_server(device) | path: "/proc/vod_file"} 28 | Client.get(uri, [psn: device.psn, filename: file, vod_token: token]) 29 | end 30 | 31 | end 32 | -------------------------------------------------------------------------------- /mix.exs: -------------------------------------------------------------------------------- 1 | defmodule Blackvue.Mixfile do 2 | use Mix.Project 3 | 4 | def project do 5 | [app: :blackvue, 6 | version: "0.1.0", 7 | elixir: "~> 1.4", 8 | build_embedded: Mix.env == :prod, 9 | start_permanent: Mix.env == :prod, 10 | deps: deps()] 11 | end 12 | 13 | # Configuration for the OTP application 14 | # 15 | # Type "mix help compile.app" for more information 16 | def application do 17 | # Specify extra applications you'll use from Erlang/Elixir 18 | [extra_applications: [:logger], 19 | mod: {Blackvue.Application, []}] 20 | end 21 | 22 | # Dependencies can be Hex packages: 23 | # 24 | # {:my_dep, "~> 0.3.0"} 25 | # 26 | # Or git/path repositories: 27 | # 28 | # {:my_dep, git: "https://github.com/elixir-lang/my_dep.git", tag: "0.1.0"} 29 | # 30 | # Type "mix help deps" for more examples and options 31 | defp deps do 32 | [ 33 | {:httpoison, "~> 0.11"}, 34 | {:poison, "~> 3.1"}, 35 | {:ini, git: "https://github.com/nathanjohnson320/ini.git"} 36 | ] 37 | end 38 | end 39 | -------------------------------------------------------------------------------- /mix.lock: -------------------------------------------------------------------------------- 1 | %{"certifi": {:hex, :certifi, "1.0.0", "1c787a85b1855ba354f0b8920392c19aa1d06b0ee1362f9141279620a5be2039", [:rebar3], []}, 2 | "hackney": {:hex, :hackney, "1.6.6", "5564b4695d48fd87859e9df77a7fa4b4d284d24519f0cd7cc898f09e8fbdc8a3", [:rebar3], [{:certifi, "1.0.0", [hex: :certifi, optional: false]}, {:idna, "4.0.0", [hex: :idna, optional: false]}, {:metrics, "1.0.1", [hex: :metrics, optional: false]}, {:mimerl, "1.0.2", [hex: :mimerl, optional: false]}, {:ssl_verify_fun, "1.1.1", [hex: :ssl_verify_fun, optional: false]}]}, 3 | "httpoison": {:hex, :httpoison, "0.11.0", "b9240a9c44fc46fcd8618d17898859ba09a3c1b47210b74316c0ffef10735e76", [:mix], [{:hackney, "~> 1.6.3", [hex: :hackney, optional: false]}]}, 4 | "idna": {:hex, :idna, "4.0.0", "10aaa9f79d0b12cf0def53038547855b91144f1bfcc0ec73494f38bb7b9c4961", [:rebar3], []}, 5 | "ini": {:git, "https://github.com/nathanjohnson320/ini.git", "999080ef19843df9e007fbd096ce55e688ef2d92", []}, 6 | "metrics": {:hex, :metrics, "1.0.1", "25f094dea2cda98213cecc3aeff09e940299d950904393b2a29d191c346a8486", [:rebar3], []}, 7 | "mimerl": {:hex, :mimerl, "1.0.2", "993f9b0e084083405ed8252b99460c4f0563e41729ab42d9074fd5e52439be88", [:rebar3], []}, 8 | "poison": {:hex, :poison, "3.1.0", "d9eb636610e096f86f25d9a46f35a9facac35609a7591b3be3326e99a0484665", [:mix], []}, 9 | "ssl_verify_fun": {:hex, :ssl_verify_fun, "1.1.1", "28a4d65b7f59893bc2c7de786dec1e1555bd742d336043fe644ae956c3497fbe", [:make, :rebar], []}} 10 | -------------------------------------------------------------------------------- /test/blackvue_test.exs: -------------------------------------------------------------------------------- 1 | defmodule BlackvueTest do 2 | use ExUnit.Case 3 | doctest Blackvue 4 | 5 | test "the truth" do 6 | assert 1 + 1 == 2 7 | end 8 | end 9 | -------------------------------------------------------------------------------- /test/test_helper.exs: -------------------------------------------------------------------------------- 1 | ExUnit.start() 2 | --------------------------------------------------------------------------------