Compare commits

...

6 commits

31 changed files with 737 additions and 374 deletions

View file

@ -1,3 +1,3 @@
erlang 25.0.4 erlang 25.0.4
elixir 1.13.4-otp-25 elixir 1.14.3-otp-25
gleam 0.25.3 gleam 0.26.2

View file

@ -77,6 +77,13 @@ config :geo_therminator,
api_device_temp_set_reg_index: 3, api_device_temp_set_reg_index: 3,
api_device_reg_set_client_id: get_env("API_DEVICE_REG_SET_CLIENT_ID"), api_device_reg_set_client_id: get_env("API_DEVICE_REG_SET_CLIENT_ID"),
api_refresh: 10_000, 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 # Database directory for settings
db_dir: db_dir:

View file

@ -1,10 +1,10 @@
name = "geo_therminator" name = "geo_therminator"
version = "0.3.0" version = "0.4.0"
target = "erlang" target = "erlang"
[dependencies] [dependencies]
gleam_stdlib = "~> 0.25" gleam_stdlib = "~> 0.25"
gleam_http = "~> 3.1" gleam_http = "~> 3.1"
gleam_hackney = "~> 0.2.1"
gleam_erlang = "~> 0.17.1" gleam_erlang = "~> 0.17.1"
gleam_json = "~> 0.5.0" gleam_json = "~> 0.5.0"
finch_gleam = "~> 1.0"

View file

@ -75,7 +75,7 @@ defmodule GeoTherminator.PumpAPI.Auth.Server do
schedule_token_check() schedule_token_check()
if diff < @token_check_diff do if diff < @token_check_diff do
Logger.debug("Renewing auth token since #{diff} < #{@token_check_diff}") Logger.info("Renewing auth token since #{diff} < #{@token_check_diff}")
case init_state(state.username, state.password) do case init_state(state.username, state.password) do
{:ok, new_state} -> {:noreply, new_state} {:ok, new_state} -> {:noreply, new_state}
@ -121,7 +121,9 @@ defmodule GeoTherminator.PumpAPI.Auth.Server do
{:ok, state} {:ok, state}
else else
_ -> :error err ->
Logger.error("Could not auth or fetch installations! #{inspect(err)}")
:error
end end
end end
end end

View file

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

View file

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

View file

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

View file

@ -1,10 +1,12 @@
defmodule GeoTherminator.PumpAPI.Device.Server do defmodule GeoTherminator.PumpAPI.Device.Server do
require Logger
require GeoTherminator.PumpAPI.Auth.InstallationInfo
use GenServer use GenServer
import GeoTherminator.TypedStruct import GeoTherminator.TypedStruct
alias GeoTherminator.PumpAPI.Device alias GeoTherminator.PumpAPI.Device
alias GeoTherminator.PumpAPI.Auth.InstallationInfo alias GeoTherminator.PumpAPI.Auth.InstallationInfo
alias GeoTherminator.PumpAPI.Auth.Server, as: AuthServer alias GeoTherminator.PumpAPI.Auth.Server, as: AuthServer
require Logger
defmodule Options do defmodule Options do
deftypedstruct(%{ deftypedstruct(%{
@ -26,7 +28,7 @@ defmodule GeoTherminator.PumpAPI.Device.Server do
@spec start_link(Options.t()) :: GenServer.on_start() @spec start_link(Options.t()) :: GenServer.on_start()
def start_link(opts) do def start_link(opts) do
GenServer.start_link(__MODULE__, opts, GenServer.start_link(__MODULE__, opts,
name: {:via, Registry, {Device.Registry, opts.installation.id}} name: {:via, Registry, {Device.Registry, InstallationInfo.record(opts.installation, :id)}}
) )
end end
@ -38,8 +40,7 @@ defmodule GeoTherminator.PumpAPI.Device.Server do
@impl true @impl true
def handle_continue({:init, installation}, state) do def handle_continue({:init, installation}, state) do
user = AuthServer.get_auth(state.auth_server) 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) :ok = Device.PubSub.broadcast_device(device)
state = state =
@ -73,7 +74,7 @@ defmodule GeoTherminator.PumpAPI.Device.Server do
user = AuthServer.get_auth(state.auth_server) user = AuthServer.get_auth(state.auth_server)
Logger.debug("Begin set temp to #{temp}") 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)}") Logger.debug("Set temp result: #{inspect(resp)}")
{:reply, resp, state} {:reply, resp, state}
end end
@ -117,11 +118,15 @@ defmodule GeoTherminator.PumpAPI.Device.Server do
[status, registers, opstat] = [status, registers, opstat] =
Task.async_stream( 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), & &1.(user, state.device),
timeout: Application.get_env(:geo_therminator, :api_timeout) 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_status(state.device, status)
Device.PubSub.broadcast_registers(state.device, registers) Device.PubSub.broadcast_registers(state.device, registers)
@ -133,7 +138,7 @@ defmodule GeoTherminator.PumpAPI.Device.Server do
Process.send_after( Process.send_after(
self(), self(),
:refresh_status, :refresh_status,
Application.get_env(:geo_therminator, :api_refresh) Application.fetch_env!(:geo_therminator, :api_refresh)
) )
end end
end end

View file

@ -1,19 +1,54 @@
defmodule GeoTherminatorWeb.Components.MainView do 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 use GeoTherminatorWeb, :live_component
alias GeoTherminator.PumpAPI.Device
@impl true @impl true
def update(assigns, socket) do def update(assigns, socket) do
priority_set =
Device.OpStat.record(assigns.opstat, :priority) |> elem(1) |> Map.keys() |> MapSet.new()
{:ok, {:ok,
assign(socket, assign(socket,
set_temp: assigns.status.heating_effect, set_temp: Device.Status.record(assigns.status, :heating_effect),
set_temp_active: assigns.status.is_heating_effect_set_by_user, set_temp_active: Device.Status.record(assigns.status, :is_heating_effect_set_by_user),
hot_water_temp: assigns.registers.hot_water_temp.register_value, hot_water_temp:
brine_out: assigns.registers.brine_out.register_value, Device.Register.record(
brine_in: assigns.registers.brine_in.register_value, Device.RegisterCollection.record(assigns.registers, :hot_water_temp),
supply_out: assigns.registers.supply_out.register_value, :value
supply_in: assigns.registers.supply_in.register_value, ),
outdoor_temp: assigns.registers.outdoor_temp.register_value, brine_out:
priority: assigns.opstat.priority 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_set: priority_set
)} )}
end end
end end

View file

@ -86,7 +86,7 @@
pointer-events="none" pointer-events="none"
/> />
<%= if @priority == :heating do %> <%= if MapSet.member?(@priority_set, :heating) do %>
<ellipse <ellipse
cx="172" cx="172"
cy="289" cy="289"
@ -98,7 +98,7 @@
/> />
<% end %> <% end %>
<%= if @priority == :hot_water do %> <%= if MapSet.member?(@priority_set, :hot_water) do %>
<ellipse <ellipse
cx="100" cx="100"
cy="169" cy="169"

View file

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

View file

@ -1,6 +1,6 @@
<section id="main-section" class="page-container"> <section id="main-section" class="page-container">
<header> <header>
<h1>Welcome, <%= @user.first_name %>!</h1> <h1>Welcome!</h1>
</header> </header>
<section class="pumps"> <section class="pumps">
@ -8,8 +8,14 @@
<ul> <ul>
<%= for installation <- @installations do %> <%= for installation <- @installations do %>
<li> <li>
<%= live_redirect(installation.id, <%= live_redirect(
to: Routes.live_path(@socket, GeoTherminatorWeb.MainLive.Pump, installation.id) InstallationInfo.record(installation, :id),
to:
Routes.live_path(
@socket,
GeoTherminatorWeb.MainLive.Pump,
InstallationInfo.record(installation, :id)
)
) %> ) %>
</li> </li>
<% end %> <% end %>

View file

@ -1,4 +1,5 @@
defmodule GeoTherminatorWeb.MainLive.Index do defmodule GeoTherminatorWeb.MainLive.Index do
require Logger
use GeoTherminatorWeb, :live_view use GeoTherminatorWeb, :live_view
@impl Phoenix.LiveView @impl Phoenix.LiveView
@ -55,8 +56,9 @@ defmodule GeoTherminatorWeb.MainLive.Index do
) )
end end
_ -> err ->
assign(socket, error: true) Logger.debug("Error starting auth server: #{inspect(err)}")
assign(socket, error: err)
end end
end end
end end

View file

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

View file

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

View file

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

View file

@ -2,25 +2,25 @@
# You typically do not need to edit this file # You typically do not need to edit this file
packages = [ packages = [
{ name = "certifi", version = "2.9.0", build_tools = ["rebar3"], requirements = [], otp_app = "certifi", source = "hex", outer_checksum = "266DA46BDB06D6C6D35FDE799BCB28D36D985D424AD7C08B5BB48F5B5CDD4641" }, { name = "castore", version = "0.1.22", build_tools = ["mix"], requirements = [], otp_app = "castore", source = "hex", outer_checksum = "C17576DF47EB5AA1EE40CC4134316A99F5CAD3E215D5C77B8DD3CFEF12A22CAC" },
{ name = "finch", version = "0.14.0", build_tools = ["mix"], requirements = ["nimble_options", "mint", "mime", "nimble_pool", "castore", "telemetry"], otp_app = "finch", source = "hex", outer_checksum = "5459ACAF18C4FDB47A8C22FB3BAFF5D8173106217C8E56C5BA0B93E66501A8DD" },
{ name = "finch_gleam", version = "1.0.0", build_tools = ["gleam"], requirements = ["gleam_http", "finch", "gleam_stdlib", "gleam_erlang"], otp_app = "finch_gleam", source = "hex", outer_checksum = "42E0C02FC48184E7B50A6D81DDE955378460142B27542BE50965C15B2AFF6CA1" },
{ name = "gleam_erlang", version = "0.17.1", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_erlang", source = "hex", outer_checksum = "BAAA84F5BCC4477E809BA3E03BB3009A3894A6544C1511626C44408E39DB2AE6" }, { name = "gleam_erlang", version = "0.17.1", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_erlang", source = "hex", outer_checksum = "BAAA84F5BCC4477E809BA3E03BB3009A3894A6544C1511626C44408E39DB2AE6" },
{ name = "gleam_hackney", version = "0.2.1", build_tools = ["gleam"], requirements = ["gleam_stdlib", "hackney", "gleam_http"], otp_app = "gleam_hackney", source = "hex", outer_checksum = "CCACA00027C827436D8EB945651392B6E5798CFC9E69907A28BE61832B0C02A4" },
{ name = "gleam_http", version = "3.1.1", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_http", source = "hex", outer_checksum = "B66B7A1539CCB577119E4DC80DD3484C1A652CB032967954498EEDBAE3355763" }, { name = "gleam_http", version = "3.1.1", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_http", source = "hex", outer_checksum = "B66B7A1539CCB577119E4DC80DD3484C1A652CB032967954498EEDBAE3355763" },
{ name = "gleam_json", version = "0.5.0", build_tools = ["gleam"], requirements = ["gleam_stdlib", "thoas"], otp_app = "gleam_json", source = "hex", outer_checksum = "E42443C98AA66E30143C24818F2CEA801491C10CE6B1A5EDDF3FC4ABDC7601CB" }, { name = "gleam_json", version = "0.5.0", build_tools = ["gleam"], requirements = ["gleam_stdlib", "thoas"], otp_app = "gleam_json", source = "hex", outer_checksum = "E42443C98AA66E30143C24818F2CEA801491C10CE6B1A5EDDF3FC4ABDC7601CB" },
{ name = "gleam_stdlib", version = "0.25.0", build_tools = ["gleam"], requirements = [], otp_app = "gleam_stdlib", source = "hex", outer_checksum = "AD0F89928E0B919C8F8EDF640484633B28DBF88630A9E6AE504617A3E3E5B9A2" }, { name = "gleam_stdlib", version = "0.26.1", build_tools = ["gleam"], requirements = [], otp_app = "gleam_stdlib", source = "hex", outer_checksum = "B17BBE8A78F3909D93BCC6C24F531673A7E328A61F24222EB1E58D0A7552B1FE" },
{ name = "hackney", version = "1.18.1", build_tools = ["rebar3"], requirements = ["certifi", "mimerl", "parse_trans", "ssl_verify_fun", "unicode_util_compat", "metrics", "idna"], otp_app = "hackney", source = "hex", outer_checksum = "A4ECDAFF44297E9B5894AE499E9A070EA1888C84AFDD1FD9B7B2BC384950128E" }, { name = "hpax", version = "0.1.2", build_tools = ["mix"], requirements = [], otp_app = "hpax", source = "hex", outer_checksum = "2C87843D5A23F5F16748EBE77969880E29809580EFDACCD615CD3BED628A8C13" },
{ name = "idna", version = "6.1.1", build_tools = ["rebar3"], requirements = ["unicode_util_compat"], otp_app = "idna", source = "hex", outer_checksum = "92376EB7894412ED19AC475E4A86F7B413C1B9FBB5BD16DCCD57934157944CEA" }, { name = "mime", version = "2.0.3", build_tools = ["mix"], requirements = [], otp_app = "mime", source = "hex", outer_checksum = "27A30BF0DB44D25EECBA73755ACF4068CBFE26A4372F9EB3E4EA3A45956BFF6B" },
{ name = "metrics", version = "1.0.1", build_tools = ["rebar3"], requirements = [], otp_app = "metrics", source = "hex", outer_checksum = "69B09ADDDC4F74A40716AE54D140F93BEB0FB8978D8636EADED0C31B6F099F16" }, { name = "mint", version = "1.4.2", build_tools = ["mix"], requirements = ["hpax", "castore"], otp_app = "mint", source = "hex", outer_checksum = "CE75A5BBCC59B4D7D8D70F8B2FC284B1751FFB35C7B6A6302B5192F8AB4DDD80" },
{ name = "mimerl", version = "1.2.0", build_tools = ["rebar3"], requirements = [], otp_app = "mimerl", source = "hex", outer_checksum = "F278585650AA581986264638EBF698F8BB19DF297F66AD91B18910DFC6E19323" }, { name = "nimble_options", version = "0.5.2", build_tools = ["mix"], requirements = [], otp_app = "nimble_options", source = "hex", outer_checksum = "4DA7F904B915FD71DB549BCDC25F8D56F378EF7AE07DC1D372CBE72BA950DCE0" },
{ name = "parse_trans", version = "3.3.1", build_tools = ["rebar3"], requirements = [], otp_app = "parse_trans", source = "hex", outer_checksum = "07CD9577885F56362D414E8C4C4E6BDF10D43A8767ABB92D24CBE8B24C54888B" }, { name = "nimble_pool", version = "0.2.6", build_tools = ["mix"], requirements = [], otp_app = "nimble_pool", source = "hex", outer_checksum = "1C715055095D3F2705C4E236C18B618420A35490DA94149FF8B580A2144F653F" },
{ name = "ssl_verify_fun", version = "1.1.6", build_tools = ["mix", "rebar3", "make"], requirements = [], otp_app = "ssl_verify_fun", source = "hex", outer_checksum = "BDB0D2471F453C88FF3908E7686F86F9BE327D065CC1EC16FA4540197EA04680" }, { name = "telemetry", version = "1.2.1", build_tools = ["rebar3"], requirements = [], otp_app = "telemetry", source = "hex", outer_checksum = "DAD9CE9D8EFFC621708F99EAC538EF1CBE05D6A874DD741DE2E689C47FEAFED5" },
{ name = "thoas", version = "0.4.0", build_tools = ["rebar3"], requirements = [], otp_app = "thoas", source = "hex", outer_checksum = "442296847ACA11DB8D25180693D7CA3073D6D7179F66952F07B16415306513B6" }, { name = "thoas", version = "0.4.1", build_tools = ["rebar3"], requirements = [], otp_app = "thoas", source = "hex", outer_checksum = "4918D50026C073C4AB1388437132C77A6F6F7C8AC43C60C13758CC0ADCE2134E" },
{ name = "unicode_util_compat", version = "0.7.0", build_tools = ["rebar3"], requirements = [], otp_app = "unicode_util_compat", source = "hex", outer_checksum = "25EEE6D67DF61960CF6A794239566599B09E17E668D3700247BC498638152521" },
] ]
[requirements] [requirements]
finch_gleam = "~> 1.0"
gleam_erlang = "~> 0.17.1" gleam_erlang = "~> 0.17.1"
gleam_hackney = "~> 0.2.1"
gleam_http = "~> 3.1" gleam_http = "~> 3.1"
gleam_json = "~> 0.5.0" gleam_json = "~> 0.5.0"
gleam_stdlib = "~> 0.25" gleam_stdlib = "~> 0.25"

25
mix.exs
View file

@ -6,8 +6,8 @@ defmodule GeoTherminator.MixProject do
def project do def project do
[ [
app: @app, app: @app,
version: "0.3.0", version: "0.4.0",
elixir: "~> 1.13", elixir: "~> 1.14",
elixirc_paths: elixirc_paths(Mix.env()), elixirc_paths: elixirc_paths(Mix.env()),
erlc_paths: [ erlc_paths: [
"build/dev/erlang/#{@app}/_gleam_artefacts" "build/dev/erlang/#{@app}/_gleam_artefacts"
@ -38,7 +38,16 @@ defmodule GeoTherminator.MixProject do
def application do def application do
[ [
mod: {GeoTherminator.Application, []}, mod: {GeoTherminator.Application, []},
extra_applications: [:logger, :runtime_tools, :logger, :ssl, :crypto, :sasl, :tools, :inets] extra_applications: [
:logger,
:runtime_tools,
:logger,
:ssl,
:crypto,
:sasl,
:tools,
:inets
]
] ]
end end
@ -63,14 +72,14 @@ defmodule GeoTherminator.MixProject do
{:gettext, "~> 0.18"}, {:gettext, "~> 0.18"},
{:jason, "~> 1.2"}, {:jason, "~> 1.2"},
{:plug_cowboy, "~> 2.5"}, {:plug_cowboy, "~> 2.5"},
{:dotenv_parser, "~> 1.2"}, {:dotenv_parser, "~> 2.0"},
{:finch, "~> 0.9.0"}, {:finch, "~> 0.14.0"},
{:finch_gleam, "~> 1.0.0"},
{:desktop, "~> 1.4"}, {:desktop, "~> 1.4"},
{:cubdb, "~> 2.0"}, {:cubdb, "~> 2.0"},
{:gleam_stdlib, "~> 0.25"}, {:gleam_stdlib, "~> 0.26"},
{:gleam_http, "~> 3.1"}, {:gleam_http, "~> 3.1"},
{:gleam_hackney, "~> 0.2.1"}, {:gleam_erlang, "~> 0.18.0"},
{:gleam_erlang, "~> 0.17.1"},
{:gleam_json, "~> 0.5.0"} {:gleam_json, "~> 0.5.0"}
] ]

View file

@ -1,5 +1,5 @@
%{ %{
"castore": {:hex, :castore, "0.1.20", "62a0126cbb7cb3e259257827b9190f88316eb7aa3fdac01fd6f2dfd64e7f46e9", [:mix], [], "hexpm", "a020b7650529c986c454a4035b6b13a328e288466986307bea3aadb4c95ac98a"}, "castore": {:hex, :castore, "0.1.22", "4127549e411bedd012ca3a308dede574f43819fe9394254ca55ab4895abfa1a2", [:mix], [], "hexpm", "c17576df47eb5aa1ee40cc4134316a99f5cad3e215d5c77b8dd3cfef12a22cac"},
"certifi": {:hex, :certifi, "2.9.0", "6f2a475689dd47f19fb74334859d460a2dc4e3252a3324bd2111b8f0429e7e21", [:rebar3], [], "hexpm", "266da46bdb06d6c6d35fde799bcb28d36d985d424ad7c08b5bb48f5b5cdd4641"}, "certifi": {:hex, :certifi, "2.9.0", "6f2a475689dd47f19fb74334859d460a2dc4e3252a3324bd2111b8f0429e7e21", [:rebar3], [], "hexpm", "266da46bdb06d6c6d35fde799bcb28d36d985d424ad7c08b5bb48f5b5cdd4641"},
"cowboy": {:hex, :cowboy, "2.9.0", "865dd8b6607e14cf03282e10e934023a1bd8be6f6bacf921a7e2a96d800cd452", [:make, :rebar3], [{:cowlib, "2.11.0", [hex: :cowlib, repo: "hexpm", optional: false]}, {:ranch, "1.8.0", [hex: :ranch, repo: "hexpm", optional: false]}], "hexpm", "2c729f934b4e1aa149aff882f57c6372c15399a20d54f65c8d67bef583021bde"}, "cowboy": {:hex, :cowboy, "2.9.0", "865dd8b6607e14cf03282e10e934023a1bd8be6f6bacf921a7e2a96d800cd452", [:make, :rebar3], [{:cowlib, "2.11.0", [hex: :cowlib, repo: "hexpm", optional: false]}, {:ranch, "1.8.0", [hex: :ranch, repo: "hexpm", optional: false]}], "hexpm", "2c729f934b4e1aa149aff882f57c6372c15399a20d54f65c8d67bef583021bde"},
"cowboy_telemetry": {:hex, :cowboy_telemetry, "0.4.0", "f239f68b588efa7707abce16a84d0d2acf3a0f50571f8bb7f56a15865aae820c", [:rebar3], [{:cowboy, "~> 2.7", [hex: :cowboy, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "7d98bac1ee4565d31b62d59f8823dfd8356a169e7fcbb83831b8a5397404c9de"}, "cowboy_telemetry": {:hex, :cowboy_telemetry, "0.4.0", "f239f68b588efa7707abce16a84d0d2acf3a0f50571f8bb7f56a15865aae820c", [:rebar3], [{:cowboy, "~> 2.7", [hex: :cowboy, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "7d98bac1ee4565d31b62d59f8823dfd8356a169e7fcbb83831b8a5397404c9de"},
@ -7,21 +7,21 @@
"cubdb": {:hex, :cubdb, "2.0.2", "d4253885084dae37a8ff73887d232864eb38ecac962aa08543e686b0183a1d62", [:mix], [], "hexpm", "c99cc8f9e6c4deb98d16cca5ded1928edd22e48b4736b76e8a1a85367d7fe921"}, "cubdb": {:hex, :cubdb, "2.0.2", "d4253885084dae37a8ff73887d232864eb38ecac962aa08543e686b0183a1d62", [:mix], [], "hexpm", "c99cc8f9e6c4deb98d16cca5ded1928edd22e48b4736b76e8a1a85367d7fe921"},
"dbus": {:hex, :dbus, "0.8.0", "7c800681f35d909c199265e55a8ee4aea9ebe4acccce77a0740f89f29cc57648", [:make], [], "hexpm", "a9784f2d9717ffa1f74169144a226c39633ac0d9c7fe8cb3594aeb89c827cca5"}, "dbus": {:hex, :dbus, "0.8.0", "7c800681f35d909c199265e55a8ee4aea9ebe4acccce77a0740f89f29cc57648", [:make], [], "hexpm", "a9784f2d9717ffa1f74169144a226c39633ac0d9c7fe8cb3594aeb89c827cca5"},
"debouncer": {:hex, :debouncer, "0.1.7", "a7f59fb55cdb54072aff8ece461f4d041d2a709da84e07ed0ab302d348724640", [:mix], [], "hexpm", "b7fd0623df8ab16933bb164d19769884b18c98cab8677cd53eed59587f290603"}, "debouncer": {:hex, :debouncer, "0.1.7", "a7f59fb55cdb54072aff8ece461f4d041d2a709da84e07ed0ab302d348724640", [:mix], [], "hexpm", "b7fd0623df8ab16933bb164d19769884b18c98cab8677cd53eed59587f290603"},
"desktop": {:hex, :desktop, "1.4.2", "48cb5f02aa77522bd9996bfe02c4b23f8dc40d30076ada46b660f4a20bd7a3a1", [:mix], [{:debouncer, "~> 0.1", [hex: :debouncer, repo: "hexpm", optional: false]}, {:ex_sni, "~> 0.2", [hex: :ex_sni, repo: "hexpm", optional: false]}, {:gettext, "> 0.10.0", [hex: :gettext, repo: "hexpm", optional: false]}, {:oncrash, "~> 0.1", [hex: :oncrash, repo: "hexpm", optional: false]}, {:phoenix, "> 1.0.0", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_live_view, "> 0.15.0", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}, {:plug, "> 1.0.0", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "bcaee5daf0c547ed988d26d4bec04388f104ce169a4e255a786cd64598ce3362"}, "desktop": {:hex, :desktop, "1.4.3", "b2e7d35d6dcfa8da8cb7db810477b6356388fd3c93c24821b8187517691232d7", [:mix], [{:debouncer, "~> 0.1", [hex: :debouncer, repo: "hexpm", optional: false]}, {:ex_sni, "~> 0.2", [hex: :ex_sni, repo: "hexpm", optional: false]}, {:gettext, "> 0.10.0", [hex: :gettext, repo: "hexpm", optional: false]}, {:oncrash, "~> 0.1", [hex: :oncrash, repo: "hexpm", optional: false]}, {:phoenix, "> 1.0.0", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_live_view, "> 0.15.0", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}, {:plug, "> 1.0.0", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "215311885601b39c962c0e06ccb1edea94a0e53240ad118e6899b957845a6f1f"},
"dotenv_parser": {:hex, :dotenv_parser, "1.2.0", "f062900aeb57727b619aeb182fa4a8b1cbb7b4260ebec2b70b3d5c064885aff3", [:mix], [], "hexpm", "eddd69e7fde28618adb2e4153fa380db5c56161b32341e7a4e0530d86987c47f"}, "dotenv_parser": {:hex, :dotenv_parser, "2.0.0", "0f999196857e4ee18cbba1413018d5e4980ab16b397e3a2f8d0cf541fe683181", [:mix], [], "hexpm", "e769bde2dbff5b0cd0d9d877a9ccfd2c6dd84772dfb405d5a43cceb4f93616c5"},
"esbuild": {:hex, :esbuild, "0.6.0", "9ba6ead054abd43cb3d7b14946a0cdd1493698ccd8e054e0e5d6286d7f0f509c", [:mix], [{:castore, ">= 0.0.0", [hex: :castore, repo: "hexpm", optional: false]}], "hexpm", "30f9a05d4a5bab0d3e37398f312f80864e1ee1a081ca09149d06d474318fd040"}, "esbuild": {:hex, :esbuild, "0.6.1", "a774bfa7b4512a1211bf15880b462be12a4c48ed753a170c68c63b2c95888150", [:mix], [{:castore, ">= 0.0.0", [hex: :castore, repo: "hexpm", optional: false]}], "hexpm", "569f7409fb5a932211573fc20e2a930a0d5cf3377c5b4f6506c651b1783a1678"},
"ex_dbus": {:hex, :ex_dbus, "0.1.4", "053df83d45b27ba0b9b6ef55a47253922069a3ace12a2a7dd30d3aff58301e17", [:mix], [{:dbus, "~> 0.8.0", [hex: :dbus, repo: "hexpm", optional: false]}, {:saxy, "~> 1.4.0", [hex: :saxy, repo: "hexpm", optional: false]}], "hexpm", "d8baeaf465eab57b70a47b70e29fdfef6eb09ba110fc37176eebe6ac7874d6d5"}, "ex_dbus": {:hex, :ex_dbus, "0.1.4", "053df83d45b27ba0b9b6ef55a47253922069a3ace12a2a7dd30d3aff58301e17", [:mix], [{:dbus, "~> 0.8.0", [hex: :dbus, repo: "hexpm", optional: false]}, {:saxy, "~> 1.4.0", [hex: :saxy, repo: "hexpm", optional: false]}], "hexpm", "d8baeaf465eab57b70a47b70e29fdfef6eb09ba110fc37176eebe6ac7874d6d5"},
"ex_sni": {:hex, :ex_sni, "0.2.9", "81f9421035dd3edb6d69f1a4dd5f53c7071b41628130d32ba5ab7bb4bfdc2da0", [:mix], [{:debouncer, "~> 0.1", [hex: :debouncer, repo: "hexpm", optional: false]}, {:ex_dbus, "~> 0.1", [hex: :ex_dbus, repo: "hexpm", optional: false]}, {:saxy, "~> 1.4.0", [hex: :saxy, repo: "hexpm", optional: false]}], "hexpm", "921d67d913765ed20ea8354fd1798dabc957bf66990a6842d6aaa7cd5ee5bc06"}, "ex_sni": {:hex, :ex_sni, "0.2.9", "81f9421035dd3edb6d69f1a4dd5f53c7071b41628130d32ba5ab7bb4bfdc2da0", [:mix], [{:debouncer, "~> 0.1", [hex: :debouncer, repo: "hexpm", optional: false]}, {:ex_dbus, "~> 0.1", [hex: :ex_dbus, repo: "hexpm", optional: false]}, {:saxy, "~> 1.4.0", [hex: :saxy, repo: "hexpm", optional: false]}], "hexpm", "921d67d913765ed20ea8354fd1798dabc957bf66990a6842d6aaa7cd5ee5bc06"},
"expo": {:hex, :expo, "0.1.0", "d4e932bdad052c374118e312e35280f1919ac13881cb3ac07a209a54d0c81dd8", [:mix], [], "hexpm", "c22c536021c56de058aaeedeabb4744eb5d48137bacf8c29f04d25b6c6bbbf45"}, "expo": {:hex, :expo, "0.4.0", "bbe4bf455e2eb2ebd2f1e7d83530ce50fb9990eb88fc47855c515bfdf1c6626f", [:mix], [], "hexpm", "a8ed1683ec8b7c7fa53fd7a41b2c6935f539168a6bb0616d7fd6b58a36f3abf2"},
"file_system": {:hex, :file_system, "0.2.10", "fb082005a9cd1711c05b5248710f8826b02d7d1784e7c3451f9c1231d4fc162d", [:mix], [], "hexpm", "41195edbfb562a593726eda3b3e8b103a309b733ad25f3d642ba49696bf715dc"}, "file_system": {:hex, :file_system, "0.2.10", "fb082005a9cd1711c05b5248710f8826b02d7d1784e7c3451f9c1231d4fc162d", [:mix], [], "hexpm", "41195edbfb562a593726eda3b3e8b103a309b733ad25f3d642ba49696bf715dc"},
"finch": {:hex, :finch, "0.9.1", "ab2b0151ba88543e221cb50bf0734860db55e8748816ee16e4997fe205f7b315", [:mix], [{:castore, "~> 0.1", [hex: :castore, repo: "hexpm", optional: false]}, {:mint, "~> 1.3", [hex: :mint, repo: "hexpm", optional: false]}, {:nimble_options, "~> 0.4.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:nimble_pool, "~> 0.2", [hex: :nimble_pool, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "6d6b898a59d19f84958eaffec40580f5a9ff88a31e93156707fa8b1d552aa425"}, "finch": {:hex, :finch, "0.14.0", "619bfdee18fc135190bf590356c4bf5d5f71f916adb12aec94caa3fa9267a4bc", [:mix], [{:castore, "~> 0.1", [hex: :castore, repo: "hexpm", optional: false]}, {:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mint, "~> 1.3", [hex: :mint, repo: "hexpm", optional: false]}, {:nimble_options, "~> 0.4", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:nimble_pool, "~> 0.2.6", [hex: :nimble_pool, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "5459acaf18c4fdb47a8c22fb3baff5d8173106217c8e56c5ba0b93e66501a8dd"},
"floki": {:hex, :floki, "0.34.0", "002d0cc194b48794d74711731db004fafeb328fe676976f160685262d43706a8", [:mix], [], "hexpm", "9c3a9f43f40dde00332a589bd9d389b90c1f518aef500364d00636acc5ebc99c"}, "finch_gleam": {:hex, :finch_gleam, "1.0.0", "40a21698f005d03cb45a122322407422de383ef34cf64c43e443efbedb2897b7", [:gleam], [{:finch, "~> 0.14", [hex: :finch, repo: "hexpm", optional: false]}, {:gleam_erlang, "~> 0.17", [hex: :gleam_erlang, repo: "hexpm", optional: false]}, {:gleam_http, "~> 3.1", [hex: :gleam_http, repo: "hexpm", optional: false]}, {:gleam_stdlib, "~> 0.26", [hex: :gleam_stdlib, repo: "hexpm", optional: false]}], "hexpm", "42e0c02fc48184e7b50a6d81dde955378460142b27542be50965c15b2aff6ca1"},
"gettext": {:hex, :gettext, "0.21.0", "15bbceb20b317b706a8041061a08e858b5a189654128618b53746bf36c84352b", [:mix], [{:expo, "~> 0.1.0", [hex: :expo, repo: "hexpm", optional: false]}], "hexpm", "04a66db4103b6d1d18f92240bb2c73167b517229316b7bef84e4eebbfb2f14f6"}, "floki": {:hex, :floki, "0.34.1", "b1f9c413d91140230788b173906065f6f8906bbbf5b3f0d3c626301aeeef44c5", [:mix], [], "hexpm", "cc9b62312a45c1239ca8f65e05377ef8c646f3d7712e5727a9b47c43c946e885"},
"gleam_erlang": {:hex, :gleam_erlang, "0.17.1", "40fff501e8ca39fa166f4c12ed13bb57e94fc5bb59a93b4446687d82d4a12ff9", [:gleam], [{:gleam_stdlib, "~> 0.22", [hex: :gleam_stdlib, repo: "hexpm", optional: false]}], "hexpm", "baaa84f5bcc4477e809ba3e03bb3009a3894a6544c1511626c44408e39db2ae6"}, "gettext": {:hex, :gettext, "0.22.1", "e7942988383c3d9eed4bdc22fc63e712b655ae94a672a27e4900e3d4a2c43581", [:mix], [{:expo, "~> 0.4.0", [hex: :expo, repo: "hexpm", optional: false]}], "hexpm", "ad105b8dab668ee3f90c0d3d94ba75e9aead27a62495c101d94f2657a190ac5d"},
"gleam_hackney": {:hex, :gleam_hackney, "0.2.1", "ca3c5677b85f31885a4366c73a110803515d6d23a2e233e459dc164260315404", [:gleam], [{:gleam_http, "~> 3.0", [hex: :gleam_http, repo: "hexpm", optional: false]}, {:gleam_stdlib, "~> 0.18", [hex: :gleam_stdlib, repo: "hexpm", optional: false]}, {:hackney, "~> 1.18", [hex: :hackney, repo: "hexpm", optional: false]}], "hexpm", "ccaca00027c827436d8eb945651392b6e5798cfc9e69907a28be61832b0c02a4"}, "gleam_erlang": {:hex, :gleam_erlang, "0.18.0", "9810c548c9a9dbcbe0e170012a9255ec18add3efbf758d2c1b2108475182100a", [:gleam], [{:gleam_stdlib, "~> 0.22", [hex: :gleam_stdlib, repo: "hexpm", optional: false]}], "hexpm", "14abc93a7975369ccddc0c4d9a34c0e0fe99ca3ad336be6a17299ec0285b7107"},
"gleam_http": {:hex, :gleam_http, "3.1.1", "609158240630e21fc70c69b21384e5ebbcd86f71bd378a6f7c2b87f910ab3561", [:gleam], [{:gleam_stdlib, "~> 0.18", [hex: :gleam_stdlib, repo: "hexpm", optional: false]}], "hexpm", "b66b7a1539ccb577119e4dc80dd3484c1a652cb032967954498eedbae3355763"}, "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_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.1", "adba364f7ce31dfa456d3d542392eb9173e6f80f925beaf0009070b75b86ce71", [:gleam], [], "hexpm", "b17bbe8a78f3909d93bcc6c24f531673a7e328a61f24222eb1e58d0a7552b1fe"},
"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"}, "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"}, "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"}, "idna": {:hex, :idna, "6.1.1", "8a63070e9f7d0c62eb9d9fcb360a7de382448200fbbd1b106cc96d3d8099df8d", [:rebar3], [{:unicode_util_compat, "~>0.7.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "92376eb7894412ed19ac475e4a86f7b413c1b9fbb5bd16dccd57934157944cea"},
@ -30,17 +30,17 @@
"mime": {:hex, :mime, "2.0.3", "3676436d3d1f7b81b5a2d2bd8405f412c677558c81b1c92be58c00562bb59095", [:mix], [], "hexpm", "27a30bf0db44d25eecba73755acf4068cbfe26a4372f9eb3e4ea3a45956bff6b"}, "mime": {:hex, :mime, "2.0.3", "3676436d3d1f7b81b5a2d2bd8405f412c677558c81b1c92be58c00562bb59095", [:mix], [], "hexpm", "27a30bf0db44d25eecba73755acf4068cbfe26a4372f9eb3e4ea3a45956bff6b"},
"mimerl": {:hex, :mimerl, "1.2.0", "67e2d3f571088d5cfd3e550c383094b47159f3eee8ffa08e64106cdf5e981be3", [:rebar3], [], "hexpm", "f278585650aa581986264638ebf698f8bb19df297f66ad91b18910dfc6e19323"}, "mimerl": {:hex, :mimerl, "1.2.0", "67e2d3f571088d5cfd3e550c383094b47159f3eee8ffa08e64106cdf5e981be3", [:rebar3], [], "hexpm", "f278585650aa581986264638ebf698f8bb19df297f66ad91b18910dfc6e19323"},
"mint": {:hex, :mint, "1.4.2", "50330223429a6e1260b2ca5415f69b0ab086141bc76dc2fbf34d7c389a6675b2", [:mix], [{:castore, "~> 0.1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:hpax, "~> 0.1.1", [hex: :hpax, repo: "hexpm", optional: false]}], "hexpm", "ce75a5bbcc59b4d7d8d70f8b2fc284b1751ffb35c7b6a6302b5192f8ab4ddd80"}, "mint": {:hex, :mint, "1.4.2", "50330223429a6e1260b2ca5415f69b0ab086141bc76dc2fbf34d7c389a6675b2", [:mix], [{:castore, "~> 0.1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:hpax, "~> 0.1.1", [hex: :hpax, repo: "hexpm", optional: false]}], "hexpm", "ce75a5bbcc59b4d7d8d70f8b2fc284b1751ffb35c7b6a6302b5192f8ab4ddd80"},
"nimble_options": {:hex, :nimble_options, "0.4.0", "c89babbab52221a24b8d1ff9e7d838be70f0d871be823165c94dd3418eea728f", [:mix], [], "hexpm", "e6701c1af326a11eea9634a3b1c62b475339ace9456c1a23ec3bc9a847bca02d"}, "nimble_options": {:hex, :nimble_options, "0.5.2", "42703307b924880f8c08d97719da7472673391905f528259915782bb346e0a1b", [:mix], [], "hexpm", "4da7f904b915fd71db549bcdc25f8d56f378ef7ae07dc1d372cbe72ba950dce0"},
"nimble_pool": {:hex, :nimble_pool, "0.2.6", "91f2f4c357da4c4a0a548286c84a3a28004f68f05609b4534526871a22053cde", [:mix], [], "hexpm", "1c715055095d3f2705c4e236c18b618420a35490da94149ff8b580a2144f653f"}, "nimble_pool": {:hex, :nimble_pool, "0.2.6", "91f2f4c357da4c4a0a548286c84a3a28004f68f05609b4534526871a22053cde", [:mix], [], "hexpm", "1c715055095d3f2705c4e236c18b618420a35490da94149ff8b580a2144f653f"},
"oncrash": {:hex, :oncrash, "0.1.0", "9cf4ae8eba4ea250b579470172c5e9b8c75418b2264de7dbcf42e408d62e30fb", [:mix], [], "hexpm", "6968e775491cd857f9b6ff940bf2574fd1c2fab84fa7e14d5f56c39174c00018"}, "oncrash": {:hex, :oncrash, "0.1.0", "9cf4ae8eba4ea250b579470172c5e9b8c75418b2264de7dbcf42e408d62e30fb", [:mix], [], "hexpm", "6968e775491cd857f9b6ff940bf2574fd1c2fab84fa7e14d5f56c39174c00018"},
"parse_trans": {:hex, :parse_trans, "3.3.1", "16328ab840cc09919bd10dab29e431da3af9e9e7e7e6f0089dd5a2d2820011d8", [:rebar3], [], "hexpm", "07cd9577885f56362d414e8c4c4e6bdf10d43a8767abb92d24cbe8b24c54888b"}, "parse_trans": {:hex, :parse_trans, "3.3.1", "16328ab840cc09919bd10dab29e431da3af9e9e7e7e6f0089dd5a2d2820011d8", [:rebar3], [], "hexpm", "07cd9577885f56362d414e8c4c4e6bdf10d43a8767abb92d24cbe8b24c54888b"},
"phoenix": {:hex, :phoenix, "1.6.15", "0a1d96bbc10747fd83525370d691953cdb6f3ccbac61aa01b4acb012474b047d", [:mix], [{:castore, ">= 0.0.0", [hex: :castore, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 2.0", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 1.0 or ~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: false]}, {:plug, "~> 1.10", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.2", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:plug_crypto, "~> 1.2", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "d70ab9fbf6b394755ea88b644d34d79d8b146e490973151f248cacd122d20672"}, "phoenix": {:hex, :phoenix, "1.6.16", "e5bdd18c7a06da5852a25c7befb72246de4ddc289182285f8685a40b7b5f5451", [:mix], [{:castore, ">= 0.0.0", [hex: :castore, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 2.0", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 1.0 or ~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: false]}, {:plug, "~> 1.10", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.2", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:plug_crypto, "~> 1.2", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "e15989ff34f670a96b95ef6d1d25bad0d9c50df5df40b671d8f4a669e050ac39"},
"phoenix_html": {:hex, :phoenix_html, "3.2.0", "1c1219d4b6cb22ac72f12f73dc5fad6c7563104d083f711c3fcd8551a1f4ae11", [:mix], [{:plug, "~> 1.5", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "36ec97ba56d25c0136ef1992c37957e4246b649d620958a1f9fa86165f8bc54f"}, "phoenix_html": {:hex, :phoenix_html, "3.3.0", "bf451c71ebdaac8d2f40d3b703435e819ccfbb9ff243140ca3bd10c155f134cc", [:mix], [{:plug, "~> 1.5", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "272c5c1533499f0132309936c619186480bafcc2246588f99a69ce85095556ef"},
"phoenix_live_dashboard": {:hex, :phoenix_live_dashboard, "0.6.5", "1495bb014be12c9a9252eca04b9af54246f6b5c1e4cd1f30210cd00ec540cf8e", [:mix], [{:ecto, "~> 3.6.2 or ~> 3.7", [hex: :ecto, repo: "hexpm", optional: true]}, {:ecto_mysql_extras, "~> 0.3", [hex: :ecto_mysql_extras, repo: "hexpm", optional: true]}, {:ecto_psql_extras, "~> 0.7", [hex: :ecto_psql_extras, repo: "hexpm", optional: true]}, {:mime, "~> 1.6 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 0.17.7", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}, {:telemetry_metrics, "~> 0.6.0", [hex: :telemetry_metrics, repo: "hexpm", optional: false]}], "hexpm", "ef4fa50dd78364409039c99cf6f98ab5209b4c5f8796c17f4db118324f0db852"}, "phoenix_live_dashboard": {:hex, :phoenix_live_dashboard, "0.6.5", "1495bb014be12c9a9252eca04b9af54246f6b5c1e4cd1f30210cd00ec540cf8e", [:mix], [{:ecto, "~> 3.6.2 or ~> 3.7", [hex: :ecto, repo: "hexpm", optional: true]}, {:ecto_mysql_extras, "~> 0.3", [hex: :ecto_mysql_extras, repo: "hexpm", optional: true]}, {:ecto_psql_extras, "~> 0.7", [hex: :ecto_psql_extras, repo: "hexpm", optional: true]}, {:mime, "~> 1.6 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 0.17.7", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}, {:telemetry_metrics, "~> 0.6.0", [hex: :telemetry_metrics, repo: "hexpm", optional: false]}], "hexpm", "ef4fa50dd78364409039c99cf6f98ab5209b4c5f8796c17f4db118324f0db852"},
"phoenix_live_reload": {:hex, :phoenix_live_reload, "1.4.1", "2aff698f5e47369decde4357ba91fc9c37c6487a512b41732818f2204a8ef1d3", [:mix], [{:file_system, "~> 0.2.1 or ~> 0.3", [hex: :file_system, repo: "hexpm", optional: false]}, {:phoenix, "~> 1.4", [hex: :phoenix, repo: "hexpm", optional: false]}], "hexpm", "9bffb834e7ddf08467fe54ae58b5785507aaba6255568ae22b4d46e2bb3615ab"}, "phoenix_live_reload": {:hex, :phoenix_live_reload, "1.4.1", "2aff698f5e47369decde4357ba91fc9c37c6487a512b41732818f2204a8ef1d3", [:mix], [{:file_system, "~> 0.2.1 or ~> 0.3", [hex: :file_system, repo: "hexpm", optional: false]}, {:phoenix, "~> 1.4", [hex: :phoenix, repo: "hexpm", optional: false]}], "hexpm", "9bffb834e7ddf08467fe54ae58b5785507aaba6255568ae22b4d46e2bb3615ab"},
"phoenix_live_view": {:hex, :phoenix_live_view, "0.17.12", "74f4c0ad02d7deac2d04f50b52827a5efdc5c6e7fac5cede145f5f0e4183aedc", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix, "~> 1.6.0 or ~> 1.7.0", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 3.1", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "af6dd5e0aac16ff43571f527a8e0616d62cb80b10eb87aac82170243e50d99c8"}, "phoenix_live_view": {:hex, :phoenix_live_view, "0.17.14", "5ec615d4d61bf9d4755f158bd6c80372b715533fe6d6219e12d74fb5eedbeac1", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix, "~> 1.6.0 or ~> 1.7.0", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 3.1", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "afeb6ba43ce329a6f7fc1c9acdfc6d3039995345f025febb7f409a92f6faebd3"},
"phoenix_pubsub": {:hex, :phoenix_pubsub, "2.1.1", "ba04e489ef03763bf28a17eb2eaddc2c20c6d217e2150a61e3298b0f4c2012b5", [:mix], [], "hexpm", "81367c6d1eea5878ad726be80808eb5a787a23dee699f96e72b1109c57cdd8d9"}, "phoenix_pubsub": {:hex, :phoenix_pubsub, "2.1.1", "ba04e489ef03763bf28a17eb2eaddc2c20c6d217e2150a61e3298b0f4c2012b5", [:mix], [], "hexpm", "81367c6d1eea5878ad726be80808eb5a787a23dee699f96e72b1109c57cdd8d9"},
"phoenix_template": {:hex, :phoenix_template, "1.0.0", "c57bc5044f25f007dc86ab21895688c098a9f846a8dda6bc40e2d0ddc146e38f", [:mix], [{:phoenix_html, "~> 2.14.2 or ~> 3.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}], "hexpm", "1b066f99a26fd22064c12b2600a9a6e56700f591bf7b20b418054ea38b4d4357"}, "phoenix_template": {:hex, :phoenix_template, "1.0.1", "85f79e3ad1b0180abb43f9725973e3b8c2c3354a87245f91431eec60553ed3ef", [:mix], [{:phoenix_html, "~> 2.14.2 or ~> 3.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}], "hexpm", "157dc078f6226334c91cb32c1865bf3911686f8bcd6bcff86736f6253e6993ee"},
"phoenix_view": {:hex, :phoenix_view, "2.0.2", "6bd4d2fd595ef80d33b439ede6a19326b78f0f1d8d62b9a318e3d9c1af351098", [:mix], [{:phoenix_html, "~> 2.14.2 or ~> 3.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}], "hexpm", "a929e7230ea5c7ee0e149ffcf44ce7cf7f4b6d2bfe1752dd7c084cdff152d36f"}, "phoenix_view": {:hex, :phoenix_view, "2.0.2", "6bd4d2fd595ef80d33b439ede6a19326b78f0f1d8d62b9a318e3d9c1af351098", [:mix], [{:phoenix_html, "~> 2.14.2 or ~> 3.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}], "hexpm", "a929e7230ea5c7ee0e149ffcf44ce7cf7f4b6d2bfe1752dd7c084cdff152d36f"},
"plug": {:hex, :plug, "1.14.0", "ba4f558468f69cbd9f6b356d25443d0b796fbdc887e03fa89001384a9cac638f", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.1.1 or ~> 1.2", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "bf020432c7d4feb7b3af16a0c2701455cbbbb95e5b6866132cb09eb0c29adc14"}, "plug": {:hex, :plug, "1.14.0", "ba4f558468f69cbd9f6b356d25443d0b796fbdc887e03fa89001384a9cac638f", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.1.1 or ~> 1.2", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "bf020432c7d4feb7b3af16a0c2701455cbbbb95e5b6866132cb09eb0c29adc14"},
"plug_cowboy": {:hex, :plug_cowboy, "2.6.0", "d1cf12ff96a1ca4f52207c5271a6c351a4733f413803488d75b70ccf44aebec2", [:mix], [{:cowboy, "~> 2.7", [hex: :cowboy, repo: "hexpm", optional: false]}, {:cowboy_telemetry, "~> 0.3", [hex: :cowboy_telemetry, repo: "hexpm", optional: false]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "073cf20b753ce6682ed72905cd62a2d4bd9bad1bf9f7feb02a1b8e525bd94fa6"}, "plug_cowboy": {:hex, :plug_cowboy, "2.6.0", "d1cf12ff96a1ca4f52207c5271a6c351a4733f413803488d75b70ccf44aebec2", [:mix], [{:cowboy, "~> 2.7", [hex: :cowboy, repo: "hexpm", optional: false]}, {:cowboy_telemetry, "~> 0.3", [hex: :cowboy_telemetry, repo: "hexpm", optional: false]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "073cf20b753ce6682ed72905cd62a2d4bd9bad1bf9f7feb02a1b8e525bd94fa6"},
@ -48,9 +48,10 @@
"ranch": {:hex, :ranch, "1.8.0", "8c7a100a139fd57f17327b6413e4167ac559fbc04ca7448e9be9057311597a1d", [:make, :rebar3], [], "hexpm", "49fbcfd3682fab1f5d109351b61257676da1a2fdbe295904176d5e521a2ddfe5"}, "ranch": {:hex, :ranch, "1.8.0", "8c7a100a139fd57f17327b6413e4167ac559fbc04ca7448e9be9057311597a1d", [:make, :rebar3], [], "hexpm", "49fbcfd3682fab1f5d109351b61257676da1a2fdbe295904176d5e521a2ddfe5"},
"saxy": {:hex, :saxy, "1.4.0", "c7203ad20001f72eaaad07d08f82be063fa94a40924e6bb39d93d55f979abcba", [:mix], [], "hexpm", "3fe790354d3f2234ad0b5be2d99822a23fa2d4e8ccd6657c672901dac172e9a9"}, "saxy": {:hex, :saxy, "1.4.0", "c7203ad20001f72eaaad07d08f82be063fa94a40924e6bb39d93d55f979abcba", [:mix], [], "hexpm", "3fe790354d3f2234ad0b5be2d99822a23fa2d4e8ccd6657c672901dac172e9a9"},
"ssl_verify_fun": {:hex, :ssl_verify_fun, "1.1.6", "cf344f5692c82d2cd7554f5ec8fd961548d4fd09e7d22f5b62482e5aeaebd4b0", [:make, :mix, :rebar3], [], "hexpm", "bdb0d2471f453c88ff3908e7686f86f9be327d065cc1ec16fa4540197ea04680"}, "ssl_verify_fun": {:hex, :ssl_verify_fun, "1.1.6", "cf344f5692c82d2cd7554f5ec8fd961548d4fd09e7d22f5b62482e5aeaebd4b0", [:make, :mix, :rebar3], [], "hexpm", "bdb0d2471f453c88ff3908e7686f86f9be327d065cc1ec16fa4540197ea04680"},
"telemetry": {:hex, :telemetry, "1.2.0", "a8ce551485a9a3dac8d523542de130eafd12e40bbf76cf0ecd2528f24e812a44", [:rebar3], [], "hexpm", "1427e73667b9a2002cf1f26694c422d5c905df889023903c4518921d53e3e883"}, "telemetry": {:hex, :telemetry, "1.2.1", "68fdfe8d8f05a8428483a97d7aab2f268aaff24b49e0f599faa091f1d4e7f61c", [:rebar3], [], "hexpm", "dad9ce9d8effc621708f99eac538ef1cbe05d6a874dd741de2e689c47feafed5"},
"telemetry_metrics": {:hex, :telemetry_metrics, "0.6.1", "315d9163a1d4660aedc3fee73f33f1d355dcc76c5c3ab3d59e76e3edf80eef1f", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "7be9e0871c41732c233be71e4be11b96e56177bf15dde64a8ac9ce72ac9834c6"}, "telemetry_metrics": {:hex, :telemetry_metrics, "0.6.1", "315d9163a1d4660aedc3fee73f33f1d355dcc76c5c3ab3d59e76e3edf80eef1f", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "7be9e0871c41732c233be71e4be11b96e56177bf15dde64a8ac9ce72ac9834c6"},
"telemetry_poller": {:hex, :telemetry_poller, "1.0.0", "db91bb424e07f2bb6e73926fcafbfcbcb295f0193e0a00e825e589a0a47e8453", [:rebar3], [{:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "b3a24eafd66c3f42da30fc3ca7dda1e9d546c12250a2d60d7b81d264fbec4f6e"}, "telemetry_poller": {:hex, :telemetry_poller, "1.0.0", "db91bb424e07f2bb6e73926fcafbfcbcb295f0193e0a00e825e589a0a47e8453", [:rebar3], [{:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "b3a24eafd66c3f42da30fc3ca7dda1e9d546c12250a2d60d7b81d264fbec4f6e"},
"thoas": {:hex, :thoas, "0.4.0", "86a72ccdc5ec388a13f9f843bcd6c1076640233b95440e47ffb8e3c0dbdb5a17", [:rebar3], [], "hexpm", "442296847aca11db8d25180693d7ca3073d6d7179f66952f07b16415306513b6"}, "thoas": {:hex, :thoas, "0.4.1", "5471b049afd1c63b67b772a181a127b2c9aca398b4d1ae877ad49959109e50c4", [:rebar3], [], "hexpm", "4918d50026c073c4ab1388437132c77a6f6f7c8ac43c60c13758cc0adce2134e"},
"unicode_util_compat": {:hex, :unicode_util_compat, "0.7.0", "bc84380c9ab48177092f43ac89e4dfa2c6d62b40b8bd132b1059ecc7232f9a78", [:rebar3], [], "hexpm", "25eee6d67df61960cf6a794239566599b09e17e668d3700247bc498638152521"}, "unicode_util_compat": {:hex, :unicode_util_compat, "0.7.0", "bc84380c9ab48177092f43ac89e4dfa2c6d62b40b8bd132b1059ecc7232f9a78", [:rebar3], [], "hexpm", "25eee6d67df61960cf6a794239566599b09e17e668d3700247bc498638152521"},
"wx": {:hex, :bridge, "1.0.10", "5051dfe881e498a0bc056603df97b734bfe5fdc084f5e56dc42fab8049247144", [:mix], [{:jason, "~> 1.2", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "b30941e57a194e557a123c5246f123f8b13acdd5c580f75590a52e72bda15632"},
} }

View file

@ -2,10 +2,12 @@
//// Mostly translated from: //// Mostly translated from:
//// https://github.com/klejejs/python-thermia-online-api/blob/2f0ec4e45bfecbd90932a10247283cbcd6a6c48c/ThermiaOnlineAPI/api/ThermiaAPI.py //// https://github.com/klejejs/python-thermia-online-api/blob/2f0ec4e45bfecbd90932a10247283cbcd6a6c48c/ThermiaOnlineAPI/api/ThermiaAPI.py
//// Used under the Gnu General Public License 3.0 //// 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/json
import gleam/base import gleam/base
import gleam/hackney
import gleam/uri import gleam/uri
import gleam/http import gleam/http
import gleam/http/request import gleam/http/request
@ -16,20 +18,14 @@ import gleam/list
import gleam/result import gleam/result
import gleam/int import gleam/int
import azure/utils import azure/utils
import helpers/config
import helpers/crypto import helpers/crypto
import helpers/uri as uri_helpers import helpers/uri as uri_helpers
import helpers/parsing import helpers/parsing
import finch
const challenge_length = 43 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 = " const b2c_authorize_prefix = "var SETTINGS = "
pub type Tokens { pub type Tokens {
@ -73,10 +69,6 @@ pub fn authenticate(
get_tokens(confirmed, code_challenge) get_tokens(confirmed, code_challenge)
} }
pub fn refresh(tokens: Tokens) -> Result(Tokens, B2CError) {
todo
}
fn authorize(code_challenge: String) -> Result(AuthInfo, B2CError) { fn authorize(code_challenge: String) -> Result(AuthInfo, B2CError) {
let auth_data = [ let auth_data = [
#("response_type", "code"), #("response_type", "code"),
@ -186,7 +178,8 @@ fn confirm(
try resp = try resp =
req req
|> hackney.send() |> finch.build([])
|> finch.request(config.finch_server())
|> b2c_error("Confirm HTTP request failed.") |> b2c_error("Confirm HTTP request failed.")
try resp = case resp.status { try resp = case resp.status {
@ -248,25 +241,28 @@ fn hash_challenge(challenge: String) -> String {
base.url_encode64(hashed, False) base.url_encode64(hashed, False)
} }
fn authorize_url() -> String { fn authorize_url() -> uri.Uri {
b2c_auth_url <> "/oauth2/v2.0/authorize" let url = config.api_url("b2c_auth_url")
uri.Uri(..url, path: url.path <> "/oauth2/v2.0/authorize")
} }
fn self_asserted_url() -> String { fn self_asserted_url() -> uri.Uri {
b2c_auth_url <> "/SelfAsserted" let url = config.api_url("b2c_auth_url")
uri.Uri(..url, path: url.path <> "/SelfAsserted")
} }
fn confirm_url() -> String { fn confirm_url() -> uri.Uri {
b2c_auth_url <> "/api/CombinedSigninAndSignup/confirmed" let url = config.api_url("b2c_auth_url")
uri.Uri(..url, path: url.path <> "/api/CombinedSigninAndSignup/confirmed")
} }
fn get_token_url() -> String { fn get_token_url() -> uri.Uri {
b2c_auth_url <> "/oauth2/v2.0/token" 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) { fn build_req(url: uri.Uri, method: http.Method) -> request.Request(String) {
assert Ok(req_url) = uri.parse(url) assert Ok(req) = request.from_uri(url)
assert Ok(req) = request.from_uri(req_url)
let req = request.set_method(req, method) let req = request.set_method(req, method)
@ -286,7 +282,8 @@ fn run_req(
) -> Result(response.Response(String), B2CError) { ) -> Result(response.Response(String), B2CError) {
try resp = try resp =
req req
|> hackney.send() |> finch.build([])
|> finch.request(config.finch_server())
|> b2c_error("HTTP request failed.") |> b2c_error("HTTP request failed.")
case resp.status { case resp.status {
@ -301,10 +298,12 @@ fn run_req(
} }
fn base_request_data() -> List(#(String, String)) { fn base_request_data() -> List(#(String, String)) {
let b2c_client_id = config.str("b2c_client_id")
[ [
#("client_id", b2c_client_id), #("client_id", b2c_client_id),
#("scope", b2c_scope), #("scope", b2c_client_id),
#("redirect_uri", b2c_redirect_uri), #("redirect_uri", config.str("b2c_redirect_url")),
] ]
} }

View file

@ -3,9 +3,8 @@ import gleam/dynamic
import gleam/uri import gleam/uri
import helpers/application import helpers/application
fn app_name() -> atom.Atom { pub fn finch_server() -> atom.Atom {
assert Ok(name) = atom.from_string("geo_therminator") atom.create_from_string("Elixir.GeoTherminator.PumpAPI.HTTP")
name
} }
pub fn api_timeout() -> Int { pub fn api_timeout() -> Int {
@ -19,19 +18,35 @@ pub fn api_timeout() -> Int {
timeout_int timeout_int
} }
pub fn api_auth_url() -> uri.Uri { pub fn api_url(config_key: String) -> uri.Uri {
config_url("api_auth_url") map_api_url(config_key, fn(s) { s })
} }
pub fn api_installations_url() -> uri.Uri { pub fn map_api_url(config_key: String, mapper: fn(String) -> String) -> uri.Uri {
config_url("api_installations_url")
}
fn config_url(config_key: String) -> uri.Uri {
let url = let url =
application.fetch_env_angry(app_name(), atom.create_from_string(config_key)) application.fetch_env_angry(app_name(), atom.create_from_string(config_key))
assert Ok(url_str) = dynamic.string(url) assert Ok(url_str) = dynamic.string(url)
let url_str = mapper(url_str)
assert Ok(parsed_url) = uri.parse(url_str) assert Ok(parsed_url) = uri.parse(url_str)
parsed_url 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
}

View file

@ -1,9 +1,45 @@
import gleam
import gleam/erlang/atom.{Atom} import gleam/erlang/atom.{Atom}
import gleam/dynamic
pub type FromDynamicError {
NotAString
ParseError
}
pub external type DateTime 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" "Elixir.DateTime" "from_iso8601"
pub external fn from_unix(Int) -> Result(DateTime, #(Atom, Atom)) = pub external fn from_unix(Int) -> Result(DateTime, #(Atom, Atom)) =
"Elixir.DateTime" "from_unix" "Elixir.DateTime" "from_unix"
pub external fn utc_now() -> DateTime =
"Elixir.DateTime" "utc_now"
pub external fn to_unix(DateTime) -> Int =
"Elixir.DateTime" "to_unix"

12
src/helpers/keyword.gleam Normal file
View file

@ -0,0 +1,12 @@
import gleam/erlang/atom.{Atom}
pub external type Keyword
pub external fn init() -> Keyword =
"Elixir.Keyword" "new"
pub external fn put_int(data: Keyword, key: Atom, value: Int) -> Keyword =
"Elixir.Keyword" "put"
pub external fn put_string(data: Keyword, key: Atom, value: String) -> Keyword =
"Elixir.Keyword" "put"

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,6 +1,4 @@
import gleam/http/request import gleam/http/request
import gleam/json
import gleam/hackney
import gleam/result import gleam/result
import gleam/dynamic import gleam/dynamic
import gleam/list import gleam/list
@ -9,31 +7,29 @@ import pump_api/auth/user.{User}
import pump_api/auth/tokens.{Tokens} import pump_api/auth/tokens.{Tokens}
import pump_api/auth/installation_info.{InstallationInfo} import pump_api/auth/installation_info.{InstallationInfo}
import pump_api/http import pump_api/http
import pump_api/api.{ApiError, AuthError, InvalidData}
import helpers/config import helpers/config
import helpers/date_time import helpers/date_time
import helpers/parsing import helpers/parsing
import azure/b2c.{B2CError} import azure/b2c
pub type ApiError {
ApiRequestFailed
NotOkResponse
InvalidData(msg: String)
AuthError(inner: B2CError)
}
pub fn auth(username: String, password: String) -> Result(User, ApiError) { pub fn auth(username: String, password: String) -> Result(User, ApiError) {
try tokens = try tokens =
b2c.authenticate(username, password) b2c.authenticate(username, password)
|> result.map_error(fn(err) { AuthError(inner: err) }) |> result.map_error(fn(err) { AuthError(inner: err) })
try access_token_expires_in = try access_token_expiry =
date_time.from_unix(tokens.access_token_expires_in) date_time.from_unix(
date_time.to_unix(date_time.utc_now()) + tokens.access_token_expires_in,
)
|> result.replace_error(InvalidData( |> result.replace_error(InvalidData(
msg: "Access token expiry could not be converted into DateTime: " <> string.inspect( msg: "Access token expiry could not be converted into DateTime: " <> string.inspect(
tokens.access_token_expires_in, tokens.access_token_expires_in,
), ),
)) ))
try refresh_token_expires_in = try refresh_token_expiry =
date_time.from_unix(tokens.refresh_token_expires_in) date_time.from_unix(
date_time.to_unix(date_time.utc_now()) + tokens.refresh_token_expires_in,
)
|> result.replace_error(InvalidData( |> result.replace_error(InvalidData(
msg: "Refresh token expiry could not be converted into DateTime: " <> string.inspect( msg: "Refresh token expiry could not be converted into DateTime: " <> string.inspect(
tokens.refresh_token_expires_in, tokens.refresh_token_expires_in,
@ -42,18 +38,22 @@ pub fn auth(username: String, password: String) -> Result(User, ApiError) {
Ok(User(tokens: Tokens( Ok(User(tokens: Tokens(
access_token: tokens.access_token, access_token: tokens.access_token,
access_token_expiry: access_token_expires_in, access_token_expiry: access_token_expiry,
refresh_token: tokens.refresh_token, refresh_token: tokens.refresh_token,
refresh_token_expiry: refresh_token_expires_in, refresh_token_expiry: refresh_token_expiry,
))) )))
} }
pub fn installation_info(user: User) -> Result(List(InstallationInfo), 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) assert Ok(raw_req) = request.from_uri(url)
let empty_req = request.set_body(raw_req, http.Empty) 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 = try items =
parsing.data_get( parsing.data_get(
@ -65,21 +65,3 @@ pub fn installation_info(user: User) -> Result(List(InstallationInfo), ApiError)
Ok(list.map(items, fn(id) { InstallationInfo(id: id) })) Ok(list.map(items, fn(id) { InstallationInfo(id: id) }))
} }
fn run_req(req: request.Request(String)) {
try resp =
req
|> hackney.send()
|> 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 -> output
_ -> set.insert(output, priority)
}
},
)
}
external fn band(i1: Int, i2: Int) -> Int =
"erlang" "band"

View file

@ -1,6 +1,10 @@
import gleam/http/request import gleam/http/request
import gleam/json.{Json} import gleam/json.{Json}
import gleam/result
import gleam/dynamic
import pump_api/auth/user.{User} import pump_api/auth/user.{User}
import helpers/config
import finch
pub type Body { pub type Body {
Empty Empty
@ -11,6 +15,12 @@ pub type Body {
pub type ApiRequest = pub type ApiRequest =
request.Request(Body) request.Request(Body)
pub type ApiError {
RequestFailed
NotOkResponse
InvalidData
}
pub fn authed_req(user: User, r: ApiRequest) { pub fn authed_req(user: User, r: ApiRequest) {
r r
|> request.set_header("authorization", "Bearer " <> user.tokens.access_token) |> 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)) |> 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)
}