Compare commits

...

9 commits

108 changed files with 3005 additions and 3505 deletions

View file

@ -1,5 +0,0 @@
[
import_deps: [:phoenix],
plugins: [Phoenix.LiveView.HTMLFormatter],
inputs: ["*.{heex,ex,exs}", "priv/*/seeds.exs", "{config,lib,test}/**/*.{heex,ex,exs}"]
]

43
.gitignore vendored
View file

@ -1,42 +1,11 @@
# The directory Mix will write compiled artifacts to.
/_build/
# Gleam build dir
/build/
# If you run "mix test --cover", coverage assets end up here.
/cover/
# The directory Mix downloads your dependencies sources to.
/deps/
# Where 3rd-party dependencies like ExDoc output generated docs.
/doc/
# Ignore .fetch files in case you like to edit your project deps locally.
/.fetch
# If the VM crashes, it generates a dump, let's ignore it too.
erl_crash.dump
# Also ignore archive artifacts (built via "mix archive.build").
*.ez
# Ignore package tarball (built via "mix hex.build").
geo_therminator-*.tar
# Ignore assets that are produced by build tools.
/priv/static/assets/
# Ignore digested assets cache.
/priv/static/cache_manifest.json
# In case you use Node.js/npm, you want to ignore these.
npm-debug.log
/assets/node_modules/
.env
# Test run file
src/geo_therminator.gleam
.elixir_ls
/old
/priv/config.mjs
/chrome-data-dir

View file

@ -1,3 +1,2 @@
erlang 25.0.4
elixir 1.13.4-otp-25
gleam 0.25.3
gleam 0.30.5
nodejs 18.16.0

View file

@ -1,110 +0,0 @@
/* This file is for your main application CSS */
@import "./phoenix.css";
/* Alerts and form errors used by phx.new */
.alert {
padding: 15px;
margin-bottom: 20px;
border: 1px solid transparent;
border-radius: 4px;
}
.alert-info {
color: #31708f;
background-color: #d9edf7;
border-color: #bce8f1;
}
.alert-warning {
color: #8a6d3b;
background-color: #fcf8e3;
border-color: #faebcc;
}
.alert-danger {
color: #a94442;
background-color: #f2dede;
border-color: #ebccd1;
}
.alert p {
margin-bottom: 0;
}
.alert:empty {
display: none;
}
.invalid-feedback {
color: #a94442;
display: block;
margin: -1rem 0 2rem;
}
/* LiveView specific classes for your customization */
.phx-no-feedback.invalid-feedback,
.phx-no-feedback .invalid-feedback {
display: none;
}
.phx-click-loading {
opacity: 0.5;
transition: opacity 1s ease-out;
}
.phx-disconnected {
cursor: wait;
}
.phx-disconnected * {
pointer-events: none;
}
html,
body,
div[data-phx-main="true"],
main.container,
.main-view-component {
width: 100%;
height: 100%;
margin: 0;
padding: 0;
}
.gthm-logo {
min-width: 300px;
margin: 1rem;
display: block;
}
.gthm-logo img {
width: auto;
display: block;
max-height: 100px;
}
.main-view-component {
position: relative;
padding: 10px;
}
.main-view-component svg {
width: 100%;
height: 100%;
object-fit: contain;
margin: 0 auto;
}
.pump-btn:hover {
cursor: pointer;
}
.pump-btn-loading:hover {
cursor: progress;
}
.page-container {
padding: 10px;
}

File diff suppressed because one or more lines are too long

View file

@ -1,44 +0,0 @@
// We import the CSS which is extracted to its own file by esbuild.
// Remove this line if you add a your own CSS build pipeline (e.g postcss).
import "../css/app.css"
// If you want to use Phoenix channels, run `mix help phx.gen.channel`
// to get started and then uncomment the line below.
// import "./user_socket.js"
// You can include dependencies in two ways.
//
// The simplest option is to put them in assets/vendor and
// import them using relative paths:
//
// import "./vendor/some-package.js"
//
// Alternatively, you can `npm install some-package` and import
// them using a path starting with the package name:
//
// import "some-package"
//
// Include phoenix_html to handle method=PUT/DELETE in forms and buttons.
import "phoenix_html"
// Establish Phoenix Socket and LiveView configuration.
import {Socket} from "phoenix"
import {LiveSocket} from "phoenix_live_view"
import topbar from "../vendor/topbar"
let csrfToken = document.querySelector("meta[name='csrf-token']").getAttribute("content")
let liveSocket = new LiveSocket("/live", Socket, {params: {_csrf_token: csrfToken}})
// Show progress bar on live navigation and form submits
topbar.config({barColors: {0: "#29d"}, shadowColor: "rgba(0, 0, 0, .3)"})
window.addEventListener("phx:page-loading-start", info => topbar.show())
window.addEventListener("phx:page-loading-stop", info => topbar.hide())
// connect if there are any LiveViews on the page
liveSocket.connect()
// expose liveSocket on window for web console debug logs and latency simulation:
// >> liveSocket.enableDebug()
// >> liveSocket.enableLatencySim(1000) // enabled for duration of browser session
// >> liveSocket.disableLatencySim()
window.liveSocket = liveSocket

View file

@ -1,157 +0,0 @@
/**
* @license MIT
* topbar 1.0.0, 2021-01-06
* http://buunguyen.github.io/topbar
* Copyright (c) 2021 Buu Nguyen
*/
(function (window, document) {
"use strict";
// https://gist.github.com/paulirish/1579671
(function () {
var lastTime = 0;
var vendors = ["ms", "moz", "webkit", "o"];
for (var x = 0; x < vendors.length && !window.requestAnimationFrame; ++x) {
window.requestAnimationFrame =
window[vendors[x] + "RequestAnimationFrame"];
window.cancelAnimationFrame =
window[vendors[x] + "CancelAnimationFrame"] ||
window[vendors[x] + "CancelRequestAnimationFrame"];
}
if (!window.requestAnimationFrame)
window.requestAnimationFrame = function (callback, element) {
var currTime = new Date().getTime();
var timeToCall = Math.max(0, 16 - (currTime - lastTime));
var id = window.setTimeout(function () {
callback(currTime + timeToCall);
}, timeToCall);
lastTime = currTime + timeToCall;
return id;
};
if (!window.cancelAnimationFrame)
window.cancelAnimationFrame = function (id) {
clearTimeout(id);
};
})();
var canvas,
progressTimerId,
fadeTimerId,
currentProgress,
showing,
addEvent = function (elem, type, handler) {
if (elem.addEventListener) elem.addEventListener(type, handler, false);
else if (elem.attachEvent) elem.attachEvent("on" + type, handler);
else elem["on" + type] = handler;
},
options = {
autoRun: true,
barThickness: 3,
barColors: {
0: "rgba(26, 188, 156, .9)",
".25": "rgba(52, 152, 219, .9)",
".50": "rgba(241, 196, 15, .9)",
".75": "rgba(230, 126, 34, .9)",
"1.0": "rgba(211, 84, 0, .9)",
},
shadowBlur: 10,
shadowColor: "rgba(0, 0, 0, .6)",
className: null,
},
repaint = function () {
canvas.width = window.innerWidth;
canvas.height = options.barThickness * 5; // need space for shadow
var ctx = canvas.getContext("2d");
ctx.shadowBlur = options.shadowBlur;
ctx.shadowColor = options.shadowColor;
var lineGradient = ctx.createLinearGradient(0, 0, canvas.width, 0);
for (var stop in options.barColors)
lineGradient.addColorStop(stop, options.barColors[stop]);
ctx.lineWidth = options.barThickness;
ctx.beginPath();
ctx.moveTo(0, options.barThickness / 2);
ctx.lineTo(
Math.ceil(currentProgress * canvas.width),
options.barThickness / 2
);
ctx.strokeStyle = lineGradient;
ctx.stroke();
},
createCanvas = function () {
canvas = document.createElement("canvas");
var style = canvas.style;
style.position = "fixed";
style.top = style.left = style.right = style.margin = style.padding = 0;
style.zIndex = 100001;
style.display = "none";
if (options.className) canvas.classList.add(options.className);
document.body.appendChild(canvas);
addEvent(window, "resize", repaint);
},
topbar = {
config: function (opts) {
for (var key in opts)
if (options.hasOwnProperty(key)) options[key] = opts[key];
},
show: function () {
if (showing) return;
showing = true;
if (fadeTimerId !== null) window.cancelAnimationFrame(fadeTimerId);
if (!canvas) createCanvas();
canvas.style.opacity = 1;
canvas.style.display = "block";
topbar.progress(0);
if (options.autoRun) {
(function loop() {
progressTimerId = window.requestAnimationFrame(loop);
topbar.progress(
"+" + 0.05 * Math.pow(1 - Math.sqrt(currentProgress), 2)
);
})();
}
},
progress: function (to) {
if (typeof to === "undefined") return currentProgress;
if (typeof to === "string") {
to =
(to.indexOf("+") >= 0 || to.indexOf("-") >= 0
? currentProgress
: 0) + parseFloat(to);
}
currentProgress = to > 1 ? 1 : to;
repaint();
return currentProgress;
},
hide: function () {
if (!showing) return;
showing = false;
if (progressTimerId != null) {
window.cancelAnimationFrame(progressTimerId);
progressTimerId = null;
}
(function loop() {
if (topbar.progress("+.1") >= 1) {
canvas.style.opacity -= 0.05;
if (canvas.style.opacity <= 0.05) {
canvas.style.display = "none";
fadeTimerId = null;
return;
}
}
fadeTimerId = window.requestAnimationFrame(loop);
})();
},
};
if (typeof module === "object" && typeof module.exports === "object") {
module.exports = topbar;
} else if (typeof define === "function" && define.amd) {
define(function () {
return topbar;
});
} else {
this.topbar = topbar;
}
}.call(this, window, document));

View file

@ -1,37 +0,0 @@
# This file is responsible for configuring your application
# and its dependencies with the aid of the Config module.
#
# This configuration file is loaded before any dependency and
# is restricted to this project.
# General application configuration
import Config
# Configures the endpoint
config :geo_therminator, GeoTherminatorWeb.Endpoint,
url: [host: "localhost"],
render_errors: [view: GeoTherminatorWeb.ErrorView, accepts: ~w(html json), layout: false],
pubsub_server: GeoTherminator.PubSub,
live_view: [signing_salt: "PC6YJv8v"]
# Configure esbuild (the version is required)
config :esbuild,
version: "0.12.18",
default: [
args:
~w(js/app.js --bundle --target=es2016 --outdir=../priv/static/assets --external:/fonts/* --external:/images/*),
cd: Path.expand("../assets", __DIR__),
env: %{"NODE_PATH" => Path.expand("../deps", __DIR__)}
]
# Configures Elixir's Logger
config :logger, :console,
format: "$time $metadata[$level] $message\n",
metadata: [:request_id]
# Use Jason for JSON parsing in Phoenix
config :phoenix, :json_library, Jason
# Import environment specific config. This must remain at the bottom
# of this file so it overrides the configuration defined above.
import_config "#{config_env()}.exs"

View file

@ -1,65 +0,0 @@
import Config
# For development, we disable any cache and enable
# debugging and code reloading.
#
# The watchers configuration can be used to run external
# watchers to your application. For example, we use it
# with esbuild to bundle .js and .css sources.
config :geo_therminator, GeoTherminatorWeb.Endpoint,
# Binding to loopback ipv4 address prevents access from other machines.
# Change to `ip: {0, 0, 0, 0}` to allow access from other machines.
http: [ip: {127, 0, 0, 1}, port: 4000],
check_origin: false,
code_reloader: true,
debug_errors: true,
secret_key_base: "iNdGHrsVxsC/iRd6ItQmU6WUOOgbs8lixEMdKIypmM2NuKb1PPrzncqR6ETXQvvR",
watchers: [
# Start the esbuild watcher by calling Esbuild.install_and_run(:default, args)
esbuild: {Esbuild, :install_and_run, [:default, ~w(--sourcemap=inline --watch)]}
]
# ## SSL Support
#
# In order to use HTTPS in development, a self-signed
# certificate can be generated by running the following
# Mix task:
#
# mix phx.gen.cert
#
# Note that this task requires Erlang/OTP 20 or later.
# Run `mix help phx.gen.cert` for more information.
#
# The `http:` config above can be replaced with:
#
# https: [
# port: 4001,
# cipher_suite: :strong,
# keyfile: "priv/cert/selfsigned_key.pem",
# certfile: "priv/cert/selfsigned.pem"
# ],
#
# If desired, both `http:` and `https:` keys can be
# configured to run both http and https servers on
# different ports.
# Watch static and templates for browser reloading.
config :geo_therminator, GeoTherminatorWeb.Endpoint,
live_reload: [
patterns: [
~r"priv/static/.*(js|css|png|jpeg|jpg|gif|svg)$",
~r"priv/gettext/.*(po)$",
~r"lib/geo_therminator_web/(live|views)/.*(ex)$",
~r"lib/geo_therminator_web/templates/.*(eex)$"
]
]
# Do not include metadata nor timestamps in development logs
config :logger, :console, format: "[$level] $message\n"
# Set a higher stacktrace during development. Avoid configuring such
# in production as building large stacktraces may be expensive.
config :phoenix, :stacktrace_depth, 20
# Initialize plugs at runtime for faster development compilation
config :phoenix, :plug_init_mode, :runtime

View file

@ -1,50 +0,0 @@
import Config
# For production, don't forget to configure the url host
# to something meaningful, Phoenix uses this information
# when generating URLs.
#
# Note we also include the path to a cache manifest
# containing the digested version of static files. This
# manifest is generated by the `mix phx.digest` task,
# which you should run after static files are built and
# before starting your production server.
config :geo_therminator, GeoTherminatorWeb.Endpoint,
cache_static_manifest: "priv/static/cache_manifest.json"
# Do not print debug messages in production
config :logger, level: :info
# ## SSL Support
#
# To get SSL working, you will need to add the `https` key
# to the previous section and set your `:url` port to 443:
#
# config :geo_therminator, GeoTherminatorWeb.Endpoint,
# ...,
# url: [host: "example.com", port: 443],
# https: [
# ...,
# port: 443,
# cipher_suite: :strong,
# keyfile: System.get_env("SOME_APP_SSL_KEY_PATH"),
# certfile: System.get_env("SOME_APP_SSL_CERT_PATH")
# ]
#
# The `cipher_suite` is set to `:strong` to support only the
# latest and more secure SSL ciphers. This means old browsers
# and clients may not be supported. You can set it to
# `:compatible` for wider support.
#
# `:keyfile` and `:certfile` expect an absolute path to the key
# and cert in disk or a relative path inside priv, for example
# "priv/ssl/server.key". For all supported SSL configuration
# options, see https://hexdocs.pm/plug/Plug.SSL.html#configure/1
#
# We also recommend setting `force_ssl` in your endpoint, ensuring
# no data is ever sent via http, always redirecting to https:
#
# config :geo_therminator, GeoTherminatorWeb.Endpoint,
# force_ssl: [hsts: true]
#
# Check `Plug.SSL` for all available options in `force_ssl`.

View file

@ -1,133 +0,0 @@
import Config
import GeoTherminator.ConfigHelpers
Application.app_dir(:geo_therminator, "priv")
|> Path.join(".env")
|> DotenvParser.load_file()
config :geo_therminator,
api_timeout: 30_000,
api_auth_url:
get_env("API_AUTH_URL", "https://thermia-auth-api.azurewebsites.net/api/v1/Jwt/login"),
api_installations_url:
get_env(
"API_INSTALLATIONS_URL",
"https://online-genesis-serviceapi.azurewebsites.net/api/v1/installationsInfo"
),
api_device_url:
get_env(
"API_DEVICE_URL",
"https://online-genesis-serviceapi.azurewebsites.net/api/v1/installations/{id}"
),
api_device_status_url:
get_env(
"API_DEVICE_STATUS_URL",
"https://online-genesis-serviceapi.azurewebsites.net/api/v1/installationstatus/{id}/status"
),
api_device_register_url:
get_env(
"API_DEVICE_REGISTER_URL",
"https://online-genesis-serviceapi.azurewebsites.net/api/v1/Registers/Installations/{id}/Groups/REG_GROUP_TEMPERATURES"
),
api_device_opstat_url:
get_env(
"API_DEVICE_OPSTAT_URL",
"https://online-genesis-serviceapi.azurewebsites.net/api/v1/Registers/Installations/{id}/Groups/REG_GROUP_OPERATIONAL_STATUS"
),
api_opstat_mapping: %{
# "REG_VALUE_STATUS_MANUAL" => 1,
1 => :hand_operated,
# "REG_VALUE_STATUS_TAPWATER" => 3,
3 => :hot_water,
# "REG_VALUE_STATUS_HEAT" => 4,
4 => :heating,
# "REG_VALUE_STATUS_COOL" => 5,
5 => :active_cooling,
# "REG_VALUE_STATUS_POOL" => 6,
6 => :pool,
# "REG_VALUE_STATUS_LEGIONELLA" => 7,
7 => :anti_legionella,
# "REG_VALUE_STATUS_PASSIVE_COOL" => 8,
8 => :passive_cooling,
# "REG_VALUE_STATUS_STANDBY" => 98,
98 => :standby,
# "REG_VALUE_STATUS_IDLE" => 99,
99 => :idle,
# "REG_VALUE_STATUS_OFF" => 100
100 => :off
},
api_opstat_bitmask_mapping: %{
1 => :hand_operated,
2 => :defrost,
4 => :hot_water,
8 => :heating,
16 => :active_cooling,
32 => :pool,
64 => :anti_legionella,
128 => :passive_cooling,
512 => :standby,
1024 => :idle,
2048 => :off
},
api_device_reg_set_url:
get_env(
"API_DEVICE_REG_SET_URL",
"https://online-genesis-serviceapi.azurewebsites.net/api/v1/Registers/Installations/{id}/Registers"
),
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,
# Database directory for settings
db_dir:
Path.join(
if config_target() in [:ios, :android] do
System.get_env("HOME")
else
Application.app_dir(:geo_therminator, "priv")
end,
get_env("DB_DIR", "db")
)
if config_env() == :dev do
config :geo_therminator, GeoTherminatorWeb.Endpoint,
http: [
port: 0
]
end
if config_env() == :prod do
# The secret key base is used to sign/encrypt cookies and other secrets.
# A default value is used in config/dev.exs and config/test.exs but you
# want to use a different value for prod and you most likely don't want
# to check this value into version control, so we use an environment
# variable instead.
secret_key_base =
System.get_env("SECRET_KEY_BASE") ||
raise """
environment variable SECRET_KEY_BASE is missing.
You can generate one by calling: mix phx.gen.secret
"""
config :geo_therminator, GeoTherminatorWeb.Endpoint,
http: [
# Enable IPv6 and bind on all interfaces.
# Set it to {0, 0, 0, 0, 0, 0, 0, 1} for local network only access.
# See the documentation on https://hexdocs.pm/plug_cowboy/Plug.Cowboy.html
# for details about using IPv6 vs IPv4 and loopback vs public addresses.
ip: {127, 0, 0, 1},
port: 0
],
secret_key_base: secret_key_base,
server: true
# ## Using releases
#
# If you are doing OTP releases, you need to instruct Phoenix
# to start each relevant endpoint:
#
# config :geo_therminator, GeoTherminatorWeb.Endpoint, server: true
#
# Then you can assemble a release by calling `mix release`.
# See `mix help release` for more information.
end

View file

@ -1,14 +0,0 @@
import Config
# We don't run a server during test. If one is required,
# you can enable the server option below.
config :geo_therminator, GeoTherminatorWeb.Endpoint,
http: [ip: {127, 0, 0, 1}, port: 4002],
secret_key_base: "jTM8H/r05f0LEiokDoSBgftJugkpUkfr/0wpwwISIZ6wOOM+90aKvcAF8G21pNKA",
server: false
# Print only warnings and errors during test
config :logger, level: :warn
# Initialize plugs at runtime for faster test compilation
config :phoenix, :plug_init_mode, :runtime

View file

@ -1,10 +1,12 @@
name = "geo_therminator"
version = "0.3.0"
target = "erlang"
version = "1.0.0"
target = "javascript"
[dependencies]
gleam_stdlib = "~> 0.25"
gleam_http = "~> 3.1"
gleam_hackney = "~> 0.2.1"
gleam_erlang = "~> 0.17.1"
gleam_json = "~> 0.5.0"
gleam_stdlib = "~> 0.30"
gleam_http = "~> 3.5"
gleam_json = "~> 0.6.0"
gleam_javascript = "~> 0.6.0"
gleam_fetch = "~> 0.2.0"
lustre = "~> 3.0"
varasto = "~> 1.0"

1
index.html Symbolic link
View file

@ -0,0 +1 @@
build/dev/javascript/geo_therminator/priv/index.html

View file

@ -1,48 +0,0 @@
defmodule GeoTherminator.Application do
# See https://hexdocs.pm/elixir/Application.html
# for more information on OTP Applications
@moduledoc false
use Application
@impl true
def start(_type, _args) do
db_dir = Application.get_env(:geo_therminator, :db_dir)
children = [
{CubDB, name: GeoTherminator.DB, data_dir: db_dir},
{Finch, name: GeoTherminator.PumpAPI.HTTP},
{Phoenix.PubSub, name: GeoTherminator.PumpAPI.Device.PubSub},
{Registry, keys: :unique, name: GeoTherminator.PumpAPI.Device.Registry},
{DynamicSupervisor, strategy: :one_for_one, name: GeoTherminator.PumpAPI.Device.Supervisor},
{DynamicSupervisor,
strategy: :one_for_one, name: GeoTherminator.PumpAPI.Auth.Server.Supervisor},
# Start the Telemetry supervisor
GeoTherminatorWeb.Telemetry,
# Start the PubSub system
Supervisor.child_spec({Phoenix.PubSub, name: GeoTherminator.PubSub}, id: :phoenix_pubsub),
# Start the Endpoint (http/https)
GeoTherminatorWeb.Endpoint,
{Desktop.Window,
[
app: :geo_therminator,
id: GeoTherminatorWindow,
url: &GeoTherminatorWeb.Endpoint.url/0
]}
]
# See https://hexdocs.pm/elixir/Supervisor.html
# for other strategies and supported options
opts = [strategy: :one_for_one, name: GeoTherminator.Supervisor]
Supervisor.start_link(children, opts)
end
# Tell Phoenix to update the endpoint configuration
# whenever the application is updated.
@impl true
def config_change(changed, _new, removed) do
GeoTherminatorWeb.Endpoint.config_change(changed, removed)
:ok
end
end

View file

@ -1,35 +0,0 @@
defmodule GeoTherminator.ConfigHelpers do
@type config_type :: :str | :int | :bool | :json
@doc """
Get value from environment variable, converting it to the given type if needed.
If no default value is given, or `:no_default` is given as the default, an error is raised if the variable is not
set.
"""
@spec get_env(String.t(), :no_default | any(), config_type()) :: any()
def get_env(var, default \\ :no_default, type \\ :str)
def get_env(var, :no_default, type) do
System.fetch_env!(var)
|> get_with_type(type)
end
def get_env(var, default, type) do
with {:ok, val} <- System.fetch_env(var) do
get_with_type(val, type)
else
:error -> default
end
end
@spec get_with_type(String.t(), config_type()) :: any()
defp get_with_type(val, type)
defp get_with_type(val, :str), do: val
defp get_with_type(val, :int), do: String.to_integer(val)
defp get_with_type("true", :bool), do: true
defp get_with_type("false", :bool), do: false
defp get_with_type(val, :json), do: Jason.decode!(val)
defp get_with_type(val, type), do: raise("Cannot convert to #{inspect(type)}: #{inspect(val)}")
end

View file

@ -1,7 +0,0 @@
defmodule GeoTherminator.PumpAPI.Auth.InstallationInfo do
require Record
Record.defrecord(:record, :installation_info, [:id])
@type t :: record(:record, id: non_neg_integer())
end

View file

@ -1,127 +0,0 @@
defmodule GeoTherminator.PumpAPI.Auth.Server do
require Logger
require GeoTherminator.PumpAPI.Auth.User
require GeoTherminator.PumpAPI.Auth.InstallationInfo
require GeoTherminator.PumpAPI.Auth.Tokens
use GenServer
import GeoTherminator.TypedStruct
alias GeoTherminator.PumpAPI.Auth
@token_check_timer 10_000
@token_check_diff 5 * 60
defmodule Options do
deftypedstruct(%{
server_name: GenServer.name(),
username: String.t(),
password: String.t()
})
end
defmodule State do
deftypedstruct(%{
username: String.t(),
password: String.t(),
authed_user: {Auth.User.t() | nil, nil},
installations_fetched: {boolean(), false},
installations: {[Auth.InstallationInfo.t()], []}
})
end
@spec start_link(Options.t()) :: GenServer.on_start()
def start_link(opts) do
GenServer.start_link(__MODULE__, opts, name: opts.server_name)
end
@impl true
def init(%Options{} = opts) do
case init_state(opts.username, opts.password) do
{:ok, state} -> {:ok, state}
:error -> {:stop, :error}
end
end
@impl true
def handle_call(msg, from, state)
def handle_call(:get_auth, _from, state) do
{:reply, state.authed_user, state}
end
def handle_call(:get_installations, _from, state) do
{:reply, state.installations, state}
end
def handle_call({:get_installation, id}, _from, state) do
{:reply, Enum.find(state.installations, &(Auth.InstallationInfo.record(&1, :id) == id)),
state}
end
@impl true
def handle_info(msg, state)
def handle_info(:token_check, state) do
now = DateTime.utc_now()
diff =
DateTime.diff(
state.authed_user
|> Auth.User.record(:tokens)
|> Auth.Tokens.record(:access_token_expiry),
now
)
schedule_token_check()
if diff < @token_check_diff do
Logger.debug("Renewing auth token since #{diff} < #{@token_check_diff}")
case init_state(state.username, state.password) do
{:ok, new_state} -> {:noreply, new_state}
:error -> {:noreply, state}
end
else
{:noreply, state}
end
end
@spec get_auth(GenServer.name()) :: Auth.User.t()
def get_auth(server) do
GenServer.call(server, :get_auth)
end
@spec get_installations(GenServer.name()) :: Auth.InstallationInfo.t()
def get_installations(server) do
GenServer.call(server, :get_installations)
end
@spec get_installation(GenServer.name(), integer()) :: Auth.InstallationInfo.t() | nil
def get_installation(server, id) do
GenServer.call(server, {:get_installation, id})
end
defp schedule_token_check() do
Process.send_after(self(), :token_check, @token_check_timer)
end
@spec init_state(String.t(), String.t()) :: {:ok, State.t()} | {:stop, :error}
defp init_state(username, password) do
with {:ok, user} <- :pump_api@auth@api.auth(username, password),
{:ok, installations} <- :pump_api@auth@api.installation_info(user) do
schedule_token_check()
state = %State{
authed_user: user,
installations: installations,
installations_fetched: true,
username: username,
password: password
}
{:ok, state}
else
_ -> :error
end
end
end

View file

@ -1,18 +0,0 @@
defmodule GeoTherminator.PumpAPI.Auth.Tokens do
require Record
Record.defrecord(:record, :tokens, [
:access_token,
:access_token_expiry,
:refresh_token,
: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()
)
end

View file

@ -1,7 +0,0 @@
defmodule GeoTherminator.PumpAPI.Auth.User do
require Record
Record.defrecord(:record, :user, [:tokens])
@type t :: record(:record, tokens: GeoTherminator.PumpAPI.Auth.Tokens.t())
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,83 +0,0 @@
defmodule GeoTherminator.PumpAPI.Device do
import GeoTherminator.TypedStruct
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()
})
defmodule Status do
deftypedstruct(%{
heating_effect: integer(),
is_heating_effect_set_by_user: boolean()
})
end
defmodule Register do
deftypedstruct(%{
register_name: String.t(),
register_value: integer(),
timestamp: DateTime.t()
})
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()
})
end
defmodule OpStat do
deftypedstruct(%{
priority:
:hand_operated
| :hot_water
| :heating
| :active_cooling
| :pool
| :anti_legionella
| :passive_cooling
| :standby
| :idle
| :off
| :defrost
| :unknown
})
end
@spec get_device_process(GenServer.name(), GeoTherminator.PumpAPI.Auth.InstallationInfo.t()) ::
{:ok, GenServer.name()} | :error
def get_device_process(auth_server, installation) do
case DynamicSupervisor.start_child(
__MODULE__.Supervisor,
{__MODULE__.Server,
%__MODULE__.Server.Options{
auth_server: auth_server,
installation: installation
}}
) do
{:ok, pid} ->
{:ok, pid}
{:error, {:already_started, pid}} ->
{:ok, pid}
err ->
IO.inspect(err)
:error
end
end
end

View file

@ -1,66 +0,0 @@
defmodule GeoTherminator.PumpAPI.Device.PubSub do
require GeoTherminator.PumpAPI.Auth.InstallationInfo
alias Phoenix.PubSub
alias GeoTherminator.PumpAPI.Auth.InstallationInfo
@installation_topic "installation:"
@spec subscribe_installation(InstallationInfo.t()) :: :ok
def subscribe_installation(installation) do
:ok =
PubSub.subscribe(
__MODULE__,
@installation_topic <> to_string(InstallationInfo.record(installation, :id))
)
end
@spec broadcast_device(GeoTherminator.PumpAPI.Device.t()) :: :ok
def broadcast_device(device) do
:ok =
PubSub.broadcast!(
__MODULE__,
@installation_topic <> to_string(device.id),
{:device, device}
)
end
@spec broadcast_status(
GeoTherminator.PumpAPI.Device.t(),
GeoTherminator.PumpAPI.Device.Status.t()
) :: :ok
def broadcast_status(device, status) do
:ok =
PubSub.broadcast!(
__MODULE__,
@installation_topic <> to_string(device.id),
{:status, status}
)
end
@spec broadcast_registers(
GeoTherminator.PumpAPI.Device.t(),
GeoTherminator.PumpAPI.Device.RegisterCollection.t()
) :: :ok
def broadcast_registers(device, registers) do
:ok =
PubSub.broadcast!(
__MODULE__,
@installation_topic <> to_string(device.id),
{:registers, registers}
)
end
@spec broadcast_opstat(
GeoTherminator.PumpAPI.Device.t(),
GeoTherminator.PumpAPI.Device.OpStat.t()
) :: :ok
def broadcast_opstat(device, opstat) do
:ok =
PubSub.broadcast!(
__MODULE__,
@installation_topic <> to_string(device.id),
{:opstat, opstat}
)
end
end

View file

@ -1,139 +0,0 @@
defmodule GeoTherminator.PumpAPI.Device.Server do
use GenServer
import GeoTherminator.TypedStruct
alias GeoTherminator.PumpAPI.Device
alias GeoTherminator.PumpAPI.Auth.InstallationInfo
alias GeoTherminator.PumpAPI.Auth.Server, as: AuthServer
require Logger
defmodule Options do
deftypedstruct(%{
auth_server: GenServer.name(),
installation: InstallationInfo.t()
})
end
defmodule State do
deftypedstruct(%{
auth_server: GenServer.name(),
device: {Device.t() | nil, nil},
status: {Device.Status.t() | nil, nil},
registers: {Device.RegisterCollection.t() | nil, nil},
opstat: {Device.OpStat.t() | nil, nil}
})
end
@spec start_link(Options.t()) :: GenServer.on_start()
def start_link(opts) do
GenServer.start_link(__MODULE__, opts,
name: {:via, Registry, {Device.Registry, opts.installation.id}}
)
end
@impl true
def init(%Options{} = opts) do
{:ok, %State{auth_server: opts.auth_server}, {:continue, {:init, opts.installation}}}
end
@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.PubSub.broadcast_device(device)
state =
%State{state | device: device}
|> refresh_status()
schedule_status()
{:noreply, state}
end
@impl true
def handle_call(msg, from, state)
def handle_call(:get_device, _from, state) do
{:reply, state.device, state}
end
def handle_call(:get_status, _from, state) do
{:reply, state.status, state}
end
def handle_call(:get_registers, _from, state) do
{:reply, state.registers, state}
end
def handle_call(:get_opstat, _from, state) do
{:reply, state.opstat, state}
end
def handle_call({:set_temp, temp}, _from, state) do
user = AuthServer.get_auth(state.auth_server)
Logger.debug("Begin set temp to #{temp}")
resp = Device.API.set_temp(user, state.device, temp)
Logger.debug("Set temp result: #{inspect(resp)}")
{:reply, resp, state}
end
@impl true
def handle_info(msg, state)
def handle_info(:refresh_status, state) do
state = refresh_status(state)
schedule_status()
{:noreply, state}
end
@spec get_device(GenServer.name()) :: Device.t()
def get_device(server) do
GenServer.call(server, :get_device)
end
@spec get_status(GenServer.name()) :: Device.Status.t()
def get_status(server) do
GenServer.call(server, :get_status)
end
@spec get_registers(GenServer.name()) :: Device.RegisterCollection.t()
def get_registers(server) do
GenServer.call(server, :get_registers)
end
@spec get_opstat(GenServer.name()) :: Device.OpStat.t()
def get_opstat(server) do
GenServer.call(server, :get_opstat)
end
@spec set_temp(GenServer.name(), integer()) :: :ok | {:error, String.t()}
def set_temp(server, temp) do
GenServer.call(server, {:set_temp, temp})
end
defp refresh_status(state) do
user = AuthServer.get_auth(state.auth_server)
[status, registers, opstat] =
Task.async_stream(
[&Device.API.status/2, &Device.API.register_info/2, &Device.API.opstat/2],
& &1.(user, state.device),
timeout: Application.get_env(:geo_therminator, :api_timeout)
)
|> Enum.map(fn {:ok, val} -> val end)
Device.PubSub.broadcast_status(state.device, status)
Device.PubSub.broadcast_registers(state.device, registers)
Device.PubSub.broadcast_opstat(state.device, opstat)
%State{state | status: status, registers: registers, opstat: opstat}
end
defp schedule_status() do
Process.send_after(
self(),
:refresh_status,
Application.get_env(:geo_therminator, :api_refresh)
)
end
end

View file

@ -1,59 +0,0 @@
defmodule GeoTherminator.PumpAPI.HTTP do
require GeoTherminator.PumpAPI.Auth.User
require GeoTherminator.PumpAPI.Auth.Tokens
alias GeoTherminator.PumpAPI.Auth.User
alias GeoTherminator.PumpAPI.Auth.Tokens
@spec authed_req(
User.t(),
Finch.Request.method(),
Finch.Request.url(),
Finch.Request.headers(),
map() | nil,
Keyword.t()
) ::
Finch.Request.t()
def authed_req(user, method, url, headers \\ [], body \\ nil, opts \\ []) do
headers =
headers
|> List.keystore(
"authorization",
0,
{"authorization", "Bearer #{User.record(user, :tokens) |> Tokens.record(:access_token)}"}
)
req(method, url, headers, body, opts)
end
@spec req(
Finch.Request.method(),
Finch.Request.url(),
Finch.Request.headers(),
map() | nil,
Keyword.t()
) ::
Finch.Request.t()
def req(method, url, headers \\ [], body \\ nil, opts \\ []) do
headers =
headers
|> List.keystore("accept", 0, {"accept", "application/json"})
headers =
if not is_nil(body) do
List.keystore(headers, "content-type", 0, {"content-type", "application/json"})
else
headers
end
opts = Keyword.put(opts, :timeout, Application.get_env(:geo_therminator, :api_timeout))
Finch.build(
method,
url,
headers,
if(not is_nil(body), do: Jason.encode!(body)),
opts
)
end
end

View file

@ -1,71 +0,0 @@
defmodule GeoTherminator.TypedStruct do
@doc """
Create typed struct with a type, default values, and enforced keys.
Input should be a map where the key names are names of the struct keys and values are the
field information. The value can be a typespec, in which case the field will be enforced, or
a 2-tuple of `{typespec, default_value}`, making the field unenforced.
To prevent ambiguity, a value of `{typespec, :ts_enforced}` will be interpreted as enforced,
this will allow you to typespec a 2-tuple.
NOTE: Due to the ambiguity removal technique above, `:ts_enforced` is not allowed as a default
value.
Example:
```elixir
deftypedstruct(%{
# Enforced with simple type
foo: integer(),
# Enforced 2-tuple typed field, written like this to remove ambiguity
bar: {{String.t(), integer()}, :ts_enforced},
# Non-enforced field with default value
baz: {any(), ""}
})
```
"""
defmacro deftypedstruct(fields) do
fields_list =
case fields do
{:%{}, _, flist} -> flist
_ -> raise ArgumentError, "Fields must be a map!"
end
enforced_list =
fields_list
|> Enum.filter(fn
{_, {_, :ts_enforced}} -> true
{_, {_, _}} -> false
{_, _} -> true
end)
|> Enum.map(&elem(&1, 0))
field_specs =
Enum.map(fields_list, fn
{field, {typespec, :ts_enforced}} ->
{field, typespec}
{field, {typespec, _}} ->
{field, typespec}
{field, typespec} ->
{field, typespec}
end)
field_vals =
Enum.map(fields_list, fn
{field, {_, :ts_enforced}} -> field
{field, {_, default}} -> {field, default}
{field, _} -> field
end)
quote do
@type t :: %__MODULE__{unquote_splicing(field_specs)}
@enforce_keys unquote(enforced_list)
defstruct unquote(field_vals)
end
end
end

View file

@ -1,49 +0,0 @@
defmodule GeoTherminatorWeb.Endpoint do
use Desktop.Endpoint, otp_app: :geo_therminator
# The session will be stored in the cookie and signed,
# this means its contents can be read but not tampered with.
# Set :encryption_salt if you would also like to encrypt it.
@session_options [
store: :cookie,
key: "_geo_therminator_key",
signing_salt: "j8JUzvur"
]
socket "/live", Phoenix.LiveView.Socket, websocket: [connect_info: [session: @session_options]]
# Serve at "/" the static files from "priv/static" directory.
#
# You should set gzip to true if you are running phx.digest
# when deploying your static files in production.
plug Plug.Static,
at: "/",
from: :geo_therminator,
gzip: false,
only: ~w(assets fonts images favicon.ico robots.txt)
# Code reloading can be explicitly enabled under the
# :code_reloader configuration of your endpoint.
if code_reloading? do
socket "/phoenix/live_reload/socket", Phoenix.LiveReloader.Socket
plug Phoenix.LiveReloader
plug Phoenix.CodeReloader
end
plug Phoenix.LiveDashboard.RequestLogger,
param_key: "request_logger",
cookie_key: "request_logger"
plug Plug.RequestId
plug Plug.Telemetry, event_prefix: [:phoenix, :endpoint]
plug Plug.Parsers,
parsers: [:urlencoded, :multipart, :json],
pass: ["*/*"],
json_decoder: Phoenix.json_library()
plug Plug.MethodOverride
plug Plug.Head
plug Plug.Session, @session_options
plug GeoTherminatorWeb.Router
end

View file

@ -1,104 +0,0 @@
defmodule GeoTherminatorWeb do
@moduledoc """
The entrypoint for defining your web interface, such
as controllers, views, channels and so on.
This can be used in your application as:
use GeoTherminatorWeb, :controller
use GeoTherminatorWeb, :view
The definitions below will be executed for every view,
controller, etc, so keep them short and clean, focused
on imports, uses and aliases.
Do NOT define functions inside the quoted expressions
below. Instead, define any helper function in modules
and import those modules here.
"""
def controller do
quote do
use Phoenix.Controller, namespace: GeoTherminatorWeb
import Plug.Conn
import GeoTherminatorWeb.Gettext
alias GeoTherminatorWeb.Router.Helpers, as: Routes
end
end
def view do
quote do
use Phoenix.View,
root: "lib/geo_therminator_web/templates",
namespace: GeoTherminatorWeb
# Import convenience functions from controllers
import Phoenix.Controller,
only: [get_flash: 1, get_flash: 2, view_module: 1, view_template: 1]
# Include shared imports and aliases for views
unquote(view_helpers())
end
end
def live_view do
quote do
use Phoenix.LiveView,
layout: {GeoTherminatorWeb.LayoutView, "live.html"}
unquote(view_helpers())
end
end
def live_component do
quote do
use Phoenix.LiveComponent
unquote(view_helpers())
end
end
def router do
quote do
use Phoenix.Router
import Plug.Conn
import Phoenix.Controller
import Phoenix.LiveView.Router
end
end
def channel do
quote do
use Phoenix.Channel
import GeoTherminatorWeb.Gettext
end
end
defp view_helpers do
quote do
# Use all HTML functionality (forms, tags, etc)
use Phoenix.HTML
# Import LiveView and .heex helpers (live_render, live_patch, <.form>, etc)
import Phoenix.LiveView.Helpers
# Import basic rendering functionality (render, render_layout, etc)
import Phoenix.View
alias Phoenix.LiveView.JS
import GeoTherminatorWeb.ErrorHelpers
import GeoTherminatorWeb.Gettext
alias GeoTherminatorWeb.Router.Helpers, as: Routes
end
end
@doc """
When used, dispatch to the appropriate controller/view/etc.
"""
defmacro __using__(which) when is_atom(which) do
apply(__MODULE__, which, [])
end
end

View file

@ -1,24 +0,0 @@
defmodule GeoTherminatorWeb.Gettext do
@moduledoc """
A module providing Internationalization with a gettext-based API.
By using [Gettext](https://hexdocs.pm/gettext),
your module gains a set of macros for translations, for example:
import GeoTherminatorWeb.Gettext
# Simple translation
gettext("Here is the string to translate")
# Plural translation
ngettext("Here is the string to translate",
"Here are the strings to translate",
3)
# Domain-based translation
dgettext("errors", "Here is the error message to translate")
See the [Gettext Docs](https://hexdocs.pm/gettext) for detailed usage.
"""
use Gettext, otp_app: :geo_therminator
end

View file

@ -1,19 +0,0 @@
defmodule GeoTherminatorWeb.Components.MainView do
use GeoTherminatorWeb, :live_component
@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
)}
end
end

View file

@ -1,508 +0,0 @@
<div class="main-view-component">
<svg
xmlns="http://www.w3.org/2000/svg"
xmlns:xlink="http://www.w3.org/1999/xlink"
version="1.1"
width="484px"
height="665px"
viewBox="-0.5 -0.5 484 665"
style="background-color: rgb(255, 255, 255);"
>
<defs>
<linearGradient x1="0%" y1="0%" x2="0%" y2="100%" id="mx-gradient-f5f5f5-1-b3b3b3-1-s-0">
<stop offset="0%" style="stop-color: rgb(245, 245, 245); stop-opacity: 1;" />
<stop offset="100%" style="stop-color: rgb(179, 179, 179); stop-opacity: 1;" />
</linearGradient>
<linearGradient x1="0%" y1="0%" x2="0%" y2="100%" id="mx-gradient-dae8fc-1-7ea6e0-1-s-0">
<stop offset="0%" style="stop-color: rgb(218, 232, 252); stop-opacity: 1;" />
<stop offset="100%" style="stop-color: rgb(126, 166, 224); stop-opacity: 1;" />
</linearGradient>
<linearGradient x1="0%" y1="0%" x2="0%" y2="100%" id="mx-gradient-f8cecc-1-ea6b66-1-s-0">
<stop offset="0%" style="stop-color: rgb(248, 206, 204); stop-opacity: 1;" />
<stop offset="100%" style="stop-color: rgb(234, 107, 102); stop-opacity: 1;" />
</linearGradient>
</defs>
<g>
<rect
x="0"
y="144"
width="200"
height="520"
fill="url(#mx-gradient-f5f5f5-1-b3b3b3-1-s-0)"
stroke="#666666"
pointer-events="none"
/>
<rect
x="60"
y="194"
width="80"
height="80"
fill="rgba(255, 255, 255, 1)"
stroke="rgba(0, 0, 0, 1)"
pointer-events="none"
/>
<rect
x="90"
y="464"
width="97"
height="48"
fill="rgba(255, 255, 255, 1)"
stroke="none"
pointer-events="none"
/>
<g transform="translate(-0.5 -0.5)">
<text
x="185"
y="498"
fill="rgba(0, 0, 0, 1)"
font-family="Helvetica"
font-size="34px"
text-anchor="end"
>
<%= @outdoor_temp %>°C
</text>
</g>
<rect
x="10"
y="468"
width="30"
height="40"
fill="rgba(255, 255, 255, 1)"
stroke="rgba(0, 0, 0, 1)"
pointer-events="none"
/>
<path
d="M 30 488 L 53.63 488"
fill="none"
stroke="rgba(0, 0, 0, 1)"
stroke-miterlimit="10"
pointer-events="none"
/>
<path
d="M 58.88 488 L 51.88 491.5 L 53.63 488 L 51.88 484.5 Z"
fill="rgba(0, 0, 0, 1)"
stroke="rgba(0, 0, 0, 1)"
stroke-miterlimit="10"
pointer-events="none"
/>
<%= if @priority == :heating do %>
<ellipse
cx="172"
cy="289"
rx="15"
ry="15"
fill="#ffff88"
stroke="#36393d"
pointer-events="none"
/>
<% end %>
<%= if @priority == :hot_water do %>
<ellipse
cx="100"
cy="169"
rx="15"
ry="15"
fill="#ffff88"
stroke="#36393d"
pointer-events="none"
/>
<% end %>
<path
d="M 10 344 Q 10 344 53.63 344"
fill="none"
stroke="rgba(0, 0, 0, 1)"
stroke-miterlimit="10"
pointer-events="none"
/>
<path
d="M 58.88 344 L 51.88 347.5 L 53.63 344 L 51.88 340.5 Z"
fill="rgba(0, 0, 0, 1)"
stroke="rgba(0, 0, 0, 1)"
stroke-miterlimit="10"
pointer-events="none"
/>
<rect
x="90"
y="320"
width="97"
height="48"
fill="rgba(255, 255, 255, 1)"
stroke="none"
pointer-events="none"
/>
<g transform="translate(-0.5 -0.5)">
<text
x="185"
y="354"
fill="rgba(0, 0, 0, 1)"
font-family="Helvetica"
font-size="34px"
text-anchor="end"
>
<%= @set_temp %>°C
</text>
</g>
<rect
id="dec-tmp-rect"
class={"pump-btn #{if @set_temp_active, do: "pump-btn-loading"}"}
x="25"
y="384"
width="60"
height="60"
rx="9"
ry="9"
fill={if @set_temp_active, do: "#ccc", else: "url(#mx-gradient-dae8fc-1-7ea6e0-1-s-0)"}
stroke="#6c8ebf"
phx-click="dec_temp"
/>
<g transform="translate(-0.5 -0.5)">
<text
id="dec-tmp-text"
class={"pump-btn #{if @set_temp_active, do: "pump-btn-loading"}"}
x="55"
y="424"
fill="rgba(0, 0, 0, 1)"
font-family="Helvetica"
font-size="34px"
text-anchor="middle"
phx-click="dec_temp"
>
-
</text>
</g>
<rect
id="inc-tmp-rect"
class={"pump-btn #{if @set_temp_active, do: "pump-btn-loading"}"}
x="115"
y="384"
width="60"
height="60"
rx="9"
ry="9"
fill={if @set_temp_active, do: "#ccc", else: "url(#mx-gradient-f8cecc-1-ea6b66-1-s-0)"}
stroke="#b85450"
phx-click="inc_temp"
/>
<g transform="translate(-0.5 -0.5)">
<text
id="inc-tmp-text"
class={"pump-btn #{if @set_temp_active, do: "pump-btn-loading"}"}
x="145"
y="424"
fill="rgba(0, 0, 0, 1)"
font-family="Helvetica"
font-size="34px"
text-anchor="middle"
phx-click="inc_temp"
>
+
</text>
</g>
<path
d="M 50 144 L 50 24 L 480 24"
fill="none"
stroke="#b85450"
stroke-width="4"
stroke-miterlimit="10"
pointer-events="none"
/>
<path
d="M 150 144 L 150 84 L 480 84"
fill="none"
stroke="#6c8ebf"
stroke-width="4"
stroke-miterlimit="10"
pointer-events="none"
/>
<path
d="M 200 353 L 310 353 L 310 323 L 480 323"
fill="none"
stroke="#6c8ebf"
stroke-width="4"
stroke-miterlimit="10"
pointer-events="none"
/>
<path
d="M 200 223 L 310 223 L 310 263 L 480 263"
fill="none"
stroke="#b85450"
stroke-width="4"
stroke-miterlimit="10"
pointer-events="none"
/>
<path
d="M 200 614 L 310 614 L 310 584 L 480 584"
fill="none"
stroke="#b85450"
stroke-width="4"
stroke-miterlimit="10"
pointer-events="none"
/>
<path
d="M 200 484 L 310 484 L 310 524 L 480 524"
fill="none"
stroke="#6c8ebf"
stroke-width="4"
stroke-miterlimit="10"
pointer-events="none"
/>
<rect
x="368"
y="560"
width="97"
height="48"
fill="rgba(255, 255, 255, 1)"
stroke="none"
pointer-events="none"
/>
<g transform="translate(-0.5 -0.5)">
<text
x="463"
y="594"
fill="rgba(0, 0, 0, 1)"
font-family="Helvetica"
font-size="34px"
text-anchor="end"
>
<%= @brine_in %>°C
</text>
</g>
<rect
x="368"
y="500"
width="97"
height="48"
fill="rgba(255, 255, 255, 1)"
stroke="none"
pointer-events="none"
/>
<g transform="translate(-0.5 -0.5)">
<text
x="463"
y="534"
fill="rgba(0, 0, 0, 1)"
font-family="Helvetica"
font-size="34px"
text-anchor="end"
>
<%= @brine_out %>°C
</text>
</g>
<rect
x="368"
y="298"
width="97"
height="48"
fill="rgba(255, 255, 255, 1)"
stroke="none"
pointer-events="none"
/>
<g transform="translate(-0.5 -0.5)">
<text
x="463"
y="332"
fill="rgba(0, 0, 0, 1)"
font-family="Helvetica"
font-size="34px"
text-anchor="end"
>
<%= @supply_in %>°C
</text>
</g>
<rect
x="368"
y="239"
width="97"
height="48"
fill="rgba(255, 255, 255, 1)"
stroke="none"
pointer-events="none"
/>
<g transform="translate(-0.5 -0.5)">
<text
x="463"
y="273"
fill="rgba(0, 0, 0, 1)"
font-family="Helvetica"
font-size="34px"
text-anchor="end"
>
<%= @supply_out %>°C
</text>
</g>
<rect
x="168"
y="32"
width="97"
height="48"
fill="rgba(255, 255, 255, 1)"
stroke="none"
pointer-events="none"
/>
<g transform="translate(-0.5 -0.5)">
<text
x="263"
y="66"
fill="rgba(0, 0, 0, 1)"
font-family="Helvetica"
font-size="34px"
text-anchor="end"
>
<%= @hot_water_temp %>°C
</text>
</g>
<rect
x="269"
y="284"
width="40"
height="10"
fill="rgba(255, 255, 255, 1)"
stroke="rgba(0, 0, 0, 1)"
pointer-events="none"
/>
<rect
x="210"
y="314"
width="40"
height="10"
fill="rgba(255, 255, 255, 1)"
stroke="rgba(0, 0, 0, 1)"
pointer-events="none"
/>
<rect
x="210"
y="284"
width="40"
height="10"
fill="rgba(255, 255, 255, 1)"
stroke="rgba(0, 0, 0, 1)"
pointer-events="none"
/>
<rect
x="220"
y="274"
width="80"
height="60"
fill="rgba(255, 255, 255, 1)"
stroke="rgba(0, 0, 0, 1)"
pointer-events="none"
/>
<path
d="M 230 326.5 L 230 281.5"
fill="none"
stroke="rgba(0, 0, 0, 1)"
stroke-miterlimit="10"
pointer-events="none"
/>
<path
d="M 240 326.5 L 240 281.5"
fill="none"
stroke="rgba(0, 0, 0, 1)"
stroke-miterlimit="10"
pointer-events="none"
/>
<path
d="M 250 326.5 L 250 281.5"
fill="none"
stroke="rgba(0, 0, 0, 1)"
stroke-miterlimit="10"
pointer-events="none"
/>
<path
d="M 260 326.5 L 260 281.5"
fill="none"
stroke="rgba(0, 0, 0, 1)"
stroke-miterlimit="10"
pointer-events="none"
/>
<path
d="M 270 326.5 L 270 281.5"
fill="none"
stroke="rgba(0, 0, 0, 1)"
stroke-miterlimit="10"
pointer-events="none"
/>
<path
d="M 280 326.5 L 280 281.5"
fill="none"
stroke="rgba(0, 0, 0, 1)"
stroke-miterlimit="10"
pointer-events="none"
/>
<path
d="M 290 326.5 L 290 281.5"
fill="none"
stroke="rgba(0, 0, 0, 1)"
stroke-miterlimit="10"
pointer-events="none"
/>
<path
d="M 230 264 Q 220 254 230 249 Q 240 244 230 234"
fill="none"
stroke="rgba(0, 0, 0, 1)"
stroke-miterlimit="10"
pointer-events="none"
/>
<path
d="M 250 264 Q 240 254 250 249 Q 260 244 250 234"
fill="none"
stroke="rgba(0, 0, 0, 1)"
stroke-miterlimit="10"
pointer-events="none"
/>
<path
d="M 270 264 Q 260 254 270 249 Q 280 244 270 234"
fill="none"
stroke="rgba(0, 0, 0, 1)"
stroke-miterlimit="10"
pointer-events="none"
/>
<path
d="M 290 264 Q 280 254 290 249 Q 300 244 290 234"
fill="none"
stroke="rgba(0, 0, 0, 1)"
stroke-miterlimit="10"
pointer-events="none"
/>
<path
d="M 70 60 L 140 60 L 140 80 L 90 80 L 90 100 L 70 100 Z"
fill="rgba(255, 255, 255, 1)"
stroke="rgba(0, 0, 0, 1)"
stroke-miterlimit="10"
pointer-events="none"
/>
<path
d="M 105 35 L 125 35 L 125 45 L 115 45 L 115 65 L 105 65 Z"
fill="rgba(255, 255, 255, 1)"
stroke="rgba(0, 0, 0, 1)"
stroke-miterlimit="10"
transform="rotate(90,115,50)"
pointer-events="none"
/>
<path
d="M 80 102 L 84.71 115.33 C 84.9 115.87 85 116.43 85 117 C 85 118.33 84.47 119.6 83.54 120.54 C 82.6 121.47 81.33 122 80 122 C 78.67 122 77.4 121.47 76.46 120.54 C 75.53 119.6 75 118.33 75 117 C 75 116.43 75.1 115.87 75.29 115.33 Z"
fill="#dae8fc"
stroke="#6c8ebf"
stroke-miterlimit="10"
pointer-events="none"
/>
<path
d="M 235 506 C 235 497.72 245.07 491 257.5 491 C 263.47 491 269.19 492.58 273.41 495.39 C 277.63 498.21 280 502.02 280 506 L 280 592 C 280 600.28 269.93 607 257.5 607 C 245.07 607 235 600.28 235 592 Z"
fill="rgba(255, 255, 255, 1)"
stroke="rgba(0, 0, 0, 1)"
stroke-miterlimit="10"
pointer-events="none"
/>
<path
d="M 280 506 C 280 514.28 269.93 521 257.5 521 C 245.07 521 235 514.28 235 506"
fill="none"
stroke="rgba(0, 0, 0, 1)"
stroke-miterlimit="10"
pointer-events="none"
/>
</g>
</svg>
</div>

View file

@ -1,28 +0,0 @@
defmodule GeoTherminatorWeb.MainLive.DeviceList do
use GeoTherminatorWeb, :live_view
@impl Phoenix.LiveView
def mount(_params, _session, socket) do
CubDB.delete(GeoTherminator.DB, :viewing_pump)
user = GeoTherminator.PumpAPI.Auth.Server.get_auth(GeoTherminator.PumpAPI.Auth.Server)
installations =
GeoTherminator.PumpAPI.Auth.Server.get_installations(GeoTherminator.PumpAPI.Auth.Server)
{:ok, assign(socket, user: user, installations: installations)}
end
@impl Phoenix.LiveView
def handle_event(evt, value, socket)
def handle_event("logout", _value, socket) do
DynamicSupervisor.stop(GeoTherminator.PumpAPI.Auth.Server.Supervisor)
CubDB.delete(GeoTherminator.DB, :auth_credentials)
{:noreply,
Phoenix.LiveView.push_redirect(socket,
to: Routes.live_path(socket, GeoTherminatorWeb.MainLive.Index)
)}
end
end

View file

@ -1,22 +0,0 @@
<section id="main-section" class="page-container">
<header>
<h1>Welcome, <%= @user.first_name %>!</h1>
</header>
<section class="pumps">
<h2>Your available pumps</h2>
<ul>
<%= for installation <- @installations do %>
<li>
<%= live_redirect(installation.id,
to: Routes.live_path(@socket, GeoTherminatorWeb.MainLive.Pump, installation.id)
) %>
</li>
<% end %>
</ul>
</section>
<footer>
<button type="button" phx-click="logout">Log out</button>
</footer>
</section>

View file

@ -1,62 +0,0 @@
defmodule GeoTherminatorWeb.MainLive.Index do
use GeoTherminatorWeb, :live_view
@impl Phoenix.LiveView
def mount(_params, _session, socket) do
socket = assign(socket, error: false)
credentials = CubDB.get(GeoTherminator.DB, :auth_credentials)
socket =
if connected?(socket) and is_map(credentials) do
try_init(socket, credentials["username"], credentials["password"])
else
socket
end
{:ok, socket}
end
@impl Phoenix.LiveView
def handle_event(evt, val, socket)
def handle_event("login", values, socket) do
{:noreply, try_init(socket, values["username"], values["password"])}
end
defp try_init(socket, username, password) do
on_start =
DynamicSupervisor.start_child(
GeoTherminator.PumpAPI.Auth.Server.Supervisor,
{GeoTherminator.PumpAPI.Auth.Server,
%GeoTherminator.PumpAPI.Auth.Server.Options{
server_name: GeoTherminator.PumpAPI.Auth.Server,
username: username,
password: password
}}
)
case on_start do
{:ok, _} ->
CubDB.put(GeoTherminator.DB, :auth_credentials, %{
"username" => username,
"password" => password
})
viewing_pump = CubDB.get(GeoTherminator.DB, :viewing_pump)
if viewing_pump != nil do
push_redirect(socket,
to: Routes.live_path(socket, GeoTherminatorWeb.MainLive.Pump, viewing_pump)
)
else
push_redirect(socket,
to: Routes.live_path(socket, GeoTherminatorWeb.MainLive.DeviceList)
)
end
_ ->
assign(socket, error: true)
end
end
end

View file

@ -1,17 +0,0 @@
<div class="container page-container">
<%= if connected?(@socket) do %>
<form phx-submit="login">
<h1>Log in</h1>
<label>Email <input type="email" name="username" /></label>
<label>Password <input type="password" name="password" /></label>
<button>Log in</button>
<%= if @error do %>
<div class="error">Error occurred, please try again.</div>
<% end %>
</form>
<% else %>
<h1>Loading…</h1>
<% end %>
</div>

View file

@ -1,94 +0,0 @@
defmodule GeoTherminatorWeb.MainLive.Pump do
use GeoTherminatorWeb, :live_view
alias GeoTherminator.PumpAPI.Auth
alias GeoTherminator.PumpAPI.Device
require Logger
@impl true
def mount(%{"id" => str_id}, _session, socket) do
socket =
with {id, ""} <- Integer.parse(str_id),
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
CubDB.put(GeoTherminator.DB, :viewing_pump, str_id)
assign(socket,
pid: pid,
device: device,
status: status,
registers: registers,
opstat: opstat
)
else
err ->
Logger.debug("EXPLODY #{inspect(err)}")
assign(socket, pid: nil, device: nil, status: nil, registers: nil, opstat: nil)
end
{:ok, socket}
end
@impl true
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
_ = Device.Server.set_temp(socket.assigns.pid, current + 1)
optimistic_status = %Device.Status{
socket.assigns.status
| is_heating_effect_set_by_user: true
}
{:noreply, assign(socket, status: optimistic_status)}
else
{:noreply, socket}
end
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
_ = Device.Server.set_temp(socket.assigns.pid, current - 1)
optimistic_status = %Device.Status{
socket.assigns.status
| is_heating_effect_set_by_user: true
}
{:noreply, assign(socket, status: optimistic_status)}
else
{:noreply, socket}
end
end
@impl true
def handle_info(msg, socket)
def handle_info({:device, device}, socket) do
{:noreply, assign(socket, device: device)}
end
def handle_info({:status, status}, socket) do
{:noreply, assign(socket, status: status)}
end
def handle_info({:registers, registers}, socket) do
{:noreply, assign(socket, registers: registers)}
end
def handle_info({:opstat, opstat}, socket) do
{:noreply, assign(socket, opstat: opstat)}
end
def handle_info(msg, socket) do
Logger.debug("Unknown message: #{inspect(msg)}")
{:noreply, socket}
end
end

View file

@ -1,16 +0,0 @@
<%= if is_nil(@pid) do %>
Some fail happened.
<% else %>
<%= if is_nil(@device) do %>
Loading pump data…
<% else %>
<.live_component
module={GeoTherminatorWeb.Components.MainView}
id={"pump-#{@device.id}"}
device={@device}
status={@status}
registers={@registers}
opstat={@opstat}
/>
<% end %>
<% end %>

View file

@ -1,45 +0,0 @@
defmodule GeoTherminatorWeb.Router do
use GeoTherminatorWeb, :router
pipeline :browser do
plug :accepts, ["html"]
plug :fetch_session
plug :fetch_live_flash
plug :put_root_layout, {GeoTherminatorWeb.LayoutView, :root}
plug :protect_from_forgery
plug :put_secure_browser_headers
end
pipeline :api do
plug :accepts, ["json"]
end
scope "/", GeoTherminatorWeb do
pipe_through :browser
live "/", MainLive.Index
live "/devices", MainLive.DeviceList
live "/pump/:id", MainLive.Pump
end
# Other scopes may use custom stacks.
# scope "/api", GeoTherminatorWeb do
# pipe_through :api
# end
# Enables LiveDashboard only for development
#
# If you want to use the LiveDashboard in production, you should put
# it behind authentication and allow only admins to access it.
# If your application does not have an admins-only section yet,
# you can use Plug.BasicAuth to set up some basic authentication
# as long as you are also using SSL (which you should anyway).
if Mix.env() in [:dev, :test] do
import Phoenix.LiveDashboard.Router
scope "/" do
pipe_through :browser
live_dashboard "/dashboard", metrics: GeoTherminatorWeb.Telemetry
end
end
end

View file

@ -1,48 +0,0 @@
defmodule GeoTherminatorWeb.Telemetry do
use Supervisor
import Telemetry.Metrics
def start_link(arg) do
Supervisor.start_link(__MODULE__, arg, name: __MODULE__)
end
@impl true
def init(_arg) do
children = [
# Telemetry poller will execute the given period measurements
# every 10_000ms. Learn more here: https://hexdocs.pm/telemetry_metrics
{:telemetry_poller, measurements: periodic_measurements(), period: 10_000}
# Add reporters as children of your supervision tree.
# {Telemetry.Metrics.ConsoleReporter, metrics: metrics()}
]
Supervisor.init(children, strategy: :one_for_one)
end
def metrics do
[
# Phoenix Metrics
summary("phoenix.endpoint.stop.duration",
unit: {:native, :millisecond}
),
summary("phoenix.router_dispatch.stop.duration",
tags: [:route],
unit: {:native, :millisecond}
),
# VM Metrics
summary("vm.memory.total", unit: {:byte, :kilobyte}),
summary("vm.total_run_queue_lengths.total"),
summary("vm.total_run_queue_lengths.cpu"),
summary("vm.total_run_queue_lengths.io")
]
end
defp periodic_measurements do
[
# A module, function and arguments to be invoked periodically.
# This function must call :telemetry.execute/3 and a metric must be added above.
# {GeoTherminatorWeb, :count_users, []}
]
end
end

View file

@ -1,5 +0,0 @@
<main class="container">
<p class="alert alert-info" role="alert"><%= get_flash(@conn, :info) %></p>
<p class="alert alert-danger" role="alert"><%= get_flash(@conn, :error) %></p>
<%= @inner_content %>
</main>

View file

@ -1,11 +0,0 @@
<main class="container">
<p class="alert alert-info" role="alert"
phx-click="lv:clear-flash"
phx-value-key="info"><%= live_flash(@flash, :info) %></p>
<p class="alert alert-danger" role="alert"
phx-click="lv:clear-flash"
phx-value-key="error"><%= live_flash(@flash, :error) %></p>
<%= @inner_content %>
</main>

View file

@ -1,15 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8"/>
<meta http-equiv="X-UA-Compatible" content="IE=edge"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<%= csrf_meta_tag() %>
<%= live_title_tag assigns[:page_title] || "Main", suffix: " · GeoTherminator" %>
<link phx-track-static rel="stylesheet" href={Routes.static_path(@conn, "/assets/app.css")}/>
<script defer phx-track-static type="text/javascript" src={Routes.static_path(@conn, "/assets/app.js")}></script>
</head>
<body>
<%= @inner_content %>
</body>
</html>

View file

@ -1,47 +0,0 @@
defmodule GeoTherminatorWeb.ErrorHelpers do
@moduledoc """
Conveniences for translating and building error messages.
"""
use Phoenix.HTML
@doc """
Generates tag for inlined form input errors.
"""
def error_tag(form, field) do
Enum.map(Keyword.get_values(form.errors, field), fn error ->
content_tag(:span, translate_error(error),
class: "invalid-feedback",
phx_feedback_for: input_name(form, field)
)
end)
end
@doc """
Translates an error message using gettext.
"""
def translate_error({msg, opts}) do
# When using gettext, we typically pass the strings we want
# to translate as a static argument:
#
# # Translate "is invalid" in the "errors" domain
# dgettext("errors", "is invalid")
#
# # Translate the number of files with plural rules
# dngettext("errors", "1 file", "%{count} files", count)
#
# Because the error messages we show in our forms and APIs
# are defined inside Ecto, we need to translate them dynamically.
# This requires us to call the Gettext module passing our gettext
# backend as first argument.
#
# Note we use the "errors" domain, which means translations
# should be written to the errors.po file. The :count option is
# set by Ecto and indicates we should also apply plural rules.
if count = opts[:count] do
Gettext.dngettext(GeoTherminatorWeb.Gettext, "errors", msg, msg, count, opts)
else
Gettext.dgettext(GeoTherminatorWeb.Gettext, "errors", msg, opts)
end
end
end

View file

@ -1,16 +0,0 @@
defmodule GeoTherminatorWeb.ErrorView do
use GeoTherminatorWeb, :view
# If you want to customize a particular status code
# for a certain format, you may uncomment below.
# def render("500.html", _assigns) do
# "Internal Server Error"
# end
# By default, Phoenix returns the status message from
# the template name. For example, "404.html" becomes
# "Not Found".
def template_not_found(template, _assigns) do
Phoenix.Controller.status_message_from_template(template)
end
end

View file

@ -1,7 +0,0 @@
defmodule GeoTherminatorWeb.LayoutView do
use GeoTherminatorWeb, :view
# Phoenix LiveDashboard is available only in development by default,
# so we instruct Elixir to not warn if the dashboard route is missing.
@compile {:no_warn_undefined, {Routes, :live_dashboard_path, 2}}
end

View file

@ -2,25 +2,22 @@
# You typically do not need to edit this file
packages = [
{ name = "certifi", version = "2.9.0", build_tools = ["rebar3"], requirements = [], otp_app = "certifi", source = "hex", outer_checksum = "266DA46BDB06D6C6D35FDE799BCB28D36D985D424AD7C08B5BB48F5B5CDD4641" },
{ 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_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 = "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 = "idna", version = "6.1.1", build_tools = ["rebar3"], requirements = ["unicode_util_compat"], otp_app = "idna", source = "hex", outer_checksum = "92376EB7894412ED19AC475E4A86F7B413C1B9FBB5BD16DCCD57934157944CEA" },
{ name = "metrics", version = "1.0.1", build_tools = ["rebar3"], requirements = [], otp_app = "metrics", source = "hex", outer_checksum = "69B09ADDDC4F74A40716AE54D140F93BEB0FB8978D8636EADED0C31B6F099F16" },
{ name = "mimerl", version = "1.2.0", build_tools = ["rebar3"], requirements = [], otp_app = "mimerl", source = "hex", outer_checksum = "F278585650AA581986264638EBF698F8BB19DF297F66AD91B18910DFC6E19323" },
{ name = "parse_trans", version = "3.3.1", build_tools = ["rebar3"], requirements = [], otp_app = "parse_trans", source = "hex", outer_checksum = "07CD9577885F56362D414E8C4C4E6BDF10D43A8767ABB92D24CBE8B24C54888B" },
{ 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 = "thoas", version = "0.4.0", build_tools = ["rebar3"], requirements = [], otp_app = "thoas", source = "hex", outer_checksum = "442296847ACA11DB8D25180693D7CA3073D6D7179F66952F07B16415306513B6" },
{ name = "unicode_util_compat", version = "0.7.0", build_tools = ["rebar3"], requirements = [], otp_app = "unicode_util_compat", source = "hex", outer_checksum = "25EEE6D67DF61960CF6A794239566599B09E17E668D3700247BC498638152521" },
{ name = "gleam_fetch", version = "0.2.0", build_tools = ["gleam"], requirements = ["gleam_javascript", "gleam_http"], otp_app = "gleam_fetch", source = "hex", outer_checksum = "D0C9E9CAE8D6EFCCC3A9FF817DCA9ED327097222086D91DE4F6CA8FBAB02D79F" },
{ name = "gleam_http", version = "3.5.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_http", source = "hex", outer_checksum = "FAE9AE3EB1CA90C2194615D20FFFD1E28B630E84DACA670B28D959B37BCBB02C" },
{ name = "gleam_javascript", version = "0.6.1", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_javascript", source = "hex", outer_checksum = "BFEBB63ABE4A1694E07DEFD19B160C2980304B5D775A89D4B02E7DE7C9D8008B" },
{ name = "gleam_json", version = "0.6.0", build_tools = ["gleam"], requirements = ["thoas", "gleam_stdlib"], otp_app = "gleam_json", source = "hex", outer_checksum = "C6CC5BEECA525117E97D0905013AB3F8836537455645DDDD10FE31A511B195EF" },
{ name = "gleam_stdlib", version = "0.30.2", build_tools = ["gleam"], requirements = [], otp_app = "gleam_stdlib", source = "hex", outer_checksum = "8D8BF3790AA31176B1E1C0B517DD74C86DA8235CF3389EA02043EE4FD82AE3DC" },
{ name = "lustre", version = "3.0.5", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "lustre", source = "hex", outer_checksum = "6DD8FC3238623EF3CEC425780596C13FC7F7FFD53B3E44073669B61E6B5E4F02" },
{ name = "plinth", version = "0.1.3", build_tools = ["gleam"], requirements = ["gleam_stdlib", "gleam_javascript"], otp_app = "plinth", source = "hex", outer_checksum = "E81BA6A6CEAFFADBCB85B04DC817A4CDC43AFA7BB6AE56CE0B7C7E66D1C9ADD1" },
{ name = "thoas", version = "0.4.1", build_tools = ["rebar3"], requirements = [], otp_app = "thoas", source = "hex", outer_checksum = "4918D50026C073C4AB1388437132C77A6F6F7C8AC43C60C13758CC0ADCE2134E" },
{ name = "varasto", version = "1.0.0", build_tools = ["gleam"], requirements = ["gleam_stdlib", "gleam_json", "plinth"], otp_app = "varasto", source = "hex", outer_checksum = "0621E5BFD0B9B7F7D19B8FC6369C6E2EAC5C1F3858A1E5E51342F5BCE10C3728" },
]
[requirements]
gleam_erlang = "~> 0.17.1"
gleam_hackney = "~> 0.2.1"
gleam_http = "~> 3.1"
gleam_json = "~> 0.5.0"
gleam_stdlib = "~> 0.25"
gleam_fetch = { version = "~> 0.2.0" }
gleam_http = { version = "~> 3.5" }
gleam_javascript = { version = "~> 0.6.0" }
gleam_json = { version = "~> 0.6.0" }
gleam_stdlib = { version = "~> 0.30" }
lustre = { version = "~> 3.0" }
varasto = { version = "~> 1.0" }

97
mix.exs
View file

@ -1,97 +0,0 @@
defmodule GeoTherminator.MixProject do
use Mix.Project
@app :geo_therminator
def project do
[
app: @app,
version: "0.3.0",
elixir: "~> 1.13",
elixirc_paths: elixirc_paths(Mix.env()),
erlc_paths: [
"build/dev/erlang/#{@app}/_gleam_artefacts"
],
erlc_include_path: "build/dev/erlang/#{@app}/include",
archives: [mix_gleam: "~> 0.6.1"],
compilers:
if Mix.env() != :test do
[:gleam]
else
[]
end ++ Mix.compilers(),
start_permanent: Mix.env() == :prod,
aliases: aliases(),
deps: deps(),
releases: [
android: [
include_executables_for: [:unix],
include_erts: false
]
]
]
end
# Configuration for the OTP application.
#
# Type `mix help compile.app` for more information.
def application do
[
mod: {GeoTherminator.Application, []},
extra_applications: [:logger, :runtime_tools, :logger, :ssl, :crypto, :sasl, :tools, :inets]
]
end
# Specifies which paths to compile per environment.
defp elixirc_paths(:test), do: ["lib", "test/support"]
defp elixirc_paths(_), do: ["lib"]
# Specifies your project dependencies.
#
# Type `mix help deps` for examples and options.
defp deps do
deps_list = [
{:phoenix, "~> 1.6.2"},
{:phoenix_html, "~> 3.0"},
{:phoenix_live_reload, "~> 1.2", only: :dev},
{:phoenix_live_view, "~> 0.17.11"},
{:floki, ">= 0.30.0", only: :test},
{:phoenix_live_dashboard, "~> 0.6"},
{:esbuild, "~> 0.2", runtime: Mix.env() == :dev},
{:telemetry_metrics, "~> 0.6"},
{:telemetry_poller, "~> 1.0"},
{:gettext, "~> 0.18"},
{:jason, "~> 1.2"},
{:plug_cowboy, "~> 2.5"},
{:dotenv_parser, "~> 1.2"},
{:finch, "~> 0.9.0"},
{:desktop, "~> 1.4"},
{:cubdb, "~> 2.0"},
{:gleam_stdlib, "~> 0.25"},
{:gleam_http, "~> 3.1"},
{:gleam_hackney, "~> 0.2.1"},
{:gleam_erlang, "~> 0.17.1"},
{:gleam_json, "~> 0.5.0"}
]
if Mix.target() in [:android, :ios] do
deps_list ++ [{:wx, "~> 1.0", hex: :bridge, targets: [:android, :ios]}]
else
deps_list
end
end
# Aliases are shortcuts or tasks specific to the current project.
# For example, to install project dependencies and perform other setup tasks, run:
#
# $ mix setup
#
# See the documentation for `Mix` for more info on aliases.
defp aliases do
[
setup: ["deps.get"],
"assets.deploy": ["esbuild default --minify", "phx.digest"],
"deps.get": ["deps.get", "gleam.deps.get"]
]
end
end

View file

@ -1,56 +0,0 @@
%{
"castore": {:hex, :castore, "0.1.20", "62a0126cbb7cb3e259257827b9190f88316eb7aa3fdac01fd6f2dfd64e7f46e9", [:mix], [], "hexpm", "a020b7650529c986c454a4035b6b13a328e288466986307bea3aadb4c95ac98a"},
"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_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"},
"cowlib": {:hex, :cowlib, "2.11.0", "0b9ff9c346629256c42ebe1eeb769a83c6cb771a6ee5960bd110ab0b9b872063", [:make, :rebar3], [], "hexpm", "2b3e9da0b21c4565751a6d4901c20d1b4cc25cbb7fd50d91d2ab6dd287bc86a9"},
"cubdb": {:hex, :cubdb, "2.0.2", "d4253885084dae37a8ff73887d232864eb38ecac962aa08543e686b0183a1d62", [:mix], [], "hexpm", "c99cc8f9e6c4deb98d16cca5ded1928edd22e48b4736b76e8a1a85367d7fe921"},
"dbus": {:hex, :dbus, "0.8.0", "7c800681f35d909c199265e55a8ee4aea9ebe4acccce77a0740f89f29cc57648", [:make], [], "hexpm", "a9784f2d9717ffa1f74169144a226c39633ac0d9c7fe8cb3594aeb89c827cca5"},
"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"},
"dotenv_parser": {:hex, :dotenv_parser, "1.2.0", "f062900aeb57727b619aeb182fa4a8b1cbb7b4260ebec2b70b3d5c064885aff3", [:mix], [], "hexpm", "eddd69e7fde28618adb2e4153fa380db5c56161b32341e7a4e0530d86987c47f"},
"esbuild": {:hex, :esbuild, "0.6.0", "9ba6ead054abd43cb3d7b14946a0cdd1493698ccd8e054e0e5d6286d7f0f509c", [:mix], [{:castore, ">= 0.0.0", [hex: :castore, repo: "hexpm", optional: false]}], "hexpm", "30f9a05d4a5bab0d3e37398f312f80864e1ee1a081ca09149d06d474318fd040"},
"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"},
"expo": {:hex, :expo, "0.1.0", "d4e932bdad052c374118e312e35280f1919ac13881cb3ac07a209a54d0c81dd8", [:mix], [], "hexpm", "c22c536021c56de058aaeedeabb4744eb5d48137bacf8c29f04d25b6c6bbbf45"},
"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"},
"floki": {:hex, :floki, "0.34.0", "002d0cc194b48794d74711731db004fafeb328fe676976f160685262d43706a8", [:mix], [], "hexpm", "9c3a9f43f40dde00332a589bd9d389b90c1f518aef500364d00636acc5ebc99c"},
"gettext": {:hex, :gettext, "0.21.0", "15bbceb20b317b706a8041061a08e858b5a189654128618b53746bf36c84352b", [:mix], [{:expo, "~> 0.1.0", [hex: :expo, repo: "hexpm", optional: false]}], "hexpm", "04a66db4103b6d1d18f92240bb2c73167b517229316b7bef84e4eebbfb2f14f6"},
"gleam_erlang": {:hex, :gleam_erlang, "0.17.1", "40fff501e8ca39fa166f4c12ed13bb57e94fc5bb59a93b4446687d82d4a12ff9", [:gleam], [{:gleam_stdlib, "~> 0.22", [hex: :gleam_stdlib, repo: "hexpm", optional: false]}], "hexpm", "baaa84f5bcc4477e809ba3e03bb3009a3894a6544c1511626c44408e39db2ae6"},
"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_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"},
"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"},
"jason": {:hex, :jason, "1.4.0", "e855647bc964a44e2f67df589ccf49105ae039d4179db7f6271dfd3843dc27e6", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "79a3791085b2a0f743ca04cec0f7be26443738779d09302e01318f97bdb82121"},
"metrics": {:hex, :metrics, "1.0.1", "25f094dea2cda98213cecc3aeff09e940299d950904393b2a29d191c346a8486", [:rebar3], [], "hexpm", "69b09adddc4f74a40716ae54d140f93beb0fb8978d8636eaded0c31b6f099f16"},
"mime": {:hex, :mime, "2.0.3", "3676436d3d1f7b81b5a2d2bd8405f412c677558c81b1c92be58c00562bb59095", [:mix], [], "hexpm", "27a30bf0db44d25eecba73755acf4068cbfe26a4372f9eb3e4ea3a45956bff6b"},
"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"},
"nimble_options": {:hex, :nimble_options, "0.4.0", "c89babbab52221a24b8d1ff9e7d838be70f0d871be823165c94dd3418eea728f", [:mix], [], "hexpm", "e6701c1af326a11eea9634a3b1c62b475339ace9456c1a23ec3bc9a847bca02d"},
"nimble_pool": {:hex, :nimble_pool, "0.2.6", "91f2f4c357da4c4a0a548286c84a3a28004f68f05609b4534526871a22053cde", [:mix], [], "hexpm", "1c715055095d3f2705c4e236c18b618420a35490da94149ff8b580a2144f653f"},
"oncrash": {:hex, :oncrash, "0.1.0", "9cf4ae8eba4ea250b579470172c5e9b8c75418b2264de7dbcf42e408d62e30fb", [:mix], [], "hexpm", "6968e775491cd857f9b6ff940bf2574fd1c2fab84fa7e14d5f56c39174c00018"},
"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_html": {:hex, :phoenix_html, "3.2.0", "1c1219d4b6cb22ac72f12f73dc5fad6c7563104d083f711c3fcd8551a1f4ae11", [:mix], [{:plug, "~> 1.5", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "36ec97ba56d25c0136ef1992c37957e4246b649d620958a1f9fa86165f8bc54f"},
"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_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_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_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_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_crypto": {:hex, :plug_crypto, "1.2.3", "8f77d13aeb32bfd9e654cb68f0af517b371fb34c56c9f2b58fe3df1235c1251a", [:mix], [], "hexpm", "b5672099c6ad5c202c45f5a403f21a3411247f164e4a8fab056e5cd8a290f4a2"},
"ranch": {:hex, :ranch, "1.8.0", "8c7a100a139fd57f17327b6413e4167ac559fbc04ca7448e9be9057311597a1d", [:make, :rebar3], [], "hexpm", "49fbcfd3682fab1f5d109351b61257676da1a2fdbe295904176d5e521a2ddfe5"},
"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"},
"telemetry": {:hex, :telemetry, "1.2.0", "a8ce551485a9a3dac8d523542de130eafd12e40bbf76cf0ecd2528f24e812a44", [:rebar3], [], "hexpm", "1427e73667b9a2002cf1f26694c422d5c905df889023903c4518921d53e3e883"},
"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"},
"thoas": {:hex, :thoas, "0.4.0", "86a72ccdc5ec388a13f9f843bcd6c1076640233b95440e47ffb8e3c0dbdb5a17", [:rebar3], [], "hexpm", "442296847aca11db8d25180693d7ca3073d6d7179f66952f07b16415306513b6"},
"unicode_util_compat": {:hex, :unicode_util_compat, "0.7.0", "bc84380c9ab48177092f43ac89e4dfa2c6d62b40b8bd132b1059ecc7232f9a78", [:rebar3], [], "hexpm", "25eee6d67df61960cf6a794239566599b09e17e668d3700247bc498638152521"},
}

View file

@ -1,11 +0,0 @@
## `msgid`s in this file come from POT (.pot) files.
##
## Do not add, change, or remove `msgid`s manually here as
## they're tied to the ones in the corresponding POT file
## (with the same domain).
##
## Use `mix gettext.extract --merge` or `mix gettext.merge`
## to merge POT files into PO files.
msgid ""
msgstr ""
"Language: en\n"

View file

@ -1,10 +0,0 @@
## This is a PO Template file.
##
## `msgid`s here are often extracted from source code.
## Add new translations manually only if they're dynamic
## translations that can't be statically extracted.
##
## Run `mix gettext.extract` to bring this file up to
## date. Leave `msgstr`s empty as changing them here has no
## effect: edit them in PO (`.po`) files instead.

52
priv/index.html Normal file
View file

@ -0,0 +1,52 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>GeoTherminator</title>
<style>
.pump {
position: relative;
padding: 10px;
}
.pump svg {
width: 100%;
height: 100%;
object-fit: contain;
margin: 0 auto;
}
.pump-btn:hover {
cursor: pointer;
}
.pump-btn-loading:hover {
cursor: progress;
}
.page-container {
padding: 10px;
}
</style>
<script type="module">
import "./build/dev/javascript/geo_therminator/priv/config.mjs";
import { main } from "./build/dev/javascript/geo_therminator/geo_t/web.mjs";
document.addEventListener("DOMContentLoaded", () => {
main();
});
</script>
</head>
<body>
<main data-lustre-app></main>
<noscript>Sorry mate, JavaScript is required. :/</noscript>
</body>
</html>

View file

@ -1,11 +0,0 @@
defmodule GeoTherminator.Repo.Migrations.CreatePaskas do
use Ecto.Migration
def change do
create table(:paskas) do
add :name, :string
timestamps()
end
end
end

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 KiB

View file

@ -1,5 +0,0 @@
# See http://www.robotstxt.org/robotstxt.html for documentation on how to use the robots.txt file
#
# To ban all spiders from the entire site uncomment the next two lines:
# User-agent: *
# Disallow: /

View file

@ -1,5 +0,0 @@
@echo off
rem Set the release to work across nodes.
rem RELEASE_DISTRIBUTION must be "sname" (local), "name" (distributed) or "none".
rem set RELEASE_DISTRIBUTION=name
rem set RELEASE_NODE=<%= @release.name %>

View file

@ -1,17 +0,0 @@
#!/bin/sh
# Sets and enables heart (recommended only in daemon mode)
# case $RELEASE_COMMAND in
# daemon*)
# HEART_COMMAND="$RELEASE_ROOT/bin/$RELEASE_NAME $RELEASE_COMMAND"
# export HEART_COMMAND
# export ELIXIR_ERL_OPTIONS="-heart"
# ;;
# *)
# ;;
# esac
# Set the release to work across nodes.
# RELEASE_DISTRIBUTION must be "sname" (local), "name" (distributed) or "none".
# export RELEASE_DISTRIBUTION=name
# export RELEASE_NODE=<%= @release.name %>

View file

@ -1,11 +0,0 @@
## Customize flags given to the VM: https://erlang.org/doc/man/erl.html
## -mode/-name/-sname/-setcookie are configured via env vars, do not set them here
## Number of dirty schedulers doing IO work (file, sockets, and others)
##+SDio 5
## Increase number of concurrent ports/sockets
##+Q 65536
## Tweak GC to run more often
##-env ERL_FULLSWEEP_AFTER 10

View file

@ -1,11 +0,0 @@
## Customize flags given to the VM: https://erlang.org/doc/man/erl.html
## -mode/-name/-sname/-setcookie are configured via env vars, do not set them here
## Number of dirty schedulers doing IO work (file, sockets, and others)
##+SDio 5
## Increase number of concurrent ports/sockets
##+Q 65536
## Tweak GC to run more often
##-env ERL_FULLSWEEP_AFTER 10

View file

@ -1,328 +0,0 @@
//// Implementation of Azure B2C authentication as used by the Thermia API.
//// 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
import gleam/json
import gleam/base
import gleam/hackney
import gleam/uri
import gleam/http
import gleam/http/request
import gleam/http/response
import gleam/dynamic
import gleam/string
import gleam/list
import gleam/result
import gleam/int
import azure/utils
import helpers/crypto
import helpers/uri as uri_helpers
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 {
Tokens(
access_token: String,
access_token_expires_in: Int,
refresh_token: String,
refresh_token_expires_in: Int,
)
}
pub type B2CError {
B2CError(msg: String)
}
type Cookies =
List(#(String, String))
type AuthInfo {
AuthInfo(state_code: String, csrf_token: String, cookies: Cookies)
}
type SelfAsserted {
SelfAsserted(cookies: Cookies)
}
type Confirmed {
Confirmed(code: String)
}
/// Authenticate to API with B2C authentication, returning the tokens needed
/// for using the API.
pub fn authenticate(
username: String,
password: String,
) -> Result(Tokens, B2CError) {
let code_challenge = utils.generate_challenge(challenge_length)
try auth_info = authorize(code_challenge)
try self_asserted = signin(username, password, auth_info)
try confirmed = confirm(self_asserted, auth_info)
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"),
#("code_challenge", hash_challenge(code_challenge)),
#("code_challenge_method", "S256"),
..base_request_data()
]
let req = build_req(authorize_url(), http.Get)
let req = request.set_query(req, auth_data)
try resp = run_req(req)
let body_split = string.split(resp.body, "\n")
try settings =
list.find(
body_split,
fn(line) { string.starts_with(line, b2c_authorize_prefix) },
)
|> b2c_error("Authorize settings string not found.")
let prefix_len = string.length(b2c_authorize_prefix)
let settings_json =
string.slice(settings, prefix_len, string.length(settings) - prefix_len - 2)
try data =
json.decode(settings_json, using: dynamic.dynamic)
|> b2c_error(
"Authorize settings JSON parsing error: " <> string.inspect(settings_json),
)
try csrf_token = data_get(data, "csrf", dynamic.string)
try state_code_block = data_get(data, "transId", dynamic.string)
try state_code =
state_code_block
|> string.split("=")
|> list.at(1)
|> b2c_error("State code parsing error: " <> state_code_block)
Ok(AuthInfo(
state_code: state_code,
csrf_token: csrf_token,
cookies: response.get_cookies(resp),
))
}
fn signin(
username: String,
password: String,
auth_info: AuthInfo,
) -> Result(SelfAsserted, B2CError) {
let self_asserted_data = [
#("request_type", "RESPONSE"),
#("signInName", username),
#("password", password),
]
let req =
build_req(self_asserted_url(), http.Post)
|> request.set_body(uri_helpers.form_urlencoded_serialize(
self_asserted_data,
))
|> request.set_query(base_query(auth_info))
|> request.set_header("x-csrf-token", auth_info.csrf_token)
let req =
list.fold(
auth_info.cookies,
req,
fn(acc, cookie) {
let #(name, value) = cookie
request.set_cookie(acc, name, value)
},
)
try resp = run_req(req)
case string.contains(resp.body, "{\"status\":\"400\"") {
True -> Error(B2CError(msg: "Wrong credentials."))
False -> Ok(SelfAsserted(cookies: response.get_cookies(resp)))
}
}
fn confirm(
self_asserted: SelfAsserted,
auth_info: AuthInfo,
) -> Result(Confirmed, B2CError) {
let csrf_cookie_key = "x-ms-cpim-csrf"
try csrf_cookie =
list.key_find(auth_info.cookies, csrf_cookie_key)
|> b2c_error("CSRF cookie not found in auth info.")
let cookies = [#(csrf_cookie_key, csrf_cookie), ..self_asserted.cookies]
let req = build_req(confirm_url(), http.Get)
let req =
list.fold(
cookies,
req,
fn(acc, cookie) { request.set_cookie(acc, cookie.0, cookie.1) },
)
|> request.set_query([
#("csrf_token", auth_info.csrf_token),
..base_query(auth_info)
])
try resp =
req
|> hackney.send()
|> b2c_error("Confirm HTTP request failed.")
try resp = case resp.status {
302 -> Ok(resp)
_ -> Error(B2CError(msg: "Confirm HTTP request bad error code."))
}
try location =
response.get_header(resp, "location")
|> b2c_error("Location not found for confirm response.")
try code =
location
|> string.split("code=")
|> list.at(1)
|> b2c_error("Confirmation code not found.")
Ok(Confirmed(code: code))
}
fn get_tokens(
confirmed: Confirmed,
code_challenge: String,
) -> Result(Tokens, B2CError) {
let request_token_data = [
#("code", confirmed.code),
#("code_verifier", code_challenge),
#("grant_type", "authorization_code"),
..base_request_data()
]
let req =
build_req(get_token_url(), http.Post)
|> request.set_body(uri_helpers.form_urlencoded_serialize(
request_token_data,
))
try resp = run_req(req)
try data =
json.decode(resp.body, using: dynamic.dynamic)
|> b2c_error("Get tokens JSON parsing error: " <> string.inspect(resp.body))
try token = data_get(data, "access_token", dynamic.string)
try expires_in = data_get(data, "expires_in", dynamic.int)
try refresh_token = data_get(data, "refresh_token", dynamic.string)
try refresh_token_expires_in =
data_get(data, "refresh_token_expires_in", dynamic.int)
Ok(Tokens(
access_token: token,
access_token_expires_in: expires_in,
refresh_token: refresh_token,
refresh_token_expires_in: refresh_token_expires_in,
))
}
fn hash_challenge(challenge: String) -> String {
let hashed = crypto.hash(crypto.Sha256, challenge)
base.url_encode64(hashed, False)
}
fn authorize_url() -> String {
b2c_auth_url <> "/oauth2/v2.0/authorize"
}
fn self_asserted_url() -> String {
b2c_auth_url <> "/SelfAsserted"
}
fn confirm_url() -> String {
b2c_auth_url <> "/api/CombinedSigninAndSignup/confirmed"
}
fn get_token_url() -> String {
b2c_auth_url <> "/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)
let req = request.set_method(req, method)
case method {
http.Post ->
request.set_header(
req,
"content-type",
"application/x-www-form-urlencoded; charset=UTF-8",
)
_ -> req
}
}
fn run_req(
req: request.Request(String),
) -> Result(response.Response(String), B2CError) {
try resp =
req
|> hackney.send()
|> b2c_error("HTTP request failed.")
case resp.status {
200 -> Ok(resp)
code ->
Error(B2CError(
msg: "Not OK response code " <> int.to_string(code) <> " from URL " <> uri.to_string(request.to_uri(
req,
)) <> "\n\n" <> string.inspect(resp),
))
}
}
fn base_request_data() -> List(#(String, String)) {
[
#("client_id", b2c_client_id),
#("scope", b2c_scope),
#("redirect_uri", b2c_redirect_uri),
]
}
fn base_query(auth_info: AuthInfo) -> List(#(String, String)) {
[
#("tx", "StateProperties=" <> auth_info.state_code),
#("p", "B2C_1A_SignUpOrSigninOnline"),
]
}
fn data_get(
data: dynamic.Dynamic,
key: String,
data_type: dynamic.Decoder(a),
) -> Result(a, B2CError) {
parsing.data_get(data, key, data_type, B2CError)
}
fn b2c_error(r: Result(a, b), msg: String) -> Result(a, B2CError) {
result.replace_error(r, B2CError(msg))
}

3
src/bitwise_ffi.mjs Normal file
View file

@ -0,0 +1,3 @@
export function band(a, b) {
return a & b;
}

3
src/config_ffi.mjs Normal file
View file

@ -0,0 +1,3 @@
export function get(key) {
return globalThis.__geo_therminator_config?.[key];
}

24
src/crypto_ffi.mjs Normal file
View file

@ -0,0 +1,24 @@
import { Ok, Error, BitString } from "./gleam.mjs";
let crypto;
const encoder = new TextEncoder();
export async function hash(algo, data) {
if (!crypto) {
if (globalThis.crypto) {
crypto = globalThis.crypto;
} else {
crypto = await import("node:crypto");
}
}
data = encoder.encode(data);
try {
const hash = await crypto.subtle.digest(algo, data);
const hashArray = new Uint8Array(hash);
return new Ok(new BitString(hashArray));
} catch (e) {
return new Error(e);
}
}

38
src/date_ffi.mjs Normal file
View file

@ -0,0 +1,38 @@
import { Ok, Error } from "./gleam.mjs";
function construct(input) {
const d = new Date(input);
if (isNaN(d.valueOf())) {
return new Error(undefined);
} else {
return new Ok(d);
}
}
export const from_iso8601 = construct;
export const from_unix = construct;
export function to_iso8601(date) {
return date.toISOString();
}
export function to_unix(date) {
return date.getTime();
}
export function unix_now() {
return Date.now();
}
export function now() {
return new Date();
}
export function equals(a, b) {
return a.getTime() === b.getTime();
}
export function bigger_than(a, b) {
return a > b;
}

9
src/fetch_ffi.mjs Normal file
View file

@ -0,0 +1,9 @@
export function preventRedirection(request) {
return new Request(request, {
redirect: "manual",
});
}
export function url(resp) {
return resp.url;
}

410
src/geo_t/azure/b2c.gleam Normal file
View file

@ -0,0 +1,410 @@
//// Implementation of Azure B2C authentication as used by the Thermia API.
//// 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
import gleam/json
import gleam/base
import gleam/uri
import gleam/http
import gleam/http/request
import gleam/http/response
import gleam/dynamic
import gleam/string
import gleam/list
import gleam/result
import gleam/int
import gleam/fetch
import gleam/bool
import gleam/javascript/promise.{Promise}
import geo_t/azure/utils
import geo_t/helpers/crypto
import geo_t/helpers/uri as uri_helpers
import geo_t/helpers/parsing
import geo_t/helpers/promise as promise_helpers
import geo_t/helpers/fetch as fetch_helpers
import geo_t/config.{Config}
const challenge_length = 43
const b2c_authorize_prefix = "var SETTINGS = "
pub type Tokens {
Tokens(
access_token: String,
access_token_expires_in: Int,
refresh_token: String,
refresh_token_expires_in: Int,
)
}
pub type Credentials {
WithToken(String)
WithUsernamePassword(username: String, password: String)
}
pub type B2CError {
HashError(inner: crypto.HashingError)
RequestError(inner: fetch.FetchError)
ContentError(msg: String)
}
type Cookies =
List(#(String, String))
type AuthInfo {
AuthInfo(state_code: String, csrf_token: String, cookies: Cookies)
}
type SelfAsserted {
SelfAsserted(cookies: Cookies)
}
type Confirmed {
Confirmed(code: String)
}
/// Authenticate to API and get new tokens with a username/password combination,
/// or by using the refresh token.
pub fn authenticate_or_refresh(
config: Config,
credentials: Credentials,
) -> Promise(Result(Tokens, B2CError)) {
case credentials {
WithUsernamePassword(username, password) ->
authenticate(config, username, password)
WithToken(refresh_token) -> refresh(config, refresh_token)
}
}
pub fn refresh(
config: Config,
token: String,
) -> Promise(Result(Tokens, B2CError)) {
let request_data = [
#("refresh_token", token),
#("grant_type", "refresh_token"),
..base_request_data(config)
]
let req =
build_req(get_token_url(config), http.Post)
|> request.set_body(uri_helpers.form_urlencoded_serialize(request_data))
use resp <- promise.try_await(run_req(req))
use data <- promise.map_try(promise.resolve(
json.decode(resp.body, using: dynamic.dynamic)
|> b2c_error("Get tokens JSON parsing error: " <> string.inspect(resp.body)),
))
parse_tokens(data)
}
/// Authenticate to API with B2C authentication, returning the tokens needed
/// for using the API.
pub fn authenticate(
config: Config,
username: String,
password: String,
) -> Promise(Result(Tokens, B2CError)) {
let code_challenge = utils.generate_challenge(challenge_length)
use auth_info <- promise.try_await(authorize(config, code_challenge))
use self_asserted <- promise.try_await(signin(
config,
username,
password,
auth_info,
))
use confirmed <- promise.try_await(confirm(config, self_asserted, auth_info))
get_tokens(config, confirmed, code_challenge)
}
fn authorize(
config: Config,
code_challenge: String,
) -> Promise(Result(AuthInfo, B2CError)) {
use challenge <- promise.try_await(
hash_challenge(code_challenge)
|> promise_helpers.map_error(HashError),
)
let auth_data = [
#("response_type", "code"),
#("code_challenge", challenge),
#("code_challenge_method", "S256"),
..base_request_data(config)
]
let req = build_req(authorize_url(config), http.Get)
let req = request.set_query(req, auth_data)
use resp <- promise.try_await(run_req(req))
let body_split = string.split(resp.body, "\n")
use settings <- promise.map_try(promise.resolve(
list.find(
body_split,
fn(line) { string.starts_with(line, b2c_authorize_prefix) },
)
|> b2c_error("Authorize settings string not found."),
))
let prefix_len = string.length(b2c_authorize_prefix)
let settings_json =
string.slice(settings, prefix_len, string.length(settings) - prefix_len - 2)
use data <- result.try(
json.decode(settings_json, using: dynamic.dynamic)
|> b2c_error(
"Authorize settings JSON parsing error: " <> string.inspect(settings_json),
),
)
use csrf_token <- result.try(data_get(data, "csrf", dynamic.string))
use state_code_block <- result.try(data_get(data, "transId", dynamic.string))
use state_code <- result.try(
state_code_block
|> string.split("=")
|> list.at(1)
|> b2c_error("State code parsing error: " <> state_code_block),
)
Ok(AuthInfo(
state_code: state_code,
csrf_token: csrf_token,
cookies: response.get_cookies(resp),
))
}
fn signin(
config: Config,
username: String,
password: String,
auth_info: AuthInfo,
) -> Promise(Result(SelfAsserted, B2CError)) {
let self_asserted_data = [
#("request_type", "RESPONSE"),
#("signInName", username),
#("password", password),
]
let req =
build_req(self_asserted_url(config), http.Post)
|> request.set_body(uri_helpers.form_urlencoded_serialize(
self_asserted_data,
))
|> request.set_query(base_query(auth_info))
|> request.set_header("x-csrf-token", auth_info.csrf_token)
let req =
list.fold(
auth_info.cookies,
req,
fn(acc, cookie) {
let #(name, value) = cookie
request.set_cookie(acc, name, value)
},
)
use resp <- promise.try_await(run_req(req))
case string.contains(resp.body, "{\"status\":\"400\"") {
True -> promise.resolve(Error(ContentError(msg: "Wrong credentials.")))
False ->
promise.resolve(Ok(SelfAsserted(cookies: response.get_cookies(resp))))
}
}
fn confirm(
config: Config,
self_asserted: SelfAsserted,
auth_info: AuthInfo,
) -> Promise(Result(Confirmed, B2CError)) {
let cookies = self_asserted.cookies
let req = build_req(confirm_url(config), http.Get)
let req =
list.fold(
cookies,
req,
fn(acc, cookie) { request.set_cookie(acc, cookie.0, cookie.1) },
)
|> request.set_query([
#("csrf_token", auth_info.csrf_token),
..base_query(auth_info)
])
use raw_resp <- promise.try_await(
req
|> fetch.to_fetch_request()
|> fetch_helpers.log_raw_send()
|> request_error(),
)
let resp = fetch.from_fetch_response(raw_resp)
use <- bool.guard(
when: resp.status != 200,
return: promise.resolve(Error(ContentError(
msg: "Confirm HTTP request bad error code: " <> int.to_string(resp.status),
))),
)
let location = fetch_helpers.url(raw_resp)
use code <- promise.try_await(promise.resolve(
location
|> string.split("code=")
|> list.at(1)
|> b2c_error("Confirmation code not found."),
))
promise.resolve(Ok(Confirmed(code: code)))
}
fn get_tokens(
config: Config,
confirmed: Confirmed,
code_challenge: String,
) -> Promise(Result(Tokens, B2CError)) {
let request_token_data = [
#("code", confirmed.code),
#("code_verifier", code_challenge),
#("grant_type", "authorization_code"),
..base_request_data(config)
]
let req =
build_req(get_token_url(config), http.Post)
|> request.set_body(uri_helpers.form_urlencoded_serialize(
request_token_data,
))
use resp <- promise.try_await(run_req(req))
use data <- promise.map_try(promise.resolve(
json.decode(resp.body, using: dynamic.dynamic)
|> b2c_error("Get tokens JSON parsing error: " <> string.inspect(resp.body)),
))
parse_tokens(data)
}
fn parse_tokens(data: dynamic.Dynamic) {
use token <- result.try(data_get(data, "access_token", dynamic.string))
use expires_in <- result.try(data_get(data, "expires_in", dynamic.int))
use refresh_token <- result.try(data_get(
data,
"refresh_token",
dynamic.string,
))
use refresh_token_expires_in <- result.try(data_get(
data,
"refresh_token_expires_in",
dynamic.int,
))
Ok(Tokens(
access_token: token,
access_token_expires_in: expires_in,
refresh_token: refresh_token,
refresh_token_expires_in: refresh_token_expires_in,
))
}
fn hash_challenge(
challenge: String,
) -> Promise(Result(String, crypto.HashingError)) {
use hashed <- promise.try_await(crypto.hash(crypto.Sha256, challenge))
promise.resolve(Ok(base.url_encode64(hashed, False)))
}
fn authorize_url(config: Config) -> uri.Uri {
resolve_url(config.b2c_auth_url, "/oauth2/v2.0/authorize")
}
fn self_asserted_url(config: Config) -> uri.Uri {
resolve_url(config.b2c_auth_url, "/SelfAsserted")
}
fn confirm_url(config: Config) -> uri.Uri {
resolve_url(config.b2c_auth_url, "/api/CombinedSigninAndSignup/confirmed")
}
fn get_token_url(config: Config) -> uri.Uri {
resolve_url(config.b2c_auth_url, "/oauth2/v2.0/token")
}
fn resolve_url(base: uri.Uri, relative: String) -> uri.Uri {
let assert Ok(relative) = uri.parse(relative)
uri.Uri(..base, path: base.path <> relative.path)
}
fn build_req(url: uri.Uri, method: http.Method) -> request.Request(String) {
let assert Ok(req) = request.from_uri(url)
let req = request.set_method(req, method)
case method {
http.Post ->
request.set_header(
req,
"content-type",
"application/x-www-form-urlencoded; charset=UTF-8",
)
_ -> req
}
}
fn run_req(
req: request.Request(String),
) -> Promise(Result(response.Response(String), B2CError)) {
use resp <- promise.try_await(
req
|> fetch_helpers.log_send()
|> request_error(),
)
case resp.status {
200 -> promise.resolve(Ok(resp))
code ->
promise.resolve(Error(ContentError(
msg: "Not OK response code " <> int.to_string(code) <> " from URL " <> uri.to_string(request.to_uri(
req,
)) <> "\n\n" <> string.inspect(resp),
)))
}
}
fn base_request_data(config: Config) -> List(#(String, String)) {
[
#("client_id", config.b2c_client_id),
#("scope", config.b2c_client_id),
#("redirect_uri", uri.to_string(config.b2c_redirect_url)),
]
}
fn base_query(auth_info: AuthInfo) -> List(#(String, String)) {
[
#("tx", "StateProperties=" <> auth_info.state_code),
#("p", "B2C_1A_SignUpOrSigninOnline"),
]
}
fn data_get(
data: dynamic.Dynamic,
key: String,
data_type: dynamic.Decoder(a),
) -> Result(a, B2CError) {
parsing.data_get(data, key, data_type, ContentError)
}
fn b2c_error(r: Result(a, b), msg: String) -> Result(a, B2CError) {
result.replace_error(r, ContentError(msg))
}
fn request_error(
p: Promise(Result(a, fetch.FetchError)),
) -> Promise(Result(a, B2CError)) {
promise_helpers.map_error(p, RequestError)
}

View file

@ -1,28 +1,19 @@
import gleam/string
import gleam/list
import gleam/int
import gleam/bit_string
import helpers/binary
const alphabet = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
const alphabet_len = 62
pub fn generate_challenge(length: Int) -> String {
let alphabet_str = bit_string.from_string(alphabet)
list.range(0, length - 1)
|> list.map(fn(_) { random_char(alphabet_str) })
|> list.map(fn(_) { random_char(alphabet) })
|> string.join(with: "")
}
fn random_char(alphabet: BitString) -> String {
fn random_char(alphabet: String) -> String {
let index = int.random(0, alphabet_len)
assert Ok(char) =
alphabet
|> binary.part(index, 1)
|> bit_string.to_string()
char
string.slice(alphabet, index, 1)
}

127
src/geo_t/config.gleam Normal file
View file

@ -0,0 +1,127 @@
import gleam/map.{Map}
import gleam/uri.{Uri}
import gleam/result
import geo_t/pump_api/device/opstat.{
ActiveCooling, AntiLegionella, Defrost, HandOperated, Heating, HotWater, Idle,
Off, OpStat, PassiveCooling, Pool, Standby,
}
import geo_t/helpers/config as config_helpers
pub type Config {
Config(
debug: Bool,
api_timeout: Int,
api_auth_url: Uri,
api_auth_token_refresh_diff: Int,
api_installations_url: Uri,
api_device_url: Uri,
api_device_status_url: Uri,
api_device_register_url: Uri,
api_device_opstat_url: Uri,
api_device_reg_set_url: Uri,
api_device_temp_set_reg_index: Int,
api_device_reg_set_client_id: String,
api_refresh: Int,
b2c_client_id: String,
b2c_redirect_url: Uri,
b2c_auth_url: Uri,
api_opstat_mapping: Map(Int, OpStat),
api_opstat_bitmask_mapping: Map(Int, OpStat),
local_storage_prefix: String,
)
}
pub fn load_config() -> Config {
Config(
debug: get_env(config_helpers.debug, False),
api_timeout: get_env(config_helpers.api_timeout, 30_000),
api_auth_url: get_uri(
config_helpers.api_auth_url,
"https://thermia-auth-api.azurewebsites.net/api/v1/Jwt/login",
),
api_auth_token_refresh_diff: get_env(
config_helpers.api_auth_token_refresh_diff,
300_000,
),
api_installations_url: get_uri(
config_helpers.api_installations_url,
"https://online-genesis-serviceapi.azurewebsites.net/api/v1/installationsInfo",
),
api_device_url: get_uri(
config_helpers.api_device_url,
"https://online-genesis-serviceapi.azurewebsites.net/api/v1/installations/{id}",
),
api_device_status_url: get_uri(
config_helpers.api_device_status_url,
"https://online-genesis-serviceapi.azurewebsites.net/api/v1/installationstatus/{id}/status",
),
api_device_register_url: get_uri(
config_helpers.api_device_register_url,
"https://online-genesis-serviceapi.azurewebsites.net/api/v1/Registers/Installations/{id}/Groups/REG_GROUP_TEMPERATURES",
),
api_device_opstat_url: get_uri(
config_helpers.api_device_opstat_url,
"https://online-genesis-serviceapi.azurewebsites.net/api/v1/Registers/Installations/{id}/Groups/REG_GROUP_OPERATIONAL_STATUS",
),
api_opstat_mapping: map.from_list([
#(1, HandOperated),
#(3, HotWater),
#(4, Heating),
#(5, ActiveCooling),
#(6, Pool),
#(7, AntiLegionella),
#(8, PassiveCooling),
#(98, Standby),
#(99, Idle),
#(100, Off),
]),
api_opstat_bitmask_mapping: map.from_list([
#(1, HandOperated),
#(2, Defrost),
#(4, HotWater),
#(8, Heating),
#(16, ActiveCooling),
#(32, Pool),
#(64, AntiLegionella),
#(128, PassiveCooling),
#(512, Standby),
#(1024, Idle),
#(2048, Off),
]),
api_device_reg_set_url: get_uri(
config_helpers.api_device_reg_set_url,
"https://online-genesis-serviceapi.azurewebsites.net/api/v1/Registers/Installations/{id}/Registers",
),
api_device_temp_set_reg_index: 3,
api_device_reg_set_client_id: get_env(
config_helpers.api_device_reg_set_client_id,
"",
),
api_refresh: 10_000,
b2c_client_id: get_env(
config_helpers.b2c_client_id,
"09ea4903-9e95-45fe-ae1f-e3b7d32fa385",
),
b2c_redirect_url: get_uri(
config_helpers.b2c_redirect_url,
"https://online-genesis.thermia.se/login",
),
b2c_auth_url: get_uri(
config_helpers.b2c_auth_url,
"https://thermialogin.b2clogin.com/thermialogin.onmicrosoft.com/b2c_1a_signuporsigninonline",
),
local_storage_prefix: get_env(
config_helpers.local_storage_prefix,
"__geo_therminator__",
),
)
}
fn get_env(getter: fn() -> Result(a, b), default: a) -> a {
result.unwrap(getter(), default)
}
fn get_uri(getter: fn() -> Result(Uri, b), default: String) -> Uri {
let assert Ok(default) = uri.parse(default)
get_env(getter, default)
}

View file

@ -0,0 +1,82 @@
import gleam/dynamic
import gleam/uri
import geo_t/helpers/try.{try}
pub const app_name = "geo_therminator"
pub fn debug() {
config("debug", dynamic.bool)
}
pub fn api_timeout() {
config("api_timeout", dynamic.int)
}
pub fn api_auth_url() {
config_url("api_auth_url")
}
pub fn api_auth_token_refresh_diff() {
config("api_auth_token_refresh_diff", dynamic.int)
}
pub fn api_installations_url() {
config_url("api_installations_url")
}
pub fn api_device_url() {
config_url("api_device_url")
}
pub fn api_device_status_url() {
config_url("api_device_status_url")
}
pub fn api_device_register_url() {
config_url("api_device_register_url")
}
pub fn api_device_opstat_url() {
config_url("api_device_opstat_url")
}
pub fn api_device_reg_set_url() {
config_url("api_device_reg_set_url")
}
pub fn b2c_redirect_url() {
config_url("b2c_redirect_url")
}
pub fn b2c_auth_url() {
config_url("b2c_auth_url")
}
pub fn b2c_client_id() {
config("b2c_client_id", dynamic.string)
}
pub fn api_device_reg_set_client_id() {
config("api_device_reg_set_client_id", dynamic.string)
}
pub fn local_storage_prefix() {
config("local_storage_prefix", dynamic.string)
}
fn config(config_key: String, decoder: dynamic.Decoder(a)) {
config_key
|> get_conf()
|> decoder()
}
fn config_url(config_key: String) -> Result(uri.Uri, Nil) {
let url = get_conf(config_key)
use url_str <- try(dynamic.string(url), fn(_) { Nil })
use parsed_url <- try(uri.parse(url_str), fn(_) { Nil })
Ok(parsed_url)
}
@external(javascript, "../../config_ffi.mjs", "get")
fn get_conf(key: String) -> dynamic.Dynamic

View file

@ -0,0 +1,27 @@
import gleam/javascript/promise.{Promise}
pub type HashAlgorithm {
// For now, only SHA256
Sha256
}
pub type HashingError
pub fn hash(
algo: HashAlgorithm,
data: String,
) -> Promise(Result(BitString, HashingError)) {
do_hash(algo_to_string(algo), data)
}
fn algo_to_string(algo: HashAlgorithm) -> String {
case algo {
Sha256 -> "SHA-256"
}
}
@external(javascript, "../../crypto_ffi.mjs", "hash")
fn do_hash(
algo algo: String,
data data: String,
) -> Promise(Result(BitString, HashingError))

View file

@ -0,0 +1,51 @@
import gleam/result
import gleam/order.{Eq, Gt, Lt, Order}
import gleam/dynamic.{DecodeError, Dynamic}
pub type Date
@external(javascript, "../../date_ffi.mjs", "from_iso8601")
pub fn from_iso8601(a: String) -> Result(Date, Nil)
@external(javascript, "../../date_ffi.mjs", "to_iso8601")
pub fn to_iso8601(d: Date) -> String
@external(javascript, "../../date_ffi.mjs", "from_unix")
pub fn from_unix(a: Int) -> Result(Date, Nil)
@external(javascript, "../../date_ffi.mjs", "to_unix")
pub fn to_unix(a: Date) -> Int
@external(javascript, "../../date_ffi.mjs", "now")
pub fn now() -> Date
@external(javascript, "../../date_ffi.mjs", "unix_now")
pub fn unix_now() -> Int
@external(javascript, "../../date_ffi.mjs", "equals")
pub fn equals(a: Date, b: Date) -> Bool
@external(javascript, "../../date_ffi.mjs", "bigger_than")
pub fn bigger_than(a: Date, b: Date) -> Bool
/// Compare given dates, returning `Gt` if `a` > `b`
pub fn compare(a: Date, b: Date) -> Order {
case equals(a, b) {
True -> Eq
False -> {
case bigger_than(a, b) {
True -> Gt
False -> Lt
}
}
}
}
pub fn decode(value: Dynamic) -> Result(Date, List(DecodeError)) {
use str <- result.try(dynamic.string(value))
use date <- result.try(
from_iso8601(str)
|> result.replace_error([DecodeError("ISO 8601 formatted string", str, [])]),
)
Ok(date)
}

View file

@ -0,0 +1,12 @@
import geo_t/config.{Config}
pub fn if_debug(config: Config, do: fn() -> a) {
case config.debug {
True -> {
do()
Nil
}
False -> Nil
}
}

View file

@ -0,0 +1,31 @@
import gleam/io
import gleam/http/request.{Request}
import gleam/javascript/promise
import gleam/string
import gleam/fetch as og_fetch
const debug_print_len = 1000
pub fn log_send(req: Request(String)) {
io.println("Sending request:")
io.debug(req)
use resp <- promise.try_await(og_fetch.send(req))
use resp <- promise.try_await(og_fetch.read_text_body(resp))
io.println(string.slice(string.inspect(resp), 0, debug_print_len))
promise.resolve(Ok(resp))
}
pub fn log_raw_send(req: og_fetch.FetchRequest) {
io.println("Sending raw request:")
io.debug(req)
use resp <- promise.try_await(og_fetch.raw_send(req))
io.println(string.slice(string.inspect(resp), 0, debug_print_len))
promise.resolve(Ok(resp))
}
@external(javascript, "../../fetch_ffi.mjs", "url")
pub fn url(resp: og_fetch.FetchResponse) -> String
@external(javascript, "../../fetch_ffi.mjs", "preventRedirection")
pub fn prevent_redirection(req: og_fetch.FetchRequest) -> og_fetch.FetchRequest

View file

@ -0,0 +1,13 @@
import gleam/result
import gleam/javascript/promise.{Promise} as og_promise
pub fn map_error(
p: Promise(Result(a, b)),
mapper: fn(b) -> c,
) -> Promise(Result(a, c)) {
og_promise.map(p, fn(r) { result.map_error(r, mapper) })
}
pub fn replace_error(p: Promise(Result(a, b)), err: c) -> Promise(Result(a, c)) {
og_promise.map(p, fn(r) { result.replace_error(r, err) })
}

View file

@ -0,0 +1,2 @@
@external(javascript, "../../timers_ffi.mjs", "setTimeout")
pub fn set_timeout(callback: fn() -> a, delay: Int) -> Nil

View file

@ -0,0 +1,13 @@
import gleam/result
pub fn try(
val: Result(val_a, err_a),
err_mapper: fn(err_a) -> err_b,
callback: fn(val_a) -> Result(val_b, err_b),
) {
result.try(
val
|> result.map_error(err_mapper),
callback,
)
}

View file

@ -0,0 +1,8 @@
import geo_t/azure/b2c.{B2CError}
pub type ApiError {
ApiRequestFailed
NotOkResponse
InvalidData(msg: String)
AuthError(inner: B2CError)
}

View file

@ -0,0 +1,96 @@
import gleam/http/request
import gleam/json
import gleam/result
import gleam/dynamic
import gleam/string
import gleam/javascript/promise.{Promise}
import geo_t/pump_api/auth/user.{User}
import geo_t/pump_api/auth/tokens.{Tokens}
import geo_t/pump_api/auth/installation_info.{InstallationInfo}
import geo_t/pump_api/http
import geo_t/helpers/date
import geo_t/helpers/parsing
import geo_t/azure/b2c.{Credentials}
import geo_t/helpers/promise as promise_helpers
import geo_t/helpers/fetch as fetch_helpers
import geo_t/config.{Config}
import geo_t/pump_api/api.{
ApiError, ApiRequestFailed, AuthError, InvalidData, NotOkResponse,
}
pub fn auth_or_refresh(
config: Config,
credentials: Credentials,
) -> Promise(Result(User, ApiError)) {
use tokens <- promise.try_await(
b2c.authenticate_or_refresh(config, credentials)
|> promise_helpers.map_error(AuthError),
)
let now = date.unix_now()
use access_token_expires_in <- promise.try_await(promise.resolve(
date.from_unix(now + tokens.access_token_expires_in * 1000)
|> result.replace_error(InvalidData(
msg: "Access token expiry could not be converted into DateTime: " <> string.inspect(
tokens.access_token_expires_in,
),
)),
))
use refresh_token_expires_in <- promise.try_await(promise.resolve(
date.from_unix(now + tokens.refresh_token_expires_in * 1000)
|> result.replace_error(InvalidData(
msg: "Refresh token expiry could not be converted into DateTime: " <> string.inspect(
tokens.refresh_token_expires_in,
),
)),
))
promise.resolve(Ok(User(tokens: Tokens(
access_token: tokens.access_token,
access_token_expiry: access_token_expires_in,
refresh_token: tokens.refresh_token,
refresh_token_expiry: refresh_token_expires_in,
))))
}
pub fn installation_info(
config: Config,
user: User,
) -> Promise(Result(List(InstallationInfo), ApiError)) {
let url = config.api_installations_url
let assert Ok(raw_req) = request.from_uri(url)
let empty_req = request.set_body(raw_req, http.Empty)
use data <- promise.try_await(run_req(http.authed_req(user, empty_req)))
use items <- promise.try_await(promise.resolve(parsing.data_get(
data,
"items",
dynamic.list(of: installation_info.parse),
InvalidData,
)))
promise.resolve(Ok(items))
}
fn run_req(req: request.Request(String)) {
use resp <- promise.try_await(
req
|> fetch_helpers.log_send()
|> promise_helpers.replace_error(ApiRequestFailed),
)
use body <- promise.try_await(promise.resolve(case resp.status {
200 -> Ok(resp.body)
_ -> Error(NotOkResponse)
}))
promise.resolve(
body
|> json.decode(using: dynamic.dynamic)
|> result.replace_error(InvalidData(
msg: "Could not parse InstallationInfo JSON.",
)),
)
}

View file

@ -0,0 +1,13 @@
import gleam/result
import gleam/dynamic.{Dynamic}
pub type InstallationInfo {
InstallationInfo(id: Int, name: String)
}
pub fn parse(value: Dynamic) {
use id <- result.try(dynamic.field("id", dynamic.int)(value))
use name <- result.try(dynamic.field("name", dynamic.string)(value))
Ok(InstallationInfo(id, name))
}

View file

@ -0,0 +1,20 @@
import gleam/order
import geo_t/helpers/date.{Date}
pub type Tokens {
Tokens(
access_token: String,
access_token_expiry: Date,
refresh_token: String,
refresh_token_expiry: Date,
)
}
pub fn is_access_expired(tokens: Tokens, diff: Int) -> Bool {
let assert Ok(target) =
date.from_unix(date.to_unix(tokens.access_token_expiry) - diff)
case date.compare(date.now(), target) {
order.Gt -> True
_ -> False
}
}

View file

@ -1,4 +1,4 @@
import pump_api/auth/tokens
import geo_t/pump_api/auth/tokens
pub type User {
User(tokens: tokens.Tokens)

View file

@ -0,0 +1,19 @@
import geo_t/helpers/date.{Date}
pub type Device {
Device(
id: Int,
device_id: Int,
is_online: Bool,
last_online: Date,
created_when: Date,
mac_address: String,
name: String,
model: String,
retailer_access: Int,
)
}
pub type Status {
Status(heating_effect: Int, is_heating_effect_set_by_user: Bool)
}

View file

@ -0,0 +1,325 @@
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 gleam/uri.{Uri}
import gleam/javascript/promise
import geo_t/pump_api/auth/user.{User}
import geo_t/pump_api/auth/installation_info.{InstallationInfo}
import geo_t/pump_api/device.{Device, Status}
import geo_t/pump_api/device/register.{Register, RegisterCollection}
import geo_t/pump_api/device/opstat.{OpStat}
import geo_t/pump_api/http
import geo_t/pump_api/api.{ApiError, InvalidData}
import geo_t/config.{Config}
import geo_t/helpers/parsing
import geo_t/helpers/date
pub fn device_info(config: Config, user: User, installation: InstallationInfo) {
let url =
Uri(
..config.api_device_url,
path: string.replace(
config.api_device_url.path,
"{id}",
int.to_string(installation.id),
),
)
let assert Ok(raw_req) = request.from_uri(url)
let empty_req = request.set_body(raw_req, http.Empty)
use data <- promise.map_try(http.run_json_req(http.authed_req(user, empty_req)))
use last_online <- result.then(parsing.data_get(
data,
"lastOnline",
date.decode,
InvalidData,
))
use created_when <- result.then(parsing.data_get(
data,
"createdWhen",
date.decode,
InvalidData,
))
use id <- result.then(parsing.data_get(data, "id", dynamic.int, InvalidData))
use device_id <- result.then(parsing.data_get(
data,
"deviceId",
dynamic.int,
InvalidData,
))
use is_online <- result.then(parsing.data_get(
data,
"isOnline",
dynamic.bool,
InvalidData,
))
use mac_address <- result.then(parsing.data_get(
data,
"macAddress",
dynamic.string,
InvalidData,
))
use name <- result.then(parsing.data_get(
data,
"name",
dynamic.string,
InvalidData,
))
use model <- result.then(parsing.data_get(
data,
"model",
dynamic.string,
InvalidData,
))
use retailer_access <- result.then(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(config: Config, user: User, device: Device) {
let url =
Uri(
..config.api_device_status_url,
path: string.replace(
config.api_device_status_url.path,
"{id}",
int.to_string(device.id),
),
)
let assert Ok(raw_req) = request.from_uri(url)
let empty_req = request.set_body(raw_req, http.Empty)
use data <- promise.map_try(http.run_json_req(http.authed_req(user, empty_req)))
use heating_effect <- result.then(parsing.data_get(
data,
"heatingEffect",
dynamic.int,
InvalidData,
))
use is_heating_effect_set_by_user <- result.then(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(config: Config, user: User, device: Device) {
let url =
Uri(
..config.api_device_register_url,
path: string.replace(
config.api_device_register_url.path,
"{id}",
int.to_string(device.id),
),
)
let assert Ok(raw_req) = request.from_uri(url)
let empty_req = request.set_body(raw_req, http.Empty)
use data <- promise.map_try(http.run_json_req(http.authed_req(user, empty_req)))
use registers <- result.then(
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()
use outdoor_temp <- result.then(get_register(
registers_map,
"REG_OUTDOOR_TEMPERATURE",
))
use supply_out <- result.then(get_register(registers_map, "REG_SUPPLY_LINE"))
use supply_in <- result.then(get_register(
registers_map,
"REG_OPER_DATA_RETURN",
))
use desired_supply <- result.then(get_register(
registers_map,
"REG_DESIRED_SYS_SUPPLY_LINE_TEMP",
))
use brine_out <- result.then(get_register(registers_map, "REG_BRINE_OUT"))
use brine_in <- result.then(get_register(registers_map, "REG_BRINE_IN"))
use hot_water_temp <- result.then(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(config: Config, user: User, device: Device) {
let url =
Uri(
..config.api_device_opstat_url,
path: string.replace(
config.api_device_opstat_url.path,
"{id}",
int.to_string(device.id),
),
)
let assert Ok(raw_req) = request.from_uri(url)
let empty_req = request.set_body(raw_req, http.Empty)
use data <- promise.map_try(http.run_json_req(http.authed_req(user, empty_req)))
use registers <- result.then(
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")
use priority <- result.then(case
#(priority_register, priority_register_fallback)
{
#(Ok(data), _) -> Ok(opstat_map(data))
#(_, Ok(data)) -> Ok(opstat_bitmask_map(config, data))
_ ->
Error(InvalidData(
"Unable to parse opstat: " <> string.inspect(#(
priority_register,
priority_register_fallback,
)),
))
})
Ok(priority)
}
pub fn set_temp(config: Config, user: User, device: Device, temp: Int) {
let url =
Uri(
..config.api_device_opstat_url,
path: string.replace(
config.api_device_opstat_url.path,
"{id}",
int.to_string(device.id),
),
)
let assert Ok(raw_req) = request.from_uri(url)
let register_index = config.api_device_temp_set_reg_index
let client_id = config.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)) {
use timestamp <- result.then(dynamic.field("timeStamp", date.decode)(item))
use name <- result.then(dynamic.field("registerName", dynamic.string)(item))
use value <- result.then(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 -> opstat.HandOperated
3 -> opstat.HotWater
4 -> opstat.Heating
5 -> opstat.ActiveCooling
6 -> opstat.Pool
7 -> opstat.AntiLegionella
8 -> opstat.PassiveCooling
98 -> opstat.Standby
99 -> opstat.Idle
100 -> opstat.Off
_ -> opstat.Unknown
}
set.new()
|> set.insert(val)
}
fn opstat_bitmask_map(config: Config, register: Register) {
let priority_set: set.Set(OpStat) = set.new()
list.fold(
map.to_list(config.api_opstat_bitmask_mapping),
priority_set,
fn(output, mapping) {
let #(int, priority) = mapping
case band(register.value, int) {
0 -> output
_ -> set.insert(output, priority)
}
},
)
}
@external(javascript, "../../../bitwise_ffi.mjs", "band")
fn band(i1 i1: Int, i2 i2: Int) -> Int

View file

@ -0,0 +1,14 @@
pub type OpStat {
HandOperated
Defrost
HotWater
Heating
ActiveCooling
Pool
AntiLegionella
PassiveCooling
Standby
Idle
Off
Unknown
}

View file

@ -0,0 +1,17 @@
import geo_t/helpers/date.{Date}
pub type Register {
Register(name: String, value: Int, timestamp: Date)
}
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,
)
}

View file

@ -0,0 +1,62 @@
import gleam/result
import gleam/http/request
import gleam/json.{Json}
import gleam/dynamic
import gleam/javascript/promise
import geo_t/helpers/fetch
import geo_t/pump_api/auth/user.{User}
import geo_t/pump_api/api
import geo_t/helpers/promise as promise_helpers
pub type Body {
Empty
Json(data: Json)
String(data: String)
}
pub type ApiRequest =
request.Request(Body)
pub fn authed_req(user: User, r: ApiRequest) {
r
|> request.set_header("authorization", "Bearer " <> user.tokens.access_token)
|> req()
}
pub fn req(r: ApiRequest) -> request.Request(String) {
let r = request.set_header(r, "accept", "application/json")
case r.body {
Empty -> request.set_body(r, "")
String(data) -> request.set_body(r, data)
Json(data) ->
r
|> request.set_header("content-type", "application/json")
|> request.set_body(json.to_string(data))
}
}
pub fn run_req(req: request.Request(String)) {
use resp <- promise.try_await(
req
|> fetch.log_send()
|> promise_helpers.replace_error(api.ApiRequestFailed),
)
case resp.status {
200 -> promise.resolve(Ok(resp.body))
_ -> promise.resolve(Error(api.NotOkResponse))
}
}
pub fn run_json_req(req: request.Request(String)) {
use body <- promise.try_await(run_req(req))
promise.resolve(
body
|> json.decode(using: dynamic.dynamic)
|> result.replace_error(api.InvalidData(
"Could not decode response as JSON.",
)),
)
}

299
src/geo_t/web.gleam Normal file
View file

@ -0,0 +1,299 @@
import gleam/option.{None, Option, Some}
import gleam/order
import gleam/string
import gleam/io
import gleam/javascript/promise
import lustre
import lustre/element/html
import lustre/effect.{Effect}
import lustre/element
import plinth/javascript/storage.{Storage}
import geo_t/azure/b2c.{Credentials, WithToken, WithUsernamePassword}
import geo_t/helpers/timers
import geo_t/helpers/date
import geo_t/config.{Config}
import geo_t/web/login_view
import geo_t/web/installations_view
import geo_t/web/pump_view
import geo_t/pump_api/auth/user.{User}
import geo_t/pump_api/auth/tokens
import geo_t/pump_api/api.{ApiError}
import geo_t/pump_api/auth/api as auth_api
import geo_t/web/auth.{AuthInfo, AuthInfoStorage}
const auth_update_check_interval = 10_000
pub fn main() {
let app = lustre.application(init, update, view)
let assert Ok(_) = lustre.start(app, "[data-lustre-app]", Nil)
Nil
}
pub type Model {
Model(
config: Config,
local_storage: Storage,
login: login_view.Model,
installations: installations_view.Model,
pump: Option(pump_view.Model),
auth_storage: AuthInfoStorage,
auth: Option(AuthInfo),
logging_in: Bool,
logged_in: Bool,
)
}
pub type Msg {
LoginView(login_view.Msg)
LoginResult(Result(User, ApiError))
InstallationsView(installations_view.Msg)
PumpView(pump_view.Msg)
AuthRefreshCheck
RefreshLogin
}
fn init(_) {
let config = config.load_config()
let assert Ok(local) = storage.local()
let auth_storage = auth.new_storage(config, local)
let auth_info = case auth.load(auth_storage) {
Ok(info) -> Some(info)
Error(_) -> None
}
let auth_refresh_effect =
effect.from(fn(dispatch) { auth_refresh_check_timer(dispatch) })
let #(inst_model, init_effect) = case auth_info {
Some(info) ->
case tokens.is_access_expired(info.user.tokens, 0) {
True -> #(installations_view.init(), effect.none())
False ->
update_installations(
installations_view.init(),
installations_view.LoadInstallations(config, info.user),
)
}
None -> #(installations_view.init(), effect.none())
}
let model =
Model(
config: config,
login: login_view.init(),
installations: inst_model,
pump: None,
local_storage: local,
auth_storage: auth_storage,
auth: auth_info,
logging_in: False,
logged_in: option.is_some(auth_info),
)
#(
model,
effect.batch([auth_refresh_effect, auth_refresh_check(model), init_effect]),
)
}
fn update(model: Model, msg: Msg) {
case msg {
AuthRefreshCheck -> #(model, auth_refresh_check(model))
RefreshLogin | LoginView(login_view.AttemptLogin) -> {
let model = Model(..model, logging_in: True)
case model.auth {
Some(auth) -> #(
model,
login(model.config, WithToken(auth.user.tokens.refresh_token)),
)
None -> {
// We don't have a refresh token, we don't have a username and password -> reset view
case model.login.username {
"" -> #(
Model(..model, logging_in: False, logged_in: False, auth: None),
effect.none(),
)
_ -> #(
Model(
..model,
logging_in: True,
login: login_view.update(model.login, login_view.AttemptLogin),
),
login(
model.config,
WithUsernamePassword(model.login.username, model.login.password),
),
)
}
}
}
}
LoginView(other) -> #(
Model(..model, login: login_view.update(model.login, other)),
effect.none(),
)
LoginResult(Ok(user)) -> {
let auth_info = AuthInfo(user: user)
let _ = auth.store(model.auth_storage, auth_info)
let #(inst_model, inst_effect) =
update_installations(
model.installations,
installations_view.LoadInstallations(model.config, user),
)
#(
Model(
..model,
logging_in: False,
logged_in: True,
auth: Some(auth_info),
installations: inst_model,
),
inst_effect,
)
}
LoginResult(Error(error)) -> #(
Model(
..model,
logging_in: False,
logged_in: False,
auth: None,
login: login_view.update(
model.login,
login_view.LoginFailed(string.inspect(error)),
),
),
effect.none(),
)
InstallationsView(installations_view.ViewInstallation(installation)) -> {
let assert Some(auth) = model.auth
let #(pump_model, pump_effect) =
pump_view.init(model.config, auth.user, installation)
#(
Model(..model, pump: Some(pump_model)),
effect.map(pump_effect, PumpView),
)
}
InstallationsView(msg) -> {
let #(new_model, new_effect) =
update_installations(model.installations, msg)
#(Model(..model, installations: new_model), new_effect)
}
PumpView(msg) -> {
case model.pump {
Some(pump) -> {
let #(new_pump, pump_effect) = update_pump(pump, msg)
#(Model(..model, pump: Some(new_pump)), pump_effect)
}
None -> #(model, effect.none())
}
}
}
}
fn view(model: Model) {
html.div(
[],
[
case model.auth {
None ->
login_view.view(model.login)
|> element.map(LoginView)
Some(_) -> {
case model.pump {
None ->
installations_view.view(model.installations)
|> element.map(InstallationsView)
Some(pump) ->
pump_view.view(pump)
|> element.map(PumpView)
}
}
},
],
)
}
fn auth_refresh_check_timer(dispatch) {
timers.set_timeout(
fn() { dispatch(AuthRefreshCheck) },
auth_update_check_interval,
)
Nil
}
fn auth_refresh_check(model: Model) {
use dispatch <- effect.from()
auth_refresh_check_timer(dispatch)
case model.auth {
Some(auth) -> {
case
tokens.is_access_expired(
auth.user.tokens,
model.config.api_auth_token_refresh_diff,
)
{
True -> {
io.println("[AuthCheck] Auth expiring soon, refreshing...")
dispatch(RefreshLogin)
Nil
}
False -> {
io.println("[AuthCheck] Auth not expiring yet, no need to refresh.")
}
}
Nil
}
None -> Nil
}
}
fn login(config: Config, credentials: Credentials) -> Effect(Msg) {
use dispatch <- effect.from()
auth_api.auth_or_refresh(config, credentials)
|> promise.map(LoginResult)
|> promise.tap(dispatch)
Nil
}
fn update_installations(
model: installations_view.Model,
msg: installations_view.Msg,
) {
update_child(model, msg, installations_view.update, InstallationsView)
}
fn update_pump(model: pump_view.Model, msg: pump_view.Msg) {
update_child(model, msg, pump_view.update, PumpView)
}
fn update_child(
model: a,
msg: b,
updater: fn(a, b) -> #(a, Effect(b)),
mapper: fn(b) -> Msg,
) {
let #(new_model, new_effect) = updater(model, msg)
let new_effect = effect.map(new_effect, mapper)
#(new_model, new_effect)
}

105
src/geo_t/web/auth.gleam Normal file
View file

@ -0,0 +1,105 @@
import gleam/result
import gleam/dynamic.{DecodeError, Dynamic}
import gleam/json.{Json}
import gleam/order
import geo_t/pump_api/auth/user.{User}
import geo_t/pump_api/auth/tokens.{Tokens}
import geo_t/helpers/date
import geo_t/config.{Config}
import varasto.{TypedStorage}
import plinth/javascript/storage.{Storage}
const storage_key = "auth_info"
pub type AuthInfo {
AuthInfo(user: User)
}
pub type AuthInfoStorage {
AuthInfoStorage(storage: TypedStorage(AuthInfo), key: String)
}
pub type LoadResult {
LoadError(err: varasto.ReadError)
ExpiredError
}
pub fn new_storage(config: Config, plinth_storage: Storage) -> AuthInfoStorage {
AuthInfoStorage(
storage: varasto.new(plinth_storage, read, write),
key: config.local_storage_prefix <> storage_key,
)
}
pub fn load(storage: AuthInfoStorage) {
use info <- result.try(
varasto.get(storage.storage, storage.key)
|> result.map_error(LoadError),
)
case date.compare(date.now(), info.user.tokens.refresh_token_expiry) {
order.Lt -> Ok(info)
_ -> Error(ExpiredError)
}
}
pub fn store(storage: AuthInfoStorage, value: AuthInfo) {
varasto.set(storage.storage, storage.key, value)
}
fn write(info: AuthInfo) -> Json {
json.object([#("user", write_user(info.user))])
}
fn write_user(user: User) -> Json {
json.object([#("tokens", write_tokens(user.tokens))])
}
fn write_tokens(tokens: Tokens) -> Json {
json.object([
#("access_token", json.string(tokens.access_token)),
#(
"access_token_expiry",
json.string(date.to_iso8601(tokens.access_token_expiry)),
),
#("refresh_token", json.string(tokens.refresh_token)),
#(
"refresh_token_expiry",
json.string(date.to_iso8601(tokens.refresh_token_expiry)),
),
])
}
fn read(value: Dynamic) -> Result(AuthInfo, List(DecodeError)) {
use user <- result.try(dynamic.field("user", read_user)(value))
Ok(AuthInfo(user: user))
}
fn read_user(value: Dynamic) -> Result(User, List(DecodeError)) {
use tokens <- result.try(dynamic.field("tokens", read_tokens)(value))
Ok(User(tokens: tokens))
}
fn read_tokens(value: Dynamic) -> Result(Tokens, List(DecodeError)) {
use access_token <- result.try(dynamic.field("access_token", dynamic.string)(
value,
))
use access_token_expiry <- result.try(dynamic.field(
"access_token_expiry",
date.decode,
)(value))
use refresh_token <- result.try(dynamic.field("refresh_token", dynamic.string)(
value,
))
use refresh_token_expiry <- result.try(dynamic.field(
"refresh_token_expiry",
date.decode,
)(value))
Ok(Tokens(
access_token: access_token,
access_token_expiry: access_token_expiry,
refresh_token: refresh_token,
refresh_token_expiry: refresh_token_expiry,
))
}

View file

@ -0,0 +1,94 @@
import gleam/list
import gleam/int
import gleam/string
import gleam/javascript/promise
import lustre/element.{Element, text}
import lustre/element/html
import lustre/attribute
import lustre/event
import lustre/effect.{Effect}
import geo_t/config.{Config}
import geo_t/pump_api/auth/installation_info.{InstallationInfo}
import geo_t/pump_api/auth/api as auth_api
import geo_t/pump_api/api.{ApiError}
import geo_t/pump_api/auth/user.{User}
type InstallationData =
Result(List(InstallationInfo), ApiError)
pub type Model {
Model(loading: Bool, installations: InstallationData)
}
pub fn init() {
Model(loading: False, installations: Ok([]))
}
pub type Msg {
LoadInstallations(Config, User)
InstallationsResult(Result(List(InstallationInfo), ApiError))
ViewInstallation(InstallationInfo)
}
pub fn update(model: Model, msg: Msg) -> #(Model, Effect(Msg)) {
case msg {
LoadInstallations(config, user) -> #(
Model(..model, loading: True),
load_installations(config, user),
)
InstallationsResult(data) -> #(
Model(loading: False, installations: data),
effect.none(),
)
ViewInstallation(_) -> #(model, effect.none())
}
}
pub fn view(model: Model) -> Element(Msg) {
html.div(
[],
[
html.h1([], [text("Installations")]),
case model.installations {
Ok(installations) ->
html.ul(
[],
list.map(
installations,
fn(installation) {
html.button(
[
attribute.type_("button"),
event.on_click(ViewInstallation(installation)),
],
[
text(installation.name),
text(" ("),
text(int.to_string(installation.id)),
text(")"),
],
)
},
),
)
Error(err) ->
html.p(
[],
[text("An error occurred while loading: " <> string.inspect(err))],
)
},
],
)
}
fn load_installations(config: Config, user: User) {
use dispatch <- effect.from()
auth_api.installation_info(config, user)
|> promise.map(InstallationsResult)
|> promise.tap(dispatch)
Nil
}

View file

@ -0,0 +1,79 @@
import lustre/element.{Element}
import lustre/element/html
import lustre/attribute
import lustre/event
pub type Model {
Model(
username: String,
password: String,
logging_in: Bool,
login_failed: Bool,
error: String,
)
}
pub fn init() -> Model {
Model("", "", False, False, "")
}
pub type Msg {
AttemptLogin
LoginFailed(String)
OnUsernameInput(String)
OnPasswordInput(String)
}
pub fn update(model: Model, msg: Msg) -> Model {
case msg {
OnUsernameInput(username) -> Model(..model, username: username)
OnPasswordInput(password) -> Model(..model, password: password)
AttemptLogin -> Model(..model, logging_in: True)
LoginFailed(error) ->
Model(..model, login_failed: True, logging_in: False, error: error)
}
}
pub fn view(model: Model) -> Element(Msg) {
html.div(
[],
[
html.h1([], [element.text("Log in")]),
html.form(
[attribute.attribute("method", "post"), event.on_submit(AttemptLogin)],
[
html.input([
attribute.type_("text"),
attribute.name("username"),
attribute.required(True),
attribute.disabled(model.logging_in),
event.on_input(OnUsernameInput),
]),
html.input([
attribute.type_("password"),
attribute.name("password"),
attribute.required(True),
attribute.disabled(model.logging_in),
event.on_input(OnPasswordInput),
]),
html.button(
[attribute.type_("submit"), attribute.disabled(model.logging_in)],
[element.text(submit_text(model))],
),
],
),
case model.login_failed {
True ->
html.p([], [element.text("Login failed, because: " <> model.error)])
False -> element.text("")
},
],
)
}
fn submit_text(model: Model) {
case model.logging_in {
True -> "Logging in…"
False -> "Log in"
}
}

View file

@ -0,0 +1,916 @@
import gleam/list
import gleam/int
import gleam/option.{None, Option, Some}
import gleam/set.{Set}
import gleam/javascript/promise
import lustre/element.{Element, text}
import lustre/element/html
import lustre/attribute.{attribute}
import lustre/effect.{Effect}
import lustre/element/svg
import geo_t/helpers/timers
import geo_t/config.{Config}
import geo_t/pump_api/auth/installation_info.{InstallationInfo}
import geo_t/pump_api/device.{Device}
import geo_t/pump_api/device/opstat
import geo_t/pump_api/device/register.{RegisterCollection}
import geo_t/pump_api/device/api as device_api
import geo_t/pump_api/api.{ApiError}
import geo_t/pump_api/auth/user.{User}
const svg_ns = "http://www.w3.org/2000/svg"
pub type UpdateResult {
Status(Result(device.Status, ApiError))
Registers(Result(RegisterCollection, ApiError))
OpStat(Result(Set(opstat.OpStat), ApiError))
}
pub type Model {
Model(
loading: Bool,
config: Config,
user: User,
installation: InstallationInfo,
device: Option(Device),
status: Option(device.Status),
registers: Option(RegisterCollection),
opstat: Set(opstat.OpStat),
)
}
pub type Msg {
DeviceLoadResult(Result(Device, ApiError))
StartUpdate
UpdateResults(List(UpdateResult))
}
pub fn init(
config: Config,
user: User,
installation: InstallationInfo,
) -> #(Model, Effect(Msg)) {
#(
Model(
loading: True,
config: config,
user: user,
installation: installation,
device: None,
status: None,
registers: None,
opstat: set.from_list([opstat.Unknown]),
),
load_device(config, user, installation),
)
}
pub fn update(model: Model, msg: Msg) -> #(Model, Effect(Msg)) {
case msg {
DeviceLoadResult(Ok(device)) -> {
let model = Model(..model, loading: False, device: Some(device))
#(model, update_state(model.config, model.user, device))
}
StartUpdate -> {
case model.device {
Some(device) -> #(
Model(..model, loading: True),
update_state(model.config, model.user, device),
)
None -> #(model, effect.none())
}
}
UpdateResults(results) -> #(
list.fold(
results,
Model(..model, loading: False),
fn(m, result) {
case result {
Status(Ok(status)) -> Model(..m, status: Some(status))
Registers(Ok(registers)) -> Model(..m, registers: Some(registers))
OpStat(Ok(opstat)) -> Model(..m, opstat: opstat)
}
},
),
effect.none(),
)
}
}
pub fn view(model: Model) -> Element(Msg) {
html.div(
[attribute.class("pump")],
[
html.div(
[attribute.class("pump-loading-info")],
[
case model.loading {
True -> text("")
False -> text(" ")
},
],
),
html.svg(
[
attribute("xmlns", svg_ns),
attribute("xmlns:xlink", "http://www.w3.org/1999/xlink"),
attribute("version", "1.1"),
attribute("width", "484px"),
attribute("height", "665px"),
attribute("viewBox", "-0.5 -0.5 484 665"),
attribute("style", "background-color: rgb(255, 255, 255);"),
],
[
svg.defs(
[],
[
svg.linear_gradient(
[
attribute("x1", "0%"),
attribute("y1", "0%"),
attribute("x2", "0%"),
attribute("y2", "100%"),
attribute("id", "mx-gradient-f5f5f5-1-b3b3b3-1-s-0"),
],
[
svg.stop([
attribute("offset", "0%"),
attribute(
"style",
"stop-color: rgb(245, 245, 245); stop-opacity: 1;",
),
]),
svg.stop([
attribute("offset", "100%"),
attribute(
"style",
"stop-color: rgb(179, 179, 179); stop-opacity: 1;",
),
]),
],
),
svg.linear_gradient(
[
attribute("x1", "0%"),
attribute("y1", "0%"),
attribute("x2", "0%"),
attribute("y2", "100%"),
attribute("id", "mx-gradient-dae8fc-1-7ea6e0-1-s-0"),
],
[
svg.stop([
attribute("offset", "0%"),
attribute(
"style",
"stop-color: rgb(218, 232, 252); stop-opacity: 1;",
),
]),
svg.stop([
attribute("offset", "100%"),
attribute(
"style",
"stop-color: rgb(126, 166, 224); stop-opacity: 1;",
),
]),
],
),
svg.linear_gradient(
[
attribute("x1", "0%"),
attribute("y1", "0%"),
attribute("x2", "0%"),
attribute("y2", "100%"),
attribute("id", "mx-gradient-f8cecc-1-ea6b66-1-s-0"),
],
[
svg.stop([
attribute("offset", "0%"),
attribute(
"style",
"stop-color: rgb(248, 206, 204); stop-opacity: 1;",
),
]),
svg.stop([
attribute("offset", "100%"),
attribute(
"style",
"stop-color: rgb(234, 107, 102); stop-opacity: 1;",
),
]),
],
),
],
),
svg.g(
[],
[
svg.rect([
attribute("x", "0"),
attribute("y", "144"),
attribute("width", "200"),
attribute("height", "520"),
attribute("fill", "url(#mx-gradient-f5f5f5-1-b3b3b3-1-s-0)"),
attribute("stroke", "#666666"),
attribute("pointer-events", "none"),
]),
svg.rect([
attribute("x", "60"),
attribute("y", "194"),
attribute("width", "80"),
attribute("height", "80"),
attribute("fill", "rgba(255, 255, 255, 1)"),
attribute("stroke", "rgba(0, 0, 0, 1)"),
attribute("pointer-events", "none"),
]),
svg.rect([
attribute("x", "90"),
attribute("y", "464"),
attribute("width", "97"),
attribute("height", "48"),
attribute("fill", "rgba(255, 255, 255, 1)"),
attribute("stroke", "none"),
attribute("pointer-events", "none"),
]),
svg.g(
[attribute("transform", "translate(-0.5 -0.5)")],
[
element.namespaced(
svg_ns,
"text",
[
attribute("x", "185"),
attribute("y", "498"),
attribute("fill", "rgba(0, 0, 0, 1)"),
attribute("font-family", "Helvetica"),
attribute("font-size", "34px"),
attribute("text-anchor", "end"),
],
[
text(
var(
model.registers,
fn(r) { r.outdoor_temp.value },
int.to_string,
) <> "°C",
),
],
),
],
),
svg.rect([
attribute("x", "10"),
attribute("y", "468"),
attribute("width", "30"),
attribute("height", "40"),
attribute("fill", "rgba(255, 255, 255, 1)"),
attribute("stroke", "rgba(0, 0, 0, 1)"),
attribute("pointer-events", "none"),
]),
svg.path([
attribute("d", "M 30 488 L 53.63 488"),
attribute("fill", "none"),
attribute("stroke", "rgba(0, 0, 0, 1)"),
attribute("stroke-miterlimit", "10"),
attribute("pointer-events", "none"),
]),
svg.path([
attribute(
"d",
"M 58.88 488 L 51.88 491.5 L 53.63 488 L 51.88 484.5 Z",
),
attribute("fill", "rgba(0, 0, 0, 1)"),
attribute("stroke", "rgba(0, 0, 0, 1)"),
attribute("stroke-miterlimit", "10"),
attribute("pointer-events", "none"),
]),
case set.contains(model.opstat, opstat.Heating) {
False -> text("")
True ->
svg.ellipse([
attribute("cx", "172"),
attribute("cy", "289"),
attribute("rx", "15"),
attribute("ry", "15"),
attribute("fill", "#ffff88"),
attribute("stroke", "#36393d"),
attribute("pointer-events", "none"),
])
},
case set.contains(model.opstat, opstat.HotWater) {
False -> text("")
True ->
svg.ellipse([
attribute("cx", "172"),
attribute("cy", "169"),
attribute("rx", "15"),
attribute("ry", "15"),
attribute("fill", "#ffff88"),
attribute("stroke", "#36393d"),
attribute("pointer-events", "none"),
])
},
svg.path([
attribute("d", "M 10 344 Q 10 344 53.63 344"),
attribute("fill", "none"),
attribute("stroke", "rgba(0, 0, 0, 1)"),
attribute("stroke-miterlimit", "10"),
attribute("pointer-events", "none"),
]),
svg.path([
attribute(
"d",
"M 58.88 344 L 51.88 347.5 L 53.63 344 L 51.88 340.5 Z",
),
attribute("fill", "rgba(0, 0, 0, 1)"),
attribute("stroke", "rgba(0, 0, 0, 1)"),
attribute("stroke-miterlimit", "10"),
attribute("pointer-events", "none"),
]),
svg.rect([
attribute("x", "90"),
attribute("y", "320"),
attribute("width", "97"),
attribute("height", "48"),
attribute("fill", "rgba(255, 255, 255, 1)"),
attribute("stroke", "none"),
attribute("pointer-events", "none"),
]),
svg.g(
[attribute("transform", "translate(-0.5 -0.5)")],
[
element.namespaced(
svg_ns,
"text",
[
attribute("x", "185"),
attribute("y", "354"),
attribute("fill", "rgba(0, 0, 0, 1)"),
attribute("font-family", "Helvetica"),
attribute("font-size", "34px"),
attribute("text-anchor", "end"),
],
[
text(
var(
model.status,
fn(s) { s.heating_effect },
int.to_string,
) <> "°C",
),
],
),
],
),
svg.rect([
attribute("id", "dec-tmp-rect"),
attribute(
"class",
"pump-btn " <> case set_temp_active(model) {
True -> "pump-btn-loading"
False -> ""
},
),
attribute("x", "25"),
attribute("y", "384"),
attribute("width", "60"),
attribute("height", "60"),
attribute("rx", "9"),
attribute("ry", "9"),
attribute(
"fill",
case set_temp_active(model) {
True -> "#ccc"
False -> "url(#mx-gradient-dae8fc-1-7ea6e0-1-s-0)"
},
),
attribute("stroke", "#6c8ebf"),
attribute("phx-click", "dec_temp"),
]),
svg.g(
[attribute("transform", "translate(-0.5 -0.5)")],
[
element.namespaced(
svg_ns,
"text",
[
attribute("id", "dec-tmp-text"),
attribute(
"class",
"pump-btn " <> case set_temp_active(model) {
True -> "pump-btn-loading"
False -> ""
},
),
attribute("x", "55"),
attribute("y", "424"),
attribute("fill", "rgba(0, 0, 0, 1)"),
attribute("font-family", "Helvetica"),
attribute("font-size", "34px"),
attribute("text-anchor", "middle"),
attribute("phx-click", "dec_temp"),
],
[text("-")],
),
],
),
svg.rect([
attribute("id", "inc-tmp-rect"),
attribute(
"class",
"pump-btn " <> case set_temp_active(model) {
True -> "pump-btn-loading"
False -> ""
},
),
attribute("x", "115"),
attribute("y", "384"),
attribute("width", "60"),
attribute("height", "60"),
attribute("rx", "9"),
attribute("ry", "9"),
attribute(
"fill",
case set_temp_active(model) {
True -> "#ccc"
False -> "url(#mx-gradient-f8cecc-1-ea6b66-1-s-0)"
},
),
attribute("stroke", "#b85450"),
attribute("phx-click", "inc_temp"),
]),
svg.g(
[attribute("transform", "translate(-0.5 -0.5)")],
[
element.namespaced(
svg_ns,
"text",
[
attribute("id", "inc-tmp-text"),
attribute(
"class",
"pump-btn " <> case set_temp_active(model) {
True -> "pump-btn-loading"
False -> ""
},
),
attribute("x", "145"),
attribute("y", "424"),
attribute("fill", "rgba(0, 0, 0, 1)"),
attribute("font-family", "Helvetica"),
attribute("font-size", "34px"),
attribute("text-anchor", "middle"),
attribute("phx-click", "inc_temp"),
],
[text("+")],
),
],
),
svg.path([
attribute("d", "M 50 144 L 50 24 L 480 24"),
attribute("fill", "none"),
attribute("stroke", "#b85450"),
attribute("stroke-width", "4"),
attribute("stroke-miterlimit", "10"),
attribute("pointer-events", "none"),
]),
svg.path([
attribute("d", "M 150 144 L 150 84 L 480 84"),
attribute("fill", "none"),
attribute("stroke", "#6c8ebf"),
attribute("stroke-width", "4"),
attribute("stroke-miterlimit", "10"),
attribute("pointer-events", "none"),
]),
svg.path([
attribute("d", "M 200 353 L 310 353 L 310 323 L 480 323"),
attribute("fill", "none"),
attribute("stroke", "#6c8ebf"),
attribute("stroke-width", "4"),
attribute("stroke-miterlimit", "10"),
attribute("pointer-events", "none"),
]),
svg.path([
attribute("d", "M 200 223 L 310 223 L 310 263 L 480 263"),
attribute("fill", "none"),
attribute("stroke", "#b85450"),
attribute("stroke-width", "4"),
attribute("stroke-miterlimit", "10"),
attribute("pointer-events", "none"),
]),
svg.path([
attribute("d", "M 200 614 L 310 614 L 310 584 L 480 584"),
attribute("fill", "none"),
attribute("stroke", "#b85450"),
attribute("stroke-width", "4"),
attribute("stroke-miterlimit", "10"),
attribute("pointer-events", "none"),
]),
svg.path([
attribute("d", "M 200 484 L 310 484 L 310 524 L 480 524"),
attribute("fill", "none"),
attribute("stroke", "#6c8ebf"),
attribute("stroke-width", "4"),
attribute("stroke-miterlimit", "10"),
attribute("pointer-events", "none"),
]),
svg.rect([
attribute("x", "368"),
attribute("y", "560"),
attribute("width", "97"),
attribute("height", "48"),
attribute("fill", "rgba(255, 255, 255, 1)"),
attribute("stroke", "none"),
attribute("pointer-events", "none"),
]),
svg.g(
[attribute("transform", "translate(-0.5 -0.5)")],
[
element.namespaced(
svg_ns,
"text",
[
attribute("x", "463"),
attribute("y", "594"),
attribute("fill", "rgba(0, 0, 0, 1)"),
attribute("font-family", "Helvetica"),
attribute("font-size", "34px"),
attribute("text-anchor", "end"),
],
[
text(
var(
model.registers,
fn(r) { r.brine_in.value },
int.to_string,
) <> "°C",
),
],
),
],
),
svg.rect([
attribute("x", "368"),
attribute("y", "500"),
attribute("width", "97"),
attribute("height", "48"),
attribute("fill", "rgba(255, 255, 255, 1)"),
attribute("stroke", "none"),
attribute("pointer-events", "none"),
]),
svg.g(
[attribute("transform", "translate(-0.5 -0.5)")],
[
element.namespaced(
svg_ns,
"text",
[
attribute("x", "463"),
attribute("y", "534"),
attribute("fill", "rgba(0, 0, 0, 1)"),
attribute("font-family", "Helvetica"),
attribute("font-size", "34px"),
attribute("text-anchor", "end"),
],
[
text(
var(
model.registers,
fn(r) { r.brine_out.value },
int.to_string,
) <> "°C",
),
],
),
],
),
svg.rect([
attribute("x", "368"),
attribute("y", "298"),
attribute("width", "97"),
attribute("height", "48"),
attribute("fill", "rgba(255, 255, 255, 1)"),
attribute("stroke", "none"),
attribute("pointer-events", "none"),
]),
svg.g(
[attribute("transform", "translate(-0.5 -0.5)")],
[
element.namespaced(
svg_ns,
"text",
[
attribute("x", "463"),
attribute("y", "332"),
attribute("fill", "rgba(0, 0, 0, 1)"),
attribute("font-family", "Helvetica"),
attribute("font-size", "34px"),
attribute("text-anchor", "end"),
],
[
text(
var(
model.registers,
fn(r) { r.supply_in.value },
int.to_string,
) <> "°C",
),
],
),
],
),
svg.rect([
attribute("x", "368"),
attribute("y", "239"),
attribute("width", "97"),
attribute("height", "48"),
attribute("fill", "rgba(255, 255, 255, 1)"),
attribute("stroke", "none"),
attribute("pointer-events", "none"),
]),
svg.g(
[attribute("transform", "translate(-0.5 -0.5)")],
[
element.namespaced(
svg_ns,
"text",
[
attribute("x", "463"),
attribute("y", "273"),
attribute("fill", "rgba(0, 0, 0, 1)"),
attribute("font-family", "Helvetica"),
attribute("font-size", "34px"),
attribute("text-anchor", "end"),
],
[
text(
var(
model.registers,
fn(r) { r.supply_out.value },
int.to_string,
) <> "°C",
),
],
),
],
),
svg.rect([
attribute("x", "168"),
attribute("y", "32"),
attribute("width", "97"),
attribute("height", "48"),
attribute("fill", "rgba(255, 255, 255, 1)"),
attribute("stroke", "none"),
attribute("pointer-events", "none"),
]),
svg.g(
[attribute("transform", "translate(-0.5 -0.5)")],
[
element.namespaced(
svg_ns,
"text",
[
attribute("x", "263"),
attribute("y", "66"),
attribute("fill", "rgba(0, 0, 0, 1)"),
attribute("font-family", "Helvetica"),
attribute("font-size", "34px"),
attribute("text-anchor", "end"),
],
[
text(
var(
model.registers,
fn(r) { r.hot_water_temp.value },
int.to_string,
) <> "°C",
),
],
),
],
),
svg.rect([
attribute("x", "269"),
attribute("y", "284"),
attribute("width", "40"),
attribute("height", "10"),
attribute("fill", "rgba(255, 255, 255, 1)"),
attribute("stroke", "rgba(0, 0, 0, 1)"),
attribute("pointer-events", "none"),
]),
svg.rect([
attribute("x", "210"),
attribute("y", "314"),
attribute("width", "40"),
attribute("height", "10"),
attribute("fill", "rgba(255, 255, 255, 1)"),
attribute("stroke", "rgba(0, 0, 0, 1)"),
attribute("pointer-events", "none"),
]),
svg.rect([
attribute("x", "210"),
attribute("y", "284"),
attribute("width", "40"),
attribute("height", "10"),
attribute("fill", "rgba(255, 255, 255, 1)"),
attribute("stroke", "rgba(0, 0, 0, 1)"),
attribute("pointer-events", "none"),
]),
svg.rect([
attribute("x", "220"),
attribute("y", "274"),
attribute("width", "80"),
attribute("height", "60"),
attribute("fill", "rgba(255, 255, 255, 1)"),
attribute("stroke", "rgba(0, 0, 0, 1)"),
attribute("pointer-events", "none"),
]),
svg.path([
attribute("d", "M 230 326.5 L 230 281.5"),
attribute("fill", "none"),
attribute("stroke", "rgba(0, 0, 0, 1)"),
attribute("stroke-miterlimit", "10"),
attribute("pointer-events", "none"),
]),
svg.path([
attribute("d", "M 240 326.5 L 240 281.5"),
attribute("fill", "none"),
attribute("stroke", "rgba(0, 0, 0, 1)"),
attribute("stroke-miterlimit", "10"),
attribute("pointer-events", "none"),
]),
svg.path([
attribute("d", "M 250 326.5 L 250 281.5"),
attribute("fill", "none"),
attribute("stroke", "rgba(0, 0, 0, 1)"),
attribute("stroke-miterlimit", "10"),
attribute("pointer-events", "none"),
]),
svg.path([
attribute("d", "M 260 326.5 L 260 281.5"),
attribute("fill", "none"),
attribute("stroke", "rgba(0, 0, 0, 1)"),
attribute("stroke-miterlimit", "10"),
attribute("pointer-events", "none"),
]),
svg.path([
attribute("d", "M 270 326.5 L 270 281.5"),
attribute("fill", "none"),
attribute("stroke", "rgba(0, 0, 0, 1)"),
attribute("stroke-miterlimit", "10"),
attribute("pointer-events", "none"),
]),
svg.path([
attribute("d", "M 280 326.5 L 280 281.5"),
attribute("fill", "none"),
attribute("stroke", "rgba(0, 0, 0, 1)"),
attribute("stroke-miterlimit", "10"),
attribute("pointer-events", "none"),
]),
svg.path([
attribute("d", "M 290 326.5 L 290 281.5"),
attribute("fill", "none"),
attribute("stroke", "rgba(0, 0, 0, 1)"),
attribute("stroke-miterlimit", "10"),
attribute("pointer-events", "none"),
]),
svg.path([
attribute("d", "M 230 264 Q 220 254 230 249 Q 240 244 230 234"),
attribute("fill", "none"),
attribute("stroke", "rgba(0, 0, 0, 1)"),
attribute("stroke-miterlimit", "10"),
attribute("pointer-events", "none"),
]),
svg.path([
attribute("d", "M 250 264 Q 240 254 250 249 Q 260 244 250 234"),
attribute("fill", "none"),
attribute("stroke", "rgba(0, 0, 0, 1)"),
attribute("stroke-miterlimit", "10"),
attribute("pointer-events", "none"),
]),
svg.path([
attribute("d", "M 270 264 Q 260 254 270 249 Q 280 244 270 234"),
attribute("fill", "none"),
attribute("stroke", "rgba(0, 0, 0, 1)"),
attribute("stroke-miterlimit", "10"),
attribute("pointer-events", "none"),
]),
svg.path([
attribute("d", "M 290 264 Q 280 254 290 249 Q 300 244 290 234"),
attribute("fill", "none"),
attribute("stroke", "rgba(0, 0, 0, 1)"),
attribute("stroke-miterlimit", "10"),
attribute("pointer-events", "none"),
]),
svg.path([
attribute(
"d",
"M 70 60 L 140 60 L 140 80 L 90 80 L 90 100 L 70 100 Z",
),
attribute("fill", "rgba(255, 255, 255, 1)"),
attribute("stroke", "rgba(0, 0, 0, 1)"),
attribute("stroke-miterlimit", "10"),
attribute("pointer-events", "none"),
]),
svg.path([
attribute(
"d",
"M 105 35 L 125 35 L 125 45 L 115 45 L 115 65 L 105 65 Z",
),
attribute("fill", "rgba(255, 255, 255, 1)"),
attribute("stroke", "rgba(0, 0, 0, 1)"),
attribute("stroke-miterlimit", "10"),
attribute("transform", "rotate(90,115,50)"),
attribute("pointer-events", "none"),
]),
svg.path([
attribute(
"d",
"M 80 102 L 84.71 115.33 C 84.9 115.87 85 116.43 85 117 C 85 118.33 84.47 119.6 83.54 120.54 C 82.6 121.47 81.33 122 80 122 C 78.67 122 77.4 121.47 76.46 120.54 C 75.53 119.6 75 118.33 75 117 C 75 116.43 75.1 115.87 75.29 115.33 Z",
),
attribute("fill", "#dae8fc"),
attribute("stroke", "#6c8ebf"),
attribute("stroke-miterlimit", "10"),
attribute("pointer-events", "none"),
]),
svg.path([
attribute(
"d",
"M 235 506 C 235 497.72 245.07 491 257.5 491 C 263.47 491 269.19 492.58 273.41 495.39 C 277.63 498.21 280 502.02 280 506 L 280 592 C 280 600.28 269.93 607 257.5 607 C 245.07 607 235 600.28 235 592 Z",
),
attribute("fill", "rgba(255, 255, 255, 1)"),
attribute("stroke", "rgba(0, 0, 0, 1)"),
attribute("stroke-miterlimit", "10"),
attribute("pointer-events", "none"),
]),
svg.path([
attribute(
"d",
"M 280 506 C 280 514.28 269.93 521 257.5 521 C 245.07 521 235 514.28 235 506",
),
attribute("fill", "none"),
attribute("stroke", "rgba(0, 0, 0, 1)"),
attribute("stroke-miterlimit", "10"),
attribute("pointer-events", "none"),
]),
],
),
],
),
],
)
}
fn var(
source: Option(a),
mapper: fn(a) -> b,
stringifier: fn(b) -> String,
) -> String {
case source {
Some(data) ->
data
|> mapper()
|> stringifier()
None -> "--"
}
}
fn set_temp_active(model: Model) -> Bool {
case model.status {
Some(status) -> status.is_heating_effect_set_by_user
None -> False
}
}
fn load_device(config: Config, user: User, installation: InstallationInfo) {
use dispatch <- effect.from()
device_api.device_info(config, user, installation)
|> promise.map(DeviceLoadResult)
|> promise.tap(dispatch)
Nil
}
fn update_state(config: Config, user: User, device: Device) {
use dispatch <- effect.from()
timers.set_timeout(fn() { dispatch(StartUpdate) }, config.api_refresh)
[
device_api.status(config, user, device)
|> promise.map(Status),
device_api.register_info(config, user, device)
|> promise.map(Registers),
device_api.opstat(config, user, device)
|> promise.map(OpStat),
]
|> promise.await_list()
|> promise.map(UpdateResults)
|> promise.tap(dispatch)
Nil
}

View file

@ -1,8 +0,0 @@
import gleam/erlang/atom.{Atom}
import gleam/dynamic.{Dynamic}
pub external fn get_env(Atom, Atom) -> Dynamic =
"Elixir.Application" "get_env"
pub external fn fetch_env_angry(Atom, Atom) -> Dynamic =
"Elixir.Application" "fetch_env!"

View file

@ -1,2 +0,0 @@
pub external fn part(data: BitString, pos: Int, len: Int) -> BitString =
"binary" "part"

View file

@ -1,37 +0,0 @@
import gleam/erlang/atom
import gleam/dynamic
import gleam/uri
import helpers/application
fn app_name() -> atom.Atom {
assert Ok(name) = atom.from_string("geo_therminator")
name
}
pub fn api_timeout() -> Int {
let timeout =
application.fetch_env_angry(
app_name(),
atom.create_from_string("api_timeout"),
)
assert Ok(timeout_int) = dynamic.int(timeout)
timeout_int
}
pub fn api_auth_url() -> uri.Uri {
config_url("api_auth_url")
}
pub fn api_installations_url() -> uri.Uri {
config_url("api_installations_url")
}
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,7 +0,0 @@
pub type HashAlgorithm {
// For now, only SHA256
Sha256
}
pub external fn hash(algo: HashAlgorithm, data: String) -> BitString =
"crypto" "hash"

View file

@ -1,9 +0,0 @@
import gleam/erlang/atom.{Atom}
pub external type DateTime
pub external fn from_iso8601(String) -> Result(#(DateTime, Int), Atom) =
"Elixir.DateTime" "from_iso8601"
pub external fn from_unix(Int) -> Result(DateTime, #(Atom, Atom)) =
"Elixir.DateTime" "from_unix"

View file

@ -1,4 +0,0 @@
import gleam/erlang/charlist.{Charlist}
pub external fn format_int(format: String, data: List(Int)) -> Charlist =
"io_lib" "format"

View file

@ -1,85 +0,0 @@
import gleam/http/request
import gleam/json
import gleam/hackney
import gleam/result
import gleam/dynamic
import gleam/list
import gleam/string
import pump_api/auth/user.{User}
import pump_api/auth/tokens.{Tokens}
import pump_api/auth/installation_info.{InstallationInfo}
import pump_api/http
import helpers/config
import helpers/date_time
import helpers/parsing
import azure/b2c.{B2CError}
pub type ApiError {
ApiRequestFailed
NotOkResponse
InvalidData(msg: String)
AuthError(inner: B2CError)
}
pub fn auth(username: String, password: String) -> Result(User, ApiError) {
try tokens =
b2c.authenticate(username, password)
|> result.map_error(fn(err) { AuthError(inner: err) })
try access_token_expires_in =
date_time.from_unix(tokens.access_token_expires_in)
|> result.replace_error(InvalidData(
msg: "Access token expiry could not be converted into DateTime: " <> string.inspect(
tokens.access_token_expires_in,
),
))
try refresh_token_expires_in =
date_time.from_unix(tokens.refresh_token_expires_in)
|> result.replace_error(InvalidData(
msg: "Refresh token expiry could not be converted into DateTime: " <> string.inspect(
tokens.refresh_token_expires_in,
),
))
Ok(User(tokens: Tokens(
access_token: tokens.access_token,
access_token_expiry: access_token_expires_in,
refresh_token: tokens.refresh_token,
refresh_token_expiry: refresh_token_expires_in,
)))
}
pub fn installation_info(user: User) -> Result(List(InstallationInfo), ApiError) {
let url = config.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 items =
parsing.data_get(
data,
"items",
dynamic.list(of: dynamic.field("id", dynamic.int)),
InvalidData,
)
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.",
))
}

Some files were not shown because too many files have changed in this diff Show more