Reimplement the rest of the pump API core code in Gleam

This commit is contained in:
Mikko Ahlroth 2023-01-14 23:27:27 +02:00
parent bcba4a92b3
commit d7a05f3b1f
25 changed files with 633 additions and 319 deletions

View file

@ -77,6 +77,13 @@ config :geo_therminator,
api_device_temp_set_reg_index: 3,
api_device_reg_set_client_id: get_env("API_DEVICE_REG_SET_CLIENT_ID"),
api_refresh: 10_000,
b2c_client_id: get_env("B2C_CLIENT_ID", "09ea4903-9e95-45fe-ae1f-e3b7d32fa385"),
b2c_redirect_url: get_env("B2C_REDIRECT_URL", "https://online-genesis.thermia.se/login"),
b2c_auth_url:
get_env(
"B2C_AUTH_URL",
"https://thermialogin.b2clogin.com/thermialogin.onmicrosoft.com/b2c_1a_signuporsigninonline"
),
# Database directory for settings
db_dir:

View file

@ -8,11 +8,5 @@ defmodule GeoTherminator.PumpAPI.Auth.Tokens do
:refresh_token_expiry
])
@type t ::
record(:record,
access_token: String.t(),
access_token_expiry: DateTime.t(),
refresh_token: String.t(),
refresh_token_expiry: DateTime.t()
)
@type t :: :pump_api@auth@tokens.tokens()
end

View file

@ -3,5 +3,5 @@ defmodule GeoTherminator.PumpAPI.Auth.User do
Record.defrecord(:record, :user, [:tokens])
@type t :: record(:record, tokens: GeoTherminator.PumpAPI.Auth.Tokens.t())
@type t :: :pump_api@auth@user.user()
end

View file

@ -1,154 +0,0 @@
defmodule GeoTherminator.PumpAPI.Device.API do
require GeoTherminator.PumpAPI.Auth.InstallationInfo
alias GeoTherminator.PumpAPI.HTTP
alias GeoTherminator.PumpAPI.Device
alias GeoTherminator.PumpAPI.Auth
@spec device_info(Auth.User.t(), Auth.InstallationInfo.t()) :: Device.t()
def device_info(user, installation) do
url =
Application.get_env(:geo_therminator, :api_device_url)
|> String.replace("{id}", to_string(Auth.InstallationInfo.record(installation, :id)))
req = HTTP.authed_req(user, :get, url)
{:ok, response} = Finch.request(req, HTTP)
json = Jason.decode!(response.body)
last_online = NaiveDateTime.from_iso8601!(json["lastOnline"])
created_when = NaiveDateTime.from_iso8601!(json["createdWhen"])
%Device{
id: json["id"],
device_id: json["deviceId"],
is_online: json["isOnline"],
last_online: last_online,
created_when: created_when,
mac_address: json["macAddress"],
name: json["name"],
model: json["model"],
retailer_access: json["retailerAccess"]
}
end
@spec status(Auth.User.t(), Device.t()) :: Device.Status.t()
def status(user, device) do
url =
Application.get_env(:geo_therminator, :api_device_status_url)
|> String.replace("{id}", to_string(device.id))
req = HTTP.authed_req(user, :get, url)
{:ok, response} = Finch.request(req, HTTP)
json = Jason.decode!(response.body)
%Device.Status{
heating_effect: json["heatingEffect"],
is_heating_effect_set_by_user: json["isHeatingEffectSetByUser"]
}
end
@spec register_info(Auth.User.t(), Device.t()) :: Device.RegisterCollection.t()
def register_info(user, device) do
url =
Application.get_env(:geo_therminator, :api_device_register_url)
|> String.replace("{id}", to_string(device.id))
req = HTTP.authed_req(user, :get, url)
{:ok, response} = Finch.request(req, HTTP)
json = Jason.decode!(response.body)
registers =
Enum.map(json, fn item ->
{:ok, timestamp, _} = DateTime.from_iso8601(item["timeStamp"])
%Device.Register{
register_name: item["registerName"],
register_value: item["registerValue"],
timestamp: timestamp
}
end)
%Device.RegisterCollection{
outdoor_temp: find_register(registers, "REG_OUTDOOR_TEMPERATURE"),
supply_out: find_register(registers, "REG_SUPPLY_LINE"),
supply_in: find_register(registers, "REG_OPER_DATA_RETURN"),
desired_supply: find_register(registers, "REG_DESIRED_SYS_SUPPLY_LINE_TEMP"),
brine_out: find_register(registers, "REG_BRINE_OUT"),
brine_in: find_register(registers, "REG_BRINE_IN"),
hot_water_temp: find_register(registers, "REG_HOT_WATER_TEMPERATURE")
}
end
@spec opstat(Auth.User.t(), Device.t()) :: Device.OpStat.t()
def opstat(user, device) do
url =
Application.get_env(:geo_therminator, :api_device_opstat_url)
|> String.replace("{id}", to_string(device.id))
req = HTTP.authed_req(user, :get, url)
{:ok, response} = Finch.request(req, HTTP)
json = Jason.decode!(response.body)
registers =
Enum.map(json, fn item ->
{:ok, timestamp, _} = DateTime.from_iso8601(item["timeStamp"])
%Device.Register{
register_name: item["registerName"],
register_value: item["registerValue"],
timestamp: timestamp
}
end)
priority_register = find_register(registers, "REG_OPERATIONAL_STATUS_PRIO1")
priority_register_fallback =
find_register(registers, "REG_OPERATIONAL_STATUS_PRIORITY_BITMASK")
{mapping, register} =
if not is_nil(priority_register) do
{Application.get_env(:geo_therminator, :api_opstat_mapping), priority_register}
else
{Application.get_env(:geo_therminator, :api_opstat_bitmask_mapping),
priority_register_fallback}
end
%Device.OpStat{
priority: Map.get(mapping, register.register_value, :unknown)
}
end
@spec set_temp(Auth.User.t(), Device.t(), integer()) :: :ok | {:error, String.t()}
def set_temp(user, device, temp) do
url =
Application.get_env(:geo_therminator, :api_device_reg_set_url)
|> String.replace("{id}", to_string(device.id))
register_index = Application.get_env(:geo_therminator, :api_device_temp_set_reg_index)
client_id = Application.get_env(:geo_therminator, :api_device_reg_set_client_id)
req =
HTTP.authed_req(user, :post, url, [], %{
registerIndex: register_index,
registerValue: temp,
clientUuid: client_id
})
IO.inspect(req)
{:ok, response} = Finch.request(req, HTTP)
if response.status == 200 do
:ok
else
{:error, "Error #{response.status}: " <> response.body}
end
end
defp find_register(registers, name) do
Enum.find(registers, &(&1.register_name == name))
end
end

View file

@ -1,61 +1,50 @@
defmodule GeoTherminator.PumpAPI.Device do
import GeoTherminator.TypedStruct
require Record
deftypedstruct(%{
id: integer(),
device_id: integer(),
is_online: boolean(),
last_online: NaiveDateTime.t(),
created_when: NaiveDateTime.t(),
mac_address: String.t(),
name: String.t(),
model: String.t(),
retailer_access: integer()
})
Record.defrecord(:record, :device, [
:id,
:device_id,
:is_online,
:last_online,
:created_when,
:mac_address,
:name,
:model,
:retailer_access
])
@type t :: :pump_api@device.device()
defmodule Status do
deftypedstruct(%{
heating_effect: integer(),
is_heating_effect_set_by_user: boolean()
})
Record.defrecord(:record, :status, [:heating_effect, :is_heating_effect_set_by_user])
@type t :: :pump_api@device.status()
end
defmodule Register do
deftypedstruct(%{
register_name: String.t(),
register_value: integer(),
timestamp: DateTime.t()
})
Record.defrecord(:record, :register, [:name, :value, :timestamp])
@type t :: :pump_api@device.register()
end
defmodule RegisterCollection do
deftypedstruct(%{
outdoor_temp: Register.t(),
supply_out: Register.t(),
supply_in: Register.t(),
desired_supply: Register.t(),
brine_out: Register.t(),
brine_in: Register.t(),
hot_water_temp: Register.t()
})
Record.defrecord(:record, :register_collection, [
:outdoor_temp,
:supply_out,
:supply_in,
:desired_supply,
:brine_out,
:brine_in,
:hot_water_temp
])
@type t :: :pump_api@device.register_collection()
end
defmodule OpStat do
deftypedstruct(%{
priority:
:hand_operated
| :hot_water
| :heating
| :active_cooling
| :pool
| :anti_legionella
| :passive_cooling
| :standby
| :idle
| :off
| :defrost
| :unknown
})
Record.defrecord(:record, :op_stat, [:priority])
@type t :: :pump_api@device.op_stat()
end
@spec get_device_process(GenServer.name(), GeoTherminator.PumpAPI.Auth.InstallationInfo.t()) ::

View file

@ -1,8 +1,10 @@
defmodule GeoTherminator.PumpAPI.Device.PubSub do
require GeoTherminator.PumpAPI.Auth.InstallationInfo
require GeoTherminator.PumpAPI.Device
alias Phoenix.PubSub
alias GeoTherminator.PumpAPI.Auth.InstallationInfo
alias GeoTherminator.PumpAPI.Device
@installation_topic "installation:"
@ -20,7 +22,7 @@ defmodule GeoTherminator.PumpAPI.Device.PubSub do
:ok =
PubSub.broadcast!(
__MODULE__,
@installation_topic <> to_string(device.id),
@installation_topic <> to_string(Device.record(device, :id)),
{:device, device}
)
end
@ -33,7 +35,7 @@ defmodule GeoTherminator.PumpAPI.Device.PubSub do
:ok =
PubSub.broadcast!(
__MODULE__,
@installation_topic <> to_string(device.id),
@installation_topic <> to_string(Device.record(device, :id)),
{:status, status}
)
end
@ -46,7 +48,7 @@ defmodule GeoTherminator.PumpAPI.Device.PubSub do
:ok =
PubSub.broadcast!(
__MODULE__,
@installation_topic <> to_string(device.id),
@installation_topic <> to_string(Device.record(device, :id)),
{:registers, registers}
)
end
@ -59,7 +61,7 @@ defmodule GeoTherminator.PumpAPI.Device.PubSub do
:ok =
PubSub.broadcast!(
__MODULE__,
@installation_topic <> to_string(device.id),
@installation_topic <> to_string(Device.record(device, :id)),
{:opstat, opstat}
)
end

View file

@ -40,8 +40,7 @@ defmodule GeoTherminator.PumpAPI.Device.Server do
@impl true
def handle_continue({:init, installation}, state) do
user = AuthServer.get_auth(state.auth_server)
device = Device.API.device_info(user, installation)
{:ok, device} = :pump_api@device@api.device_info(user, installation)
:ok = Device.PubSub.broadcast_device(device)
state =
@ -75,7 +74,7 @@ defmodule GeoTherminator.PumpAPI.Device.Server do
user = AuthServer.get_auth(state.auth_server)
Logger.debug("Begin set temp to #{temp}")
resp = Device.API.set_temp(user, state.device, temp)
resp = :pump_api@device@api.set_temp(user, state.device, temp)
Logger.debug("Set temp result: #{inspect(resp)}")
{:reply, resp, state}
end
@ -119,11 +118,15 @@ defmodule GeoTherminator.PumpAPI.Device.Server do
[status, registers, opstat] =
Task.async_stream(
[&Device.API.status/2, &Device.API.register_info/2, &Device.API.opstat/2],
[
&:pump_api@device@api.status/2,
&:pump_api@device@api.register_info/2,
&:pump_api@device@api.opstat/2
],
& &1.(user, state.device),
timeout: Application.fetch_env!(:geo_therminator, :api_timeout)
)
|> Enum.map(fn {:ok, val} -> val end)
|> Enum.map(fn {:ok, {:ok, val}} -> val end)
Device.PubSub.broadcast_status(state.device, status)
Device.PubSub.broadcast_registers(state.device, registers)

View file

@ -1,19 +1,51 @@
defmodule GeoTherminatorWeb.Components.MainView do
require GeoTherminator.PumpAPI.Device
require GeoTherminator.PumpAPI.Device.Status
require GeoTherminator.PumpAPI.Device.RegisterCollection
require GeoTherminator.PumpAPI.Device.OpStat
require GeoTherminator.PumpAPI.Device.Register
use GeoTherminatorWeb, :live_component
alias GeoTherminator.PumpAPI.Device
@impl true
def update(assigns, socket) do
{:ok,
assign(socket,
set_temp: assigns.status.heating_effect,
set_temp_active: assigns.status.is_heating_effect_set_by_user,
hot_water_temp: assigns.registers.hot_water_temp.register_value,
brine_out: assigns.registers.brine_out.register_value,
brine_in: assigns.registers.brine_in.register_value,
supply_out: assigns.registers.supply_out.register_value,
supply_in: assigns.registers.supply_in.register_value,
outdoor_temp: assigns.registers.outdoor_temp.register_value,
priority: assigns.opstat.priority
set_temp: Device.Status.record(assigns.status, :heating_effect),
set_temp_active: Device.Status.record(assigns.status, :is_heating_effect_set_by_user),
hot_water_temp:
Device.Register.record(
Device.RegisterCollection.record(assigns.registers, :hot_water_temp),
:value
),
brine_out:
Device.Register.record(
Device.RegisterCollection.record(assigns.registers, :brine_out),
:value
),
brine_in:
Device.Register.record(
Device.RegisterCollection.record(assigns.registers, :brine_in),
:value
),
supply_out:
Device.Register.record(
Device.RegisterCollection.record(assigns.registers, :supply_out),
:value
),
supply_in:
Device.Register.record(
Device.RegisterCollection.record(assigns.registers, :supply_in),
:value
),
outdoor_temp:
Device.Register.record(
Device.RegisterCollection.record(assigns.registers, :outdoor_temp),
:value
),
priority: Device.OpStat.record(assigns.opstat, :priority)
)}
end
end

View file

@ -1,5 +1,7 @@
defmodule GeoTherminatorWeb.MainLive.DeviceList do
require GeoTherminator.PumpAPI.Auth.InstallationInfo
use GeoTherminatorWeb, :live_view
alias GeoTherminator.PumpAPI.Auth.InstallationInfo
@impl Phoenix.LiveView
def mount(_params, _session, socket) do

View file

@ -8,8 +8,14 @@
<ul>
<%= for installation <- @installations do %>
<li>
<%= live_redirect(installation.id,
to: Routes.live_path(@socket, GeoTherminatorWeb.MainLive.Pump, installation.id)
<%= live_redirect(
InstallationInfo.record(installation, :id),
to:
Routes.live_path(
@socket,
GeoTherminatorWeb.MainLive.Pump,
InstallationInfo.record(installation, :id)
)
) %>
</li>
<% end %>

View file

@ -58,7 +58,7 @@ defmodule GeoTherminatorWeb.MainLive.Index do
err ->
Logger.debug("Error starting auth server: #{inspect(err)}")
assign(socket, error: true)
assign(socket, error: err)
end
end
end

View file

@ -7,8 +7,8 @@
<label>Password <input type="password" name="password" /></label>
<button>Log in</button>
<%= if @error do %>
<div class="error">Error occurred, please try again.</div>
<%= if @error != false do %>
<div class="error">Error occurred, please try again: <%= inspect(@error) %></div>
<% end %>
</form>
<% else %>

View file

@ -1,4 +1,9 @@
defmodule GeoTherminatorWeb.MainLive.Pump do
require GeoTherminator.PumpAPI.Device
require GeoTherminator.PumpAPI.Device.Status
require GeoTherminator.PumpAPI.Device.RegisterCollection
require GeoTherminator.PumpAPI.Device.OpStat
require GeoTherminator.PumpAPI.Device.Register
use GeoTherminatorWeb, :live_view
alias GeoTherminator.PumpAPI.Auth
alias GeoTherminator.PumpAPI.Device
@ -11,10 +16,10 @@ defmodule GeoTherminatorWeb.MainLive.Pump do
info when info != nil <- Auth.Server.get_installation(Auth.Server, id),
:ok <- Device.PubSub.subscribe_installation(info),
{:ok, pid} <- Device.get_device_process(Auth.Server, info),
%Device{} = device <- Device.Server.get_device(pid),
%Device.Status{} = status <- Device.Server.get_status(pid),
%Device.RegisterCollection{} = registers <- Device.Server.get_registers(pid),
%Device.OpStat{} = opstat <- Device.Server.get_opstat(pid) do
device <- Device.Server.get_device(pid),
status <- Device.Server.get_status(pid),
registers <- Device.Server.get_registers(pid),
opstat <- Device.Server.get_opstat(pid) do
CubDB.put(GeoTherminator.DB, :viewing_pump, str_id)
assign(socket,
@ -37,14 +42,15 @@ defmodule GeoTherminatorWeb.MainLive.Pump do
def handle_event(event, unsigned_params, socket)
def handle_event("inc_temp", _params, socket) do
if not socket.assigns.status.is_heating_effect_set_by_user do
current = socket.assigns.status.heating_effect
if not Device.Status.record(socket.assigns.status, :is_heating_effect_set_by_user) do
current = Device.Status.record(socket.assigns.status, :heating_effect)
_ = Device.Server.set_temp(socket.assigns.pid, current + 1)
optimistic_status = %Device.Status{
socket.assigns.status
| is_heating_effect_set_by_user: true
}
optimistic_status =
Device.Status.record(
socket.assigns.status,
is_heating_effect_set_by_user: true
)
{:noreply, assign(socket, status: optimistic_status)}
else
@ -53,14 +59,15 @@ defmodule GeoTherminatorWeb.MainLive.Pump do
end
def handle_event("dec_temp", _params, socket) do
if not socket.assigns.status.is_heating_effect_set_by_user do
current = socket.assigns.status.heating_effect
if not Device.Status.record(socket.assigns.status, :is_heating_effect_set_by_user) do
current = Device.Status.record(socket.assigns.status, :heating_effect)
_ = Device.Server.set_temp(socket.assigns.pid, current - 1)
optimistic_status = %Device.Status{
socket.assigns.status
| is_heating_effect_set_by_user: true
}
optimistic_status =
Device.Status.record(
socket.assigns.status,
is_heating_effect_set_by_user: true
)
{:noreply, assign(socket, status: optimistic_status)}
else

View file

@ -6,7 +6,7 @@
<% else %>
<.live_component
module={GeoTherminatorWeb.Components.MainView}
id={"pump-#{@device.id}"}
id={"pump-#{Device.record(@device, :id)}"}
device={@device}
status={@status}
registers={@registers}

View file

@ -76,7 +76,7 @@ defmodule GeoTherminator.MixProject do
{:finch, "~> 0.9.0"},
{:desktop, "~> 1.4"},
{:cubdb, "~> 2.0"},
{:gleam_stdlib, "~> 0.25"},
{:gleam_stdlib, "~> 0.26"},
{:gleam_http, "~> 3.1"},
{:gleam_erlang, "~> 0.17.1"},
{:gleam_json, "~> 0.5.0"}

View file

@ -20,7 +20,7 @@
"gleam_erlang": {:hex, :gleam_erlang, "0.17.1", "40fff501e8ca39fa166f4c12ed13bb57e94fc5bb59a93b4446687d82d4a12ff9", [:gleam], [{:gleam_stdlib, "~> 0.22", [hex: :gleam_stdlib, repo: "hexpm", optional: false]}], "hexpm", "baaa84f5bcc4477e809ba3e03bb3009a3894a6544c1511626c44408e39db2ae6"},
"gleam_http": {:hex, :gleam_http, "3.1.1", "609158240630e21fc70c69b21384e5ebbcd86f71bd378a6f7c2b87f910ab3561", [:gleam], [{:gleam_stdlib, "~> 0.18", [hex: :gleam_stdlib, repo: "hexpm", optional: false]}], "hexpm", "b66b7a1539ccb577119e4dc80dd3484c1a652cb032967954498eedbae3355763"},
"gleam_json": {:hex, :gleam_json, "0.5.0", "aff4507ad7700ad794ada6671c6dfd0174696713659bd8782858135b19f41b58", [:gleam], [{:gleam_stdlib, "~> 0.19", [hex: :gleam_stdlib, repo: "hexpm", optional: false]}, {:thoas, "~> 0.2", [hex: :thoas, repo: "hexpm", optional: false]}], "hexpm", "e42443c98aa66e30143c24818f2cea801491c10ce6b1a5eddf3fc4abdc7601cb"},
"gleam_stdlib": {:hex, :gleam_stdlib, "0.25.0", "656f39258dcc8772719e463bbe7d1d1c7800238a520b41558fad53ea206ee3ab", [:gleam], [], "hexpm", "ad0f89928e0b919c8f8edf640484633b28dbf88630a9e6ae504617a3e3e5b9a2"},
"gleam_stdlib": {:hex, :gleam_stdlib, "0.26.0", "2f5814a495eeabfd0c63fe22bf26d054c544e641f3cfcf114ee74e91548272f7", [:gleam], [], "hexpm", "6221f9d7a08b6d6dbcdd567b2bb7c4b2a7bbf4c04c6110757be04635143bdec8"},
"hackney": {:hex, :hackney, "1.18.1", "f48bf88f521f2a229fc7bae88cf4f85adc9cd9bcf23b5dc8eb6a1788c662c4f6", [:rebar3], [{:certifi, "~>2.9.0", [hex: :certifi, repo: "hexpm", optional: false]}, {:idna, "~>6.1.0", [hex: :idna, repo: "hexpm", optional: false]}, {:metrics, "~>1.0.0", [hex: :metrics, repo: "hexpm", optional: false]}, {:mimerl, "~>1.1", [hex: :mimerl, repo: "hexpm", optional: false]}, {:parse_trans, "3.3.1", [hex: :parse_trans, repo: "hexpm", optional: false]}, {:ssl_verify_fun, "~>1.1.0", [hex: :ssl_verify_fun, repo: "hexpm", optional: false]}, {:unicode_util_compat, "~>0.7.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "a4ecdaff44297e9b5894ae499e9a070ea1888c84afdd1fd9b7b2bc384950128e"},
"hpax": {:hex, :hpax, "0.1.2", "09a75600d9d8bbd064cdd741f21fc06fc1f4cf3d0fcc335e5aa19be1a7235c84", [:mix], [], "hexpm", "2c87843d5a23f5f16748ebe77969880e29809580efdaccd615cd3bed628a8c13"},
"idna": {:hex, :idna, "6.1.1", "8a63070e9f7d0c62eb9d9fcb360a7de382448200fbbd1b106cc96d3d8099df8d", [:rebar3], [{:unicode_util_compat, "~>0.7.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "92376eb7894412ed19ac475e4a86f7b413c1b9fbb5bd16dccd57934157944cea"},

View file

@ -2,6 +2,9 @@
//// Mostly translated from:
//// https://github.com/klejejs/python-thermia-online-api/blob/2f0ec4e45bfecbd90932a10247283cbcd6a6c48c/ThermiaOnlineAPI/api/ThermiaAPI.py
//// Used under the Gnu General Public License 3.0
////
//// Refreshing using the refresh token is not implemented, we just get a new
//// access token every time, YOLO
import gleam/json
import gleam/base
@ -23,14 +26,6 @@ import helpers/parsing
const challenge_length = 43
const b2c_client_id = "09ea4903-9e95-45fe-ae1f-e3b7d32fa385"
const b2c_scope = b2c_client_id
const b2c_redirect_uri = "https://online-genesis.thermia.se/login"
const b2c_auth_url = "https://thermialogin.b2clogin.com/thermialogin.onmicrosoft.com/b2c_1a_signuporsigninonline"
const b2c_authorize_prefix = "var SETTINGS = "
pub type Tokens {
@ -74,10 +69,6 @@ pub fn authenticate(
get_tokens(confirmed, code_challenge)
}
pub fn refresh(tokens: Tokens) -> Result(Tokens, B2CError) {
todo
}
fn authorize(code_challenge: String) -> Result(AuthInfo, B2CError) {
let auth_data = [
#("response_type", "code"),
@ -250,25 +241,28 @@ fn hash_challenge(challenge: String) -> String {
base.url_encode64(hashed, False)
}
fn authorize_url() -> String {
b2c_auth_url <> "/oauth2/v2.0/authorize"
fn authorize_url() -> uri.Uri {
let url = config.api_url("b2c_auth_url")
uri.Uri(..url, path: url.path <> "/oauth2/v2.0/authorize")
}
fn self_asserted_url() -> String {
b2c_auth_url <> "/SelfAsserted"
fn self_asserted_url() -> uri.Uri {
let url = config.api_url("b2c_auth_url")
uri.Uri(..url, path: url.path <> "/SelfAsserted")
}
fn confirm_url() -> String {
b2c_auth_url <> "/api/CombinedSigninAndSignup/confirmed"
fn confirm_url() -> uri.Uri {
let url = config.api_url("b2c_auth_url")
uri.Uri(..url, path: url.path <> "/api/CombinedSigninAndSignup/confirmed")
}
fn get_token_url() -> String {
b2c_auth_url <> "/oauth2/v2.0/token"
fn get_token_url() -> uri.Uri {
let url = config.api_url("b2c_auth_url")
uri.Uri(..url, path: url.path <> "/oauth2/v2.0/token")
}
fn build_req(url: String, method: http.Method) -> request.Request(String) {
assert Ok(req_url) = uri.parse(url)
assert Ok(req) = request.from_uri(req_url)
fn build_req(url: uri.Uri, method: http.Method) -> request.Request(String) {
assert Ok(req) = request.from_uri(url)
let req = request.set_method(req, method)
@ -304,10 +298,12 @@ fn run_req(
}
fn base_request_data() -> List(#(String, String)) {
let b2c_client_id = config.str("b2c_client_id")
[
#("client_id", b2c_client_id),
#("scope", b2c_scope),
#("redirect_uri", b2c_redirect_uri),
#("scope", b2c_client_id),
#("redirect_uri", config.str("b2c_redirect_url")),
]
}

View file

@ -18,24 +18,35 @@ pub fn api_timeout() -> Int {
timeout_int
}
pub fn api_auth_url() -> uri.Uri {
config_url("api_auth_url")
pub fn api_url(config_key: String) -> uri.Uri {
map_api_url(config_key, fn(s) { s })
}
pub fn api_installations_url() -> uri.Uri {
config_url("api_installations_url")
pub fn map_api_url(config_key: String, mapper: fn(String) -> String) -> uri.Uri {
let url =
application.fetch_env_angry(app_name(), atom.create_from_string(config_key))
assert Ok(url_str) = dynamic.string(url)
let url_str = mapper(url_str)
assert Ok(parsed_url) = uri.parse(url_str)
parsed_url
}
pub fn str(config_key: String) -> String {
let val =
application.fetch_env_angry(app_name(), atom.create_from_string(config_key))
assert Ok(val_str) = dynamic.string(val)
val_str
}
pub fn int(config_key: String) -> Int {
let val =
application.fetch_env_angry(app_name(), atom.create_from_string(config_key))
assert Ok(val_int) = dynamic.int(val)
val_int
}
fn app_name() -> atom.Atom {
assert Ok(name) = atom.from_string("geo_therminator")
name
}
fn config_url(config_key: String) -> uri.Uri {
let url =
application.fetch_env_angry(app_name(), atom.create_from_string(config_key))
assert Ok(url_str) = dynamic.string(url)
assert Ok(parsed_url) = uri.parse(url_str)
parsed_url
}

View file

@ -1,8 +1,38 @@
import gleam
import gleam/erlang/atom.{Atom}
import gleam/dynamic
pub type FromDynamicError {
NotAString
ParseError
}
pub external type DateTime
pub external fn from_iso8601(String) -> Result(#(DateTime, Int), Atom) =
pub type DateTimeFromISO8601Result {
Ok(datetime: DateTime, offset: Int)
Error(reason: Atom)
}
pub fn from_dynamic_iso8601(
data: dynamic.Dynamic,
) -> Result(#(DateTime, Int), List(dynamic.DecodeError)) {
try data_str = dynamic.string(data)
case from_iso8601(data_str) {
Ok(datetime, offset) -> gleam.Ok(#(datetime, offset))
Error(_reason) ->
gleam.Error([
dynamic.DecodeError(
expected: "ISO-8601 formatted datetime with timezone",
found: data_str,
path: [],
),
])
}
}
pub external fn from_iso8601(String) -> DateTimeFromISO8601Result =
"Elixir.DateTime" "from_iso8601"
pub external fn from_unix(Int) -> Result(DateTime, #(Atom, Atom)) =

View file

@ -0,0 +1,22 @@
import gleam/dynamic
import gleam/erlang/atom.{Atom}
import gleam/result
pub external type NaiveDateTime
pub fn from_dynamic_iso8601(
data: dynamic.Dynamic,
) -> Result(NaiveDateTime, List(dynamic.DecodeError)) {
try data_str = dynamic.string(data)
from_iso8601(data_str)
|> result.replace_error([
dynamic.DecodeError(
expected: "ISO-8601 formatted naive datetime",
found: data_str,
path: [],
),
])
}
pub external fn from_iso8601(String) -> Result(NaiveDateTime, Atom) =
"Elixir.NaiveDateTime" "from_iso8601"

17
src/pump_api/api.gleam Normal file
View file

@ -0,0 +1,17 @@
import azure/b2c.{B2CError}
import pump_api/http
pub type ApiError {
RequestFailed
NotOkResponse
InvalidData(msg: String)
AuthError(inner: B2CError)
}
pub fn from_http(err: http.ApiError) -> ApiError {
case err {
http.RequestFailed -> RequestFailed
http.NotOkResponse -> NotOkResponse
http.InvalidData -> InvalidData("Invalid data in HTTP response.")
}
}

View file

@ -1,5 +1,4 @@
import gleam/http/request
import gleam/json
import gleam/result
import gleam/dynamic
import gleam/list
@ -8,18 +7,11 @@ import pump_api/auth/user.{User}
import pump_api/auth/tokens.{Tokens}
import pump_api/auth/installation_info.{InstallationInfo}
import pump_api/http
import pump_api/api.{ApiError, AuthError, InvalidData}
import helpers/config
import helpers/date_time
import helpers/parsing
import helpers/finch
import azure/b2c.{B2CError}
pub type ApiError {
ApiRequestFailed
NotOkResponse
InvalidData(msg: String)
AuthError(inner: B2CError)
}
import azure/b2c
pub fn auth(username: String, password: String) -> Result(User, ApiError) {
try tokens =
@ -49,11 +41,15 @@ pub fn auth(username: String, password: String) -> Result(User, ApiError) {
}
pub fn installation_info(user: User) -> Result(List(InstallationInfo), ApiError) {
let url = config.api_installations_url()
let url = config.api_url("api_installations_url")
assert Ok(raw_req) = request.from_uri(url)
let empty_req = request.set_body(raw_req, http.Empty)
try data = run_req(http.authed_req(user, empty_req))
try data =
http.run_json_req(http.authed_req(user, empty_req))
|> result.replace_error(InvalidData(
msg: "Could not parse InstallationInfo JSON.",
))
try items =
parsing.data_get(
@ -65,22 +61,3 @@ pub fn installation_info(user: User) -> Result(List(InstallationInfo), ApiError)
Ok(list.map(items, fn(id) { InstallationInfo(id: id) }))
}
fn run_req(req: request.Request(String)) {
try resp =
req
|> finch.build()
|> finch.request(config.finch_server())
|> result.replace_error(ApiRequestFailed)
try body = case resp.status {
200 -> Ok(resp.body)
_ -> Error(NotOkResponse)
}
body
|> json.decode(using: dynamic.dynamic)
|> result.replace_error(InvalidData(
msg: "Could not parse InstallationInfo JSON.",
))
}

56
src/pump_api/device.gleam Normal file
View file

@ -0,0 +1,56 @@
import gleam/set
import helpers/naive_date_time.{NaiveDateTime}
import helpers/date_time.{DateTime}
pub type Device {
Device(
id: Int,
device_id: Int,
is_online: Bool,
last_online: NaiveDateTime,
created_when: NaiveDateTime,
mac_address: String,
name: String,
model: String,
retailer_access: Int,
)
}
pub type Status {
Status(heating_effect: Int, is_heating_effect_set_by_user: Bool)
}
pub type Register {
Register(name: String, value: Int, timestamp: DateTime)
}
pub type RegisterCollection {
RegisterCollection(
outdoor_temp: Register,
supply_out: Register,
supply_in: Register,
desired_supply: Register,
brine_out: Register,
brine_in: Register,
hot_water_temp: Register,
)
}
pub type Priority {
HandOperated
HotWater
Heating
ActiveCooling
Pool
AntiLegionella
PassiveCooling
Standby
Idle
Off
Defrost
Unknown
}
pub type OpStat {
OpStat(priority: set.Set(Priority))
}

View file

@ -0,0 +1,286 @@
import gleam/http/request
import gleam/http as gleam_http
import gleam/string
import gleam/int
import gleam/dynamic
import gleam/result
import gleam/list
import gleam/map
import gleam/set
import gleam/json
import pump_api/auth/user.{User}
import pump_api/auth/installation_info.{InstallationInfo}
import pump_api/device.{
Device, OpStat, Priority, Register, RegisterCollection, Status,
}
import pump_api/http
import pump_api/api.{ApiError, InvalidData}
import helpers/config
import helpers/parsing
import helpers/naive_date_time
import helpers/date_time
const opstat_bitmask_mapping = [
#(1, device.HandOperated),
#(2, device.Defrost),
#(4, device.HotWater),
#(8, device.Heating),
#(16, device.ActiveCooling),
#(32, device.Pool),
#(64, device.AntiLegionella),
#(128, device.PassiveCooling),
#(512, device.Standby),
#(1024, device.Idle),
#(2048, device.Off),
]
pub fn device_info(
user: User,
installation: InstallationInfo,
) -> Result(Device, ApiError) {
let url =
config.map_api_url(
"api_device_url",
fn(url) { string.replace(url, "{id}", int.to_string(installation.id)) },
)
assert Ok(raw_req) = request.from_uri(url)
let empty_req = request.set_body(raw_req, http.Empty)
try data =
http.run_json_req(http.authed_req(user, empty_req))
|> result.map_error(api.from_http)
try last_online =
parsing.data_get(
data,
"lastOnline",
naive_date_time.from_dynamic_iso8601,
InvalidData,
)
try created_when =
parsing.data_get(
data,
"createdWhen",
naive_date_time.from_dynamic_iso8601,
InvalidData,
)
try id = parsing.data_get(data, "id", dynamic.int, InvalidData)
try device_id = parsing.data_get(data, "deviceId", dynamic.int, InvalidData)
try is_online = parsing.data_get(data, "isOnline", dynamic.bool, InvalidData)
try mac_address =
parsing.data_get(data, "macAddress", dynamic.string, InvalidData)
try name = parsing.data_get(data, "name", dynamic.string, InvalidData)
try model = parsing.data_get(data, "model", dynamic.string, InvalidData)
try retailer_access =
parsing.data_get(data, "retailerAccess", dynamic.int, InvalidData)
Ok(Device(
id: id,
device_id: device_id,
is_online: is_online,
last_online: last_online,
created_when: created_when,
mac_address: mac_address,
name: name,
model: model,
retailer_access: retailer_access,
))
}
pub fn status(user: User, device: Device) {
let url =
config.map_api_url(
"api_device_status_url",
fn(url) { string.replace(url, "{id}", int.to_string(device.id)) },
)
assert Ok(raw_req) = request.from_uri(url)
let empty_req = request.set_body(raw_req, http.Empty)
try data =
http.run_json_req(http.authed_req(user, empty_req))
|> result.map_error(api.from_http)
try heating_effect =
parsing.data_get(data, "heatingEffect", dynamic.int, InvalidData)
try is_heating_effect_set_by_user =
parsing.data_get(
data,
"isHeatingEffectSetByUser",
dynamic.bool,
InvalidData,
)
Ok(Status(
heating_effect: heating_effect,
is_heating_effect_set_by_user: is_heating_effect_set_by_user,
))
}
pub fn register_info(user: User, device: Device) {
let url =
config.map_api_url(
"api_device_register_url",
fn(url) { string.replace(url, "{id}", int.to_string(device.id)) },
)
assert Ok(raw_req) = request.from_uri(url)
let empty_req = request.set_body(raw_req, http.Empty)
try data =
http.run_json_req(http.authed_req(user, empty_req))
|> result.map_error(api.from_http)
try registers =
dynamic.list(parse_register)(data)
|> result.map_error(fn(err) {
InvalidData("Unable to parse registers: " <> string.inspect(err))
})
let registers_map =
registers
|> list.map(fn(r) { #(r.name, r) })
|> map.from_list()
try outdoor_temp = get_register(registers_map, "REG_OUTDOOR_TEMPERATURE")
try supply_out = get_register(registers_map, "REG_SUPPLY_LINE")
try supply_in = get_register(registers_map, "REG_OPER_DATA_RETURN")
try desired_supply =
get_register(registers_map, "REG_DESIRED_SYS_SUPPLY_LINE_TEMP")
try brine_out = get_register(registers_map, "REG_BRINE_OUT")
try brine_in = get_register(registers_map, "REG_BRINE_IN")
try hot_water_temp = get_register(registers_map, "REG_HOT_WATER_TEMPERATURE")
Ok(RegisterCollection(
outdoor_temp: outdoor_temp,
supply_out: supply_out,
supply_in: supply_in,
desired_supply: desired_supply,
brine_out: brine_out,
brine_in: brine_in,
hot_water_temp: hot_water_temp,
))
}
pub fn opstat(user: User, device: Device) {
let url =
config.map_api_url(
"api_device_opstat_url",
fn(url) { string.replace(url, "{id}", int.to_string(device.id)) },
)
assert Ok(raw_req) = request.from_uri(url)
let empty_req = request.set_body(raw_req, http.Empty)
try data =
http.run_json_req(http.authed_req(user, empty_req))
|> result.map_error(api.from_http)
try registers =
dynamic.list(parse_register)(data)
|> result.map_error(fn(err) {
InvalidData("Unable to parse registers: " <> string.inspect(err))
})
let registers_map =
registers
|> list.map(fn(r) { #(r.name, r) })
|> map.from_list()
let priority_register =
get_register(registers_map, "REG_OPERATIONAL_STATUS_PRIO1")
let priority_register_fallback =
get_register(registers_map, "REG_OPERATIONAL_STATUS_PRIORITY_BITMASK")
try priority = case #(priority_register, priority_register_fallback) {
#(Ok(data), _) -> Ok(opstat_map(data))
#(_, Ok(data)) -> Ok(opstat_bitmask_map(data))
_ ->
Error(InvalidData(
"Unable to parse opstat: " <> string.inspect(#(
priority_register,
priority_register_fallback,
)),
))
}
Ok(OpStat(priority: priority))
}
pub fn set_temp(user: User, device: Device, temp: Int) {
let url =
config.map_api_url(
"api_device_opstat_url",
fn(url) { string.replace(url, "{id}", int.to_string(device.id)) },
)
assert Ok(raw_req) = request.from_uri(url)
let register_index = config.int("api_device_temp_set_reg_index")
let client_id = config.str("api_device_reg_set_client_id")
let req =
raw_req
|> request.set_method(gleam_http.Post)
|> request.set_body(http.Json(data: json.object([
#("registerIndex", json.int(register_index)),
#("clientUuid", json.string(client_id)),
#("registerValue", json.int(temp)),
])))
http.run_req(http.authed_req(user, req))
}
fn parse_register(
item: dynamic.Dynamic,
) -> Result(Register, List(dynamic.DecodeError)) {
try #(timestamp, _) =
dynamic.field("timeStamp", date_time.from_dynamic_iso8601)(item)
try name = dynamic.field("registerName", dynamic.string)(item)
try value = dynamic.field("registerValue", dynamic.int)(item)
Ok(Register(timestamp: timestamp, name: name, value: value))
}
fn get_register(
data: map.Map(String, Register),
key: String,
) -> Result(Register, ApiError) {
map.get(data, key)
|> result.replace_error(InvalidData(
"Could not find " <> key <> " in data: " <> string.inspect(data),
))
}
fn opstat_map(register: Register) {
let val = case register.value {
1 -> device.HandOperated
3 -> device.HotWater
4 -> device.Heating
5 -> device.ActiveCooling
6 -> device.Pool
7 -> device.AntiLegionella
8 -> device.PassiveCooling
98 -> device.Standby
99 -> device.Idle
100 -> device.Off
_ -> device.Unknown
}
set.new()
|> set.insert(val)
}
fn opstat_bitmask_map(register: Register) {
let priority_set: set.Set(Priority) = set.new()
list.fold(
opstat_bitmask_mapping,
priority_set,
fn(output, mapping) {
let #(int, priority) = mapping
case band(register.value, int) {
0 -> set.insert(output, priority)
_ -> priority_set
}
},
)
}
external fn band(i1: Int, i2: Int) -> Int =
"erlang" "band"

View file

@ -1,6 +1,10 @@
import gleam/http/request
import gleam/json.{Json}
import gleam/result
import gleam/dynamic
import pump_api/auth/user.{User}
import helpers/finch
import helpers/config
pub type Body {
Empty
@ -11,6 +15,12 @@ pub type Body {
pub type ApiRequest =
request.Request(Body)
pub type ApiError {
RequestFailed
NotOkResponse
InvalidData
}
pub fn authed_req(user: User, r: ApiRequest) {
r
|> request.set_header("authorization", "Bearer " <> user.tokens.access_token)
@ -29,3 +39,24 @@ pub fn req(r: ApiRequest) -> request.Request(String) {
|> request.set_body(json.to_string(data))
}
}
pub fn run_req(req: request.Request(String)) {
try resp =
req
|> finch.build()
|> finch.request(config.finch_server())
|> result.replace_error(RequestFailed)
case resp.status {
200 -> Ok(resp.body)
_ -> Error(NotOkResponse)
}
}
pub fn run_json_req(req: request.Request(String)) {
try body = run_req(req)
body
|> json.decode(using: dynamic.dynamic)
|> result.replace_error(InvalidData)
}