Compare commits

..

No commits in common. "main" and "v1.5.0" have entirely different histories.
main ... v1.5.0

52 changed files with 2961 additions and 4691 deletions

View file

@ -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
View file

View file

@ -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
View file

@ -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"

View file

@ -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 }

Binary file not shown.

View file

@ -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

View file

@ -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

View file

@ -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 },
}

View file

@ -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(),
}
}
}
}

View file

@ -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,
}
}
}

View file

@ -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

View file

@ -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)
}
}

View file

@ -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());
}
}

View file

@ -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
}
}

View file

@ -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",
));
}
}

View file

@ -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)
}
}

View file

@ -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>

View file

@ -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);
});
}

View file

@ -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
View 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

View file

@ -1,7 +1,6 @@
[Unit]
Description=Tesla Charge Controller
After=network.target
Requires=charge-controller-supervisor.service
StartLimitIntervalSec=0
[Service]

View file

@ -1,3 +0,0 @@
[toolchain]
channel = "nightly-2025-01-16"
targets = ["aarch64-unknown-linux-musl"]

247
src/api_interface.rs Normal file
View 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)
}

View file

@ -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",

View file

@ -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();
}

View 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";

View 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]
}

View 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]
}
}

View file

@ -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();
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
View 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
View 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
View 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"));
}
}

View file

@ -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 {
.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
View 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)
}
}

View file

@ -1,92 +1,28 @@
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 fn is_charging(&self) -> bool {
self.charge_state
.as_ref()
.is_some_and(|v| v.charging_state == ChargingState::Charging)
}
}
pub async fn is_charging(&self) -> bool {
self.charge_state()
.await
.is_some_and(ChargeState::is_charging)
#[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,
}
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
}
}
// impl CarState {
// pub fn charging_state(&self) -> ChargingState {
// match self {
// CarState::Unavailable => ChargingState::Unavailable,
// CarState::Available {
// charge_state,
// data_received: _,
// } => charge_state.charging_state,
// }
// }
// }
#[derive(Serialize, Deserialize, Debug, Clone)]
pub struct ChargeState {
pub battery_heater_on: bool,
@ -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
View 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

View file

@ -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

View file

@ -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:?}");
}
}
}

View file

@ -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(())
}

View file

@ -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,
})
}
}

View file

@ -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 "🔋";
}

View file

@ -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
View 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"
)

View file

@ -37,6 +37,7 @@
<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>

441
webapp/script.js Normal file
View 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 "🔋";
}

102
webapp/style.css Normal file
View 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);
}
}