Implement pump view

This commit is contained in:
Mikko Ahlroth 2023-09-22 21:09:36 +03:00
parent 28c09b5086
commit 3695d4874f
16 changed files with 1470 additions and 32 deletions

View file

@ -2,15 +2,15 @@
# You typically do not need to edit this file
packages = [
{ name = "gleam_fetch", version = "0.2.0", build_tools = ["gleam"], requirements = ["gleam_http", "gleam_javascript"], otp_app = "gleam_fetch", source = "hex", outer_checksum = "D0C9E9CAE8D6EFCCC3A9FF817DCA9ED327097222086D91DE4F6CA8FBAB02D79F" },
{ 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.2", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "lustre", source = "hex", outer_checksum = "9A18A7588776C14FD14D3838DF5881EF12C611C2F55637DA528A60BCCA135B41" },
{ name = "plinth", version = "0.1.2", build_tools = ["gleam"], requirements = ["gleam_javascript", "gleam_stdlib"], otp_app = "plinth", source = "hex", outer_checksum = "E40A48FAA3AB9410803AB937BE620692D86B7ABB46459A83E8C674B82CFFD05B" },
{ 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_json", "gleam_stdlib", "plinth"], otp_app = "varasto", source = "hex", outer_checksum = "0621E5BFD0B9B7F7D19B8FC6369C6E2EAC5C1F3858A1E5E51342F5BCE10C3728" },
{ name = "varasto", version = "1.0.0", build_tools = ["gleam"], requirements = ["gleam_stdlib", "gleam_json", "plinth"], otp_app = "varasto", source = "hex", outer_checksum = "0621E5BFD0B9B7F7D19B8FC6369C6E2EAC5C1F3858A1E5E51342F5BCE10C3728" },
]
[requirements]

View file

@ -6,6 +6,34 @@
<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";

3
src/bitwise_ffi.mjs Normal file
View file

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

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,8 @@
import geo_t/azure/b2c.{B2CError}
pub type ApiError {
ApiRequestFailed
NotOkResponse
InvalidData(msg: String)
AuthError(inner: B2CError)
}

View file

@ -2,7 +2,6 @@ import gleam/http/request
import gleam/json
import gleam/result
import gleam/dynamic
import gleam/list
import gleam/string
import gleam/javascript/promise.{Promise}
import geo_t/pump_api/auth/user.{User}
@ -11,16 +10,12 @@ 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.{B2CError}
import geo_t/azure/b2c
import geo_t/helpers/promise as promise_helpers
import geo_t/helpers/fetch as fetch_helpers
import geo_t/config.{Config}
pub type ApiError {
ApiRequestFailed
NotOkResponse
InvalidData(msg: String)
AuthError(inner: B2CError)
import geo_t/pump_api/api.{
ApiError, ApiRequestFailed, AuthError, InvalidData, NotOkResponse,
}
pub fn auth(
@ -73,11 +68,11 @@ pub fn installation_info(
use items <- promise.try_await(promise.resolve(parsing.data_get(
data,
"items",
dynamic.list(of: dynamic.field("id", dynamic.int)),
dynamic.list(of: installation_info.parse),
InvalidData,
)))
promise.resolve(Ok(list.map(items, fn(id) { InstallationInfo(id: id) })))
promise.resolve(Ok(items))
}
fn run_req(req: request.Request(String)) {

View file

@ -1,3 +1,13 @@
import gleam/result
import gleam/dynamic.{Dynamic}
pub type InstallationInfo {
InstallationInfo(id: Int)
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,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

@ -10,4 +10,5 @@ pub type OpStat {
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

@ -1,6 +1,12 @@
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
@ -29,3 +35,28 @@ pub fn req(r: ApiRequest) -> request.Request(String) {
|> 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.",
)),
)
}

View file

@ -9,8 +9,10 @@ import plinth/javascript/storage.{Storage}
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/api.{ApiError}
import geo_t/pump_api/api.{ApiError}
import geo_t/pump_api/auth/api as auth_api
import geo_t/web/auth.{AuthInfo, AuthInfoStorage}
pub fn main() {
@ -26,6 +28,7 @@ pub type Model {
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,
@ -37,6 +40,7 @@ pub type Msg {
LoginView(login_view.Msg)
LoginResult(Result(User, ApiError))
InstallationsView(installations_view.Msg)
PumpView(pump_view.Msg)
}
fn init(_) {
@ -48,18 +52,28 @@ fn init(_) {
Error(_) -> None
}
let #(inst_model, inst_effect) = case auth_info {
Some(info) ->
update_installations(
installations_view.init(),
installations_view.LoadInstallations(config, info.user),
)
None -> #(installations_view.init(), effect.none())
}
#(
Model(
config: config,
login: login_view.init(),
installations: installations_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),
),
effect.none(),
inst_effect,
)
}
@ -87,7 +101,7 @@ fn update(model: Model, msg: Msg) {
let #(inst_model, inst_effect) =
update_installations(
model,
model.installations,
installations_view.LoadInstallations(model.config, user),
)
@ -117,10 +131,32 @@ fn update(model: Model, msg: Msg) {
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, 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())
}
}
}
}
@ -132,9 +168,16 @@ fn view(model: Model) {
False ->
login_view.view(model.login)
|> element.map(LoginView)
True ->
installations_view.view(model.installations)
|> element.map(InstallationsView)
True -> {
case model.pump {
None ->
installations_view.view(model.installations)
|> element.map(InstallationsView)
Some(pump) ->
pump_view.view(pump)
|> element.map(PumpView)
}
}
},
],
)
@ -143,16 +186,31 @@ fn view(model: Model) {
fn login(model: Model) -> Effect(Msg) {
use dispatch <- effect.from()
api.auth(model.config, model.login.username, model.login.password)
auth_api.auth(model.config, model.login.username, model.login.password)
|> promise.map(LoginResult)
|> promise.tap(dispatch)
Nil
}
fn update_installations(model: Model, msg: installations_view.Msg) {
let #(new_model, new_effect) =
installations_view.update(model.installations, msg)
let new_effect = effect.map(new_effect, fn(msg) { InstallationsView(msg) })
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)
}

View file

@ -9,7 +9,8 @@ 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.{ApiError}
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 =
@ -26,6 +27,7 @@ pub fn init() {
pub type Msg {
LoadInstallations(Config, User)
InstallationsResult(Result(List(InstallationInfo), ApiError))
ViewInstallation(InstallationInfo)
}
pub fn update(model: Model, msg: Msg) -> #(Model, Effect(Msg)) {
@ -39,6 +41,8 @@ pub fn update(model: Model, msg: Msg) -> #(Model, Effect(Msg)) {
Model(loading: False, installations: data),
effect.none(),
)
ViewInstallation(_) -> #(model, effect.none())
}
}
@ -55,8 +59,16 @@ pub fn view(model: Model) -> Element(Msg) {
installations,
fn(installation) {
html.button(
[attribute.type_("button")],
[text(int.to_string(installation.id))],
[
attribute.type_("button"),
event.on_click(ViewInstallation(installation)),
],
[
text(installation.name),
text(" ("),
text(int.to_string(installation.id)),
text(")"),
],
)
},
),
@ -74,7 +86,7 @@ pub fn view(model: Model) -> Element(Msg) {
fn load_installations(config: Config, user: User) {
use dispatch <- effect.from()
api.installation_info(config, user)
auth_api.installation_info(config, user)
|> promise.map(InstallationsResult)
|> promise.tap(dispatch)

View file

@ -0,0 +1,926 @@
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(Device)
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)) -> #(
Model(..model, loading: False, device: Some(device)),
update_state(model.config, model.user, device),
)
StartUpdate(device) -> #(
Model(..model, loading: True, device: Some(device)),
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()
dispatch(StartUpdate(device))
do_update_state(dispatch, config, user, device)
Nil
}
fn do_update_state(
dispatch: fn(Msg) -> Nil,
config: Config,
user: User,
device: Device,
) {
timers.set_timeout(
fn() {
dispatch(StartUpdate(device))
do_update_state(dispatch, config, user, device)
},
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
}

3
src/timers_ffi.mjs Normal file
View file

@ -0,0 +1,3 @@
export function setTimeout(callback, delay) {
globalThis.setTimeout(callback, delay);
}