Compare commits
No commits in common. "main" and "v1.5.0" have entirely different histories.
52 changed files with 2961 additions and 4691 deletions
|
@ -3,7 +3,7 @@ name: Build and release .deb
|
|||
on:
|
||||
push:
|
||||
tags:
|
||||
- "v*"
|
||||
- "v[0-9]+.[0-9]+.[0-9]+"
|
||||
|
||||
jobs:
|
||||
Release:
|
||||
|
@ -31,5 +31,5 @@ jobs:
|
|||
with:
|
||||
files: |-
|
||||
./target/aarch64-unknown-linux-musl/debian/*.deb
|
||||
./target/aarch64-unknown-linux-musl/release/charge-controller-supervisor
|
||||
./target/aarch64-unknown-linux-musl/release/tesla-charge-controller
|
||||
api_key: "${{secrets.PACKAGING_TOKEN}}"
|
0
.gitmodules
vendored
Normal file
0
.gitmodules
vendored
Normal file
32
.taplo.toml
32
.taplo.toml
|
@ -1,32 +0,0 @@
|
|||
[formatting]
|
||||
indent_string = "\t"
|
||||
reorder_keys = false
|
||||
reorder_arrays = false
|
||||
reorder_inline_tables = true
|
||||
|
||||
[[rule]]
|
||||
include = ["**/Cargo.toml"]
|
||||
|
||||
[rule.schema]
|
||||
path = "https://json.schemastore.org/cargo.json"
|
||||
enabled = true
|
||||
|
||||
# schema definition for .cargo/config.toml is not yet available
|
||||
# see https://github.com/rust-lang/cargo/issues/12883
|
||||
|
||||
[[rule]]
|
||||
include = ["**/Cargo.toml"]
|
||||
keys = [
|
||||
"build-dependencies",
|
||||
"dependencies",
|
||||
"dev-dependencies",
|
||||
"target.*.build-dependencies",
|
||||
"target.*.dependencies",
|
||||
"target.*.dev-dependencies",
|
||||
"workspace.dependencies",
|
||||
"workspace.lints.clippy",
|
||||
]
|
||||
|
||||
[rule.formatting]
|
||||
reorder_keys = true
|
||||
reorder_arrays = true
|
617
Cargo.lock
generated
617
Cargo.lock
generated
|
@ -144,6 +144,32 @@ version = "1.4.0"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ace50bade8e6234aa140d9a2f552bbee1db4d353f69b8217bc503490fc1a9f26"
|
||||
|
||||
[[package]]
|
||||
name = "aws-lc-rs"
|
||||
version = "1.12.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f409eb70b561706bf8abba8ca9c112729c481595893fd06a2dd9af8ed8441148"
|
||||
dependencies = [
|
||||
"aws-lc-sys",
|
||||
"paste",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "aws-lc-sys"
|
||||
version = "0.24.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8478a5c29ead3f3be14aff8a202ad965cf7da6856860041bfca271becf8ba48b"
|
||||
dependencies = [
|
||||
"bindgen",
|
||||
"cc",
|
||||
"cmake",
|
||||
"dunce",
|
||||
"fs_extra",
|
||||
"libc",
|
||||
"paste",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "backtrace"
|
||||
version = "0.3.74"
|
||||
|
@ -159,6 +185,15 @@ dependencies = [
|
|||
"windows-targets 0.52.6",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "backtrace-ext"
|
||||
version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "537beee3be4a18fb023b570f80e3ae28003db9167a751266b259926e25539d50"
|
||||
dependencies = [
|
||||
"backtrace",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "base64"
|
||||
version = "0.21.7"
|
||||
|
@ -177,6 +212,29 @@ version = "0.1.4"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "383d29d513d8764dcdc42ea295d979eb99c3c9f00607b3692cf68a431f7dca72"
|
||||
|
||||
[[package]]
|
||||
name = "bindgen"
|
||||
version = "0.69.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "271383c67ccabffb7381723dea0672a673f292304fcb45c01cc648c7a8d58088"
|
||||
dependencies = [
|
||||
"bitflags 2.6.0",
|
||||
"cexpr",
|
||||
"clang-sys",
|
||||
"itertools",
|
||||
"lazy_static",
|
||||
"lazycell",
|
||||
"log",
|
||||
"prettyplease",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"regex",
|
||||
"rustc-hash 1.1.0",
|
||||
"shlex",
|
||||
"syn",
|
||||
"which",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bitflags"
|
||||
version = "1.3.2"
|
||||
|
@ -185,13 +243,22 @@ checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a"
|
|||
|
||||
[[package]]
|
||||
name = "bitflags"
|
||||
version = "2.7.0"
|
||||
version = "2.6.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1be3f42a67d6d345ecd59f675f3f012d6974981560836e938c22b424b85ce1be"
|
||||
checksum = "b048fb63fd8b5923fc5aa7b340d8e156aec7ec02f0c78fa8a6ddc2613f6f71de"
|
||||
dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "block-buffer"
|
||||
version = "0.10.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71"
|
||||
dependencies = [
|
||||
"generic-array",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bumpalo"
|
||||
version = "3.16.0"
|
||||
|
@ -222,9 +289,20 @@ version = "1.2.5"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c31a0499c1dc64f458ad13872de75c0eb7e3fdb0e67964610c914b034fc5956e"
|
||||
dependencies = [
|
||||
"jobserver",
|
||||
"libc",
|
||||
"shlex",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cexpr"
|
||||
version = "0.6.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6fac387a98bb7c37292057cffc56d62ecb629900026402633ae9160df93a8766"
|
||||
dependencies = [
|
||||
"nom",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cfg-if"
|
||||
version = "1.0.0"
|
||||
|
@ -237,30 +315,6 @@ version = "0.2.1"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724"
|
||||
|
||||
[[package]]
|
||||
name = "charge-controller-supervisor"
|
||||
version = "1.9.9-pre-30"
|
||||
dependencies = [
|
||||
"bitflags 2.7.0",
|
||||
"chrono",
|
||||
"clap",
|
||||
"env_logger",
|
||||
"eyre",
|
||||
"futures",
|
||||
"include_dir",
|
||||
"lazy_static",
|
||||
"log",
|
||||
"notify-debouncer-mini",
|
||||
"prometheus",
|
||||
"rocket",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"thiserror 2.0.11",
|
||||
"tokio",
|
||||
"tokio-modbus",
|
||||
"tokio-serial",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "chrono"
|
||||
version = "0.4.39"
|
||||
|
@ -271,10 +325,22 @@ dependencies = [
|
|||
"iana-time-zone",
|
||||
"js-sys",
|
||||
"num-traits",
|
||||
"serde",
|
||||
"wasm-bindgen",
|
||||
"windows-targets 0.52.6",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "clang-sys"
|
||||
version = "1.8.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0b023947811758c97c59bf9d1c188fd619ad4718dcaa767947df1cadb14f39f4"
|
||||
dependencies = [
|
||||
"glob",
|
||||
"libc",
|
||||
"libloading",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "clap"
|
||||
version = "4.5.23"
|
||||
|
@ -315,12 +381,41 @@ version = "0.7.4"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f46ad14479a25103f283c0f10005961cf086d8dc42205bb44c46ac563475dca6"
|
||||
|
||||
[[package]]
|
||||
name = "cmake"
|
||||
version = "0.1.52"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c682c223677e0e5b6b7f63a64b9351844c3f1b1678a68b7ee617e30fb082620e"
|
||||
dependencies = [
|
||||
"cc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "colorchoice"
|
||||
version = "1.0.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5b63caa9aa9397e2d9480a9b13673856c78d8ac123288526c37d7839f2a86990"
|
||||
|
||||
[[package]]
|
||||
name = "colored_json"
|
||||
version = "5.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e35980a1b846f8e3e359fd18099172a0857140ba9230affc4f71348081e039b6"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
"yansi",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "convert_case"
|
||||
version = "0.6.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ec182b0ca2f35d8fc196cf3404988fd8b8c739a4d270ff118a398feb0cbec1ca"
|
||||
dependencies = [
|
||||
"unicode-segmentation",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cookie"
|
||||
version = "0.18.1"
|
||||
|
@ -332,6 +427,24 @@ dependencies = [
|
|||
"version_check",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cookie_store"
|
||||
version = "0.21.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2eac901828f88a5241ee0600950ab981148a18f2f756900ffba1b125ca6a3ef9"
|
||||
dependencies = [
|
||||
"cookie",
|
||||
"document-features",
|
||||
"idna",
|
||||
"log",
|
||||
"publicsuffix",
|
||||
"serde",
|
||||
"serde_derive",
|
||||
"serde_json",
|
||||
"time",
|
||||
"url",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "core-foundation"
|
||||
version = "0.10.0"
|
||||
|
@ -348,6 +461,25 @@ version = "0.8.7"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b"
|
||||
|
||||
[[package]]
|
||||
name = "cpufeatures"
|
||||
version = "0.2.16"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "16b80225097f2e5ae4e7179dd2266824648f3e2f49d9134d584b76389d31c4c3"
|
||||
dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crypto-common"
|
||||
version = "0.1.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1bfb12502f3fc46cca1bb51ac28df9d618d813cdc3d2f25b9fe775a34af26bb3"
|
||||
dependencies = [
|
||||
"generic-array",
|
||||
"typenum",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "deranged"
|
||||
version = "0.3.11"
|
||||
|
@ -357,6 +489,28 @@ dependencies = [
|
|||
"powerfmt",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "derive_more"
|
||||
version = "1.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4a9b99b9cbbe49445b21764dc0625032a89b145a2642e67603e1c936f5458d05"
|
||||
dependencies = [
|
||||
"derive_more-impl",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "derive_more-impl"
|
||||
version = "1.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cb7330aeadfbe296029522e6c40f315320aba36fc43a5b3632f3795348f3bd22"
|
||||
dependencies = [
|
||||
"convert_case",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
"unicode-xid",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "devise"
|
||||
version = "0.4.2"
|
||||
|
@ -383,13 +537,23 @@ version = "0.4.2"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b035a542cf7abf01f2e3c4d5a7acbaebfefe120ae4efc7bde3df98186e4b8af7"
|
||||
dependencies = [
|
||||
"bitflags 2.7.0",
|
||||
"bitflags 2.6.0",
|
||||
"proc-macro2",
|
||||
"proc-macro2-diagnostics",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "digest"
|
||||
version = "0.10.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
|
||||
dependencies = [
|
||||
"block-buffer",
|
||||
"crypto-common",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "displaydoc"
|
||||
version = "0.2.5"
|
||||
|
@ -401,6 +565,21 @@ dependencies = [
|
|||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "document-features"
|
||||
version = "0.2.10"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cb6969eaabd2421f8a2775cfd2471a2b634372b4a25d41e3bd647b79912850a0"
|
||||
dependencies = [
|
||||
"litrs",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "dunce"
|
||||
version = "1.0.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813"
|
||||
|
||||
[[package]]
|
||||
name = "either"
|
||||
version = "1.13.0"
|
||||
|
@ -455,16 +634,6 @@ dependencies = [
|
|||
"windows-sys 0.59.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "eyre"
|
||||
version = "0.6.12"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7cd915d99f24784cdc19fd37ef22b97e3ff0ae756c7e492e9fbfe897d61e2aec"
|
||||
dependencies = [
|
||||
"indenter",
|
||||
"once_cell",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fastrand"
|
||||
version = "2.3.0"
|
||||
|
@ -512,6 +681,12 @@ dependencies = [
|
|||
"percent-encoding",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fs_extra"
|
||||
version = "1.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c"
|
||||
|
||||
[[package]]
|
||||
name = "fsevent-sys"
|
||||
version = "4.1.0"
|
||||
|
@ -623,6 +798,16 @@ dependencies = [
|
|||
"windows",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "generic-array"
|
||||
version = "0.14.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a"
|
||||
dependencies = [
|
||||
"typenum",
|
||||
"version_check",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "getrandom"
|
||||
version = "0.2.15"
|
||||
|
@ -691,6 +876,15 @@ version = "0.4.0"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fbf6a919d6cf397374f7dfeeea91d974c7c0a7221d0d0f4f20d859d329e53fcc"
|
||||
|
||||
[[package]]
|
||||
name = "home"
|
||||
version = "0.5.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "589533453244b0995c858700322199b2becb13b627df2851f64a2775d024abcf"
|
||||
dependencies = [
|
||||
"windows-sys 0.59.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "http"
|
||||
version = "0.2.12"
|
||||
|
@ -1032,12 +1226,6 @@ dependencies = [
|
|||
"quote",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "indenter"
|
||||
version = "0.3.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ce23b50ad8242c51a442f3ff322d56b02f08852c77e4c0b4d3fd684abc89c683"
|
||||
|
||||
[[package]]
|
||||
name = "indexmap"
|
||||
version = "2.7.0"
|
||||
|
@ -1111,18 +1299,42 @@ dependencies = [
|
|||
"windows-sys 0.52.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "is_ci"
|
||||
version = "1.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7655c9839580ee829dfacba1d1278c2b7883e50a277ff7541299489d6bdfdc45"
|
||||
|
||||
[[package]]
|
||||
name = "is_terminal_polyfill"
|
||||
version = "1.70.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7943c866cc5cd64cbc25b2e01621d07fa8eb2a1a23160ee81ce38704e97b8ecf"
|
||||
|
||||
[[package]]
|
||||
name = "itertools"
|
||||
version = "0.12.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ba291022dbbd398a455acf126c1e341954079855bc60dfdda641363bd6922569"
|
||||
dependencies = [
|
||||
"either",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "itoa"
|
||||
version = "1.0.14"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d75a2a4b1b190afb6f5425f10f6a8f959d2ea0b9c2b1d79553551850539e4674"
|
||||
|
||||
[[package]]
|
||||
name = "jobserver"
|
||||
version = "0.1.32"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "48d1dbcbbeb6a7fec7e059840aa538bd62aaccf972c7346c4d9d2059312853d0"
|
||||
dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "js-sys"
|
||||
version = "0.3.76"
|
||||
|
@ -1159,19 +1371,35 @@ version = "1.5.0"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe"
|
||||
|
||||
[[package]]
|
||||
name = "lazycell"
|
||||
version = "1.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "830d08ce1d1d941e6b30645f1a0eb5643013d835ce3779a5fc208261dbe10f55"
|
||||
|
||||
[[package]]
|
||||
name = "libc"
|
||||
version = "0.2.169"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b5aba8db14291edd000dfcc4d620c7ebfb122c613afb886ca8803fa4e128a20a"
|
||||
|
||||
[[package]]
|
||||
name = "libloading"
|
||||
version = "0.8.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fc2f4eb4bc735547cfed7c0a4922cbd04a4655978c09b54f1f7b228750664c34"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"windows-targets 0.52.6",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "libredox"
|
||||
version = "0.1.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c0ff37bd590ca25063e35af745c343cb7a0271906fb7b37e4813e8f79f00268d"
|
||||
dependencies = [
|
||||
"bitflags 2.7.0",
|
||||
"bitflags 2.6.0",
|
||||
"libc",
|
||||
"redox_syscall",
|
||||
]
|
||||
|
@ -1208,6 +1436,12 @@ version = "0.7.4"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4ee93343901ab17bd981295f2cf0026d4ad018c7c31ba84549a4ddbb47a45104"
|
||||
|
||||
[[package]]
|
||||
name = "litrs"
|
||||
version = "0.4.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b4ce301924b7887e9d637144fdade93f9dfff9b60981d4ac161db09720d39aa5"
|
||||
|
||||
[[package]]
|
||||
name = "lock_api"
|
||||
version = "0.4.12"
|
||||
|
@ -1272,12 +1506,49 @@ dependencies = [
|
|||
"autocfg",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "miette"
|
||||
version = "7.4.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "317f146e2eb7021892722af37cf1b971f0a70c8406f487e24952667616192c64"
|
||||
dependencies = [
|
||||
"backtrace",
|
||||
"backtrace-ext",
|
||||
"cfg-if",
|
||||
"miette-derive",
|
||||
"owo-colors",
|
||||
"supports-color",
|
||||
"supports-hyperlinks",
|
||||
"supports-unicode",
|
||||
"terminal_size",
|
||||
"textwrap",
|
||||
"thiserror 1.0.69",
|
||||
"unicode-width",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "miette-derive"
|
||||
version = "7.4.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "23c9b935fbe1d6cbd1dac857b54a688145e2d93f48db36010514d0f612d0ad67"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "mime"
|
||||
version = "0.3.17"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
|
||||
|
||||
[[package]]
|
||||
name = "minimal-lexical"
|
||||
version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a"
|
||||
|
||||
[[package]]
|
||||
name = "miniz_oxide"
|
||||
version = "0.8.2"
|
||||
|
@ -1356,13 +1627,23 @@ dependencies = [
|
|||
"pin-utils",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "nom"
|
||||
version = "7.1.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a"
|
||||
dependencies = [
|
||||
"memchr",
|
||||
"minimal-lexical",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "notify"
|
||||
version = "7.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c533b4c39709f9ba5005d8002048266593c1cfaf3c5f0739d5b8ab0c6c504009"
|
||||
dependencies = [
|
||||
"bitflags 2.7.0",
|
||||
"bitflags 2.6.0",
|
||||
"filetime",
|
||||
"fsevent-sys",
|
||||
"inotify",
|
||||
|
@ -1452,6 +1733,12 @@ version = "0.1.1"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b15813163c1d831bf4a13c3610c05c0d03b39feb07f7e09fa234dac9b15aaf39"
|
||||
|
||||
[[package]]
|
||||
name = "owo-colors"
|
||||
version = "4.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fb37767f6569cd834a413442455e0f066d0d522de8630436e2a1761d9726ba56"
|
||||
|
||||
[[package]]
|
||||
name = "parking_lot"
|
||||
version = "0.12.3"
|
||||
|
@ -1475,6 +1762,12 @@ dependencies = [
|
|||
"windows-targets 0.52.6",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "paste"
|
||||
version = "1.0.15"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a"
|
||||
|
||||
[[package]]
|
||||
name = "pear"
|
||||
version = "0.2.9"
|
||||
|
@ -1516,6 +1809,17 @@ version = "0.1.0"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184"
|
||||
|
||||
[[package]]
|
||||
name = "pkce"
|
||||
version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e228dbe2aebd82de09c914fe28d36d3170ed5192e8d52b9c070ee0794519c2d3"
|
||||
dependencies = [
|
||||
"base64 0.21.7",
|
||||
"rand",
|
||||
"sha2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pkg-config"
|
||||
version = "0.3.31"
|
||||
|
@ -1537,6 +1841,16 @@ dependencies = [
|
|||
"zerocopy",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "prettyplease"
|
||||
version = "0.2.25"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "64d1ec885c64d0457d564db4ec299b2dae3f9c02808b8ad9c3a089c591b18033"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "proc-macro2"
|
||||
version = "1.0.92"
|
||||
|
@ -1580,6 +1894,22 @@ version = "2.28.0"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "106dd99e98437432fed6519dedecfade6a06a73bb7b2a1e019fdd2bee5778d94"
|
||||
|
||||
[[package]]
|
||||
name = "psl-types"
|
||||
version = "2.0.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "33cb294fe86a74cbcf50d4445b37da762029549ebeea341421c7c70370f86cac"
|
||||
|
||||
[[package]]
|
||||
name = "publicsuffix"
|
||||
version = "2.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6f42ea446cab60335f76979ec15e12619a2165b5ae2c12166bef27d283a9fadf"
|
||||
dependencies = [
|
||||
"idna",
|
||||
"psl-types",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quinn"
|
||||
version = "0.11.6"
|
||||
|
@ -1590,10 +1920,10 @@ dependencies = [
|
|||
"pin-project-lite",
|
||||
"quinn-proto",
|
||||
"quinn-udp",
|
||||
"rustc-hash",
|
||||
"rustc-hash 2.1.0",
|
||||
"rustls",
|
||||
"socket2",
|
||||
"thiserror 2.0.11",
|
||||
"thiserror 2.0.9",
|
||||
"tokio",
|
||||
"tracing",
|
||||
]
|
||||
|
@ -1608,11 +1938,11 @@ dependencies = [
|
|||
"getrandom",
|
||||
"rand",
|
||||
"ring",
|
||||
"rustc-hash",
|
||||
"rustc-hash 2.1.0",
|
||||
"rustls",
|
||||
"rustls-pki-types",
|
||||
"slab",
|
||||
"thiserror 2.0.11",
|
||||
"thiserror 2.0.9",
|
||||
"tinyvec",
|
||||
"tracing",
|
||||
"web-time",
|
||||
|
@ -1677,7 +2007,7 @@ version = "0.5.8"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "03a862b389f93e68874fbf580b9de08dd02facb9a788ebadaf4a3fd33cf58834"
|
||||
dependencies = [
|
||||
"bitflags 2.7.0",
|
||||
"bitflags 2.6.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
@ -1752,6 +2082,8 @@ checksum = "a77c62af46e79de0a562e1a9849205ffcb7fc1238876e9bd743357570e04046f"
|
|||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"bytes",
|
||||
"cookie",
|
||||
"cookie_store",
|
||||
"futures-core",
|
||||
"futures-util",
|
||||
"http 1.2.0",
|
||||
|
@ -1890,7 +2222,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||
checksum = "b91f7eff05f748767f183df4320a63d6936e9c6107d97c9e6bdd9784f4289c94"
|
||||
dependencies = [
|
||||
"base64 0.21.7",
|
||||
"bitflags 2.7.0",
|
||||
"bitflags 2.6.0",
|
||||
"serde",
|
||||
"serde_derive",
|
||||
]
|
||||
|
@ -1901,6 +2233,12 @@ version = "0.1.24"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "719b953e2095829ee67db738b3bfa9fa368c94900df327b3f07fe6e794d2fe1f"
|
||||
|
||||
[[package]]
|
||||
name = "rustc-hash"
|
||||
version = "1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2"
|
||||
|
||||
[[package]]
|
||||
name = "rustc-hash"
|
||||
version = "2.1.0"
|
||||
|
@ -1913,7 +2251,7 @@ version = "0.38.42"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f93dc38ecbab2eb790ff964bb77fa94faf256fd3e73285fd7ba0903b76bedb85"
|
||||
dependencies = [
|
||||
"bitflags 2.7.0",
|
||||
"bitflags 2.6.0",
|
||||
"errno",
|
||||
"libc",
|
||||
"linux-raw-sys",
|
||||
|
@ -1926,6 +2264,8 @@ version = "0.23.20"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5065c3f250cbd332cd894be57c40fa52387247659b14a2d6041d121547903b1b"
|
||||
dependencies = [
|
||||
"aws-lc-rs",
|
||||
"log",
|
||||
"once_cell",
|
||||
"ring",
|
||||
"rustls-pki-types",
|
||||
|
@ -1958,6 +2298,7 @@ version = "0.102.8"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "64ca1bc8749bd4cf37b5ce386cc146580777b4e8572c7b97baf22c83f444bee9"
|
||||
dependencies = [
|
||||
"aws-lc-rs",
|
||||
"ring",
|
||||
"rustls-pki-types",
|
||||
"untrusted",
|
||||
|
@ -2055,7 +2396,7 @@ version = "4.6.1"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "779e2977f0cc2ff39708fef48f96f3768ac8ddd8c6caaaab82e83bd240ef99b2"
|
||||
dependencies = [
|
||||
"bitflags 2.7.0",
|
||||
"bitflags 2.6.0",
|
||||
"cfg-if",
|
||||
"core-foundation",
|
||||
"core-foundation-sys",
|
||||
|
@ -2068,6 +2409,17 @@ dependencies = [
|
|||
"winapi",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sha2"
|
||||
version = "0.10.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "793db75ad2bcafc3ffa7c68b215fee268f537982cd901d132f89c6343f3a3dc8"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"cpufeatures",
|
||||
"digest",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sharded-slab"
|
||||
version = "0.1.7"
|
||||
|
@ -2153,12 +2505,55 @@ version = "0.11.1"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
|
||||
|
||||
[[package]]
|
||||
name = "strum"
|
||||
version = "0.26.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8fec0f0aef304996cf250b31b5a10dee7980c85da9d759361292b8bca5a18f06"
|
||||
dependencies = [
|
||||
"strum_macros",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "strum_macros"
|
||||
version = "0.26.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4c6bee85a5a24955dc440386795aa378cd9cf82acd5f764469152d2270e581be"
|
||||
dependencies = [
|
||||
"heck",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"rustversion",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "subtle"
|
||||
version = "2.6.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
|
||||
|
||||
[[package]]
|
||||
name = "supports-color"
|
||||
version = "3.0.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c64fc7232dd8d2e4ac5ce4ef302b1d81e0b80d055b9d77c7c4f51f6aa4c867d6"
|
||||
dependencies = [
|
||||
"is_ci",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "supports-hyperlinks"
|
||||
version = "3.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "804f44ed3c63152de6a9f90acbea1a110441de43006ea51bcce8f436196a288b"
|
||||
|
||||
[[package]]
|
||||
name = "supports-unicode"
|
||||
version = "3.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b7401a30af6cb5818bb64852270bb722533397edcfc7344954a38f420819ece2"
|
||||
|
||||
[[package]]
|
||||
name = "syn"
|
||||
version = "2.0.91"
|
||||
|
@ -2203,14 +2598,23 @@ dependencies = [
|
|||
"windows-sys 0.59.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "terminal_size"
|
||||
version = "0.4.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5352447f921fda68cf61b4101566c0bdb5104eff6804d0678e5227580ab6a4e9"
|
||||
dependencies = [
|
||||
"rustix",
|
||||
"windows-sys 0.59.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tesla-charge-controller"
|
||||
version = "1.9.9-pre-30"
|
||||
version = "1.5.0"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"clap",
|
||||
"env_logger",
|
||||
"eyre",
|
||||
"if_chain",
|
||||
"include_dir",
|
||||
"lazy_static",
|
||||
|
@ -2224,12 +2628,59 @@ dependencies = [
|
|||
"serde",
|
||||
"serde_json",
|
||||
"serialport",
|
||||
"thiserror 2.0.11",
|
||||
"tesla-common",
|
||||
"thiserror 2.0.9",
|
||||
"tokio",
|
||||
"tokio-modbus",
|
||||
"tokio-serial",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tesla-common"
|
||||
version = "0.3.1"
|
||||
source = "git+https://git.alexjanka.com/alex/tesla-common#74859ebe6f1c96ae6a62d9156beffd8df46dea37"
|
||||
dependencies = [
|
||||
"ron",
|
||||
"serde",
|
||||
"teslatte",
|
||||
"thiserror 1.0.69",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "teslatte"
|
||||
version = "0.1.16"
|
||||
source = "git+https://git.alexjanka.com/alex/teslatte#416f3a20ee3e8dcf540b8196a836acd671b2d0c3"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"clap",
|
||||
"colored_json",
|
||||
"derive_more",
|
||||
"miette",
|
||||
"pkce",
|
||||
"rand",
|
||||
"reqwest",
|
||||
"rustls",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"strum",
|
||||
"thiserror 2.0.9",
|
||||
"tokio",
|
||||
"tracing",
|
||||
"tracing-subscriber",
|
||||
"url",
|
||||
"urlencoding",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "textwrap"
|
||||
version = "0.16.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "23d434d3f8967a09480fb04132ebe0a3e088c173e6d0ee7897abbdf4eab0f8b9"
|
||||
dependencies = [
|
||||
"unicode-linebreak",
|
||||
"unicode-width",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "thiserror"
|
||||
version = "1.0.69"
|
||||
|
@ -2241,11 +2692,11 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "thiserror"
|
||||
version = "2.0.11"
|
||||
version = "2.0.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d452f284b73e6d76dd36758a0c8684b1d5be31f92b89d07fd5822175732206fc"
|
||||
checksum = "f072643fd0190df67a8bab670c20ef5d8737177d6ac6b2e9a236cb096206b2cc"
|
||||
dependencies = [
|
||||
"thiserror-impl 2.0.11",
|
||||
"thiserror-impl 2.0.9",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
@ -2261,9 +2712,9 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "thiserror-impl"
|
||||
version = "2.0.11"
|
||||
version = "2.0.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "26afc1baea8a989337eeb52b6e72a039780ce45c3edfcc9c5b9d112feeb173c2"
|
||||
checksum = "7b50fa271071aae2e6ee85f842e2e28ba8cd2c5fb67f11fcb1fd70b276f9e7d4"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
|
@ -2378,7 +2829,7 @@ dependencies = [
|
|||
"futures-util",
|
||||
"log",
|
||||
"smallvec",
|
||||
"thiserror 2.0.11",
|
||||
"thiserror 2.0.9",
|
||||
"tokio",
|
||||
"tokio-util",
|
||||
]
|
||||
|
@ -2537,6 +2988,12 @@ version = "0.2.5"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b"
|
||||
|
||||
[[package]]
|
||||
name = "typenum"
|
||||
version = "1.17.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "42ff0bf0c66b8238c6f3b578df37d0b7848e55df8577b3f74f92a69acceeb825"
|
||||
|
||||
[[package]]
|
||||
name = "ubyte"
|
||||
version = "0.10.4"
|
||||
|
@ -2571,6 +3028,24 @@ version = "1.0.14"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "adb9e6ca4f869e1180728b7950e35922a7fc6397f7b641499e8f3ef06e50dc83"
|
||||
|
||||
[[package]]
|
||||
name = "unicode-linebreak"
|
||||
version = "0.1.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3b09c83c3c29d37506a3e260c08c03743a6bb66a9cd432c6934ab501a190571f"
|
||||
|
||||
[[package]]
|
||||
name = "unicode-segmentation"
|
||||
version = "1.12.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493"
|
||||
|
||||
[[package]]
|
||||
name = "unicode-width"
|
||||
version = "0.1.14"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af"
|
||||
|
||||
[[package]]
|
||||
name = "unicode-xid"
|
||||
version = "0.2.6"
|
||||
|
@ -2594,6 +3069,12 @@ dependencies = [
|
|||
"percent-encoding",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "urlencoding"
|
||||
version = "2.1.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da"
|
||||
|
||||
[[package]]
|
||||
name = "utf16_iter"
|
||||
version = "1.0.5"
|
||||
|
@ -2745,6 +3226,18 @@ dependencies = [
|
|||
"rustls-pki-types",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "which"
|
||||
version = "4.4.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "87ba24419a2078cd2b0f2ede2691b6c66d8e47836da3b6db8265ebad47afbfc7"
|
||||
dependencies = [
|
||||
"either",
|
||||
"home",
|
||||
"once_cell",
|
||||
"rustix",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "winapi"
|
||||
version = "0.3.9"
|
||||
|
|
73
Cargo.toml
73
Cargo.toml
|
@ -1,35 +1,50 @@
|
|||
[workspace]
|
||||
members = ["charge-controller-supervisor", "tesla-charge-controller"]
|
||||
default-members = ["charge-controller-supervisor"]
|
||||
resolver = "2"
|
||||
[package]
|
||||
name = "tesla-charge-controller"
|
||||
version = "1.5.0"
|
||||
edition = "2021"
|
||||
license = "MITNFA"
|
||||
description = "Controls Tesla charge rate based on solar charge data"
|
||||
authors = ["Alex Janka"]
|
||||
|
||||
[workspace.package]
|
||||
version = "1.9.9-pre-30"
|
||||
[package.metadata.deb]
|
||||
maintainer-scripts = "debian/"
|
||||
systemd-units = { enable = false }
|
||||
depends = ""
|
||||
assets = [["target/release/tesla-charge-controller", "usr/bin/", "755"]]
|
||||
|
||||
[workspace.lints.clippy]
|
||||
[dependencies]
|
||||
tesla-common = { git = "https://git.alexjanka.com/alex/tesla-common" }
|
||||
clap = { version = "4.5.23", features = ["derive"] }
|
||||
ron = "0.8.1"
|
||||
serde = { version = "1.0.216", features = ["derive"] }
|
||||
serde_json = "1.0.134"
|
||||
tokio = { version = "1.42.0", features = ["full"] }
|
||||
thiserror = "2.0.9"
|
||||
rocket = { version = "0.5.1", features = ["json"] }
|
||||
include_dir = "0.7.4"
|
||||
chrono = "0.4.39"
|
||||
prometheus = "0.13.4"
|
||||
env_logger = "0.11.6"
|
||||
log = "0.4.22"
|
||||
serialport = "4.6.1"
|
||||
tokio-modbus = "0.16.1"
|
||||
tokio-serial = "5.4.4"
|
||||
if_chain = "1.0.2"
|
||||
notify-debouncer-mini = { version = "0.5.0", default-features = false }
|
||||
lazy_static = "1.5.0"
|
||||
rand = "0.8.5"
|
||||
reqwest = { version = "0.12.9", default-features = false, features = [
|
||||
"rustls-tls",
|
||||
] }
|
||||
|
||||
# [patch."https://git.alexjanka.com/alex/tesla-common"]
|
||||
# tesla-common = { path = "../tesla-common" }
|
||||
|
||||
[lints.clippy]
|
||||
pedantic = "warn"
|
||||
|
||||
branches_sharing_code = "warn"
|
||||
derive_partial_eq_without_eq = "warn"
|
||||
equatable_if_let = "warn"
|
||||
fallible_impl_from = "warn"
|
||||
large_stack_frames = "warn"
|
||||
missing_const_for_fn = "warn"
|
||||
needless_collect = "warn"
|
||||
needless_pass_by_ref_mut = "warn"
|
||||
or_fun_call = "warn"
|
||||
redundant_clone = "warn"
|
||||
significant_drop_in_scrutinee = "warn"
|
||||
significant_drop_tightening = "warn"
|
||||
too_long_first_doc_paragraph = "warn"
|
||||
trait_duplication_in_bounds = "warn"
|
||||
|
||||
cast-possible-truncation = { level = "allow", priority = 1 }
|
||||
cast-precision-loss = { level = "allow", priority = 1 }
|
||||
default-trait-access = { level = "allow", priority = 1 }
|
||||
missing-errors-doc = { level = "allow", priority = 1 }
|
||||
missing-panics-doc = { level = "allow", priority = 1 }
|
||||
module-name-repetitions = { level = "allow", priority = 1 }
|
||||
similar-names = { level = "allow", priority = 1 }
|
||||
struct-excessive-bools = { level = "allow", priority = 1 }
|
||||
too-many-lines = { level = "allow", priority = 1 }
|
||||
default-trait-access = { level = "allow", priority = 1 }
|
||||
cast-precision-loss = { level = "allow", priority = 1 }
|
||||
cast-possible-truncation = { level = "allow", priority = 1 }
|
||||
|
|
BIN
charge-controller-fields.xlsx
Normal file
BIN
charge-controller-fields.xlsx
Normal file
Binary file not shown.
|
@ -1,35 +0,0 @@
|
|||
[package]
|
||||
name = "charge-controller-supervisor"
|
||||
version.workspace = true
|
||||
edition = "2021"
|
||||
license = "MITNFA"
|
||||
description = "Monitors multiple charge controllers"
|
||||
authors = ["Alex Janka"]
|
||||
|
||||
[package.metadata.deb]
|
||||
maintainer-scripts = "debian/"
|
||||
systemd-units = { enable = false }
|
||||
depends = ""
|
||||
|
||||
[dependencies]
|
||||
bitflags = { version = "2.7.0", features = ["serde"] }
|
||||
chrono = "0.4.39"
|
||||
clap = { version = "4.5.23", features = ["derive"] }
|
||||
env_logger = "0.11.6"
|
||||
eyre = "0.6.12"
|
||||
futures = "0.3.31"
|
||||
include_dir = "0.7.4"
|
||||
lazy_static = "1.5.0"
|
||||
log = "0.4.22"
|
||||
notify-debouncer-mini = { version = "0.5.0", default-features = false }
|
||||
prometheus = "0.13.4"
|
||||
rocket = { version = "0.5.1", features = ["json"] }
|
||||
serde = { version = "1.0.216", features = ["derive"] }
|
||||
serde_json = "1.0.134"
|
||||
thiserror = "2.0.11"
|
||||
tokio = { version = "1.42.0", features = ["full"] }
|
||||
tokio-modbus = "0.16.1"
|
||||
tokio-serial = "5.4.4"
|
||||
|
||||
[lints]
|
||||
workspace = true
|
|
@ -1,15 +0,0 @@
|
|||
[Unit]
|
||||
Description=Charge Controller Supervisor
|
||||
After=network.target
|
||||
StartLimitIntervalSec=0
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
Restart=always
|
||||
RestartSec=10s
|
||||
Environment="RUST_LOG=error,warn"
|
||||
Environment="LOG_TIMESTAMP=false"
|
||||
ExecStart=/usr/bin/charge-controller-supervisor watch
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
|
@ -1,208 +0,0 @@
|
|||
use serde::{Deserialize, Serialize};
|
||||
|
||||
static CONFIG_PATH: std::sync::OnceLock<std::path::PathBuf> = std::sync::OnceLock::new();
|
||||
pub(super) static CONFIG: std::sync::OnceLock<tokio::sync::RwLock<Config>> =
|
||||
std::sync::OnceLock::new();
|
||||
|
||||
pub async fn access_config<'a>() -> tokio::sync::RwLockReadGuard<'a, Config> {
|
||||
CONFIG.get().unwrap().read().await
|
||||
}
|
||||
|
||||
pub(super) struct ConfigWatcher {
|
||||
_debouncer: notify_debouncer_mini::Debouncer<notify_debouncer_mini::notify::RecommendedWatcher>,
|
||||
_handle: tokio::task::JoinHandle<()>,
|
||||
}
|
||||
|
||||
pub fn init_config(path: impl AsRef<std::path::Path>) -> Option<ConfigWatcher> {
|
||||
let _ = CONFIG_PATH.get_or_init(|| path.as_ref().to_path_buf());
|
||||
log::trace!("loading config...");
|
||||
let config = Config::load(&path).unwrap();
|
||||
if let Err(e) = config.save() {
|
||||
log::warn!("couldn't save updated config: {e:?}");
|
||||
}
|
||||
log::trace!("watching config for changes...");
|
||||
let config_watcher = ConfigWatcher::new(&path);
|
||||
CONFIG.set(tokio::sync::RwLock::new(config)).unwrap();
|
||||
|
||||
config_watcher
|
||||
}
|
||||
|
||||
impl ConfigWatcher {
|
||||
pub fn new(path: impl AsRef<std::path::Path>) -> Option<Self> {
|
||||
let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
|
||||
|
||||
let mut debouncer =
|
||||
notify_debouncer_mini::new_debouncer(std::time::Duration::from_secs(1), move |v| {
|
||||
tx.send(v).unwrap();
|
||||
})
|
||||
.ok()?;
|
||||
|
||||
debouncer
|
||||
.watcher()
|
||||
.watch(
|
||||
path.as_ref(),
|
||||
notify_debouncer_mini::notify::RecursiveMode::NonRecursive,
|
||||
)
|
||||
.ok()?;
|
||||
|
||||
let config_path = std::path::PathBuf::from(path.as_ref());
|
||||
|
||||
let handle = tokio::task::spawn(async move {
|
||||
loop {
|
||||
match rx.recv().await {
|
||||
Some(Ok(_event)) => {
|
||||
let config = Config::load(&config_path).unwrap();
|
||||
|
||||
if let Err(e) = overwrite_config(config).await {
|
||||
log::error!("{e:?}");
|
||||
}
|
||||
}
|
||||
Some(Err(e)) => log::error!("Error {e:?} from watcher"),
|
||||
None => {}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
Some(Self {
|
||||
_debouncer: debouncer,
|
||||
_handle: handle,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
async fn overwrite_config(config: Config) -> eyre::Result<()> {
|
||||
let mut h = CONFIG
|
||||
.get()
|
||||
.ok_or_else(|| eyre::eyre!("could not get config"))?
|
||||
.write()
|
||||
.await;
|
||||
if h.charge_controllers != config.charge_controllers
|
||||
|| h.primary_charge_controller != config.primary_charge_controller
|
||||
{
|
||||
log::warn!("charge controller configuration changed on disk; please restart");
|
||||
}
|
||||
*h = config;
|
||||
drop(h);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub struct ConfigHandle<'a> {
|
||||
handle: tokio::sync::RwLockWriteGuard<'a, Config>,
|
||||
}
|
||||
|
||||
impl<'a> core::ops::Deref for ConfigHandle<'a> {
|
||||
type Target = tokio::sync::RwLockWriteGuard<'a, Config>;
|
||||
|
||||
fn deref(&self) -> &Self::Target {
|
||||
&self.handle
|
||||
}
|
||||
}
|
||||
|
||||
impl std::ops::DerefMut for ConfigHandle<'_> {
|
||||
fn deref_mut(&mut self) -> &mut Self::Target {
|
||||
&mut self.handle
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for ConfigHandle<'_> {
|
||||
fn drop(&mut self) {
|
||||
if let Err(e) = self.save() {
|
||||
log::error!("error saving config on drop of handle: {e:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn write_to_config<'a>() -> ConfigHandle<'a> {
|
||||
ConfigHandle {
|
||||
handle: CONFIG.get().unwrap().write().await,
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)]
|
||||
#[serde(tag = "version")]
|
||||
pub enum ConfigStorage {
|
||||
#[serde(rename = "1")]
|
||||
V1(outdated::ConfigV1),
|
||||
#[serde(rename = "2")]
|
||||
V2(Config),
|
||||
}
|
||||
|
||||
mod outdated;
|
||||
|
||||
impl Default for ConfigStorage {
|
||||
fn default() -> Self {
|
||||
Self::from_latest(Default::default())
|
||||
}
|
||||
}
|
||||
|
||||
impl ConfigStorage {
|
||||
pub const fn from_latest(config: Config) -> Self {
|
||||
Self::V2(config)
|
||||
}
|
||||
|
||||
pub fn into_latest(self) -> Config {
|
||||
match self {
|
||||
ConfigStorage::V1(v1) => v1.into(),
|
||||
ConfigStorage::V2(config) => config,
|
||||
}
|
||||
}
|
||||
|
||||
fn load(path: impl AsRef<std::path::Path>) -> eyre::Result<Self> {
|
||||
Ok(serde_json::from_str(&std::fs::read_to_string(path)?)?)
|
||||
}
|
||||
|
||||
fn save_to(&self, path: impl AsRef<std::path::Path>) -> eyre::Result<()> {
|
||||
Ok(serde_json::ser::to_writer_pretty(
|
||||
std::io::BufWriter::new(std::fs::File::create(path)?),
|
||||
self,
|
||||
)?)
|
||||
}
|
||||
|
||||
fn save(&self) -> eyre::Result<()> {
|
||||
self.save_to(CONFIG_PATH.get().unwrap())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, Default)]
|
||||
#[serde(default)]
|
||||
pub struct Config {
|
||||
pub primary_charge_controller: String,
|
||||
pub enable_secondary_control: bool,
|
||||
pub charge_controllers: Vec<ChargeControllerConfig>,
|
||||
}
|
||||
|
||||
impl Config {
|
||||
fn load(path: impl AsRef<std::path::Path>) -> eyre::Result<Self> {
|
||||
let storage = ConfigStorage::load(path)?;
|
||||
Ok(storage.into_latest())
|
||||
}
|
||||
|
||||
fn save(&self) -> eyre::Result<()> {
|
||||
let as_storage = ConfigStorage::from_latest(self.clone());
|
||||
as_storage.save()?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)]
|
||||
pub struct ChargeControllerConfig {
|
||||
pub name: String,
|
||||
pub watch_interval_seconds: u64,
|
||||
pub variant: ChargeControllerVariant,
|
||||
#[serde(default)]
|
||||
pub follow_primary: bool,
|
||||
pub transport: Transport,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)]
|
||||
pub enum ChargeControllerVariant {
|
||||
Tristar,
|
||||
Pl { timeout_milliseconds: u64 },
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum Transport {
|
||||
Serial { port: String, baud_rate: u32 },
|
||||
Tcp { ip: std::net::IpAddr, port: u16 },
|
||||
}
|
|
@ -1,72 +0,0 @@
|
|||
pub use v1::ConfigV1;
|
||||
|
||||
mod v1 {
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Default)]
|
||||
#[serde(default)]
|
||||
pub struct ConfigV1 {
|
||||
primary_charge_controller: String,
|
||||
enable_secondary_control: bool,
|
||||
charge_controllers: Vec<ChargeControllerConfigV1>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)]
|
||||
struct ChargeControllerConfigV1 {
|
||||
name: String,
|
||||
serial_port: String,
|
||||
baud_rate: u32,
|
||||
watch_interval_seconds: u64,
|
||||
variant: ChargeControllerVariantV1,
|
||||
#[serde(default)]
|
||||
follow_primary: bool,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)]
|
||||
enum ChargeControllerVariantV1 {
|
||||
Tristar,
|
||||
Pl { timeout_milliseconds: u64 },
|
||||
}
|
||||
|
||||
impl From<ChargeControllerConfigV1> for crate::config::ChargeControllerConfig {
|
||||
fn from(value: ChargeControllerConfigV1) -> Self {
|
||||
Self {
|
||||
name: value.name,
|
||||
transport: crate::config::Transport::Serial {
|
||||
port: value.serial_port,
|
||||
baud_rate: value.baud_rate,
|
||||
},
|
||||
watch_interval_seconds: value.watch_interval_seconds,
|
||||
variant: value.variant.into(),
|
||||
follow_primary: value.follow_primary,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<ChargeControllerVariantV1> for crate::config::ChargeControllerVariant {
|
||||
fn from(value: ChargeControllerVariantV1) -> Self {
|
||||
match value {
|
||||
ChargeControllerVariantV1::Tristar => Self::Tristar,
|
||||
ChargeControllerVariantV1::Pl {
|
||||
timeout_milliseconds,
|
||||
} => Self::Pl {
|
||||
timeout_milliseconds,
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<ConfigV1> for crate::config::Config {
|
||||
fn from(value: ConfigV1) -> Self {
|
||||
Self {
|
||||
primary_charge_controller: value.primary_charge_controller,
|
||||
enable_secondary_control: value.enable_secondary_control,
|
||||
charge_controllers: value
|
||||
.charge_controllers
|
||||
.into_iter()
|
||||
.map(Into::into)
|
||||
.collect(),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,221 +0,0 @@
|
|||
use crate::storage::ControllerData;
|
||||
|
||||
mod pl;
|
||||
mod tristar;
|
||||
|
||||
pub struct Controller {
|
||||
name: String,
|
||||
interval: std::time::Duration,
|
||||
inner: ControllerInner,
|
||||
data: std::sync::Arc<ControllerData>,
|
||||
follow_voltage: bool,
|
||||
voltage_rx: tokio::sync::watch::Receiver<VoltageCommand>,
|
||||
voltage_tx: Option<tokio::sync::watch::Sender<VoltageCommand>>,
|
||||
settings_last_read: Option<std::time::Instant>,
|
||||
}
|
||||
|
||||
#[derive(Default, serde::Serialize, Clone)]
|
||||
pub struct CommonData {
|
||||
pub battery_voltage: f64,
|
||||
pub target_voltage: f64,
|
||||
pub battery_temp: f64,
|
||||
}
|
||||
|
||||
#[derive(serde::Serialize, Clone)]
|
||||
#[serde(tag = "model")]
|
||||
pub enum ControllerState {
|
||||
Pl(pl::PlState),
|
||||
Tristar(tristar::TristarState),
|
||||
}
|
||||
|
||||
impl ControllerState {
|
||||
pub fn common(&self) -> CommonData {
|
||||
match self {
|
||||
Self::Pl(pl_state) => crate::controller::CommonData {
|
||||
battery_voltage: pl_state.battery_voltage,
|
||||
target_voltage: pl_state.target_voltage,
|
||||
battery_temp: pl_state.battery_temp,
|
||||
},
|
||||
Self::Tristar(tristar_state) => crate::controller::CommonData {
|
||||
battery_voltage: tristar_state.battery_voltage,
|
||||
target_voltage: tristar_state.target_voltage,
|
||||
battery_temp: f64::from(tristar_state.battery_temp),
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(serde::Serialize, Clone)]
|
||||
#[serde(tag = "model")]
|
||||
pub enum ControllerSettings {
|
||||
Pl(pl::PlSettings),
|
||||
Tristar(tristar::TristarSettings),
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub enum VoltageCommand {
|
||||
None,
|
||||
Set(f64),
|
||||
}
|
||||
|
||||
impl Controller {
|
||||
pub async fn new(
|
||||
config: crate::config::ChargeControllerConfig,
|
||||
voltage_rx: tokio::sync::watch::Receiver<VoltageCommand>,
|
||||
) -> eyre::Result<Self> {
|
||||
let inner = match config.variant {
|
||||
crate::config::ChargeControllerVariant::Tristar => ControllerInner::Tristar(
|
||||
tristar::Tristar::new(&config.name, &config.transport).await?,
|
||||
),
|
||||
crate::config::ChargeControllerVariant::Pl {
|
||||
timeout_milliseconds,
|
||||
} => match &config.transport {
|
||||
crate::config::Transport::Serial { port, baud_rate } => ControllerInner::Pl(
|
||||
pl::Pli::new(port, &config.name, *baud_rate, timeout_milliseconds)?,
|
||||
),
|
||||
crate::config::Transport::Tcp { ip: _, port: _ } => {
|
||||
return Err(eyre::eyre!("pl doesn't support tcp"))
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
let data = std::sync::Arc::new(ControllerData::new());
|
||||
|
||||
Ok(Self {
|
||||
name: config.name,
|
||||
interval: std::time::Duration::from_secs(config.watch_interval_seconds),
|
||||
inner,
|
||||
data,
|
||||
voltage_rx,
|
||||
voltage_tx: None,
|
||||
settings_last_read: None,
|
||||
follow_voltage: config.follow_primary,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn get_data_ptr(&self) -> std::sync::Arc<ControllerData> {
|
||||
self.data.clone()
|
||||
}
|
||||
|
||||
pub async fn refresh(&mut self) -> eyre::Result<()> {
|
||||
let data = self.inner.refresh().await?;
|
||||
|
||||
if let Some(tx) = self.voltage_tx.as_mut() {
|
||||
if crate::config::access_config()
|
||||
.await
|
||||
.enable_secondary_control
|
||||
{
|
||||
let target = data.common().target_voltage;
|
||||
log::debug!(
|
||||
"tristar {}: primary: sending target voltage {}",
|
||||
self.name,
|
||||
target
|
||||
);
|
||||
|
||||
tx.send(VoltageCommand::Set(target))?;
|
||||
}
|
||||
}
|
||||
|
||||
*self.data.write_state().await = Some(data);
|
||||
if self.needs_new_settings() {
|
||||
match self.inner.get_settings().await {
|
||||
Ok(s) => {
|
||||
*self.data.write_settings().await = Some(s);
|
||||
self.settings_last_read = Some(std::time::Instant::now());
|
||||
}
|
||||
Err(e) => {
|
||||
log::error!("couldn't read config from {}: {e:?}", self.name);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub const fn timeout_interval(&self) -> std::time::Duration {
|
||||
self.interval
|
||||
}
|
||||
|
||||
pub fn name(&self) -> &str {
|
||||
self.name.as_str()
|
||||
}
|
||||
|
||||
pub fn set_tx_to_secondary(&mut self, tx: tokio::sync::watch::Sender<VoltageCommand>) {
|
||||
assert!(
|
||||
!self.follow_voltage,
|
||||
"trying to set {} as primary when it is also a secondary!",
|
||||
self.name
|
||||
);
|
||||
|
||||
self.voltage_tx = Some(tx);
|
||||
}
|
||||
|
||||
pub fn get_rx(&mut self) -> &mut tokio::sync::watch::Receiver<VoltageCommand> {
|
||||
&mut self.voltage_rx
|
||||
}
|
||||
|
||||
pub async fn process_command(&mut self, command: VoltageCommand) -> eyre::Result<()> {
|
||||
match command {
|
||||
VoltageCommand::Set(target_voltage) => {
|
||||
if self.follow_voltage {
|
||||
self.inner.set_target_voltage(target_voltage).await
|
||||
} else {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
VoltageCommand::None => {
|
||||
// todo: disable voltage control
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn needs_new_settings(&self) -> bool {
|
||||
self.settings_last_read.is_none_or(|t| {
|
||||
std::time::Instant::now().duration_since(t) >= std::time::Duration::from_secs(60 * 60)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[expect(clippy::large_enum_variant)]
|
||||
pub enum ControllerInner {
|
||||
Pl(pl::Pli),
|
||||
Tristar(tristar::Tristar),
|
||||
}
|
||||
|
||||
impl ControllerInner {
|
||||
pub async fn refresh(&mut self) -> eyre::Result<ControllerState> {
|
||||
match self {
|
||||
ControllerInner::Pl(pli) => {
|
||||
let pl_data = pli.refresh().await?;
|
||||
|
||||
Ok(ControllerState::Pl(pl_data))
|
||||
}
|
||||
ControllerInner::Tristar(tristar) => {
|
||||
let tristar_data = tristar.refresh().await?;
|
||||
|
||||
Ok(ControllerState::Tristar(tristar_data))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn get_settings(&mut self) -> eyre::Result<ControllerSettings> {
|
||||
match self {
|
||||
ControllerInner::Pl(pl) => {
|
||||
let settings = pl.read_settings().await?;
|
||||
Ok(ControllerSettings::Pl(settings))
|
||||
}
|
||||
ControllerInner::Tristar(tristar) => {
|
||||
let settings = tristar.read_settings().await?;
|
||||
Ok(ControllerSettings::Tristar(settings))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn set_target_voltage(&mut self, target_voltage: f64) -> eyre::Result<()> {
|
||||
match self {
|
||||
ControllerInner::Pl(_) => todo!(),
|
||||
ControllerInner::Tristar(tristar) => tristar.set_target_voltage(target_voltage).await,
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,671 +0,0 @@
|
|||
use chrono::Timelike;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
use tokio_serial::{SerialPort, SerialPortBuilderExt};
|
||||
|
||||
use crate::gauges::{
|
||||
BATTERY_TEMP, BATTERY_VOLTAGE, CHARGE_STATE, INPUT_CURRENT, PL_DUTY_CYCLE, PL_LOAD_CURRENT,
|
||||
TARGET_VOLTAGE,
|
||||
};
|
||||
|
||||
pub struct Pli {
|
||||
friendly_name: String,
|
||||
port: tokio_serial::SerialStream,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, Default)]
|
||||
pub struct PlState {
|
||||
pub battery_voltage: f64,
|
||||
pub target_voltage: f64,
|
||||
pub duty_cycle: f64,
|
||||
pub internal_charge_current: f64,
|
||||
pub internal_load_current: f64,
|
||||
pub battery_temp: f64,
|
||||
pub regulator_state: RegulatorState,
|
||||
// pub internal_charge_ah_accumulator: u16,
|
||||
// pub external_charge_ah_accumulator: u16,
|
||||
// pub internal_load_ah_accumulator: u16,
|
||||
// pub external_load_ah_accumulator: u16,
|
||||
// pub internal_charge_ah: u16,
|
||||
// pub external_charge_ah: u16,
|
||||
// pub internal_load_ah: u16,
|
||||
// pub external_load_ah: u16,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
|
||||
pub enum RegulatorState {
|
||||
Boost,
|
||||
Equalise,
|
||||
Absorption,
|
||||
Float,
|
||||
}
|
||||
|
||||
impl From<u8> for RegulatorState {
|
||||
fn from(value: u8) -> Self {
|
||||
match value & 0b11 {
|
||||
0b00 => Self::Boost,
|
||||
0b01 => Self::Equalise,
|
||||
0b10 => Self::Absorption,
|
||||
0b11 => Self::Float,
|
||||
_ => unreachable!(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<RegulatorState> for u8 {
|
||||
fn from(value: RegulatorState) -> Self {
|
||||
match value {
|
||||
RegulatorState::Boost => 0b00,
|
||||
RegulatorState::Equalise => 0b01,
|
||||
RegulatorState::Absorption => 0b10,
|
||||
RegulatorState::Float => 0b11,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for RegulatorState {
|
||||
fn default() -> Self {
|
||||
Self::Absorption
|
||||
}
|
||||
}
|
||||
|
||||
fn set_regulator_gauges(state: RegulatorState, label: &str) {
|
||||
let boost = CHARGE_STATE.with_label_values(&[label, "boost"]);
|
||||
let equalise = CHARGE_STATE.with_label_values(&[label, "equalise"]);
|
||||
let absorption = CHARGE_STATE.with_label_values(&[label, "absorption"]);
|
||||
let float = CHARGE_STATE.with_label_values(&[label, "float"]);
|
||||
|
||||
match state {
|
||||
RegulatorState::Boost => {
|
||||
boost.set(1);
|
||||
equalise.set(0);
|
||||
absorption.set(0);
|
||||
float.set(0);
|
||||
}
|
||||
RegulatorState::Equalise => {
|
||||
boost.set(0);
|
||||
equalise.set(1);
|
||||
absorption.set(0);
|
||||
float.set(0);
|
||||
}
|
||||
RegulatorState::Absorption => {
|
||||
boost.set(0);
|
||||
equalise.set(0);
|
||||
absorption.set(1);
|
||||
float.set(0);
|
||||
}
|
||||
RegulatorState::Float => {
|
||||
boost.set(0);
|
||||
equalise.set(0);
|
||||
absorption.set(0);
|
||||
float.set(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct PlSettings {
|
||||
load_disconnect_voltage: f64,
|
||||
load_reconnect_voltage: f64,
|
||||
delay_before_disconnect_minutes: u8,
|
||||
days_between_boost: u8,
|
||||
absorption_time: u8,
|
||||
hysteresis: u8,
|
||||
boost_return_voltage: f64,
|
||||
charge_current_limit: u8,
|
||||
battery_2_regulation_voltage: f64,
|
||||
days_between_equalization: u8,
|
||||
equalization_length: u8,
|
||||
absorption_voltage: f64,
|
||||
equalization_voltage: f64,
|
||||
float_voltage: f64,
|
||||
boost_voltage: f64,
|
||||
program: Program,
|
||||
system_voltage: SystemVoltage,
|
||||
battery_capacity_ah: usize,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub enum Program {
|
||||
Prog0,
|
||||
Prog1,
|
||||
Prog2,
|
||||
Prog3,
|
||||
Prog4,
|
||||
Unknown,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub enum SystemVoltage {
|
||||
V12,
|
||||
V24,
|
||||
V32,
|
||||
V36,
|
||||
V48,
|
||||
Unknown,
|
||||
}
|
||||
|
||||
#[expect(dead_code, reason = "writing settings is not yet implemented")]
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub enum PliRequest {
|
||||
ReadRam(u8),
|
||||
ReadEeprom(u8),
|
||||
SyncTime,
|
||||
SetRegulatorState(RegulatorState),
|
||||
}
|
||||
|
||||
impl Pli {
|
||||
pub fn new(
|
||||
serial_port: &str,
|
||||
friendly_name: &str,
|
||||
baud_rate: u32,
|
||||
timeout: u64,
|
||||
) -> Result<Self, tokio_serial::Error> {
|
||||
let port = tokio_serial::new(serial_port, baud_rate)
|
||||
.timeout(std::time::Duration::from_millis(timeout))
|
||||
.open_native_async()?;
|
||||
|
||||
Ok(Self {
|
||||
friendly_name: friendly_name.to_owned(),
|
||||
port,
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn refresh(&mut self) -> eyre::Result<PlState> {
|
||||
let new_state = self.read_state().await?;
|
||||
BATTERY_VOLTAGE
|
||||
.with_label_values(&[&self.friendly_name])
|
||||
.set(new_state.battery_voltage);
|
||||
TARGET_VOLTAGE
|
||||
.with_label_values(&[&self.friendly_name])
|
||||
.set(new_state.target_voltage);
|
||||
PL_DUTY_CYCLE
|
||||
.with_label_values(&[&self.friendly_name])
|
||||
.set(new_state.duty_cycle);
|
||||
INPUT_CURRENT
|
||||
.with_label_values(&[&self.friendly_name])
|
||||
.set(new_state.internal_charge_current);
|
||||
PL_LOAD_CURRENT
|
||||
.with_label_values(&[&self.friendly_name])
|
||||
.set(new_state.internal_load_current);
|
||||
BATTERY_TEMP
|
||||
.with_label_values(&[&self.friendly_name])
|
||||
.set(new_state.battery_temp);
|
||||
|
||||
set_regulator_gauges(new_state.regulator_state, &self.friendly_name);
|
||||
|
||||
Ok(new_state)
|
||||
}
|
||||
|
||||
#[expect(dead_code, reason = "writing settings is not yet implemented")]
|
||||
pub async fn process_request(&mut self, message: PliRequest) -> eyre::Result<()> {
|
||||
match message {
|
||||
PliRequest::ReadRam(address) => {
|
||||
let data = self.read_ram_with_retires(address).await?;
|
||||
log::warn!("Read RAM at {address}: data {data}");
|
||||
}
|
||||
PliRequest::ReadEeprom(address) => {
|
||||
let data = self.read_eeprom(address).await?;
|
||||
log::warn!("Read EEPROM at {address}: data {data}");
|
||||
}
|
||||
PliRequest::SyncTime => {
|
||||
let now = chrono::Local::now();
|
||||
let timestamp = (((now.hour() * 10) + (now.minute() / 6)) & 0xFF) as u8;
|
||||
let min = (now.minute() % 6) as u8;
|
||||
let sec = (now.second() / 2).min(29) as u8;
|
||||
self.write_ram(PlRamAddress::Hour, timestamp).await?;
|
||||
self.write_ram(PlRamAddress::Min, min).await?;
|
||||
self.write_ram(PlRamAddress::Sec, sec).await?;
|
||||
log::warn!(
|
||||
"Set time: {now} corresponds to {timestamp} + minutes {min} + seconds {sec}"
|
||||
);
|
||||
}
|
||||
PliRequest::SetRegulatorState(state) => {
|
||||
log::warn!("Setting regulator state to {state:?}");
|
||||
self.write_ram(PlRamAddress::Rstate, state.into()).await?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn read_settings(&mut self) -> eyre::Result<PlSettings> {
|
||||
let load_disconnect_voltage =
|
||||
f64::from(self.read_eeprom(PlEepromAddress::LOff).await?) * (4. / 10.);
|
||||
let load_reconnect_voltage =
|
||||
f64::from(self.read_eeprom(PlEepromAddress::LOn).await?) * (4. / 10.);
|
||||
let delay_before_disconnect_minutes = self.read_eeprom(PlEepromAddress::LDel).await?;
|
||||
let days_between_boost = self.read_eeprom(PlEepromAddress::BstFreq).await?;
|
||||
let absorption_time = self.read_eeprom(PlEepromAddress::ATim).await?;
|
||||
let hysteresis = self.read_eeprom(PlEepromAddress::Hyst).await?;
|
||||
let boost_return_voltage =
|
||||
f64::from(self.read_eeprom(PlEepromAddress::BRet).await?) * (4. / 10.);
|
||||
let charge_current_limit = self.read_eeprom(PlEepromAddress::CurLim).await?;
|
||||
let battery_2_regulation_voltage =
|
||||
f64::from(self.read_eeprom(PlEepromAddress::Bat2).await?) * (4. / 10.);
|
||||
let days_between_equalization = self.read_eeprom(PlEepromAddress::EqFreq).await?;
|
||||
let equalization_length = self.read_eeprom(PlEepromAddress::ETim).await?;
|
||||
let absorption_voltage =
|
||||
f64::from(self.read_eeprom(PlEepromAddress::AbsV).await?) * (4. / 10.);
|
||||
let equalization_voltage =
|
||||
f64::from(self.read_eeprom(PlEepromAddress::EMax).await?) * (4. / 10.);
|
||||
let float_voltage = f64::from(self.read_eeprom(PlEepromAddress::FltV).await?) * (4. / 10.);
|
||||
let boost_voltage = f64::from(self.read_eeprom(PlEepromAddress::BMax).await?) * (4. / 10.);
|
||||
let volt = self.read_eeprom(PlEepromAddress::Volt).await?;
|
||||
let program = match volt >> 4 {
|
||||
0 => Program::Prog0,
|
||||
1 => Program::Prog1,
|
||||
2 => Program::Prog2,
|
||||
3 => Program::Prog3,
|
||||
4 => Program::Prog4,
|
||||
_ => Program::Unknown,
|
||||
};
|
||||
let system_voltage = match volt & 0xF {
|
||||
0 => SystemVoltage::V12,
|
||||
1 => SystemVoltage::V24,
|
||||
2 => SystemVoltage::V32,
|
||||
3 => SystemVoltage::V36,
|
||||
4 => SystemVoltage::V48,
|
||||
_ => SystemVoltage::Unknown,
|
||||
};
|
||||
let battery_cap = usize::from(self.read_eeprom(PlEepromAddress::BCap).await?);
|
||||
|
||||
let battery_capacity_ah = if battery_cap > 50 {
|
||||
(50 * 20) + ((battery_cap - 50) * 100)
|
||||
} else {
|
||||
battery_cap * 20
|
||||
};
|
||||
|
||||
Ok(PlSettings {
|
||||
load_disconnect_voltage,
|
||||
load_reconnect_voltage,
|
||||
delay_before_disconnect_minutes,
|
||||
days_between_boost,
|
||||
absorption_time,
|
||||
hysteresis,
|
||||
boost_return_voltage,
|
||||
charge_current_limit,
|
||||
battery_2_regulation_voltage,
|
||||
days_between_equalization,
|
||||
equalization_length,
|
||||
absorption_voltage,
|
||||
equalization_voltage,
|
||||
float_voltage,
|
||||
boost_voltage,
|
||||
program,
|
||||
system_voltage,
|
||||
battery_capacity_ah,
|
||||
})
|
||||
}
|
||||
|
||||
async fn read_state(&mut self) -> eyre::Result<PlState> {
|
||||
// let int_charge_acc_low = self.read_ram(PlRamAddress::Ciacc1).await?;
|
||||
// let int_charge_acc = self.read_ram(PlRamAddress::Ciacc2).await?;
|
||||
// let int_charge_acc_high = self.read_ram(PlRamAddress::Ciacc3).await?;
|
||||
// let mut internal_charge_ah_accumulator = u16::from(int_charge_acc_high) << 9;
|
||||
// internal_charge_ah_accumulator |= u16::from(int_charge_acc) << 1;
|
||||
// internal_charge_ah_accumulator |= u16::from(int_charge_acc_low & 0b1);
|
||||
// let int_charge_low = self.read_ram(PlRamAddress::Ciahl).await?;
|
||||
// let int_charge_high = self.read_ram(PlRamAddress::Ciahh).await?;
|
||||
// let internal_charge_ah = u16::from_le_bytes([int_charge_low, int_charge_high]);
|
||||
|
||||
// let int_load_acc_low = self.read_ram(PlRamAddress::Liacc1).await?;
|
||||
// let int_load_acc = self.read_ram(PlRamAddress::Liacc2).await?;
|
||||
// let int_load_acc_high = self.read_ram(PlRamAddress::Liacc3).await?;
|
||||
// let mut internal_load_ah_accumulator = u16::from(int_load_acc_high) << 9;
|
||||
// internal_load_ah_accumulator |= u16::from(int_load_acc) << 1;
|
||||
// internal_load_ah_accumulator |= u16::from(int_load_acc_low & 0b1);
|
||||
// let int_load_low = self.read_ram(PlRamAddress::Liahl).await?;
|
||||
// let int_load_high = self.read_ram(PlRamAddress::Liahh).await?;
|
||||
// let internal_load_ah = u16::from_le_bytes([int_load_low, int_load_high]);
|
||||
|
||||
// let ext_charge_acc_low = self.read_ram(PlRamAddress::Ceacc1).await?;
|
||||
// let ext_charge_acc = self.read_ram(PlRamAddress::Ceacc2).await?;
|
||||
// let ext_charge_acc_high = self.read_ram(PlRamAddress::Ceacc3).await?;
|
||||
// let mut external_charge_ah_accumulator = u16::from(ext_charge_acc_high) << 9;
|
||||
// external_charge_ah_accumulator |= u16::from(ext_charge_acc) << 1;
|
||||
// external_charge_ah_accumulator |= u16::from(ext_charge_acc_low & 0b1);
|
||||
// let ext_charge_low = self.read_ram(PlRamAddress::Ceahl).await?;
|
||||
// let ext_charge_high = self.read_ram(PlRamAddress::Ceahh).await?;
|
||||
// let external_charge_ah = u16::from_le_bytes([ext_charge_low, ext_charge_high]);
|
||||
|
||||
// let ext_load_acc_low = self.read_ram(PlRamAddress::Leacc1).await?;
|
||||
// let ext_load_acc = self.read_ram(PlRamAddress::Leacc2).await?;
|
||||
// let ext_load_acc_high = self.read_ram(PlRamAddress::Leacc3).await?;
|
||||
// let mut external_load_ah_accumulator = u16::from(ext_load_acc_high) << 9;
|
||||
// external_load_ah_accumulator |= u16::from(ext_load_acc) << 1;
|
||||
// external_load_ah_accumulator |= u16::from(ext_load_acc_low & 0b1);
|
||||
// let ext_load_low = self.read_ram(PlRamAddress::Leahl).await?;
|
||||
// let ext_load_high = self.read_ram(PlRamAddress::Leahh).await?;
|
||||
// let external_load_ah = u16::from_le_bytes([ext_load_low, ext_load_high]);
|
||||
|
||||
Ok(PlState {
|
||||
battery_voltage: f64::from(self.read_ram_with_retires(PlRamAddress::Batv).await?)
|
||||
* (4. / 10.),
|
||||
target_voltage: f64::from(self.read_ram_with_retires(PlRamAddress::Vreg).await?)
|
||||
* (4. / 10.),
|
||||
duty_cycle: f64::from(self.read_ram_with_retires(PlRamAddress::Dutycyc).await?) / 255.,
|
||||
internal_charge_current: f64::from(
|
||||
self.read_ram_with_retires(PlRamAddress::Cint).await?,
|
||||
) * (4. / 10.),
|
||||
internal_load_current: f64::from(self.read_ram_with_retires(PlRamAddress::Lint).await?)
|
||||
* (2. / 10.),
|
||||
battery_temp: f64::from(self.read_ram_with_retires(PlRamAddress::Battemp).await?),
|
||||
regulator_state: self
|
||||
.read_ram_with_retires(PlRamAddress::Rstate)
|
||||
.await?
|
||||
.into(),
|
||||
// internal_charge_ah_accumulator,
|
||||
// external_charge_ah_accumulator,
|
||||
// internal_load_ah_accumulator,
|
||||
// external_load_ah_accumulator,
|
||||
// internal_charge_ah,
|
||||
// external_charge_ah,
|
||||
// internal_load_ah,
|
||||
// external_load_ah,
|
||||
})
|
||||
}
|
||||
|
||||
async fn send_command(&mut self, req: [u8; 4]) -> eyre::Result<()> {
|
||||
self.flush()?;
|
||||
self.port.write_all(&req).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn flush(&self) -> eyre::Result<()> {
|
||||
self.port.clear(tokio_serial::ClearBuffer::All)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn receive<const LENGTH: usize>(&mut self) -> eyre::Result<[u8; LENGTH]> {
|
||||
let mut buf = [0; LENGTH];
|
||||
self.port.read_exact(&mut buf).await?;
|
||||
Ok(buf)
|
||||
}
|
||||
|
||||
async fn read_ram_with_retires<T>(&mut self, address: T) -> eyre::Result<u8>
|
||||
where
|
||||
T: Into<u8>,
|
||||
{
|
||||
const READ_TRIES: usize = 3;
|
||||
let address: u8 = address.into();
|
||||
|
||||
let mut last_err = None;
|
||||
for _ in 0..READ_TRIES {
|
||||
match self.read_ram_single(address).await {
|
||||
Ok(v) => return Ok(v),
|
||||
Err(e) => last_err = Some(e),
|
||||
}
|
||||
}
|
||||
|
||||
Err(last_err.unwrap_or_else(|| {
|
||||
eyre::eyre!(
|
||||
"no error was stored in read_ram_with_retries: this should be unreachable??"
|
||||
)
|
||||
}))
|
||||
}
|
||||
|
||||
async fn read_ram_single<T>(&mut self, address: T) -> eyre::Result<u8>
|
||||
where
|
||||
T: Into<u8>,
|
||||
{
|
||||
self.send_command(command(20, address.into(), 0)).await?;
|
||||
let response = self.get_response().await?;
|
||||
match response {
|
||||
Ok(()) => Ok(self.receive::<1>().await?[0]),
|
||||
Err(e) => Err(e.into()),
|
||||
}
|
||||
}
|
||||
|
||||
async fn write_ram<T>(&mut self, address: T, data: u8) -> eyre::Result<()>
|
||||
where
|
||||
T: Into<u8>,
|
||||
{
|
||||
self.send_command(command(152, address.into(), data))
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn read_eeprom<T>(&mut self, address: T) -> eyre::Result<u8>
|
||||
where
|
||||
T: Into<u8>,
|
||||
{
|
||||
self.send_command(command(72, address.into(), 0)).await?;
|
||||
|
||||
let response = self.get_response().await?;
|
||||
match response {
|
||||
Ok(()) => Ok(self.receive::<1>().await?[0]),
|
||||
Err(e) => Err(e.into()),
|
||||
}
|
||||
}
|
||||
|
||||
async fn get_response(&mut self) -> eyre::Result<Result<(), PlError>> {
|
||||
let res = self.receive::<1>().await?[0];
|
||||
Ok(PlError::parse(res))
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(thiserror::Error, Debug)]
|
||||
enum PlError {
|
||||
#[error("No comms or corrupt comms")]
|
||||
NoComms,
|
||||
#[error("Loopback response code")]
|
||||
Loopback,
|
||||
#[error("Timeout error")]
|
||||
Timeout,
|
||||
#[error("Checksum error in PLI receive data.")]
|
||||
Checksum,
|
||||
#[error("Command received by PLI is not recognised.")]
|
||||
CommandNotRecognised,
|
||||
#[error("Unused - or could be returning PL40 version!")]
|
||||
Unused1,
|
||||
#[error("Processor did not receive a reply to request.")]
|
||||
NoReply,
|
||||
#[error("Error in reply from PL.")]
|
||||
ErrorFromPl,
|
||||
#[error("<not used> #2")]
|
||||
Unused2,
|
||||
#[error("<not used> #3")]
|
||||
Unused3,
|
||||
#[error("Unknown: {0:#X?}")]
|
||||
Unknown(u8),
|
||||
}
|
||||
|
||||
impl PlError {
|
||||
const fn parse(value: u8) -> Result<(), Self> {
|
||||
match value {
|
||||
200 => Ok(()),
|
||||
5 => Err(Self::NoComms),
|
||||
128 => Err(Self::Loopback),
|
||||
129 => Err(Self::Timeout),
|
||||
130 => Err(Self::Checksum),
|
||||
131 => Err(Self::CommandNotRecognised),
|
||||
132 => Err(Self::Unused1),
|
||||
133 => Err(Self::NoReply),
|
||||
134 => Err(Self::ErrorFromPl),
|
||||
135 => Err(Self::Unused2),
|
||||
136 => Err(Self::Unused3),
|
||||
v => Err(Self::Unknown(v)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
enum PlRamAddress {
|
||||
Dutycyc,
|
||||
Sec,
|
||||
Min,
|
||||
Hour,
|
||||
Batv,
|
||||
Battemp,
|
||||
Rstate,
|
||||
Vreg,
|
||||
Cint,
|
||||
Lint,
|
||||
Ciacc1,
|
||||
Ciacc2,
|
||||
Ciacc3,
|
||||
Ciahl,
|
||||
Ciahh,
|
||||
Ceacc1,
|
||||
Ceacc2,
|
||||
Ceacc3,
|
||||
Ceahl,
|
||||
Ceahh,
|
||||
Liacc1,
|
||||
Liacc2,
|
||||
Liacc3,
|
||||
Liahl,
|
||||
Liahh,
|
||||
Leacc1,
|
||||
Leacc2,
|
||||
Leacc3,
|
||||
Leahl,
|
||||
Leahh,
|
||||
}
|
||||
|
||||
impl From<PlRamAddress> for u8 {
|
||||
fn from(value: PlRamAddress) -> Self {
|
||||
match value {
|
||||
PlRamAddress::Dutycyc => 39,
|
||||
PlRamAddress::Sec => 46,
|
||||
PlRamAddress::Min => 47,
|
||||
PlRamAddress::Hour => 48,
|
||||
PlRamAddress::Batv => 50,
|
||||
PlRamAddress::Battemp => 52,
|
||||
PlRamAddress::Rstate => 101,
|
||||
PlRamAddress::Vreg => 105,
|
||||
PlRamAddress::Cint => 213,
|
||||
PlRamAddress::Lint => 217,
|
||||
PlRamAddress::Ciacc1 => 0xB9,
|
||||
PlRamAddress::Ciacc2 => 0xBA,
|
||||
PlRamAddress::Ciacc3 => 0xBB,
|
||||
PlRamAddress::Ciahl => 0xBC,
|
||||
PlRamAddress::Ciahh => 0xBD,
|
||||
PlRamAddress::Ceacc1 => 0xBE,
|
||||
PlRamAddress::Ceacc2 => 0xBF,
|
||||
PlRamAddress::Ceacc3 => 0xC0,
|
||||
PlRamAddress::Ceahl => 0xC1,
|
||||
PlRamAddress::Ceahh => 0xC2,
|
||||
PlRamAddress::Liacc1 => 0xC3,
|
||||
PlRamAddress::Liacc2 => 0xC4,
|
||||
PlRamAddress::Liacc3 => 0xC5,
|
||||
PlRamAddress::Liahl => 0xC6,
|
||||
PlRamAddress::Liahh => 0xC7,
|
||||
PlRamAddress::Leacc1 => 0xC8,
|
||||
PlRamAddress::Leacc2 => 0xC9,
|
||||
PlRamAddress::Leacc3 => 0xCA,
|
||||
PlRamAddress::Leahl => 0xCB,
|
||||
PlRamAddress::Leahh => 0xCC,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
enum PlEepromAddress {
|
||||
// calibration
|
||||
BCals,
|
||||
BCal12,
|
||||
BCal24,
|
||||
BCal48,
|
||||
ChargeOffset,
|
||||
ChargeGain,
|
||||
LoadOffset,
|
||||
LoadGain,
|
||||
BatTmpOffset,
|
||||
BatTmpGain,
|
||||
SolarOffset,
|
||||
SolarGain,
|
||||
BatsenOffset,
|
||||
BatsenGain,
|
||||
|
||||
// settings
|
||||
GOn,
|
||||
GOff,
|
||||
GDel,
|
||||
GExF,
|
||||
GRun,
|
||||
LOff,
|
||||
LOn,
|
||||
LDel,
|
||||
ASet,
|
||||
BstFreq,
|
||||
ATim,
|
||||
Hyst,
|
||||
BRet,
|
||||
CurLim,
|
||||
Bat2,
|
||||
ESet1,
|
||||
ESet2,
|
||||
ESet3,
|
||||
EqFreq,
|
||||
ETim,
|
||||
AbsV,
|
||||
EMax,
|
||||
FltV,
|
||||
BMax,
|
||||
LGSet,
|
||||
PwmE,
|
||||
SStop,
|
||||
EtMod,
|
||||
GMod,
|
||||
Volt,
|
||||
BCap,
|
||||
HistoryDataPtr,
|
||||
}
|
||||
|
||||
impl From<PlEepromAddress> for u8 {
|
||||
fn from(value: PlEepromAddress) -> Self {
|
||||
match value {
|
||||
PlEepromAddress::BCals => 0x00,
|
||||
PlEepromAddress::BCal12 => 0x01,
|
||||
PlEepromAddress::BCal24 => 0x02,
|
||||
PlEepromAddress::BCal48 => 0x03,
|
||||
PlEepromAddress::ChargeOffset => 0x04,
|
||||
PlEepromAddress::ChargeGain => 0x05,
|
||||
PlEepromAddress::LoadOffset => 0x06,
|
||||
PlEepromAddress::LoadGain => 0x07,
|
||||
PlEepromAddress::BatTmpOffset => 0x08,
|
||||
PlEepromAddress::BatTmpGain => 0x09,
|
||||
PlEepromAddress::SolarOffset => 0x0A,
|
||||
PlEepromAddress::SolarGain => 0x0B,
|
||||
PlEepromAddress::BatsenOffset => 0x0C,
|
||||
PlEepromAddress::BatsenGain => 0x0D,
|
||||
PlEepromAddress::GOn => 0x0E,
|
||||
PlEepromAddress::GOff => 0x0F,
|
||||
PlEepromAddress::GDel => 0x10,
|
||||
PlEepromAddress::GExF => 0x11,
|
||||
PlEepromAddress::GRun => 0x12,
|
||||
PlEepromAddress::LOff => 0x13,
|
||||
PlEepromAddress::LOn => 0x14,
|
||||
PlEepromAddress::LDel => 0x15,
|
||||
PlEepromAddress::ASet => 0x16,
|
||||
PlEepromAddress::BstFreq => 0x17,
|
||||
PlEepromAddress::ATim => 0x18,
|
||||
PlEepromAddress::Hyst => 0x19,
|
||||
PlEepromAddress::BRet => 0x1A,
|
||||
PlEepromAddress::CurLim => 0x1B,
|
||||
PlEepromAddress::Bat2 => 0x1C,
|
||||
PlEepromAddress::ESet1 => 0x1D,
|
||||
PlEepromAddress::ESet2 => 0x1E,
|
||||
PlEepromAddress::ESet3 => 0x1F,
|
||||
PlEepromAddress::EqFreq => 0x20,
|
||||
PlEepromAddress::ETim => 0x21,
|
||||
PlEepromAddress::AbsV => 0x22,
|
||||
PlEepromAddress::EMax => 0x23,
|
||||
PlEepromAddress::FltV => 0x24,
|
||||
PlEepromAddress::BMax => 0x25,
|
||||
PlEepromAddress::LGSet => 0x26,
|
||||
PlEepromAddress::PwmE => 0x27,
|
||||
PlEepromAddress::SStop => 0x28,
|
||||
PlEepromAddress::EtMod => 0x29,
|
||||
PlEepromAddress::GMod => 0x2A,
|
||||
PlEepromAddress::Volt => 0x2B,
|
||||
PlEepromAddress::BCap => 0x2c,
|
||||
PlEepromAddress::HistoryDataPtr => 0x2d,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const fn command(operation: u8, address: u8, data: u8) -> [u8; 4] {
|
||||
[operation, address, data, !operation]
|
||||
}
|
File diff suppressed because it is too large
Load diff
|
@ -1,192 +0,0 @@
|
|||
pub struct ModbusTimeout {
|
||||
context: Option<tokio_modbus::client::Context>,
|
||||
transport_settings: crate::config::Transport,
|
||||
counters: Counters,
|
||||
}
|
||||
|
||||
const MODBUS_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(3);
|
||||
|
||||
async fn connect(
|
||||
transport_settings: &crate::config::Transport,
|
||||
) -> eyre::Result<tokio_modbus::client::Context> {
|
||||
let slave = tokio_modbus::Slave(super::DEVICE_ID);
|
||||
|
||||
let modbus = match transport_settings {
|
||||
crate::config::Transport::Serial { port, baud_rate } => {
|
||||
let modbus_serial =
|
||||
tokio_serial::SerialStream::open(&tokio_serial::new(port, *baud_rate))?;
|
||||
tokio_modbus::client::rtu::attach_slave(modbus_serial, slave)
|
||||
}
|
||||
crate::config::Transport::Tcp { ip, port } => {
|
||||
let modbus_tcp = tokio::net::TcpStream::connect((*ip, *port)).await?;
|
||||
tokio_modbus::client::tcp::attach_slave(modbus_tcp, slave)
|
||||
}
|
||||
};
|
||||
|
||||
Ok(modbus)
|
||||
}
|
||||
|
||||
type ModbusDeviceResult<T> = Result<T, tokio_modbus::ExceptionCode>;
|
||||
type ModbusResult<T> = Result<ModbusDeviceResult<T>, tokio_modbus::Error>;
|
||||
|
||||
type ContextFuture<'a, R> = dyn std::future::Future<Output = ModbusResult<R>> + Send + 'a;
|
||||
type ContextFn<R, D> =
|
||||
fn(&mut tokio_modbus::client::Context, D) -> std::pin::Pin<Box<ContextFuture<'_, R>>>;
|
||||
|
||||
trait TryInsert {
|
||||
type T;
|
||||
async fn get_or_try_insert_with<F, R, Fut>(&mut self, f: F) -> Result<&mut Self::T, R>
|
||||
where
|
||||
Fut: std::future::Future<Output = Result<Self::T, R>>,
|
||||
F: FnOnce() -> Fut;
|
||||
}
|
||||
|
||||
impl<T> TryInsert for Option<T> {
|
||||
type T = T;
|
||||
|
||||
async fn get_or_try_insert_with<F, R, Fut>(&mut self, f: F) -> Result<&mut Self::T, R>
|
||||
where
|
||||
Fut: std::future::Future<Output = Result<Self::T, R>>,
|
||||
F: FnOnce() -> Fut,
|
||||
{
|
||||
if self.is_none() {
|
||||
let got = f().await?;
|
||||
*self = Some(got);
|
||||
}
|
||||
|
||||
// a `None` variant for `self` would have been replaced by a `Some` variant
|
||||
// in the code above, or the ? would have caused an early return
|
||||
Ok(self.as_mut().unwrap())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default, Debug)]
|
||||
struct Counters {
|
||||
gateway: usize,
|
||||
timeout: usize,
|
||||
protocol: usize,
|
||||
}
|
||||
|
||||
const MAX_ERRORS: usize = 2;
|
||||
|
||||
impl Counters {
|
||||
fn reset(&mut self) {
|
||||
*self = Self::default();
|
||||
}
|
||||
|
||||
const fn any_above_max(&self) -> bool {
|
||||
self.gateway > MAX_ERRORS || self.timeout > MAX_ERRORS || self.protocol > MAX_ERRORS
|
||||
}
|
||||
}
|
||||
|
||||
const NUM_TRIES: usize = 3;
|
||||
|
||||
impl ModbusTimeout {
|
||||
pub async fn new(transport_settings: crate::config::Transport) -> eyre::Result<Self> {
|
||||
let context = Some(connect(&transport_settings).await?);
|
||||
Ok(Self {
|
||||
context,
|
||||
transport_settings,
|
||||
counters: Counters::default(),
|
||||
})
|
||||
}
|
||||
|
||||
async fn with_context<R, D: Copy>(
|
||||
&mut self,
|
||||
f: ContextFn<R, D>,
|
||||
data: D,
|
||||
) -> eyre::Result<Result<R, tokio_modbus::ExceptionCode>> {
|
||||
let mut last_err = None;
|
||||
for _ in 0..NUM_TRIES {
|
||||
if let Ok(context) = self
|
||||
.context
|
||||
.get_or_try_insert_with(async || connect(&self.transport_settings).await)
|
||||
.await
|
||||
{
|
||||
let res = tokio::time::timeout(MODBUS_TIMEOUT, f(context, data)).await;
|
||||
match res {
|
||||
Ok(Ok(Err(e)))
|
||||
if e == tokio_modbus::ExceptionCode::GatewayTargetDevice
|
||||
|| e == tokio_modbus::ExceptionCode::GatewayPathUnavailable =>
|
||||
{
|
||||
log::warn!("gateway error: {e:?}");
|
||||
last_err = Some(e.into());
|
||||
self.counters.gateway += 1;
|
||||
}
|
||||
Ok(Ok(v)) => {
|
||||
self.counters.reset();
|
||||
return Ok(v);
|
||||
}
|
||||
Ok(Err(tokio_modbus::Error::Protocol(e))) => {
|
||||
// protocol error
|
||||
log::warn!("protocol error: {e:?}");
|
||||
last_err = Some(e.into());
|
||||
self.counters.protocol += 1;
|
||||
}
|
||||
Ok(Err(tokio_modbus::Error::Transport(e))) => {
|
||||
// transport error
|
||||
log::warn!("reconnecting due to transport error: {e:?}");
|
||||
last_err = Some(e.into());
|
||||
self.context = None;
|
||||
}
|
||||
Err(_) => {
|
||||
// timeout
|
||||
last_err = Some(eyre::eyre!("timeout"));
|
||||
self.counters.timeout += 1;
|
||||
}
|
||||
}
|
||||
if self.counters.any_above_max() {
|
||||
self.context = None;
|
||||
log::warn!(
|
||||
"reconnecting due to multiple errors without a successful operation: {:?}",
|
||||
self.counters
|
||||
);
|
||||
self.counters.reset();
|
||||
}
|
||||
} else {
|
||||
// failed to reconnect
|
||||
return Err(eyre::eyre!("failed to reconnect to controller"));
|
||||
}
|
||||
}
|
||||
|
||||
Err(last_err.unwrap_or_else(|| eyre::eyre!("unknown last error????")))
|
||||
}
|
||||
|
||||
pub async fn write_single_register(
|
||||
&mut self,
|
||||
addr: tokio_modbus::Address,
|
||||
word: u16,
|
||||
) -> eyre::Result<ModbusDeviceResult<()>> {
|
||||
async fn write(
|
||||
context: &mut tokio_modbus::client::Context,
|
||||
addr: tokio_modbus::Address,
|
||||
word: u16,
|
||||
) -> ModbusResult<()> {
|
||||
use tokio_modbus::client::Writer;
|
||||
context.write_single_register(addr, word).await
|
||||
}
|
||||
|
||||
let fut: ContextFn<(), _> = |context, (addr, word)| Box::pin(write(context, addr, word));
|
||||
let r = self.with_context(fut, (addr, word)).await?;
|
||||
Ok(r)
|
||||
}
|
||||
|
||||
pub async fn read_holding_registers(
|
||||
&mut self,
|
||||
addr: tokio_modbus::Address,
|
||||
cnt: tokio_modbus::Quantity,
|
||||
) -> eyre::Result<ModbusDeviceResult<Vec<u16>>> {
|
||||
async fn read(
|
||||
context: &mut tokio_modbus::client::Context,
|
||||
addr: tokio_modbus::Address,
|
||||
cnt: tokio_modbus::Quantity,
|
||||
) -> ModbusResult<Vec<u16>> {
|
||||
use tokio_modbus::client::Reader;
|
||||
context.read_holding_registers(addr, cnt).await
|
||||
}
|
||||
|
||||
let fut: ContextFn<_, _> = |context, (addr, cnt)| Box::pin(read(context, addr, cnt));
|
||||
let res = self.with_context(fut, (addr, cnt)).await?;
|
||||
Ok(res)
|
||||
}
|
||||
}
|
|
@ -1,203 +0,0 @@
|
|||
#![feature(let_chains)]
|
||||
|
||||
use clap::Parser;
|
||||
use futures::StreamExt;
|
||||
use std::path::PathBuf;
|
||||
|
||||
mod config;
|
||||
|
||||
#[derive(clap::Parser, Debug, Clone)]
|
||||
#[clap(author, version, about, long_about = None)]
|
||||
struct Args {
|
||||
#[command(subcommand)]
|
||||
command: Commands,
|
||||
#[clap(long, default_value = "/etc/charge-controller-supervisor/config.json")]
|
||||
config: PathBuf,
|
||||
}
|
||||
|
||||
#[derive(clap::Subcommand, Debug, Clone)]
|
||||
enum Commands {
|
||||
/// Run charge controller server
|
||||
Watch,
|
||||
/// Print the default config file
|
||||
GenerateConfig,
|
||||
}
|
||||
|
||||
mod controller;
|
||||
mod gauges;
|
||||
|
||||
mod storage;
|
||||
|
||||
mod web;
|
||||
|
||||
pub const CHARGE_CONTROLLER_LABEL: &str = "charge_controller";
|
||||
pub const PL_LABEL: &str = "pl_device";
|
||||
pub const TRISTAR_LABEL: &str = "tristar_device";
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() {
|
||||
env_logger::builder()
|
||||
.format_module_path(false)
|
||||
.format_timestamp(
|
||||
if std::env::var("LOG_TIMESTAMP").is_ok_and(|v| v == "false") {
|
||||
None
|
||||
} else {
|
||||
Some(env_logger::TimestampPrecision::Seconds)
|
||||
},
|
||||
)
|
||||
.init();
|
||||
|
||||
if let Err(e) = run().await {
|
||||
log::error!("{e:?}");
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
async fn run() -> eyre::Result<()> {
|
||||
let args = Args::parse();
|
||||
|
||||
match args.command {
|
||||
Commands::Watch => watch(args).await,
|
||||
Commands::GenerateConfig => {
|
||||
let mut config = config::Config::default();
|
||||
config
|
||||
.charge_controllers
|
||||
.push(config::ChargeControllerConfig {
|
||||
name: String::from("tcp"),
|
||||
transport: config::Transport::Tcp {
|
||||
ip: std::net::IpAddr::V4(std::net::Ipv4Addr::new(192, 168, 1, 102)),
|
||||
port: 420,
|
||||
},
|
||||
watch_interval_seconds: 0,
|
||||
variant: config::ChargeControllerVariant::Tristar,
|
||||
follow_primary: false,
|
||||
});
|
||||
config
|
||||
.charge_controllers
|
||||
.push(config::ChargeControllerConfig {
|
||||
name: String::from("serial"),
|
||||
transport: config::Transport::Serial {
|
||||
port: "/dev/someport".to_string(),
|
||||
baud_rate: 69,
|
||||
},
|
||||
watch_interval_seconds: 0,
|
||||
variant: config::ChargeControllerVariant::Tristar,
|
||||
follow_primary: false,
|
||||
});
|
||||
let config = config::ConfigStorage::from_latest(config);
|
||||
let json = serde_json::to_string_pretty(&config)?;
|
||||
println!("{json}");
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn watch(args: Args) -> eyre::Result<()> {
|
||||
let _w = config::init_config(&args.config);
|
||||
let (storage, follow_voltage_tx, mut controller_tasks) = {
|
||||
let config = config::access_config().await;
|
||||
if config
|
||||
.charge_controllers
|
||||
.iter()
|
||||
.any(|cc| cc.follow_primary && cc.name == config.primary_charge_controller)
|
||||
{
|
||||
return Err(eyre::eyre!(
|
||||
"primary charge controller is set to follow primary!"
|
||||
));
|
||||
}
|
||||
|
||||
let mut controllers = Vec::new();
|
||||
let mut map = std::collections::HashMap::new();
|
||||
|
||||
let (voltage_tx, voltage_rx) =
|
||||
tokio::sync::watch::channel(controller::VoltageCommand::None);
|
||||
|
||||
for config in &config.charge_controllers {
|
||||
let n = config.name.clone();
|
||||
match controller::Controller::new(config.clone(), voltage_rx.clone()).await {
|
||||
Ok(v) => {
|
||||
map.insert(n, v.get_data_ptr());
|
||||
controllers.push(v);
|
||||
}
|
||||
Err(e) => log::error!("couldn't connect to {}: {e:?}", n),
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(primary) = controllers
|
||||
.iter_mut()
|
||||
.find(|c| c.name() == config.primary_charge_controller)
|
||||
{
|
||||
primary.set_tx_to_secondary(voltage_tx.clone());
|
||||
}
|
||||
|
||||
drop(config);
|
||||
|
||||
let controller_tasks = futures::stream::FuturesUnordered::new();
|
||||
for controller in controllers {
|
||||
controller_tasks.push(run_loop(controller));
|
||||
}
|
||||
|
||||
(
|
||||
storage::AllControllers::new(map),
|
||||
voltage_tx,
|
||||
controller_tasks,
|
||||
)
|
||||
};
|
||||
|
||||
let server = web::rocket(web::ServerState::new(
|
||||
&config::access_config().await.primary_charge_controller,
|
||||
storage,
|
||||
follow_voltage_tx,
|
||||
));
|
||||
let server_task = tokio::task::spawn(server.launch());
|
||||
log::warn!("...started!");
|
||||
|
||||
tokio::select! {
|
||||
v = controller_tasks.next() => {
|
||||
match v {
|
||||
Some(Err(e)) => {
|
||||
log::error!("{e:?}");
|
||||
}
|
||||
_ => {
|
||||
log::error!("no controller tasks left???");
|
||||
}
|
||||
}
|
||||
}
|
||||
v = server_task => {
|
||||
if let Err(e)=v {
|
||||
log::error!("server exited: {e:#?}");
|
||||
std::process::exit(1);
|
||||
} else {
|
||||
std::process::exit(0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
std::process::exit(1)
|
||||
}
|
||||
|
||||
async fn run_loop(mut controller: controller::Controller) -> eyre::Result<()> {
|
||||
let mut timeout = tokio::time::interval(controller.timeout_interval());
|
||||
timeout.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
|
||||
|
||||
loop {
|
||||
let rx = controller.get_rx();
|
||||
tokio::select! {
|
||||
_ = timeout.tick() => {
|
||||
do_refresh(&mut controller).await;
|
||||
}
|
||||
Ok(()) = rx.changed() => {
|
||||
let command = *rx.borrow();
|
||||
if let Err(e) = controller.process_command(command).await {
|
||||
log::error!("controller {} failed to process command: {e}", controller.name());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn do_refresh(controller: &mut controller::Controller) {
|
||||
if let Err(e) = controller.refresh().await {
|
||||
log::warn!("error reading controller {}: {e:?}", controller.name());
|
||||
}
|
||||
}
|
|
@ -1,61 +0,0 @@
|
|||
pub struct AllControllers {
|
||||
map: std::collections::HashMap<String, std::sync::Arc<ControllerData>>,
|
||||
}
|
||||
|
||||
impl AllControllers {
|
||||
pub const fn new(
|
||||
map: std::collections::HashMap<String, std::sync::Arc<ControllerData>>,
|
||||
) -> Self {
|
||||
Self { map }
|
||||
}
|
||||
|
||||
pub fn controller_names(&self) -> impl Iterator<Item = &String> {
|
||||
self.map.keys()
|
||||
}
|
||||
|
||||
pub fn get(&self, name: &str) -> Option<&std::sync::Arc<ControllerData>> {
|
||||
self.map.get(name)
|
||||
}
|
||||
|
||||
pub fn all_data(&self) -> impl Iterator<Item = (&String, &std::sync::Arc<ControllerData>)> {
|
||||
self.map.iter()
|
||||
}
|
||||
}
|
||||
|
||||
pub struct ControllerData {
|
||||
state: tokio::sync::RwLock<Option<crate::controller::ControllerState>>,
|
||||
settings: tokio::sync::RwLock<Option<crate::controller::ControllerSettings>>,
|
||||
}
|
||||
|
||||
impl ControllerData {
|
||||
pub const fn new() -> Self {
|
||||
Self {
|
||||
state: tokio::sync::RwLock::const_new(None),
|
||||
settings: tokio::sync::RwLock::const_new(None),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn write_state(
|
||||
&self,
|
||||
) -> tokio::sync::RwLockWriteGuard<Option<crate::controller::ControllerState>> {
|
||||
self.state.write().await
|
||||
}
|
||||
|
||||
pub async fn read_state(
|
||||
&self,
|
||||
) -> tokio::sync::RwLockReadGuard<Option<crate::controller::ControllerState>> {
|
||||
self.state.read().await
|
||||
}
|
||||
|
||||
pub async fn write_settings(
|
||||
&self,
|
||||
) -> tokio::sync::RwLockWriteGuard<Option<crate::controller::ControllerSettings>> {
|
||||
self.settings.write().await
|
||||
}
|
||||
|
||||
pub async fn read_settings(
|
||||
&self,
|
||||
) -> tokio::sync::RwLockReadGuard<Option<crate::controller::ControllerSettings>> {
|
||||
self.settings.read().await
|
||||
}
|
||||
}
|
|
@ -1,279 +0,0 @@
|
|||
use rocket::{get, post, routes, serde::json::Json, State};
|
||||
|
||||
use crate::storage::AllControllers;
|
||||
|
||||
mod static_handler;
|
||||
|
||||
pub struct ServerState {
|
||||
primary_name: String,
|
||||
data: AllControllers,
|
||||
tx_to_controllers: tokio::sync::watch::Sender<crate::controller::VoltageCommand>,
|
||||
}
|
||||
|
||||
impl ServerState {
|
||||
pub fn new(
|
||||
primary_name: &impl ToString,
|
||||
data: AllControllers,
|
||||
tx_to_controllers: tokio::sync::watch::Sender<crate::controller::VoltageCommand>,
|
||||
) -> Self {
|
||||
let primary_name = primary_name.to_string();
|
||||
Self {
|
||||
primary_name,
|
||||
data,
|
||||
tx_to_controllers,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn rocket(state: ServerState) -> rocket::Rocket<rocket::Build> {
|
||||
// serve the html from disk if running in a debug build
|
||||
// this allows editing the webpage without having to rebuild the executable
|
||||
// but in release builds, bundle the entire webapp folder into the exe
|
||||
let fileserver: Vec<rocket::Route> = if cfg!(debug_assertions) {
|
||||
rocket::fs::FileServer::from(format!(
|
||||
"{}/webapp",
|
||||
std::env::var("CARGO_MANIFEST_DIR").unwrap()
|
||||
))
|
||||
.into()
|
||||
} else {
|
||||
static_handler::UiStatic {}.into()
|
||||
};
|
||||
rocket::build()
|
||||
.attach(Cors)
|
||||
.manage(state)
|
||||
.mount("/", fileserver)
|
||||
.mount(
|
||||
"/",
|
||||
routes![
|
||||
metrics,
|
||||
interfaces,
|
||||
all_interfaces,
|
||||
all_interfaces_full,
|
||||
all_interfaces_settings,
|
||||
primary_interface,
|
||||
interface,
|
||||
interface_full,
|
||||
interface_settings,
|
||||
get_control,
|
||||
enable_control,
|
||||
disable_control
|
||||
],
|
||||
)
|
||||
}
|
||||
|
||||
#[get("/interfaces")]
|
||||
fn interfaces(state: &State<ServerState>) -> Json<Vec<String>> {
|
||||
Json(state.data.controller_names().cloned().collect())
|
||||
}
|
||||
|
||||
#[get("/interfaces/primary")]
|
||||
async fn primary_interface(
|
||||
state: &State<ServerState>,
|
||||
) -> Result<Json<crate::controller::CommonData>, ServerError> {
|
||||
let s = state
|
||||
.data
|
||||
.get(&state.primary_name)
|
||||
.ok_or(ServerError::InvalidPrimaryName)?
|
||||
.read_state()
|
||||
.await;
|
||||
|
||||
Ok(Json(s.as_ref().ok_or(ServerError::NoData)?.common()))
|
||||
}
|
||||
|
||||
#[get("/interfaces/data")]
|
||||
async fn all_interfaces(
|
||||
state: &State<ServerState>,
|
||||
) -> Json<Vec<(String, crate::controller::CommonData)>> {
|
||||
let mut data = Vec::new();
|
||||
|
||||
for (k, v) in state.data.all_data() {
|
||||
let v = v.read_state().await;
|
||||
if let Some(v) = v.as_ref() {
|
||||
data.push((k.clone(), v.common().clone()));
|
||||
}
|
||||
}
|
||||
|
||||
Json(data)
|
||||
}
|
||||
|
||||
#[get("/interfaces/data/full")]
|
||||
async fn all_interfaces_full(
|
||||
state: &State<ServerState>,
|
||||
) -> Json<Vec<(String, crate::controller::ControllerState)>> {
|
||||
let mut data = Vec::new();
|
||||
|
||||
for (k, v) in state.data.all_data() {
|
||||
let v = v.read_state().await;
|
||||
if let Some(v) = v.as_ref() {
|
||||
data.push((k.clone(), v.clone()));
|
||||
}
|
||||
}
|
||||
|
||||
Json(data)
|
||||
}
|
||||
|
||||
#[get("/interfaces/settings")]
|
||||
async fn all_interfaces_settings(
|
||||
state: &State<ServerState>,
|
||||
) -> Json<Vec<(String, crate::controller::ControllerSettings)>> {
|
||||
let mut data = Vec::new();
|
||||
|
||||
for (k, v) in state.data.all_data() {
|
||||
let v = v.read_settings().await;
|
||||
if let Some(v) = v.as_ref() {
|
||||
data.push((k.clone(), v.clone()));
|
||||
}
|
||||
}
|
||||
|
||||
Json(data)
|
||||
}
|
||||
|
||||
#[get("/interface/<name>")]
|
||||
async fn interface(
|
||||
name: &str,
|
||||
state: &State<ServerState>,
|
||||
) -> Result<Json<crate::controller::CommonData>, ServerError> {
|
||||
let data = state
|
||||
.data
|
||||
.get(name)
|
||||
.ok_or(ServerError::NotFound)?
|
||||
.read_state()
|
||||
.await;
|
||||
Ok(Json(data.as_ref().ok_or(ServerError::NoData)?.common()))
|
||||
}
|
||||
|
||||
#[get("/interface/<name>/full")]
|
||||
async fn interface_full(
|
||||
name: &str,
|
||||
state: &State<ServerState>,
|
||||
) -> Result<Json<crate::controller::ControllerState>, ServerError> {
|
||||
let data = state
|
||||
.data
|
||||
.get(name)
|
||||
.ok_or(ServerError::NotFound)?
|
||||
.read_state()
|
||||
.await;
|
||||
Ok(Json(data.as_ref().ok_or(ServerError::NoData)?.clone()))
|
||||
}
|
||||
|
||||
#[get("/interface/<name>/settings")]
|
||||
async fn interface_settings(
|
||||
name: &str,
|
||||
state: &State<ServerState>,
|
||||
) -> Result<Json<crate::controller::ControllerSettings>, ServerError> {
|
||||
let data = state
|
||||
.data
|
||||
.get(name)
|
||||
.ok_or(ServerError::NotFound)?
|
||||
.read_settings()
|
||||
.await;
|
||||
Ok(Json(data.as_ref().ok_or(ServerError::NoData)?.clone()))
|
||||
}
|
||||
|
||||
#[get("/metrics")]
|
||||
fn metrics() -> Result<String, ServerError> {
|
||||
Ok(
|
||||
prometheus::TextEncoder::new()
|
||||
.encode_to_string(&prometheus::default_registry().gather())?,
|
||||
)
|
||||
}
|
||||
|
||||
#[derive(serde::Serialize)]
|
||||
struct ControlState {
|
||||
enabled: bool,
|
||||
}
|
||||
|
||||
#[get("/control")]
|
||||
async fn get_control() -> Json<ControlState> {
|
||||
let enabled = crate::config::access_config()
|
||||
.await
|
||||
.enable_secondary_control;
|
||||
Json(ControlState { enabled })
|
||||
}
|
||||
|
||||
#[post("/control/enable")]
|
||||
async fn enable_control() {
|
||||
log::warn!("enabling control");
|
||||
crate::config::write_to_config()
|
||||
.await
|
||||
.enable_secondary_control = true;
|
||||
}
|
||||
|
||||
#[post("/control/disable")]
|
||||
async fn disable_control(state: &State<ServerState>) -> Result<(), ServerError> {
|
||||
log::warn!("disabling control");
|
||||
crate::config::write_to_config()
|
||||
.await
|
||||
.enable_secondary_control = false;
|
||||
state
|
||||
.tx_to_controllers
|
||||
.send(crate::controller::VoltageCommand::None)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
enum ServerError {
|
||||
Prometheus,
|
||||
NotFound,
|
||||
InvalidPrimaryName,
|
||||
NoData,
|
||||
ControllerTx,
|
||||
}
|
||||
|
||||
impl From<prometheus::Error> for ServerError {
|
||||
fn from(_: prometheus::Error) -> Self {
|
||||
Self::Prometheus
|
||||
}
|
||||
}
|
||||
|
||||
impl<T> From<tokio::sync::watch::error::SendError<T>> for ServerError {
|
||||
fn from(_: tokio::sync::watch::error::SendError<T>) -> Self {
|
||||
Self::ControllerTx
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a> rocket::response::Responder<'a, 'a> for ServerError {
|
||||
fn respond_to(self, _: &'a rocket::Request<'_>) -> rocket::response::Result<'a> {
|
||||
Err(match self {
|
||||
Self::NotFound => rocket::http::Status::NotFound,
|
||||
Self::InvalidPrimaryName => rocket::http::Status::ServiceUnavailable,
|
||||
Self::ControllerTx | Self::NoData | Self::Prometheus => {
|
||||
rocket::http::Status::InternalServerError
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
pub struct Cors;
|
||||
|
||||
#[rocket::async_trait]
|
||||
impl rocket::fairing::Fairing for Cors {
|
||||
fn info(&self) -> rocket::fairing::Info {
|
||||
rocket::fairing::Info {
|
||||
name: "Add CORS headers to responses",
|
||||
kind: rocket::fairing::Kind::Response,
|
||||
}
|
||||
}
|
||||
|
||||
async fn on_response<'r>(
|
||||
&self,
|
||||
_request: &'r rocket::Request<'_>,
|
||||
response: &mut rocket::Response<'r>,
|
||||
) {
|
||||
response.set_header(rocket::http::Header::new(
|
||||
"Access-Control-Allow-Origin",
|
||||
"*",
|
||||
));
|
||||
response.set_header(rocket::http::Header::new(
|
||||
"Access-Control-Allow-Methods",
|
||||
"POST, GET, PATCH, OPTIONS",
|
||||
));
|
||||
response.set_header(rocket::http::Header::new(
|
||||
"Access-Control-Allow-Headers",
|
||||
"*",
|
||||
));
|
||||
response.set_header(rocket::http::Header::new(
|
||||
"Access-Control-Allow-Credentials",
|
||||
"true",
|
||||
));
|
||||
}
|
||||
}
|
|
@ -1,83 +0,0 @@
|
|||
use include_dir::{include_dir, Dir};
|
||||
use rocket::{
|
||||
http::{ContentType, Status},
|
||||
outcome::IntoOutcome,
|
||||
response::Responder,
|
||||
route::{Handler, Outcome},
|
||||
Data, Request,
|
||||
};
|
||||
use std::path::PathBuf;
|
||||
|
||||
static UI_DIR_FILES: Dir<'_> = include_dir!("$CARGO_MANIFEST_DIR/webapp");
|
||||
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub(super) struct UiStatic {}
|
||||
|
||||
impl From<UiStatic> for Vec<rocket::Route> {
|
||||
fn from(server: UiStatic) -> Self {
|
||||
vec![rocket::Route::ranked(
|
||||
None,
|
||||
rocket::http::Method::Get,
|
||||
"/<path..>",
|
||||
server,
|
||||
)]
|
||||
}
|
||||
}
|
||||
|
||||
#[rocket::async_trait]
|
||||
impl Handler for UiStatic {
|
||||
async fn handle<'r>(&self, req: &'r Request<'_>, data: Data<'r>) -> Outcome<'r> {
|
||||
use rocket::http::uri::fmt::Path;
|
||||
|
||||
let path = req
|
||||
.segments::<rocket::http::uri::Segments<'_, Path>>(0..)
|
||||
.ok()
|
||||
.and_then(|segments| segments.to_path_buf(true).ok());
|
||||
|
||||
match path {
|
||||
Some(p) => {
|
||||
if p.as_os_str() == "" {
|
||||
let index = UI_DIR_FILES.get_file("index.html").map(|v| RawHtml {
|
||||
data: v.contents().to_vec(),
|
||||
name: PathBuf::from("index.html"),
|
||||
});
|
||||
|
||||
index.respond_to(req).or_forward((data, Status::NotFound))
|
||||
} else {
|
||||
let plus_index = p.join("index.html");
|
||||
let file = UI_DIR_FILES
|
||||
.get_file(&p)
|
||||
.map(|v| RawHtml {
|
||||
data: v.contents().to_vec(),
|
||||
name: p,
|
||||
})
|
||||
.or_else(|| {
|
||||
UI_DIR_FILES.get_file(&plus_index).map(|v| RawHtml {
|
||||
data: v.contents().to_vec(),
|
||||
name: plus_index,
|
||||
})
|
||||
});
|
||||
file.respond_to(req).or_forward((data, Status::NotFound))
|
||||
}
|
||||
}
|
||||
None => Outcome::forward(data, Status::NotFound),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct RawHtml {
|
||||
data: Vec<u8>,
|
||||
name: PathBuf,
|
||||
}
|
||||
|
||||
impl<'r> Responder<'r, 'static> for RawHtml {
|
||||
fn respond_to(self, request: &'r Request<'_>) -> rocket::response::Result<'static> {
|
||||
let mut response = self.data.respond_to(request)?;
|
||||
if let Some(ext) = self.name.extension()
|
||||
&& let Some(ct) = ContentType::from_extension(&ext.to_string_lossy())
|
||||
{
|
||||
response.set_header(ct);
|
||||
}
|
||||
Ok(response)
|
||||
}
|
||||
}
|
|
@ -1,26 +0,0 @@
|
|||
<!doctype html>
|
||||
<html>
|
||||
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Charge Control Control</title>
|
||||
<link id="favicon" rel="icon"
|
||||
href="data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 100 100%22><text y=%22.9em%22 font-size=%2290%22>🤯</text></svg>">
|
||||
<link rel="stylesheet" href="style.css">
|
||||
<script src="script.js"></script>
|
||||
</head>
|
||||
|
||||
<body onload="init_main()">
|
||||
<div class="container">
|
||||
<h3>Follow primary controller:</h3>
|
||||
<div class="selector" id="control-selector">
|
||||
<input id="control-enabled" type="radio" name="control" onclick="enable_automatic_control()">
|
||||
<label for="control-enabled">enabled</label>
|
||||
<input id="control-disabled" type="radio" name="control" onclick="disable_automatic_control()">
|
||||
<label for="control-disabled">disabled</label>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
|
||||
</html>
|
|
@ -1,80 +0,0 @@
|
|||
const api_url =
|
||||
window.location.protocol +
|
||||
"//" +
|
||||
window.location.hostname +
|
||||
":" +
|
||||
window.location.port;
|
||||
|
||||
const delay = (time) => {
|
||||
return new Promise((resolve) => setTimeout(resolve, time));
|
||||
};
|
||||
|
||||
Object.prototype.disable = function () {
|
||||
var that = this;
|
||||
for (var i = 0, len = that.length; i < len; i++) {
|
||||
that[i].disabled = true;
|
||||
}
|
||||
return that;
|
||||
};
|
||||
|
||||
Object.prototype.enable = function () {
|
||||
var that = this;
|
||||
for (var i = 0, len = that.length; i < len; i++) {
|
||||
that[i].disabled = false;
|
||||
}
|
||||
return that;
|
||||
};
|
||||
|
||||
function init_main() {
|
||||
refresh_buttons();
|
||||
}
|
||||
|
||||
function disable_automatic_control() {
|
||||
if (is_automatic_control) {
|
||||
document.getElementById("control-disabled").checked = true;
|
||||
document.body.classList.add("loading");
|
||||
|
||||
fetch(api_url + "/control/disable", { method: "POST" }).then(
|
||||
async (response) => {
|
||||
let delayres = await delay(100);
|
||||
refresh_buttons();
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function enable_automatic_control() {
|
||||
if (!is_automatic_control) {
|
||||
document.getElementById("control-enabled").checked = true;
|
||||
document.body.classList.add("loading");
|
||||
fetch(api_url + "/control/enable", { method: "POST" }).then(
|
||||
async (response) => {
|
||||
let delayres = await delay(100);
|
||||
refresh_buttons();
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function update_control_buttons(data) {
|
||||
document.body.classList.remove("loading");
|
||||
console.log("got data");
|
||||
console.log(data);
|
||||
is_automatic_control = data.enabled;
|
||||
if (is_automatic_control) {
|
||||
document.getElementById("control-enabled").checked = true;
|
||||
} else {
|
||||
document.getElementById("control-disabled").checked = true;
|
||||
}
|
||||
}
|
||||
|
||||
function refresh_buttons() {
|
||||
console.log("get data");
|
||||
console.log(api_url);
|
||||
fetch(api_url + "/control").then(async (response) => {
|
||||
console.log("got response");
|
||||
json = await response.json();
|
||||
console.log(json);
|
||||
update_control_buttons(json);
|
||||
});
|
||||
}
|
|
@ -1,101 +0,0 @@
|
|||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica,
|
||||
Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol";
|
||||
background-color: #333;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.container {
|
||||
max-width: 40rem;
|
||||
margin: auto;
|
||||
padding: 0.5rem 2rem 2rem 2rem;
|
||||
border-radius: 10px;
|
||||
background-color: #faf9fd;
|
||||
}
|
||||
|
||||
.outlink {
|
||||
display: block;
|
||||
font-weight: bold;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
a.outlink {
|
||||
text-decoration: none;
|
||||
color: rgb(52, 52, 246);
|
||||
}
|
||||
|
||||
a.outlink:hover {
|
||||
color: rgb(110, 100, 255);
|
||||
}
|
||||
|
||||
.loading,
|
||||
.loading * {
|
||||
cursor: progress;
|
||||
}
|
||||
|
||||
.disabled,
|
||||
.disabled * {
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.selector {
|
||||
padding: 1rem;
|
||||
background-color: gray;
|
||||
color: #333;
|
||||
width: max-content;
|
||||
border: 0.2rem;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
label {
|
||||
padding: 0.5rem 1rem;
|
||||
margin: 0.5rem;
|
||||
font-weight: bold;
|
||||
transition: all 0.2s 0s ease;
|
||||
border-radius: 4px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
input[type="radio"] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
input[type="radio"]:checked + label {
|
||||
background-color: white;
|
||||
}
|
||||
|
||||
input[type="radio"]:checked:disabled + label {
|
||||
background-color: #ddd;
|
||||
}
|
||||
|
||||
input[type="radio"]:disabled + label {
|
||||
color: #666;
|
||||
}
|
||||
|
||||
@media (width > 600px) {
|
||||
.container {
|
||||
margin-top: 2rem;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
body {
|
||||
background-color: #191919;
|
||||
}
|
||||
|
||||
.container {
|
||||
background-color: #444;
|
||||
color: #f0f0f0;
|
||||
}
|
||||
|
||||
a.outlink {
|
||||
text-decoration: none;
|
||||
/* color: rgb(152, 152, 242); */
|
||||
color: rgb(125, 125, 250);
|
||||
/* color: rgb(94, 94, 252); */
|
||||
}
|
||||
|
||||
a.outlink:hover {
|
||||
color: rgb(130, 120, 255);
|
||||
}
|
||||
}
|
2
control-loop.txt
Normal file
2
control-loop.txt
Normal file
|
@ -0,0 +1,2 @@
|
|||
targeting pl's target voltage
|
||||
if load current > 0 or duty cycle < ~0.8 then there is power to spare
|
|
@ -1,7 +1,6 @@
|
|||
[Unit]
|
||||
Description=Tesla Charge Controller
|
||||
After=network.target
|
||||
Requires=charge-controller-supervisor.service
|
||||
StartLimitIntervalSec=0
|
||||
|
||||
[Service]
|
|
@ -1,3 +0,0 @@
|
|||
[toolchain]
|
||||
channel = "nightly-2025-01-16"
|
||||
targets = ["aarch64-unknown-linux-musl"]
|
247
src/api_interface.rs
Normal file
247
src/api_interface.rs
Normal file
|
@ -0,0 +1,247 @@
|
|||
use lazy_static::lazy_static;
|
||||
use prometheus::{
|
||||
core::{AtomicI64, GenericGauge},
|
||||
register_gauge, register_int_gauge, register_int_gauge_vec, Gauge, IntGauge, IntGaugeVec,
|
||||
};
|
||||
use std::sync::{Arc, RwLock};
|
||||
|
||||
mod http;
|
||||
|
||||
use crate::types::{CarState, ChargingState};
|
||||
|
||||
lazy_static! {
|
||||
pub static ref BATTERY_LEVEL: IntGauge =
|
||||
register_int_gauge!("tesla_battery_level", "Battery level",).unwrap();
|
||||
pub static ref CHARGE_RATE: Gauge =
|
||||
register_gauge!("tesla_charge_rate", "Charge rate",).unwrap();
|
||||
pub static ref CHARGE_REQUEST: IntGauge =
|
||||
register_int_gauge!("tesla_charge_request", "Requested charge rate",).unwrap();
|
||||
pub static ref CHARGE_ENABLE_REQUEST: IntGauge =
|
||||
register_int_gauge!("tesla_charge_enable_request", "Charge enable request",).unwrap();
|
||||
pub static ref CHARGER_CONNECTED: IntGauge =
|
||||
register_int_gauge!("tesla_charger_connected", "Charger connected",).unwrap();
|
||||
pub static ref INSIDE_TEMP: Gauge =
|
||||
register_gauge!("tesla_inside_temp", "Inside temperature",).unwrap();
|
||||
pub static ref OUTSIDE_TEMP: Gauge =
|
||||
register_gauge!("tesla_outside_temp", "Outside temperature",).unwrap();
|
||||
pub static ref BATTERY_HEATER: IntGauge =
|
||||
register_int_gauge!("tesla_battery_heater", "Battery heater",).unwrap();
|
||||
pub static ref CLIMATE_ON: IntGauge =
|
||||
register_int_gauge!("tesla_climate_on", "Climate control",).unwrap();
|
||||
pub static ref PRECONDITIONING: IntGauge =
|
||||
register_int_gauge!("tesla_preconditioning", "Preconditioning",).unwrap();
|
||||
pub static ref REMOTE_HEATER_CONTROL_ENABLED: IntGauge = register_int_gauge!(
|
||||
"tesla_remote_heater_control_enabled",
|
||||
"Remote heater control enabled",
|
||||
)
|
||||
.unwrap();
|
||||
pub static ref IS_AUTO_CONDITIONING_ON: IntGauge =
|
||||
register_int_gauge!("tesla_is_auto_conditioning_on", "Auto conditioning on",).unwrap();
|
||||
pub static ref DRIVER_TEMP_SETTING: Gauge =
|
||||
register_gauge!("tesla_driver_temp_setting", "Driver temp",).unwrap();
|
||||
pub static ref PASSENGER_TEMP_SETTING: Gauge =
|
||||
register_gauge!("tesla_passenger_temp_setting", "Passenger temp",).unwrap();
|
||||
pub static ref TESLA_ONLINE: IntGauge =
|
||||
register_int_gauge!("tesla_online", "Tesla online",).unwrap();
|
||||
pub static ref HOME: IntGauge = register_int_gauge!("tesla_home", "Is home",).unwrap();
|
||||
pub static ref SENTRY_MODE: IntGauge =
|
||||
register_int_gauge!("tesla_sentry_mode", "Sentry mode",).unwrap();
|
||||
pub static ref SENTRY_MODE_AVAILABLE: IntGauge =
|
||||
register_int_gauge!("tesla_sentry_mode_available", "Sentry mode available",).unwrap();
|
||||
pub static ref CHARGER_ACTUAL_CURRENT: IntGauge =
|
||||
register_int_gauge!("tesla_charger_actual_current", "Charger actual current",).unwrap();
|
||||
pub static ref CHARGER_PHASES: IntGauge =
|
||||
register_int_gauge!("tesla_charger_phases", "Charger phases",).unwrap();
|
||||
pub static ref CHARGER_PILOT_CURRENT: IntGauge =
|
||||
register_int_gauge!("tesla_charger_pilot_current", "Charger pilot current",).unwrap();
|
||||
pub static ref CHARGER_POWER: IntGauge =
|
||||
register_int_gauge!("tesla_charger_power", "Charger power",).unwrap();
|
||||
pub static ref CHARGER_VOLTAGE: IntGauge =
|
||||
register_int_gauge!("tesla_charger_voltage", "Charger voltage",).unwrap();
|
||||
pub static ref CHARGING_STATE: IntGaugeVec =
|
||||
register_int_gauge_vec!("tesla_charging_state", "Tesla charging state", &["state"])
|
||||
.unwrap();
|
||||
pub static ref CABIN_OVERHEAT_PROTECTION: IntGaugeVec = register_int_gauge_vec!(
|
||||
"tesla_cabin_overheat_protection_state",
|
||||
"Cabin overheat protection state",
|
||||
&["state"]
|
||||
)
|
||||
.unwrap();
|
||||
pub static ref HVAC_AUTO: IntGaugeVec =
|
||||
register_int_gauge_vec!("tesla_hvac_auto_request", "HVAC auto", &["state"]).unwrap();
|
||||
}
|
||||
|
||||
struct ChargingStateGauges {
|
||||
charging: GenericGauge<AtomicI64>,
|
||||
stopped: GenericGauge<AtomicI64>,
|
||||
disconnected: GenericGauge<AtomicI64>,
|
||||
complete: GenericGauge<AtomicI64>,
|
||||
other: GenericGauge<AtomicI64>,
|
||||
}
|
||||
|
||||
impl ChargingStateGauges {
|
||||
fn new() -> Self {
|
||||
let charging = CHARGING_STATE.with_label_values(&["charging"]);
|
||||
let stopped = CHARGING_STATE.with_label_values(&["stopped"]);
|
||||
let disconnected = CHARGING_STATE.with_label_values(&["disconnected"]);
|
||||
let complete = CHARGING_STATE.with_label_values(&["complete"]);
|
||||
let other = CHARGING_STATE.with_label_values(&["other"]);
|
||||
|
||||
Self {
|
||||
charging,
|
||||
stopped,
|
||||
disconnected,
|
||||
complete,
|
||||
other,
|
||||
}
|
||||
}
|
||||
|
||||
fn set(&mut self, state: ChargingState) {
|
||||
match state {
|
||||
ChargingState::Charging => {
|
||||
self.charging.set(1);
|
||||
self.stopped.set(0);
|
||||
self.disconnected.set(0);
|
||||
self.complete.set(0);
|
||||
self.other.set(0);
|
||||
}
|
||||
ChargingState::Stopped => {
|
||||
self.charging.set(0);
|
||||
self.stopped.set(1);
|
||||
self.disconnected.set(0);
|
||||
self.complete.set(0);
|
||||
self.other.set(0);
|
||||
}
|
||||
ChargingState::Disconnected => {
|
||||
self.charging.set(0);
|
||||
self.stopped.set(0);
|
||||
self.disconnected.set(1);
|
||||
self.complete.set(0);
|
||||
self.other.set(0);
|
||||
}
|
||||
ChargingState::Other => {
|
||||
self.charging.set(0);
|
||||
self.stopped.set(0);
|
||||
self.disconnected.set(0);
|
||||
self.complete.set(0);
|
||||
self.other.set(1);
|
||||
}
|
||||
ChargingState::Complete => {
|
||||
self.charging.set(0);
|
||||
self.stopped.set(0);
|
||||
self.disconnected.set(0);
|
||||
self.complete.set(1);
|
||||
self.other.set(0);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct TeslaInterface {
|
||||
pub state: Arc<RwLock<CarState>>,
|
||||
vehicle: http::Vehicle,
|
||||
charging_state: ChargingStateGauges,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
// these are the messages that the webserver can send the api thread
|
||||
pub enum InterfaceRequest {
|
||||
FlashLights,
|
||||
StopCharge,
|
||||
SetChargeRate(i64),
|
||||
}
|
||||
|
||||
impl TeslaInterface {
|
||||
pub fn new(vin: &str) -> Self {
|
||||
let vehicle = http::Vehicle::new(vin);
|
||||
|
||||
Self {
|
||||
state: Arc::new(RwLock::new(Default::default())),
|
||||
vehicle,
|
||||
charging_state: ChargingStateGauges::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn process_request(&mut self, request: InterfaceRequest) {
|
||||
match request {
|
||||
InterfaceRequest::FlashLights => {
|
||||
let _ = self.vehicle.flash_lights().await;
|
||||
}
|
||||
InterfaceRequest::StopCharge => match self.vehicle.charge_stop().await {
|
||||
Ok(()) => log::warn!("Successfully stopped charge"),
|
||||
Err(e) => log::error!("Error stopping charge: {e:?}"),
|
||||
},
|
||||
InterfaceRequest::SetChargeRate(charging_amps) => {
|
||||
match self.vehicle.set_charging_amps(charging_amps).await {
|
||||
Ok(()) => {}
|
||||
Err(e) => log::error!("Error setting charge rate: {e:?}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn refresh(&mut self) {
|
||||
if let Some(new_charge_state) = self.get_state().await {
|
||||
TESLA_ONLINE.set(1);
|
||||
let mut state = self
|
||||
.state
|
||||
.write()
|
||||
.expect("Tesla API state handler panicked!!");
|
||||
|
||||
BATTERY_LEVEL.set(new_charge_state.battery_level);
|
||||
CHARGE_RATE.set(new_charge_state.charge_rate);
|
||||
CHARGE_REQUEST.set(new_charge_state.charge_current_request);
|
||||
CHARGE_ENABLE_REQUEST.set(bi(new_charge_state.charge_enable_request));
|
||||
CHARGER_CONNECTED.set(bi(
|
||||
new_charge_state.charging_state != ChargingState::Disconnected
|
||||
));
|
||||
self.charging_state.set(new_charge_state.charging_state);
|
||||
if let Some(v) = new_charge_state.charger_actual_current {
|
||||
CHARGER_ACTUAL_CURRENT.set(v);
|
||||
}
|
||||
if let Some(v) = new_charge_state.charger_phases {
|
||||
CHARGER_PHASES.set(v);
|
||||
}
|
||||
if let Some(v) = new_charge_state.charger_pilot_current {
|
||||
CHARGER_PILOT_CURRENT.set(v);
|
||||
}
|
||||
if let Some(v) = new_charge_state.charger_power {
|
||||
CHARGER_POWER.set(v);
|
||||
}
|
||||
if let Some(v) = new_charge_state.charger_voltage {
|
||||
CHARGER_VOLTAGE.set(v);
|
||||
}
|
||||
state.charge_state = Some(new_charge_state);
|
||||
} else {
|
||||
TESLA_ONLINE.set(0);
|
||||
self.charging_state.set(ChargingState::Disconnected);
|
||||
match self.state.write() {
|
||||
Ok(mut state) => {
|
||||
state.charge_state = None;
|
||||
}
|
||||
Err(e) => {
|
||||
log::error!("failed to lock state: {e:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn get_state(&self) -> Option<crate::types::ChargeState> {
|
||||
match self.vehicle.charge_data().await {
|
||||
Ok(v) => Some(v),
|
||||
Err(e) => {
|
||||
if let crate::errors::TeslaError::Reqwest(e) = &e {
|
||||
if e.is_timeout() {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
log::error!("error fetching charge data: {e:?}");
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn bi(value: bool) -> i64 {
|
||||
i64::from(value)
|
||||
}
|
|
@ -1,5 +1,3 @@
|
|||
use super::ChargeState;
|
||||
|
||||
const API_URL: &str = if cfg!(debug_assertions) {
|
||||
"http://cnut.internal.alexjanka.com:4443/api/1"
|
||||
} else {
|
||||
|
@ -13,22 +11,12 @@ pub struct Vehicle {
|
|||
|
||||
#[derive(serde::Deserialize)]
|
||||
struct ApiResponseOuter {
|
||||
response: ApiResponse,
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
#[derive(serde::Deserialize, Debug)]
|
||||
pub struct ApiResponse {
|
||||
pub command: String,
|
||||
pub reason: String,
|
||||
pub response: serde_json::Value,
|
||||
pub result: bool,
|
||||
pub vin: String,
|
||||
response: crate::types::ApiResponse,
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize, Debug)]
|
||||
struct ChargeStateOuter {
|
||||
charge_state: ChargeState,
|
||||
charge_state: crate::types::ChargeState,
|
||||
}
|
||||
|
||||
macro_rules! commands {
|
||||
|
@ -36,7 +24,7 @@ macro_rules! commands {
|
|||
#[allow(dead_code)]
|
||||
impl $v {
|
||||
$(
|
||||
pub async fn $command(&self) -> eyre::Result<()> {
|
||||
pub async fn $command(&self) -> Result<(), Box<dyn std::error::Error>> {
|
||||
self.client
|
||||
.post(format!(
|
||||
"{API_URL}/vehicles/{}/command/{}",
|
||||
|
@ -72,7 +60,9 @@ impl Vehicle {
|
|||
Self { vin, client }
|
||||
}
|
||||
|
||||
pub async fn charge_data(&self) -> eyre::Result<ChargeState> {
|
||||
pub async fn charge_data(
|
||||
&self,
|
||||
) -> Result<crate::types::ChargeState, crate::errors::TeslaError> {
|
||||
log::trace!("getting charge data...");
|
||||
let data = self
|
||||
.client
|
||||
|
@ -83,20 +73,21 @@ impl Vehicle {
|
|||
.timeout(std::time::Duration::from_secs(5))
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
let response: ApiResponseOuter = data.json().await?;
|
||||
let response = response.response;
|
||||
|
||||
if !response.result {
|
||||
return Err(eyre::eyre!("got error response from API: {response:#?}"));
|
||||
return Err(crate::errors::TeslaError::Tesla(response));
|
||||
}
|
||||
let state: ChargeStateOuter = serde_json::from_value(response.response)?;
|
||||
|
||||
Ok(state.charge_state)
|
||||
}
|
||||
|
||||
#[expect(dead_code, reason = "active charge control not yet implemented")]
|
||||
pub async fn set_charging_amps(&self, charging_amps: i64) -> eyre::Result<()> {
|
||||
pub async fn set_charging_amps(
|
||||
&self,
|
||||
charging_amps: i64,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
self.client
|
||||
.post(format!(
|
||||
"{API_URL}/vehicles/{}/command/set_charging_amps",
|
|
@ -41,8 +41,6 @@ lazy_static! {
|
|||
&[PL_LABEL]
|
||||
)
|
||||
.unwrap();
|
||||
pub static ref HEATSINK_TEMP: GaugeVec =
|
||||
register_gauge_vec!("heatsink_temp", "Heatsink temperature", &[TRISTAR_LABEL]).unwrap();
|
||||
pub static ref TRISTAR_INPUT_VOLTAGE: GaugeVec =
|
||||
register_gauge_vec!("tristar_input_voltage", "Input voltage", &[TRISTAR_LABEL]).unwrap();
|
||||
pub static ref TRISTAR_CHARGE_CURRENT: GaugeVec =
|
||||
|
@ -69,16 +67,4 @@ lazy_static! {
|
|||
&[TRISTAR_LABEL]
|
||||
)
|
||||
.unwrap();
|
||||
pub static ref TRISTAR_TOTAL_AH_CHARGE_DAILY: GaugeVec = register_gauge_vec!(
|
||||
"tristar_total_ah_charge_daily",
|
||||
"Total charge daily (Ah)",
|
||||
&[TRISTAR_LABEL]
|
||||
)
|
||||
.unwrap();
|
||||
pub static ref TRISTAR_TOTAL_WH_CHARGE_DAILY: GaugeVec = register_gauge_vec!(
|
||||
"tristar_total_wh_charge_daily",
|
||||
"Total charge daily (Wh)",
|
||||
&[TRISTAR_LABEL]
|
||||
)
|
||||
.unwrap();
|
||||
}
|
7
src/charge_controllers/mod.rs
Normal file
7
src/charge_controllers/mod.rs
Normal file
|
@ -0,0 +1,7 @@
|
|||
pub mod gauges;
|
||||
pub mod pl;
|
||||
pub mod tristar;
|
||||
|
||||
pub const CHARGE_CONTROLLER_LABEL: &str = "charge_controller";
|
||||
pub const PL_LABEL: &str = "pl_device";
|
||||
pub const TRISTAR_LABEL: &str = "tristar_device";
|
310
src/charge_controllers/pl.rs
Normal file
310
src/charge_controllers/pl.rs
Normal file
|
@ -0,0 +1,310 @@
|
|||
use std::{
|
||||
io::Write,
|
||||
sync::{Arc, RwLock},
|
||||
time::Duration,
|
||||
};
|
||||
|
||||
use chrono::Timelike;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serialport::SerialPort;
|
||||
|
||||
use crate::{
|
||||
charge_controllers::gauges::{
|
||||
BATTERY_TEMP, BATTERY_VOLTAGE, CHARGE_STATE, INPUT_CURRENT, PL_DUTY_CYCLE, PL_LOAD_CURRENT,
|
||||
TARGET_VOLTAGE,
|
||||
},
|
||||
errors::{PliError, PrintErrors},
|
||||
};
|
||||
|
||||
pub struct Pli {
|
||||
pub state: Arc<RwLock<PlState>>,
|
||||
port_name: String,
|
||||
port: Box<dyn SerialPort>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, Default)]
|
||||
pub struct PlState {
|
||||
pub battery_voltage: f64,
|
||||
pub target_voltage: f64,
|
||||
pub duty_cycle: f64,
|
||||
pub internal_charge_current: f64,
|
||||
pub internal_load_current: f64,
|
||||
pub battery_temp: f64,
|
||||
pub regulator_state: RegulatorState,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
|
||||
pub enum RegulatorState {
|
||||
Boost,
|
||||
Equalise,
|
||||
Absorption,
|
||||
Float,
|
||||
}
|
||||
|
||||
impl From<u8> for RegulatorState {
|
||||
fn from(value: u8) -> Self {
|
||||
match value & 0b11 {
|
||||
0b00 => Self::Boost,
|
||||
0b01 => Self::Equalise,
|
||||
0b10 => Self::Absorption,
|
||||
0b11 => Self::Float,
|
||||
_ => unreachable!(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<RegulatorState> for u8 {
|
||||
fn from(value: RegulatorState) -> Self {
|
||||
match value {
|
||||
RegulatorState::Boost => 0b00,
|
||||
RegulatorState::Equalise => 0b01,
|
||||
RegulatorState::Absorption => 0b10,
|
||||
RegulatorState::Float => 0b11,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for RegulatorState {
|
||||
fn default() -> Self {
|
||||
Self::Absorption
|
||||
}
|
||||
}
|
||||
|
||||
fn set_regulator_gauges(state: RegulatorState, label: &str) {
|
||||
let boost = CHARGE_STATE.with_label_values(&[label, "boost"]);
|
||||
let equalise = CHARGE_STATE.with_label_values(&[label, "equalise"]);
|
||||
let absorption = CHARGE_STATE.with_label_values(&[label, "absorption"]);
|
||||
let float = CHARGE_STATE.with_label_values(&[label, "float"]);
|
||||
|
||||
match state {
|
||||
RegulatorState::Boost => {
|
||||
boost.set(1);
|
||||
equalise.set(0);
|
||||
absorption.set(0);
|
||||
float.set(0);
|
||||
}
|
||||
RegulatorState::Equalise => {
|
||||
boost.set(0);
|
||||
equalise.set(1);
|
||||
absorption.set(0);
|
||||
float.set(0);
|
||||
}
|
||||
RegulatorState::Absorption => {
|
||||
boost.set(0);
|
||||
equalise.set(0);
|
||||
absorption.set(1);
|
||||
float.set(0);
|
||||
}
|
||||
RegulatorState::Float => {
|
||||
boost.set(0);
|
||||
equalise.set(0);
|
||||
absorption.set(0);
|
||||
float.set(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub enum PliRequest {
|
||||
ReadRam(u8),
|
||||
ReadEeprom(u8),
|
||||
SyncTime,
|
||||
SetRegulatorState(RegulatorState),
|
||||
}
|
||||
|
||||
impl Pli {
|
||||
pub fn new(
|
||||
serial_port: String,
|
||||
baud_rate: u32,
|
||||
timeout: u64,
|
||||
) -> Result<Self, serialport::Error> {
|
||||
let port = serialport::new(serial_port.clone(), baud_rate)
|
||||
.timeout(Duration::from_millis(timeout))
|
||||
.open()?;
|
||||
|
||||
Ok(Self {
|
||||
state: Arc::new(RwLock::new(Default::default())),
|
||||
port_name: serial_port,
|
||||
port,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn refresh(&mut self) {
|
||||
if let Some(new_state) = self.read_state().some_or_print_with("reading pl state") {
|
||||
BATTERY_VOLTAGE
|
||||
.with_label_values(&[&self.port_name])
|
||||
.set(new_state.battery_voltage);
|
||||
TARGET_VOLTAGE
|
||||
.with_label_values(&[&self.port_name])
|
||||
.set(new_state.target_voltage);
|
||||
PL_DUTY_CYCLE
|
||||
.with_label_values(&[&self.port_name])
|
||||
.set(new_state.duty_cycle);
|
||||
INPUT_CURRENT
|
||||
.with_label_values(&[&self.port_name])
|
||||
.set(new_state.internal_charge_current);
|
||||
PL_LOAD_CURRENT
|
||||
.with_label_values(&[&self.port_name])
|
||||
.set(new_state.internal_load_current);
|
||||
BATTERY_TEMP
|
||||
.with_label_values(&[&self.port_name])
|
||||
.set(new_state.battery_temp);
|
||||
|
||||
set_regulator_gauges(new_state.regulator_state, &self.port_name);
|
||||
|
||||
*self.state.write().expect("PLI state handler panicked!!") = new_state;
|
||||
}
|
||||
}
|
||||
|
||||
pub fn process_request(&mut self, message: PliRequest) {
|
||||
match message {
|
||||
PliRequest::ReadRam(address) => {
|
||||
if let Some(data) = self.read_ram(address).some_or_print_with("reading pl ram") {
|
||||
log::warn!("Read RAM at {address}: data {data}");
|
||||
}
|
||||
}
|
||||
PliRequest::ReadEeprom(address) => {
|
||||
if let Some(data) = self
|
||||
.read_eeprom(address)
|
||||
.some_or_print_with("reading pl eeprom")
|
||||
{
|
||||
log::warn!("Read EEPROM at {address}: data {data}");
|
||||
}
|
||||
}
|
||||
PliRequest::SyncTime => {
|
||||
let now = chrono::Local::now();
|
||||
let timestamp = (((now.hour() * 10) + (now.minute() / 6)) & 0xFF) as u8;
|
||||
let min = (now.minute() % 6) as u8;
|
||||
let sec = (now.second() / 2).min(29) as u8;
|
||||
if self
|
||||
.write_ram(PlRamAddress::Hour, timestamp)
|
||||
.some_or_print_with("Setting time")
|
||||
.is_none()
|
||||
{
|
||||
return;
|
||||
}
|
||||
if self
|
||||
.write_ram(PlRamAddress::Min, min)
|
||||
.some_or_print_with("Setting time (minutes)")
|
||||
.is_none()
|
||||
{
|
||||
return;
|
||||
}
|
||||
let _ = self
|
||||
.write_ram(PlRamAddress::Sec, sec)
|
||||
.some_or_print_with("Setting time (seconds)");
|
||||
log::warn!(
|
||||
"Set time: {now} corresponds to {timestamp} + minutes {min} + seconds {sec}"
|
||||
);
|
||||
}
|
||||
PliRequest::SetRegulatorState(state) => {
|
||||
log::warn!("Setting regulator state to {state:?}");
|
||||
self.write_ram(PlRamAddress::Rstate, state.into())
|
||||
.some_or_print_with("setting regulator state");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn read_state(&mut self) -> Result<PlState, PliError> {
|
||||
Ok(PlState {
|
||||
battery_voltage: f64::from(self.read_ram(PlRamAddress::Batv)?) * (4. / 10.),
|
||||
target_voltage: f64::from(self.read_ram(PlRamAddress::Vreg)?) * (4. / 10.),
|
||||
duty_cycle: f64::from(self.read_ram(PlRamAddress::Dutycyc)?) / 255.,
|
||||
internal_charge_current: f64::from(self.read_ram(PlRamAddress::Cint)?) * (4. / 10.),
|
||||
internal_load_current: f64::from(self.read_ram(PlRamAddress::Lint)?) * (4. / 10.),
|
||||
battery_temp: f64::from(self.read_ram(PlRamAddress::Battemp)?),
|
||||
regulator_state: self.read_ram(PlRamAddress::Rstate)?.into(),
|
||||
})
|
||||
}
|
||||
|
||||
fn send_command(&mut self, req: [u8; 4]) -> Result<(), PliError> {
|
||||
self.flush()?;
|
||||
self.port.write_all(&req)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn flush(&mut self) -> Result<(), PliError> {
|
||||
self.port.flush()?;
|
||||
while let Ok(num) = self.port.bytes_to_read() {
|
||||
if num == 0 {
|
||||
return Ok(());
|
||||
}
|
||||
let _ = self.port.read(&mut [0; 8]);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn receive<const LENGTH: usize>(&mut self) -> Result<[u8; LENGTH], PliError> {
|
||||
let mut buf = [0; LENGTH];
|
||||
self.port.read_exact(&mut buf)?;
|
||||
Ok(buf)
|
||||
}
|
||||
|
||||
fn read_ram<T>(&mut self, address: T) -> Result<u8, PliError>
|
||||
where
|
||||
T: Into<u8>,
|
||||
{
|
||||
self.send_command(command(20, address.into(), 0))?;
|
||||
let buf = self.receive::<1>()?;
|
||||
if buf[0] == 200 {
|
||||
Ok(self.receive::<1>()?[0])
|
||||
} else {
|
||||
Err(PliError::ReadError(buf[0]))
|
||||
}
|
||||
}
|
||||
|
||||
fn write_ram<T>(&mut self, address: T, data: u8) -> Result<(), PliError>
|
||||
where
|
||||
T: Into<u8>,
|
||||
{
|
||||
self.send_command(command(152, address.into(), data))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn read_eeprom<T>(&mut self, address: T) -> Result<u8, PliError>
|
||||
where
|
||||
T: Into<u8>,
|
||||
{
|
||||
self.send_command(command(72, address.into(), 0))?;
|
||||
let buf = self.receive::<1>()?;
|
||||
if buf[0] == 200 {
|
||||
Ok(self.receive::<1>()?[0])
|
||||
} else {
|
||||
Err(PliError::ReadError(buf[0]))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum PlRamAddress {
|
||||
Dutycyc,
|
||||
Sec,
|
||||
Min,
|
||||
Hour,
|
||||
Batv,
|
||||
Battemp,
|
||||
Rstate,
|
||||
Vreg,
|
||||
Cint,
|
||||
Lint,
|
||||
}
|
||||
|
||||
impl From<PlRamAddress> for u8 {
|
||||
fn from(value: PlRamAddress) -> Self {
|
||||
match value {
|
||||
PlRamAddress::Dutycyc => 39,
|
||||
PlRamAddress::Sec => 46,
|
||||
PlRamAddress::Min => 47,
|
||||
PlRamAddress::Hour => 48,
|
||||
PlRamAddress::Batv => 50,
|
||||
PlRamAddress::Battemp => 52,
|
||||
PlRamAddress::Rstate => 101,
|
||||
PlRamAddress::Vreg => 105,
|
||||
PlRamAddress::Cint => 213,
|
||||
PlRamAddress::Lint => 217,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn command(operation: u8, address: u8, data: u8) -> [u8; 4] {
|
||||
[operation, address, data, !operation]
|
||||
}
|
332
src/charge_controllers/tristar.rs
Normal file
332
src/charge_controllers/tristar.rs
Normal file
|
@ -0,0 +1,332 @@
|
|||
use prometheus::core::{AtomicI64, GenericGauge};
|
||||
use tokio_modbus::client::Reader;
|
||||
|
||||
use crate::{
|
||||
charge_controllers::gauges::{
|
||||
BATTERY_TEMP, BATTERY_VOLTAGE, CHARGE_STATE, INPUT_CURRENT, TARGET_VOLTAGE,
|
||||
TRISTAR_CHARGE_CURRENT, TRISTAR_INPUT_VOLTAGE, TRISTAR_MAX_ARRAY_POWER,
|
||||
TRISTAR_MAX_ARRAY_VOLTAGE, TRISTAR_OPEN_CIRCUIT_VOLTAGE, TRISTAR_POWER_IN,
|
||||
TRISTAR_POWER_OUT,
|
||||
},
|
||||
errors::{PrintErrors, TristarError},
|
||||
};
|
||||
|
||||
const DEVICE_ID: u8 = 0x01;
|
||||
const RAM_DATA_SIZE: u16 = 0x005B;
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct Scaling {
|
||||
pub v_scale: f64,
|
||||
pub i_scale: f64,
|
||||
}
|
||||
|
||||
impl Scaling {
|
||||
fn from(data: &[u16]) -> Self {
|
||||
Self::from_internal(data[0], data[1], data[2], data[3])
|
||||
}
|
||||
|
||||
fn from_internal(v_pu_hi: u16, v_pu_lo: u16, i_pu_hi: u16, i_pu_lo: u16) -> Self {
|
||||
Self {
|
||||
v_scale: f64::from(v_pu_hi) + (f64::from(v_pu_lo) / f64::powf(2., 16.)),
|
||||
i_scale: f64::from(i_pu_hi) + (f64::from(i_pu_lo) / f64::powf(2., 16.)),
|
||||
}
|
||||
}
|
||||
|
||||
fn get_voltage(&self, data: u16) -> f64 {
|
||||
f64::from(data) * self.v_scale * f64::powf(2., -15.)
|
||||
}
|
||||
|
||||
fn get_current(&self, data: u16) -> f64 {
|
||||
f64::from(data) * self.i_scale * f64::powf(2., -15.)
|
||||
}
|
||||
|
||||
fn get_power(&self, data: u16) -> f64 {
|
||||
f64::from(data) * self.v_scale * self.i_scale * f64::powf(2., -17.)
|
||||
}
|
||||
}
|
||||
|
||||
pub struct Tristar {
|
||||
state: TristarState,
|
||||
port_name: String,
|
||||
modbus: tokio_modbus::client::Context,
|
||||
charge_state_gauges: ChargeStateGauges,
|
||||
consecutive_errors: usize,
|
||||
}
|
||||
|
||||
#[derive(Default, Debug, Clone, Copy)]
|
||||
pub struct TristarState {
|
||||
battery_voltage: f64,
|
||||
target_voltage: f64,
|
||||
input_current: f64,
|
||||
battery_temp: u16,
|
||||
charge_state: ChargeState,
|
||||
tristar_input_voltage: f64,
|
||||
tristar_charge_current: f64,
|
||||
tristar_power_out: f64,
|
||||
tristar_power_in: f64,
|
||||
tristar_max_array_power: f64,
|
||||
tristar_max_array_voltage: f64,
|
||||
tristar_open_circuit_voltage: f64,
|
||||
}
|
||||
|
||||
impl TristarState {
|
||||
fn from_ram(ram: &[u16]) -> Self {
|
||||
let scaling = Scaling::from(ram);
|
||||
Self {
|
||||
battery_voltage: scaling.get_voltage(ram[TristarRamAddress::AdcVbFMed]),
|
||||
target_voltage: scaling.get_voltage(ram[TristarRamAddress::VbRef]),
|
||||
input_current: scaling.get_current(ram[TristarRamAddress::AdcIaFShadow]),
|
||||
battery_temp: ram[TristarRamAddress::Tbatt],
|
||||
charge_state: ChargeState::from(ram[TristarRamAddress::ChargeState]),
|
||||
tristar_input_voltage: scaling.get_voltage(ram[TristarRamAddress::AdcVaF]),
|
||||
tristar_charge_current: scaling.get_current(ram[TristarRamAddress::AdcIbFShadow]),
|
||||
tristar_power_out: scaling.get_power(ram[TristarRamAddress::PowerOutShadow]),
|
||||
tristar_power_in: scaling.get_power(ram[TristarRamAddress::PowerInShadow]),
|
||||
tristar_max_array_power: scaling.get_power(ram[TristarRamAddress::SweepPinMax]),
|
||||
tristar_max_array_voltage: scaling.get_voltage(ram[TristarRamAddress::SweepVmp]),
|
||||
tristar_open_circuit_voltage: scaling.get_voltage(ram[TristarRamAddress::SweepVoc]),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
enum ChargeState {
|
||||
Start,
|
||||
NightCheck,
|
||||
Disconnect,
|
||||
Night,
|
||||
Fault,
|
||||
Mppt,
|
||||
Absorption,
|
||||
Float,
|
||||
Equalize,
|
||||
Slave,
|
||||
Unknown,
|
||||
}
|
||||
|
||||
impl Default for ChargeState {
|
||||
fn default() -> Self {
|
||||
Self::Unknown
|
||||
}
|
||||
}
|
||||
|
||||
impl From<u16> for ChargeState {
|
||||
fn from(value: u16) -> Self {
|
||||
match value {
|
||||
0 => Self::Start,
|
||||
1 => Self::NightCheck,
|
||||
2 => Self::Disconnect,
|
||||
3 => Self::Night,
|
||||
4 => Self::Fault,
|
||||
5 => Self::Mppt,
|
||||
6 => Self::Absorption,
|
||||
7 => Self::Float,
|
||||
8 => Self::Equalize,
|
||||
9 => Self::Slave,
|
||||
_ => {
|
||||
log::warn!("Unknown chargestate value: {value}");
|
||||
Self::Unknown
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct ChargeStateGauges {
|
||||
start: GenericGauge<AtomicI64>,
|
||||
night_check: GenericGauge<AtomicI64>,
|
||||
disconnect: GenericGauge<AtomicI64>,
|
||||
night: GenericGauge<AtomicI64>,
|
||||
fault: GenericGauge<AtomicI64>,
|
||||
mppt: GenericGauge<AtomicI64>,
|
||||
absorption: GenericGauge<AtomicI64>,
|
||||
float: GenericGauge<AtomicI64>,
|
||||
equalize: GenericGauge<AtomicI64>,
|
||||
slave: GenericGauge<AtomicI64>,
|
||||
unknown: GenericGauge<AtomicI64>,
|
||||
}
|
||||
|
||||
impl ChargeStateGauges {
|
||||
fn new(label: &str) -> Self {
|
||||
let start = CHARGE_STATE.with_label_values(&[label, "start"]);
|
||||
let night_check = CHARGE_STATE.with_label_values(&[label, "night_check"]);
|
||||
let disconnect = CHARGE_STATE.with_label_values(&[label, "disconnect"]);
|
||||
let night = CHARGE_STATE.with_label_values(&[label, "night"]);
|
||||
let fault = CHARGE_STATE.with_label_values(&[label, "fault"]);
|
||||
let mppt = CHARGE_STATE.with_label_values(&[label, "mppt"]);
|
||||
let absorption = CHARGE_STATE.with_label_values(&[label, "absorption"]);
|
||||
let float = CHARGE_STATE.with_label_values(&[label, "float"]);
|
||||
let equalize = CHARGE_STATE.with_label_values(&[label, "equalize"]);
|
||||
let slave = CHARGE_STATE.with_label_values(&[label, "slave"]);
|
||||
let unknown = CHARGE_STATE.with_label_values(&[label, "unknown"]);
|
||||
Self {
|
||||
start,
|
||||
night_check,
|
||||
disconnect,
|
||||
night,
|
||||
fault,
|
||||
mppt,
|
||||
absorption,
|
||||
float,
|
||||
equalize,
|
||||
slave,
|
||||
unknown,
|
||||
}
|
||||
}
|
||||
|
||||
fn zero_all(&mut self) {
|
||||
self.start.set(0);
|
||||
self.night_check.set(0);
|
||||
self.disconnect.set(0);
|
||||
self.night.set(0);
|
||||
self.fault.set(0);
|
||||
self.mppt.set(0);
|
||||
self.absorption.set(0);
|
||||
self.float.set(0);
|
||||
self.equalize.set(0);
|
||||
self.slave.set(0);
|
||||
self.unknown.set(0);
|
||||
}
|
||||
|
||||
fn set(&mut self, state: ChargeState) {
|
||||
match state {
|
||||
ChargeState::Start => {
|
||||
self.zero_all();
|
||||
self.start.set(1);
|
||||
}
|
||||
ChargeState::NightCheck => {
|
||||
self.zero_all();
|
||||
self.night_check.set(1);
|
||||
}
|
||||
ChargeState::Disconnect => {
|
||||
self.zero_all();
|
||||
self.disconnect.set(1);
|
||||
}
|
||||
ChargeState::Night => {
|
||||
self.zero_all();
|
||||
self.night.set(1);
|
||||
}
|
||||
ChargeState::Fault => {
|
||||
self.zero_all();
|
||||
self.fault.set(1);
|
||||
}
|
||||
ChargeState::Mppt => {
|
||||
self.zero_all();
|
||||
self.mppt.set(1);
|
||||
}
|
||||
ChargeState::Absorption => {
|
||||
self.zero_all();
|
||||
self.absorption.set(1);
|
||||
}
|
||||
ChargeState::Float => {
|
||||
self.zero_all();
|
||||
self.float.set(1);
|
||||
}
|
||||
ChargeState::Equalize => {
|
||||
self.zero_all();
|
||||
self.equalize.set(1);
|
||||
}
|
||||
ChargeState::Slave => {
|
||||
self.zero_all();
|
||||
self.slave.set(1);
|
||||
}
|
||||
ChargeState::Unknown => {
|
||||
self.zero_all();
|
||||
self.unknown.set(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Tristar {
|
||||
pub fn new(serial_port: String, baud_rate: u32) -> Result<Self, TristarError> {
|
||||
let modbus_serial =
|
||||
tokio_serial::SerialStream::open(&tokio_serial::new(&serial_port, baud_rate))?;
|
||||
let slave = tokio_modbus::Slave(DEVICE_ID);
|
||||
let modbus = tokio_modbus::client::rtu::attach_slave(modbus_serial, slave);
|
||||
let charge_state_gauges = ChargeStateGauges::new(&serial_port);
|
||||
Ok(Self {
|
||||
state: Default::default(),
|
||||
port_name: serial_port,
|
||||
modbus,
|
||||
charge_state_gauges,
|
||||
consecutive_errors: 0,
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn refresh(&mut self) {
|
||||
if let Some(new_state) = self
|
||||
.get_data()
|
||||
.await
|
||||
.some_or_print_with("reading tristar state")
|
||||
{
|
||||
self.consecutive_errors = 0;
|
||||
BATTERY_VOLTAGE
|
||||
.with_label_values(&[&self.port_name])
|
||||
.set(new_state.battery_voltage);
|
||||
TARGET_VOLTAGE
|
||||
.with_label_values(&[&self.port_name])
|
||||
.set(new_state.target_voltage);
|
||||
INPUT_CURRENT
|
||||
.with_label_values(&[&self.port_name])
|
||||
.set(new_state.input_current);
|
||||
BATTERY_TEMP
|
||||
.with_label_values(&[&self.port_name])
|
||||
.set(new_state.battery_temp.into());
|
||||
TRISTAR_INPUT_VOLTAGE
|
||||
.with_label_values(&[&self.port_name])
|
||||
.set(new_state.tristar_input_voltage);
|
||||
TRISTAR_CHARGE_CURRENT
|
||||
.with_label_values(&[&self.port_name])
|
||||
.set(new_state.tristar_charge_current);
|
||||
TRISTAR_POWER_OUT
|
||||
.with_label_values(&[&self.port_name])
|
||||
.set(new_state.tristar_power_out);
|
||||
TRISTAR_POWER_IN
|
||||
.with_label_values(&[&self.port_name])
|
||||
.set(new_state.tristar_power_in);
|
||||
TRISTAR_MAX_ARRAY_POWER
|
||||
.with_label_values(&[&self.port_name])
|
||||
.set(new_state.tristar_max_array_power);
|
||||
TRISTAR_MAX_ARRAY_VOLTAGE
|
||||
.with_label_values(&[&self.port_name])
|
||||
.set(new_state.tristar_max_array_voltage);
|
||||
TRISTAR_OPEN_CIRCUIT_VOLTAGE
|
||||
.with_label_values(&[&self.port_name])
|
||||
.set(new_state.tristar_open_circuit_voltage);
|
||||
|
||||
self.charge_state_gauges.set(new_state.charge_state);
|
||||
self.state = new_state;
|
||||
}
|
||||
}
|
||||
|
||||
async fn get_data(&mut self) -> Result<TristarState, TristarError> {
|
||||
let data = self
|
||||
.modbus
|
||||
.read_holding_registers(0x0000, RAM_DATA_SIZE + 1)
|
||||
.await??;
|
||||
Ok(TristarState::from_ram(&data))
|
||||
}
|
||||
}
|
||||
|
||||
enum TristarRamAddress {
|
||||
AdcVbFMed = 0x0018,
|
||||
AdcVaF = 0x001B,
|
||||
AdcIbFShadow = 0x001C,
|
||||
AdcIaFShadow = 0x001D,
|
||||
Tbatt = 0x0025,
|
||||
ChargeState = 0x0032,
|
||||
VbRef = 0x0033,
|
||||
PowerOutShadow = 0x003A,
|
||||
PowerInShadow = 0x003B,
|
||||
SweepPinMax = 0x003C,
|
||||
SweepVmp = 0x003D,
|
||||
SweepVoc = 0x003E,
|
||||
}
|
||||
|
||||
impl std::ops::Index<TristarRamAddress> for [u16] {
|
||||
type Output = u16;
|
||||
|
||||
fn index(&self, index: TristarRamAddress) -> &Self::Output {
|
||||
&self[index as usize]
|
||||
}
|
||||
}
|
|
@ -1,37 +1,47 @@
|
|||
use std::{
|
||||
ops::DerefMut,
|
||||
path::{Path, PathBuf},
|
||||
sync::{OnceLock, RwLock, RwLockReadGuard, RwLockWriteGuard},
|
||||
time::Duration,
|
||||
};
|
||||
|
||||
use notify_debouncer_mini::{new_debouncer, notify::RecommendedWatcher, Debouncer};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tokio::task::JoinHandle;
|
||||
|
||||
static CONFIG_PATH: std::sync::OnceLock<std::path::PathBuf> = std::sync::OnceLock::new();
|
||||
pub(super) static CONFIG: std::sync::OnceLock<tokio::sync::RwLock<Config>> =
|
||||
std::sync::OnceLock::new();
|
||||
use crate::errors::{ConfigError, PrintErrors};
|
||||
|
||||
pub async fn access_config<'a>() -> tokio::sync::RwLockReadGuard<'a, Config> {
|
||||
CONFIG.get().unwrap().read().await
|
||||
static CONFIG_PATH: OnceLock<PathBuf> = OnceLock::new();
|
||||
pub(super) static CONFIG: OnceLock<RwLock<Config>> = OnceLock::new();
|
||||
|
||||
pub fn access_config<'a>() -> RwLockReadGuard<'a, Config> {
|
||||
CONFIG.get().unwrap().read().unwrap()
|
||||
}
|
||||
|
||||
pub(super) struct ConfigWatcher {
|
||||
_debouncer: notify_debouncer_mini::Debouncer<notify_debouncer_mini::notify::RecommendedWatcher>,
|
||||
_handle: tokio::task::JoinHandle<()>,
|
||||
_debouncer: Debouncer<RecommendedWatcher>,
|
||||
_handle: JoinHandle<()>,
|
||||
}
|
||||
|
||||
impl ConfigWatcher {
|
||||
pub fn new(path: impl AsRef<std::path::Path>) -> Option<Self> {
|
||||
pub fn new(path: &Path) -> Option<Self> {
|
||||
let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
|
||||
|
||||
let mut debouncer =
|
||||
notify_debouncer_mini::new_debouncer(std::time::Duration::from_secs(1), move |v| {
|
||||
tx.send(v).unwrap();
|
||||
})
|
||||
.ok()?;
|
||||
let mut debouncer = new_debouncer(Duration::from_secs(1), move |v| {
|
||||
tx.send(v)
|
||||
.some_or_print_with("Failed to send event to queue");
|
||||
})
|
||||
.ok()?;
|
||||
|
||||
debouncer
|
||||
.watcher()
|
||||
.watch(
|
||||
path.as_ref(),
|
||||
path,
|
||||
notify_debouncer_mini::notify::RecursiveMode::NonRecursive,
|
||||
)
|
||||
.ok()?;
|
||||
|
||||
let config_path = std::path::PathBuf::from(path.as_ref());
|
||||
let config_path = PathBuf::from(path);
|
||||
|
||||
let handle = tokio::task::spawn(async move {
|
||||
loop {
|
||||
|
@ -39,9 +49,10 @@ impl ConfigWatcher {
|
|||
Some(Ok(_event)) => {
|
||||
let mut config = Config::load(&config_path);
|
||||
config.validate();
|
||||
|
||||
if let Err(e) = overwrite_config(config).await {
|
||||
log::error!("{e:?}");
|
||||
if let Some(mut c) = CONFIG.get().and_then(|v| v.write().ok()) {
|
||||
*c = config;
|
||||
} else {
|
||||
log::error!("Reloading config: got notified, but failed to lock");
|
||||
}
|
||||
}
|
||||
Some(Err(e)) => log::error!("Error {e:?} from watcher"),
|
||||
|
@ -57,39 +68,28 @@ impl ConfigWatcher {
|
|||
}
|
||||
}
|
||||
|
||||
async fn overwrite_config(config: Config) -> eyre::Result<()> {
|
||||
*CONFIG
|
||||
.get()
|
||||
.ok_or_else(|| eyre::eyre!("could not get config"))?
|
||||
.write()
|
||||
.await = config;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn init_config(path: impl AsRef<std::path::Path>) -> Option<ConfigWatcher> {
|
||||
pub fn init_config(path: PathBuf) -> (Config, Option<ConfigWatcher>) {
|
||||
log::trace!("loading config...");
|
||||
let config = Config::load_and_save_defaults(&path);
|
||||
log::trace!("watching config for changes...");
|
||||
let config_watcher = ConfigWatcher::new(&path);
|
||||
let _ = CONFIG_PATH.get_or_init(|| path.as_ref().to_path_buf());
|
||||
CONFIG.set(tokio::sync::RwLock::new(config)).unwrap();
|
||||
|
||||
config_watcher
|
||||
let _ = CONFIG_PATH.get_or_init(|| path);
|
||||
(config, config_watcher)
|
||||
}
|
||||
|
||||
pub struct ConfigHandle<'a> {
|
||||
handle: tokio::sync::RwLockWriteGuard<'a, Config>,
|
||||
handle: RwLockWriteGuard<'a, Config>,
|
||||
}
|
||||
|
||||
impl<'a> core::ops::Deref for ConfigHandle<'a> {
|
||||
type Target = tokio::sync::RwLockWriteGuard<'a, Config>;
|
||||
type Target = RwLockWriteGuard<'a, Config>;
|
||||
|
||||
fn deref(&self) -> &Self::Target {
|
||||
&self.handle
|
||||
}
|
||||
}
|
||||
|
||||
impl std::ops::DerefMut for ConfigHandle<'_> {
|
||||
impl DerefMut for ConfigHandle<'_> {
|
||||
fn deref_mut(&mut self) -> &mut Self::Target {
|
||||
&mut self.handle
|
||||
}
|
||||
|
@ -97,15 +97,13 @@ impl std::ops::DerefMut for ConfigHandle<'_> {
|
|||
|
||||
impl Drop for ConfigHandle<'_> {
|
||||
fn drop(&mut self) {
|
||||
if let Err(e) = self.save() {
|
||||
log::error!("error saving config on drop of handle: {e:?}");
|
||||
}
|
||||
let _ = self.save().some_or_print_with("saving config");
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn write_to_config<'a>() -> ConfigHandle<'a> {
|
||||
pub fn write_to_config<'a>() -> ConfigHandle<'a> {
|
||||
ConfigHandle {
|
||||
handle: CONFIG.get().unwrap().write().await,
|
||||
handle: CONFIG.get().unwrap().write().unwrap(),
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -115,32 +113,18 @@ pub struct Config {
|
|||
pub car_vin: String,
|
||||
pub control_enable: bool,
|
||||
pub tesla_update_interval_seconds: u64,
|
||||
pub pl_watch_interval_seconds: u64,
|
||||
pub pl_timeout_milliseconds: u64,
|
||||
pub serial_port: String,
|
||||
pub baud_rate: u32,
|
||||
pub shutoff_voltage: f64,
|
||||
pub shutoff_voltage_time_seconds: u64,
|
||||
pub min_rate: i64,
|
||||
pub max_rate: i64,
|
||||
pub duty_cycle_too_high: f64,
|
||||
pub duty_cycle_too_low: f64,
|
||||
pub additional_charge_controllers: Vec<ChargeControllerConfig>,
|
||||
pub pid_controls: PidControls,
|
||||
pub car_state_interval: std::time::Duration,
|
||||
pub car_state_expiry: std::time::Duration,
|
||||
}
|
||||
|
||||
impl Default for Config {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
car_vin: String::from("unknown"),
|
||||
control_enable: false,
|
||||
tesla_update_interval_seconds: 120,
|
||||
baud_rate: 9600,
|
||||
shutoff_voltage: 50.,
|
||||
shutoff_voltage_time_seconds: 15,
|
||||
min_rate: 5,
|
||||
max_rate: 10,
|
||||
pid_controls: Default::default(),
|
||||
car_state_interval: std::time::Duration::from_secs(20),
|
||||
car_state_expiry: std::time::Duration::from_secs(30),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Copy, Clone, Debug, PartialEq)]
|
||||
|
@ -163,24 +147,61 @@ impl Default for PidControls {
|
|||
}
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)]
|
||||
pub enum ChargeControllerConfig {
|
||||
Pl {
|
||||
serial_port: String,
|
||||
baud_rate: u32,
|
||||
timeout_milliseconds: u64,
|
||||
watch_interval_seconds: u64,
|
||||
},
|
||||
Tristar {
|
||||
serial_port: String,
|
||||
baud_rate: u32,
|
||||
watch_interval_seconds: u64,
|
||||
},
|
||||
}
|
||||
|
||||
impl Default for Config {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
car_vin: String::from("unknown"),
|
||||
control_enable: false,
|
||||
tesla_update_interval_seconds: 120,
|
||||
pl_watch_interval_seconds: 5,
|
||||
pl_timeout_milliseconds: 400,
|
||||
serial_port: String::from("/dev/ttyUSB0"),
|
||||
baud_rate: 9600,
|
||||
shutoff_voltage: 50.,
|
||||
shutoff_voltage_time_seconds: 15,
|
||||
min_rate: 5,
|
||||
max_rate: 10,
|
||||
duty_cycle_too_high: 0.8,
|
||||
duty_cycle_too_low: 0.1,
|
||||
additional_charge_controllers: Vec::new(),
|
||||
pid_controls: Default::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Config {
|
||||
fn load(path: impl AsRef<std::path::Path>) -> Self {
|
||||
fn load(path: &Path) -> Self {
|
||||
serde_json::from_str(&std::fs::read_to_string(path).unwrap()).unwrap()
|
||||
}
|
||||
|
||||
fn save_to(&self, path: impl AsRef<std::path::Path>) -> eyre::Result<()> {
|
||||
fn save_to(&self, path: &Path) -> Result<(), ConfigError> {
|
||||
Ok(serde_json::ser::to_writer_pretty(
|
||||
std::io::BufWriter::new(std::fs::File::create(path)?),
|
||||
self,
|
||||
)?)
|
||||
}
|
||||
|
||||
fn save(&self) -> eyre::Result<()> {
|
||||
fn save(&self) -> Result<(), ConfigError> {
|
||||
self.save_to(CONFIG_PATH.get().unwrap())
|
||||
}
|
||||
|
||||
fn load_and_save_defaults(path: impl AsRef<std::path::Path>) -> Self {
|
||||
let mut config = Self::load(&path);
|
||||
fn load_and_save_defaults(path: &Path) -> Self {
|
||||
let mut config = Self::load(path);
|
||||
config.validate();
|
||||
let result = config.save_to(path);
|
||||
if let Err(e) = result {
|
||||
|
@ -193,8 +214,8 @@ impl Config {
|
|||
self.shutoff_voltage = self.shutoff_voltage.clamp(40.0, 60.0);
|
||||
self.max_rate = self.max_rate.clamp(0, 30);
|
||||
self.min_rate = self.min_rate.clamp(0, self.max_rate);
|
||||
// self.duty_cycle_too_high = self.duty_cycle_too_high.clamp(0.0, 1.0);
|
||||
// self.duty_cycle_too_low = self.duty_cycle_too_low.clamp(0.0, 1.0);
|
||||
self.duty_cycle_too_high = self.duty_cycle_too_high.clamp(0.0, 1.0);
|
||||
self.duty_cycle_too_low = self.duty_cycle_too_low.clamp(0.0, 1.0);
|
||||
self.pid_controls.proportional_gain = self.pid_controls.proportional_gain.clamp(0.0, 50.0);
|
||||
self.pid_controls.derivative_gain = self.pid_controls.derivative_gain.clamp(0.0, 50.0);
|
||||
self.pid_controls.load_divisor = self.pid_controls.load_divisor.clamp(1.0, 50.0);
|
117
src/errors.rs
Normal file
117
src/errors.rs
Normal file
|
@ -0,0 +1,117 @@
|
|||
use std::sync::PoisonError;
|
||||
|
||||
use rocket::response::Responder;
|
||||
use thiserror::Error;
|
||||
|
||||
pub trait PrintErrors {
|
||||
type Inner;
|
||||
|
||||
fn some_or_print_with(self, context: &str) -> Option<Self::Inner>;
|
||||
}
|
||||
|
||||
impl<T, E> PrintErrors for Result<T, E>
|
||||
where
|
||||
E: std::error::Error,
|
||||
{
|
||||
type Inner = T;
|
||||
|
||||
fn some_or_print_with(self, context: &str) -> Option<Self::Inner> {
|
||||
match self {
|
||||
Ok(val) => Some(val),
|
||||
Err(e) => {
|
||||
log::error!("{context}: {e:?}");
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Error, Debug)]
|
||||
pub enum ServerError {
|
||||
#[error("rwlock error")]
|
||||
// 500
|
||||
Lock,
|
||||
#[error("no data")]
|
||||
// 503
|
||||
NoData,
|
||||
#[error("invalid parameters")]
|
||||
InvalidParameters,
|
||||
#[error("prometheus")]
|
||||
Prometheus(#[from] prometheus::Error),
|
||||
#[error("uri")]
|
||||
Uri,
|
||||
#[error(transparent)]
|
||||
Auth(#[from] AuthKeyError),
|
||||
#[error(transparent)]
|
||||
Channel(#[from] tokio::sync::mpsc::error::SendError<crate::api_interface::InterfaceRequest>),
|
||||
}
|
||||
|
||||
impl From<rocket::http::uri::Error<'_>> for ServerError {
|
||||
fn from(_: rocket::http::uri::Error<'_>) -> Self {
|
||||
Self::Uri
|
||||
}
|
||||
}
|
||||
|
||||
impl<T> From<PoisonError<T>> for ServerError {
|
||||
fn from(_: PoisonError<T>) -> Self {
|
||||
Self::Lock
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a> Responder<'a, 'a> for ServerError {
|
||||
fn respond_to(self, _: &'a rocket::Request<'_>) -> rocket::response::Result<'a> {
|
||||
Err(match self {
|
||||
ServerError::NoData => rocket::http::Status::ServiceUnavailable,
|
||||
ServerError::InvalidParameters => rocket::http::Status::BadRequest,
|
||||
ServerError::Auth(_) => rocket::http::Status::Unauthorized,
|
||||
ServerError::Channel(_)
|
||||
| ServerError::Uri
|
||||
| ServerError::Lock
|
||||
| ServerError::Prometheus(_) => rocket::http::Status::InternalServerError,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Error, Debug)]
|
||||
pub enum PliError {
|
||||
#[error("read error")]
|
||||
ReadError(u8),
|
||||
#[error("io error")]
|
||||
StdioErr(#[from] std::io::Error),
|
||||
}
|
||||
|
||||
#[derive(Error, Debug)]
|
||||
pub enum TristarError {
|
||||
#[error(transparent)]
|
||||
Modbus(#[from] tokio_modbus::Error),
|
||||
#[error(transparent)]
|
||||
ModbusException(#[from] tokio_modbus::ExceptionCode),
|
||||
#[error(transparent)]
|
||||
Serial(#[from] tokio_serial::Error),
|
||||
}
|
||||
|
||||
#[derive(Error, Debug)]
|
||||
pub enum ConfigError {
|
||||
#[error("json")]
|
||||
Json(#[from] serde_json::Error),
|
||||
#[error("io")]
|
||||
Io(#[from] std::io::Error),
|
||||
}
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub(crate) enum AuthKeyError {
|
||||
#[error(transparent)]
|
||||
SerdeError(#[from] serde_json::Error),
|
||||
#[error(transparent)]
|
||||
Reqwest(#[from] reqwest::Error),
|
||||
}
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum TeslaError {
|
||||
#[error(transparent)]
|
||||
Reqwest(#[from] reqwest::Error),
|
||||
#[error(transparent)]
|
||||
Json(#[from] serde_json::Error),
|
||||
#[error("{0:#?}")]
|
||||
Tesla(crate::types::ApiResponse),
|
||||
}
|
218
src/main.rs
Normal file
218
src/main.rs
Normal file
|
@ -0,0 +1,218 @@
|
|||
#[macro_use]
|
||||
extern crate rocket;
|
||||
|
||||
use api_interface::TeslaInterface;
|
||||
use charge_controllers::{pl::Pli, tristar::Tristar};
|
||||
use clap::{Parser, Subcommand};
|
||||
use config::{access_config, ChargeControllerConfig};
|
||||
use errors::PrintErrors;
|
||||
use std::{path::PathBuf, sync::RwLock};
|
||||
use tesla_charge_rate::TeslaChargeRateController;
|
||||
|
||||
use crate::config::Config;
|
||||
|
||||
mod api_interface;
|
||||
mod charge_controllers;
|
||||
mod config;
|
||||
mod errors;
|
||||
mod server;
|
||||
mod tesla_charge_rate;
|
||||
mod types;
|
||||
|
||||
#[derive(Parser, Debug, Clone)]
|
||||
#[clap(author, version, about, long_about = None)]
|
||||
struct Args {
|
||||
#[command(subcommand)]
|
||||
command: Commands,
|
||||
#[clap(long, default_value = "/etc/tesla-charge-controller")]
|
||||
config_dir: PathBuf,
|
||||
}
|
||||
|
||||
#[derive(Subcommand, Debug, Clone)]
|
||||
enum Commands {
|
||||
/// Run charge controller server
|
||||
Watch,
|
||||
/// Print the default config file
|
||||
GenerateConfig,
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() {
|
||||
env_logger::builder()
|
||||
.format_module_path(false)
|
||||
.format_timestamp(
|
||||
if std::env::var("LOG_TIMESTAMP").is_ok_and(|v| v == "false") {
|
||||
None
|
||||
} else {
|
||||
Some(env_logger::TimestampPrecision::Seconds)
|
||||
},
|
||||
)
|
||||
.init();
|
||||
|
||||
let args = Args::parse();
|
||||
|
||||
match args.command {
|
||||
Commands::GenerateConfig => {
|
||||
println!(
|
||||
"{}",
|
||||
serde_json::ser::to_string_pretty(&Config::default()).unwrap(),
|
||||
);
|
||||
}
|
||||
Commands::Watch => {
|
||||
log::trace!("begin");
|
||||
let config_path = args.config_dir.join("config.json");
|
||||
let (config, _config_watcher) = config::init_config(config_path);
|
||||
log::trace!("config initialised, create interface and get state");
|
||||
let interface = TeslaInterface::new(&config.car_vin);
|
||||
config::CONFIG.get_or_init(|| RwLock::new(config));
|
||||
|
||||
// build the channel that takes messages from the webserver thread to the api thread
|
||||
let (api_requests, mut api_receiver) = tokio::sync::mpsc::unbounded_channel();
|
||||
// and to the pli thread
|
||||
let (pli_requests, mut pli_receiver) = tokio::sync::mpsc::unbounded_channel();
|
||||
|
||||
// try to spawn the pli loop
|
||||
let pli = {
|
||||
let config = access_config();
|
||||
Pli::new(
|
||||
config.serial_port.clone(),
|
||||
config.baud_rate,
|
||||
config.pl_timeout_milliseconds,
|
||||
)
|
||||
};
|
||||
let pl_state = match pli {
|
||||
Ok(mut pli) => {
|
||||
log::trace!("begin charge controller monitoring...");
|
||||
let pl_state = pli.state.clone();
|
||||
tokio::task::spawn(async move {
|
||||
let mut interval = tokio::time::interval(std::time::Duration::from_secs(
|
||||
access_config().pl_watch_interval_seconds,
|
||||
));
|
||||
loop {
|
||||
tokio::select! {
|
||||
_ = interval.tick() => pli.refresh(),
|
||||
message = pli_receiver.recv() => match message {
|
||||
Some(message) => pli.process_request(message),
|
||||
None => panic!("PLI send channel dropped")
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
Some(pl_state)
|
||||
}
|
||||
Err(e) => {
|
||||
log::error!("Error connecting to serial device for PLI: {e:?}");
|
||||
None
|
||||
}
|
||||
};
|
||||
let local = tokio::task::LocalSet::new();
|
||||
// spawn a loop for each additional charge controller to log
|
||||
// failed connections will print an error but the program will continue
|
||||
let _additional_controllers: Vec<_> = access_config()
|
||||
.additional_charge_controllers
|
||||
.clone()
|
||||
.into_iter()
|
||||
.filter_map(|v| match v {
|
||||
ChargeControllerConfig::Pl {
|
||||
serial_port,
|
||||
baud_rate,
|
||||
timeout_milliseconds,
|
||||
watch_interval_seconds,
|
||||
} => Pli::new(serial_port.clone(), baud_rate, timeout_milliseconds)
|
||||
.some_or_print_with("Failed to connect to additional PLI")
|
||||
.map(|mut pli| {
|
||||
log::trace!("monitoring additional PL (port: {serial_port}");
|
||||
tokio::task::spawn(async move {
|
||||
let mut interval = tokio::time::interval(
|
||||
std::time::Duration::from_secs(watch_interval_seconds),
|
||||
);
|
||||
loop {
|
||||
interval.tick().await;
|
||||
pli.refresh();
|
||||
}
|
||||
})
|
||||
}),
|
||||
ChargeControllerConfig::Tristar {
|
||||
serial_port,
|
||||
baud_rate,
|
||||
watch_interval_seconds,
|
||||
} => Tristar::new(serial_port.clone(), baud_rate)
|
||||
.some_or_print_with("Failed to connect to additional Tristar")
|
||||
.map(|mut tristar| {
|
||||
log::trace!("monitoring additional tristar (port: {serial_port}");
|
||||
local.spawn_local(async move {
|
||||
let mut interval = tokio::time::interval(
|
||||
std::time::Duration::from_secs(watch_interval_seconds),
|
||||
);
|
||||
loop {
|
||||
interval.tick().await;
|
||||
tristar.refresh().await;
|
||||
}
|
||||
})
|
||||
}),
|
||||
})
|
||||
.collect();
|
||||
|
||||
let tcrc = TeslaChargeRateController::new(interface.state.clone(), pl_state.clone());
|
||||
let car_state = interface.state.clone();
|
||||
|
||||
let server_handle = server::launch_server(server::ServerState::new(
|
||||
car_state,
|
||||
pl_state,
|
||||
api_requests,
|
||||
pli_requests,
|
||||
));
|
||||
|
||||
// spawn the api / charge rate control loop
|
||||
tokio::task::spawn(async move {
|
||||
let mut normal_data_update_interval = tokio::time::interval(
|
||||
std::time::Duration::from_secs(access_config().tesla_update_interval_seconds),
|
||||
);
|
||||
let mut charge_rate_update_interval = tokio::time::interval(
|
||||
std::time::Duration::from_secs(access_config().pid_controls.loop_time_seconds),
|
||||
);
|
||||
let mut was_connected = false;
|
||||
|
||||
let (mut interface, mut tcrc) = (interface, tcrc);
|
||||
log::trace!("begin control loop...");
|
||||
|
||||
loop {
|
||||
// await either the next interval OR a message from the other thread
|
||||
tokio::select! {
|
||||
_ = normal_data_update_interval.tick() => {
|
||||
if !interface.state.read().unwrap().is_charging() {
|
||||
interface.refresh().await;
|
||||
}
|
||||
},
|
||||
_ = charge_rate_update_interval.tick() => {
|
||||
if interface.state.read().unwrap().is_charging() {
|
||||
was_connected = true;
|
||||
if let Some(request) = tcrc.control_charge_rate() {
|
||||
interface.process_request(request).await;
|
||||
}
|
||||
interface.refresh().await;
|
||||
} else if was_connected
|
||||
&& interface
|
||||
.state
|
||||
.read()
|
||||
.unwrap()
|
||||
.charge_state
|
||||
.as_ref()
|
||||
.is_some_and(|v| v.charging_state == types::ChargingState::Disconnected)
|
||||
{
|
||||
// reenable control when charger is disconnected
|
||||
was_connected = false;
|
||||
}
|
||||
},
|
||||
api_message = api_receiver.recv() => match api_message {
|
||||
Some(message) => interface.process_request(message).await,
|
||||
None => panic!("Tesla send channel dropped")
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
tokio::join!(server_handle, local);
|
||||
}
|
||||
}
|
||||
}
|
302
src/server/mod.rs
Normal file
302
src/server/mod.rs
Normal file
|
@ -0,0 +1,302 @@
|
|||
use std::sync::{Arc, RwLock};
|
||||
|
||||
use rocket::{
|
||||
fairing::{Fairing, Info, Kind},
|
||||
http::Header,
|
||||
request::FromParam,
|
||||
serde::json::Json,
|
||||
Request, Response, State,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tokio::sync::mpsc::UnboundedSender;
|
||||
|
||||
use crate::{
|
||||
api_interface::InterfaceRequest,
|
||||
charge_controllers::pl::{PlState, PliRequest, RegulatorState},
|
||||
config::{access_config, write_to_config},
|
||||
errors::{PrintErrors, ServerError},
|
||||
types::CarState,
|
||||
};
|
||||
|
||||
use self::static_handler::UiStatic;
|
||||
|
||||
mod static_handler;
|
||||
|
||||
pub struct ServerState {
|
||||
pub car_state: Arc<RwLock<CarState>>,
|
||||
pub pl_state: Option<Arc<RwLock<PlState>>>,
|
||||
pub api_requests: UnboundedSender<InterfaceRequest>,
|
||||
pub pli_requests: UnboundedSender<PliRequest>,
|
||||
}
|
||||
|
||||
impl ServerState {
|
||||
pub fn new(
|
||||
car_state: Arc<RwLock<CarState>>,
|
||||
pl_state: Option<Arc<RwLock<PlState>>>,
|
||||
api_requests: UnboundedSender<InterfaceRequest>,
|
||||
pli_requests: UnboundedSender<PliRequest>,
|
||||
) -> Self {
|
||||
Self {
|
||||
car_state,
|
||||
pl_state,
|
||||
api_requests,
|
||||
pli_requests,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn launch_server(state: ServerState) {
|
||||
let _ = rocket(state).launch().await;
|
||||
}
|
||||
|
||||
fn rocket(state: ServerState) -> rocket::Rocket<rocket::Build> {
|
||||
// serve the html from disk if running in a debug build
|
||||
// this allows editing the webpage without having to rebuild the executable
|
||||
// but in release builds, bundle the entire webapp folder into the exe
|
||||
let fileserver: Vec<rocket::Route> = if cfg!(debug_assertions) {
|
||||
rocket::fs::FileServer::from(format!(
|
||||
"{}/webapp",
|
||||
std::env::var("CARGO_MANIFEST_DIR").unwrap()
|
||||
))
|
||||
.into()
|
||||
} else {
|
||||
UiStatic {}.into()
|
||||
};
|
||||
rocket::build()
|
||||
.attach(Cors)
|
||||
.manage(state)
|
||||
.mount("/", fileserver)
|
||||
.mount(
|
||||
"/",
|
||||
routes![
|
||||
car_state,
|
||||
regulator_state,
|
||||
control_state,
|
||||
flash,
|
||||
disable_control,
|
||||
enable_control,
|
||||
set_shutoff,
|
||||
set_shutoff_time,
|
||||
shutoff_status,
|
||||
set_max,
|
||||
set_min,
|
||||
set_proportional_gain,
|
||||
set_derivative_gain,
|
||||
set_pid_loop_length,
|
||||
set_load_divisor,
|
||||
pid_settings,
|
||||
metrics,
|
||||
sync_time,
|
||||
read_ram,
|
||||
read_eeprom,
|
||||
set_regulator_state
|
||||
],
|
||||
)
|
||||
}
|
||||
|
||||
#[get("/car-state")]
|
||||
fn car_state(state: &State<ServerState>) -> Result<Json<CarState>, ServerError> {
|
||||
Ok(Json(state.car_state.read()?.clone()))
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Serialize, Deserialize, Debug)]
|
||||
struct ControlState {
|
||||
control_enable: bool,
|
||||
is_charging: bool,
|
||||
max_rate: i64,
|
||||
min_rate: i64,
|
||||
}
|
||||
|
||||
#[get("/control-state")]
|
||||
fn control_state(state: &State<ServerState>) -> Result<Json<ControlState>, ServerError> {
|
||||
let is_charging = state.car_state.read()?.is_charging();
|
||||
let config = access_config();
|
||||
let control_enable = config.control_enable;
|
||||
let max_rate = config.max_rate;
|
||||
let min_rate = config.min_rate;
|
||||
|
||||
Ok(Json(ControlState {
|
||||
control_enable,
|
||||
is_charging,
|
||||
max_rate,
|
||||
min_rate,
|
||||
}))
|
||||
}
|
||||
|
||||
#[post("/flash")]
|
||||
fn flash(state: &State<ServerState>, remote_addr: std::net::IpAddr) {
|
||||
log::warn!("flash requested: {remote_addr:?}");
|
||||
let _ = state.api_requests.send(InterfaceRequest::FlashLights);
|
||||
}
|
||||
|
||||
#[post("/disable-control")]
|
||||
fn disable_control(remote_addr: std::net::IpAddr) {
|
||||
log::warn!("disabling control: {remote_addr:?}");
|
||||
crate::config::write_to_config().control_enable = false;
|
||||
}
|
||||
|
||||
#[post("/enable-control")]
|
||||
fn enable_control(remote_addr: std::net::IpAddr) {
|
||||
log::warn!("enabling control: {remote_addr:?}");
|
||||
crate::config::write_to_config().control_enable = true;
|
||||
}
|
||||
|
||||
#[post("/shutoff/voltage/<voltage>")]
|
||||
fn set_shutoff(voltage: f64, remote_addr: std::net::IpAddr) {
|
||||
log::warn!("setting shutoff voltage: {remote_addr:?}");
|
||||
let voltage = voltage.clamp(40., 60.);
|
||||
write_to_config().shutoff_voltage = voltage;
|
||||
}
|
||||
|
||||
#[post("/shutoff/time/<time>")]
|
||||
fn set_shutoff_time(time: u64, remote_addr: std::net::IpAddr) {
|
||||
log::warn!("setting shutoff time: {remote_addr:?}");
|
||||
let time = time.clamp(5, 120);
|
||||
write_to_config().shutoff_voltage_time_seconds = time;
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Serialize, Deserialize, Debug)]
|
||||
struct ShutoffStatus {
|
||||
voltage: f64,
|
||||
time: u64,
|
||||
}
|
||||
|
||||
#[get("/shutoff/status")]
|
||||
fn shutoff_status() -> Json<ShutoffStatus> {
|
||||
let config = access_config();
|
||||
Json(ShutoffStatus {
|
||||
voltage: config.shutoff_voltage,
|
||||
time: config.shutoff_voltage_time_seconds,
|
||||
})
|
||||
}
|
||||
|
||||
#[post("/set-max/<limit>")]
|
||||
fn set_max(limit: i64, remote_addr: std::net::IpAddr) {
|
||||
log::warn!("setting max: {remote_addr:?}");
|
||||
let limit = limit.clamp(access_config().min_rate, 32);
|
||||
write_to_config().max_rate = limit;
|
||||
}
|
||||
|
||||
#[post("/set-min/<limit>")]
|
||||
fn set_min(limit: i64, remote_addr: std::net::IpAddr) {
|
||||
log::warn!("setting min: {remote_addr:?}");
|
||||
let limit = limit.clamp(0, access_config().max_rate);
|
||||
write_to_config().min_rate = limit;
|
||||
}
|
||||
|
||||
#[post("/pid-settings/proportional/<gain>")]
|
||||
fn set_proportional_gain(gain: f64, remote_addr: std::net::IpAddr) {
|
||||
log::warn!("setting proportional gain: {remote_addr:?}");
|
||||
write_to_config().pid_controls.proportional_gain = gain;
|
||||
}
|
||||
|
||||
#[post("/pid-settings/derivative/<gain>")]
|
||||
fn set_derivative_gain(gain: f64, remote_addr: std::net::IpAddr) {
|
||||
log::warn!("setting derivative gain: {remote_addr:?}");
|
||||
write_to_config().pid_controls.derivative_gain = gain;
|
||||
}
|
||||
|
||||
#[post("/pid-settings/loop_time_seconds/<time>")]
|
||||
fn set_pid_loop_length(time: u64, remote_addr: std::net::IpAddr) {
|
||||
log::warn!("setting pid loop interval: {remote_addr:?}");
|
||||
write_to_config().pid_controls.loop_time_seconds = time;
|
||||
}
|
||||
|
||||
#[post("/pid-settings/load_divisor/<divisor>")]
|
||||
fn set_load_divisor(divisor: f64, remote_addr: std::net::IpAddr) {
|
||||
log::warn!("setting load divisor interval: {remote_addr:?}");
|
||||
write_to_config().pid_controls.load_divisor = divisor;
|
||||
}
|
||||
|
||||
#[get("/pid-settings/status")]
|
||||
fn pid_settings() -> Json<crate::config::PidControls> {
|
||||
Json(access_config().pid_controls)
|
||||
}
|
||||
|
||||
#[get("/metrics")]
|
||||
fn metrics() -> Result<String, ServerError> {
|
||||
Ok(
|
||||
prometheus::TextEncoder::new()
|
||||
.encode_to_string(&prometheus::default_registry().gather())?,
|
||||
)
|
||||
}
|
||||
|
||||
#[get("/sync-time")]
|
||||
fn sync_time(state: &State<ServerState>) -> String {
|
||||
let _ = state.pli_requests.send(PliRequest::SyncTime);
|
||||
String::from("syncing time...")
|
||||
}
|
||||
|
||||
#[get("/read-ram/<address>")]
|
||||
fn read_ram(address: u8, state: &State<ServerState>) -> String {
|
||||
let _ = state.pli_requests.send(PliRequest::ReadRam(address));
|
||||
format!("reading at ram address {address}")
|
||||
}
|
||||
|
||||
#[get("/read-eeprom/<address>")]
|
||||
fn read_eeprom(address: u8, state: &State<ServerState>) -> String {
|
||||
let _ = state.pli_requests.send(PliRequest::ReadEeprom(address));
|
||||
format!("reading at eeprom address {address}")
|
||||
}
|
||||
|
||||
#[get("/regulator-state")]
|
||||
fn regulator_state(state: &State<ServerState>) -> Result<Json<PlState>, ServerError> {
|
||||
state
|
||||
.pl_state
|
||||
.as_ref()
|
||||
.ok_or(ServerError::NoData)
|
||||
.and_then(|v| Ok(Json(*(v.read()?))))
|
||||
}
|
||||
|
||||
#[post("/set-regulator-state/<regulator_state>")]
|
||||
fn set_regulator_state(
|
||||
state: &State<ServerState>,
|
||||
regulator_state: Option<RegulatorState>,
|
||||
) -> Result<(), ServerError> {
|
||||
if let Some(regulator_state) = regulator_state {
|
||||
log::info!("Requesting regulator state set to {regulator_state:?}");
|
||||
state
|
||||
.pli_requests
|
||||
.send(PliRequest::SetRegulatorState(regulator_state))
|
||||
.some_or_print_with("requesting new pl regulator state");
|
||||
Ok(())
|
||||
} else {
|
||||
Err(ServerError::InvalidParameters)
|
||||
}
|
||||
}
|
||||
|
||||
impl FromParam<'_> for RegulatorState {
|
||||
type Error = ();
|
||||
|
||||
fn from_param(param: &'_ str) -> Result<Self, Self::Error> {
|
||||
match param.to_lowercase().as_str() {
|
||||
"boost" => Ok(Self::Boost),
|
||||
"equalise" => Ok(Self::Equalise),
|
||||
"absorption" => Ok(Self::Absorption),
|
||||
"float" => Ok(Self::Float),
|
||||
_ => Err(()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct Cors;
|
||||
|
||||
#[rocket::async_trait]
|
||||
impl Fairing for Cors {
|
||||
fn info(&self) -> Info {
|
||||
Info {
|
||||
name: "Add CORS headers to responses",
|
||||
kind: Kind::Response,
|
||||
}
|
||||
}
|
||||
|
||||
async fn on_response<'r>(&self, _request: &'r Request<'_>, response: &mut Response<'r>) {
|
||||
response.set_header(Header::new("Access-Control-Allow-Origin", "*"));
|
||||
response.set_header(Header::new(
|
||||
"Access-Control-Allow-Methods",
|
||||
"POST, GET, PATCH, OPTIONS",
|
||||
));
|
||||
response.set_header(Header::new("Access-Control-Allow-Headers", "*"));
|
||||
response.set_header(Header::new("Access-Control-Allow-Credentials", "true"));
|
||||
}
|
||||
}
|
|
@ -52,12 +52,10 @@ impl Handler for UiStatic {
|
|||
data: v.contents().to_vec(),
|
||||
name: p,
|
||||
})
|
||||
.or_else(|| {
|
||||
UI_DIR_FILES.get_file(&plus_index).map(|v| RawHtml {
|
||||
data: v.contents().to_vec(),
|
||||
name: plus_index,
|
||||
})
|
||||
});
|
||||
.or(UI_DIR_FILES.get_file(&plus_index).map(|v| RawHtml {
|
||||
data: v.contents().to_vec(),
|
||||
name: plus_index,
|
||||
}));
|
||||
file.respond_to(req).or_forward((data, Status::NotFound))
|
||||
}
|
||||
}
|
138
src/tesla_charge_rate.rs
Normal file
138
src/tesla_charge_rate.rs
Normal file
|
@ -0,0 +1,138 @@
|
|||
use std::sync::{Arc, RwLock};
|
||||
|
||||
use lazy_static::lazy_static;
|
||||
use prometheus::{register_gauge, register_int_gauge, Gauge, IntGauge};
|
||||
|
||||
use crate::{
|
||||
api_interface::InterfaceRequest,
|
||||
charge_controllers::pl::PlState,
|
||||
config::access_config,
|
||||
types::{CarState, ChargeState},
|
||||
};
|
||||
|
||||
lazy_static! {
|
||||
pub static ref CONTROL_ENABLE_GAUGE: IntGauge =
|
||||
register_int_gauge!("tcrc_control_enable", "Enable Tesla charge rate control",).unwrap();
|
||||
pub static ref PROPORTIONAL_GAUGE: Gauge = register_gauge!(
|
||||
"tcrc_proportional",
|
||||
"Proportional component of requested change to charge rate",
|
||||
)
|
||||
.unwrap();
|
||||
pub static ref DERIVATIVE_GAUGE: Gauge = register_gauge!(
|
||||
"tcrc_derivative",
|
||||
"Derivative component of requested change to charge rate",
|
||||
)
|
||||
.unwrap();
|
||||
pub static ref LOAD_GAUGE: Gauge = register_gauge!(
|
||||
"tcrc_load",
|
||||
"Fudge factor from internal load of requested change to charge rate",
|
||||
)
|
||||
.unwrap();
|
||||
pub static ref CHANGE_REQUEST_GAUGE: Gauge =
|
||||
register_gauge!("tcrc_change_request", "Requested change to charge rate",).unwrap();
|
||||
}
|
||||
|
||||
pub struct TeslaChargeRateController {
|
||||
pub car_state: Arc<RwLock<CarState>>,
|
||||
pub pl_state: Option<Arc<RwLock<PlState>>>,
|
||||
pid: PidLoop,
|
||||
voltage_low: u64,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
#[expect(dead_code)]
|
||||
pub enum TcrcRequest {
|
||||
DisableAutomaticControl,
|
||||
EnableAutomaticControl,
|
||||
}
|
||||
|
||||
impl TeslaChargeRateController {
|
||||
pub fn new(car_state: Arc<RwLock<CarState>>, pl_state: Option<Arc<RwLock<PlState>>>) -> Self {
|
||||
Self {
|
||||
car_state,
|
||||
pl_state,
|
||||
pid: Default::default(),
|
||||
voltage_low: 0,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn control_charge_rate(&mut self) -> Option<InterfaceRequest> {
|
||||
let delta_time = access_config().pid_controls.loop_time_seconds;
|
||||
if let Some(pl_state) = self.pl_state.as_ref().and_then(|v| v.read().ok()) {
|
||||
if let Ok(car_state) = self.car_state.read() {
|
||||
if let Some(charge_state) = car_state.charge_state.as_ref() {
|
||||
if pl_state.battery_voltage < access_config().shutoff_voltage {
|
||||
self.voltage_low += 1;
|
||||
if (self.voltage_low * delta_time)
|
||||
>= access_config().shutoff_voltage_time_seconds
|
||||
{
|
||||
return Some(InterfaceRequest::StopCharge);
|
||||
}
|
||||
} else {
|
||||
self.voltage_low = 0;
|
||||
if crate::config::access_config().control_enable {
|
||||
return self
|
||||
.pid
|
||||
.step(&pl_state, charge_state, delta_time as f64)
|
||||
.map(InterfaceRequest::SetChargeRate);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
struct PidLoop {
|
||||
previous_error: f64,
|
||||
}
|
||||
|
||||
impl Default for PidLoop {
|
||||
fn default() -> Self {
|
||||
Self { previous_error: 0. }
|
||||
}
|
||||
}
|
||||
|
||||
impl PidLoop {
|
||||
fn step(
|
||||
&mut self,
|
||||
pl_state: &PlState,
|
||||
charge_state: &ChargeState,
|
||||
delta_time: f64,
|
||||
) -> Option<i64> {
|
||||
let error = pl_state.battery_voltage - pl_state.target_voltage;
|
||||
let derivative = (error - self.previous_error) / delta_time;
|
||||
let config = access_config();
|
||||
|
||||
let proportional_component = config.pid_controls.proportional_gain * error;
|
||||
let derivative_component = config.pid_controls.derivative_gain * derivative;
|
||||
|
||||
let extra_offsets =
|
||||
(pl_state.internal_load_current / config.pid_controls.load_divisor).clamp(0., 2.);
|
||||
|
||||
let offset = proportional_component + derivative_component + extra_offsets;
|
||||
|
||||
PROPORTIONAL_GAUGE.set(proportional_component);
|
||||
DERIVATIVE_GAUGE.set(derivative_component);
|
||||
LOAD_GAUGE.set(extra_offsets);
|
||||
CHANGE_REQUEST_GAUGE.set(offset);
|
||||
|
||||
let new_target = offset + charge_state.charge_amps as f64;
|
||||
|
||||
self.previous_error = error;
|
||||
|
||||
let new_target_int = new_target.round() as i64;
|
||||
valid_rate(new_target_int, charge_state.charge_amps)
|
||||
}
|
||||
}
|
||||
|
||||
fn valid_rate(rate: i64, previous: i64) -> Option<i64> {
|
||||
let config = access_config();
|
||||
let new = rate.clamp(config.min_rate, config.max_rate);
|
||||
if new == previous {
|
||||
None
|
||||
} else {
|
||||
Some(new)
|
||||
}
|
||||
}
|
|
@ -1,91 +1,27 @@
|
|||
use serde::{Deserialize, Serialize};
|
||||
|
||||
mod http;
|
||||
|
||||
// pub enum CarState {
|
||||
// Unavailable,
|
||||
// Available {
|
||||
// charge_state: ChargeState,
|
||||
// data_received: std::time::Instant,
|
||||
// },
|
||||
// }
|
||||
|
||||
pub struct Car {
|
||||
vehicle: http::Vehicle,
|
||||
state: tokio::sync::RwLock<CarState>,
|
||||
}
|
||||
|
||||
#[derive(Default, Clone, Serialize, Deserialize, Debug)]
|
||||
pub struct CarState {
|
||||
charge_state: Option<ChargeState>,
|
||||
data_received: std::time::Instant,
|
||||
}
|
||||
|
||||
impl Car {
|
||||
pub fn new(vin: &str) -> Self {
|
||||
let state = CarState {
|
||||
charge_state: None,
|
||||
data_received: std::time::Instant::now(),
|
||||
};
|
||||
let state = tokio::sync::RwLock::new(state);
|
||||
|
||||
Self {
|
||||
vehicle: http::Vehicle::new(vin),
|
||||
state,
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn update(&self) {
|
||||
let mut state = self.state.write().await;
|
||||
if let Ok(data) = self.vehicle.charge_data().await {
|
||||
state.data_received = std::time::Instant::now();
|
||||
state.charge_state = Some(data);
|
||||
}
|
||||
}
|
||||
|
||||
pub const fn vehicle(&self) -> &http::Vehicle {
|
||||
&self.vehicle
|
||||
}
|
||||
|
||||
pub const fn state(&self) -> &tokio::sync::RwLock<CarState> {
|
||||
&self.state
|
||||
}
|
||||
pub charge_state: Option<ChargeState>,
|
||||
}
|
||||
|
||||
impl CarState {
|
||||
pub async fn charge_state(&self) -> Option<&ChargeState> {
|
||||
if self.is_outdated().await {
|
||||
None
|
||||
} else {
|
||||
self.charge_state.as_ref()
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn is_charging(&self) -> bool {
|
||||
self.charge_state()
|
||||
.await
|
||||
.is_some_and(ChargeState::is_charging)
|
||||
}
|
||||
|
||||
pub fn data_age(&self) -> std::time::Duration {
|
||||
std::time::Instant::now().duration_since(self.data_received)
|
||||
}
|
||||
|
||||
async fn is_outdated(&self) -> bool {
|
||||
self.data_age() > crate::config::access_config().await.car_state_interval
|
||||
pub fn is_charging(&self) -> bool {
|
||||
self.charge_state
|
||||
.as_ref()
|
||||
.is_some_and(|v| v.charging_state == ChargingState::Charging)
|
||||
}
|
||||
}
|
||||
|
||||
// impl CarState {
|
||||
// pub fn charging_state(&self) -> ChargingState {
|
||||
// match self {
|
||||
// CarState::Unavailable => ChargingState::Unavailable,
|
||||
// CarState::Available {
|
||||
// charge_state,
|
||||
// data_received: _,
|
||||
// } => charge_state.charging_state,
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
#[allow(dead_code)]
|
||||
#[derive(serde::Deserialize, Debug)]
|
||||
pub struct ApiResponse {
|
||||
pub command: String,
|
||||
pub reason: String,
|
||||
pub response: serde_json::Value,
|
||||
pub result: bool,
|
||||
pub vin: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone)]
|
||||
pub struct ChargeState {
|
||||
|
@ -147,19 +83,12 @@ pub struct ChargeState {
|
|||
pub user_charge_enable_request: Option<bool>,
|
||||
}
|
||||
|
||||
impl ChargeState {
|
||||
pub fn is_charging(&self) -> bool {
|
||||
self.charging_state == ChargingState::Charging
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq)]
|
||||
pub enum ChargingState {
|
||||
Charging,
|
||||
Stopped,
|
||||
Disconnected,
|
||||
Complete,
|
||||
Unavailable,
|
||||
#[serde(other)]
|
||||
Other,
|
||||
}
|
12
string_fields.txt
Normal file
12
string_fields.txt
Normal file
|
@ -0,0 +1,12 @@
|
|||
cabin_overheat_protection:
|
||||
FanOnly
|
||||
Off
|
||||
On
|
||||
climate_keeper_mode:
|
||||
off
|
||||
hvac_auto_request:
|
||||
Override
|
||||
On
|
||||
conn_charge_cable:
|
||||
<invalid>
|
||||
IEC
|
|
@ -1,41 +0,0 @@
|
|||
[package]
|
||||
name = "tesla-charge-controller"
|
||||
version.workspace = true
|
||||
edition = "2021"
|
||||
license = "MITNFA"
|
||||
description = "Controls Tesla charge rate based on solar charge data"
|
||||
authors = ["Alex Janka"]
|
||||
|
||||
[package.metadata.deb]
|
||||
maintainer-scripts = "debian/"
|
||||
systemd-units = { enable = false }
|
||||
depends = "charge-controller-supervisor"
|
||||
|
||||
[dependencies]
|
||||
chrono = "0.4.39"
|
||||
clap = { version = "4.5.23", features = ["derive"] }
|
||||
env_logger = "0.11.6"
|
||||
eyre = "0.6.12"
|
||||
if_chain = "1.0.2"
|
||||
include_dir = "0.7.4"
|
||||
lazy_static = "1.5.0"
|
||||
log = "0.4.22"
|
||||
notify-debouncer-mini = { version = "0.5.0", default-features = false }
|
||||
prometheus = "0.13.4"
|
||||
rand = "0.8.5"
|
||||
reqwest = { version = "0.12.9", default-features = false, features = [
|
||||
"json",
|
||||
"rustls-tls",
|
||||
] }
|
||||
rocket = { version = "0.5.1", features = ["json"] }
|
||||
ron = "0.8.1"
|
||||
serde = { version = "1.0.216", features = ["derive"] }
|
||||
serde_json = "1.0.134"
|
||||
serialport = "4.6.1"
|
||||
thiserror = "2.0.9"
|
||||
tokio = { version = "1.42.0", features = ["full"] }
|
||||
tokio-modbus = "0.16.1"
|
||||
tokio-serial = "5.4.4"
|
||||
|
||||
[lints]
|
||||
workspace = true
|
|
@ -1,80 +0,0 @@
|
|||
pub struct VehicleController {
|
||||
car: std::sync::Arc<crate::api::Car>,
|
||||
requests: tokio::sync::mpsc::UnboundedReceiver<InterfaceRequest>,
|
||||
control_state: ChargeRateControllerState,
|
||||
}
|
||||
|
||||
#[expect(dead_code, reason = "not all states are currently in use")]
|
||||
pub enum ChargeRateControllerState {
|
||||
Inactive,
|
||||
Charging { rate_amps: i64 },
|
||||
}
|
||||
|
||||
pub enum InterfaceRequest {
|
||||
FlashLights,
|
||||
}
|
||||
|
||||
impl VehicleController {
|
||||
pub const fn new(
|
||||
car: std::sync::Arc<crate::api::Car>,
|
||||
requests: tokio::sync::mpsc::UnboundedReceiver<InterfaceRequest>,
|
||||
) -> Self {
|
||||
Self {
|
||||
car,
|
||||
requests,
|
||||
control_state: ChargeRateControllerState::Inactive,
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn run(&mut self) -> ! {
|
||||
let (mut car_state_tick, mut pid_loop_tick) = {
|
||||
let config = crate::config::access_config().await;
|
||||
let car_state_tick = tokio::time::interval(config.car_state_interval);
|
||||
let pid_loop_tick = tokio::time::interval(std::time::Duration::from_secs(
|
||||
config.pid_controls.loop_time_seconds,
|
||||
));
|
||||
(car_state_tick, pid_loop_tick)
|
||||
};
|
||||
loop {
|
||||
tokio::select! {
|
||||
_ = car_state_tick.tick() => { self.car.update().await; },
|
||||
_ = pid_loop_tick.tick() => { self.run_cycle().await; }
|
||||
Some(req) = self.requests.recv() => { self.process_requests(req).await; }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn run_cycle(&mut self) {
|
||||
let age = self.car.state().read().await.data_age();
|
||||
if age >= crate::config::access_config().await.car_state_interval {
|
||||
self.car.update().await;
|
||||
}
|
||||
match self.control_state {
|
||||
ChargeRateControllerState::Inactive => {
|
||||
let car_state = self.car.state().read().await;
|
||||
let state = car_state.charge_state().await;
|
||||
|
||||
if let Some(state) = state {
|
||||
if state.is_charging() {
|
||||
self.control_state = ChargeRateControllerState::Charging {
|
||||
rate_amps: state.charge_amps,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
ChargeRateControllerState::Charging { rate_amps: _ } => todo!(),
|
||||
}
|
||||
}
|
||||
|
||||
#[expect(
|
||||
clippy::needless_pass_by_ref_mut,
|
||||
reason = "this will eventually need to mutate self"
|
||||
)]
|
||||
pub async fn process_requests(&mut self, req: InterfaceRequest) {
|
||||
if let Err(e) = match req {
|
||||
InterfaceRequest::FlashLights => self.car.vehicle().flash_lights().await,
|
||||
} {
|
||||
log::error!("failed to execute request: {e:?}");
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,82 +0,0 @@
|
|||
#![allow(clippy::significant_drop_tightening)]
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
use clap::Parser;
|
||||
|
||||
mod api;
|
||||
mod config;
|
||||
mod control;
|
||||
mod server;
|
||||
|
||||
#[derive(clap::Parser, Debug, Clone)]
|
||||
#[clap(author, version, about, long_about = None)]
|
||||
struct Args {
|
||||
#[command(subcommand)]
|
||||
command: Commands,
|
||||
#[clap(long, default_value = "/etc/tesla-charge-controller/config.json")]
|
||||
config: PathBuf,
|
||||
}
|
||||
|
||||
#[derive(clap::Subcommand, Debug, Clone)]
|
||||
enum Commands {
|
||||
/// Run charge controller server
|
||||
Watch,
|
||||
/// Print the default config file
|
||||
GenerateConfig,
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() {
|
||||
env_logger::builder()
|
||||
.format_module_path(false)
|
||||
.format_timestamp(
|
||||
if std::env::var("LOG_TIMESTAMP").is_ok_and(|v| v == "false") {
|
||||
None
|
||||
} else {
|
||||
Some(env_logger::TimestampPrecision::Seconds)
|
||||
},
|
||||
)
|
||||
.init();
|
||||
|
||||
let args = Args::parse();
|
||||
match args.command {
|
||||
Commands::Watch => {
|
||||
if let Err(e) = run(args).await {
|
||||
log::error!("{e:?}");
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
Commands::GenerateConfig => {
|
||||
let config = config::Config::default();
|
||||
match serde_json::to_string_pretty(&config) {
|
||||
Ok(config_json) => {
|
||||
println!("{config_json}");
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("error serializing config: {e:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn run(args: Args) -> eyre::Result<()> {
|
||||
let _config_watcher = config::init_config(args.config);
|
||||
|
||||
let car = {
|
||||
let config = config::access_config().await;
|
||||
api::Car::new(&config.car_vin)
|
||||
};
|
||||
let car = std::sync::Arc::new(car);
|
||||
let (tx, rx) = tokio::sync::mpsc::unbounded_channel();
|
||||
let mut vehicle_controller = control::VehicleController::new(car.clone(), rx);
|
||||
let server = server::launch_server(server::ServerState::new(car, tx));
|
||||
|
||||
tokio::select! {
|
||||
() = server => {}
|
||||
() = vehicle_controller.run() => {}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
|
@ -1,338 +0,0 @@
|
|||
use std::sync::Arc;
|
||||
|
||||
use rocket::{
|
||||
fairing::{Fairing, Info, Kind},
|
||||
get,
|
||||
http::Header,
|
||||
post, routes,
|
||||
serde::json::Json,
|
||||
Request, Response, State,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tokio::sync::mpsc::UnboundedSender;
|
||||
|
||||
use crate::{
|
||||
api::Car,
|
||||
config::{access_config, write_to_config},
|
||||
control::InterfaceRequest,
|
||||
};
|
||||
|
||||
use self::static_handler::UiStatic;
|
||||
|
||||
mod static_handler;
|
||||
|
||||
pub struct ServerState {
|
||||
pub car: Arc<Car>,
|
||||
pub api_requests: UnboundedSender<InterfaceRequest>,
|
||||
}
|
||||
|
||||
impl ServerState {
|
||||
pub const fn new(car: Arc<Car>, api_requests: UnboundedSender<InterfaceRequest>) -> Self {
|
||||
Self { car, api_requests }
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn launch_server(state: ServerState) {
|
||||
let _ = rocket(state).launch().await;
|
||||
}
|
||||
|
||||
fn rocket(state: ServerState) -> rocket::Rocket<rocket::Build> {
|
||||
// serve the html from disk if running in a debug build
|
||||
// this allows editing the webpage without having to rebuild the executable
|
||||
// but in release builds, bundle the entire webapp folder into the exe
|
||||
let fileserver: Vec<rocket::Route> = if cfg!(debug_assertions) {
|
||||
rocket::fs::FileServer::from(format!(
|
||||
"{}/webapp",
|
||||
std::env::var("CARGO_MANIFEST_DIR").unwrap()
|
||||
))
|
||||
.into()
|
||||
} else {
|
||||
UiStatic {}.into()
|
||||
};
|
||||
rocket::build()
|
||||
.attach(Cors)
|
||||
.manage(state)
|
||||
.mount("/", fileserver)
|
||||
.mount(
|
||||
"/",
|
||||
routes![
|
||||
car_state,
|
||||
control_state,
|
||||
flash,
|
||||
disable_control,
|
||||
enable_control,
|
||||
set_shutoff,
|
||||
set_shutoff_time,
|
||||
shutoff_status,
|
||||
set_max,
|
||||
set_min,
|
||||
set_proportional_gain,
|
||||
set_derivative_gain,
|
||||
set_pid_loop_length,
|
||||
set_load_divisor,
|
||||
pid_settings,
|
||||
metrics,
|
||||
],
|
||||
)
|
||||
}
|
||||
|
||||
#[get("/car-state")]
|
||||
async fn car_state(
|
||||
state: &State<ServerState>,
|
||||
) -> Result<Json<crate::api::ChargeState>, ServerError> {
|
||||
Ok(Json(
|
||||
state
|
||||
.car
|
||||
.state()
|
||||
.read()
|
||||
.await
|
||||
.charge_state()
|
||||
.await
|
||||
.ok_or(ServerError::NoData)?
|
||||
.clone(),
|
||||
))
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Serialize, Deserialize, Debug)]
|
||||
struct ControlState {
|
||||
control_enable: bool,
|
||||
is_charging: bool,
|
||||
max_rate: i64,
|
||||
min_rate: i64,
|
||||
}
|
||||
|
||||
#[get("/control-state")]
|
||||
async fn control_state(state: &State<ServerState>) -> Result<Json<ControlState>, ServerError> {
|
||||
let is_charging = state.car.state().read().await.is_charging().await;
|
||||
let config = access_config().await;
|
||||
let control_enable = config.control_enable;
|
||||
let max_rate = config.max_rate;
|
||||
let min_rate = config.min_rate;
|
||||
|
||||
Ok(Json(ControlState {
|
||||
control_enable,
|
||||
is_charging,
|
||||
max_rate,
|
||||
min_rate,
|
||||
}))
|
||||
}
|
||||
|
||||
#[post("/flash")]
|
||||
fn flash(state: &State<ServerState>, remote_addr: std::net::IpAddr) {
|
||||
log::warn!("flash requested: {remote_addr:?}");
|
||||
let _ = state.api_requests.send(InterfaceRequest::FlashLights);
|
||||
}
|
||||
|
||||
#[post("/disable-control")]
|
||||
async fn disable_control(remote_addr: std::net::IpAddr) {
|
||||
log::warn!("disabling control: {remote_addr:?}");
|
||||
write_to_config().await.control_enable = false;
|
||||
}
|
||||
|
||||
#[post("/enable-control")]
|
||||
async fn enable_control(remote_addr: std::net::IpAddr) {
|
||||
log::warn!("enabling control: {remote_addr:?}");
|
||||
write_to_config().await.control_enable = true;
|
||||
}
|
||||
|
||||
#[post("/shutoff/voltage/<voltage>")]
|
||||
async fn set_shutoff(voltage: f64, remote_addr: std::net::IpAddr) {
|
||||
log::warn!("setting shutoff voltage: {remote_addr:?}");
|
||||
let voltage = voltage.clamp(40., 60.);
|
||||
write_to_config().await.shutoff_voltage = voltage;
|
||||
}
|
||||
|
||||
#[post("/shutoff/time/<time>")]
|
||||
async fn set_shutoff_time(time: u64, remote_addr: std::net::IpAddr) {
|
||||
log::warn!("setting shutoff time: {remote_addr:?}");
|
||||
let time = time.clamp(5, 120);
|
||||
write_to_config().await.shutoff_voltage_time_seconds = time;
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Serialize, Deserialize, Debug)]
|
||||
struct ShutoffStatus {
|
||||
voltage: f64,
|
||||
time: u64,
|
||||
}
|
||||
|
||||
#[get("/shutoff/status")]
|
||||
async fn shutoff_status() -> Json<ShutoffStatus> {
|
||||
let config = access_config().await;
|
||||
Json(ShutoffStatus {
|
||||
voltage: config.shutoff_voltage,
|
||||
time: config.shutoff_voltage_time_seconds,
|
||||
})
|
||||
}
|
||||
|
||||
#[post("/set-max/<limit>")]
|
||||
async fn set_max(limit: i64, remote_addr: std::net::IpAddr) {
|
||||
log::warn!("setting max: {remote_addr:?}");
|
||||
let mut config = write_to_config().await;
|
||||
let limit = limit.clamp(config.min_rate, 32);
|
||||
config.max_rate = limit;
|
||||
}
|
||||
|
||||
#[post("/set-min/<limit>")]
|
||||
async fn set_min(limit: i64, remote_addr: std::net::IpAddr) {
|
||||
log::warn!("setting min: {remote_addr:?}");
|
||||
let mut config = write_to_config().await;
|
||||
let limit = limit.clamp(0, config.max_rate);
|
||||
config.min_rate = limit;
|
||||
}
|
||||
|
||||
#[post("/pid-settings/proportional/<gain>")]
|
||||
async fn set_proportional_gain(gain: f64, remote_addr: std::net::IpAddr) {
|
||||
log::warn!("setting proportional gain: {remote_addr:?}");
|
||||
write_to_config().await.pid_controls.proportional_gain = gain;
|
||||
}
|
||||
|
||||
#[post("/pid-settings/derivative/<gain>")]
|
||||
async fn set_derivative_gain(gain: f64, remote_addr: std::net::IpAddr) {
|
||||
log::warn!("setting derivative gain: {remote_addr:?}");
|
||||
write_to_config().await.pid_controls.derivative_gain = gain;
|
||||
}
|
||||
|
||||
#[post("/pid-settings/loop_time_seconds/<time>")]
|
||||
async fn set_pid_loop_length(time: u64, remote_addr: std::net::IpAddr) {
|
||||
log::warn!("setting pid loop interval: {remote_addr:?}");
|
||||
write_to_config().await.pid_controls.loop_time_seconds = time;
|
||||
}
|
||||
|
||||
#[post("/pid-settings/load_divisor/<divisor>")]
|
||||
async fn set_load_divisor(divisor: f64, remote_addr: std::net::IpAddr) {
|
||||
log::warn!("setting load divisor interval: {remote_addr:?}");
|
||||
write_to_config().await.pid_controls.load_divisor = divisor;
|
||||
}
|
||||
|
||||
#[get("/pid-settings/status")]
|
||||
async fn pid_settings() -> Json<crate::config::PidControls> {
|
||||
Json(access_config().await.pid_controls)
|
||||
}
|
||||
|
||||
#[get("/metrics")]
|
||||
fn metrics() -> Result<String, ServerError> {
|
||||
Ok(
|
||||
prometheus::TextEncoder::new()
|
||||
.encode_to_string(&prometheus::default_registry().gather())?,
|
||||
)
|
||||
}
|
||||
|
||||
// #[get("/sync-time")]
|
||||
// fn sync_time(state: &State<ServerState>) -> String {
|
||||
// let _ = state.pli_requests.send(PliRequest::SyncTime);
|
||||
// String::from("syncing time...")
|
||||
// }
|
||||
|
||||
// #[get("/read-ram/<address>")]
|
||||
// fn read_ram(address: u8, state: &State<ServerState>) -> String {
|
||||
// let _ = state.pli_requests.send(PliRequest::ReadRam(address));
|
||||
// format!("reading at ram address {address}")
|
||||
// }
|
||||
|
||||
// #[get("/read-eeprom/<address>")]
|
||||
// fn read_eeprom(address: u8, state: &State<ServerState>) -> String {
|
||||
// let _ = state.pli_requests.send(PliRequest::ReadEeprom(address));
|
||||
// format!("reading at eeprom address {address}")
|
||||
// }
|
||||
|
||||
// #[get("/regulator-state")]
|
||||
// fn regulator_state(state: &State<ServerState>) -> Result<Json<PlState>, ServerError> {
|
||||
// state
|
||||
// .pl_state
|
||||
// .as_ref()
|
||||
// .ok_or(ServerError::NoData)
|
||||
// .and_then(|v| Ok(Json(*(v.read()?))))
|
||||
// }
|
||||
|
||||
// #[post("/set-regulator-state/<regulator_state>")]
|
||||
// fn set_regulator_state(
|
||||
// state: &State<ServerState>,
|
||||
// regulator_state: Option<RegulatorState>,
|
||||
// ) -> Result<(), ServerError> {
|
||||
// if let Some(regulator_state) = regulator_state {
|
||||
// log::info!("Requesting regulator state set to {regulator_state:?}");
|
||||
// state
|
||||
// .pli_requests
|
||||
// .send(PliRequest::SetRegulatorState(regulator_state))
|
||||
// .some_or_print_with("requesting new pl regulator state");
|
||||
// Ok(())
|
||||
// } else {
|
||||
// Err(ServerError::InvalidParameters)
|
||||
// }
|
||||
// }
|
||||
|
||||
// impl FromParam<'_> for RegulatorState {
|
||||
// type Error = ();
|
||||
|
||||
// fn from_param(param: &'_ str) -> Result<Self, Self::Error> {
|
||||
// match param.to_lowercase().as_str() {
|
||||
// "boost" => Ok(Self::Boost),
|
||||
// "equalise" => Ok(Self::Equalise),
|
||||
// "absorption" => Ok(Self::Absorption),
|
||||
// "float" => Ok(Self::Float),
|
||||
// _ => Err(()),
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
|
||||
pub struct Cors;
|
||||
|
||||
#[rocket::async_trait]
|
||||
impl Fairing for Cors {
|
||||
fn info(&self) -> Info {
|
||||
Info {
|
||||
name: "Add CORS headers to responses",
|
||||
kind: Kind::Response,
|
||||
}
|
||||
}
|
||||
|
||||
async fn on_response<'r>(&self, _request: &'r Request<'_>, response: &mut Response<'r>) {
|
||||
response.set_header(Header::new("Access-Control-Allow-Origin", "*"));
|
||||
response.set_header(Header::new(
|
||||
"Access-Control-Allow-Methods",
|
||||
"POST, GET, PATCH, OPTIONS",
|
||||
));
|
||||
response.set_header(Header::new("Access-Control-Allow-Headers", "*"));
|
||||
response.set_header(Header::new("Access-Control-Allow-Credentials", "true"));
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(thiserror::Error, Debug)]
|
||||
pub enum ServerError {
|
||||
#[error("rwlock error")]
|
||||
// 500
|
||||
Lock,
|
||||
#[error("no data")]
|
||||
// 503
|
||||
NoData,
|
||||
#[error("prometheus")]
|
||||
Prometheus(#[from] prometheus::Error),
|
||||
#[error("uri")]
|
||||
Uri,
|
||||
#[error(transparent)]
|
||||
Channel(#[from] tokio::sync::mpsc::error::SendError<InterfaceRequest>),
|
||||
}
|
||||
|
||||
impl From<rocket::http::uri::Error<'_>> for ServerError {
|
||||
fn from(_: rocket::http::uri::Error<'_>) -> Self {
|
||||
Self::Uri
|
||||
}
|
||||
}
|
||||
|
||||
impl<T> From<std::sync::PoisonError<T>> for ServerError {
|
||||
fn from(_: std::sync::PoisonError<T>) -> Self {
|
||||
Self::Lock
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a> rocket::response::Responder<'a, 'a> for ServerError {
|
||||
fn respond_to(self, _: &'a rocket::Request<'_>) -> rocket::response::Result<'a> {
|
||||
Err(match self {
|
||||
ServerError::NoData => rocket::http::Status::ServiceUnavailable,
|
||||
ServerError::Channel(_)
|
||||
| ServerError::Uri
|
||||
| ServerError::Lock
|
||||
| ServerError::Prometheus(_) => rocket::http::Status::InternalServerError,
|
||||
})
|
||||
}
|
||||
}
|
|
@ -1,473 +0,0 @@
|
|||
const api_url =
|
||||
window.location.protocol +
|
||||
"//" +
|
||||
window.location.hostname +
|
||||
":" +
|
||||
window.location.port;
|
||||
|
||||
Object.prototype.disable = function () {
|
||||
var that = this;
|
||||
for (var i = 0, len = that.length; i < len; i++) {
|
||||
that[i].disabled = true;
|
||||
}
|
||||
return that;
|
||||
};
|
||||
|
||||
Object.prototype.enable = function () {
|
||||
var that = this;
|
||||
for (var i = 0, len = that.length; i < len; i++) {
|
||||
that[i].disabled = false;
|
||||
}
|
||||
return that;
|
||||
};
|
||||
|
||||
function init_main() {
|
||||
refresh_interval = register(refresh);
|
||||
|
||||
refresh();
|
||||
document.addEventListener("visibilitychange", () => {
|
||||
if (document.hidden) {
|
||||
clearInterval(refresh_interval);
|
||||
} else {
|
||||
refresh();
|
||||
refresh_interval = register(refresh);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function init_pid() {
|
||||
refresh_gains();
|
||||
document.addEventListener("visibilitychange", () => {
|
||||
if (!document.hidden) {
|
||||
refresh_gains();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function init_shutoff() {
|
||||
refresh_shutoff();
|
||||
document.addEventListener("visibilitychange", () => {
|
||||
if (!document.hidden) {
|
||||
refresh_shutoff();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function init_info() {
|
||||
refresh_interval = register(refresh_info);
|
||||
|
||||
refresh_info();
|
||||
document.addEventListener("visibilitychange", () => {
|
||||
if (document.hidden) {
|
||||
clearInterval(refresh_interval);
|
||||
} else {
|
||||
refresh_info();
|
||||
refresh_interval = register(refresh_info);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function init_regulator() {
|
||||
refresh_interval = register(refresh_regulator_state);
|
||||
|
||||
refresh_regulator_state();
|
||||
document.addEventListener("visibilitychange", () => {
|
||||
if (document.hidden) {
|
||||
clearInterval(refresh_interval);
|
||||
} else {
|
||||
refresh_regulator_state();
|
||||
refresh_interval = register(refresh_regulator_state);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function register(func) {
|
||||
return setInterval(func, 5000);
|
||||
}
|
||||
|
||||
function flash() {
|
||||
fetch(api_url + "/flash", { method: "POST" });
|
||||
}
|
||||
|
||||
var is_automatic_control;
|
||||
var current_min_rate;
|
||||
var current_max_rate;
|
||||
|
||||
const delay = (time) => {
|
||||
return new Promise((resolve) => setTimeout(resolve, time));
|
||||
};
|
||||
|
||||
function set_minimum() {
|
||||
var set_button = document.getElementById("set-minimum");
|
||||
var number_input = document.getElementById("min-rate");
|
||||
if (!isNaN(number_input.value)) {
|
||||
set_button.disabled = true;
|
||||
number_input.disabled = true;
|
||||
fetch(api_url + "/set-min/" + number_input.value, { method: "POST" }).then(
|
||||
async (response) => {
|
||||
let delayres = await delay(100);
|
||||
refresh_buttons();
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function change_min() {
|
||||
var set_button = document.getElementById("set-minimum");
|
||||
var number_input = document.getElementById("min-rate");
|
||||
set_button.disabled = number_input.value == current_min_rate;
|
||||
}
|
||||
|
||||
function set_maximum() {
|
||||
var set_button = document.getElementById("set-maximum");
|
||||
var number_input = document.getElementById("max-rate");
|
||||
if (!isNaN(number_input.value)) {
|
||||
set_button.disabled = true;
|
||||
number_input.disabled = true;
|
||||
fetch(api_url + "/set-max/" + number_input.value, { method: "POST" }).then(
|
||||
async (response) => {
|
||||
let delayres = await delay(100);
|
||||
refresh_buttons();
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function change_max() {
|
||||
var set_button = document.getElementById("set-maximum");
|
||||
var number_input = document.getElementById("max-rate");
|
||||
set_button.disabled = number_input.value == current_max_rate;
|
||||
}
|
||||
|
||||
function set_proportional() {
|
||||
var set_button = document.getElementById("set-proportional");
|
||||
var number_input = document.getElementById("proportional-gain");
|
||||
if (!isNaN(number_input.value)) {
|
||||
set_button.disabled = true;
|
||||
number_input.disabled = true;
|
||||
fetch(api_url + "/pid-settings/proportional/" + number_input.value, {
|
||||
method: "POST",
|
||||
}).then(async (response) => {
|
||||
let delayres = await delay(100);
|
||||
refresh_gains();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function set_derivative() {
|
||||
var set_button = document.getElementById("set-derivative");
|
||||
var number_input = document.getElementById("derivative-gain");
|
||||
if (!isNaN(number_input.value)) {
|
||||
set_button.disabled = true;
|
||||
number_input.disabled = true;
|
||||
fetch(api_url + "/pid-settings/derivative/" + number_input.value, {
|
||||
method: "POST",
|
||||
}).then(async (response) => {
|
||||
let delayres = await delay(100);
|
||||
refresh_gains();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function set_shutoff_voltage() {
|
||||
var set_button = document.getElementById("set-shutoff-voltage");
|
||||
var number_input = document.getElementById("shutoff-voltage");
|
||||
if (!isNaN(number_input.value)) {
|
||||
set_button.disabled = true;
|
||||
number_input.disabled = true;
|
||||
fetch(api_url + "/shutoff/voltage/" + number_input.value, {
|
||||
method: "POST",
|
||||
}).then(async (response) => {
|
||||
let delayres = await delay(100);
|
||||
refresh_shutoff();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function set_shutoff_time() {
|
||||
var set_button = document.getElementById("set-shutoff-time");
|
||||
var number_input = document.getElementById("shutoff-time");
|
||||
if (!isNaN(number_input.value)) {
|
||||
set_button.disabled = true;
|
||||
number_input.disabled = true;
|
||||
fetch(api_url + "/shutoff/time/" + number_input.value, {
|
||||
method: "POST",
|
||||
}).then(async (response) => {
|
||||
let delayres = await delay(100);
|
||||
refresh_shutoff();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function set_regulator_state() {
|
||||
var set_button = document.getElementById("set-regulator-state");
|
||||
var state_input = document.getElementById("regstate");
|
||||
|
||||
set_button.disabled = true;
|
||||
state_input.disabled = true;
|
||||
|
||||
fetch(api_url + "/set-regulator-state/" + state_input.value, {
|
||||
method: "POST",
|
||||
}).then(async (response) => {
|
||||
let delayres = await delay(300);
|
||||
refresh_regulator_state();
|
||||
});
|
||||
}
|
||||
|
||||
function set_load_divisor() {
|
||||
var set_button = document.getElementById("set-load-divisor");
|
||||
var number_input = document.getElementById("load-divisor");
|
||||
if (!isNaN(number_input.value)) {
|
||||
set_button.disabled = true;
|
||||
number_input.disabled = true;
|
||||
fetch(api_url + "/pid-settings/load_divisor/" + number_input.value, {
|
||||
method: "POST",
|
||||
}).then(async (response) => {
|
||||
let delayres = await delay(100);
|
||||
refresh_gains();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function disable_automatic_control() {
|
||||
if (is_automatic_control) {
|
||||
document.getElementById("control-disabled").checked = true;
|
||||
document.body.classList.add("loading");
|
||||
|
||||
fetch(api_url + "/disable-control", { method: "POST" }).then(
|
||||
async (response) => {
|
||||
let delayres = await delay(100);
|
||||
refresh_buttons();
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function enable_automatic_control() {
|
||||
if (!is_automatic_control) {
|
||||
document.getElementById("control-enabled").checked = true;
|
||||
document.body.classList.add("loading");
|
||||
fetch(api_url + "/enable-control", { method: "POST" }).then(
|
||||
async (response) => {
|
||||
let delayres = await delay(100);
|
||||
refresh_buttons();
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function update_control_buttons(data) {
|
||||
current_max_rate = data.max_rate;
|
||||
current_min_rate = data.min_rate;
|
||||
|
||||
var number_input_min = document.getElementById("min-rate");
|
||||
if (number_input_min.disabled || number_input_min.value == "") {
|
||||
number_input_min.value = data.min_rate;
|
||||
number_input_min.disabled = false;
|
||||
}
|
||||
document.getElementById("set-minimum").disabled =
|
||||
number_input_min.value == current_min_rate;
|
||||
|
||||
var number_input_max = document.getElementById("max-rate");
|
||||
if (number_input_max.disabled || number_input_max.value == "") {
|
||||
number_input_max.value = data.max_rate;
|
||||
number_input_max.disabled = false;
|
||||
}
|
||||
document.getElementById("set-maximum").disabled =
|
||||
number_input_max.value == current_max_rate;
|
||||
|
||||
document.body.classList.remove("loading");
|
||||
is_automatic_control = data.control_enable;
|
||||
if (data.control_enable) {
|
||||
document.getElementById("control-enabled").checked = true;
|
||||
} else {
|
||||
document.getElementById("control-disabled").checked = true;
|
||||
}
|
||||
|
||||
var control_selector = document.getElementById("control-selector");
|
||||
if (data.is_charging) {
|
||||
if (control_selector.classList.contains("disabled")) {
|
||||
control_selector.classList.remove("disabled");
|
||||
}
|
||||
document.getElementsByName("control").enable();
|
||||
} else {
|
||||
if (!control_selector.classList.contains("disabled")) {
|
||||
control_selector.classList.add("disabled");
|
||||
}
|
||||
document.getElementsByName("control").disable();
|
||||
}
|
||||
}
|
||||
|
||||
function refresh_gains() {
|
||||
fetch(api_url + "/pid-settings/status")
|
||||
.then((response) => response.json())
|
||||
.then((json) => update_gains(json));
|
||||
}
|
||||
|
||||
function update_gains(data) {
|
||||
var proportional_set_button = document.getElementById("set-proportional");
|
||||
var proportional_number_input = document.getElementById("proportional-gain");
|
||||
proportional_set_button.disabled = false;
|
||||
proportional_number_input.disabled = false;
|
||||
proportional_number_input.value = data.proportional_gain;
|
||||
var derivative_set_button = document.getElementById("set-derivative");
|
||||
var derivative_number_input = document.getElementById("derivative-gain");
|
||||
derivative_set_button.disabled = false;
|
||||
derivative_number_input.disabled = false;
|
||||
derivative_number_input.value = data.derivative_gain;
|
||||
var load_divisor_button = document.getElementById("set-load-divisor");
|
||||
var load_divisor_input = document.getElementById("load-divisor");
|
||||
load_divisor_button.disabled = false;
|
||||
load_divisor_input.disabled = false;
|
||||
load_divisor_input.value = data.load_divisor;
|
||||
}
|
||||
|
||||
function refresh_shutoff() {
|
||||
fetch(api_url + "/shutoff/status")
|
||||
.then((response) => response.json())
|
||||
.then((json) => update_shutoff(json));
|
||||
}
|
||||
|
||||
function refresh_regulator_state() {
|
||||
var set_button = document.getElementById("set-regulator-state");
|
||||
var state_input = document.getElementById("regstate");
|
||||
|
||||
set_button.disabled = false;
|
||||
state_input.disabled = false;
|
||||
|
||||
fetch(api_url + "/regulator-state")
|
||||
.then((response) => response.json())
|
||||
.catch(() => null)
|
||||
.then((json) => update_regulator_state(json));
|
||||
}
|
||||
|
||||
function update_shutoff(data) {
|
||||
var voltage_set_button = document.getElementById("set-shutoff-voltage");
|
||||
var voltage_number_input = document.getElementById("shutoff-voltage");
|
||||
voltage_set_button.disabled = false;
|
||||
voltage_number_input.disabled = false;
|
||||
voltage_number_input.value = data.voltage;
|
||||
var time_set_button = document.getElementById("set-shutoff-time");
|
||||
var time_number_input = document.getElementById("shutoff-time");
|
||||
time_set_button.disabled = false;
|
||||
time_number_input.disabled = false;
|
||||
time_number_input.value = data.time;
|
||||
}
|
||||
|
||||
function update_regulator_state(state) {
|
||||
console.log(state);
|
||||
if (state == null) {
|
||||
var cur = "Unknown";
|
||||
} else {
|
||||
var cur = state.regulator_state;
|
||||
}
|
||||
|
||||
var current_state = document.getElementById("current-state");
|
||||
current_state.textContent = "Current state: " + cur;
|
||||
}
|
||||
|
||||
function refresh_buttons() {
|
||||
fetch(api_url + "/control-state")
|
||||
.then((response) => response.json())
|
||||
.then((json) => update_control_buttons(json));
|
||||
}
|
||||
|
||||
function refresh() {
|
||||
set_favicon(null);
|
||||
|
||||
refresh_buttons();
|
||||
|
||||
fetch(api_url + "/car-state")
|
||||
.then((response) => response.json())
|
||||
.then((json) => update_state(json));
|
||||
}
|
||||
|
||||
function refresh_info() {
|
||||
set_favicon(null);
|
||||
|
||||
fetch(api_url + "/car-state")
|
||||
.then((response) => response.json())
|
||||
.then((json) => update_info(json));
|
||||
}
|
||||
|
||||
function update_info(state) {
|
||||
set_favicon(state.charge_state);
|
||||
|
||||
var info_div = document.getElementById("info");
|
||||
while (info_div.childElementCount > 0) {
|
||||
info_div.removeChild(info_div.firstChild);
|
||||
}
|
||||
|
||||
el = document.createElement("p");
|
||||
|
||||
state_json = document.createElement("pre");
|
||||
state_json.appendChild(
|
||||
document.createTextNode(JSON.stringify(state, null, "\t"))
|
||||
);
|
||||
el.appendChild(state_json);
|
||||
info_div.appendChild(el);
|
||||
}
|
||||
|
||||
function update_state(state) {
|
||||
set_favicon(state.charge_state);
|
||||
|
||||
var info_div = document.getElementById("info");
|
||||
while (info_div.childElementCount > 0) {
|
||||
info_div.removeChild(info_div.firstChild);
|
||||
}
|
||||
|
||||
el = document.createElement("p");
|
||||
|
||||
var charging_state_info = "";
|
||||
|
||||
switch (state.charge_state.charging_state) {
|
||||
case "Charging":
|
||||
charging_state_info =
|
||||
"charging at " + state.charge_state.charge_rate + "A";
|
||||
break;
|
||||
case "Stopped":
|
||||
charging_state_info = "charge stopped";
|
||||
break;
|
||||
case "Disconnected":
|
||||
charging_state_info = "disconnected";
|
||||
break;
|
||||
}
|
||||
|
||||
if (state.location_data.home) {
|
||||
if (state.charge_state.charging_state == "Charging") {
|
||||
charging_state_info =
|
||||
"set at " +
|
||||
state.charge_state.charge_current_request +
|
||||
"A, " +
|
||||
charging_state_info;
|
||||
}
|
||||
charging_state_info = "At home; " + charging_state_info;
|
||||
} else {
|
||||
charging_state_info = "Not home; " + charging_state_info;
|
||||
}
|
||||
|
||||
el.appendChild(document.createTextNode(charging_state_info));
|
||||
|
||||
info_div.appendChild(el);
|
||||
}
|
||||
|
||||
function set_favicon(charge_state) {
|
||||
let favicon = document.getElementById("favicon");
|
||||
|
||||
favicon.setAttribute(
|
||||
"href",
|
||||
"data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 100 100%22><text y=%22.9em%22 font-size=%2290%22>" +
|
||||
get_emoji(charge_state) +
|
||||
"</text></svg>"
|
||||
);
|
||||
}
|
||||
|
||||
function get_emoji(charge_state) {
|
||||
if (charge_state == null) {
|
||||
return "⏳";
|
||||
} else if (charge_state.charge_rate > 0) {
|
||||
return "🔌";
|
||||
} else if (charge_state.battery_level < 60) {
|
||||
return "🪫";
|
||||
} else return "🔋";
|
||||
}
|
|
@ -1,101 +0,0 @@
|
|||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica,
|
||||
Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol";
|
||||
background-color: #333;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.container {
|
||||
max-width: 40rem;
|
||||
margin: auto;
|
||||
padding: 0.5rem 2rem;
|
||||
border-radius: 10px;
|
||||
background-color: #faf9fd;
|
||||
}
|
||||
|
||||
.outlink {
|
||||
display: block;
|
||||
font-weight: bold;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
a.outlink {
|
||||
text-decoration: none;
|
||||
color: rgb(52, 52, 246);
|
||||
}
|
||||
|
||||
a.outlink:hover {
|
||||
color: rgb(110, 100, 255);
|
||||
}
|
||||
|
||||
.loading,
|
||||
.loading * {
|
||||
cursor: progress;
|
||||
}
|
||||
|
||||
.disabled,
|
||||
.disabled * {
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.selector {
|
||||
padding: 1rem;
|
||||
background-color: gray;
|
||||
color: #333;
|
||||
width: max-content;
|
||||
border: 0.2rem;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
label {
|
||||
padding: 0.5rem 1rem;
|
||||
margin: 0.5rem;
|
||||
font-weight: bold;
|
||||
transition: all 0.2s 0s ease;
|
||||
border-radius: 4px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
input[type="radio"] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
input[type="radio"]:checked + label {
|
||||
background-color: white;
|
||||
}
|
||||
|
||||
input[type="radio"]:checked:disabled + label {
|
||||
background-color: #ddd;
|
||||
}
|
||||
|
||||
input[type="radio"]:disabled + label {
|
||||
color: #666;
|
||||
}
|
||||
|
||||
@media (width > 600px) {
|
||||
.container {
|
||||
margin-top: 2rem;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
body {
|
||||
background-color: #191919;
|
||||
}
|
||||
|
||||
.container {
|
||||
background-color: #444;
|
||||
color: #f0f0f0;
|
||||
}
|
||||
|
||||
a.outlink {
|
||||
text-decoration: none;
|
||||
/* color: rgb(152, 152, 242); */
|
||||
color: rgb(125, 125, 250);
|
||||
/* color: rgb(94, 94, 252); */
|
||||
}
|
||||
|
||||
a.outlink:hover {
|
||||
color: rgb(130, 120, 255);
|
||||
}
|
||||
}
|
7
watch.sh
Executable file
7
watch.sh
Executable file
|
@ -0,0 +1,7 @@
|
|||
#!/usr/bin/env bash
|
||||
|
||||
(
|
||||
trap 'kill 0' SIGINT
|
||||
cargo watch -w "src" -x check -s 'touch .trigger' &
|
||||
RUST_LOG=error,warn,info cargo watch --no-vcs-ignores -w .trigger -x "run -- --config-dir test-config watch"
|
||||
)
|
|
@ -37,8 +37,9 @@
|
|||
<a class="outlink" href="/regulator">Regulator control→</a>
|
||||
<a class="outlink" href="/pid">PID control variables→</a>
|
||||
<a class="outlink" href="/shutoff">Shutoff voltage control→</a>
|
||||
<a class="outlink" href="/reauthenticate">Reauthenticate→</a>
|
||||
</p>
|
||||
</div>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
</html>
|
|
@ -20,4 +20,4 @@
|
|||
</div>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
</html>
|
|
@ -30,4 +30,4 @@
|
|||
</div>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
</html>
|
|
@ -31,4 +31,4 @@
|
|||
</div>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
</html>
|
441
webapp/script.js
Normal file
441
webapp/script.js
Normal file
|
@ -0,0 +1,441 @@
|
|||
const api_url = window.location.protocol + "//" + window.location.hostname + ":" + window.location.port;
|
||||
|
||||
Object.prototype.disable = function () {
|
||||
var that = this;
|
||||
for (var i = 0, len = that.length; i < len; i++) {
|
||||
that[i].disabled = true;
|
||||
}
|
||||
return that;
|
||||
};
|
||||
|
||||
Object.prototype.enable = function () {
|
||||
var that = this;
|
||||
for (var i = 0, len = that.length; i < len; i++) {
|
||||
that[i].disabled = false;
|
||||
}
|
||||
return that;
|
||||
};
|
||||
|
||||
|
||||
function init_main() {
|
||||
refresh_interval = register(refresh);
|
||||
|
||||
refresh();
|
||||
document.addEventListener("visibilitychange", () => {
|
||||
if (document.hidden) {
|
||||
clearInterval(refresh_interval);
|
||||
} else {
|
||||
refresh();
|
||||
refresh_interval = register(refresh);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function init_pid() {
|
||||
refresh_gains();
|
||||
document.addEventListener("visibilitychange", () => {
|
||||
if (!document.hidden) {
|
||||
refresh_gains();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function init_shutoff() {
|
||||
refresh_shutoff();
|
||||
document.addEventListener("visibilitychange", () => {
|
||||
if (!document.hidden) {
|
||||
refresh_shutoff();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function init_info() {
|
||||
refresh_interval = register(refresh_info);
|
||||
|
||||
refresh_info();
|
||||
document.addEventListener("visibilitychange", () => {
|
||||
if (document.hidden) {
|
||||
clearInterval(refresh_interval);
|
||||
} else {
|
||||
refresh_info();
|
||||
refresh_interval = register(refresh_info);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function init_regulator() {
|
||||
|
||||
refresh_interval = register(refresh_regulator_state);
|
||||
|
||||
refresh_regulator_state();
|
||||
document.addEventListener("visibilitychange", () => {
|
||||
if (document.hidden) {
|
||||
clearInterval(refresh_interval);
|
||||
} else {
|
||||
refresh_regulator_state();
|
||||
refresh_interval = register(refresh_regulator_state);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function register(func) {
|
||||
return setInterval(func, 5000);
|
||||
}
|
||||
|
||||
function flash() {
|
||||
fetch(api_url + "/flash", { method: "POST" });
|
||||
}
|
||||
|
||||
var is_automatic_control;
|
||||
var current_min_rate;
|
||||
var current_max_rate;
|
||||
|
||||
const delay = (time) => {
|
||||
return new Promise(resolve => setTimeout(resolve, time));
|
||||
};
|
||||
|
||||
function set_minimum() {
|
||||
var set_button = document.getElementById("set-minimum");
|
||||
var number_input = document.getElementById("min-rate");
|
||||
if (!isNaN(number_input.value)) {
|
||||
set_button.disabled = true;
|
||||
number_input.disabled = true;
|
||||
fetch(api_url + "/set-min/" + number_input.value, { method: "POST" })
|
||||
.then(async (response) => {
|
||||
let delayres = await delay(100);
|
||||
refresh_buttons();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function change_min() {
|
||||
var set_button = document.getElementById("set-minimum");
|
||||
var number_input = document.getElementById("min-rate");
|
||||
set_button.disabled = (number_input.value == current_min_rate);
|
||||
}
|
||||
|
||||
function set_maximum() {
|
||||
var set_button = document.getElementById("set-maximum");
|
||||
var number_input = document.getElementById("max-rate");
|
||||
if (!isNaN(number_input.value)) {
|
||||
set_button.disabled = true;
|
||||
number_input.disabled = true;
|
||||
fetch(api_url + "/set-max/" + number_input.value, { method: "POST" })
|
||||
.then(async (response) => {
|
||||
let delayres = await delay(100);
|
||||
refresh_buttons();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function change_max() {
|
||||
var set_button = document.getElementById("set-maximum");
|
||||
var number_input = document.getElementById("max-rate");
|
||||
set_button.disabled = (number_input.value == current_max_rate);
|
||||
}
|
||||
|
||||
function set_proportional() {
|
||||
var set_button = document.getElementById("set-proportional");
|
||||
var number_input = document.getElementById("proportional-gain");
|
||||
if (!isNaN(number_input.value)) {
|
||||
set_button.disabled = true;
|
||||
number_input.disabled = true;
|
||||
fetch(api_url + "/pid-settings/proportional/" + number_input.value, { method: "POST" })
|
||||
.then(async (response) => {
|
||||
let delayres = await delay(100);
|
||||
refresh_gains();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function set_derivative() {
|
||||
var set_button = document.getElementById("set-derivative");
|
||||
var number_input = document.getElementById("derivative-gain");
|
||||
if (!isNaN(number_input.value)) {
|
||||
set_button.disabled = true;
|
||||
number_input.disabled = true;
|
||||
fetch(api_url + "/pid-settings/derivative/" + number_input.value, { method: "POST" })
|
||||
.then(async (response) => {
|
||||
let delayres = await delay(100);
|
||||
refresh_gains();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function set_shutoff_voltage() {
|
||||
var set_button = document.getElementById("set-shutoff-voltage");
|
||||
var number_input = document.getElementById("shutoff-voltage");
|
||||
if (!isNaN(number_input.value)) {
|
||||
set_button.disabled = true;
|
||||
number_input.disabled = true;
|
||||
fetch(api_url + "/shutoff/voltage/" + number_input.value, { method: "POST" })
|
||||
.then(async (response) => {
|
||||
let delayres = await delay(100);
|
||||
refresh_shutoff();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function set_shutoff_time() {
|
||||
var set_button = document.getElementById("set-shutoff-time");
|
||||
var number_input = document.getElementById("shutoff-time");
|
||||
if (!isNaN(number_input.value)) {
|
||||
set_button.disabled = true;
|
||||
number_input.disabled = true;
|
||||
fetch(api_url + "/shutoff/time/" + number_input.value, { method: "POST" })
|
||||
.then(async (response) => {
|
||||
let delayres = await delay(100);
|
||||
refresh_shutoff();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function set_regulator_state() {
|
||||
var set_button = document.getElementById("set-regulator-state");
|
||||
var state_input = document.getElementById("regstate");
|
||||
|
||||
set_button.disabled = true;
|
||||
state_input.disabled = true;
|
||||
|
||||
fetch(api_url + "/set-regulator-state/" + state_input.value, { method: "POST" })
|
||||
.then(async (response) => {
|
||||
let delayres = await delay(300);
|
||||
refresh_regulator_state();
|
||||
});
|
||||
|
||||
}
|
||||
|
||||
function set_load_divisor() {
|
||||
var set_button = document.getElementById("set-load-divisor");
|
||||
var number_input = document.getElementById("load-divisor");
|
||||
if (!isNaN(number_input.value)) {
|
||||
set_button.disabled = true;
|
||||
number_input.disabled = true;
|
||||
fetch(api_url + "/pid-settings/load_divisor/" + number_input.value, { method: "POST" })
|
||||
.then(async (response) => {
|
||||
let delayres = await delay(100);
|
||||
refresh_gains();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function disable_automatic_control() {
|
||||
if (is_automatic_control) {
|
||||
document.getElementById('control-disabled').checked = true;
|
||||
document.body.classList.add("loading");
|
||||
|
||||
fetch(api_url + "/disable-control", { method: "POST" })
|
||||
.then(async (response) => {
|
||||
let delayres = await delay(100);
|
||||
refresh_buttons();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function enable_automatic_control() {
|
||||
if (!is_automatic_control) {
|
||||
document.getElementById('control-enabled').checked = true;
|
||||
document.body.classList.add("loading");
|
||||
fetch(api_url + "/enable-control", { method: "POST" })
|
||||
.then(async (response) => {
|
||||
let delayres = await delay(100);
|
||||
refresh_buttons();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function update_control_buttons(data) {
|
||||
current_max_rate = data.max_rate;
|
||||
current_min_rate = data.min_rate;
|
||||
|
||||
var number_input_min = document.getElementById("min-rate");
|
||||
if (number_input_min.disabled || number_input_min.value == "") {
|
||||
number_input_min.value = data.min_rate;
|
||||
number_input_min.disabled = false;
|
||||
}
|
||||
document.getElementById("set-minimum").disabled = (number_input_min.value == current_min_rate);
|
||||
|
||||
var number_input_max = document.getElementById("max-rate");
|
||||
if (number_input_max.disabled || number_input_max.value == "") {
|
||||
number_input_max.value = data.max_rate;
|
||||
number_input_max.disabled = false;
|
||||
}
|
||||
document.getElementById("set-maximum").disabled = (number_input_max.value == current_max_rate);
|
||||
|
||||
document.body.classList.remove("loading");
|
||||
is_automatic_control = data.control_enable;
|
||||
if (data.control_enable) {
|
||||
document.getElementById('control-enabled').checked = true;
|
||||
} else {
|
||||
document.getElementById('control-disabled').checked = true;
|
||||
}
|
||||
|
||||
var control_selector = document.getElementById("control-selector");
|
||||
if (data.is_charging) {
|
||||
if (control_selector.classList.contains('disabled')) {
|
||||
control_selector.classList.remove('disabled');
|
||||
}
|
||||
document.getElementsByName('control').enable();
|
||||
}
|
||||
else {
|
||||
if (!control_selector.classList.contains('disabled')) {
|
||||
control_selector.classList.add('disabled');
|
||||
}
|
||||
document.getElementsByName('control').disable();
|
||||
}
|
||||
}
|
||||
|
||||
function refresh_gains() {
|
||||
fetch(api_url + "/pid-settings/status")
|
||||
.then((response) => response.json())
|
||||
.then((json) => update_gains(json));
|
||||
}
|
||||
|
||||
function update_gains(data) {
|
||||
var proportional_set_button = document.getElementById("set-proportional");
|
||||
var proportional_number_input = document.getElementById("proportional-gain");
|
||||
proportional_set_button.disabled = false;
|
||||
proportional_number_input.disabled = false;
|
||||
proportional_number_input.value = data.proportional_gain;
|
||||
var derivative_set_button = document.getElementById("set-derivative");
|
||||
var derivative_number_input = document.getElementById("derivative-gain");
|
||||
derivative_set_button.disabled = false;
|
||||
derivative_number_input.disabled = false;
|
||||
derivative_number_input.value = data.derivative_gain;
|
||||
var load_divisor_button = document.getElementById("set-load-divisor");
|
||||
var load_divisor_input = document.getElementById("load-divisor");
|
||||
load_divisor_button.disabled = false;
|
||||
load_divisor_input.disabled = false;
|
||||
load_divisor_input.value = data.load_divisor;
|
||||
}
|
||||
|
||||
function refresh_shutoff() {
|
||||
fetch(api_url + "/shutoff/status")
|
||||
.then((response) => response.json())
|
||||
.then((json) => update_shutoff(json));
|
||||
}
|
||||
|
||||
function refresh_regulator_state() {
|
||||
var set_button = document.getElementById("set-regulator-state");
|
||||
var state_input = document.getElementById("regstate");
|
||||
|
||||
set_button.disabled = false;
|
||||
state_input.disabled = false;
|
||||
|
||||
fetch(api_url + "/regulator-state")
|
||||
.then((response) => response.json())
|
||||
.catch(() => null)
|
||||
.then((json) => update_regulator_state(json));
|
||||
}
|
||||
|
||||
function update_shutoff(data) {
|
||||
var voltage_set_button = document.getElementById("set-shutoff-voltage");
|
||||
var voltage_number_input = document.getElementById("shutoff-voltage");
|
||||
voltage_set_button.disabled = false;
|
||||
voltage_number_input.disabled = false;
|
||||
voltage_number_input.value = data.voltage;
|
||||
var time_set_button = document.getElementById("set-shutoff-time");
|
||||
var time_number_input = document.getElementById("shutoff-time");
|
||||
time_set_button.disabled = false;
|
||||
time_number_input.disabled = false;
|
||||
time_number_input.value = data.time;
|
||||
}
|
||||
|
||||
function update_regulator_state(state) {
|
||||
console.log(state);
|
||||
if (state == null) { var cur = "Unknown" } else { var cur = state.regulator_state }
|
||||
|
||||
var current_state = document.getElementById("current-state");
|
||||
current_state.textContent = "Current state: " + cur;
|
||||
}
|
||||
|
||||
function refresh_buttons() {
|
||||
fetch(api_url + "/control-state")
|
||||
.then((response) => response.json())
|
||||
.then((json) => update_control_buttons(json));
|
||||
}
|
||||
|
||||
function refresh() {
|
||||
set_favicon(null);
|
||||
|
||||
refresh_buttons();
|
||||
|
||||
fetch(api_url + "/car-state")
|
||||
.then((response) => response.json())
|
||||
.then((json) => update_state(json));
|
||||
}
|
||||
|
||||
function refresh_info() {
|
||||
set_favicon(null);
|
||||
|
||||
fetch(api_url + "/car-state")
|
||||
.then((response) => response.json())
|
||||
.then((json) => update_info(json));
|
||||
}
|
||||
|
||||
function update_info(state) {
|
||||
set_favicon(state.charge_state);
|
||||
|
||||
var info_div = document.getElementById("info");
|
||||
while (info_div.childElementCount > 0) { info_div.removeChild(info_div.firstChild) }
|
||||
|
||||
el = document.createElement('p');
|
||||
|
||||
state_json = document.createElement('pre');
|
||||
state_json.appendChild(document.createTextNode(JSON.stringify(state, null, '\t')));
|
||||
el.appendChild(state_json);
|
||||
info_div.appendChild(el);
|
||||
}
|
||||
|
||||
function update_state(state) {
|
||||
set_favicon(state.charge_state);
|
||||
|
||||
var info_div = document.getElementById("info");
|
||||
while (info_div.childElementCount > 0) { info_div.removeChild(info_div.firstChild) }
|
||||
|
||||
el = document.createElement('p');
|
||||
|
||||
var charging_state_info = "";
|
||||
|
||||
switch (state.charge_state.charging_state) {
|
||||
case "Charging":
|
||||
charging_state_info = "charging at " + state.charge_state.charge_rate + "A";
|
||||
break;
|
||||
case "Stopped":
|
||||
charging_state_info = "charge stopped";
|
||||
break;
|
||||
case "Disconnected":
|
||||
charging_state_info = "disconnected"
|
||||
break;
|
||||
}
|
||||
|
||||
if (state.location_data.home) {
|
||||
if (state.charge_state.charging_state == "Charging") {
|
||||
charging_state_info = "set at " + state.charge_state.charge_current_request + "A, " + charging_state_info;
|
||||
}
|
||||
charging_state_info = "At home; " + charging_state_info;
|
||||
} else {
|
||||
charging_state_info = "Not home; " + charging_state_info;
|
||||
}
|
||||
|
||||
el.appendChild(document.createTextNode(charging_state_info));
|
||||
|
||||
info_div.appendChild(el);
|
||||
}
|
||||
|
||||
function set_favicon(charge_state) {
|
||||
let favicon = document.getElementById("favicon");
|
||||
|
||||
favicon.setAttribute("href", "data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 100 100%22><text y=%22.9em%22 font-size=%2290%22>" + get_emoji(charge_state) + "</text></svg>");
|
||||
}
|
||||
|
||||
function get_emoji(charge_state) {
|
||||
if (charge_state == null) {
|
||||
return "⏳";
|
||||
}
|
||||
else if (charge_state.charge_rate > 0) {
|
||||
return "🔌";
|
||||
} else if (charge_state.battery_level < 60) {
|
||||
return "🪫"
|
||||
} else return "🔋";
|
||||
}
|
|
@ -27,4 +27,4 @@
|
|||
</div>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
</html>
|
102
webapp/style.css
Normal file
102
webapp/style.css
Normal file
|
@ -0,0 +1,102 @@
|
|||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol";
|
||||
background-color: #333;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.container {
|
||||
max-width: 40rem;
|
||||
margin: auto;
|
||||
padding: 0.5rem 2rem;
|
||||
border-radius: 10px;
|
||||
background-color: #faf9fd;
|
||||
}
|
||||
|
||||
.outlink {
|
||||
display: block;
|
||||
font-weight: bold;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
a.outlink {
|
||||
text-decoration: none;
|
||||
color: rgb(52, 52, 246);
|
||||
}
|
||||
|
||||
a.outlink:hover {
|
||||
color: rgb(110, 100, 255);
|
||||
}
|
||||
|
||||
.loading,
|
||||
.loading * {
|
||||
cursor: progress;
|
||||
}
|
||||
|
||||
.disabled,
|
||||
.disabled * {
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.selector {
|
||||
padding: 1rem;
|
||||
background-color: gray;
|
||||
color: #333;
|
||||
width: max-content;
|
||||
border: 0.2rem;
|
||||
border-radius: 6px;
|
||||
|
||||
}
|
||||
|
||||
label {
|
||||
padding: 0.5rem 1rem;
|
||||
margin: 0.5rem;
|
||||
font-weight: bold;
|
||||
transition: all .2s 0s ease;
|
||||
border-radius: 4px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
input[type=radio] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
input[type=radio]:checked+label {
|
||||
background-color: white;
|
||||
}
|
||||
|
||||
input[type=radio]:checked:disabled+label {
|
||||
background-color: #ddd;
|
||||
}
|
||||
|
||||
input[type=radio]:disabled+label {
|
||||
color: #666;
|
||||
}
|
||||
|
||||
@media (width > 600px) {
|
||||
.container {
|
||||
margin-top: 2rem;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
body {
|
||||
background-color: #191919;
|
||||
}
|
||||
|
||||
.container {
|
||||
background-color: #444;
|
||||
color: #f0f0f0;
|
||||
}
|
||||
|
||||
a.outlink {
|
||||
text-decoration: none;
|
||||
/* color: rgb(152, 152, 242); */
|
||||
color: rgb(125, 125, 250);
|
||||
/* color: rgb(94, 94, 252); */
|
||||
}
|
||||
|
||||
a.outlink:hover {
|
||||
color: rgb(130, 120, 255);
|
||||
}
|
||||
}
|
Loading…
Add table
Reference in a new issue