cleanup
This commit is contained in:
		
							
								
								
									
										10
									
								
								bootloader/.gitignore
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										10
									
								
								bootloader/.gitignore
									
									
									
									
										vendored
									
									
								
							| @@ -1,10 +0,0 @@ | ||||
| # ESP-IDF build artifacts | ||||
| build/ | ||||
| .sdkconfig* | ||||
| CMakeFiles/ | ||||
| CMakeCache.txt | ||||
| cmake-build-*/ | ||||
| *.log | ||||
| *.bin | ||||
| *.elf | ||||
| *.map | ||||
							
								
								
									
										8
									
								
								bootloader/.idea/.gitignore
									
									
									
										generated
									
									
										vendored
									
									
								
							
							
						
						
									
										8
									
								
								bootloader/.idea/.gitignore
									
									
									
										generated
									
									
										vendored
									
									
								
							| @@ -1,8 +0,0 @@ | ||||
| # Default ignored files | ||||
| /shelf/ | ||||
| /workspace.xml | ||||
| # Editor-based HTTP Client requests | ||||
| /httpRequests/ | ||||
| # Datasource local storage ignored files | ||||
| /dataSources/ | ||||
| /dataSources.local.xml | ||||
							
								
								
									
										8
									
								
								bootloader/.idea/bootloader.iml
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										8
									
								
								bootloader/.idea/bootloader.iml
									
									
									
										generated
									
									
									
								
							| @@ -1,8 +0,0 @@ | ||||
| <?xml version="1.0" encoding="UTF-8"?> | ||||
| <module type="EMPTY_MODULE" version="4"> | ||||
|   <component name="NewModuleRootManager"> | ||||
|     <content url="file://$MODULE_DIR$" /> | ||||
|     <orderEntry type="inheritedJdk" /> | ||||
|     <orderEntry type="sourceFolder" forTests="false" /> | ||||
|   </component> | ||||
| </module> | ||||
							
								
								
									
										8
									
								
								bootloader/.idea/modules.xml
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										8
									
								
								bootloader/.idea/modules.xml
									
									
									
										generated
									
									
									
								
							| @@ -1,8 +0,0 @@ | ||||
| <?xml version="1.0" encoding="UTF-8"?> | ||||
| <project version="4"> | ||||
|   <component name="ProjectModuleManager"> | ||||
|     <modules> | ||||
|       <module fileurl="file://$PROJECT_DIR$/.idea/bootloader.iml" filepath="$PROJECT_DIR$/.idea/bootloader.iml" /> | ||||
|     </modules> | ||||
|   </component> | ||||
| </project> | ||||
							
								
								
									
										7
									
								
								bootloader/.idea/vcs.xml
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										7
									
								
								bootloader/.idea/vcs.xml
									
									
									
										generated
									
									
									
								
							| @@ -1,7 +0,0 @@ | ||||
| <?xml version="1.0" encoding="UTF-8"?> | ||||
| <project version="4"> | ||||
|   <component name="VcsDirectoryMappings"> | ||||
|     <mapping directory="$PROJECT_DIR$/.." vcs="Git" /> | ||||
|     <mapping directory="$PROJECT_DIR$/../website/themes/blowfish" vcs="Git" /> | ||||
|   </component> | ||||
| </project> | ||||
| @@ -1,11 +0,0 @@ | ||||
| cmake_minimum_required(VERSION 3.16) | ||||
|  | ||||
| # Minimal ESP-IDF project to build only the bootloader | ||||
| # You must have ESP-IDF installed and IDF_PATH exported. | ||||
|  | ||||
| # Pin the target to ESP32-C6 to ensure correct bootloader build | ||||
| # (must be set before including project.cmake) | ||||
| set(IDF_TARGET "esp32c6") | ||||
|  | ||||
| include($ENV{IDF_PATH}/tools/cmake/project.cmake) | ||||
| project(custom_bootloader) | ||||
| @@ -1,43 +0,0 @@ | ||||
| Custom ESP-IDF Bootloader (Rollback Enabled) | ||||
|  | ||||
| This minimal project builds a custom ESP-IDF bootloader with rollback support enabled. | ||||
| You can flash it later alongside a Rust firmware using `espflash`. | ||||
|  | ||||
| What this provides | ||||
| - A minimal ESP-IDF project (CMake) that can build just the bootloader. | ||||
| - Rollback support enabled via sdkconfig.defaults (CONFIG_BOOTLOADER_APP_ROLLBACK_ENABLE=y). | ||||
| - A sample OTA partition table (partitions.csv) suitable for OTA and rollback (otadata + two OTA slots). | ||||
| - A convenience script to build the bootloader for the desired target. | ||||
|  | ||||
| Requirements | ||||
| - ESP-IDF installed and set up (IDF_PATH exported, Python env activated). | ||||
| - A selected target (esp32, esp32s3, esp32c3, etc.). | ||||
|  | ||||
| Build | ||||
| 1) Ensure ESP-IDF is set up: | ||||
|    source "$IDF_PATH/export.sh" | ||||
|  | ||||
| 2) Pick a target (examples): | ||||
|    idf.py set-target esp32 | ||||
|    # or use the script: | ||||
|    ./build_bootloader.sh esp32 | ||||
|  | ||||
| 3) Build only the bootloader: | ||||
|    idf.py bootloader | ||||
|    # or using the script (which also supports setting target): | ||||
|    ./build_bootloader.sh esp32 | ||||
|  | ||||
| Artifacts | ||||
| - build/bootloader/bootloader.bin | ||||
|  | ||||
| Using with espflash (Rust) | ||||
| - For a no_std Rust firmware, you can pass this custom bootloader to espflash: | ||||
|   espflash flash --bootloader build/bootloader/bootloader.bin \ | ||||
|                  --partition-table partitions.csv \ | ||||
|                  <your-app-binary-or-elf> | ||||
|  | ||||
| Notes | ||||
| - Rollback logic requires an OTA layout (otadata + at least two OTA app partitions). The provided partitions.csv is a starting point; adjust sizes/offsets to match your needs. | ||||
| - This project doesn’t build an application; it exists solely to produce a bootloader with the right configuration. | ||||
| - If you need different log verbosity or features, run `idf.py menuconfig` and then diff/port the changes back into sdkconfig.defaults. | ||||
| - Targets supported depend on your ESP-IDF version. Use `idf.py set-target <chip>` or `./build_bootloader.sh <chip>`. | ||||
| @@ -1,41 +0,0 @@ | ||||
| #!/usr/bin/env bash | ||||
| set -euo pipefail | ||||
|  | ||||
| # Build script for custom ESP-IDF bootloader with rollback enabled. | ||||
| # Requirements: | ||||
| #   - ESP-IDF installed | ||||
| #   - IDF_PATH exported | ||||
| #   - Python env prepared (the usual ESP-IDF setup) | ||||
| # Usage: | ||||
| #   ./build_bootloader.sh [esp32|esp32s3|esp32c3|esp32s2|esp32c2|esp32c6|esp32h2] | ||||
| #   If target is omitted, the last configured target will be used. | ||||
|  | ||||
| TARGET=${1:-} | ||||
|  | ||||
| if [[ -z "${IDF_PATH:-}" ]]; then | ||||
|   echo "ERROR: IDF_PATH is not set. Please install ESP-IDF and export the environment (source export.sh)." >&2 | ||||
|   exit 1 | ||||
| fi | ||||
|  | ||||
| # shellcheck source=/dev/null | ||||
| source "$IDF_PATH/export.sh" | ||||
|  | ||||
| if [[ -n "$TARGET" ]]; then | ||||
|   idf.py set-target "$TARGET" | ||||
| fi | ||||
|  | ||||
| # Ensure sdkconfig.defaults is considered (ESP-IDF does this automatically). | ||||
| # Build only the bootloader. | ||||
| idf.py bootloader | ||||
|  | ||||
| echo | ||||
| BOOTLOADER_BIN="build/bootloader/bootloader.bin" | ||||
| if [[ -f "$BOOTLOADER_BIN" ]]; then | ||||
|   echo "Bootloader built: $BOOTLOADER_BIN" | ||||
|   echo "You can use this with espflash via:" | ||||
|   echo "  espflash flash --bootloader $BOOTLOADER_BIN [--partition-table partitions.csv] <your-app-binary>" | ||||
| else | ||||
|   echo "ERROR: Bootloader binary not found. Check build logs above." >&2 | ||||
|   exit 2 | ||||
| fi | ||||
| cp build/bootloader/bootloader.bin ../rust/bootloader.bin | ||||
| @@ -1 +0,0 @@ | ||||
| idf_component_register(SRCS "dummy.c" INCLUDE_DIRS ".") | ||||
| @@ -1,4 +0,0 @@ | ||||
| // This file intentionally left almost empty. | ||||
| // ESP-IDF expects at least one component; the bootloader build does not use this. | ||||
|  | ||||
| void __unused_dummy_symbol(void) {} | ||||
| @@ -1,6 +0,0 @@ | ||||
| nvs,      data, nvs,     ,        16k, | ||||
| otadata,  data, ota,     ,        8k, | ||||
| phy_init, data, phy,     ,        4k, | ||||
| ota_0,    app,  ota_0,   ,        3968k, | ||||
| ota_1,    app,  ota_1,   ,        3968k, | ||||
| storage,  data, littlefs,,        8M, | ||||
| 
 | 
							
								
								
									
										2385
									
								
								bootloader/sdkconfig
									
									
									
									
									
								
							
							
						
						
									
										2385
									
								
								bootloader/sdkconfig
									
									
									
									
									
								
							
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							| @@ -1,17 +0,0 @@ | ||||
| # Target can be set with: idf.py set-target esp32|esp32s3|esp32c3|... | ||||
| # If not set via idf.py, ESP-IDF may default to a target; it's recommended to set it explicitly. | ||||
|  | ||||
| # Explicitly pin target to ESP32-C6 | ||||
| CONFIG_IDF_TARGET="esp32c6" | ||||
| CONFIG_IDF_TARGET_ESP32C6=y | ||||
| CONFIG_IDF_TARGET_ARCH_RISCV=y | ||||
|  | ||||
| # Bootloader configuration | ||||
| CONFIG_BOOTLOADER_APP_ROLLBACK_ENABLE=y | ||||
| CONFIG_BOOTLOADER_LOG_LEVEL_INFO=y | ||||
| # Slightly faster boot by skipping GPIO checks unless you need that feature | ||||
| CONFIG_BOOTLOADER_SKIP_VALIDATE_IN_DEEP_SLEEP=y | ||||
|  | ||||
| # Partition table config is not required to build bootloader, but shown for clarity when you build full app later | ||||
| # CONFIG_PARTITION_TABLE_CUSTOM_FILENAME="partitions.csv" | ||||
| # CONFIG_PARTITION_TABLE_FILENAME="partitions.csv" | ||||
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							| @@ -1,31 +0,0 @@ | ||||
| [build] | ||||
| rustflags = [ | ||||
|     # Required to obtain backtraces (e.g. when using the "esp-backtrace" crate.) | ||||
|     # NOTE: May negatively impact performance of produced code | ||||
|     "-C", "force-frame-pointers", | ||||
|     "-Z", "stack-protector=all", | ||||
|     "-C", "link-arg=-Tlinkall.x", | ||||
| ] | ||||
|  | ||||
| target = "riscv32imac-unknown-none-elf" | ||||
|  | ||||
| [target.riscv32imac-unknown-none-elf] | ||||
| #runner = "espflash flash --monitor --bootloader bootloader.bin --chip esp32c6 --baud 921600 --partition-table partitions.csv" | ||||
| #runner = "espflash flash --monitor --baud 921600 --partition-table partitions.csv -b no-reset" # Select this runner in case of usb ttl | ||||
| #runner = "espflash flash --monitor" | ||||
| #runner = "cargo runner" | ||||
|  | ||||
|  | ||||
| #runner = "espflash flash --monitor --partition-table partitions.csv -b no-reset" # create upgrade image file for webupload | ||||
| # runner = espflash erase-parts otadata  //ensure flash is clean | ||||
|  | ||||
| [env] | ||||
| CHRONO_TZ_TIMEZONE_FILTER = "UTC|America/New_York|America/Chicago|America/Los_Angeles|Europe/London|Europe/Berlin|Europe/Paris|Asia/Tokyo|Asia/Shanghai|Asia/Kolkata|Australia/Sydney|America/Sao_Paulo|Africa/Johannesburg|Asia/Dubai|Pacific/Auckland" | ||||
| CARGO_WORKSPACE_DIR = { value = "", relative = true } | ||||
| ESP_LOG = "info" | ||||
|  | ||||
|  | ||||
|  | ||||
| [unstable] | ||||
| build-std = ["alloc", "core"] | ||||
|  | ||||
							
								
								
									
										8
									
								
								rust/.idea/.gitignore
									
									
									
										generated
									
									
										vendored
									
									
								
							
							
						
						
									
										8
									
								
								rust/.idea/.gitignore
									
									
									
										generated
									
									
										vendored
									
									
								
							| @@ -1,8 +0,0 @@ | ||||
| # Default ignored files | ||||
| /shelf/ | ||||
| /workspace.xml | ||||
| # Editor-based HTTP Client requests | ||||
| /httpRequests/ | ||||
| # Datasource local storage ignored files | ||||
| /dataSources/ | ||||
| /dataSources.local.xml | ||||
							
								
								
									
										18
									
								
								rust/.idea/dictionaries/project.xml
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										18
									
								
								rust/.idea/dictionaries/project.xml
									
									
									
										generated
									
									
									
								
							| @@ -1,18 +0,0 @@ | ||||
| <component name="ProjectDictionaryState"> | ||||
|   <dictionary name="project"> | ||||
|     <words> | ||||
|       <w>boardtest</w> | ||||
|       <w>buildtime</w> | ||||
|       <w>deepsleep</w> | ||||
|       <w>githash</w> | ||||
|       <w>lamptest</w> | ||||
|       <w>lightstate</w> | ||||
|       <w>mppt</w> | ||||
|       <w>plantstate</w> | ||||
|       <w>pumptest</w> | ||||
|       <w>sntp</w> | ||||
|       <w>vergen</w> | ||||
|       <w>wifiscan</w> | ||||
|     </words> | ||||
|   </dictionary> | ||||
| </component> | ||||
							
								
								
									
										12
									
								
								rust/.idea/inspectionProfiles/Project_Default.xml
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										12
									
								
								rust/.idea/inspectionProfiles/Project_Default.xml
									
									
									
										generated
									
									
									
								
							| @@ -1,12 +0,0 @@ | ||||
| <component name="InspectionProjectProfileManager"> | ||||
|   <profile version="1.0"> | ||||
|     <option name="myName" value="Project Default" /> | ||||
|     <inspection_tool class="DuplicatedCode" enabled="true" level="WEAK WARNING" enabled_by_default="true"> | ||||
|       <Languages> | ||||
|         <language minSize="102" name="Rust" /> | ||||
|       </Languages> | ||||
|     </inspection_tool> | ||||
|     <inspection_tool class="Eslint" enabled="true" level="WARNING" enabled_by_default="true" /> | ||||
|     <inspection_tool class="NewCrateVersionAvailable" enabled="true" level="INFORMATION" enabled_by_default="true" /> | ||||
|   </profile> | ||||
| </component> | ||||
							
								
								
									
										8
									
								
								rust/.idea/modules.xml
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										8
									
								
								rust/.idea/modules.xml
									
									
									
										generated
									
									
									
								
							| @@ -1,8 +0,0 @@ | ||||
| <?xml version="1.0" encoding="UTF-8"?> | ||||
| <project version="4"> | ||||
|   <component name="ProjectModuleManager"> | ||||
|     <modules> | ||||
|       <module fileurl="file://$PROJECT_DIR$/.idea/plant-ctrl2.iml" filepath="$PROJECT_DIR$/.idea/plant-ctrl2.iml" /> | ||||
|     </modules> | ||||
|   </component> | ||||
| </project> | ||||
							
								
								
									
										11
									
								
								rust/.idea/plant-ctrl2.iml
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										11
									
								
								rust/.idea/plant-ctrl2.iml
									
									
									
										generated
									
									
									
								
							| @@ -1,11 +0,0 @@ | ||||
| <?xml version="1.0" encoding="UTF-8"?> | ||||
| <module type="EMPTY_MODULE" version="4"> | ||||
|   <component name="NewModuleRootManager"> | ||||
|     <content url="file://$MODULE_DIR$"> | ||||
|       <sourceFolder url="file://$MODULE_DIR$/src" isTestSource="false" /> | ||||
|       <excludeFolder url="file://$MODULE_DIR$/target" /> | ||||
|     </content> | ||||
|     <orderEntry type="inheritedJdk" /> | ||||
|     <orderEntry type="sourceFolder" forTests="false" /> | ||||
|   </component> | ||||
| </module> | ||||
							
								
								
									
										7
									
								
								rust/.idea/vcs.xml
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										7
									
								
								rust/.idea/vcs.xml
									
									
									
										generated
									
									
									
								
							| @@ -1,7 +0,0 @@ | ||||
| <?xml version="1.0" encoding="UTF-8"?> | ||||
| <project version="4"> | ||||
|   <component name="VcsDirectoryMappings"> | ||||
|     <mapping directory="$PROJECT_DIR$/.." vcs="Git" /> | ||||
|     <mapping directory="$PROJECT_DIR$/../website/themes/blowfish" vcs="Git" /> | ||||
|   </component> | ||||
| </project> | ||||
							
								
								
									
										162
									
								
								rust/Cargo.toml
									
									
									
									
									
								
							
							
						
						
									
										162
									
								
								rust/Cargo.toml
									
									
									
									
									
								
							| @@ -1,162 +0,0 @@ | ||||
| [package] | ||||
| edition = "2021" | ||||
| name = "plant-ctrl2" | ||||
| rust-version = "1.86" | ||||
| version = "0.1.0" | ||||
|  | ||||
| # Explicitly configure the binary target and disable building it as a test/bench. | ||||
| [[bin]] | ||||
| name = "plant-ctrl2" | ||||
| path = "src/main.rs" | ||||
| # Prevent IDEs/Cargo from trying to compile a test harness for this no_std binary. | ||||
| test = false | ||||
| bench = false | ||||
| doc = false | ||||
|  | ||||
| #this strips the bootloader, we need that tho | ||||
| #strip = true | ||||
|  | ||||
| [profile.dev] | ||||
| lto = "fat" | ||||
| debug = false | ||||
| overflow-checks = true | ||||
| panic = "abort" | ||||
| incremental = true | ||||
| opt-level = "z" | ||||
|  | ||||
| [profile.release] | ||||
| lto = "fat" | ||||
| #debug = false | ||||
| overflow-checks = true | ||||
| panic = "abort" | ||||
| incremental = false | ||||
| opt-level = "z" | ||||
|  | ||||
| [package.metadata.espflash] | ||||
| partition_table = "partitions.csv" | ||||
|  | ||||
|  | ||||
| [dependencies] | ||||
| # Shared CAN API | ||||
| canapi = { path = "canapi" } | ||||
| #ESP stuff | ||||
| esp-bootloader-esp-idf = { version = "0.2.0", features = ["esp32c6"] } | ||||
| esp-hal = { version = "=1.0.0-rc.0", features = [ | ||||
|     "esp32c6", | ||||
|     "log-04", | ||||
|     "unstable", | ||||
|     "rt" | ||||
| ] } | ||||
| log = "0.4.27" | ||||
|  | ||||
| embassy-net = { version = "0.7.1", default-features = false, features = [ | ||||
|     "dhcpv4", | ||||
|     "log", | ||||
|     "medium-ethernet", | ||||
|     "tcp", | ||||
|     "udp", | ||||
|     "proto-ipv4", | ||||
|     "dns" | ||||
| ] } | ||||
| embedded-io = "0.6.1" | ||||
| embedded-io-async = "0.6.1" | ||||
| esp-alloc = "0.8.0" | ||||
| esp-backtrace = { version = "0.17.0", features = [ | ||||
|     "esp32c6", | ||||
|     "exception-handler", | ||||
|     "panic-handler", | ||||
|     "println", | ||||
|     "colors", | ||||
|     "custom-halt" | ||||
| ] } | ||||
| esp-println = { version = "0.15.0", features = ["esp32c6", "log-04"] } | ||||
| # for more networking protocol support see https://crates.io/crates/edge-net | ||||
| embassy-executor = { version = "0.7.0", features = [ | ||||
|     "log", | ||||
|     "task-arena-size-64", | ||||
|     "nightly" | ||||
| ] } | ||||
| embassy-time = { version = "0.5.0", features = ["log"], default-features = false } | ||||
| esp-hal-embassy = { version = "0.9.0", features = ["esp32c6", "log-04"] } | ||||
| esp-storage = { version = "0.7.0", features = ["esp32c6"] } | ||||
|  | ||||
| esp-wifi = { version = "0.15.0", features = [ | ||||
|     "builtin-scheduler", | ||||
|     "esp-alloc", | ||||
|     "esp32c6", | ||||
|     "log-04", | ||||
|     "smoltcp", | ||||
|     "wifi", | ||||
| ] } | ||||
| smoltcp = { version = "0.12.0", default-features = false, features = [ | ||||
|     "alloc", | ||||
|     "log", | ||||
|     "medium-ethernet", | ||||
|     "multicast", | ||||
|     "proto-dhcpv4", | ||||
|     "proto-ipv6", | ||||
|     "proto-dns", | ||||
|     "proto-ipv4", | ||||
|     "socket-dns", | ||||
|     "socket-icmp", | ||||
|     "socket-raw", | ||||
|     "socket-tcp", | ||||
|     "socket-udp", | ||||
| ] } | ||||
| #static_cell = "2.1.1" | ||||
| embedded-hal = "1.0.0" | ||||
| embedded-hal-bus = { version = "0.3.0" } | ||||
|  | ||||
| #Hardware additional driver | ||||
|  | ||||
| #bq34z100 = { version = "0.3.0", default-features = false } | ||||
| onewire = "0.4.0" | ||||
| #strum = { version = "0.27.0", default-feature = false, features = ["derive"] } | ||||
| measurements = "0.11.0" | ||||
| ds323x = "0.6.0" | ||||
|  | ||||
| #json | ||||
| serde = { version = "1.0.219", features = ["derive", "alloc"], default-features = false } | ||||
| serde_json = { version = "1.0.143", default-features = false, features = ["alloc"] } | ||||
|  | ||||
| chrono = { version = "0.4.42", default-features = false, features = ["iana-time-zone", "alloc", "serde"] } | ||||
| chrono-tz = { version = "0.10.4", default-features = false, features = ["filter-by-regex"] } | ||||
| eeprom24x = "0.7.2" | ||||
| crc = "3.2.1" | ||||
| strum_macros = "0.27.0" | ||||
| unit-enum = "1.4.1" | ||||
| pca9535 = { version = "2.0.0" } | ||||
| ina219 = { version = "0.2.0" } | ||||
| embedded-storage = "=0.3.1" | ||||
| portable-atomic = "1.11.1" | ||||
| embassy-sync = { version = "0.7.2", features = ["log"] } | ||||
| async-trait = "0.1.89" | ||||
| bq34z100 = { version = "0.4.0", default-features = false } | ||||
| edge-dhcp = "0.6.0" | ||||
| edge-nal = "0.5.0" | ||||
| edge-nal-embassy = "0.6.0" | ||||
| static_cell = "2.1.1" | ||||
| edge-http = { version = "0.6.1", features = ["log"] } | ||||
| littlefs2 = { version = "0.6.1", features = ["c-stubs", "alloc"] } | ||||
| littlefs2-core = "0.1.1" | ||||
| bytemuck = { version = "1.23.2", features = ["derive", "min_const_generics", "pod_saturating", "extern_crate_alloc"] } | ||||
| deranged = "0.5.3" | ||||
| embassy-embedded-hal = "0.5.0" | ||||
| bincode = { version = "2.0.1", default-features = false, features = ["derive"] } | ||||
| sntpc = { version = "0.6.0", default-features = false, features = ["log", "embassy-socket", "embassy-socket-ipv6"] } | ||||
| option-lock = { version = "0.3.1", default-features = false } | ||||
|  | ||||
| #stay in sync with mcutie version here! | ||||
| heapless = { version = "0.7.17", features = ["serde"] } | ||||
| mcutie = { version = "0.3.0", default-features = false, features = ["log", "homeassistant"] } | ||||
| nb = "1.1.0" | ||||
| embedded-can = "0.4.1" | ||||
|  | ||||
|  | ||||
|  | ||||
| [patch.crates-io] | ||||
| mcutie = { git = 'https://github.com/empirephoenix/mcutie.git' } | ||||
| #bq34z100 = { path = "../../bq34z100_rust" } | ||||
|  | ||||
| [build-dependencies] | ||||
| vergen = { version = "8.2.6", features = ["build", "git", "gitcl"] } | ||||
							
								
								
									
										12
									
								
								rust/all.sh
									
									
									
									
									
								
							
							
						
						
									
										12
									
								
								rust/all.sh
									
									
									
									
									
								
							| @@ -1,12 +0,0 @@ | ||||
| rm ./src/webserver/index.html.gz | ||||
| rm ./src/webserver/bundle.js.gz | ||||
| set -e | ||||
| cd ./src_webpack/ | ||||
| npx webpack build | ||||
| cp index.html.gz ../src/webserver/index.html.gz | ||||
| cp bundle.js.gz ../src/webserver/bundle.js.gz | ||||
| cd ../ | ||||
|  | ||||
| cargo build --release | ||||
| espflash save-image --bootloader bootloader.bin --partition-table partitions.csv --chip esp32c6 target/riscv32imac-unknown-none-elf/release/plant-ctrl2 image.bin | ||||
| espflash flash --monitor --bootloader bootloader.bin --chip esp32c6 --baud 921600 --partition-table partitions.csv  target/riscv32imac-unknown-none-elf/release/plant-ctrl2 | ||||
										
											Binary file not shown.
										
									
								
							
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							| @@ -1,197 +0,0 @@ | ||||
| ;-------------------------------------------------------- | ||||
| ;Verify Existing Firmware Version | ||||
| ;-------------------------------------------------------- | ||||
| W: AA 00 01 00 | ||||
| C: AA 00 01 00 | ||||
| W: AA 00 02 00 | ||||
| C: AA 00 02 00 | ||||
| ;-------------------------------------------------------- | ||||
| ;Unseal device | ||||
| ;-------------------------------------------------------- | ||||
| W: AA 00 14 04 | ||||
| W: AA 00 72 36 | ||||
| W: AA 00 FF FF | ||||
| W: AA 00 FF FF | ||||
| X: 1000 | ||||
| ;-------------------------------------------------------- | ||||
| ;Go To ROM Mode | ||||
| ;-------------------------------------------------------- | ||||
| W: AA 00 00 0F | ||||
| X: 1000 | ||||
| ;-------------------------------------------------------- | ||||
| ;Data Block | ||||
| ;-------------------------------------------------------- | ||||
| W: 16 00 03 00 00 | ||||
| W: 16 64 03 00 | ||||
| X: 20 | ||||
| C: 16 66 00 | ||||
| W: 16 00 02 00 00 00 EA FF 33 FA FA 33 B5 FB 33 95 FE 33 AD FE 33 FF FF 3F FF FF 3F FF FF 3F FF FF 3F FF FF 3F FF FF 3F FF FF 3F FF FF 3F FF FF 3F FF FF 3F FF FF 3F 02 02 3F 58 CB 33 C3 A0 08 1F 00 00 00 00 00 FF AA 0E FB A7 0E FF A6 0E FF A1 0E FF A0 0E FE A3 0E 54 A2 0E DE FF 3A 67 FF 3A E2 FF 33 FF AF 0E | ||||
| W: 16 64 3D 38 | ||||
| X: 2 | ||||
| C: 16 66 00 | ||||
| W: 16 00 02 01 00 00 01 4F 03 3F 11 0C D9 FF 30 E0 FF 35 2F 10 0C E0 FF 35 FF FF 23 E3 AF 04 E2 BF 04 D1 FF 36 E2 A0 04 E3 A1 04 00 C7 02 01 C6 02 E5 AF 04 E3 BF 01 E4 AF 04 E2 BF 01 FF AC 0E E1 1C 04 8A FF 31 E2 A0 04 E3 A1 04 02 AB 18 7F 2B 0E FF 1B 0E BF FF 32 FC FA 0E FB AF 0C 02 2B 18 FF 1B 0E 98 FF 36 | ||||
| W: 16 64 AE 2C | ||||
| X: 2 | ||||
| C: 16 66 00 | ||||
| W: 16 00 05 | ||||
| W: 16 64 05 00 | ||||
| X: 170 | ||||
| C: 16 66 00 | ||||
| C: 16 04 0A 91 AF DC | ||||
| W: 16 00 0C 00 00 00 83 DE | ||||
| W: 16 64 6D 01 | ||||
| X: 400 | ||||
| C: 16 66 00 | ||||
| W: 16 00 0A 00 00 00 7F 71 20 5C 94 08 98 C0 FB 50 00 00 00 00 13 88 51 F6 C9 F4 14 52 09 21 32 6D 00 00 00 00 D0 7E | ||||
| W: 16 64 D1 0A | ||||
| X: 2 | ||||
| C: 16 66 00 | ||||
| W: 16 00 0A 01 00 00 17 DF 00 00 17 F3 00 00 3A 36 FD 91 00 25 30 01 00 EF 05 11 05 01 00 00 10 01 00 3C 00 50 3C 00 | ||||
| W: 16 64 43 06 | ||||
| X: 2 | ||||
| C: 16 66 00 | ||||
| W: 16 00 0A 02 00 00 64 3C 00 20 03 E8 00 00 04 10 68 FE D5 FB 95 00 02 00 14 03 E8 01 00 01 F4 00 1E 00 3C 0E 10 00 | ||||
| W: 16 64 05 08 | ||||
| X: 2 | ||||
| C: 16 66 00 | ||||
| W: 16 00 0A 03 00 00 0A 46 05 32 01 0F 01 F4 00 64 46 50 0A 0E D8 0E 99 01 90 00 64 19 00 01 00 14 00 01 00 14 03 23 | ||||
| W: 16 64 88 05 | ||||
| X: 2 | ||||
| C: 16 66 00 | ||||
| W: 16 00 0A 04 00 00 07 08 25 5A 32 0F 64 60 00 A0 0B B8 00 C8 28 01 F4 00 00 00 00 00 00 00 00 00 00 43 80 04 00 00 | ||||
| W: 16 64 B0 05 | ||||
| X: 2 | ||||
| C: 16 66 00 | ||||
| W: 16 00 0A 05 00 00 0F 00 2A 04 0A 7D 00 00 01 0A FE 76 E7 54 00 28 03 E8 02 01 2C F0 00 01 07 10 63 10 48 10 2D 10 | ||||
| W: 16 64 DF 06 | ||||
| X: 2 | ||||
| C: 16 66 00 | ||||
| W: 16 00 0A 06 00 00 15 0F FC 0F E6 0F D0 0F BC 0F A8 0F 96 0F 84 0F 74 0F 65 0F 56 0F 45 0F 30 0F 1C 0F 09 0E F9 0E | ||||
| W: 16 64 05 09 | ||||
| X: 2 | ||||
| C: 16 66 00 | ||||
| W: 16 00 0A 07 00 00 EF 0E E6 0E DF 0E D8 0E D3 0E CF 0E CB 0E CA 0E C9 0E C7 0E C2 0E B8 0E AC 0E 9A 0E 85 0E 7B 0E | ||||
| W: 16 64 04 0D | ||||
| X: 2 | ||||
| C: 16 66 00 | ||||
| W: 16 00 0A 08 00 00 75 0E 56 0E 30 0D AB 0A 69 FF 2B FF 41 FF 56 FF 61 FF 67 FF 3B FF 16 FF 21 FF 21 FE EB FE B5 FE | ||||
| W: 16 64 02 12 | ||||
| X: 2 | ||||
| C: 16 66 00 | ||||
| W: 16 00 0A 09 00 00 7F FE 5E FE 7F FE E8 00 02 00 9A 00 2E FF E2 FF CD FF B7 FF A1 FF 77 FF 36 FE DA FE 62 FC F3 FA | ||||
| W: 16 64 EA 15 | ||||
| X: 2 | ||||
| C: 16 66 00 | ||||
| W: 16 00 0A 0A 00 00 B3 F8 ED F7 7C F7 3B F7 B2 F7 FC F8 32 F8 F7 F9 F0 F9 B0 F9 18 EC 9D 4D C0 FF 65 FF AC FF 98 FF | ||||
| W: 16 64 E4 18 | ||||
| X: 2 | ||||
| C: 16 66 00 | ||||
| W: 16 00 0A 0B 00 00 75 FF BB FF 82 FF 93 FF BB FF D5 FF E4 FF CE FF AD 00 80 FF 73 00 00 FF 16 FE EC FE ED FE DC FE | ||||
| W: 16 64 F5 17 | ||||
| X: 2 | ||||
| C: 16 66 00 | ||||
| W: 16 00 0A 0C 00 00 F3 FF 09 FE F7 FE AC FE 9E FE C6 FE DA FE DC FE 8E FF 75 00 00 00 00 01 C2 00 32 FF CE 02 26 64 | ||||
| W: 16 64 10 13 | ||||
| X: 2 | ||||
| C: 16 66 00 | ||||
| W: 16 00 0A 0D 00 00 7B 20 00 00 60 7E 00 00 00 00 64 00 19 00 64 28 63 5F 64 62 00 64 00 1E 00 B4 00 64 00 F0 00 FA | ||||
| W: 16 64 A5 07 | ||||
| X: 2 | ||||
| C: 16 66 00 | ||||
| W: 16 00 0A 0E 00 00 11 10 10 68 41 D9 AF 37 00 00 00 01 00 14 00 00 0A F0 00 0A 05 00 32 01 C2 14 14 00 00 3C 00 4B | ||||
| W: 16 64 73 05 | ||||
| X: 2 | ||||
| C: 16 66 00 | ||||
| W: 16 00 0A 0F 00 00 00 28 00 3C 3C 01 90 36 72 04 14 FF FF FF FF 01 23 45 67 89 AB CD EF FE DC BA 98 76 54 32 10 04 | ||||
| W: 16 64 02 0E | ||||
| X: 2 | ||||
| C: 16 66 00 | ||||
| W: 16 00 0A 10 00 00 01 2C 00 C8 00 00 00 00 00 A0 00 AF 00 00 0A 64 01 00 3C 00 00 00 00 00 00 00 00 00 00 00 00 00 | ||||
| W: 16 64 09 03 | ||||
| X: 2 | ||||
| C: 16 66 00 | ||||
| W: 16 00 0A 11 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 | ||||
| W: 16 64 1B 00 | ||||
| X: 2 | ||||
| C: 16 66 00 | ||||
| W: 16 00 0A 12 00 00 02 26 02 01 F4 02 58 02 02 26 0E 00 00 00 00 00 00 00 00 00 00 00 00 01 00 DC 84 E5 5F 00 00 0E | ||||
| W: 16 64 80 04 | ||||
| X: 2 | ||||
| C: 16 66 00 | ||||
| W: 16 00 0A 13 00 00 10 00 00 00 01 00 00 03 84 64 03 E8 15 18 FE 70 10 68 10 68 10 04 0A 32 1E 00 0A 2D 37 01 01 01 | ||||
| W: 16 64 6E 05 | ||||
| X: 2 | ||||
| C: 16 66 00 | ||||
| W: 16 00 0A 14 00 00 0B 62 71 33 34 7A 31 30 30 2D 47 31 0B 54 65 78 61 73 20 49 6E 73 74 2E 04 4C 49 4F 4E 00 96 00 | ||||
| W: 16 64 DB 08 | ||||
| X: 2 | ||||
| C: 16 66 00 | ||||
| W: 16 00 0A 15 00 00 AF 00 4B 00 64 0A F0 02 0B 54 10 CC 02 10 68 64 01 03 05 00 00 00 00 00 00 00 00 00 00 00 00 00 | ||||
| W: 16 64 9B 04 | ||||
| X: 2 | ||||
| C: 16 66 00 | ||||
| W: 16 00 0A 16 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 FF FF FF FF FF FF FF FF FF FF FF FF FF | ||||
| W: 16 64 13 0D | ||||
| X: 2 | ||||
| C: 16 66 00 | ||||
| W: 16 00 0A 17 00 00 FF FF FF FF FF FF FF FF FF FF FF FF FF FF FF FF FF FF FF FF FF FF FF FF FF FF FF FF FF FF FF FF | ||||
| W: 16 64 01 20 | ||||
| X: 2 | ||||
| C: 16 66 00 | ||||
| W: 16 00 0A 18 00 00 FF 55 00 69 00 64 00 71 00 8F 00 62 00 61 00 6C 00 59 00 56 00 55 00 57 00 5A 00 6E 02 87 05 DC | ||||
| W: 16 64 FF 07 | ||||
| X: 2 | ||||
| C: 16 66 00 | ||||
| W: 16 00 0A 19 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 | ||||
| W: 16 64 23 00 | ||||
| X: 2 | ||||
| C: 16 66 00 | ||||
| W: 16 00 0A 1A 00 00 FF FF 00 69 00 64 00 71 00 8F 00 62 00 61 00 6C 00 59 00 56 00 55 00 57 00 5A 00 6E 02 87 05 DC | ||||
| W: 16 64 AB 08 | ||||
| X: 2 | ||||
| C: 16 66 00 | ||||
| W: 16 00 0A 1B 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 | ||||
| W: 16 64 25 00 | ||||
| X: 2 | ||||
| C: 16 66 00 | ||||
| W: 16 00 0A 1C 00 00 64 3C 00 20 03 E8 00 00 04 10 68 FE D5 FB 95 00 02 00 14 03 E8 01 00 01 F4 00 1E 00 3C 0E 10 00 | ||||
| W: 16 64 1F 08 | ||||
| X: 2 | ||||
| C: 16 66 00 | ||||
| W: 16 00 0A 1D 00 00 0A 46 05 32 01 0F 01 F4 00 64 46 50 0A 0E D8 0E 99 01 90 00 64 19 00 01 00 14 00 01 00 14 03 23 | ||||
| W: 16 64 A2 05 | ||||
| X: 2 | ||||
| C: 16 66 00 | ||||
| W: 16 00 0A 1E 00 00 FF FF FF FF 00 00 00 01 FF FF FF FD FF FF FF FF FF FF FF FF FF FF FF FF FF FF FF FF FF FF FF FF | ||||
| W: 16 64 0B 1C | ||||
| X: 2 | ||||
| C: 16 66 00 | ||||
| W: 16 00 0A 1F 00 00 FF FF FF FF FF FF FF FF FF FF FF FF FF FF FF FF FF FF FF FF FF FF FF FF FF FF FF FF FF FF FF FF | ||||
| W: 16 64 09 20 | ||||
| X: 2 | ||||
| C: 16 66 00 | ||||
| W: 16 00 08 | ||||
| W: 16 64 08 00 | ||||
| X: 2 | ||||
| C: 16 66 00 | ||||
| C: 16 04 5B 67 | ||||
| W: 16 00 05 | ||||
| W: 16 64 05 00 | ||||
| X: 170 | ||||
| C: 16 66 00 | ||||
| C: 16 04 0A 91 AF DC | ||||
| W: 16 00 01 00 00 05 54 54 15 | ||||
| W: 16 64 C3 00 | ||||
| X: 20 | ||||
| C: 16 66 00 | ||||
| W: 16 00 05 | ||||
| W: 16 64 05 00 | ||||
| X: 170 | ||||
| C: 16 66 00 | ||||
| C: 16 04 5F E5 84 DC | ||||
| ;-------------------------------------------------------- | ||||
| ;Execute Flash Code | ||||
| ;-------------------------------------------------------- | ||||
| W: 16 00 0F | ||||
| W: 16 64 0F 00 | ||||
| X: 4000 | ||||
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							| @@ -1,53 +0,0 @@ | ||||
| use vergen::EmitBuilder; | ||||
|  | ||||
| fn linker_be_nice() { | ||||
|     let args: Vec<String> = std::env::args().collect(); | ||||
|     if args.len() > 1 { | ||||
|         let kind = &args[1]; | ||||
|         let what = &args[2]; | ||||
|  | ||||
|         match kind.as_str() { | ||||
|             "undefined-symbol" => match what.as_str() { | ||||
|                 "_defmt_timestamp" => { | ||||
|                     eprintln!(); | ||||
|                     eprintln!("💡 `defmt` not found - make sure `defmt.x` is added as a linker script and you have included `use defmt_rtt as _;`"); | ||||
|                     eprintln!(); | ||||
|                 } | ||||
|                 "_stack_start" => { | ||||
|                     eprintln!(); | ||||
|                     eprintln!("💡 Is the linker script `linkall.x` missing?"); | ||||
|                     eprintln!(); | ||||
|                 } | ||||
|                 "esp_wifi_preempt_enable" | ||||
|                 | "esp_wifi_preempt_yield_task" | ||||
|                 | "esp_wifi_preempt_task_create" => { | ||||
|                     eprintln!(); | ||||
|                     eprintln!("💡 `esp-wifi` has no scheduler enabled. Make sure you have the `builtin-scheduler` feature enabled, or that you provide an external scheduler."); | ||||
|                     eprintln!(); | ||||
|                 } | ||||
|                 "embedded_test_linker_file_not_added_to_rustflags" => { | ||||
|                     eprintln!(); | ||||
|                     eprintln!("💡 `embedded-test` not found - make sure `embedded-test.x` is added as a linker script for tests"); | ||||
|                     eprintln!(); | ||||
|                 } | ||||
|                 _ => (), | ||||
|             }, | ||||
|             // we don't have anything helpful for "missing-lib" yet | ||||
|             _ => { | ||||
|                 std::process::exit(1); | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         std::process::exit(0); | ||||
|     } | ||||
|  | ||||
|     println!( | ||||
|         "cargo:rustc-link-arg=--error-handling-script={}", | ||||
|         std::env::current_exe().unwrap().display() | ||||
|     ); | ||||
| } | ||||
|  | ||||
| fn main() { | ||||
|     linker_be_nice(); | ||||
|     let _ = EmitBuilder::builder().all_git().all_build().emit(); | ||||
| } | ||||
| @@ -1,14 +0,0 @@ | ||||
| [package] | ||||
| name = "canapi" | ||||
| version = "0.1.0" | ||||
| edition = "2021" | ||||
|  | ||||
| [lib] | ||||
| name = "canapi" | ||||
| path = "src/lib.rs" | ||||
|  | ||||
| [features] | ||||
| default = [] | ||||
|  | ||||
| [dependencies] | ||||
| bincode = { version = "2.0.1", default-features = false, features = ["derive"] } | ||||
| @@ -1,138 +0,0 @@ | ||||
| #![no_std] | ||||
| //! CAN bus API shared crate for PlantCtrl sensors and controller. | ||||
| //! Addressing and messages are defined here to be reused by all bus participants. | ||||
|  | ||||
| use bincode::{Decode, Encode}; | ||||
|  | ||||
| /// Total plants supported by addressing (0..=15) | ||||
| pub const MAX_PLANTS: u8 = 16; | ||||
|  | ||||
| /// Sensors per plant: 0..=1 => A/B | ||||
| #[derive(Debug, Clone, Copy, PartialEq, Eq, Encode, Decode)] | ||||
| #[repr(u8)] | ||||
| pub enum SensorSlot { | ||||
|     A = 0, | ||||
|     B = 1, | ||||
| } | ||||
|  | ||||
| impl SensorSlot { | ||||
|     pub const fn from_index(idx: u8) -> Option<Self> { | ||||
|         match idx { | ||||
|             0 => Some(SensorSlot::A), | ||||
|             1 => Some(SensorSlot::B), | ||||
|             _ => None, | ||||
|         } | ||||
|     } | ||||
| } | ||||
|  | ||||
| /// Legacy sensor base address kept for compatibility with existing code. | ||||
| /// Each plant uses SENSOR_BASE_ADDRESS + plant_index (0..PLANT_COUNT-1). | ||||
| /// 11-bit standard ID space, safe range. | ||||
| pub const SENSOR_BASE_ADDRESS: u16 = 1000; | ||||
|  | ||||
| /// Typed topics within the SENSOR_BASE space. | ||||
| /// Additional offsets allow distinct message semantics while keeping plant-indexed layout. | ||||
| pub mod id { | ||||
|     use crate::{SensorSlot, MAX_PLANTS, SENSOR_BASE_ADDRESS}; | ||||
|  | ||||
|     /// Number of plants addressable per sensor slot group | ||||
|     pub const PLANTS_PER_GROUP: u16 = MAX_PLANTS as u16; // 16 | ||||
|     /// Offset applied for SensorSlot::B within a message group | ||||
|     pub const B_OFFSET: u16 = PLANTS_PER_GROUP; // 16 | ||||
|  | ||||
|     // Message group base offsets relative to SENSOR_BASE_ADDRESS | ||||
|     pub const MOISTURE_DATA_OFFSET: u16 = 0; // periodic data from sensor (sensor -> controller) | ||||
|     pub const IDENTIFY_CMD_OFFSET: u16 = 32; // identify LED command (controller -> sensor) | ||||
|  | ||||
|     // Convenience constants for per-slot base offsets | ||||
|     pub const IDENTIFY_CMD_OFFSET_A: u16 = IDENTIFY_CMD_OFFSET + 0; | ||||
|     pub const IDENTIFY_CMD_OFFSET_B: u16 = IDENTIFY_CMD_OFFSET + B_OFFSET; | ||||
|  | ||||
|     #[inline] | ||||
|     pub const fn plant_id(message_type_offset: u16, sensor: SensorSlot, plant: u16) -> u16 { | ||||
|         match sensor { | ||||
|             SensorSlot::A => SENSOR_BASE_ADDRESS + message_type_offset + plant, | ||||
|             SensorSlot::B => SENSOR_BASE_ADDRESS + message_type_offset + B_OFFSET + plant, | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     /// Kinds of message spaces recognized by the addressing scheme. | ||||
|     #[derive(Debug, Clone, Copy, PartialEq, Eq)] | ||||
|     pub enum MessageKind { | ||||
|         MoistureData,  // sensor -> controller | ||||
|         IdentifyCmd,   // controller -> sensor | ||||
|     } | ||||
|  | ||||
|     /// Try to classify a received 11-bit standard ID into a known message kind and extract plant and sensor slot. | ||||
|     /// Returns (kind, plant, slot) on success. | ||||
|     #[inline] | ||||
|     pub const fn classify(id: u16) -> Option<(MessageKind, u8, SensorSlot)> { | ||||
|         // Ensure the ID is within our base space | ||||
|         if id < SENSOR_BASE_ADDRESS { | ||||
|             return None; | ||||
|         } | ||||
|         let rel = id - SENSOR_BASE_ADDRESS; | ||||
|  | ||||
|         // Helper: decode within a given group offset | ||||
|         const fn decode_in_group(rel: u16, group_base: u16) -> Option<(u8, SensorSlot)> { | ||||
|             if rel < group_base { return None; } | ||||
|             let inner = rel - group_base; | ||||
|             if inner < PLANTS_PER_GROUP { // A slot | ||||
|                 Some((inner as u8, SensorSlot::A)) | ||||
|             } else if inner >= B_OFFSET && inner < B_OFFSET + PLANTS_PER_GROUP { // B slot | ||||
|                 Some(((inner - B_OFFSET) as u8, SensorSlot::B)) | ||||
|             } else { | ||||
|                 None | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         // Check known groups in order | ||||
|         if let Some((plant, slot)) = decode_in_group(rel, MOISTURE_DATA_OFFSET) { | ||||
|             return Some((MessageKind::MoistureData, plant, slot)); | ||||
|         } | ||||
|         if let Some((plant, slot)) = decode_in_group(rel, IDENTIFY_CMD_OFFSET) { | ||||
|             return Some((MessageKind::IdentifyCmd, plant, slot)); | ||||
|         } | ||||
|         None | ||||
|     } | ||||
|  | ||||
|     /// Returns Some((plant, slot)) regardless of message kind, if the id falls into any known group; otherwise None. | ||||
|     #[inline] | ||||
|     pub const fn extract_plant_slot(id: u16) -> Option<(u8, SensorSlot)> { | ||||
|         match classify(id) { | ||||
|             Some((_kind, plant, slot)) => Some((plant, slot)), | ||||
|             None => None, | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     /// Check if an id corresponds exactly to the given message kind, plant and slot. | ||||
|     #[inline] | ||||
|     pub const fn is_identify_for(id: u16, plant: u8, slot: SensorSlot) -> bool { | ||||
|         id == plant_id(IDENTIFY_CMD_OFFSET, slot, plant as u16) | ||||
|     } | ||||
|  | ||||
|     #[inline] | ||||
|     pub const fn is_moisture_data_for(id: u16, plant: u8, slot: SensorSlot) -> bool { | ||||
|         id == plant_id(MOISTURE_DATA_OFFSET, slot, plant as u16) | ||||
|     } | ||||
| } | ||||
|  | ||||
| /// Periodic moisture data sent by sensors. | ||||
| /// Fits into 5 bytes with bincode-v2 (no varint): u8 + u8 + u16 = 4, alignment may keep 4. | ||||
| #[derive(Debug, Clone, Copy, Encode, Decode)] | ||||
| pub struct MoistureData { | ||||
|     pub plant: u8,         // 0..MAX_PLANTS-1 | ||||
|     pub sensor: SensorSlot, // A/B | ||||
|     pub hz: u16,           // measured frequency of moisture sensor | ||||
| } | ||||
|  | ||||
| /// Request a sensor to report immediately (controller -> sensor). | ||||
| #[derive(Debug, Clone, Copy, Encode, Decode)] | ||||
| pub struct MoistureRequest { | ||||
|     pub plant: u8, | ||||
|     pub sensor: SensorSlot, // target sensor (sensor filters by this) | ||||
| } | ||||
|  | ||||
| /// Control a sensor's identify LED, if received by sensor, blink for a few seconds | ||||
| #[derive(Debug, Clone, Copy, Encode, Decode)] | ||||
| pub struct IdentifyLed {} | ||||
										
											Binary file not shown.
										
									
								
							| @@ -1,8 +0,0 @@ | ||||
| [connection] | ||||
|  | ||||
| [[usb_device]] | ||||
| vid = "303a" | ||||
| pid = "1001" | ||||
|  | ||||
| [flash] | ||||
| size = "16MB" | ||||
| @@ -1,11 +0,0 @@ | ||||
| rm ./src/webserver/index.html.gz | ||||
| rm ./src/webserver/bundle.js.gz | ||||
| set -e | ||||
| cd ./src_webpack/ | ||||
| npx webpack build | ||||
| cp index.html.gz ../src/webserver/index.html.gz | ||||
| cp bundle.js.gz ../src/webserver/bundle.js.gz | ||||
| cd ../ | ||||
|  | ||||
| cargo build --release | ||||
| espflash flash --monitor --bootloader bootloader.bin --chip esp32c6 --baud 921600 --partition-table partitions.csv  target/riscv32imac-unknown-none-elf/release/plant-ctrl2 | ||||
| @@ -1,13 +0,0 @@ | ||||
| rm image.bin | ||||
| rm ./src/webserver/index.html.gz | ||||
| rm ./src/webserver/bundle.js.gz | ||||
| set -e | ||||
| cd ./src_webpack/ | ||||
| npx webpack build | ||||
| cp index.html.gz ../src/webserver/index.html.gz | ||||
| cp bundle.js.gz ../src/webserver/bundle.js.gz | ||||
| cd ../ | ||||
|  | ||||
| set -e | ||||
| cargo build --release | ||||
| espflash save-image --bootloader bootloader.bin --partition-table partitions.csv --chip esp32c6 target/riscv32imac-unknown-none-elf/release/plant-ctrl2 image.bin | ||||
| @@ -1,6 +0,0 @@ | ||||
| nvs,      data, nvs,     ,        16k, | ||||
| otadata,  data, ota,     ,        8k, | ||||
| phy_init, data, phy,     ,        4k, | ||||
| ota_0,    app,  ota_0,   ,        3968k, | ||||
| ota_1,    app,  ota_1,   ,        3968k, | ||||
| storage,  data, littlefs,,        8M, | ||||
| 
 | 
| @@ -1,2 +0,0 @@ | ||||
| [toolchain] | ||||
| channel = "nightly" | ||||
| @@ -1,10 +0,0 @@ | ||||
| # Rust often needs a bit of an extra main task stack size compared to C (the default is 3K) | ||||
| CONFIG_ESP_MAIN_TASK_STACK_SIZE=50000 | ||||
|  | ||||
| # Use this to set FreeRTOS kernel tick frequency to 1000 Hz (100 Hz by default). | ||||
| # This allows to use 1 ms granuality for thread sleeps (10 ms by default). | ||||
| CONFIG_FREERTOS_HZ=1000 | ||||
|  | ||||
| CONFIG_BOOTLOADER_APP_ROLLBACK_ENABLE=y | ||||
| CONFIG_I2C_ENABLE_DEBUG_LOG=y | ||||
| DEBUG_LEVEL=5 | ||||
| @@ -1,18 +0,0 @@ | ||||
| cargo install cargo-generate | ||||
| cargo install ldproxy | ||||
| cargo install espup | ||||
| cargo install espflash | ||||
| cargo install cargo-espflash | ||||
|  | ||||
| cargo generate esp-rs/esp-idf-template cargo | ||||
| export PATH="$PATH:$HOME/.cargo/bin" | ||||
|  | ||||
| espup install | ||||
| rustup toolchain link esp ~/.rustup/toolchains/esp/ | ||||
| cargo install ldproxy | ||||
|  | ||||
| cargo espflash save-image --chip esp32 image.bin | ||||
|  | ||||
|  | ||||
| esptool.py --chip ESP32-C3 elf2image --output my-app.bin target/release/my-app | ||||
| $ espflash save-image ESP32-C3 target/release/my-app my-app.bin | ||||
| @@ -1,156 +0,0 @@ | ||||
| use crate::hal::PLANT_COUNT; | ||||
| use crate::plant_state::PlantWateringMode; | ||||
| use alloc::string::String; | ||||
| use core::str::FromStr; | ||||
| use serde::{Deserialize, Serialize}; | ||||
|  | ||||
| #[derive(Serialize, Deserialize, Clone, Debug, PartialEq)] | ||||
| #[serde(default)] | ||||
| pub struct NetworkConfig { | ||||
|     pub ap_ssid: heapless::String<32>, | ||||
|     pub ssid: Option<heapless::String<32>>, | ||||
|     pub password: Option<heapless::String<64>>, | ||||
|     pub mqtt_url: Option<String>, | ||||
|     pub base_topic: Option<heapless::String<64>>, | ||||
|     pub mqtt_user: Option<String>, | ||||
|     pub mqtt_password: Option<String>, | ||||
|     pub max_wait: u32, | ||||
| } | ||||
| impl Default for NetworkConfig { | ||||
|     fn default() -> Self { | ||||
|         Self { | ||||
|             ap_ssid: heapless::String::from_str("PlantCtrl Init").unwrap(), | ||||
|             ssid: None, | ||||
|             password: None, | ||||
|             mqtt_url: None, | ||||
|             base_topic: None, | ||||
|             mqtt_user: None, | ||||
|             mqtt_password: None, | ||||
|             max_wait: 10000, | ||||
|         } | ||||
|     } | ||||
| } | ||||
|  | ||||
| #[derive(Serialize, Deserialize, Clone, Debug, PartialEq)] | ||||
| #[serde(default)] | ||||
| pub struct NightLampConfig { | ||||
|     pub enabled: bool, | ||||
|     pub night_lamp_hour_start: u8, | ||||
|     pub night_lamp_hour_end: u8, | ||||
|     pub night_lamp_only_when_dark: bool, | ||||
|     pub low_soc_cutoff: u8, | ||||
|     pub low_soc_restore: u8, | ||||
| } | ||||
| impl Default for NightLampConfig { | ||||
|     fn default() -> Self { | ||||
|         Self { | ||||
|             enabled: true, | ||||
|             night_lamp_hour_start: 19, | ||||
|             night_lamp_hour_end: 2, | ||||
|             night_lamp_only_when_dark: true, | ||||
|             low_soc_cutoff: 30, | ||||
|             low_soc_restore: 50, | ||||
|         } | ||||
|     } | ||||
| } | ||||
|  | ||||
| #[derive(Serialize, Deserialize, Clone, Debug, PartialEq)] | ||||
| #[serde(default)] | ||||
| pub struct TankConfig { | ||||
|     pub tank_sensor_enabled: bool, | ||||
|     pub tank_allow_pumping_if_sensor_error: bool, | ||||
|     pub tank_useable_ml: u32, | ||||
|     pub tank_warn_percent: u8, | ||||
|     pub tank_empty_percent: u8, | ||||
|     pub tank_full_percent: u8, | ||||
|     pub ml_per_pulse: f32, | ||||
| } | ||||
| impl Default for TankConfig { | ||||
|     fn default() -> Self { | ||||
|         Self { | ||||
|             tank_sensor_enabled: false, | ||||
|             tank_allow_pumping_if_sensor_error: true, | ||||
|             tank_useable_ml: 50000, | ||||
|             tank_warn_percent: 40, | ||||
|             tank_empty_percent: 5, | ||||
|             tank_full_percent: 95, | ||||
|             ml_per_pulse: 0.0, | ||||
|         } | ||||
|     } | ||||
| } | ||||
|  | ||||
| #[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Default)] | ||||
| pub enum BatteryBoardVersion { | ||||
|     #[default] | ||||
|     Disabled, | ||||
|     BQ34Z100G1, | ||||
|     WchI2cSlave, | ||||
| } | ||||
| #[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Default)] | ||||
| pub enum BoardVersion { | ||||
|     #[default] | ||||
|     INITIAL, | ||||
|     V3, | ||||
|     V4, | ||||
| } | ||||
|  | ||||
| #[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Default)] | ||||
| pub struct BoardHardware { | ||||
|     pub board: BoardVersion, | ||||
|     pub battery: BatteryBoardVersion, | ||||
| } | ||||
|  | ||||
| #[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Default)] | ||||
| #[serde(default)] | ||||
| pub struct PlantControllerConfig { | ||||
|     pub hardware: BoardHardware, | ||||
|     pub network: NetworkConfig, | ||||
|     pub tank: TankConfig, | ||||
|     pub night_lamp: NightLampConfig, | ||||
|     pub plants: [PlantConfig; PLANT_COUNT], | ||||
|     pub timezone: Option<String>, | ||||
| } | ||||
|  | ||||
| #[derive(Serialize, Deserialize, Clone, Debug, PartialEq)] | ||||
| #[serde(default)] | ||||
| pub struct PlantConfig { | ||||
|     pub mode: PlantWateringMode, | ||||
|     pub target_moisture: f32, | ||||
|     pub min_moisture: f32, | ||||
|     pub pump_time_s: u16, | ||||
|     pub pump_limit_ml: u16, | ||||
|     pub pump_cooldown_min: u16, | ||||
|     pub pump_hour_start: u8, | ||||
|     pub pump_hour_end: u8, | ||||
|     pub sensor_a: bool, | ||||
|     pub sensor_b: bool, | ||||
|     pub max_consecutive_pump_count: u8, | ||||
|     pub moisture_sensor_min_frequency: Option<f32>, // Optional min frequency | ||||
|     pub moisture_sensor_max_frequency: Option<f32>, // Optional max frequency | ||||
|     pub min_pump_current_ma: u16, | ||||
|     pub max_pump_current_ma: u16, | ||||
|     pub ignore_current_error: bool, | ||||
| } | ||||
|  | ||||
| impl Default for PlantConfig { | ||||
|     fn default() -> Self { | ||||
|         Self { | ||||
|             mode: PlantWateringMode::OFF, | ||||
|             target_moisture: 40., | ||||
|             min_moisture: 30., | ||||
|             pump_time_s: 30, | ||||
|             pump_limit_ml: 5000, | ||||
|             pump_cooldown_min: 60, | ||||
|             pump_hour_start: 9, | ||||
|             pump_hour_end: 20, | ||||
|             sensor_a: true, | ||||
|             sensor_b: false, | ||||
|             max_consecutive_pump_count: 10, | ||||
|             moisture_sensor_min_frequency: None, // No override by default | ||||
|             moisture_sensor_max_frequency: None, // No override by default | ||||
|             min_pump_current_ma: 10, | ||||
|             max_pump_current_ma: 3000, | ||||
|             ignore_current_error: true, | ||||
|         } | ||||
|     } | ||||
| } | ||||
| @@ -1,318 +0,0 @@ | ||||
| use alloc::format; | ||||
| use alloc::string::{String, ToString}; | ||||
| use core::convert::Infallible; | ||||
| use core::fmt; | ||||
| use core::str::Utf8Error; | ||||
| use embassy_embedded_hal::shared_bus::I2cDeviceError; | ||||
| use embassy_executor::SpawnError; | ||||
| use embassy_sync::mutex::TryLockError; | ||||
| use embedded_storage::nor_flash::NorFlashErrorKind; | ||||
| use esp_hal::i2c::master::ConfigError; | ||||
| use esp_hal::pcnt::unit::{InvalidHighLimit, InvalidLowLimit}; | ||||
| use esp_hal::twai::EspTwaiError; | ||||
| use esp_wifi::wifi::WifiError; | ||||
| use ina219::errors::{BusVoltageReadError, ShuntVoltageReadError}; | ||||
| use littlefs2_core::PathError; | ||||
| use onewire::Error; | ||||
| use pca9535::ExpanderError; | ||||
|  | ||||
| //All error superconstruct | ||||
| #[derive(Debug)] | ||||
| pub enum FatError { | ||||
|     OneWireError { | ||||
|         error: Error<Infallible>, | ||||
|     }, | ||||
|     String { | ||||
|         error: String, | ||||
|     }, | ||||
|     LittleFSError { | ||||
|         error: littlefs2_core::Error, | ||||
|     }, | ||||
|     PathError { | ||||
|         error: PathError, | ||||
|     }, | ||||
|     TryLockError { | ||||
|         error: TryLockError, | ||||
|     }, | ||||
|     WifiError { | ||||
|         error: WifiError, | ||||
|     }, | ||||
|     SerdeError { | ||||
|         error: serde_json::Error, | ||||
|     }, | ||||
|     PreconditionFailed { | ||||
|         error: String, | ||||
|     }, | ||||
|     NoBatteryMonitor, | ||||
|     SpawnError { | ||||
|         error: SpawnError, | ||||
|     }, | ||||
|     PartitionError { | ||||
|         error: esp_bootloader_esp_idf::partitions::Error, | ||||
|     }, | ||||
|     I2CConfigError { | ||||
|         error: ConfigError, | ||||
|     }, | ||||
|     DS323 { | ||||
|         error: String, | ||||
|     }, | ||||
|     Eeprom24x { | ||||
|         error: String, | ||||
|     }, | ||||
|     ExpanderError { | ||||
|         error: String, | ||||
|     }, | ||||
|     CanBusError { | ||||
|         error: EspTwaiError, | ||||
|     }, | ||||
|     SNTPError { | ||||
|         error: sntpc::Error, | ||||
|     }, | ||||
| } | ||||
|  | ||||
| pub type FatResult<T> = Result<T, FatError>; | ||||
|  | ||||
| impl fmt::Display for FatError { | ||||
|     fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { | ||||
|         match self { | ||||
|             FatError::SpawnError { error } => { | ||||
|                 write!(f, "SpawnError {:?}", error.to_string()) | ||||
|             } | ||||
|             FatError::OneWireError { error } => write!(f, "OneWireError {:?}", error), | ||||
|             FatError::String { error } => write!(f, "{}", error), | ||||
|             FatError::LittleFSError { error } => write!(f, "LittleFSError {:?}", error), | ||||
|             FatError::PathError { error } => write!(f, "PathError {:?}", error), | ||||
|             FatError::TryLockError { error } => write!(f, "TryLockError {:?}", error), | ||||
|             FatError::WifiError { error } => write!(f, "WifiError {:?}", error), | ||||
|             FatError::SerdeError { error } => write!(f, "SerdeError {:?}", error), | ||||
|             FatError::PreconditionFailed { error } => write!(f, "PreconditionFailed {:?}", error), | ||||
|             FatError::PartitionError { error } => { | ||||
|                 write!(f, "PartitionError {:?}", error) | ||||
|             } | ||||
|             FatError::NoBatteryMonitor => { | ||||
|                 write!(f, "No Battery Monitor") | ||||
|             } | ||||
|             FatError::I2CConfigError { error } => write!(f, "I2CConfigError {:?}", error), | ||||
|             FatError::DS323 { error } => write!(f, "DS323 {:?}", error), | ||||
|             FatError::Eeprom24x { error } => write!(f, "Eeprom24x {:?}", error), | ||||
|             FatError::ExpanderError { error } => write!(f, "ExpanderError {:?}", error), | ||||
|             FatError::CanBusError { error } => { | ||||
|                 write!(f, "CanBusError {:?}", error) | ||||
|             } | ||||
|             FatError::SNTPError { error } => write!(f, "SNTPError {:?}", error), | ||||
|         } | ||||
|     } | ||||
| } | ||||
|  | ||||
| #[macro_export] | ||||
| macro_rules! bail { | ||||
|     ($msg:literal $(,)?) => { | ||||
|         return $crate::fat_error::fat_bail($msg) | ||||
|     }; | ||||
|     ($fmt:literal, $($arg:tt)*) => { | ||||
|         return $crate::fat_error::fat_bail(&alloc::format!($fmt, $($arg)*)) | ||||
|     }; | ||||
| } | ||||
|  | ||||
| pub fn fat_bail<X>(message: &str) -> Result<X, FatError> { | ||||
|     Err(FatError::String { | ||||
|         error: message.to_string(), | ||||
|     }) | ||||
| } | ||||
|  | ||||
| pub trait ContextExt<T> { | ||||
|     fn context<C>(self, context: C) -> Result<T, FatError> | ||||
|     where | ||||
|         C: AsRef<str>; | ||||
| } | ||||
| impl<T> ContextExt<T> for Option<T> { | ||||
|     fn context<C>(self, context: C) -> Result<T, FatError> | ||||
|     where | ||||
|         C: AsRef<str>, | ||||
|     { | ||||
|         match self { | ||||
|             Some(value) => Ok(value), | ||||
|             None => Err(FatError::PreconditionFailed { | ||||
|                 error: context.as_ref().to_string(), | ||||
|             }), | ||||
|         } | ||||
|     } | ||||
| } | ||||
|  | ||||
| impl From<Error<Infallible>> for FatError { | ||||
|     fn from(error: Error<Infallible>) -> Self { | ||||
|         FatError::OneWireError { error } | ||||
|     } | ||||
| } | ||||
| impl From<littlefs2_core::Error> for FatError { | ||||
|     fn from(value: littlefs2_core::Error) -> Self { | ||||
|         FatError::LittleFSError { error: value } | ||||
|     } | ||||
| } | ||||
|  | ||||
| impl From<PathError> for FatError { | ||||
|     fn from(value: PathError) -> Self { | ||||
|         FatError::PathError { error: value } | ||||
|     } | ||||
| } | ||||
|  | ||||
| impl From<TryLockError> for FatError { | ||||
|     fn from(value: TryLockError) -> Self { | ||||
|         FatError::TryLockError { error: value } | ||||
|     } | ||||
| } | ||||
|  | ||||
| impl From<WifiError> for FatError { | ||||
|     fn from(value: WifiError) -> Self { | ||||
|         FatError::WifiError { error: value } | ||||
|     } | ||||
| } | ||||
|  | ||||
| impl From<serde_json::error::Error> for FatError { | ||||
|     fn from(value: serde_json::Error) -> Self { | ||||
|         FatError::SerdeError { error: value } | ||||
|     } | ||||
| } | ||||
|  | ||||
| impl From<SpawnError> for FatError { | ||||
|     fn from(value: SpawnError) -> Self { | ||||
|         FatError::SpawnError { error: value } | ||||
|     } | ||||
| } | ||||
|  | ||||
| impl From<esp_bootloader_esp_idf::partitions::Error> for FatError { | ||||
|     fn from(value: esp_bootloader_esp_idf::partitions::Error) -> Self { | ||||
|         FatError::PartitionError { error: value } | ||||
|     } | ||||
| } | ||||
|  | ||||
| impl From<Utf8Error> for FatError { | ||||
|     fn from(value: Utf8Error) -> Self { | ||||
|         FatError::String { | ||||
|             error: value.to_string(), | ||||
|         } | ||||
|     } | ||||
| } | ||||
|  | ||||
| impl<E: core::fmt::Debug> From<edge_http::io::Error<E>> for FatError { | ||||
|     fn from(value: edge_http::io::Error<E>) -> Self { | ||||
|         FatError::String { | ||||
|             error: format!("{:?}", value), | ||||
|         } | ||||
|     } | ||||
| } | ||||
|  | ||||
| impl<E: core::fmt::Debug> From<ds323x::Error<E>> for FatError { | ||||
|     fn from(value: ds323x::Error<E>) -> Self { | ||||
|         FatError::DS323 { | ||||
|             error: format!("{:?}", value), | ||||
|         } | ||||
|     } | ||||
| } | ||||
|  | ||||
| impl<E: core::fmt::Debug> From<eeprom24x::Error<E>> for FatError { | ||||
|     fn from(value: eeprom24x::Error<E>) -> Self { | ||||
|         FatError::Eeprom24x { | ||||
|             error: format!("{:?}", value), | ||||
|         } | ||||
|     } | ||||
| } | ||||
|  | ||||
| impl<E: core::fmt::Debug> From<ExpanderError<I2cDeviceError<E>>> for FatError { | ||||
|     fn from(value: ExpanderError<I2cDeviceError<E>>) -> Self { | ||||
|         FatError::ExpanderError { | ||||
|             error: format!("{:?}", value), | ||||
|         } | ||||
|     } | ||||
| } | ||||
|  | ||||
| impl From<bincode::error::DecodeError> for FatError { | ||||
|     fn from(value: bincode::error::DecodeError) -> Self { | ||||
|         FatError::Eeprom24x { | ||||
|             error: format!("{:?}", value), | ||||
|         } | ||||
|     } | ||||
| } | ||||
|  | ||||
| impl From<bincode::error::EncodeError> for FatError { | ||||
|     fn from(value: bincode::error::EncodeError) -> Self { | ||||
|         FatError::Eeprom24x { | ||||
|             error: format!("{:?}", value), | ||||
|         } | ||||
|     } | ||||
| } | ||||
|  | ||||
| impl From<ConfigError> for FatError { | ||||
|     fn from(value: ConfigError) -> Self { | ||||
|         FatError::I2CConfigError { error: value } | ||||
|     } | ||||
| } | ||||
|  | ||||
| impl<E: core::fmt::Debug> From<I2cDeviceError<E>> for FatError { | ||||
|     fn from(value: I2cDeviceError<E>) -> Self { | ||||
|         FatError::String { | ||||
|             error: format!("{:?}", value), | ||||
|         } | ||||
|     } | ||||
| } | ||||
|  | ||||
| impl<E: core::fmt::Debug> From<BusVoltageReadError<I2cDeviceError<E>>> for FatError { | ||||
|     fn from(value: BusVoltageReadError<I2cDeviceError<E>>) -> Self { | ||||
|         FatError::String { | ||||
|             error: format!("{:?}", value), | ||||
|         } | ||||
|     } | ||||
| } | ||||
| impl<E: core::fmt::Debug> From<ShuntVoltageReadError<I2cDeviceError<E>>> for FatError { | ||||
|     fn from(value: ShuntVoltageReadError<I2cDeviceError<E>>) -> Self { | ||||
|         FatError::String { | ||||
|             error: format!("{:?}", value), | ||||
|         } | ||||
|     } | ||||
| } | ||||
|  | ||||
| impl From<Infallible> for FatError { | ||||
|     fn from(value: Infallible) -> Self { | ||||
|         panic!("Infallible error: {:?}", value) | ||||
|     } | ||||
| } | ||||
|  | ||||
| impl From<InvalidLowLimit> for FatError { | ||||
|     fn from(value: InvalidLowLimit) -> Self { | ||||
|         FatError::String { | ||||
|             error: format!("{:?}", value), | ||||
|         } | ||||
|     } | ||||
| } | ||||
| impl From<InvalidHighLimit> for FatError { | ||||
|     fn from(value: InvalidHighLimit) -> Self { | ||||
|         FatError::String { | ||||
|             error: format!("{:?}", value), | ||||
|         } | ||||
|     } | ||||
| } | ||||
|  | ||||
| impl From<nb::Error<EspTwaiError>> for FatError { | ||||
|     fn from(value: nb::Error<EspTwaiError>) -> Self { | ||||
|         match value { | ||||
|             nb::Error::Other(can_error) => FatError::CanBusError { error: can_error }, | ||||
|             nb::Error::WouldBlock => FatError::String { | ||||
|                 error: "Would block".to_string(), | ||||
|             }, | ||||
|         } | ||||
|     } | ||||
| } | ||||
|  | ||||
| impl From<NorFlashErrorKind> for FatError { | ||||
|     fn from(value: NorFlashErrorKind) -> Self { | ||||
|         FatError::String { | ||||
|             error: value.to_string(), | ||||
|         } | ||||
|     } | ||||
| } | ||||
|  | ||||
| impl From<sntpc::Error> for FatError { | ||||
|     fn from(value: sntpc::Error) -> Self { | ||||
|         FatError::SNTPError { error: value } | ||||
|     } | ||||
| } | ||||
| @@ -1,258 +0,0 @@ | ||||
| use crate::fat_error::{FatError, FatResult}; | ||||
| use crate::hal::Box; | ||||
| use async_trait::async_trait; | ||||
| use bq34z100::{Bq34z100g1, Bq34z100g1Driver, Flags}; | ||||
| use embassy_embedded_hal::shared_bus::blocking::i2c::I2cDevice; | ||||
| use embassy_sync::blocking_mutex::raw::CriticalSectionRawMutex; | ||||
| use esp_hal::delay::Delay; | ||||
| use esp_hal::i2c::master::I2c; | ||||
| use esp_hal::Blocking; | ||||
| use measurements::Temperature; | ||||
| use serde::Serialize; | ||||
|  | ||||
| #[async_trait(?Send)] | ||||
| pub trait BatteryInteraction { | ||||
|     async fn state_charge_percent(&mut self) -> FatResult<f32>; | ||||
|     async fn remaining_milli_ampere_hour(&mut self) -> FatResult<u16>; | ||||
|     async fn max_milli_ampere_hour(&mut self) -> FatResult<u16>; | ||||
|     async fn design_milli_ampere_hour(&mut self) -> FatResult<u16>; | ||||
|     async fn voltage_milli_volt(&mut self) -> FatResult<u16>; | ||||
|     async fn average_current_milli_ampere(&mut self) -> FatResult<i16>; | ||||
|     async fn cycle_count(&mut self) -> FatResult<u16>; | ||||
|     async fn state_health_percent(&mut self) -> FatResult<u16>; | ||||
|     async fn bat_temperature(&mut self) -> FatResult<u16>; | ||||
|     async fn get_battery_state(&mut self) -> FatResult<BatteryState>; | ||||
| } | ||||
|  | ||||
| #[derive(Debug, Serialize)] | ||||
| pub struct BatteryInfo { | ||||
|     pub voltage_milli_volt: u16, | ||||
|     pub average_current_milli_ampere: i16, | ||||
|     pub cycle_count: u16, | ||||
|     pub design_milli_ampere_hour: u16, | ||||
|     pub remaining_milli_ampere_hour: u16, | ||||
|     pub state_of_charge: f32, | ||||
|     pub state_of_health: u16, | ||||
|     pub temperature: u16, | ||||
| } | ||||
|  | ||||
| #[derive(Debug, Serialize)] | ||||
| pub enum BatteryState { | ||||
|     Unknown, | ||||
|     Info(BatteryInfo), | ||||
| } | ||||
|  | ||||
| /// If no battery monitor is installed this implementation will be used | ||||
| pub struct NoBatteryMonitor {} | ||||
| #[async_trait(?Send)] | ||||
| impl BatteryInteraction for NoBatteryMonitor { | ||||
|     async fn state_charge_percent(&mut self) -> FatResult<f32> { | ||||
|         // No monitor configured: assume full battery for lightstate logic | ||||
|         Ok(100.0) | ||||
|     } | ||||
|  | ||||
|     async fn remaining_milli_ampere_hour(&mut self) -> FatResult<u16> { | ||||
|         Err(FatError::NoBatteryMonitor) | ||||
|     } | ||||
|  | ||||
|     async fn max_milli_ampere_hour(&mut self) -> FatResult<u16> { | ||||
|         Err(FatError::NoBatteryMonitor) | ||||
|     } | ||||
|  | ||||
|     async fn design_milli_ampere_hour(&mut self) -> FatResult<u16> { | ||||
|         Err(FatError::NoBatteryMonitor) | ||||
|     } | ||||
|  | ||||
|     async fn voltage_milli_volt(&mut self) -> FatResult<u16> { | ||||
|         Err(FatError::NoBatteryMonitor) | ||||
|     } | ||||
|  | ||||
|     async fn average_current_milli_ampere(&mut self) -> FatResult<i16> { | ||||
|         Err(FatError::NoBatteryMonitor) | ||||
|     } | ||||
|  | ||||
|     async fn cycle_count(&mut self) -> FatResult<u16> { | ||||
|         Err(FatError::NoBatteryMonitor) | ||||
|     } | ||||
|  | ||||
|     async fn state_health_percent(&mut self) -> FatResult<u16> { | ||||
|         Err(FatError::NoBatteryMonitor) | ||||
|     } | ||||
|  | ||||
|     async fn bat_temperature(&mut self) -> FatResult<u16> { | ||||
|         Err(FatError::NoBatteryMonitor) | ||||
|     } | ||||
|  | ||||
|     async fn get_battery_state(&mut self) -> FatResult<BatteryState> { | ||||
|         Ok(BatteryState::Unknown) | ||||
|     } | ||||
| } | ||||
|  | ||||
| //TODO implement this battery monitor kind once controller is complete | ||||
| #[allow(dead_code)] | ||||
| pub struct WchI2cSlave {} | ||||
|  | ||||
| pub type I2cDev = I2cDevice<'static, CriticalSectionRawMutex, I2c<'static, Blocking>>; | ||||
|  | ||||
| pub struct BQ34Z100G1 { | ||||
|     pub battery_driver: Bq34z100g1Driver<I2cDev, Delay>, | ||||
| } | ||||
|  | ||||
| #[async_trait(?Send)] | ||||
| impl BatteryInteraction for BQ34Z100G1 { | ||||
|     async fn state_charge_percent(&mut self) -> FatResult<f32> { | ||||
|         self.battery_driver | ||||
|             .state_of_charge() | ||||
|             .map(|v| v as f32) | ||||
|             .map_err(|e| FatError::String { | ||||
|                 error: alloc::format!("{:?}", e), | ||||
|             }) | ||||
|     } | ||||
|  | ||||
|     async fn remaining_milli_ampere_hour(&mut self) -> FatResult<u16> { | ||||
|         self.battery_driver | ||||
|             .remaining_capacity() | ||||
|             .map_err(|e| FatError::String { | ||||
|                 error: alloc::format!("{:?}", e), | ||||
|             }) | ||||
|     } | ||||
|  | ||||
|     async fn max_milli_ampere_hour(&mut self) -> FatResult<u16> { | ||||
|         self.battery_driver | ||||
|             .full_charge_capacity() | ||||
|             .map_err(|e| FatError::String { | ||||
|                 error: alloc::format!("{:?}", e), | ||||
|             }) | ||||
|     } | ||||
|  | ||||
|     async fn design_milli_ampere_hour(&mut self) -> FatResult<u16> { | ||||
|         self.battery_driver | ||||
|             .design_capacity() | ||||
|             .map_err(|e| FatError::String { | ||||
|                 error: alloc::format!("{:?}", e), | ||||
|             }) | ||||
|     } | ||||
|  | ||||
|     async fn voltage_milli_volt(&mut self) -> FatResult<u16> { | ||||
|         self.battery_driver.voltage().map_err(|e| FatError::String { | ||||
|             error: alloc::format!("{:?}", e), | ||||
|         }) | ||||
|     } | ||||
|  | ||||
|     async fn average_current_milli_ampere(&mut self) -> FatResult<i16> { | ||||
|         self.battery_driver | ||||
|             .average_current() | ||||
|             .map_err(|e| FatError::String { | ||||
|                 error: alloc::format!("{:?}", e), | ||||
|             }) | ||||
|     } | ||||
|  | ||||
|     async fn cycle_count(&mut self) -> FatResult<u16> { | ||||
|         self.battery_driver | ||||
|             .cycle_count() | ||||
|             .map_err(|e| FatError::String { | ||||
|                 error: alloc::format!("{:?}", e), | ||||
|             }) | ||||
|     } | ||||
|  | ||||
|     async fn state_health_percent(&mut self) -> FatResult<u16> { | ||||
|         self.battery_driver | ||||
|             .state_of_health() | ||||
|             .map_err(|e| FatError::String { | ||||
|                 error: alloc::format!("{:?}", e), | ||||
|             }) | ||||
|     } | ||||
|  | ||||
|     async fn bat_temperature(&mut self) -> FatResult<u16> { | ||||
|         self.battery_driver | ||||
|             .temperature() | ||||
|             .map_err(|e| FatError::String { | ||||
|                 error: alloc::format!("{:?}", e), | ||||
|             }) | ||||
|     } | ||||
|  | ||||
|     async fn get_battery_state(&mut self) -> FatResult<BatteryState> { | ||||
|         Ok(BatteryState::Info(BatteryInfo { | ||||
|             voltage_milli_volt: self.voltage_milli_volt().await?, | ||||
|             average_current_milli_ampere: self.average_current_milli_ampere().await?, | ||||
|             cycle_count: self.cycle_count().await?, | ||||
|             design_milli_ampere_hour: self.design_milli_ampere_hour().await?, | ||||
|             remaining_milli_ampere_hour: self.remaining_milli_ampere_hour().await?, | ||||
|             state_of_charge: self.state_charge_percent().await?, | ||||
|             state_of_health: self.state_health_percent().await?, | ||||
|             temperature: self.bat_temperature().await?, | ||||
|         })) | ||||
|     } | ||||
| } | ||||
|  | ||||
| pub fn print_battery_bq34z100( | ||||
|     battery_driver: &mut Bq34z100g1Driver<I2cDevice<CriticalSectionRawMutex, I2c<Blocking>>, Delay>, | ||||
| ) -> FatResult<()> { | ||||
|     log::info!("Try communicating with battery"); | ||||
|     let fwversion = battery_driver.fw_version().unwrap_or_else(|e| { | ||||
|         log::info!("Firmware {:?}", e); | ||||
|         0 | ||||
|     }); | ||||
|     log::info!("fw version is {}", fwversion); | ||||
|  | ||||
|     let design_capacity = battery_driver.design_capacity().unwrap_or_else(|e| { | ||||
|         log::info!("Design capacity {:?}", e); | ||||
|         0 | ||||
|     }); | ||||
|     log::info!("Design Capacity {}", design_capacity); | ||||
|     if design_capacity == 1000 { | ||||
|         log::info!("Still stock configuring battery, readouts are likely to be wrong!"); | ||||
|     } | ||||
|  | ||||
|     let flags = battery_driver.get_flags_decoded().unwrap_or(Flags { | ||||
|         fast_charge_allowed: false, | ||||
|         full_chage: false, | ||||
|         charging_not_allowed: false, | ||||
|         charge_inhibit: false, | ||||
|         bat_low: false, | ||||
|         bat_high: false, | ||||
|         over_temp_discharge: false, | ||||
|         over_temp_charge: false, | ||||
|         discharge: false, | ||||
|         state_of_charge_f: false, | ||||
|         state_of_charge_1: false, | ||||
|         cf: false, | ||||
|         ocv_taken: false, | ||||
|     }); | ||||
|     log::info!("Flags {:?}", flags); | ||||
|  | ||||
|     let chem_id = battery_driver.chem_id().unwrap_or_else(|e| { | ||||
|         log::info!("Chemid {:?}", e); | ||||
|         0 | ||||
|     }); | ||||
|  | ||||
|     let bat_temp = battery_driver.internal_temperature().unwrap_or_else(|e| { | ||||
|         log::info!("Bat Temp {:?}", e); | ||||
|         0 | ||||
|     }); | ||||
|     let temp_c = Temperature::from_kelvin(bat_temp as f64 / 10_f64).as_celsius(); | ||||
|     let voltage = battery_driver.voltage().unwrap_or_else(|e| { | ||||
|         log::info!("Bat volt {:?}", e); | ||||
|         0 | ||||
|     }); | ||||
|     let current = battery_driver.current().unwrap_or_else(|e| { | ||||
|         log::info!("Bat current {:?}", e); | ||||
|         0 | ||||
|     }); | ||||
|     let state = battery_driver.state_of_charge().unwrap_or_else(|e| { | ||||
|         log::info!("Bat Soc {:?}", e); | ||||
|         0 | ||||
|     }); | ||||
|     let charge_voltage = battery_driver.charge_voltage().unwrap_or_else(|e| { | ||||
|         log::info!("Bat Charge Volt {:?}", e); | ||||
|         0 | ||||
|     }); | ||||
|     let charge_current = battery_driver.charge_current().unwrap_or_else(|e| { | ||||
|         log::info!("Bat Charge Current {:?}", e); | ||||
|         0 | ||||
|     }); | ||||
|     log::info!("ChemId: {} Current voltage {} and current {} with charge {}% and temp {} CVolt: {} CCur {}", chem_id, voltage, current, state, temp_c, charge_voltage, charge_current); | ||||
|     let _ = battery_driver.unsealed(); | ||||
|     let _ = battery_driver.it_enable(); | ||||
|     Ok(()) | ||||
| } | ||||
| @@ -1,13 +0,0 @@ | ||||
| use crate::hal::Sensor; | ||||
| use bincode::{Decode, Encode}; | ||||
|  | ||||
| pub(crate) const SENSOR_BASE_ADDRESS: u16 = 1000; | ||||
| #[derive(Debug, Clone, Copy, Encode, Decode)] | ||||
| pub(crate) struct AutoDetectRequest {} | ||||
|  | ||||
| #[derive(Debug, Clone, Copy, Encode, Decode)] | ||||
| pub(crate) struct ResponseMoisture { | ||||
|     pub plant: u8, | ||||
|     pub sensor: Sensor, | ||||
|     pub hz: u32, | ||||
| } | ||||
| @@ -1,994 +0,0 @@ | ||||
| use crate::bail; | ||||
| use crate::config::{NetworkConfig, PlantControllerConfig}; | ||||
| use crate::hal::{PLANT_COUNT, TIME_ACCESS}; | ||||
| use crate::log::{LogMessage, LOG_ACCESS}; | ||||
| use chrono::{DateTime, Utc}; | ||||
| use serde::Serialize; | ||||
|  | ||||
| use crate::fat_error::{ContextExt, FatError, FatResult}; | ||||
| use crate::hal::little_fs2storage_adapter::LittleFs2Filesystem; | ||||
| use alloc::string::ToString; | ||||
| use alloc::sync::Arc; | ||||
| use alloc::{format, string::String, vec, vec::Vec}; | ||||
| use core::net::{IpAddr, Ipv4Addr, SocketAddr}; | ||||
| use core::str::FromStr; | ||||
| use core::sync::atomic::Ordering; | ||||
| use embassy_executor::Spawner; | ||||
| use embassy_net::udp::UdpSocket; | ||||
| use embassy_net::{DhcpConfig, Ipv4Cidr, Runner, Stack, StackResources, StaticConfigV4}; | ||||
| use embassy_sync::blocking_mutex::raw::CriticalSectionRawMutex; | ||||
| use embassy_sync::mutex::{Mutex, MutexGuard}; | ||||
| use embassy_sync::once_lock::OnceLock; | ||||
| use embassy_time::{Duration, Timer, WithTimeout}; | ||||
| use embedded_storage::nor_flash::{check_erase, NorFlash, ReadNorFlash}; | ||||
| use esp_bootloader_esp_idf::ota::OtaImageState::Valid; | ||||
| use esp_bootloader_esp_idf::ota::{Ota, OtaImageState, Slot}; | ||||
| use esp_bootloader_esp_idf::partitions::FlashRegion; | ||||
| use esp_hal::gpio::{Input, RtcPinWithResistors}; | ||||
| use esp_hal::rng::Rng; | ||||
| use esp_hal::rtc_cntl::{ | ||||
|     sleep::{TimerWakeupSource, WakeupLevel}, | ||||
|     Rtc, | ||||
| }; | ||||
| use esp_hal::system::software_reset; | ||||
| use esp_println::println; | ||||
| use esp_storage::FlashStorage; | ||||
| use esp_wifi::wifi::{ | ||||
|     AccessPointConfiguration, AccessPointInfo, AuthMethod, ClientConfiguration, Configuration, | ||||
|     ScanConfig, ScanTypeConfig, WifiController, WifiDevice, WifiState, | ||||
| }; | ||||
| use littlefs2::fs::Filesystem; | ||||
| use littlefs2_core::{FileType, PathBuf, SeekFrom}; | ||||
| use log::{info, warn}; | ||||
| use mcutie::{ | ||||
|     Error, McutieBuilder, McutieReceiver, McutieTask, MqttMessage, PublishDisplay, Publishable, | ||||
|     QoS, Topic, | ||||
| }; | ||||
| use portable_atomic::AtomicBool; | ||||
| use smoltcp::socket::udp::PacketMetadata; | ||||
| use smoltcp::wire::DnsQueryType; | ||||
| use sntpc::{get_time, NtpContext, NtpTimestampGenerator}; | ||||
|  | ||||
| #[esp_hal::ram(rtc_fast, persistent)] | ||||
| static mut LAST_WATERING_TIMESTAMP: [i64; PLANT_COUNT] = [0; PLANT_COUNT]; | ||||
| #[esp_hal::ram(rtc_fast, persistent)] | ||||
| static mut CONSECUTIVE_WATERING_PLANT: [u32; PLANT_COUNT] = [0; PLANT_COUNT]; | ||||
| #[esp_hal::ram(rtc_fast, persistent)] | ||||
| static mut LOW_VOLTAGE_DETECTED: i8 = 0; | ||||
| #[esp_hal::ram(rtc_fast, persistent)] | ||||
| static mut RESTART_TO_CONF: i8 = 0; | ||||
|  | ||||
| const CONFIG_FILE: &str = "config.json"; | ||||
| const NTP_SERVER: &str = "pool.ntp.org"; | ||||
|  | ||||
| static MQTT_CONNECTED_EVENT_RECEIVED: AtomicBool = AtomicBool::new(false); | ||||
| static MQTT_ROUND_TRIP_RECEIVED: AtomicBool = AtomicBool::new(false); | ||||
| pub static MQTT_STAY_ALIVE: AtomicBool = AtomicBool::new(false); | ||||
| static MQTT_BASE_TOPIC: OnceLock<String> = OnceLock::new(); | ||||
|  | ||||
| #[derive(Serialize, Debug)] | ||||
| pub struct FileInfo { | ||||
|     filename: String, | ||||
|     size: usize, | ||||
| } | ||||
|  | ||||
| #[derive(Serialize, Debug)] | ||||
| pub struct FileList { | ||||
|     total: usize, | ||||
|     used: usize, | ||||
|     files: Vec<FileInfo>, | ||||
| } | ||||
|  | ||||
| #[derive(Copy, Clone, Default)] | ||||
| struct Timestamp { | ||||
|     stamp: DateTime<Utc>, | ||||
| } | ||||
|  | ||||
| // Minimal esp-idf equivalent for gpio_hold on esp32c6 via ROM functions | ||||
| extern "C" { | ||||
|     fn gpio_pad_hold(gpio_num: u32); | ||||
|     fn gpio_pad_unhold(gpio_num: u32); | ||||
| } | ||||
|  | ||||
| #[inline(always)] | ||||
| pub fn hold_enable(gpio_num: u8) { | ||||
|     unsafe { gpio_pad_hold(gpio_num as u32) } | ||||
| } | ||||
|  | ||||
| #[inline(always)] | ||||
| pub fn hold_disable(gpio_num: u8) { | ||||
|     unsafe { gpio_pad_unhold(gpio_num as u32) } | ||||
| } | ||||
|  | ||||
| impl NtpTimestampGenerator for Timestamp { | ||||
|     fn init(&mut self) { | ||||
|         self.stamp = DateTime::default(); | ||||
|     } | ||||
|  | ||||
|     fn timestamp_sec(&self) -> u64 { | ||||
|         self.stamp.timestamp() as u64 | ||||
|     } | ||||
|  | ||||
|     fn timestamp_subsec_micros(&self) -> u32 { | ||||
|         self.stamp.timestamp_subsec_micros() | ||||
|     } | ||||
| } | ||||
|  | ||||
| pub struct Esp<'a> { | ||||
|     pub fs: Arc<Mutex<CriticalSectionRawMutex, Filesystem<'static, LittleFs2Filesystem>>>, | ||||
|     pub rng: Rng, | ||||
|     //first starter (ap or sta will take these) | ||||
|     pub interface_sta: Option<WifiDevice<'static>>, | ||||
|     pub interface_ap: Option<WifiDevice<'static>>, | ||||
|     pub controller: Arc<Mutex<CriticalSectionRawMutex, WifiController<'static>>>, | ||||
|  | ||||
|     pub boot_button: Input<'a>, | ||||
|  | ||||
|     // RTC-capable GPIO used as external wake source (store the raw peripheral) | ||||
|     pub wake_gpio1: esp_hal::peripherals::GPIO1<'static>, | ||||
|  | ||||
|     pub ota: Ota<'static, FlashStorage>, | ||||
|     pub ota_target: &'static mut FlashRegion<'static, FlashStorage>, | ||||
|     pub current: Slot, | ||||
|     pub slot0_state: OtaImageState, | ||||
|     pub slot1_state: OtaImageState, | ||||
| } | ||||
|  | ||||
| // SAFETY: On this target we never move Esp across OS threads; the firmware runs single-core | ||||
| // cooperative tasks with Embassy. All interior mutability of non-Send peripherals is gated | ||||
| // behind &mut self or embassy_sync Mutex with CriticalSectionRawMutex, which does not rely on | ||||
| // thread scheduling. Therefore it is sound to mark Esp as Send to satisfy trait object bounds | ||||
| // (e.g., Box<dyn BoardInteraction + Send>). If you add fields that are accessed from multiple | ||||
| // CPU cores/threads, reconsider this. | ||||
| unsafe impl Send for Esp<'_> {} | ||||
|  | ||||
| macro_rules! mk_static { | ||||
|     ($t:ty,$val:expr) => {{ | ||||
|         static STATIC_CELL: static_cell::StaticCell<$t> = static_cell::StaticCell::new(); | ||||
|         #[deny(unused_attributes)] | ||||
|         let x = STATIC_CELL.uninit().write(($val)); | ||||
|         x | ||||
|     }}; | ||||
| } | ||||
|  | ||||
| impl Esp<'_> { | ||||
|     pub(crate) async fn delete_file(&self, filename: String) -> FatResult<()> { | ||||
|         let file = PathBuf::try_from(filename.as_str())?; | ||||
|         let access = self.fs.lock().await; | ||||
|         access.remove(&*file)?; | ||||
|         Ok(()) | ||||
|     } | ||||
|     pub(crate) async fn write_file( | ||||
|         &mut self, | ||||
|         filename: String, | ||||
|         offset: u32, | ||||
|         buf: &[u8], | ||||
|     ) -> Result<(), FatError> { | ||||
|         let file = PathBuf::try_from(filename.as_str())?; | ||||
|         let access = self.fs.lock().await; | ||||
|         access.open_file_with_options_and_then( | ||||
|             |options| options.read(true).write(true).create(true), | ||||
|             &*file, | ||||
|             |file| { | ||||
|                 file.seek(SeekFrom::Start(offset))?; | ||||
|                 file.write(buf)?; | ||||
|                 Ok(()) | ||||
|             }, | ||||
|         )?; | ||||
|         Ok(()) | ||||
|     } | ||||
|  | ||||
|     pub async fn get_size(&mut self, filename: String) -> FatResult<usize> { | ||||
|         let file = PathBuf::try_from(filename.as_str())?; | ||||
|         let access = self.fs.lock().await; | ||||
|         let data = access.metadata(&*file)?; | ||||
|         Ok(data.len()) | ||||
|     } | ||||
|     pub(crate) async fn get_file( | ||||
|         &mut self, | ||||
|         filename: String, | ||||
|         chunk: u32, | ||||
|     ) -> FatResult<([u8; 512], usize)> { | ||||
|         use littlefs2::io::Error as lfs2Error; | ||||
|  | ||||
|         let file = PathBuf::try_from(filename.as_str())?; | ||||
|         let access = self.fs.lock().await; | ||||
|         let mut buf = [0_u8; 512]; | ||||
|         let mut read = 0; | ||||
|         let offset = chunk * buf.len() as u32; | ||||
|         access.open_file_with_options_and_then( | ||||
|             |options| options.read(true), | ||||
|             &*file, | ||||
|             |file| { | ||||
|                 let length = file.len()? as u32; | ||||
|                 if length == 0 { | ||||
|                     Err(lfs2Error::IO) | ||||
|                 } else if length > offset { | ||||
|                     file.seek(SeekFrom::Start(offset))?; | ||||
|                     read = file.read(&mut buf)?; | ||||
|                     Ok(()) | ||||
|                 } else { | ||||
|                     //exactly at end, do nothing | ||||
|                     Ok(()) | ||||
|                 } | ||||
|             }, | ||||
|         )?; | ||||
|         Ok((buf, read)) | ||||
|     } | ||||
|  | ||||
|     pub(crate) async fn write_ota(&mut self, offset: u32, buf: &[u8]) -> Result<(), FatError> { | ||||
|         let _ = check_erase(self.ota_target, offset, offset + 4096); | ||||
|         self.ota_target.erase(offset, offset + 4096)?; | ||||
|  | ||||
|         let mut temp = vec![0; buf.len()]; | ||||
|         let read_back = temp.as_mut_slice(); | ||||
|         //change to nor flash, align writes! | ||||
|         self.ota_target.write(offset, buf)?; | ||||
|         self.ota_target.read(offset, read_back)?; | ||||
|         if buf != read_back { | ||||
|             info!("Expected {:?} but got {:?}", buf, read_back); | ||||
|             bail!( | ||||
|                 "Flash error, read back does not match write buffer at offset {:x}", | ||||
|                 offset | ||||
|             ) | ||||
|         } | ||||
|         Ok(()) | ||||
|     } | ||||
|  | ||||
|     pub(crate) async fn finalize_ota(&mut self) -> Result<(), FatError> { | ||||
|         let current = self.ota.current_slot()?; | ||||
|         if self.ota.current_ota_state()? != OtaImageState::Valid { | ||||
|             info!( | ||||
|                 "Validating current slot {:?} as it was able to ota", | ||||
|                 current | ||||
|             ); | ||||
|             self.ota.set_current_ota_state(Valid)?; | ||||
|         } | ||||
|  | ||||
|         self.ota.set_current_slot(current.next())?; | ||||
|         info!("switched slot"); | ||||
|         self.ota.set_current_ota_state(OtaImageState::New)?; | ||||
|         info!("switched state for new partition"); | ||||
|         let state_new = self.ota.current_ota_state()?; | ||||
|         info!("state on new partition now {:?}", state_new); | ||||
|         //determine nextslot crc | ||||
|  | ||||
|         self.set_restart_to_conf(true); | ||||
|         Ok(()) | ||||
|     } | ||||
|  | ||||
|     pub(crate) fn mode_override_pressed(&mut self) -> bool { | ||||
|         self.boot_button.is_low() | ||||
|     } | ||||
|  | ||||
|     pub(crate) async fn sntp( | ||||
|         &mut self, | ||||
|         _max_wait_ms: u32, | ||||
|         stack: Stack<'_>, | ||||
|     ) -> FatResult<DateTime<Utc>> { | ||||
|         println!("start sntp"); | ||||
|         let mut rx_meta = [PacketMetadata::EMPTY; 16]; | ||||
|         let mut rx_buffer = [0; 4096]; | ||||
|         let mut tx_meta = [PacketMetadata::EMPTY; 16]; | ||||
|         let mut tx_buffer = [0; 4096]; | ||||
|  | ||||
|         let mut socket = UdpSocket::new( | ||||
|             stack, | ||||
|             &mut rx_meta, | ||||
|             &mut rx_buffer, | ||||
|             &mut tx_meta, | ||||
|             &mut tx_buffer, | ||||
|         ); | ||||
|         socket.bind(123).unwrap(); | ||||
|  | ||||
|         let context = NtpContext::new(Timestamp::default()); | ||||
|  | ||||
|         let ntp_addrs = stack | ||||
|             .dns_query(NTP_SERVER, DnsQueryType::A) | ||||
|             .await | ||||
|             .expect("Failed to resolve DNS"); | ||||
|         if ntp_addrs.is_empty() { | ||||
|             bail!("Failed to resolve DNS"); | ||||
|         } | ||||
|         info!("NTP server: {:?}", ntp_addrs); | ||||
|  | ||||
|         let mut counter = 0; | ||||
|         loop { | ||||
|             let addr: IpAddr = ntp_addrs[0].into(); | ||||
|             let timeout = get_time(SocketAddr::from((addr, 123)), &socket, context) | ||||
|                 .with_timeout(Duration::from_millis((_max_wait_ms / 10) as u64)) | ||||
|                 .await; | ||||
|  | ||||
|             match timeout { | ||||
|                 Ok(result) => { | ||||
|                     let time = result?; | ||||
|                     info!("Time: {:?}", time); | ||||
|                     return DateTime::from_timestamp(time.seconds as i64, 0) | ||||
|                         .context("Could not convert Sntp result"); | ||||
|                 } | ||||
|                 Err(err) => { | ||||
|                     warn!("sntp timeout, retry: {:?}", err); | ||||
|                     counter += 1; | ||||
|                     if counter > 10 { | ||||
|                         bail!("Failed to get time from NTP server"); | ||||
|                     } | ||||
|                     Timer::after(Duration::from_millis(100)).await; | ||||
|                 } | ||||
|             } | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     pub(crate) async fn wifi_scan(&mut self) -> FatResult<Vec<AccessPointInfo>> { | ||||
|         info!("start wifi scan"); | ||||
|         let mut lock = self.controller.try_lock()?; | ||||
|         info!("start wifi scan lock"); | ||||
|         let scan_config = ScanConfig { | ||||
|             ssid: None, | ||||
|             bssid: None, | ||||
|             channel: None, | ||||
|             show_hidden: false, | ||||
|             scan_type: ScanTypeConfig::Active { | ||||
|                 min: Default::default(), | ||||
|                 max: Default::default(), | ||||
|             }, | ||||
|         }; | ||||
|         let rv = lock.scan_with_config_async(scan_config).await?; | ||||
|         info!("end wifi scan lock"); | ||||
|         Ok(rv) | ||||
|     } | ||||
|  | ||||
|     pub(crate) fn last_pump_time(&self, plant: usize) -> Option<DateTime<Utc>> { | ||||
|         let ts = unsafe { LAST_WATERING_TIMESTAMP }[plant]; | ||||
|         DateTime::from_timestamp_millis(ts) | ||||
|     } | ||||
|     pub(crate) fn store_last_pump_time(&mut self, plant: usize, time: DateTime<Utc>) { | ||||
|         unsafe { | ||||
|             LAST_WATERING_TIMESTAMP[plant] = time.timestamp_millis(); | ||||
|         } | ||||
|     } | ||||
|     pub(crate) fn set_low_voltage_in_cycle(&mut self) { | ||||
|         unsafe { | ||||
|             LOW_VOLTAGE_DETECTED = 1; | ||||
|         } | ||||
|     } | ||||
|     pub(crate) fn clear_low_voltage_in_cycle(&mut self) { | ||||
|         unsafe { | ||||
|             LOW_VOLTAGE_DETECTED = 0; | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     pub(crate) fn low_voltage_in_cycle(&mut self) -> bool { | ||||
|         unsafe { LOW_VOLTAGE_DETECTED == 1 } | ||||
|     } | ||||
|     pub(crate) fn store_consecutive_pump_count(&mut self, plant: usize, count: u32) { | ||||
|         unsafe { | ||||
|             CONSECUTIVE_WATERING_PLANT[plant] = count; | ||||
|         } | ||||
|     } | ||||
|     pub(crate) fn consecutive_pump_count(&mut self, plant: usize) -> u32 { | ||||
|         unsafe { CONSECUTIVE_WATERING_PLANT[plant] } | ||||
|     } | ||||
|     pub(crate) fn get_restart_to_conf(&mut self) -> bool { | ||||
|         unsafe { RESTART_TO_CONF == 1 } | ||||
|     } | ||||
|     pub(crate) fn set_restart_to_conf(&mut self, to_conf: bool) { | ||||
|         unsafe { | ||||
|             if to_conf { | ||||
|                 RESTART_TO_CONF = 1; | ||||
|             } else { | ||||
|                 RESTART_TO_CONF = 0; | ||||
|             } | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     pub(crate) async fn wifi_ap(&mut self) -> FatResult<Stack<'static>> { | ||||
|         let ssid = match self.load_config().await { | ||||
|             Ok(config) => config.network.ap_ssid.as_str().to_string(), | ||||
|             Err(_) => "PlantCtrl Emergency Mode".to_string(), | ||||
|         }; | ||||
|  | ||||
|         let spawner = Spawner::for_current_executor().await; | ||||
|  | ||||
|         let device = self.interface_ap.take().unwrap(); | ||||
|         let gw_ip_addr_str = "192.168.71.1"; | ||||
|         let gw_ip_addr = Ipv4Addr::from_str(gw_ip_addr_str).expect("failed to parse gateway ip"); | ||||
|  | ||||
|         let config = embassy_net::Config::ipv4_static(StaticConfigV4 { | ||||
|             address: Ipv4Cidr::new(gw_ip_addr, 24), | ||||
|             gateway: Some(gw_ip_addr), | ||||
|             dns_servers: Default::default(), | ||||
|         }); | ||||
|  | ||||
|         let seed = (self.rng.random() as u64) << 32 | self.rng.random() as u64; | ||||
|  | ||||
|         println!("init secondary stack"); | ||||
|         // Init network stack | ||||
|         let (stack, runner) = embassy_net::new( | ||||
|             device, | ||||
|             config, | ||||
|             mk_static!(StackResources<4>, StackResources::<4>::new()), | ||||
|             seed, | ||||
|         ); | ||||
|         let stack = mk_static!(Stack, stack); | ||||
|  | ||||
|         let client_config = Configuration::AccessPoint(AccessPointConfiguration { | ||||
|             ssid: ssid.clone(), | ||||
|             ..Default::default() | ||||
|         }); | ||||
|  | ||||
|         self.controller | ||||
|             .lock() | ||||
|             .await | ||||
|             .set_configuration(&client_config)?; | ||||
|  | ||||
|         println!("start new"); | ||||
|         self.controller.lock().await.start()?; | ||||
|         println!("start net task"); | ||||
|         spawner.spawn(net_task(runner)).ok(); | ||||
|         println!("run dhcp"); | ||||
|         spawner.spawn(run_dhcp(stack.clone(), gw_ip_addr_str)).ok(); | ||||
|  | ||||
|         loop { | ||||
|             if stack.is_link_up() { | ||||
|                 break; | ||||
|             } | ||||
|             Timer::after(Duration::from_millis(500)).await; | ||||
|         } | ||||
|         while !stack.is_config_up() { | ||||
|             Timer::after(Duration::from_millis(100)).await | ||||
|         } | ||||
|         println!("Connect to the AP `${ssid}` and point your browser to http://{gw_ip_addr_str}/"); | ||||
|         stack | ||||
|             .config_v4() | ||||
|             .inspect(|c| println!("ipv4 config: {c:?}")); | ||||
|  | ||||
|         Ok(stack.clone()) | ||||
|     } | ||||
|  | ||||
|     pub(crate) async fn wifi( | ||||
|         &mut self, | ||||
|         network_config: &NetworkConfig, | ||||
|     ) -> FatResult<Stack<'static>> { | ||||
|         esp_wifi::wifi_set_log_verbose(); | ||||
|         let ssid = network_config.ssid.clone(); | ||||
|         match &ssid { | ||||
|             Some(ssid) => { | ||||
|                 if ssid.is_empty() { | ||||
|                     bail!("Wifi ssid was empty") | ||||
|                 } | ||||
|             } | ||||
|             None => { | ||||
|                 bail!("Wifi ssid was empty") | ||||
|             } | ||||
|         } | ||||
|         let ssid = ssid.unwrap().to_string(); | ||||
|         info!("attempting to connect wifi {ssid}"); | ||||
|         let password = match network_config.password { | ||||
|             Some(ref password) => password.to_string(), | ||||
|             None => "".to_string(), | ||||
|         }; | ||||
|         let max_wait = network_config.max_wait; | ||||
|  | ||||
|         let spawner = Spawner::for_current_executor().await; | ||||
|  | ||||
|         let device = self.interface_sta.take().unwrap(); | ||||
|         let config = embassy_net::Config::dhcpv4(DhcpConfig::default()); | ||||
|  | ||||
|         let seed = (self.rng.random() as u64) << 32 | self.rng.random() as u64; | ||||
|  | ||||
|         // Init network stack | ||||
|         let (stack, runner) = embassy_net::new( | ||||
|             device, | ||||
|             config, | ||||
|             mk_static!(StackResources<8>, StackResources::<8>::new()), | ||||
|             seed, | ||||
|         ); | ||||
|         let stack = mk_static!(Stack, stack); | ||||
|  | ||||
|         let client_config = Configuration::Client(ClientConfiguration { | ||||
|             ssid, | ||||
|             bssid: None, | ||||
|             auth_method: AuthMethod::WPA2Personal, //FIXME read from config, fill via scan | ||||
|             password, | ||||
|             channel: None, | ||||
|         }); | ||||
|         self.controller | ||||
|             .lock() | ||||
|             .await | ||||
|             .set_configuration(&client_config)?; | ||||
|         spawner.spawn(net_task(runner)).ok(); | ||||
|         self.controller.lock().await.start_async().await?; | ||||
|  | ||||
|         let timeout = { | ||||
|             let guard = TIME_ACCESS.get().await.lock().await; | ||||
|             guard.current_time_us() | ||||
|         } + max_wait as u64 * 1000; | ||||
|         loop { | ||||
|             let state = esp_wifi::wifi::sta_state(); | ||||
|             match state { | ||||
|                 WifiState::StaStarted => { | ||||
|                     self.controller.lock().await.connect()?; | ||||
|                     break; | ||||
|                 } | ||||
|                 _ => {} | ||||
|             } | ||||
|             if { | ||||
|                 let guard = TIME_ACCESS.get().await.lock().await; | ||||
|                 guard.current_time_us() | ||||
|             } > timeout | ||||
|             { | ||||
|                 bail!("Timeout waiting for wifi sta ready") | ||||
|             } | ||||
|             Timer::after(Duration::from_millis(500)).await; | ||||
|         } | ||||
|         let timeout = { | ||||
|             let guard = TIME_ACCESS.get().await.lock().await; | ||||
|             guard.current_time_us() | ||||
|         } + max_wait as u64 * 1000; | ||||
|         loop { | ||||
|             let state = esp_wifi::wifi::sta_state(); | ||||
|             match state { | ||||
|                 WifiState::StaConnected => { | ||||
|                     break; | ||||
|                 } | ||||
|                 _ => {} | ||||
|             } | ||||
|             if { | ||||
|                 let guard = TIME_ACCESS.get().await.lock().await; | ||||
|                 guard.current_time_us() | ||||
|             } > timeout | ||||
|             { | ||||
|                 bail!("Timeout waiting for wifi sta connected") | ||||
|             } | ||||
|             Timer::after(Duration::from_millis(500)).await; | ||||
|         } | ||||
|         let timeout = { | ||||
|             let guard = TIME_ACCESS.get().await.lock().await; | ||||
|             guard.current_time_us() | ||||
|         } + max_wait as u64 * 1000; | ||||
|         while !stack.is_link_up() { | ||||
|             if { | ||||
|                 let guard = TIME_ACCESS.get().await.lock().await; | ||||
|                 guard.current_time_us() | ||||
|             } > timeout | ||||
|             { | ||||
|                 bail!("Timeout waiting for wifi link up") | ||||
|             } | ||||
|             Timer::after(Duration::from_millis(500)).await; | ||||
|         } | ||||
|         let timeout = { | ||||
|             let guard = TIME_ACCESS.get().await.lock().await; | ||||
|             guard.current_time_us() | ||||
|         } + max_wait as u64 * 1000; | ||||
|         while !stack.is_config_up() { | ||||
|             if { | ||||
|                 let guard = TIME_ACCESS.get().await.lock().await; | ||||
|                 guard.current_time_us() | ||||
|             } > timeout | ||||
|             { | ||||
|                 bail!("Timeout waiting for wifi config up") | ||||
|             } | ||||
|             Timer::after(Duration::from_millis(100)).await | ||||
|         } | ||||
|  | ||||
|         info!("Connected WIFI, dhcp: {:?}", stack.config_v4()); | ||||
|         Ok(stack.clone()) | ||||
|     } | ||||
|  | ||||
|     pub fn deep_sleep( | ||||
|         &mut self, | ||||
|         duration_in_ms: u64, | ||||
|         mut rtc: MutexGuard<CriticalSectionRawMutex, Rtc>, | ||||
|     ) -> ! { | ||||
|         // Configure and enter deep sleep using esp-hal. Also keep prior behavior where | ||||
|         // duration_in_ms == 0 triggers an immediate reset. | ||||
|  | ||||
|         // Mark the current OTA image as valid if we reached here while in pending verify. | ||||
|         if let Ok(cur) = self.ota.current_ota_state() { | ||||
|             if cur == OtaImageState::PendingVerify { | ||||
|                 self.ota | ||||
|                     .set_current_ota_state(OtaImageState::Valid) | ||||
|                     .expect("Could not set image to valid"); | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         if duration_in_ms == 0 { | ||||
|             software_reset(); | ||||
|         } else { | ||||
|             let timer = TimerWakeupSource::new(core::time::Duration::from_millis(duration_in_ms)); | ||||
|             let mut wake_pins: [(&mut dyn RtcPinWithResistors, WakeupLevel); 1] = | ||||
|                 [(&mut self.wake_gpio1, WakeupLevel::Low)]; | ||||
|             let ext1 = esp_hal::rtc_cntl::sleep::Ext1WakeupSource::new(&mut wake_pins); | ||||
|             rtc.sleep_deep(&[&timer, &ext1]); | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     pub(crate) async fn load_config(&mut self) -> FatResult<PlantControllerConfig> { | ||||
|         let cfg = PathBuf::try_from(CONFIG_FILE)?; | ||||
|         let config_exist = self.fs.lock().await.exists(&cfg); | ||||
|         if !config_exist { | ||||
|             bail!("No config file stored") | ||||
|         } | ||||
|         let data = self.fs.lock().await.read::<4096>(&cfg)?; | ||||
|         let config: PlantControllerConfig = serde_json::from_slice(&data)?; | ||||
|         return Ok(config); | ||||
|     } | ||||
|     pub(crate) async fn save_config(&mut self, config: Vec<u8>) -> FatResult<()> { | ||||
|         let filesystem = self.fs.lock().await; | ||||
|         let cfg = PathBuf::try_from(CONFIG_FILE)?; | ||||
|         filesystem.write(&cfg, &*config)?; | ||||
|         Ok(()) | ||||
|     } | ||||
|     pub(crate) async fn list_files(&self) -> FatResult<FileList> { | ||||
|         let path = PathBuf::new(); | ||||
|  | ||||
|         let fs = self.fs.lock().await; | ||||
|         let free_size = fs.available_space()?; | ||||
|         let total_size = fs.total_space(); | ||||
|  | ||||
|         let mut result = FileList { | ||||
|             total: total_size, | ||||
|             used: total_size - free_size, | ||||
|             files: Vec::new(), | ||||
|         }; | ||||
|  | ||||
|         fs.read_dir_and_then(&path, |dir| { | ||||
|             for entry in dir { | ||||
|                 let e = entry?; | ||||
|                 if e.file_type() == FileType::File { | ||||
|                     result.files.push(FileInfo { | ||||
|                         filename: e.path().to_string(), | ||||
|                         size: e.metadata().len(), | ||||
|                     }); | ||||
|                 } | ||||
|             } | ||||
|             Ok(()) | ||||
|         })?; | ||||
|         Ok(result) | ||||
|     } | ||||
|  | ||||
|     pub(crate) async fn init_rtc_deepsleep_memory( | ||||
|         &self, | ||||
|         init_rtc_store: bool, | ||||
|         to_config_mode: bool, | ||||
|     ) { | ||||
|         if init_rtc_store { | ||||
|             unsafe { | ||||
|                 LAST_WATERING_TIMESTAMP = [0; PLANT_COUNT]; | ||||
|                 CONSECUTIVE_WATERING_PLANT = [0; PLANT_COUNT]; | ||||
|                 LOW_VOLTAGE_DETECTED = 0; | ||||
|                 if to_config_mode { | ||||
|                     RESTART_TO_CONF = 1 | ||||
|                 } else { | ||||
|                     RESTART_TO_CONF = 0; | ||||
|                 } | ||||
|             }; | ||||
|         } else { | ||||
|             unsafe { | ||||
|                 if to_config_mode { | ||||
|                     RESTART_TO_CONF = 1; | ||||
|                 } | ||||
|                 LOG_ACCESS | ||||
|                     .lock() | ||||
|                     .await | ||||
|                     .log( | ||||
|                         LogMessage::RestartToConfig, | ||||
|                         RESTART_TO_CONF as u32, | ||||
|                         0, | ||||
|                         "", | ||||
|                         "", | ||||
|                     ) | ||||
|                     .await; | ||||
|                 LOG_ACCESS | ||||
|                     .lock() | ||||
|                     .await | ||||
|                     .log( | ||||
|                         LogMessage::LowVoltage, | ||||
|                         LOW_VOLTAGE_DETECTED as u32, | ||||
|                         0, | ||||
|                         "", | ||||
|                         "", | ||||
|                     ) | ||||
|                     .await; | ||||
|                 for i in 0..PLANT_COUNT { | ||||
|                     log::info!( | ||||
|                         "LAST_WATERING_TIMESTAMP[{}] = UTC {}", | ||||
|                         i, | ||||
|                         LAST_WATERING_TIMESTAMP[i] | ||||
|                     ); | ||||
|                 } | ||||
|                 for i in 0..PLANT_COUNT { | ||||
|                     log::info!( | ||||
|                         "CONSECUTIVE_WATERING_PLANT[{}] = {}", | ||||
|                         i, | ||||
|                         CONSECUTIVE_WATERING_PLANT[i] | ||||
|                     ); | ||||
|                 } | ||||
|             } | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     pub(crate) async fn mqtt( | ||||
|         &mut self, | ||||
|         network_config: &'static NetworkConfig, | ||||
|         stack: Stack<'static>, | ||||
|     ) -> FatResult<()> { | ||||
|         let base_topic = network_config | ||||
|             .base_topic | ||||
|             .as_ref() | ||||
|             .context("missing base topic")?; | ||||
|         if base_topic.is_empty() { | ||||
|             bail!("Mqtt base_topic was empty") | ||||
|         } | ||||
|         MQTT_BASE_TOPIC | ||||
|             .init(base_topic.to_string()) | ||||
|             .map_err(|_| FatError::String { | ||||
|                 error: "Error setting basetopic".to_string(), | ||||
|             })?; | ||||
|  | ||||
|         let mqtt_url = network_config | ||||
|             .mqtt_url | ||||
|             .as_ref() | ||||
|             .context("missing mqtt url")?; | ||||
|         if mqtt_url.is_empty() { | ||||
|             bail!("Mqtt url was empty") | ||||
|         } | ||||
|  | ||||
|         let last_will_topic = format!("{}/state", base_topic); | ||||
|         let round_trip_topic = format!("{}/internal/roundtrip", base_topic); | ||||
|         let stay_alive_topic = format!("{}/stay_alive", base_topic); | ||||
|  | ||||
|         let mut builder: McutieBuilder<'_, String, PublishDisplay<String, &str>, 0> = | ||||
|             McutieBuilder::new(stack, "plant ctrl", mqtt_url); | ||||
|         if network_config.mqtt_user.is_some() && network_config.mqtt_password.is_some() { | ||||
|             builder = builder.with_authentication( | ||||
|                 network_config.mqtt_user.as_ref().unwrap().as_str(), | ||||
|                 network_config.mqtt_password.as_ref().unwrap().as_str(), | ||||
|             ); | ||||
|             info!("With authentification"); | ||||
|         } | ||||
|  | ||||
|         let lwt = Topic::General(last_will_topic); | ||||
|         let lwt = mk_static!(Topic<String>, lwt); | ||||
|         let lwt = lwt.with_display("lost").retain(true).qos(QoS::AtLeastOnce); | ||||
|         builder = builder.with_last_will(lwt); | ||||
|         //TODO make configurable | ||||
|         builder = builder.with_device_id("plantctrl"); | ||||
|  | ||||
|         let builder: McutieBuilder<'_, String, PublishDisplay<String, &str>, 2> = builder | ||||
|             .with_subscriptions([ | ||||
|                 Topic::General(round_trip_topic.clone()), | ||||
|                 Topic::General(stay_alive_topic.clone()), | ||||
|             ]); | ||||
|  | ||||
|         let keep_alive = Duration::from_secs(60 * 60 * 2).as_secs() as u16; | ||||
|         let (receiver, task) = builder.build(keep_alive); | ||||
|  | ||||
|         let spawner = Spawner::for_current_executor().await; | ||||
|         spawner.spawn(mqtt_incoming_task( | ||||
|             receiver, | ||||
|             round_trip_topic.clone(), | ||||
|             stay_alive_topic.clone(), | ||||
|         ))?; | ||||
|         spawner.spawn(mqtt_runner(task))?; | ||||
|  | ||||
|         LOG_ACCESS | ||||
|             .lock() | ||||
|             .await | ||||
|             .log(LogMessage::StayAlive, 0, 0, "", &stay_alive_topic) | ||||
|             .await; | ||||
|  | ||||
|         LOG_ACCESS | ||||
|             .lock() | ||||
|             .await | ||||
|             .log(LogMessage::MqttInfo, 0, 0, "", mqtt_url) | ||||
|             .await; | ||||
|  | ||||
|         let mqtt_timeout = 15000; | ||||
|         let timeout = { | ||||
|             let guard = TIME_ACCESS.get().await.lock().await; | ||||
|             guard.current_time_us() | ||||
|         } + mqtt_timeout as u64 * 1000; | ||||
|         while !MQTT_CONNECTED_EVENT_RECEIVED.load(Ordering::Relaxed) { | ||||
|             let cur = TIME_ACCESS.get().await.lock().await.current_time_us(); | ||||
|             if cur > timeout { | ||||
|                 bail!("Timeout waiting MQTT connect event") | ||||
|             } | ||||
|             Timer::after(Duration::from_millis(100)).await; | ||||
|         } | ||||
|  | ||||
|         Topic::General(round_trip_topic.clone()) | ||||
|             .with_display("online_text") | ||||
|             .publish() | ||||
|             .await | ||||
|             .unwrap(); | ||||
|  | ||||
|         let timeout = { | ||||
|             let guard = TIME_ACCESS.get().await.lock().await; | ||||
|             guard.current_time_us() | ||||
|         } + mqtt_timeout as u64 * 1000; | ||||
|         while !MQTT_ROUND_TRIP_RECEIVED.load(Ordering::Relaxed) { | ||||
|             let cur = TIME_ACCESS.get().await.lock().await.current_time_us(); | ||||
|             if cur > timeout { | ||||
|                 //ensure we do not further try to publish | ||||
|                 MQTT_CONNECTED_EVENT_RECEIVED.store(false, Ordering::Relaxed); | ||||
|                 bail!("Timeout waiting MQTT roundtrip") | ||||
|             } | ||||
|             Timer::after(Duration::from_millis(100)).await; | ||||
|         } | ||||
|         Ok(()) | ||||
|     } | ||||
|  | ||||
|     pub(crate) async fn mqtt_inner(&mut self, subtopic: &str, message: &str) -> FatResult<()> { | ||||
|         if !subtopic.starts_with("/") { | ||||
|             bail!("Subtopic without / at start {}", subtopic); | ||||
|         } | ||||
|         if subtopic.len() > 192 { | ||||
|             bail!("Subtopic exceeds 192 chars {}", subtopic); | ||||
|         } | ||||
|         let base_topic = MQTT_BASE_TOPIC | ||||
|             .try_get() | ||||
|             .context("missing base topic in static!")?; | ||||
|  | ||||
|         let full_topic = format!("{base_topic}{subtopic}"); | ||||
|  | ||||
|         loop { | ||||
|             let result = Topic::General(full_topic.as_str()) | ||||
|                 .with_display(message) | ||||
|                 .retain(true) | ||||
|                 .publish() | ||||
|                 .await; | ||||
|             match result { | ||||
|                 Ok(()) => return Ok(()), | ||||
|                 Err(err) => { | ||||
|                     let retry = match err { | ||||
|                         Error::IOError => false, | ||||
|                         Error::TimedOut => true, | ||||
|                         Error::TooLarge => false, | ||||
|                         Error::PacketError => false, | ||||
|                         Error::Invalid => false, | ||||
|                     }; | ||||
|                     if !retry { | ||||
|                         bail!( | ||||
|                             "Error during mqtt send on topic {} with message {:#?} error is {:?}", | ||||
|                             &full_topic, | ||||
|                             message, | ||||
|                             err | ||||
|                         ); | ||||
|                     } | ||||
|                     info!( | ||||
|                         "Retransmit for {} with message {:#?} error is {:?} retrying {}", | ||||
|                         &full_topic, message, err, retry | ||||
|                     ); | ||||
|                     Timer::after(Duration::from_millis(100)).await; | ||||
|                 } | ||||
|             } | ||||
|         } | ||||
|     } | ||||
|     pub(crate) async fn mqtt_publish(&mut self, subtopic: &str, message: &str) { | ||||
|         let online = MQTT_CONNECTED_EVENT_RECEIVED.load(Ordering::Relaxed); | ||||
|         if !online { | ||||
|             return; | ||||
|         } | ||||
|         let roundtrip_ok = MQTT_ROUND_TRIP_RECEIVED.load(Ordering::Relaxed); | ||||
|         if !roundtrip_ok { | ||||
|             info!("MQTT roundtrip not received yet, dropping message"); | ||||
|             return; | ||||
|         } | ||||
|         match self.mqtt_inner(subtopic, message).await { | ||||
|             Ok(()) => {} | ||||
|             Err(err) => { | ||||
|                 info!( | ||||
|                     "Error during mqtt send on topic {} with message {:#?} error is {:?}", | ||||
|                     subtopic, message, err | ||||
|                 ); | ||||
|             } | ||||
|         }; | ||||
|     } | ||||
| } | ||||
|  | ||||
| #[embassy_executor::task] | ||||
| async fn mqtt_runner( | ||||
|     task: McutieTask<'static, String, PublishDisplay<'static, String, &'static str>, 2>, | ||||
| ) { | ||||
|     task.run().await; | ||||
| } | ||||
|  | ||||
| #[embassy_executor::task] | ||||
| async fn mqtt_incoming_task( | ||||
|     receiver: McutieReceiver, | ||||
|     round_trip_topic: String, | ||||
|     stay_alive_topic: String, | ||||
| ) { | ||||
|     loop { | ||||
|         let message = receiver.receive().await; | ||||
|         match message { | ||||
|             MqttMessage::Connected => { | ||||
|                 info!("Mqtt connected"); | ||||
|                 MQTT_CONNECTED_EVENT_RECEIVED.store(true, Ordering::Relaxed); | ||||
|             } | ||||
|             MqttMessage::Publish(topic, payload) => match topic { | ||||
|                 Topic::DeviceType(_type_topic) => {} | ||||
|                 Topic::Device(_device_topic) => {} | ||||
|                 Topic::General(topic) => { | ||||
|                     let subtopic = topic.as_str(); | ||||
|  | ||||
|                     if subtopic.eq(round_trip_topic.as_str()) { | ||||
|                         MQTT_ROUND_TRIP_RECEIVED.store(true, Ordering::Relaxed); | ||||
|                     } else if subtopic.eq(stay_alive_topic.as_str()) { | ||||
|                         let value = payload.eq_ignore_ascii_case("true".as_ref()) | ||||
|                             || payload.eq_ignore_ascii_case("1".as_ref()); | ||||
|                         let a = match value { | ||||
|                             true => 1, | ||||
|                             false => 0, | ||||
|                         }; | ||||
|                         LOG_ACCESS | ||||
|                             .lock() | ||||
|                             .await | ||||
|                             .log(LogMessage::MqttStayAliveRec, a, 0, "", "") | ||||
|                             .await; | ||||
|                         MQTT_STAY_ALIVE.store(value, Ordering::Relaxed); | ||||
|                     } else { | ||||
|                         LOG_ACCESS | ||||
|                             .lock() | ||||
|                             .await | ||||
|                             .log(LogMessage::UnknownTopic, 0, 0, "", &*topic) | ||||
|                             .await; | ||||
|                     } | ||||
|                 } | ||||
|             }, | ||||
|             MqttMessage::Disconnected => { | ||||
|                 MQTT_CONNECTED_EVENT_RECEIVED.store(false, Ordering::Relaxed); | ||||
|                 info!("Mqtt disconnected"); | ||||
|             } | ||||
|             MqttMessage::HomeAssistantOnline => { | ||||
|                 info!("Home assistant is online"); | ||||
|             } | ||||
|         } | ||||
|     } | ||||
| } | ||||
|  | ||||
| #[embassy_executor::task(pool_size = 2)] | ||||
| async fn net_task(mut runner: Runner<'static, WifiDevice<'static>>) { | ||||
|     runner.run().await; | ||||
| } | ||||
|  | ||||
| #[embassy_executor::task] | ||||
| async fn run_dhcp(stack: Stack<'static>, gw_ip_addr: &'static str) { | ||||
|     use core::net::{Ipv4Addr, SocketAddrV4}; | ||||
|  | ||||
|     use edge_dhcp::{ | ||||
|         io::{self, DEFAULT_SERVER_PORT}, | ||||
|         server::{Server, ServerOptions}, | ||||
|     }; | ||||
|     use edge_nal::UdpBind; | ||||
|     use edge_nal_embassy::{Udp, UdpBuffers}; | ||||
|  | ||||
|     let ip = Ipv4Addr::from_str(gw_ip_addr).expect("dhcp task failed to parse gw ip"); | ||||
|  | ||||
|     let mut buf = [0u8; 1500]; | ||||
|  | ||||
|     let mut gw_buf = [Ipv4Addr::UNSPECIFIED]; | ||||
|  | ||||
|     let buffers = UdpBuffers::<3, 1024, 1024, 10>::new(); | ||||
|     let unbound_socket = Udp::new(stack, &buffers); | ||||
|     let mut bound_socket = unbound_socket | ||||
|         .bind(SocketAddr::V4(SocketAddrV4::new( | ||||
|             Ipv4Addr::UNSPECIFIED, | ||||
|             DEFAULT_SERVER_PORT, | ||||
|         ))) | ||||
|         .await | ||||
|         .unwrap(); | ||||
|  | ||||
|     loop { | ||||
|         _ = io::server::run( | ||||
|             &mut Server::<_, 64>::new_with_et(ip), | ||||
|             &ServerOptions::new(ip, Some(&mut gw_buf)), | ||||
|             &mut bound_socket, | ||||
|             &mut buf, | ||||
|         ) | ||||
|         .await | ||||
|         .inspect_err(|e| log::warn!("DHCP server error: {e:?}")); | ||||
|         Timer::after(Duration::from_millis(500)).await; | ||||
|     } | ||||
| } | ||||
| @@ -1,144 +0,0 @@ | ||||
| use crate::alloc::boxed::Box; | ||||
| use crate::fat_error::{FatError, FatResult}; | ||||
| use crate::hal::esp::Esp; | ||||
| use crate::hal::rtc::{BackupHeader, RTCModuleInteraction}; | ||||
| use crate::hal::water::TankSensor; | ||||
| use crate::hal::{BoardInteraction, FreePeripherals, Moistures, TIME_ACCESS}; | ||||
| use crate::{ | ||||
|     bail, | ||||
|     config::PlantControllerConfig, | ||||
|     hal::battery::{BatteryInteraction, NoBatteryMonitor}, | ||||
| }; | ||||
| use async_trait::async_trait; | ||||
| use chrono::{DateTime, Utc}; | ||||
| use esp_hal::gpio::{Level, Output, OutputConfig}; | ||||
| use measurements::{Current, Voltage}; | ||||
|  | ||||
| pub struct Initial<'a> { | ||||
|     pub(crate) general_fault: Output<'a>, | ||||
|     pub(crate) esp: Esp<'a>, | ||||
|     pub(crate) config: PlantControllerConfig, | ||||
|     pub(crate) battery: Box<dyn BatteryInteraction + Send>, | ||||
|     pub rtc: Box<dyn RTCModuleInteraction + Send>, | ||||
| } | ||||
|  | ||||
| pub(crate) struct NoRTC {} | ||||
|  | ||||
| #[async_trait(?Send)] | ||||
| impl RTCModuleInteraction for NoRTC { | ||||
|     async fn get_backup_info(&mut self) -> Result<BackupHeader, FatError> { | ||||
|         bail!("Please configure board revision") | ||||
|     } | ||||
|  | ||||
|     async fn get_backup_config(&mut self, _chunk: usize) -> FatResult<([u8; 32], usize, u16)> { | ||||
|         bail!("Please configure board revision") | ||||
|     } | ||||
|  | ||||
|     async fn backup_config(&mut self, _offset: usize, _bytes: &[u8]) -> FatResult<()> { | ||||
|         bail!("Please configure board revision") | ||||
|     } | ||||
|  | ||||
|     async fn backup_config_finalize(&mut self, _crc: u16, _length: usize) -> FatResult<()> { | ||||
|         bail!("Please configure board revision") | ||||
|     } | ||||
|  | ||||
|     async fn get_rtc_time(&mut self) -> Result<DateTime<Utc>, FatError> { | ||||
|         bail!("Please configure board revision") | ||||
|     } | ||||
|  | ||||
|     async fn set_rtc_time(&mut self, _time: &DateTime<Utc>) -> Result<(), FatError> { | ||||
|         bail!("Please configure board revision") | ||||
|     } | ||||
| } | ||||
|  | ||||
| pub(crate) fn create_initial_board( | ||||
|     free_pins: FreePeripherals<'static>, | ||||
|     config: PlantControllerConfig, | ||||
|     esp: Esp<'static>, | ||||
| ) -> Result<Box<dyn BoardInteraction<'static> + Send>, FatError> { | ||||
|     log::info!("Start initial"); | ||||
|     let general_fault = Output::new(free_pins.gpio23, Level::Low, OutputConfig::default()); | ||||
|     let v = Initial { | ||||
|         general_fault, | ||||
|         config, | ||||
|         esp, | ||||
|         battery: Box::new(NoBatteryMonitor {}), | ||||
|         rtc: Box::new(NoRTC {}), | ||||
|     }; | ||||
|     Ok(Box::new(v)) | ||||
| } | ||||
|  | ||||
| #[async_trait(?Send)] | ||||
| impl<'a> BoardInteraction<'a> for Initial<'a> { | ||||
|     fn get_tank_sensor(&mut self) -> Result<&mut TankSensor<'a>, FatError> { | ||||
|         bail!("Please configure board revision") | ||||
|     } | ||||
|  | ||||
|     fn get_esp(&mut self) -> &mut Esp<'a> { | ||||
|         &mut self.esp | ||||
|     } | ||||
|  | ||||
|     fn get_config(&mut self) -> &PlantControllerConfig { | ||||
|         &self.config | ||||
|     } | ||||
|  | ||||
|     fn get_battery_monitor(&mut self) -> &mut Box<dyn BatteryInteraction + Send> { | ||||
|         &mut self.battery | ||||
|     } | ||||
|  | ||||
|     fn get_rtc_module(&mut self) -> &mut Box<dyn RTCModuleInteraction + Send> { | ||||
|         &mut self.rtc | ||||
|     } | ||||
|  | ||||
|     async fn set_charge_indicator(&mut self, _charging: bool) -> Result<(), FatError> { | ||||
|         bail!("Please configure board revision") | ||||
|     } | ||||
|  | ||||
|     async fn deep_sleep(&mut self, duration_in_ms: u64) -> ! { | ||||
|         let rtc = TIME_ACCESS.get().await.lock().await; | ||||
|         self.esp.deep_sleep(duration_in_ms, rtc); | ||||
|     } | ||||
|     fn is_day(&self) -> bool { | ||||
|         false | ||||
|     } | ||||
|     async fn light(&mut self, _enable: bool) -> Result<(), FatError> { | ||||
|         bail!("Please configure board revision") | ||||
|     } | ||||
|  | ||||
|     async fn pump(&mut self, _plant: usize, _enable: bool) -> Result<(), FatError> { | ||||
|         bail!("Please configure board revision") | ||||
|     } | ||||
|  | ||||
|     async fn pump_current(&mut self, _plant: usize) -> Result<Current, FatError> { | ||||
|         bail!("Please configure board revision") | ||||
|     } | ||||
|  | ||||
|     async fn fault(&mut self, _plant: usize, _enable: bool) -> Result<(), FatError> { | ||||
|         bail!("Please configure board revision") | ||||
|     } | ||||
|  | ||||
|     async fn measure_moisture_hz(&mut self) -> Result<Moistures, FatError> { | ||||
|         bail!("Please configure board revision") | ||||
|     } | ||||
|  | ||||
|  | ||||
|     async fn general_fault(&mut self, enable: bool) { | ||||
|         self.general_fault.set_level(enable.into()); | ||||
|     } | ||||
|  | ||||
|     async fn test(&mut self) -> Result<(), FatError> { | ||||
|         bail!("Please configure board revision") | ||||
|     } | ||||
|  | ||||
|     fn set_config(&mut self, config: PlantControllerConfig) { | ||||
|         self.config = config; | ||||
|     } | ||||
|  | ||||
|     async fn get_mptt_voltage(&mut self) -> Result<Voltage, FatError> { | ||||
|         bail!("Please configure board revision") | ||||
|     } | ||||
|  | ||||
|     async fn get_mptt_current(&mut self) -> Result<Current, FatError> { | ||||
|         bail!("Please configure board revision") | ||||
|     } | ||||
| } | ||||
| @@ -1,88 +0,0 @@ | ||||
| use embedded_storage::nor_flash::{check_erase, NorFlash, ReadNorFlash}; | ||||
| use esp_bootloader_esp_idf::partitions::FlashRegion; | ||||
| use esp_storage::FlashStorage; | ||||
| use littlefs2::consts::U4096 as lfsCache; | ||||
| use littlefs2::consts::U512 as lfsLookahead; | ||||
| use littlefs2::driver::Storage as lfs2Storage; | ||||
| use littlefs2::io::Error as lfs2Error; | ||||
| use littlefs2::io::Result as lfs2Result; | ||||
| use log::error; | ||||
|  | ||||
| pub struct LittleFs2Filesystem { | ||||
|     pub(crate) storage: &'static mut FlashRegion<'static, FlashStorage>, | ||||
| } | ||||
|  | ||||
| impl lfs2Storage for LittleFs2Filesystem { | ||||
|     const READ_SIZE: usize = 4096; | ||||
|     const WRITE_SIZE: usize = 4096; | ||||
|     const BLOCK_SIZE: usize = 4096; //usually optimal for flash access | ||||
|     const BLOCK_COUNT: usize = 8 * 1000 * 1000 / 4096; //8Mb in 4k blocks + a little space for stupid calculation errors | ||||
|     const BLOCK_CYCLES: isize = 100; | ||||
|     type CACHE_SIZE = lfsCache; | ||||
|     type LOOKAHEAD_SIZE = lfsLookahead; | ||||
|  | ||||
|     fn read(&mut self, off: usize, buf: &mut [u8]) -> lfs2Result<usize> { | ||||
|         let read_size: usize = Self::READ_SIZE; | ||||
|         if off % read_size != 0 { | ||||
|             error!("Littlefs2Filesystem read error: offset not aligned to read size offset: {} read_size: {}", off, read_size); | ||||
|             return Err(lfs2Error::IO); | ||||
|         } | ||||
|         if buf.len() % read_size != 0 { | ||||
|             error!("Littlefs2Filesystem read error: length not aligned to read size length: {} read_size: {}", buf.len(), read_size); | ||||
|             return Err(lfs2Error::IO); | ||||
|         } | ||||
|         match self.storage.read(off as u32, buf) { | ||||
|             Ok(..) => Ok(buf.len()), | ||||
|             Err(err) => { | ||||
|                 error!("Littlefs2Filesystem read error: {:?}", err); | ||||
|                 Err(lfs2Error::IO) | ||||
|             } | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     fn write(&mut self, off: usize, data: &[u8]) -> lfs2Result<usize> { | ||||
|         let write_size: usize = Self::WRITE_SIZE; | ||||
|         if off % write_size != 0 { | ||||
|             error!("Littlefs2Filesystem write error: offset not aligned to write size offset: {} write_size: {}", off, write_size); | ||||
|             return Err(lfs2Error::IO); | ||||
|         } | ||||
|         if data.len() % write_size != 0 { | ||||
|             error!("Littlefs2Filesystem write error: length not aligned to write size length: {} write_size: {}", data.len(), write_size); | ||||
|             return Err(lfs2Error::IO); | ||||
|         } | ||||
|         match self.storage.write(off as u32, data) { | ||||
|             Ok(..) => Ok(data.len()), | ||||
|             Err(err) => { | ||||
|                 error!("Littlefs2Filesystem write error: {:?}", err); | ||||
|                 Err(lfs2Error::IO) | ||||
|             } | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     fn erase(&mut self, off: usize, len: usize) -> lfs2Result<usize> { | ||||
|         let block_size: usize = Self::BLOCK_SIZE; | ||||
|         if off % block_size != 0 { | ||||
|             error!("Littlefs2Filesystem erase error: offset not aligned to block size offset: {} block_size: {}", off, block_size); | ||||
|             return lfs2Result::Err(lfs2Error::IO); | ||||
|         } | ||||
|         if len % block_size != 0 { | ||||
|             error!("Littlefs2Filesystem erase error: length not aligned to block size length: {} block_size: {}", len, block_size); | ||||
|             return lfs2Result::Err(lfs2Error::IO); | ||||
|         } | ||||
|  | ||||
|         match check_erase(self.storage, off as u32, (off+len) as u32) { | ||||
|             Ok(_) => {} | ||||
|             Err(err) => { | ||||
|                 error!("Littlefs2Filesystem check erase error: {:?}", err); | ||||
|                 return lfs2Result::Err(lfs2Error::IO); | ||||
|             } | ||||
|         } | ||||
|         match self.storage.erase(off as u32, (off + len) as u32) { | ||||
|             Ok(..) => lfs2Result::Ok(len), | ||||
|             Err(err) => { | ||||
|                 error!("Littlefs2Filesystem erase error: {:?}", err); | ||||
|                 lfs2Result::Err(lfs2Error::IO) | ||||
|             } | ||||
|         } | ||||
|     } | ||||
| } | ||||
| @@ -1,715 +0,0 @@ | ||||
| pub(crate) mod battery; | ||||
| // mod can_api; // replaced by external canapi crate | ||||
| pub mod esp; | ||||
| mod initial_hal; | ||||
| mod little_fs2storage_adapter; | ||||
| pub(crate) mod rtc; | ||||
| mod v3_hal; | ||||
| mod v3_shift_register; | ||||
| mod v4_hal; | ||||
| pub(crate) mod v4_sensor; | ||||
| mod water; | ||||
| use crate::alloc::string::ToString; | ||||
| use crate::hal::rtc::{DS3231Module, RTCModuleInteraction}; | ||||
| use esp_hal::peripherals::Peripherals; | ||||
| use esp_hal::peripherals::ADC1; | ||||
| use esp_hal::peripherals::GPIO0; | ||||
| use esp_hal::peripherals::GPIO10; | ||||
| use esp_hal::peripherals::GPIO11; | ||||
| use esp_hal::peripherals::GPIO12; | ||||
| use esp_hal::peripherals::GPIO13; | ||||
| use esp_hal::peripherals::GPIO14; | ||||
| use esp_hal::peripherals::GPIO15; | ||||
| use esp_hal::peripherals::GPIO16; | ||||
| use esp_hal::peripherals::GPIO17; | ||||
| use esp_hal::peripherals::GPIO18; | ||||
| use esp_hal::peripherals::GPIO2; | ||||
| use esp_hal::peripherals::GPIO21; | ||||
| use esp_hal::peripherals::GPIO22; | ||||
| use esp_hal::peripherals::GPIO23; | ||||
| use esp_hal::peripherals::GPIO24; | ||||
| use esp_hal::peripherals::GPIO25; | ||||
| use esp_hal::peripherals::GPIO26; | ||||
| use esp_hal::peripherals::GPIO27; | ||||
| use esp_hal::peripherals::GPIO28; | ||||
| use esp_hal::peripherals::GPIO29; | ||||
| use esp_hal::peripherals::GPIO3; | ||||
| use esp_hal::peripherals::GPIO30; | ||||
| use esp_hal::peripherals::GPIO4; | ||||
| use esp_hal::peripherals::GPIO5; | ||||
| use esp_hal::peripherals::GPIO6; | ||||
| use esp_hal::peripherals::GPIO7; | ||||
| use esp_hal::peripherals::GPIO8; | ||||
| use esp_hal::peripherals::TWAI0; | ||||
|  | ||||
| use crate::{ | ||||
|     bail, | ||||
|     config::{BatteryBoardVersion, BoardVersion, PlantControllerConfig}, | ||||
|     hal::{ | ||||
|         battery::{BatteryInteraction, NoBatteryMonitor}, | ||||
|         esp::Esp, | ||||
|     }, | ||||
|     log::LogMessage, | ||||
|     BOARD_ACCESS, | ||||
| }; | ||||
| use alloc::boxed::Box; | ||||
| use alloc::format; | ||||
| use alloc::sync::Arc; | ||||
| use async_trait::async_trait; | ||||
| use bincode::{Decode, Encode}; | ||||
| use bq34z100::Bq34z100g1Driver; | ||||
| use chrono::{DateTime, FixedOffset, Utc}; | ||||
| use core::cell::RefCell; | ||||
| use canapi::SensorSlot; | ||||
| use ds323x::ic::DS3231; | ||||
| use ds323x::interface::I2cInterface; | ||||
| use ds323x::{DateTimeAccess, Ds323x}; | ||||
| use eeprom24x::addr_size::TwoBytes; | ||||
| use eeprom24x::page_size::B32; | ||||
| use eeprom24x::unique_serial::No; | ||||
| use eeprom24x::{Eeprom24x, SlaveAddr, Storage}; | ||||
| use embassy_embedded_hal::shared_bus::blocking::i2c::I2cDevice; | ||||
| use embassy_sync::blocking_mutex::raw::CriticalSectionRawMutex; | ||||
| use embassy_sync::blocking_mutex::CriticalSectionMutex; | ||||
| use esp_bootloader_esp_idf::partitions::{ | ||||
|     AppPartitionSubType, DataPartitionSubType, FlashRegion, PartitionEntry, | ||||
| }; | ||||
| use esp_hal::clock::CpuClock; | ||||
| use esp_hal::gpio::{Input, InputConfig, Pull}; | ||||
| use measurements::{Current, Voltage}; | ||||
|  | ||||
| use crate::fat_error::{ContextExt, FatError, FatResult}; | ||||
| use crate::hal::battery::{print_battery_bq34z100, BQ34Z100G1}; | ||||
| use crate::hal::little_fs2storage_adapter::LittleFs2Filesystem; | ||||
| use crate::hal::water::TankSensor; | ||||
| use crate::log::LOG_ACCESS; | ||||
| use embassy_sync::mutex::Mutex; | ||||
| use embassy_sync::once_lock::OnceLock; | ||||
| use embedded_storage::nor_flash::ReadNorFlash; | ||||
| use esp_alloc as _; | ||||
| use esp_backtrace as _; | ||||
| use esp_bootloader_esp_idf::ota::{Ota, OtaImageState}; | ||||
| use esp_bootloader_esp_idf::ota::{Slot as ota_slot, Slot}; | ||||
| use esp_hal::delay::Delay; | ||||
| use esp_hal::i2c::master::{BusTimeout, Config, I2c}; | ||||
| use esp_hal::pcnt::unit::Unit; | ||||
| use esp_hal::pcnt::Pcnt; | ||||
| use esp_hal::rng::Rng; | ||||
| use esp_hal::rtc_cntl::{Rtc, SocResetReason}; | ||||
| use esp_hal::system::reset_reason; | ||||
| use esp_hal::time::Rate; | ||||
| use esp_hal::timer::timg::TimerGroup; | ||||
| use esp_hal::Blocking; | ||||
| use esp_storage::FlashStorage; | ||||
| use esp_wifi::{init, EspWifiController}; | ||||
| use littlefs2::fs::{Allocation, Filesystem as lfs2Filesystem}; | ||||
| use littlefs2::object_safe::DynStorage; | ||||
| use log::{error, info, warn}; | ||||
| use portable_atomic::AtomicBool; | ||||
| use serde::Serialize; | ||||
|  | ||||
|  | ||||
| pub static TIME_ACCESS: OnceLock<Mutex<CriticalSectionRawMutex, Rtc>> = OnceLock::new(); | ||||
|  | ||||
| //Only support for 8 right now! | ||||
| pub const PLANT_COUNT: usize = 8; | ||||
|  | ||||
| pub static PROGRESS_ACTIVE: AtomicBool = AtomicBool::new(false); | ||||
|  | ||||
| const TANK_MULTI_SAMPLE: usize = 11; | ||||
| pub static I2C_DRIVER: OnceLock< | ||||
|     embassy_sync::blocking_mutex::Mutex<CriticalSectionRawMutex, RefCell<I2c<Blocking>>>, | ||||
| > = OnceLock::new(); | ||||
|  | ||||
| #[derive(Debug, PartialEq, Clone, Copy, Encode, Decode)] | ||||
| pub enum Sensor { | ||||
|     A, | ||||
|     B, | ||||
| } | ||||
|  | ||||
| impl Into<SensorSlot> for Sensor { | ||||
|     fn into(self) -> SensorSlot { | ||||
|         match self { | ||||
|             Sensor::A => SensorSlot::A, | ||||
|             Sensor::B => SensorSlot::B, | ||||
|         } | ||||
|     } | ||||
| } | ||||
|  | ||||
| pub struct PlantHal {} | ||||
|  | ||||
| pub struct HAL<'a> { | ||||
|     pub board_hal: Box<dyn BoardInteraction<'a> + Send>, | ||||
| } | ||||
|  | ||||
| #[async_trait(?Send)] | ||||
| pub trait BoardInteraction<'a> { | ||||
|     fn get_tank_sensor(&mut self) -> Result<&mut TankSensor<'a>, FatError>; | ||||
|     fn get_esp(&mut self) -> &mut Esp<'a>; | ||||
|     fn get_config(&mut self) -> &PlantControllerConfig; | ||||
|     fn get_battery_monitor(&mut self) -> &mut Box<dyn BatteryInteraction + Send>; | ||||
|     fn get_rtc_module(&mut self) -> &mut Box<dyn RTCModuleInteraction + Send>; | ||||
|     async fn set_charge_indicator(&mut self, charging: bool) -> Result<(), FatError>; | ||||
|     async fn deep_sleep(&mut self, duration_in_ms: u64) -> !; | ||||
|  | ||||
|     fn is_day(&self) -> bool; | ||||
|     //should be multsampled | ||||
|     async fn light(&mut self, enable: bool) -> FatResult<()>; | ||||
|     async fn pump(&mut self, plant: usize, enable: bool) -> FatResult<()>; | ||||
|     async fn pump_current(&mut self, plant: usize) -> FatResult<Current>; | ||||
|     async fn fault(&mut self, plant: usize, enable: bool) -> FatResult<()>; | ||||
|     async fn measure_moisture_hz(&mut self) -> Result<Moistures, FatError>; | ||||
|     async fn general_fault(&mut self, enable: bool); | ||||
|     async fn test(&mut self) -> FatResult<()>; | ||||
|     fn set_config(&mut self, config: PlantControllerConfig); | ||||
|     async fn get_mptt_voltage(&mut self) -> FatResult<Voltage>; | ||||
|     async fn get_mptt_current(&mut self) -> FatResult<Current>; | ||||
|  | ||||
|     // Return JSON string with autodetected sensors per plant. Default: not supported. | ||||
|     async fn detect_sensors(&mut self) -> FatResult<DetectionResult> { | ||||
|         bail!("Autodetection is only available on v4 HAL with CAN bus"); | ||||
|     } | ||||
|  | ||||
|     async fn progress(&mut self, counter: u32) { | ||||
|         // Indicate progress is active to suppress default wait_infinity blinking | ||||
|         crate::hal::PROGRESS_ACTIVE.store(true, core::sync::atomic::Ordering::Relaxed); | ||||
|  | ||||
|         let current = counter % PLANT_COUNT as u32; | ||||
|         for led in 0..PLANT_COUNT { | ||||
|             if let Err(err) = self.fault(led, current == led as u32).await { | ||||
|                 warn!("Fault on plant {}: {:?}", led, err); | ||||
|             } | ||||
|         } | ||||
|         let even = counter % 2 == 0; | ||||
|         let _ = self.general_fault(even.into()).await; | ||||
|     } | ||||
|  | ||||
|     async fn clear_progress(&mut self) { | ||||
|         for led in 0..PLANT_COUNT { | ||||
|             if let Err(err) = self.fault(led, false).await { | ||||
|                 warn!("Fault on plant {}: {:?}", led, err); | ||||
|             } | ||||
|         } | ||||
|         let _ = self.general_fault(false).await; | ||||
|  | ||||
|         // Reset progress active flag so wait_infinity can resume blinking | ||||
|         crate::hal::PROGRESS_ACTIVE.store(false, core::sync::atomic::Ordering::Relaxed); | ||||
|     } | ||||
| } | ||||
|  | ||||
| #[allow(dead_code)] | ||||
| pub struct FreePeripherals<'a> { | ||||
|     pub gpio0: GPIO0<'a>, | ||||
|     pub gpio2: GPIO2<'a>, | ||||
|     pub gpio3: GPIO3<'a>, | ||||
|     pub gpio4: GPIO4<'a>, | ||||
|     pub gpio5: GPIO5<'a>, | ||||
|     pub gpio6: GPIO6<'a>, | ||||
|     pub gpio7: GPIO7<'a>, | ||||
|     pub gpio8: GPIO8<'a>, | ||||
|     // //config button here | ||||
|     pub gpio10: GPIO10<'a>, | ||||
|     pub gpio11: GPIO11<'a>, | ||||
|     pub gpio12: GPIO12<'a>, | ||||
|     pub gpio13: GPIO13<'a>, | ||||
|     pub gpio14: GPIO14<'a>, | ||||
|     pub gpio15: GPIO15<'a>, | ||||
|     pub gpio16: GPIO16<'a>, | ||||
|     pub gpio17: GPIO17<'a>, | ||||
|     pub gpio18: GPIO18<'a>, | ||||
|     // //i2c here | ||||
|     pub gpio21: GPIO21<'a>, | ||||
|     pub gpio22: GPIO22<'a>, | ||||
|     pub gpio23: GPIO23<'a>, | ||||
|     pub gpio24: GPIO24<'a>, | ||||
|     pub gpio25: GPIO25<'a>, | ||||
|     pub gpio26: GPIO26<'a>, | ||||
|     pub gpio27: GPIO27<'a>, | ||||
|     pub gpio28: GPIO28<'a>, | ||||
|     pub gpio29: GPIO29<'a>, | ||||
|     pub gpio30: GPIO30<'a>, | ||||
|     pub twai: TWAI0<'a>, | ||||
|     pub pcnt0: Unit<'a, 0>, | ||||
|     pub pcnt1: Unit<'a, 1>, | ||||
|     pub adc1: ADC1<'a>, | ||||
| } | ||||
|  | ||||
| macro_rules! mk_static { | ||||
|     ($t:ty,$val:expr) => {{ | ||||
|         static STATIC_CELL: static_cell::StaticCell<$t> = static_cell::StaticCell::new(); | ||||
|         #[deny(unused_attributes)] | ||||
|         let x = STATIC_CELL.uninit().write(($val)); | ||||
|         x | ||||
|     }}; | ||||
| } | ||||
|  | ||||
| impl PlantHal { | ||||
|     pub async fn create() -> Result<Mutex<CriticalSectionRawMutex, HAL<'static>>, FatError> { | ||||
|         let config = esp_hal::Config::default().with_cpu_clock(CpuClock::max()); | ||||
|         let peripherals: Peripherals = esp_hal::init(config); | ||||
|  | ||||
|         esp_alloc::heap_allocator!(size: 64 * 1024); | ||||
|         esp_alloc::heap_allocator!(#[link_section = ".dram2_uninit"] size: 64000); | ||||
|  | ||||
|         let rtc: Rtc = Rtc::new(peripherals.LPWR); | ||||
|         TIME_ACCESS | ||||
|             .init(Mutex::new(rtc)) | ||||
|             .map_err(|_| FatError::String { | ||||
|                 error: "Init error rct".to_string(), | ||||
|             })?; | ||||
|  | ||||
|         let systimer = SystemTimer::new(peripherals.SYSTIMER); | ||||
|  | ||||
|         let boot_button = Input::new( | ||||
|             peripherals.GPIO9, | ||||
|             InputConfig::default().with_pull(Pull::None), | ||||
|         ); | ||||
|  | ||||
|         // Reserve GPIO1 for deep sleep wake (configured just before entering sleep) | ||||
|         let wake_gpio1 = peripherals.GPIO1; | ||||
|  | ||||
|         let rng = Rng::new(peripherals.RNG); | ||||
|         let timg0 = TimerGroup::new(peripherals.TIMG0); | ||||
|         let esp_wifi_ctrl = &*mk_static!( | ||||
|             EspWifiController<'static>, | ||||
|             init(timg0.timer0, rng.clone()).expect("Could not init wifi controller") | ||||
|         ); | ||||
|  | ||||
|         let (controller, interfaces) = | ||||
|             esp_wifi::wifi::new(&esp_wifi_ctrl, peripherals.WIFI).expect("Could not init wifi"); | ||||
|  | ||||
|         use esp_hal::timer::systimer::SystemTimer; | ||||
|         esp_hal_embassy::init(systimer.alarm0); | ||||
|  | ||||
|         //let mut adc1 = Adc::new(peripherals.ADC1, adc1_config); | ||||
|         // | ||||
|  | ||||
|         let pcnt_module = Pcnt::new(peripherals.PCNT); | ||||
|  | ||||
|         let free_pins = FreePeripherals { | ||||
|             //     can: peripherals.can, | ||||
|             //     adc1: peripherals.adc1, | ||||
|             //     pcnt0: peripherals.pcnt0, | ||||
|             //     pcnt1: peripherals.pcnt1, | ||||
|             gpio0: peripherals.GPIO0, | ||||
|             gpio2: peripherals.GPIO2, | ||||
|             gpio3: peripherals.GPIO3, | ||||
|             gpio4: peripherals.GPIO4, | ||||
|             gpio5: peripherals.GPIO5, | ||||
|             gpio6: peripherals.GPIO6, | ||||
|             gpio7: peripherals.GPIO7, | ||||
|             gpio8: peripherals.GPIO8, | ||||
|             gpio10: peripherals.GPIO10, | ||||
|             gpio11: peripherals.GPIO11, | ||||
|             gpio12: peripherals.GPIO12, | ||||
|             gpio13: peripherals.GPIO13, | ||||
|             gpio14: peripherals.GPIO14, | ||||
|             gpio15: peripherals.GPIO15, | ||||
|             gpio16: peripherals.GPIO16, | ||||
|             gpio17: peripherals.GPIO17, | ||||
|             gpio18: peripherals.GPIO18, | ||||
|             gpio21: peripherals.GPIO21, | ||||
|             gpio22: peripherals.GPIO22, | ||||
|             gpio23: peripherals.GPIO23, | ||||
|             gpio24: peripherals.GPIO24, | ||||
|             gpio25: peripherals.GPIO25, | ||||
|             gpio26: peripherals.GPIO26, | ||||
|             gpio27: peripherals.GPIO27, | ||||
|             gpio28: peripherals.GPIO28, | ||||
|             gpio29: peripherals.GPIO29, | ||||
|             gpio30: peripherals.GPIO30, | ||||
|             twai: peripherals.TWAI0, | ||||
|             pcnt0: pcnt_module.unit0, | ||||
|             pcnt1: pcnt_module.unit1, | ||||
|             adc1: peripherals.ADC1, | ||||
|         }; | ||||
|  | ||||
|         let tablebuffer = mk_static!( | ||||
|             [u8; esp_bootloader_esp_idf::partitions::PARTITION_TABLE_MAX_LEN], | ||||
|             [0u8; esp_bootloader_esp_idf::partitions::PARTITION_TABLE_MAX_LEN] | ||||
|         ); | ||||
|         let storage_ota = mk_static!(FlashStorage, FlashStorage::new()); | ||||
|         let pt = | ||||
|             esp_bootloader_esp_idf::partitions::read_partition_table(storage_ota, tablebuffer)?; | ||||
|  | ||||
|         let ota_data = mk_static!( | ||||
|             PartitionEntry, | ||||
|             pt.find_partition(esp_bootloader_esp_idf::partitions::PartitionType::Data( | ||||
|                 DataPartitionSubType::Ota, | ||||
|             ))? | ||||
|             .expect("No OTA data partition found") | ||||
|         ); | ||||
|  | ||||
|         let ota_data = mk_static!( | ||||
|             FlashRegion<FlashStorage>, | ||||
|             ota_data.as_embedded_storage(storage_ota) | ||||
|         ); | ||||
|  | ||||
|         let state_0 = ota_state(ota_slot::Slot0, ota_data); | ||||
|         let state_1 = ota_state(ota_slot::Slot1, ota_data); | ||||
|         let mut ota = Ota::new(ota_data)?; | ||||
|         let running = get_current_slot_and_fix_ota_data(&mut ota, state_0, state_1)?; | ||||
|         let target = running.next(); | ||||
|  | ||||
|         info!("Currently running OTA slot: {:?}", running); | ||||
|         info!("Slot0 state: {:?}", state_0); | ||||
|         info!("Slot1 state: {:?}", state_1); | ||||
|  | ||||
|         //obtain current_state and next_state here! | ||||
|         let ota_target = match target { | ||||
|             Slot::None => { | ||||
|                 panic!("No OTA slot active?"); | ||||
|             } | ||||
|             Slot::Slot0 => pt | ||||
|                 .find_partition(esp_bootloader_esp_idf::partitions::PartitionType::App( | ||||
|                     AppPartitionSubType::Ota0, | ||||
|                 ))? | ||||
|                 .context("Partition table invalid no ota0")?, | ||||
|             Slot::Slot1 => pt | ||||
|                 .find_partition(esp_bootloader_esp_idf::partitions::PartitionType::App( | ||||
|                     AppPartitionSubType::Ota1, | ||||
|                 ))? | ||||
|                 .context("Partition table invalid no ota1")?, | ||||
|         }; | ||||
|  | ||||
|         let ota_target = mk_static!(PartitionEntry, ota_target); | ||||
|         let storage_ota = mk_static!(FlashStorage, FlashStorage::new()); | ||||
|         let ota_target = mk_static!( | ||||
|             FlashRegion<FlashStorage>, | ||||
|             ota_target.as_embedded_storage(storage_ota) | ||||
|         ); | ||||
|  | ||||
|         let data_partition = pt | ||||
|             .find_partition(esp_bootloader_esp_idf::partitions::PartitionType::Data( | ||||
|                 DataPartitionSubType::LittleFs, | ||||
|             ))? | ||||
|             .expect("Data partition with littlefs not found"); | ||||
|         let data_partition = mk_static!(PartitionEntry, data_partition); | ||||
|  | ||||
|         let storage_data = mk_static!(FlashStorage, FlashStorage::new()); | ||||
|         let data = mk_static!( | ||||
|             FlashRegion<FlashStorage>, | ||||
|             data_partition.as_embedded_storage(storage_data) | ||||
|         ); | ||||
|         let lfs2filesystem = mk_static!(LittleFs2Filesystem, LittleFs2Filesystem { storage: data }); | ||||
|         let alloc = mk_static!(Allocation<LittleFs2Filesystem>, lfs2Filesystem::allocate()); | ||||
|         if lfs2filesystem.is_mountable() { | ||||
|             log::info!("Littlefs2 filesystem is mountable"); | ||||
|         } else { | ||||
|             match lfs2filesystem.format() { | ||||
|                 Result::Ok(..) => { | ||||
|                     log::info!("Littlefs2 filesystem is formatted"); | ||||
|                 } | ||||
|                 Err(err) => { | ||||
|                     error!("Littlefs2 filesystem could not be formatted: {:?}", err); | ||||
|                 } | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         let fs = Arc::new(Mutex::new( | ||||
|             lfs2Filesystem::mount(alloc, lfs2filesystem).expect("Could not mount lfs2 filesystem"), | ||||
|         )); | ||||
|  | ||||
|         let ap = interfaces.ap; | ||||
|         let sta = interfaces.sta; | ||||
|         let mut esp = Esp { | ||||
|             fs, | ||||
|             rng, | ||||
|             controller: Arc::new(Mutex::new(controller)), | ||||
|             interface_sta: Some(sta), | ||||
|             interface_ap: Some(ap), | ||||
|             boot_button, | ||||
|             wake_gpio1, | ||||
|             ota, | ||||
|             ota_target, | ||||
|             current: running, | ||||
|             slot0_state: state_0, | ||||
|             slot1_state: state_1, | ||||
|         }; | ||||
|  | ||||
|         //init,reset rtc memory depending on cause | ||||
|         let mut init_rtc_store: bool = false; | ||||
|         let mut to_config_mode: bool = false; | ||||
|         let reasons = match reset_reason() { | ||||
|             None => "unknown", | ||||
|             Some(reason) => match reason { | ||||
|                 SocResetReason::ChipPowerOn => "power on", | ||||
|                 SocResetReason::CoreSDIO => "sdio reset", | ||||
|                 SocResetReason::CoreMwdt0 => "Watchdog Main", | ||||
|                 SocResetReason::CoreMwdt1 => "Watchdog 1", | ||||
|                 SocResetReason::CoreRtcWdt => "Watchdog RTC", | ||||
|                 SocResetReason::Cpu0Mwdt0 => "Watchdog MCpu0", | ||||
|                 SocResetReason::Cpu0Sw => "software reset cpu0", | ||||
|                 SocResetReason::SysRtcWdt => "Watchdog Sys rtc", | ||||
|                 SocResetReason::Cpu0Mwdt1 => "cpu0 mwdt1", | ||||
|                 SocResetReason::SysSuperWdt => "Watchdog Super", | ||||
|                 SocResetReason::Cpu0RtcWdt => { | ||||
|                     init_rtc_store = true; | ||||
|                     "Watchdog RTC cpu0" | ||||
|                 } | ||||
|                 SocResetReason::CoreSw => "software reset", | ||||
|                 SocResetReason::CoreDeepSleep => "deep sleep", | ||||
|                 SocResetReason::SysBrownOut => "sys brown out", | ||||
|                 SocResetReason::CoreEfuseCrc => "core efuse crc", | ||||
|                 SocResetReason::CoreUsbUart => { | ||||
|                     //TODO still required? or via button ignore? to_config_mode = true; | ||||
|                     to_config_mode = true; | ||||
|                     "core usb uart" | ||||
|                 } | ||||
|                 SocResetReason::CoreUsbJtag => "core usb jtag", | ||||
|                 SocResetReason::Cpu0JtagCpu => "cpu0 jtag cpu", | ||||
|             }, | ||||
|         }; | ||||
|         LOG_ACCESS | ||||
|             .lock() | ||||
|             .await | ||||
|             .log( | ||||
|                 LogMessage::ResetReason, | ||||
|                 init_rtc_store as u32, | ||||
|                 to_config_mode as u32, | ||||
|                 "", | ||||
|                 &format!("{reasons:?}"), | ||||
|             ) | ||||
|             .await; | ||||
|  | ||||
|         esp.init_rtc_deepsleep_memory(init_rtc_store, to_config_mode) | ||||
|             .await; | ||||
|  | ||||
|         let config = esp.load_config().await; | ||||
|  | ||||
|         log::info!("Init rtc driver"); | ||||
|  | ||||
|         let sda = peripherals.GPIO20; | ||||
|         let scl = peripherals.GPIO19; | ||||
|  | ||||
|         let i2c = I2c::new( | ||||
|             peripherals.I2C0, | ||||
|             Config::default() | ||||
|                 .with_frequency(Rate::from_hz(100)) | ||||
|                 .with_timeout(BusTimeout::Maximum), | ||||
|         )? | ||||
|         .with_scl(scl) | ||||
|         .with_sda(sda); | ||||
|         let i2c_bus: embassy_sync::blocking_mutex::Mutex< | ||||
|             CriticalSectionRawMutex, | ||||
|             RefCell<I2c<Blocking>>, | ||||
|         > = CriticalSectionMutex::new(RefCell::new(i2c)); | ||||
|  | ||||
|         I2C_DRIVER.init(i2c_bus).expect("Could not init i2c driver"); | ||||
|  | ||||
|         let i2c_bus = I2C_DRIVER.get().await; | ||||
|         let rtc_device = I2cDevice::new(&i2c_bus); | ||||
|         let eeprom_device = I2cDevice::new(&i2c_bus); | ||||
|  | ||||
|         let mut rtc: Ds323x< | ||||
|             I2cInterface<I2cDevice<CriticalSectionRawMutex, I2c<Blocking>>>, | ||||
|             DS3231, | ||||
|         > = Ds323x::new_ds3231(rtc_device); | ||||
|  | ||||
|         info!("Init rtc eeprom driver"); | ||||
|         let eeprom = Eeprom24x::new_24x32(eeprom_device, SlaveAddr::Alternative(true, true, true)); | ||||
|         let rtc_time = rtc.datetime(); | ||||
|         match rtc_time { | ||||
|             Ok(tt) => { | ||||
|                 log::info!("Rtc Module reports time at UTC {}", tt); | ||||
|             } | ||||
|             Err(err) => { | ||||
|                 log::info!("Rtc Module could not be read {:?}", err); | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         let storage: Storage< | ||||
|             I2cDevice<'static, CriticalSectionRawMutex, I2c<Blocking>>, | ||||
|             B32, | ||||
|             TwoBytes, | ||||
|             No, | ||||
|             Delay, | ||||
|         > = Storage::new(eeprom, Delay::new()); | ||||
|         let rtc_module: Box<dyn RTCModuleInteraction + Send> = | ||||
|             Box::new(DS3231Module { rtc, storage }) as Box<dyn RTCModuleInteraction + Send>; | ||||
|  | ||||
|         let hal = match config { | ||||
|             Result::Ok(config) => { | ||||
|                 let battery_interaction: Box<dyn BatteryInteraction + Send> = | ||||
|                     match config.hardware.battery { | ||||
|                         BatteryBoardVersion::Disabled => Box::new(NoBatteryMonitor {}), | ||||
|                         BatteryBoardVersion::BQ34Z100G1 => { | ||||
|                             let battery_device = I2cDevice::new(I2C_DRIVER.get().await); | ||||
|                             let mut battery_driver = Bq34z100g1Driver { | ||||
|                                 i2c: battery_device, | ||||
|                                 delay: Delay::new(), | ||||
|                                 flash_block_data: [0; 32], | ||||
|                             }; | ||||
|                             let status = print_battery_bq34z100(&mut battery_driver); | ||||
|                             match status { | ||||
|                                 Ok(_) => {} | ||||
|                                 Err(err) => { | ||||
|                                     LOG_ACCESS | ||||
|                                         .lock() | ||||
|                                         .await | ||||
|                                         .log( | ||||
|                                             LogMessage::BatteryCommunicationError, | ||||
|                                             0u32, | ||||
|                                             0, | ||||
|                                             "", | ||||
|                                             &format!("{err:?})"), | ||||
|                                         ) | ||||
|                                         .await; | ||||
|                                 } | ||||
|                             } | ||||
|                             Box::new(BQ34Z100G1 { battery_driver }) | ||||
|                         } | ||||
|                         BatteryBoardVersion::WchI2cSlave => { | ||||
|                             // TODO use correct implementation once availible | ||||
|                             Box::new(NoBatteryMonitor {}) | ||||
|                         } | ||||
|                     }; | ||||
|  | ||||
|                 let board_hal: Box<dyn BoardInteraction + Send> = match config.hardware.board { | ||||
|                     BoardVersion::INITIAL => { | ||||
|                         initial_hal::create_initial_board(free_pins, config, esp)? | ||||
|                     } | ||||
|                     BoardVersion::V3 => { | ||||
|                         v3_hal::create_v3(free_pins, esp, config, battery_interaction, rtc_module)? | ||||
|                     } | ||||
|                     BoardVersion::V4 => { | ||||
|                         v4_hal::create_v4(free_pins, esp, config, battery_interaction, rtc_module) | ||||
|                             .await? | ||||
|                     } | ||||
|                 }; | ||||
|  | ||||
|                 HAL { board_hal } | ||||
|             } | ||||
|             Err(err) => { | ||||
|                 LOG_ACCESS | ||||
|                     .lock() | ||||
|                     .await | ||||
|                     .log( | ||||
|                         LogMessage::ConfigModeMissingConfig, | ||||
|                         0, | ||||
|                         0, | ||||
|                         "", | ||||
|                         &err.to_string(), | ||||
|                     ) | ||||
|                     .await; | ||||
|                 HAL { | ||||
|                     board_hal: initial_hal::create_initial_board( | ||||
|                         free_pins, | ||||
|                         PlantControllerConfig::default(), | ||||
|                         esp, | ||||
|                     )?, | ||||
|                 } | ||||
|             } | ||||
|         }; | ||||
|  | ||||
|         Ok(Mutex::new(hal)) | ||||
|     } | ||||
| } | ||||
|  | ||||
| fn ota_state(slot: ota_slot, ota_data: &mut FlashRegion<FlashStorage>) -> OtaImageState { | ||||
|     // Read and log OTA states for both slots before constructing Ota | ||||
|     // Each OTA select entry is 32 bytes: [seq:4][label:20][state:4][crc:4] | ||||
|     // Offsets within the OTA data partition: slot0 @ 0x0000, slot1 @ 0x1000 | ||||
|     if slot == ota_slot::None { | ||||
|         return OtaImageState::Undefined; | ||||
|     } | ||||
|     let mut slot_buf = [0u8; 32]; | ||||
|     if slot == ota_slot::Slot0 { | ||||
|         let _ = ota_data.read(0x0000, &mut slot_buf); | ||||
|     } else { | ||||
|         let _ = ota_data.read(0x1000, &mut slot_buf); | ||||
|     } | ||||
|     let raw_state = u32::from_le_bytes(slot_buf[24..28].try_into().unwrap_or([0xff; 4])); | ||||
|     let state0 = OtaImageState::try_from(raw_state).unwrap_or(OtaImageState::Undefined); | ||||
|     state0 | ||||
| } | ||||
|  | ||||
| fn get_current_slot_and_fix_ota_data( | ||||
|     ota: &mut Ota<FlashStorage>, | ||||
|     state0: OtaImageState, | ||||
|     state1: OtaImageState, | ||||
| ) -> Result<ota_slot, FatError> { | ||||
|     let state = ota.current_ota_state().unwrap_or_default(); | ||||
|     let swap = match state { | ||||
|         OtaImageState::Invalid => true, | ||||
|         OtaImageState::Aborted => true, | ||||
|         OtaImageState::Undefined => { | ||||
|             info!("Undefined image in current slot, bootloader wrong?"); | ||||
|             false | ||||
|         } | ||||
|         _ => false, | ||||
|     }; | ||||
|     let current = ota.current_slot()?; | ||||
|     if swap { | ||||
|         let other = match current { | ||||
|             ota_slot::Slot0 => state1, | ||||
|             ota_slot::Slot1 => state0, | ||||
|             _ => OtaImageState::Invalid, | ||||
|         }; | ||||
|  | ||||
|         match other { | ||||
|             OtaImageState::Invalid => { | ||||
|                 bail!( | ||||
|                     "cannot recover slot, as both slots in invalid state {:?} {:?} {:?}", | ||||
|                     current, | ||||
|                     state0, | ||||
|                     state1 | ||||
|                 ); | ||||
|             } | ||||
|             OtaImageState::Aborted => { | ||||
|                 bail!( | ||||
|                     "cannot recover slot, as both slots in invalid state {:?} {:?} {:?}", | ||||
|                     current, | ||||
|                     state0, | ||||
|                     state1 | ||||
|                 ); | ||||
|             } | ||||
|             _ => {} | ||||
|         } | ||||
|         info!( | ||||
|             "Current slot has state {:?} other state has {:?} swapping", | ||||
|             state, other | ||||
|         ); | ||||
|         ota.set_current_slot(current.next())?; | ||||
|         //we actually booted other slot, than partition table assumes | ||||
|         return Ok(ota.current_slot()?); | ||||
|     }; | ||||
|     Ok(current) | ||||
| } | ||||
|  | ||||
| pub async fn esp_time() -> DateTime<Utc> { | ||||
|     let guard = TIME_ACCESS.get().await.lock().await; | ||||
|     DateTime::from_timestamp_micros(guard.current_time_us() as i64).unwrap() | ||||
| } | ||||
|  | ||||
| pub async fn esp_set_time(time: DateTime<FixedOffset>) -> FatResult<()> { | ||||
|     { | ||||
|         let guard = TIME_ACCESS.get().await.lock().await; | ||||
|         guard.set_current_time_us(time.timestamp_micros() as u64); | ||||
|     } | ||||
|     BOARD_ACCESS | ||||
|         .get() | ||||
|         .await | ||||
|         .lock() | ||||
|         .await | ||||
|         .board_hal | ||||
|         .get_rtc_module() | ||||
|         .set_rtc_time(&time.to_utc()) | ||||
|         .await | ||||
| } | ||||
|  | ||||
| #[derive(Debug, Clone, Copy, PartialEq, Default, Serialize)] | ||||
| pub struct Moistures{ | ||||
|     pub sensor_a_hz: [f32; PLANT_COUNT], | ||||
|     pub sensor_b_hz: [f32; PLANT_COUNT], | ||||
| } | ||||
|  | ||||
| #[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize)] | ||||
| pub struct DetectionResult { | ||||
|     plant: [DetectionSensorResult; crate::hal::PLANT_COUNT] | ||||
| } | ||||
| #[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize)] | ||||
| pub struct DetectionSensorResult{ | ||||
|     sensor_a: bool, | ||||
|     sensor_b: bool, | ||||
| } | ||||
| @@ -1,133 +0,0 @@ | ||||
| use crate::hal::Box; | ||||
| use crate::fat_error::FatResult; | ||||
| use async_trait::async_trait; | ||||
| use bincode::config::Configuration; | ||||
| use bincode::{config, Decode, Encode}; | ||||
| use chrono::{DateTime, Utc}; | ||||
| use ds323x::ic::DS3231; | ||||
| use ds323x::interface::I2cInterface; | ||||
| use ds323x::{DateTimeAccess, Ds323x}; | ||||
| use eeprom24x::addr_size::TwoBytes; | ||||
| use eeprom24x::page_size::B32; | ||||
| use eeprom24x::unique_serial::No; | ||||
| use embassy_embedded_hal::shared_bus::blocking::i2c::I2cDevice; | ||||
| use embassy_sync::blocking_mutex::raw::CriticalSectionRawMutex; | ||||
| use embedded_storage::{ReadStorage, Storage}; | ||||
| use esp_hal::delay::Delay; | ||||
| use esp_hal::i2c::master::I2c; | ||||
| use esp_hal::Blocking; | ||||
| use serde::{Deserialize, Serialize}; | ||||
|  | ||||
| pub const X25: crc::Crc<u16> = crc::Crc::<u16>::new(&crc::CRC_16_IBM_SDLC); | ||||
| const CONFIG: Configuration = config::standard(); | ||||
| // | ||||
| #[async_trait(?Send)] | ||||
| pub trait RTCModuleInteraction { | ||||
|     async fn get_backup_info(&mut self) -> FatResult<BackupHeader>; | ||||
|     async fn get_backup_config(&mut self, chunk: usize) -> FatResult<([u8; 32], usize, u16)>; | ||||
|     async fn backup_config(&mut self, offset: usize, bytes: &[u8]) -> FatResult<()>; | ||||
|     async fn backup_config_finalize(&mut self, crc: u16, length: usize) -> FatResult<()>; | ||||
|     async fn get_rtc_time(&mut self) -> FatResult<DateTime<Utc>>; | ||||
|     async fn set_rtc_time(&mut self, time: &DateTime<Utc>) -> FatResult<()>; | ||||
| } | ||||
| // | ||||
| const BACKUP_HEADER_MAX_SIZE: usize = 64; | ||||
|  | ||||
| #[derive(Serialize, Deserialize, PartialEq, Debug, Default, Encode, Decode)] | ||||
| pub struct BackupHeader { | ||||
|     pub timestamp: i64, | ||||
|     crc16: u16, | ||||
|     pub size: u16, | ||||
| } | ||||
| // | ||||
| pub struct DS3231Module { | ||||
|     pub(crate) rtc: Ds323x< | ||||
|         I2cInterface<I2cDevice<'static, CriticalSectionRawMutex, I2c<'static, Blocking>>>, | ||||
|         DS3231, | ||||
|     >, | ||||
|  | ||||
|     pub(crate) storage: eeprom24x::Storage< | ||||
|         I2cDevice<'static, CriticalSectionRawMutex, I2c<'static, Blocking>>, | ||||
|         B32, | ||||
|         TwoBytes, | ||||
|         No, | ||||
|         Delay, | ||||
|     >, | ||||
| } | ||||
|  | ||||
| #[async_trait(?Send)] | ||||
| impl RTCModuleInteraction for DS3231Module { | ||||
|     async fn get_backup_info(&mut self) -> FatResult<BackupHeader> { | ||||
|         let mut header_page_buffer = [0_u8; BACKUP_HEADER_MAX_SIZE]; | ||||
|  | ||||
|         self.storage.read(0, &mut header_page_buffer)?; | ||||
|  | ||||
|         let (header, len): (BackupHeader, usize) = | ||||
|             bincode::decode_from_slice(&header_page_buffer[..], CONFIG)?; | ||||
|  | ||||
|         log::info!("Raw header is {:?} with size {}", header_page_buffer, len); | ||||
|         Ok(header) | ||||
|     } | ||||
|  | ||||
|     async fn get_backup_config(&mut self, chunk: usize) -> FatResult<([u8; 32], usize, u16)> { | ||||
|         let mut header_page_buffer = [0_u8; BACKUP_HEADER_MAX_SIZE]; | ||||
|  | ||||
|         self.storage.read(0, &mut header_page_buffer)?; | ||||
|         let (header, _header_size): (BackupHeader, usize) = | ||||
|             bincode::decode_from_slice(&header_page_buffer[..], CONFIG)?; | ||||
|  | ||||
|         let mut buf = [0_u8; 32]; | ||||
|         let offset = chunk * buf.len() + BACKUP_HEADER_MAX_SIZE; | ||||
|  | ||||
|         let end: usize = header.size as usize + BACKUP_HEADER_MAX_SIZE; | ||||
|         let current_end = offset + buf.len(); | ||||
|         let chunk_size = if current_end > end { | ||||
|             end - offset | ||||
|         } else { | ||||
|             buf.len() | ||||
|         }; | ||||
|         if chunk_size == 0 { | ||||
|             Ok((buf, 0, header.crc16)) | ||||
|         } else { | ||||
|             self.storage.read(offset as u32, &mut buf)?; | ||||
|             //&buf[..chunk_size]; | ||||
|             Ok((buf, chunk_size, header.crc16)) | ||||
|         } | ||||
|     } | ||||
|     async fn backup_config(&mut self, offset: usize, bytes: &[u8]) -> FatResult<()> { | ||||
|         //skip header and write after | ||||
|         self.storage | ||||
|             .write((BACKUP_HEADER_MAX_SIZE + offset) as u32, &bytes)?; | ||||
|  | ||||
|         Ok(()) | ||||
|     } | ||||
|  | ||||
|     async fn backup_config_finalize(&mut self, crc: u16, length: usize) -> FatResult<()> { | ||||
|         let mut header_page_buffer = [0_u8; BACKUP_HEADER_MAX_SIZE]; | ||||
|  | ||||
|         let time = self.get_rtc_time().await?.timestamp_millis(); | ||||
|         let header = BackupHeader { | ||||
|             crc16: crc, | ||||
|             timestamp: time, | ||||
|             size: length as u16, | ||||
|         }; | ||||
|         let config = config::standard(); | ||||
|         let encoded = bincode::encode_into_slice(&header, &mut header_page_buffer, config)?; | ||||
|         log::info!( | ||||
|             "Raw header is {:?} with size {}", | ||||
|             header_page_buffer, | ||||
|             encoded | ||||
|         ); | ||||
|         self.storage.write(0, &header_page_buffer)?; | ||||
|         Ok(()) | ||||
|     } | ||||
|  | ||||
|     async fn get_rtc_time(&mut self) -> FatResult<DateTime<Utc>> { | ||||
|         Ok(self.rtc.datetime()?.and_utc()) | ||||
|     } | ||||
|  | ||||
|     async fn set_rtc_time(&mut self, time: &DateTime<Utc>) -> FatResult<()> { | ||||
|         let naive_time = time.naive_utc(); | ||||
|         Ok(self.rtc.set_datetime(&naive_time)?) | ||||
|     } | ||||
| } | ||||
| @@ -1,450 +0,0 @@ | ||||
| use crate::bail; | ||||
| use crate::fat_error::FatError; | ||||
| use crate::hal::esp::{hold_disable, hold_enable}; | ||||
| use crate::hal::rtc::RTCModuleInteraction; | ||||
| use crate::hal::v3_shift_register::ShiftRegister40; | ||||
| use crate::hal::water::TankSensor; | ||||
| use crate::hal::{BoardInteraction, FreePeripherals, Moistures, Sensor, PLANT_COUNT, TIME_ACCESS}; | ||||
| use crate::log::{LogMessage, LOG_ACCESS}; | ||||
| use crate::{ | ||||
|     config::PlantControllerConfig, | ||||
|     hal::{battery::BatteryInteraction, esp::Esp}, | ||||
| }; | ||||
| use alloc::boxed::Box; | ||||
| use alloc::format; | ||||
| use alloc::string::ToString; | ||||
| use async_trait::async_trait; | ||||
| use embassy_sync::blocking_mutex::raw::CriticalSectionRawMutex; | ||||
| use embassy_sync::mutex::Mutex; | ||||
| use embassy_time::Timer; | ||||
| use embedded_hal::digital::OutputPin as _; | ||||
| use esp_hal::gpio::{Flex, Input, InputConfig, Level, Output, OutputConfig, Pull}; | ||||
| use esp_hal::pcnt::channel::CtrlMode::Keep; | ||||
| use esp_hal::pcnt::channel::EdgeMode::{Hold, Increment}; | ||||
| use esp_hal::pcnt::unit::Unit; | ||||
| use measurements::{Current, Voltage}; | ||||
|  | ||||
| const PUMP8_BIT: usize = 0; | ||||
| const PUMP1_BIT: usize = 1; | ||||
| const PUMP2_BIT: usize = 2; | ||||
| const PUMP3_BIT: usize = 3; | ||||
| const PUMP4_BIT: usize = 4; | ||||
| const PUMP5_BIT: usize = 5; | ||||
| const PUMP6_BIT: usize = 6; | ||||
| const PUMP7_BIT: usize = 7; | ||||
| const MS_0: usize = 8; | ||||
| const MS_4: usize = 9; | ||||
| const MS_2: usize = 10; | ||||
| const MS_3: usize = 11; | ||||
| const MS_1: usize = 13; | ||||
| const SENSOR_ON: usize = 12; | ||||
|  | ||||
| const SENSOR_A_1: u8 = 7; | ||||
| const SENSOR_A_2: u8 = 6; | ||||
| const SENSOR_A_3: u8 = 5; | ||||
| const SENSOR_A_4: u8 = 4; | ||||
| const SENSOR_A_5: u8 = 3; | ||||
| const SENSOR_A_6: u8 = 2; | ||||
| const SENSOR_A_7: u8 = 1; | ||||
| const SENSOR_A_8: u8 = 0; | ||||
|  | ||||
| const SENSOR_B_1: u8 = 8; | ||||
| const SENSOR_B_2: u8 = 9; | ||||
| const SENSOR_B_3: u8 = 10; | ||||
| const SENSOR_B_4: u8 = 11; | ||||
| const SENSOR_B_5: u8 = 12; | ||||
| const SENSOR_B_6: u8 = 13; | ||||
| const SENSOR_B_7: u8 = 14; | ||||
| const SENSOR_B_8: u8 = 15; | ||||
|  | ||||
| const CHARGING: usize = 14; | ||||
| const AWAKE: usize = 15; | ||||
|  | ||||
| const FAULT_3: usize = 16; | ||||
| const FAULT_8: usize = 17; | ||||
| const FAULT_7: usize = 18; | ||||
| const FAULT_6: usize = 19; | ||||
| const FAULT_5: usize = 20; | ||||
| const FAULT_4: usize = 21; | ||||
| const FAULT_1: usize = 22; | ||||
| const FAULT_2: usize = 23; | ||||
|  | ||||
| const REPEAT_MOIST_MEASURE: usize = 1; | ||||
|  | ||||
| pub struct V3<'a> { | ||||
|     config: PlantControllerConfig, | ||||
|     battery_monitor: Box<dyn BatteryInteraction + Send>, | ||||
|     rtc_module: Box<dyn RTCModuleInteraction + Send>, | ||||
|     esp: Esp<'a>, | ||||
|     shift_register: | ||||
|         Mutex<CriticalSectionRawMutex, ShiftRegister40<Output<'a>, Output<'a>, Output<'a>>>, | ||||
|     _shift_register_enable_invert: Output<'a>, | ||||
|     tank_sensor: TankSensor<'a>, | ||||
|     solar_is_day: Input<'a>, | ||||
|     light: Output<'a>, | ||||
|     main_pump: Output<'a>, | ||||
|     general_fault: Output<'a>, | ||||
|     pub signal_counter: Unit<'static, 0>, | ||||
| } | ||||
|  | ||||
| pub(crate) fn create_v3( | ||||
|     peripherals: FreePeripherals<'static>, | ||||
|     esp: Esp<'static>, | ||||
|     config: PlantControllerConfig, | ||||
|     battery_monitor: Box<dyn BatteryInteraction + Send>, | ||||
|     rtc_module: Box<dyn RTCModuleInteraction + Send>, | ||||
| ) -> Result<Box<dyn BoardInteraction<'static> + Send + 'static>, FatError> { | ||||
|     log::info!("Start v3"); | ||||
|     let clock = Output::new(peripherals.gpio15, Level::Low, OutputConfig::default()); | ||||
|     let latch = Output::new(peripherals.gpio3, Level::Low, OutputConfig::default()); | ||||
|     let data = Output::new(peripherals.gpio23, Level::Low, OutputConfig::default()); | ||||
|     let shift_register = ShiftRegister40::new(clock, latch, data); | ||||
|     //disable all | ||||
|     for mut pin in shift_register.decompose() { | ||||
|         let _ = pin.set_low(); | ||||
|     } | ||||
|  | ||||
|     // Set always-on status bits | ||||
|     let _ = shift_register.decompose()[AWAKE].set_high(); | ||||
|     let _ = shift_register.decompose()[CHARGING].set_high(); | ||||
|  | ||||
|     // Multiplexer defaults: ms0..ms3 low, ms4 high (disabled) | ||||
|     let _ = shift_register.decompose()[MS_0].set_low(); | ||||
|     let _ = shift_register.decompose()[MS_1].set_low(); | ||||
|     let _ = shift_register.decompose()[MS_2].set_low(); | ||||
|     let _ = shift_register.decompose()[MS_3].set_low(); | ||||
|     let _ = shift_register.decompose()[MS_4].set_high(); | ||||
|  | ||||
|     let one_wire_pin = Flex::new(peripherals.gpio18); | ||||
|     let tank_power_pin = Output::new(peripherals.gpio11, Level::Low, OutputConfig::default()); | ||||
|  | ||||
|     let flow_sensor_pin = Input::new( | ||||
|         peripherals.gpio4, | ||||
|         InputConfig::default().with_pull(Pull::Up), | ||||
|     ); | ||||
|  | ||||
|     let tank_sensor = TankSensor::create( | ||||
|         one_wire_pin, | ||||
|         peripherals.adc1, | ||||
|         peripherals.gpio5, | ||||
|         tank_power_pin, | ||||
|         flow_sensor_pin, | ||||
|         peripherals.pcnt1, | ||||
|     )?; | ||||
|  | ||||
|     let solar_is_day = Input::new(peripherals.gpio7, InputConfig::default()); | ||||
|     let light = Output::new(peripherals.gpio10, Level::Low, OutputConfig::default()); | ||||
|     let mut main_pump = Output::new(peripherals.gpio2, Level::Low, OutputConfig::default()); | ||||
|     main_pump.set_low(); | ||||
|     let mut general_fault = Output::new(peripherals.gpio6, Level::Low, OutputConfig::default()); | ||||
|     general_fault.set_low(); | ||||
|  | ||||
|     let mut shift_register_enable_invert = | ||||
|         Output::new(peripherals.gpio21, Level::Low, OutputConfig::default()); | ||||
|     shift_register_enable_invert.set_low(); | ||||
|  | ||||
|     let signal_counter = peripherals.pcnt0; | ||||
|  | ||||
|     signal_counter.set_high_limit(Some(i16::MAX))?; | ||||
|  | ||||
|     let ch0 = &signal_counter.channel0; | ||||
|     let edge_pin = Input::new(peripherals.gpio22, InputConfig::default()); | ||||
|     ch0.set_edge_signal(edge_pin.peripheral_input()); | ||||
|     ch0.set_input_mode(Hold, Increment); | ||||
|     ch0.set_ctrl_mode(Keep, Keep); | ||||
|     signal_counter.listen(); | ||||
|  | ||||
|     Ok(Box::new(V3 { | ||||
|         config, | ||||
|         battery_monitor, | ||||
|         rtc_module, | ||||
|         esp, | ||||
|         shift_register: Mutex::new(shift_register), | ||||
|         _shift_register_enable_invert: shift_register_enable_invert, | ||||
|         tank_sensor, | ||||
|         solar_is_day, | ||||
|         light, | ||||
|         main_pump, | ||||
|         general_fault, | ||||
|         signal_counter, | ||||
|     })) | ||||
| } | ||||
|  | ||||
| impl V3<'_> { | ||||
|  | ||||
|     async fn inner_measure_moisture_hz(&mut self, plant: usize, sensor: Sensor) -> Result<f32, FatError> { | ||||
|         let mut results = [0_f32; REPEAT_MOIST_MEASURE]; | ||||
|         for repeat in 0..REPEAT_MOIST_MEASURE { | ||||
|             self.signal_counter.pause(); | ||||
|             self.signal_counter.clear(); | ||||
|             //Disable all | ||||
|             { | ||||
|                 let shift_register = self.shift_register.lock().await; | ||||
|                 shift_register.decompose()[MS_4].set_high()?; | ||||
|             } | ||||
|  | ||||
|             let sensor_channel = match sensor { | ||||
|                 Sensor::A => match plant { | ||||
|                     0 => SENSOR_A_1, | ||||
|                     1 => SENSOR_A_2, | ||||
|                     2 => SENSOR_A_3, | ||||
|                     3 => SENSOR_A_4, | ||||
|                     4 => SENSOR_A_5, | ||||
|                     5 => SENSOR_A_6, | ||||
|                     6 => SENSOR_A_7, | ||||
|                     7 => SENSOR_A_8, | ||||
|                     _ => bail!("Invalid plant id {}", plant), | ||||
|                 }, | ||||
|                 Sensor::B => match plant { | ||||
|                     0 => SENSOR_B_1, | ||||
|                     1 => SENSOR_B_2, | ||||
|                     2 => SENSOR_B_3, | ||||
|                     3 => SENSOR_B_4, | ||||
|                     4 => SENSOR_B_5, | ||||
|                     5 => SENSOR_B_6, | ||||
|                     6 => SENSOR_B_7, | ||||
|                     7 => SENSOR_B_8, | ||||
|                     _ => bail!("Invalid plant id {}", plant), | ||||
|                 }, | ||||
|             }; | ||||
|  | ||||
|             let is_bit_set = |b: u8| -> bool { sensor_channel & (1 << b) != 0 }; | ||||
|             { | ||||
|                 let shift_register = self.shift_register.lock().await; | ||||
|                 let pin_0 = &mut shift_register.decompose()[MS_0]; | ||||
|                 let pin_1 = &mut shift_register.decompose()[MS_1]; | ||||
|                 let pin_2 = &mut shift_register.decompose()[MS_2]; | ||||
|                 let pin_3 = &mut shift_register.decompose()[MS_3]; | ||||
|                 if is_bit_set(0) { | ||||
|                     pin_0.set_high()?; | ||||
|                 } else { | ||||
|                     pin_0.set_low()?; | ||||
|                 } | ||||
|                 if is_bit_set(1) { | ||||
|                     pin_1.set_high()?; | ||||
|                 } else { | ||||
|                     pin_1.set_low()?; | ||||
|                 } | ||||
|                 if is_bit_set(2) { | ||||
|                     pin_2.set_high()?; | ||||
|                 } else { | ||||
|                     pin_2.set_low()?; | ||||
|                 } | ||||
|                 if is_bit_set(3) { | ||||
|                     pin_3.set_high()?; | ||||
|                 } else { | ||||
|                     pin_3.set_low()?; | ||||
|                 } | ||||
|  | ||||
|                 shift_register.decompose()[MS_4].set_low()?; | ||||
|                 shift_register.decompose()[SENSOR_ON].set_high()?; | ||||
|             } | ||||
|             let measurement = 100; //how long to measure and then extrapolate to hz | ||||
|             let factor = 1000f32 / measurement as f32; //scale raw cound by this number to get hz | ||||
|  | ||||
|             //give some time to stabilize | ||||
|             Timer::after_millis(10).await; | ||||
|             self.signal_counter.resume(); | ||||
|             Timer::after_millis(measurement).await; | ||||
|             self.signal_counter.pause(); | ||||
|             { | ||||
|                 let shift_register = self.shift_register.lock().await; | ||||
|                 shift_register.decompose()[MS_4].set_high()?; | ||||
|                 shift_register.decompose()[SENSOR_ON].set_low()?; | ||||
|             } | ||||
|             Timer::after_millis(10).await; | ||||
|             let unscaled = self.signal_counter.value(); | ||||
|             let hz = unscaled as f32 * factor; | ||||
|             LOG_ACCESS | ||||
|                 .lock() | ||||
|                 .await | ||||
|                 .log( | ||||
|                     LogMessage::RawMeasure, | ||||
|                     unscaled as u32, | ||||
|                     hz as u32, | ||||
|                     &plant.to_string(), | ||||
|                     &format!("{sensor:?}"), | ||||
|                 ) | ||||
|                 .await; | ||||
|             results[repeat] = hz; | ||||
|         } | ||||
|         results.sort_by(|a, b| a.partial_cmp(b).unwrap()); // floats don't seem to implement total_ord | ||||
|  | ||||
|         let mid = results.len() / 2; | ||||
|         let median = results[mid]; | ||||
|         Ok(median) | ||||
|     } | ||||
| } | ||||
|  | ||||
| #[async_trait(?Send)] | ||||
| impl<'a> BoardInteraction<'a> for V3<'a> { | ||||
|     fn get_tank_sensor(&mut self) -> Result<&mut TankSensor<'a>, FatError> { | ||||
|         Ok(&mut self.tank_sensor) | ||||
|     } | ||||
|  | ||||
|     fn get_esp(&mut self) -> &mut Esp<'a> { | ||||
|         &mut self.esp | ||||
|     } | ||||
|  | ||||
|     fn get_config(&mut self) -> &PlantControllerConfig { | ||||
|         &self.config | ||||
|     } | ||||
|  | ||||
|     fn get_battery_monitor(&mut self) -> &mut Box<dyn BatteryInteraction + Send> { | ||||
|         &mut self.battery_monitor | ||||
|     } | ||||
|  | ||||
|     fn get_rtc_module(&mut self) -> &mut Box<dyn RTCModuleInteraction + Send> { | ||||
|         &mut self.rtc_module | ||||
|     } | ||||
|     async fn set_charge_indicator(&mut self, charging: bool) -> Result<(), FatError> { | ||||
|         let shift_register = self.shift_register.lock().await; | ||||
|         if charging { | ||||
|             let _ = shift_register.decompose()[CHARGING].set_high(); | ||||
|         } else { | ||||
|             let _ = shift_register.decompose()[CHARGING].set_low(); | ||||
|         } | ||||
|         Ok(()) | ||||
|     } | ||||
|  | ||||
|     async fn deep_sleep(&mut self, duration_in_ms: u64) -> ! { | ||||
|         let _ = self.shift_register.lock().await.decompose()[AWAKE].set_low(); | ||||
|         let guard = TIME_ACCESS.get().await.lock().await; | ||||
|         self.esp.deep_sleep(duration_in_ms, guard) | ||||
|     } | ||||
|  | ||||
|     fn is_day(&self) -> bool { | ||||
|         self.solar_is_day.is_high() | ||||
|     } | ||||
|  | ||||
|     async fn light(&mut self, enable: bool) -> Result<(), FatError> { | ||||
|         hold_disable(10); | ||||
|         if enable { | ||||
|             self.light.set_high(); | ||||
|         } else { | ||||
|             self.light.set_low(); | ||||
|         } | ||||
|         hold_enable(10); | ||||
|         Ok(()) | ||||
|     } | ||||
|     async fn pump(&mut self, plant: usize, enable: bool) -> Result<(), FatError> { | ||||
|         if enable { | ||||
|             self.main_pump.set_high(); | ||||
|         } | ||||
|  | ||||
|         let index = match plant { | ||||
|             0 => PUMP1_BIT, | ||||
|             1 => PUMP2_BIT, | ||||
|             2 => PUMP3_BIT, | ||||
|             3 => PUMP4_BIT, | ||||
|             4 => PUMP5_BIT, | ||||
|             5 => PUMP6_BIT, | ||||
|             6 => PUMP7_BIT, | ||||
|             7 => PUMP8_BIT, | ||||
|             _ => bail!("Invalid pump {plant}"), | ||||
|         }; | ||||
|         let shift_register = self.shift_register.lock().await; | ||||
|         if enable { | ||||
|             let _ = shift_register.decompose()[index].set_high(); | ||||
|         } else { | ||||
|             let _ = shift_register.decompose()[index].set_low(); | ||||
|         } | ||||
|  | ||||
|         if !enable { | ||||
|             self.main_pump.set_low(); | ||||
|         } | ||||
|         Ok(()) | ||||
|     } | ||||
|  | ||||
|     async fn pump_current(&mut self, _plant: usize) -> Result<Current, FatError> { | ||||
|         bail!("Not implemented in v3") | ||||
|     } | ||||
|  | ||||
|     async fn fault(&mut self, plant: usize, enable: bool) -> Result<(), FatError> { | ||||
|         let index = match plant { | ||||
|             0 => FAULT_1, | ||||
|             1 => FAULT_2, | ||||
|             2 => FAULT_3, | ||||
|             3 => FAULT_4, | ||||
|             4 => FAULT_5, | ||||
|             5 => FAULT_6, | ||||
|             6 => FAULT_7, | ||||
|             7 => FAULT_8, | ||||
|             _ => panic!("Invalid plant id {}", plant), | ||||
|         }; | ||||
|         let shift_register = self.shift_register.lock().await; | ||||
|         if enable { | ||||
|             let _ = shift_register.decompose()[index].set_high(); | ||||
|         } else { | ||||
|             let _ = shift_register.decompose()[index].set_low(); | ||||
|         } | ||||
|         Ok(()) | ||||
|     } | ||||
|  | ||||
|     async fn measure_moisture_hz(&mut self) -> Result<Moistures, FatError> { | ||||
|         let mut result = Moistures::default(); | ||||
|         for plant in 0..PLANT_COUNT { | ||||
|             let a = self.inner_measure_moisture_hz(plant, Sensor::A).await; | ||||
|             let b = self.inner_measure_moisture_hz(plant, Sensor::B).await; | ||||
|             let aa = a.unwrap_or_else(|_| u32::MAX as f32); | ||||
|             let bb = b.unwrap_or_else(|_| u32::MAX as f32); | ||||
|             LOG_ACCESS | ||||
|                 .lock() | ||||
|                 .await | ||||
|                 .log(LogMessage::TestSensor, aa as u32, bb as u32, &plant.to_string(), "") | ||||
|                 .await; | ||||
|             result.sensor_a_hz[plant] = aa; | ||||
|             result.sensor_b_hz[plant] = bb; | ||||
|         } | ||||
|         Ok(result) | ||||
|     } | ||||
|  | ||||
|  | ||||
|     async fn general_fault(&mut self, enable: bool) { | ||||
|         hold_disable(6); | ||||
|         if enable { | ||||
|             self.general_fault.set_high(); | ||||
|         } else { | ||||
|             self.general_fault.set_low(); | ||||
|         } | ||||
|         hold_enable(6); | ||||
|     } | ||||
|  | ||||
|     async fn test(&mut self) -> Result<(), FatError> { | ||||
|         self.general_fault(true).await; | ||||
|         Timer::after_millis(100).await; | ||||
|         self.general_fault(false).await; | ||||
|         Timer::after_millis(100).await; | ||||
|         self.light(true).await?; | ||||
|         Timer::after_millis(500).await; | ||||
|  | ||||
|         self.light(false).await?; | ||||
|         Timer::after_millis(500).await; | ||||
|         for i in 0..PLANT_COUNT { | ||||
|             self.fault(i, true).await?; | ||||
|             Timer::after_millis(500).await; | ||||
|             self.fault(i, false).await?; | ||||
|             Timer::after_millis(500).await; | ||||
|         } | ||||
|         for i in 0..PLANT_COUNT { | ||||
|             self.pump(i, true).await?; | ||||
|             Timer::after_millis(100).await; | ||||
|             self.pump(i, false).await?; | ||||
|             Timer::after_millis(100).await; | ||||
|         } | ||||
|         self.measure_moisture_hz().await?; | ||||
|         Timer::after_millis(10).await; | ||||
|         Ok(()) | ||||
|     } | ||||
|  | ||||
|     fn set_config(&mut self, config: PlantControllerConfig) { | ||||
|         self.config = config; | ||||
|     } | ||||
|  | ||||
|     async fn get_mptt_voltage(&mut self) -> Result<Voltage, FatError> { | ||||
|         bail!("Not implemented in v3") | ||||
|     } | ||||
|     async fn get_mptt_current(&mut self) -> Result<Current, FatError> { | ||||
|         bail!("Not implemented in v3") | ||||
|     } | ||||
| } | ||||
| @@ -1,154 +0,0 @@ | ||||
| //! Serial-in parallel-out shift register | ||||
| #![allow(warnings)] | ||||
| use core::cell::RefCell; | ||||
| use core::convert::Infallible; | ||||
| use core::iter::Iterator; | ||||
| use core::mem::{self, MaybeUninit}; | ||||
| use core::result::{Result, Result::Ok}; | ||||
| use embedded_hal::digital::OutputPin; | ||||
|  | ||||
| trait ShiftRegisterInternal: Send { | ||||
|     fn update(&self, index: usize, command: bool) -> Result<(), ()>; | ||||
| } | ||||
|  | ||||
| /// Output pin of the shift register | ||||
| pub struct ShiftRegisterPin<'a> { | ||||
|     shift_register: &'a dyn ShiftRegisterInternal, | ||||
|     index: usize, | ||||
| } | ||||
|  | ||||
| impl<'a> ShiftRegisterPin<'a> { | ||||
|     fn new(shift_register: &'a dyn ShiftRegisterInternal, index: usize) -> Self { | ||||
|         ShiftRegisterPin { | ||||
|             shift_register, | ||||
|             index, | ||||
|         } | ||||
|     } | ||||
| } | ||||
|  | ||||
| impl embedded_hal::digital::ErrorType for ShiftRegisterPin<'_> { | ||||
|     type Error = Infallible; | ||||
| } | ||||
|  | ||||
| impl OutputPin for ShiftRegisterPin<'_> { | ||||
|     fn set_low(&mut self) -> Result<(), Infallible> { | ||||
|         self.shift_register.update(self.index, false).unwrap(); | ||||
|         Ok(()) | ||||
|     } | ||||
|  | ||||
|     fn set_high(&mut self) -> Result<(), Infallible> { | ||||
|         self.shift_register.update(self.index, true).unwrap(); | ||||
|         Ok(()) | ||||
|     } | ||||
| } | ||||
|  | ||||
| macro_rules! ShiftRegisterBuilder { | ||||
|     ($name: ident, $size: expr) => { | ||||
|         /// Serial-in parallel-out shift register | ||||
|         pub struct $name<Pin1, Pin2, Pin3> | ||||
|         where | ||||
|             Pin1: OutputPin + Send, | ||||
|             Pin2: OutputPin + Send, | ||||
|             Pin3: OutputPin + Send, | ||||
|         { | ||||
|             clock: RefCell<Pin1>, | ||||
|             latch: RefCell<Pin2>, | ||||
|             data: RefCell<Pin3>, | ||||
|             output_state: RefCell<[bool; $size]>, | ||||
|         } | ||||
|  | ||||
|         impl<Pin1, Pin2, Pin3> ShiftRegisterInternal for $name<Pin1, Pin2, Pin3> | ||||
|         where | ||||
|             Pin1: OutputPin + Send, | ||||
|             Pin2: OutputPin + Send, | ||||
|             Pin3: OutputPin + Send, | ||||
|         { | ||||
|             /// Sets the value of the shift register output at `index` to value `command` | ||||
|             fn update(&self, index: usize, command: bool) -> Result<(), ()> { | ||||
|                 self.output_state.borrow_mut()[index] = command; | ||||
|                 let output_state = self.output_state.borrow(); | ||||
|                 self.latch.borrow_mut().set_low().map_err(|_e| ())?; | ||||
|  | ||||
|                 for i in 1..=output_state.len() { | ||||
|                     if output_state[output_state.len() - i] { | ||||
|                         self.data.borrow_mut().set_high().map_err(|_e| ())?; | ||||
|                     } else { | ||||
|                         self.data.borrow_mut().set_low().map_err(|_e| ())?; | ||||
|                     } | ||||
|                     self.clock.borrow_mut().set_high().map_err(|_e| ())?; | ||||
|                     self.clock.borrow_mut().set_low().map_err(|_e| ())?; | ||||
|                 } | ||||
|  | ||||
|                 self.latch.borrow_mut().set_high().map_err(|_e| ())?; | ||||
|                 Ok(()) | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         impl<Pin1, Pin2, Pin3> $name<Pin1, Pin2, Pin3> | ||||
|         where | ||||
|             Pin1: OutputPin + Send, | ||||
|             Pin2: OutputPin + Send, | ||||
|             Pin3: OutputPin + Send, | ||||
|         { | ||||
|             /// Creates a new SIPO shift register from clock, latch, and data output pins | ||||
|             pub fn new(clock: Pin1, latch: Pin2, data: Pin3) -> Self { | ||||
|                 $name { | ||||
|                     clock: RefCell::new(clock), | ||||
|                     latch: RefCell::new(latch), | ||||
|                     data: RefCell::new(data), | ||||
|                     output_state: RefCell::new([false; $size]), | ||||
|                 } | ||||
|             } | ||||
|  | ||||
|             /// Get embedded-hal output pins to control the shift register outputs | ||||
|             pub fn decompose(&self) -> [ShiftRegisterPin<'_>; $size] { | ||||
|                 // Create an uninitialized array of `MaybeUninit`. The `assume_init` is | ||||
|                 // safe because the type we are claiming to have initialized here is a | ||||
|                 // bunch of `MaybeUninit`s, which do not require initialization. | ||||
|                 let mut pins: [MaybeUninit<ShiftRegisterPin>; $size] = | ||||
|                     unsafe { MaybeUninit::uninit().assume_init() }; | ||||
|  | ||||
|                 // Dropping a `MaybeUninit` does nothing, so if there is a panic during this loop, | ||||
|                 // we have a memory leak, but there is no memory safety issue. | ||||
|                 for (index, elem) in pins.iter_mut().enumerate() { | ||||
|                     elem.write(ShiftRegisterPin::new(self, index)); | ||||
|                 } | ||||
|  | ||||
|                 // Everything is initialized. Transmute the array to the | ||||
|                 // initialized type. | ||||
|                 unsafe { mem::transmute::<_, [ShiftRegisterPin; $size]>(pins) } | ||||
|             } | ||||
|  | ||||
|             /// Consume the shift register and return the original clock, latch, and data output pins | ||||
|             pub fn release(self) -> (Pin1, Pin2, Pin3) { | ||||
|                 let Self { | ||||
|                     clock, | ||||
|                     latch, | ||||
|                     data, | ||||
|                     output_state: _, | ||||
|                 } = self; | ||||
|                 (clock.into_inner(), latch.into_inner(), data.into_inner()) | ||||
|             } | ||||
|         } | ||||
|     }; | ||||
| } | ||||
|  | ||||
| ShiftRegisterBuilder!(ShiftRegister8, 8); | ||||
| ShiftRegisterBuilder!(ShiftRegister16, 16); | ||||
| ShiftRegisterBuilder!(ShiftRegister24, 24); | ||||
| ShiftRegisterBuilder!(ShiftRegister32, 32); | ||||
| ShiftRegisterBuilder!(ShiftRegister40, 40); | ||||
| ShiftRegisterBuilder!(ShiftRegister48, 48); | ||||
| ShiftRegisterBuilder!(ShiftRegister56, 56); | ||||
| ShiftRegisterBuilder!(ShiftRegister64, 64); | ||||
| ShiftRegisterBuilder!(ShiftRegister72, 72); | ||||
| ShiftRegisterBuilder!(ShiftRegister80, 80); | ||||
| ShiftRegisterBuilder!(ShiftRegister88, 88); | ||||
| ShiftRegisterBuilder!(ShiftRegister96, 96); | ||||
| ShiftRegisterBuilder!(ShiftRegister104, 104); | ||||
| ShiftRegisterBuilder!(ShiftRegister112, 112); | ||||
| ShiftRegisterBuilder!(ShiftRegister120, 120); | ||||
| ShiftRegisterBuilder!(ShiftRegister128, 128); | ||||
|  | ||||
| /// 8 output serial-in parallel-out shift register | ||||
| pub type ShiftRegister<Pin1, Pin2, Pin3> = ShiftRegister8<Pin1, Pin2, Pin3>; | ||||
| @@ -1,458 +0,0 @@ | ||||
| use crate::bail; | ||||
| use crate::config::PlantControllerConfig; | ||||
| use crate::fat_error::{FatError, FatResult}; | ||||
| use crate::hal::battery::BatteryInteraction; | ||||
| use crate::hal::esp::{hold_disable, hold_enable, Esp}; | ||||
| use crate::hal::rtc::RTCModuleInteraction; | ||||
| use crate::hal::v4_sensor::{SensorImpl, SensorInteraction}; | ||||
| use crate::hal::water::TankSensor; | ||||
| use crate::hal::{BoardInteraction, DetectionResult, FreePeripherals, Moistures, I2C_DRIVER, PLANT_COUNT, TIME_ACCESS}; | ||||
| use crate::log::{LogMessage, LOG_ACCESS}; | ||||
| use alloc::boxed::Box; | ||||
| use alloc::string::ToString; | ||||
| use async_trait::async_trait; | ||||
| use embassy_embedded_hal::shared_bus::blocking::i2c::I2cDevice; | ||||
| use embassy_sync::blocking_mutex::raw::CriticalSectionRawMutex; | ||||
| use embassy_time::Timer; | ||||
| use esp_hal::gpio::{Flex, Input, InputConfig, Level, Output, OutputConfig, Pull}; | ||||
| use esp_hal::i2c::master::I2c; | ||||
| use esp_hal::pcnt::channel::CtrlMode::Keep; | ||||
| use esp_hal::pcnt::channel::EdgeMode::{Hold, Increment}; | ||||
| use esp_hal::twai::TwaiMode; | ||||
| use esp_hal::{twai, Blocking}; | ||||
| use ina219::address::{Address, Pin}; | ||||
| use ina219::calibration::UnCalibrated; | ||||
| use ina219::configuration::{Configuration, OperatingMode, Resolution}; | ||||
| use ina219::SyncIna219; | ||||
| use measurements::Resistance; | ||||
| use measurements::{Current, Voltage}; | ||||
| use pca9535::{GPIOBank, Pca9535Immediate, StandardExpanderInterface}; | ||||
|  | ||||
| const MPPT_CURRENT_SHUNT_OHMS: f64 = 0.05_f64; | ||||
| const TWAI_BAUDRATE: twai::BaudRate = twai::BaudRate::B125K; | ||||
|  | ||||
| pub enum Charger<'a> { | ||||
|     SolarMpptV1 { | ||||
|         mppt_ina: SyncIna219< | ||||
|             I2cDevice<'a, CriticalSectionRawMutex, I2c<'static, Blocking>>, | ||||
|             UnCalibrated, | ||||
|         >, | ||||
|         solar_is_day: Input<'a>, | ||||
|         charge_indicator: Output<'a>, | ||||
|     }, | ||||
|     ErrorInit {}, | ||||
| } | ||||
|  | ||||
| impl<'a> Charger<'a> { | ||||
|     pub(crate) fn get_mppt_current(&mut self) -> FatResult<Current> { | ||||
|         match self { | ||||
|             Charger::SolarMpptV1 { mppt_ina, .. } => { | ||||
|                 let v = mppt_ina.shunt_voltage()?; | ||||
|                 let shunt_voltage = Voltage::from_microvolts(v.shunt_voltage_uv().abs() as f64); | ||||
|                 let shut_value = Resistance::from_ohms(MPPT_CURRENT_SHUNT_OHMS); | ||||
|                 let current = shunt_voltage.as_volts() / shut_value.as_ohms(); | ||||
|                 Ok(Current::from_amperes(current)) | ||||
|             } | ||||
|             Charger::ErrorInit { .. } => { | ||||
|                 bail!("hardware error during init"); | ||||
|             } | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     pub(crate) fn get_mptt_voltage(&mut self) -> FatResult<Voltage> { | ||||
|         match self { | ||||
|             Charger::SolarMpptV1 { mppt_ina, .. } => { | ||||
|                 let v = mppt_ina.bus_voltage()?; | ||||
|                 Ok(Voltage::from_millivolts(v.voltage_mv() as f64)) | ||||
|             } | ||||
|             Charger::ErrorInit { .. } => { | ||||
|                 bail!("hardware error during init"); | ||||
|             } | ||||
|         } | ||||
|     } | ||||
| } | ||||
|  | ||||
| impl Charger<'_> { | ||||
|     pub(crate) fn power_save(&mut self) { | ||||
|         match self { | ||||
|             Charger::SolarMpptV1 { mppt_ina, .. } => { | ||||
|                 let _ = mppt_ina | ||||
|                     .set_configuration(Configuration { | ||||
|                         reset: Default::default(), | ||||
|                         bus_voltage_range: Default::default(), | ||||
|                         shunt_voltage_range: Default::default(), | ||||
|                         bus_resolution: Default::default(), | ||||
|                         shunt_resolution: Default::default(), | ||||
|                         operating_mode: OperatingMode::PowerDown, | ||||
|                     }) | ||||
|                     .map_err(|e| { | ||||
|                         log::info!( | ||||
|                     "Error setting ina mppt configuration during deep sleep preparation{:?}", | ||||
|                     e | ||||
|                 ); | ||||
|                     }); | ||||
|             } | ||||
|             _ => {} | ||||
|         } | ||||
|     } | ||||
|     fn set_charge_indicator(&mut self, charging: bool) -> FatResult<()> { | ||||
|         match self { | ||||
|             Self::SolarMpptV1 { | ||||
|                 charge_indicator, .. | ||||
|             } => { | ||||
|                 charge_indicator.set_level(charging.into()); | ||||
|             } | ||||
|             _ => {} | ||||
|         } | ||||
|         Ok(()) | ||||
|     } | ||||
|  | ||||
|     fn is_day(&self) -> bool { | ||||
|         match self { | ||||
|             Charger::SolarMpptV1 { solar_is_day, .. } => solar_is_day.is_high(), | ||||
|             _ => true, | ||||
|         } | ||||
|     } | ||||
| } | ||||
|  | ||||
| pub struct V4<'a> { | ||||
|     esp: Esp<'a>, | ||||
|     tank_sensor: TankSensor<'a>, | ||||
|     charger: Charger<'a>, | ||||
|     rtc_module: Box<dyn RTCModuleInteraction + Send>, | ||||
|     battery_monitor: Box<dyn BatteryInteraction + Send>, | ||||
|     config: PlantControllerConfig, | ||||
|  | ||||
|     awake: Output<'a>, | ||||
|     light: Output<'a>, | ||||
|     general_fault: Output<'a>, | ||||
|     pump_expander: Pca9535Immediate<I2cDevice<'a, CriticalSectionRawMutex, I2c<'static, Blocking>>>, | ||||
|     pump_ina: Option< | ||||
|         SyncIna219<I2cDevice<'a, CriticalSectionRawMutex, I2c<'static, Blocking>>, UnCalibrated>, | ||||
|     >, | ||||
|     sensor: SensorImpl, | ||||
|     extra1: Output<'a>, | ||||
|     extra2: Output<'a>, | ||||
| } | ||||
|  | ||||
| pub(crate) async fn create_v4( | ||||
|     peripherals: FreePeripherals<'static>, | ||||
|     esp: Esp<'static>, | ||||
|     config: PlantControllerConfig, | ||||
|     battery_monitor: Box<dyn BatteryInteraction + Send>, | ||||
|     rtc_module: Box<dyn RTCModuleInteraction + Send>, | ||||
| ) -> Result<Box<dyn BoardInteraction<'static> + Send + 'static>, FatError> { | ||||
|     log::info!("Start v4"); | ||||
|     let mut awake = Output::new(peripherals.gpio21, Level::High, OutputConfig::default()); | ||||
|     awake.set_high(); | ||||
|  | ||||
|     let mut general_fault = Output::new(peripherals.gpio23, Level::Low, OutputConfig::default()); | ||||
|     general_fault.set_low(); | ||||
|  | ||||
|     let extra1 = Output::new(peripherals.gpio6, Level::Low, OutputConfig::default()); | ||||
|     let extra2 = Output::new(peripherals.gpio15, Level::Low, OutputConfig::default()); | ||||
|  | ||||
|     let one_wire_pin = Flex::new(peripherals.gpio18); | ||||
|     let tank_power_pin = Output::new(peripherals.gpio11, Level::Low, OutputConfig::default()); | ||||
|     let flow_sensor_pin = Input::new( | ||||
|         peripherals.gpio4, | ||||
|         InputConfig::default().with_pull(Pull::Up), | ||||
|     ); | ||||
|  | ||||
|     let tank_sensor = TankSensor::create( | ||||
|         one_wire_pin, | ||||
|         peripherals.adc1, | ||||
|         peripherals.gpio5, | ||||
|         tank_power_pin, | ||||
|         flow_sensor_pin, | ||||
|         peripherals.pcnt1, | ||||
|     )?; | ||||
|  | ||||
|     let sensor_expander_device = I2cDevice::new(I2C_DRIVER.get().await); | ||||
|     let mut sensor_expander = Pca9535Immediate::new(sensor_expander_device, 34); | ||||
|     let sensor = match sensor_expander.pin_into_output(GPIOBank::Bank0, 0) { | ||||
|         Ok(_) => { | ||||
|             log::info!("SensorExpander answered"); | ||||
|  | ||||
|             let signal_counter = peripherals.pcnt0; | ||||
|  | ||||
|             signal_counter.set_high_limit(Some(i16::MAX))?; | ||||
|  | ||||
|             let ch0 = &signal_counter.channel0; | ||||
|             let edge_pin = Input::new(peripherals.gpio22, InputConfig::default()); | ||||
|             ch0.set_edge_signal(edge_pin.peripheral_input()); | ||||
|             ch0.set_input_mode(Hold, Increment); | ||||
|             ch0.set_ctrl_mode(Keep, Keep); | ||||
|             signal_counter.listen(); | ||||
|  | ||||
|             for pin in 0..8 { | ||||
|                 let _ = sensor_expander.pin_into_output(GPIOBank::Bank0, pin); | ||||
|                 let _ = sensor_expander.pin_into_output(GPIOBank::Bank1, pin); | ||||
|                 let _ = sensor_expander.pin_set_low(GPIOBank::Bank0, pin); | ||||
|                 let _ = sensor_expander.pin_set_low(GPIOBank::Bank1, pin); | ||||
|             } | ||||
|  | ||||
|             SensorImpl::PulseCounter { | ||||
|                 signal_counter, | ||||
|                 sensor_expander, | ||||
|             } | ||||
|         } | ||||
|         Err(_) => { | ||||
|             log::info!("Can bus mode "); | ||||
|             let twai_config = Some(twai::TwaiConfiguration::new( | ||||
|                 peripherals.twai, | ||||
|                 peripherals.gpio2, | ||||
|                 peripherals.gpio0, | ||||
|                 TWAI_BAUDRATE, | ||||
|                 TwaiMode::Normal, | ||||
|             )); | ||||
|             let can_power = Output::new(peripherals.gpio22, Level::Low, OutputConfig::default()); | ||||
|  | ||||
|             //can bus version | ||||
|             SensorImpl::CanBus { | ||||
|                 twai_config, | ||||
|                 can_power, | ||||
|             } | ||||
|         } | ||||
|     }; | ||||
|  | ||||
|     let solar_is_day = Input::new(peripherals.gpio7, InputConfig::default()); | ||||
|     let light = Output::new(peripherals.gpio10, Level::Low, Default::default()); | ||||
|     let charge_indicator = Output::new(peripherals.gpio3, Level::Low, Default::default()); | ||||
|  | ||||
|     let pump_device = I2cDevice::new(I2C_DRIVER.get().await); | ||||
|     let mut pump_expander = Pca9535Immediate::new(pump_device, 32); | ||||
|     for pin in 0..8 { | ||||
|         let _ = pump_expander.pin_into_output(GPIOBank::Bank0, pin); | ||||
|         let _ = pump_expander.pin_into_output(GPIOBank::Bank1, pin); | ||||
|         let _ = pump_expander.pin_set_low(GPIOBank::Bank0, pin); | ||||
|         let _ = pump_expander.pin_set_low(GPIOBank::Bank1, pin); | ||||
|     } | ||||
|  | ||||
|     let mppt_current = I2cDevice::new(I2C_DRIVER.get().await); | ||||
|     let mppt_ina = match SyncIna219::new(mppt_current, Address::from_pins(Pin::Vcc, Pin::Gnd)) { | ||||
|         Ok(mut ina) => { | ||||
|             // Prefer higher averaging for more stable readings | ||||
|             let _ = ina.set_configuration(Configuration { | ||||
|                 reset: Default::default(), | ||||
|                 bus_voltage_range: Default::default(), | ||||
|                 shunt_voltage_range: Default::default(), | ||||
|                 bus_resolution: Default::default(), | ||||
|                 shunt_resolution: Resolution::Avg128, | ||||
|                 operating_mode: Default::default(), | ||||
|             }); | ||||
|             Some(ina) | ||||
|         } | ||||
|         Err(err) => { | ||||
|             log::info!("Error creating mppt ina: {:?}", err); | ||||
|             None | ||||
|         } | ||||
|     }; | ||||
|  | ||||
|     let pump_current_dev = I2cDevice::new(I2C_DRIVER.get().await); | ||||
|     let pump_ina = match SyncIna219::new(pump_current_dev, Address::from_pins(Pin::Gnd, Pin::Sda)) { | ||||
|         Ok(ina) => Some(ina), | ||||
|         Err(err) => { | ||||
|             log::info!("Error creating pump ina: {:?}", err); | ||||
|             None | ||||
|         } | ||||
|     }; | ||||
|  | ||||
|     let charger = match mppt_ina { | ||||
|         Some(mut mppt_ina) => { | ||||
|             mppt_ina.set_configuration(Configuration { | ||||
|                 reset: Default::default(), | ||||
|                 bus_voltage_range: Default::default(), | ||||
|                 shunt_voltage_range: Default::default(), | ||||
|                 bus_resolution: Default::default(), | ||||
|                 shunt_resolution: ina219::configuration::Resolution::Avg128, | ||||
|                 operating_mode: Default::default(), | ||||
|             })?; | ||||
|  | ||||
|             Charger::SolarMpptV1 { | ||||
|                 mppt_ina, | ||||
|                 solar_is_day, | ||||
|                 charge_indicator, | ||||
|             } | ||||
|         } | ||||
|         None => Charger::ErrorInit {}, | ||||
|     }; | ||||
|  | ||||
|     let v = V4 { | ||||
|         rtc_module, | ||||
|         esp, | ||||
|         awake, | ||||
|         tank_sensor, | ||||
|         light, | ||||
|         general_fault, | ||||
|         pump_expander, | ||||
|         config, | ||||
|         battery_monitor, | ||||
|         pump_ina, | ||||
|         charger, | ||||
|         extra1, | ||||
|         extra2, | ||||
|         sensor, | ||||
|     }; | ||||
|     Ok(Box::new(v)) | ||||
| } | ||||
|  | ||||
| #[async_trait(?Send)] | ||||
| impl<'a> BoardInteraction<'a> for V4<'a> { | ||||
|     fn get_tank_sensor(&mut self) -> Result<&mut TankSensor<'a>, FatError> { | ||||
|         Ok(&mut self.tank_sensor) | ||||
|     } | ||||
|  | ||||
|     fn get_esp(&mut self) -> &mut Esp<'a> { | ||||
|         &mut self.esp | ||||
|     } | ||||
|  | ||||
|     fn get_config(&mut self) -> &PlantControllerConfig { | ||||
|         &self.config | ||||
|     } | ||||
|  | ||||
|     fn get_battery_monitor(&mut self) -> &mut Box<dyn BatteryInteraction + Send> { | ||||
|         &mut self.battery_monitor | ||||
|     } | ||||
|  | ||||
|     fn get_rtc_module(&mut self) -> &mut Box<dyn RTCModuleInteraction + Send> { | ||||
|         &mut self.rtc_module | ||||
|     } | ||||
|  | ||||
|     async fn set_charge_indicator(&mut self, charging: bool) -> Result<(), FatError> { | ||||
|         self.charger.set_charge_indicator(charging) | ||||
|     } | ||||
|  | ||||
|     async fn deep_sleep(&mut self, duration_in_ms: u64) -> ! { | ||||
|         self.awake.set_low(); | ||||
|         self.charger.power_save(); | ||||
|         let rtc = TIME_ACCESS.get().await.lock().await; | ||||
|         self.esp.deep_sleep(duration_in_ms, rtc); | ||||
|     } | ||||
|  | ||||
|     fn is_day(&self) -> bool { | ||||
|         self.charger.is_day() | ||||
|     } | ||||
|  | ||||
|     async fn light(&mut self, enable: bool) -> Result<(), FatError> { | ||||
|         hold_disable(10); | ||||
|         self.light.set_level(enable.into()); | ||||
|         hold_enable(10); | ||||
|         Ok(()) | ||||
|     } | ||||
|  | ||||
|     async fn pump(&mut self, plant: usize, enable: bool) -> FatResult<()> { | ||||
|         if enable { | ||||
|             self.pump_expander | ||||
|                 .pin_set_high(GPIOBank::Bank0, plant as u8)?; | ||||
|         } else { | ||||
|             self.pump_expander | ||||
|                 .pin_set_low(GPIOBank::Bank0, plant as u8)?; | ||||
|         } | ||||
|         Ok(()) | ||||
|     } | ||||
|  | ||||
|     async fn pump_current(&mut self, _plant: usize) -> Result<Current, FatError> { | ||||
|         // sensor is shared for all pumps, ignore plant id | ||||
|         match self.pump_ina.as_mut() { | ||||
|             None => { | ||||
|                 bail!("pump current sensor not available"); | ||||
|             } | ||||
|             Some(pump_ina) => { | ||||
|                 let v = pump_ina | ||||
|                     .shunt_voltage() | ||||
|                     .map_err(|e| FatError::String { | ||||
|                         error: alloc::format!("{:?}", e), | ||||
|                     }) | ||||
|                     .map(|v| { | ||||
|                         let shunt_voltage = | ||||
|                             Voltage::from_microvolts(v.shunt_voltage_uv().abs() as f64); | ||||
|                         let shut_value = Resistance::from_ohms(0.05_f64); | ||||
|                         let current = shunt_voltage.as_volts() / shut_value.as_ohms(); | ||||
|                         Current::from_amperes(current) | ||||
|                     })?; | ||||
|                 Ok(v) | ||||
|             } | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     async fn fault(&mut self, plant: usize, enable: bool) -> FatResult<()> { | ||||
|         if enable { | ||||
|             self.pump_expander | ||||
|                 .pin_set_high(GPIOBank::Bank1, plant as u8)?; | ||||
|         } else { | ||||
|             self.pump_expander | ||||
|                 .pin_set_low(GPIOBank::Bank1, plant as u8)?; | ||||
|         } | ||||
|         Ok(()) | ||||
|     } | ||||
|  | ||||
|     async fn measure_moisture_hz(&mut self) -> Result<Moistures, FatError> { | ||||
|         self.sensor.measure_moisture_hz().await | ||||
|     } | ||||
|  | ||||
|     async fn general_fault(&mut self, enable: bool) { | ||||
|         hold_disable(23); | ||||
|         self.general_fault.set_level(enable.into()); | ||||
|         hold_enable(23); | ||||
|     } | ||||
|  | ||||
|     async fn test(&mut self) -> Result<(), FatError> { | ||||
|         self.general_fault(true).await; | ||||
|         Timer::after_millis(100).await; | ||||
|         self.general_fault(false).await; | ||||
|         Timer::after_millis(500).await; | ||||
|         self.extra1.set_high(); | ||||
|         Timer::after_millis(500).await; | ||||
|         self.extra1.set_low(); | ||||
|         Timer::after_millis(500).await; | ||||
|         self.extra2.set_high(); | ||||
|         Timer::after_millis(500).await; | ||||
|         self.extra2.set_low(); | ||||
|         Timer::after_millis(500).await; | ||||
|         self.light(true).await?; | ||||
|         Timer::after_millis(500).await; | ||||
|         self.light(false).await?; | ||||
|         Timer::after_millis(500).await; | ||||
|         for i in 0..PLANT_COUNT { | ||||
|             self.fault(i, true).await?; | ||||
|             Timer::after_millis(500).await; | ||||
|             self.fault(i, false).await?; | ||||
|             Timer::after_millis(500).await; | ||||
|         } | ||||
|         for i in 0..PLANT_COUNT { | ||||
|             self.pump(i, true).await?; | ||||
|             Timer::after_millis(100).await; | ||||
|             self.pump(i, false).await?; | ||||
|             Timer::after_millis(100).await; | ||||
|         } | ||||
|         let moisture = self.measure_moisture_hz().await?; | ||||
|         for plant in 0..PLANT_COUNT { | ||||
|             let a = moisture.sensor_a_hz[plant] as u32; | ||||
|             let b = moisture.sensor_b_hz[plant] as u32; | ||||
|             LOG_ACCESS | ||||
|                 .lock() | ||||
|                 .await | ||||
|                 .log(LogMessage::TestSensor, a, b, &plant.to_string(), "") | ||||
|                 .await; | ||||
|         } | ||||
|         Timer::after_millis(10).await; | ||||
|         Ok(()) | ||||
|     } | ||||
|  | ||||
|     fn set_config(&mut self, config: PlantControllerConfig) { | ||||
|         self.config = config; | ||||
|     } | ||||
|  | ||||
|     async fn get_mptt_voltage(&mut self) -> FatResult<Voltage> { | ||||
|         self.charger.get_mptt_voltage() | ||||
|     } | ||||
|  | ||||
|     async fn get_mptt_current(&mut self) -> FatResult<Current> { | ||||
|         self.charger.get_mppt_current() | ||||
|     } | ||||
|  | ||||
|     async fn detect_sensors(&mut self) -> FatResult<DetectionResult> { | ||||
|         self.sensor.autodetect().await | ||||
|     } | ||||
| } | ||||
| @@ -1,314 +0,0 @@ | ||||
| use canapi::id::{classify, plant_id, MessageKind, IDENTIFY_CMD_OFFSET}; | ||||
| use crate::bail; | ||||
| use crate::fat_error::{ContextExt, FatError, FatResult}; | ||||
| use canapi::{SensorSlot}; | ||||
| use crate::hal::{DetectionResult, Moistures, Sensor}; | ||||
| use crate::hal::Box; | ||||
| use crate::log::{LogMessage, LOG_ACCESS}; | ||||
| use alloc::format; | ||||
| use alloc::string::ToString; | ||||
| use async_trait::async_trait; | ||||
| use bincode::config; | ||||
| use embassy_embedded_hal::shared_bus::blocking::i2c::I2cDevice; | ||||
| use embassy_sync::blocking_mutex::raw::CriticalSectionRawMutex; | ||||
| use embassy_time::{Instant, Timer, WithTimeout}; | ||||
| use embedded_can::{Frame, Id}; | ||||
| use esp_hal::gpio::Output; | ||||
| use esp_hal::i2c::master::I2c; | ||||
| use esp_hal::pcnt::unit::Unit; | ||||
| use esp_hal::twai::{EspTwaiFrame, StandardId, Twai, TwaiConfiguration}; | ||||
| use esp_hal::{Blocking}; | ||||
| use log::{error, info, warn}; | ||||
| use pca9535::{GPIOBank, Pca9535Immediate, StandardExpanderInterface}; | ||||
|  | ||||
| const REPEAT_MOIST_MEASURE: usize = 10; | ||||
|  | ||||
|  | ||||
|  | ||||
| #[async_trait(?Send)] | ||||
| pub trait SensorInteraction { | ||||
|     async fn measure_moisture_hz(&mut self) -> FatResult<Moistures>; | ||||
| } | ||||
|  | ||||
| const MS0: u8 = 1_u8; | ||||
| const MS1: u8 = 0_u8; | ||||
| const MS2: u8 = 3_u8; | ||||
| const MS3: u8 = 4_u8; | ||||
| const MS4: u8 = 2_u8; | ||||
| const SENSOR_ON: u8 = 5_u8; | ||||
|  | ||||
| pub enum SensorImpl { | ||||
|     PulseCounter { | ||||
|         signal_counter: Unit<'static, 0>, | ||||
|         sensor_expander: | ||||
|             Pca9535Immediate<I2cDevice<'static, CriticalSectionRawMutex, I2c<'static, Blocking>>>, | ||||
|     }, | ||||
|     CanBus { | ||||
|         twai_config: Option<TwaiConfiguration<'static, Blocking>>, | ||||
|         can_power: Output<'static>, | ||||
|     }, | ||||
| } | ||||
|  | ||||
| #[async_trait(?Send)] | ||||
| impl SensorInteraction for SensorImpl { | ||||
|     async fn measure_moisture_hz(&mut self) -> FatResult<Moistures> { | ||||
|         match self { | ||||
|             SensorImpl::PulseCounter { | ||||
|                 signal_counter, | ||||
|                 sensor_expander, | ||||
|                 .. | ||||
|             } => { | ||||
|                 let mut result = Moistures::default(); | ||||
|                 for plant in 0..crate::hal::PLANT_COUNT{ | ||||
|                     result.sensor_a_hz[plant] =  Self::inner_pulse(plant, Sensor::A, signal_counter, sensor_expander).await?; | ||||
|                     info!("Sensor {} {:?}: {}", plant, Sensor::A, result.sensor_a_hz[plant]); | ||||
|                     result.sensor_b_hz[plant] =  Self::inner_pulse(plant, Sensor::B, signal_counter, sensor_expander).await?; | ||||
|                     info!("Sensor {} {:?}: {}", plant, Sensor::B, result.sensor_b_hz[plant]); | ||||
|                 } | ||||
|                 Ok(result) | ||||
|             } | ||||
|  | ||||
|             SensorImpl::CanBus { | ||||
|                 twai_config, | ||||
|                 can_power, | ||||
|             } => { | ||||
|                 can_power.set_high(); | ||||
|                 let config = twai_config.take().expect("twai config not set"); | ||||
|                 let mut twai = config.start(); | ||||
|  | ||||
|                 loop { | ||||
|                     let rec = twai.receive(); | ||||
|                     match rec { | ||||
|                         Ok(_) => {} | ||||
|                         Err(err) => { | ||||
|                             info!("Error receiving CAN message: {:?}", err); | ||||
|                             break; | ||||
|                         } | ||||
|                     } | ||||
|                 } | ||||
|  | ||||
|                 Timer::after_millis(10).await; | ||||
|                 let can = Self::inner_can(&mut twai).await; | ||||
|  | ||||
|                 can_power.set_low(); | ||||
|  | ||||
|                 let config = twai.stop(); | ||||
|                 twai_config.replace(config); | ||||
|  | ||||
|                 let value = can?; | ||||
|                 Ok(value) | ||||
|             } | ||||
|         } | ||||
|     } | ||||
| } | ||||
|  | ||||
|  | ||||
|  | ||||
| impl SensorImpl { | ||||
|     pub async fn autodetect(&mut self) -> FatResult<DetectionResult> { | ||||
|         match self { | ||||
|             SensorImpl::PulseCounter { .. } => { | ||||
|                 bail!("Only CAN bus implementation supports autodetection") | ||||
|             } | ||||
|             SensorImpl::CanBus { | ||||
|                 twai_config, | ||||
|                 can_power, | ||||
|             } => { | ||||
|                 // Power on CAN transceiver and start controller | ||||
|                 can_power.set_high(); | ||||
|                 let config = twai_config.take().expect("twai config not set"); | ||||
|                 let mut as_async = config.into_async().start(); | ||||
|                 // Give CAN some time to stabilize | ||||
|                 Timer::after_millis(10).await; | ||||
|  | ||||
|                 // Send a few test messages per potential sensor node | ||||
|                 for plant in 0..crate::hal::PLANT_COUNT { | ||||
|                     for sensor in [Sensor::A, Sensor::B] { | ||||
|                         let target = StandardId::new(plant_id(IDENTIFY_CMD_OFFSET, sensor.into(),  plant as u16)).context(">> Could not create address for sensor! (plant: {}) <<")?; | ||||
|                         let can_buffer = [0_u8; 0]; | ||||
|                         if let Some(frame) = EspTwaiFrame::new(target, &can_buffer) { | ||||
|                             // Try a few times; we intentionally ignore rx here and rely on stub logic | ||||
|                             let resu = as_async.transmit_async(&frame).await; | ||||
|                             match resu { | ||||
|                                 Ok(_) => { | ||||
|                                     info!( | ||||
|                                         "Sent test message to plant {} sensor {:?}", | ||||
|                                         plant, sensor | ||||
|                                     ); | ||||
|                                 } | ||||
|                                 Err(err) => { | ||||
|                                     info!("Error sending test message to plant {} sensor {:?}: {:?}", plant, sensor, err); | ||||
|                                 } | ||||
|                             } | ||||
|                         } else { | ||||
|                             info!("Error building CAN frame"); | ||||
|                         } | ||||
|  | ||||
|                     } | ||||
|                 } | ||||
|  | ||||
|                 let mut result = DetectionResult::default(); | ||||
|                 loop { | ||||
|                     match as_async.receive_async().with_deadline(Instant::from_millis(100)).await { | ||||
|                         Ok(or) => { | ||||
|                             match or { | ||||
|                                 Ok(can_frame) => { | ||||
|                                     match can_frame.id() { | ||||
|                                         Id::Standard(id) => { | ||||
|                                             let rawid = id.as_raw(); | ||||
|                                             match classify(rawid) { | ||||
|                                                 None => {} | ||||
|                                                 Some(msg) => { | ||||
|                                                     if msg.0 == MessageKind::MoistureData { | ||||
|                                                         let plant = msg.1 as usize; | ||||
|                                                         let sensor = msg.2; | ||||
|                                                         match sensor { | ||||
|                                                             SensorSlot::A => { | ||||
|                                                                 result.plant[plant].sensor_a = true; | ||||
|                                                             } | ||||
|                                                             SensorSlot::B => { | ||||
|                                                                 result.plant[plant].sensor_b = true; | ||||
|                                                             } | ||||
|                                                         } | ||||
|                                                     } | ||||
|                                                 } | ||||
|                                             } | ||||
|                                         } | ||||
|                                         Id::Extended(ext) => { | ||||
|                                             warn!("Received extended ID: {:?}", ext); | ||||
|                                         } | ||||
|                                     } | ||||
|  | ||||
|                                 } | ||||
|                                 Err(err ) => { | ||||
|                                     error!("Error receiving CAN message: {:?}", err); | ||||
|                                     break; | ||||
|                                 } | ||||
|                             } | ||||
|                             info!("Received CAN message: {:?}", or); | ||||
|  | ||||
|                         } | ||||
|                         Err(err) => { | ||||
|                             error!("Timeout receiving CAN message: {:?}", err); | ||||
|                             break; | ||||
|                         } | ||||
|                     } | ||||
|                 } | ||||
|  | ||||
|                 let config = as_async.stop().into_blocking(); | ||||
|                 can_power.set_low(); | ||||
|                 twai_config.replace(config); | ||||
|  | ||||
|                 info!("Autodetection result: {:?}", result); | ||||
|                 Ok(result) | ||||
|             } | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     pub async fn inner_pulse(plant: usize, sensor: Sensor, signal_counter: &mut Unit<'_, 0>, sensor_expander: &mut Pca9535Immediate<I2cDevice<'static, CriticalSectionRawMutex, I2c<'static, Blocking>>>) -> FatResult<f32> { | ||||
|  | ||||
|     let mut results = [0_f32; REPEAT_MOIST_MEASURE]; | ||||
|     for repeat in 0..REPEAT_MOIST_MEASURE { | ||||
|     signal_counter.pause(); | ||||
|     signal_counter.clear(); | ||||
|  | ||||
|     //Disable all | ||||
|     sensor_expander.pin_set_high(GPIOBank::Bank0, MS4)?; | ||||
|  | ||||
|     let sensor_channel = match sensor { | ||||
|     Sensor::A => plant as u32, | ||||
|     Sensor::B => (15 - plant) as u32, | ||||
|     }; | ||||
|  | ||||
|     let is_bit_set = |b: u8| -> bool { sensor_channel & (1 << b) != 0 }; | ||||
|     if is_bit_set(0) { | ||||
|     sensor_expander.pin_set_high(GPIOBank::Bank0, MS0)?; | ||||
|     } else { | ||||
|     sensor_expander.pin_set_low(GPIOBank::Bank0, MS0)?; | ||||
|     } | ||||
|     if is_bit_set(1) { | ||||
|     sensor_expander.pin_set_high(GPIOBank::Bank0, MS1)?; | ||||
|     } else { | ||||
|     sensor_expander.pin_set_low(GPIOBank::Bank0, MS1)?; | ||||
|     } | ||||
|     if is_bit_set(2) { | ||||
|     sensor_expander.pin_set_high(GPIOBank::Bank0, MS2)?; | ||||
|     } else { | ||||
|     sensor_expander.pin_set_low(GPIOBank::Bank0, MS2)?; | ||||
|     } | ||||
|     if is_bit_set(3) { | ||||
|     sensor_expander.pin_set_high(GPIOBank::Bank0, MS3)?; | ||||
|     } else { | ||||
|     sensor_expander.pin_set_low(GPIOBank::Bank0, MS3)?; | ||||
|     } | ||||
|  | ||||
|     sensor_expander.pin_set_low(GPIOBank::Bank0, MS4)?; | ||||
|     sensor_expander.pin_set_high(GPIOBank::Bank0, SENSOR_ON)?; | ||||
|  | ||||
|     let measurement = 100; // TODO what is this scaling factor? what is its purpose? | ||||
|     let factor = 1000f32 / measurement as f32; | ||||
|  | ||||
|     //give some time to stabilize | ||||
|     Timer::after_millis(10).await; | ||||
|     signal_counter.resume(); | ||||
|     Timer::after_millis(measurement).await; | ||||
|     signal_counter.pause(); | ||||
|     sensor_expander.pin_set_high(GPIOBank::Bank0, MS4)?; | ||||
|     sensor_expander.pin_set_low(GPIOBank::Bank0, SENSOR_ON)?; | ||||
|     sensor_expander.pin_set_low(GPIOBank::Bank0, MS0)?; | ||||
|     sensor_expander.pin_set_low(GPIOBank::Bank0, MS1)?; | ||||
|     sensor_expander.pin_set_low(GPIOBank::Bank0, MS2)?; | ||||
|     sensor_expander.pin_set_low(GPIOBank::Bank0, MS3)?; | ||||
|     Timer::after_millis(10).await; | ||||
|     let unscaled = 1337; //signal_counter.get_counter_value()? as i32; | ||||
|     let hz = unscaled as f32 * factor; | ||||
|     LOG_ACCESS | ||||
|     .lock() | ||||
|     .await | ||||
|     .log( | ||||
|     LogMessage::RawMeasure, | ||||
|     unscaled as u32, | ||||
|     hz as u32, | ||||
|     &plant.to_string(), | ||||
|     &format!("{sensor:?}"), | ||||
|     ) | ||||
|     .await; | ||||
|     results[repeat] = hz; | ||||
|     } | ||||
|     results.sort_by(|a, b| a.partial_cmp(b).unwrap()); // floats don't seem to implement total_ord | ||||
|  | ||||
|     let mid = results.len() / 2; | ||||
|     let median = results[mid]; | ||||
|     Ok(median) | ||||
| } | ||||
|  | ||||
|     async fn inner_can( | ||||
|         twai: &mut Twai<'static, Blocking>, | ||||
|     ) -> FatResult<Moistures> { | ||||
|         [0_u8; 8]; | ||||
|         config::standard(); | ||||
|  | ||||
|         let timeout = Instant::now() | ||||
|             .checked_add(embassy_time::Duration::from_millis(100)) | ||||
|             .context("Timeout")?; | ||||
|         loop { | ||||
|             let answer = twai.receive(); | ||||
|             match answer { | ||||
|                 Ok(answer) => { | ||||
|                     info!("Received CAN message: {:?}", answer); | ||||
|                 } | ||||
|                 Err(error) => match error { | ||||
|                     nb::Error::Other(error) => { | ||||
|                         return Err(FatError::CanBusError { error }); | ||||
|                     } | ||||
|                     nb::Error::WouldBlock => { | ||||
|                         if Instant::now() > timeout { | ||||
|                             bail!("Timeout waiting for CAN answer"); | ||||
|                         } | ||||
|                         Timer::after_millis(10).await; | ||||
|                     } | ||||
|                 }, | ||||
|             } | ||||
|         } | ||||
|     } | ||||
| } | ||||
| @@ -1,153 +0,0 @@ | ||||
| use crate::bail; | ||||
| use crate::fat_error::FatError; | ||||
| use crate::hal::{ADC1, TANK_MULTI_SAMPLE}; | ||||
| use embassy_time::Timer; | ||||
| use esp_hal::analog::adc::{Adc, AdcConfig, AdcPin, Attenuation}; | ||||
| use esp_hal::delay::Delay; | ||||
| use esp_hal::gpio::{Flex, Input, Output, OutputConfig, Pull}; | ||||
| use esp_hal::pcnt::channel::CtrlMode::Keep; | ||||
| use esp_hal::pcnt::channel::EdgeMode::{Hold, Increment}; | ||||
| use esp_hal::pcnt::unit::Unit; | ||||
| use esp_hal::peripherals::GPIO5; | ||||
| use esp_hal::Blocking; | ||||
| use esp_println::println; | ||||
| use onewire::{ds18b20, Device, DeviceSearch, OneWire, DS18B20}; | ||||
|  | ||||
| pub struct TankSensor<'a> { | ||||
|     one_wire_bus: OneWire<Flex<'a>>, | ||||
|     tank_channel: Adc<'a, ADC1<'a>, Blocking>, | ||||
|     tank_power: Output<'a>, | ||||
|     tank_pin: AdcPin<GPIO5<'a>, ADC1<'a>>, | ||||
|     flow_counter: Unit<'a, 1>, | ||||
| } | ||||
|  | ||||
| impl<'a> TankSensor<'a> { | ||||
|     pub(crate) fn create( | ||||
|         mut one_wire_pin: Flex<'a>, | ||||
|         adc1: ADC1<'a>, | ||||
|         gpio5: GPIO5<'a>, | ||||
|         tank_power: Output<'a>, | ||||
|         flow_sensor: Input, | ||||
|         pcnt1: Unit<'a, 1>, | ||||
|     ) -> Result<TankSensor<'a>, FatError> { | ||||
|         one_wire_pin.apply_output_config(&OutputConfig::default().with_pull(Pull::None)); | ||||
|  | ||||
|         let mut adc1_config = AdcConfig::new(); | ||||
|         let tank_pin = adc1_config.enable_pin(gpio5, Attenuation::_11dB); | ||||
|         let tank_channel = Adc::new(adc1, adc1_config); | ||||
|  | ||||
|         let one_wire_bus = OneWire::new(one_wire_pin, false); | ||||
|  | ||||
|         pcnt1.set_high_limit(Some(i16::MAX))?; | ||||
|  | ||||
|         let ch0 = &pcnt1.channel0; | ||||
|         ch0.set_edge_signal(flow_sensor.peripheral_input()); | ||||
|         ch0.set_input_mode(Hold, Increment); | ||||
|         ch0.set_ctrl_mode(Keep, Keep); | ||||
|         pcnt1.listen(); | ||||
|  | ||||
|         Ok(TankSensor { | ||||
|             one_wire_bus, | ||||
|             tank_channel, | ||||
|             tank_power, | ||||
|             tank_pin, | ||||
|             flow_counter: pcnt1, | ||||
|         }) | ||||
|     } | ||||
|  | ||||
|     pub fn reset_flow_meter(&mut self) { | ||||
|         self.flow_counter.pause(); | ||||
|         self.flow_counter.clear(); | ||||
|     } | ||||
|  | ||||
|     pub fn start_flow_meter(&mut self) { | ||||
|         self.flow_counter.resume(); | ||||
|     } | ||||
|  | ||||
|     pub fn get_flow_meter_value(&mut self) -> i16 { | ||||
|         self.flow_counter.value() | ||||
|     } | ||||
|  | ||||
|     pub fn stop_flow_meter(&mut self) -> i16 { | ||||
|         self.flow_counter.pause(); | ||||
|         self.get_flow_meter_value() | ||||
|     } | ||||
|  | ||||
|     pub async fn water_temperature_c(&mut self) -> Result<f32, FatError> { | ||||
|         //multisample should be moved to water_temperature_c | ||||
|         let mut attempt = 1; | ||||
|         let mut delay = Delay::new(); | ||||
|         self.one_wire_bus.reset(&mut delay)?; | ||||
|         let mut search = DeviceSearch::new(); | ||||
|         let mut water_temp_sensor: Option<Device> = None; | ||||
|         while let Some(device) = self.one_wire_bus.search_next(&mut search, &mut delay)? { | ||||
|             if device.address[0] == ds18b20::FAMILY_CODE { | ||||
|                 water_temp_sensor = Some(device); | ||||
|                 break; | ||||
|             } | ||||
|         } | ||||
|         match water_temp_sensor { | ||||
|             Some(device) => { | ||||
|                 println!("Found one wire device: {:?}", device); | ||||
|                 let mut water_temp_sensor = DS18B20::new(device)?; | ||||
|  | ||||
|                 let water_temp: Result<f32, FatError> = loop { | ||||
|                     let temp = self | ||||
|                         .single_temperature_c(&mut water_temp_sensor, &mut delay) | ||||
|                         .await; | ||||
|                     match &temp { | ||||
|                         Ok(res) => { | ||||
|                             println!("Water temp is {}", res); | ||||
|                             break temp; | ||||
|                         } | ||||
|                         Err(err) => { | ||||
|                             println!("Could not get water temp {} attempt {}", err, attempt) | ||||
|                         } | ||||
|                     } | ||||
|                     if attempt == 5 { | ||||
|                         break temp; | ||||
|                     } | ||||
|                     attempt += 1; | ||||
|                 }; | ||||
|                 water_temp | ||||
|             } | ||||
|             None => { | ||||
|                 bail!("Not found any one wire Ds18b20"); | ||||
|             } | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     async fn single_temperature_c( | ||||
|         &mut self, | ||||
|         sensor: &mut DS18B20, | ||||
|         delay: &mut Delay, | ||||
|     ) -> Result<f32, FatError> { | ||||
|         let resolution = sensor.measure_temperature(&mut self.one_wire_bus, delay)?; | ||||
|         Timer::after_millis(resolution.time_ms() as u64).await; | ||||
|         let temperature = sensor.read_temperature(&mut self.one_wire_bus, delay)? as f32; | ||||
|         if temperature == 85_f32 { | ||||
|             bail!("Ds18b20 dummy temperature returned"); | ||||
|         } | ||||
|         Ok(temperature / 10_f32) | ||||
|     } | ||||
|  | ||||
|     pub async fn tank_sensor_voltage(&mut self) -> Result<f32, FatError> { | ||||
|         self.tank_power.set_high(); | ||||
|         //let stabilize | ||||
|         Timer::after_millis(100).await; | ||||
|  | ||||
|         let mut store = [0_u16; TANK_MULTI_SAMPLE]; | ||||
|         for multisample in 0..TANK_MULTI_SAMPLE { | ||||
|             let value = self.tank_channel.read_oneshot(&mut self.tank_pin); | ||||
|             //force yield | ||||
|             Timer::after_millis(10).await; | ||||
|             store[multisample] = value.unwrap(); | ||||
|         } | ||||
|         self.tank_power.set_low(); | ||||
|  | ||||
|         store.sort(); | ||||
|         //TODO probably wrong? check! | ||||
|         let median_mv = store[6] as f32 * 3300_f32 / 4096_f32; | ||||
|         Ok(median_mv) | ||||
|     } | ||||
| } | ||||
| @@ -1,284 +0,0 @@ | ||||
| use crate::hal::TIME_ACCESS; | ||||
| use crate::vec; | ||||
| use alloc::string::ToString; | ||||
| use alloc::vec::Vec; | ||||
| use bytemuck::{AnyBitPattern, Pod, Zeroable}; | ||||
| use deranged::RangedU8; | ||||
| use embassy_sync::blocking_mutex::raw::CriticalSectionRawMutex; | ||||
| use embassy_sync::mutex::Mutex; | ||||
| use esp_hal::Persistable; | ||||
| use log::info; | ||||
| use serde::Serialize; | ||||
| use strum_macros::IntoStaticStr; | ||||
| use unit_enum::UnitEnum; | ||||
|  | ||||
| const LOG_ARRAY_SIZE: u8 = 220; | ||||
| const MAX_LOG_ARRAY_INDEX: u8 = LOG_ARRAY_SIZE - 1; | ||||
| #[esp_hal::ram(rtc_fast, persistent)] | ||||
| static mut LOG_ARRAY: LogArray = LogArray { | ||||
|     buffer: [LogEntryInner { | ||||
|         timestamp: 0, | ||||
|         message_id: 0, | ||||
|         a: 0, | ||||
|         b: 0, | ||||
|         txt_short: [0; TXT_SHORT_LENGTH], | ||||
|         txt_long: [0; TXT_LONG_LENGTH], | ||||
|     }; LOG_ARRAY_SIZE as usize], | ||||
|     head: 0, | ||||
| }; | ||||
| pub static LOG_ACCESS: Mutex<CriticalSectionRawMutex, &'static mut LogArray> = | ||||
|     unsafe { Mutex::new(&mut *&raw mut LOG_ARRAY) }; | ||||
|  | ||||
| const TXT_SHORT_LENGTH: usize = 8; | ||||
| const TXT_LONG_LENGTH: usize = 32; | ||||
|  | ||||
| #[derive(Debug, Clone, Copy, AnyBitPattern)] | ||||
| #[repr(C)] | ||||
| pub struct LogArray { | ||||
|     buffer: [LogEntryInner; LOG_ARRAY_SIZE as usize], | ||||
|     head: u8, | ||||
| } | ||||
|  | ||||
| unsafe impl Persistable for LogArray {} | ||||
| unsafe impl Zeroable for LogEntryInner {} | ||||
|  | ||||
| unsafe impl Pod for LogEntryInner {} | ||||
|  | ||||
| #[derive(Debug, Clone, Copy)] | ||||
| struct LogEntryInner { | ||||
|     pub timestamp: u64, | ||||
|     pub message_id: u16, | ||||
|     pub a: u32, | ||||
|     pub b: u32, | ||||
|     pub txt_short: [u8; TXT_SHORT_LENGTH], | ||||
|     pub txt_long: [u8; TXT_LONG_LENGTH], | ||||
| } | ||||
|  | ||||
| #[derive(Serialize)] | ||||
| pub struct LogEntry { | ||||
|     pub timestamp: u64, | ||||
|     pub message_id: u16, | ||||
|     pub a: u32, | ||||
|     pub b: u32, | ||||
|     pub txt_short: alloc::string::String, | ||||
|     pub txt_long: alloc::string::String, | ||||
| } | ||||
|  | ||||
| impl From<LogEntryInner> for LogEntry { | ||||
|     fn from(value: LogEntryInner) -> Self { | ||||
|         LogEntry { | ||||
|             timestamp: value.timestamp, | ||||
|             message_id: value.message_id, | ||||
|             a: value.a, | ||||
|             b: value.b, | ||||
|             txt_short: alloc::string::String::from_utf8_lossy_owned(value.txt_short.to_vec()), | ||||
|             txt_long: alloc::string::String::from_utf8_lossy_owned(value.txt_long.to_vec()), | ||||
|         } | ||||
|     } | ||||
| } | ||||
|  | ||||
| pub async fn log( | ||||
|     message_key: LogMessage, | ||||
|     number_a: u32, | ||||
|     number_b: u32, | ||||
|     txt_short: &str, | ||||
|     txt_long: &str, | ||||
| ) { | ||||
|     LOG_ACCESS | ||||
|         .lock() | ||||
|         .await | ||||
|         .log(message_key, number_a, number_b, txt_short, txt_long) | ||||
|         .await | ||||
| } | ||||
|  | ||||
| impl LogArray { | ||||
|     pub fn get(&mut self) -> Vec<LogEntry> { | ||||
|         let head: RangedU8<0, MAX_LOG_ARRAY_INDEX> = | ||||
|             RangedU8::new(self.head).unwrap_or(RangedU8::new(0).unwrap()); | ||||
|  | ||||
|         let mut rv: Vec<LogEntry> = Vec::new(); | ||||
|         let mut index = head.wrapping_sub(1); | ||||
|         for _ in 0..self.buffer.len() { | ||||
|             let entry = self.buffer[index.get() as usize]; | ||||
|             if (entry.message_id as usize) != LogMessage::Empty.ordinal() { | ||||
|                 rv.push(entry.into()); | ||||
|             } | ||||
|             index = index.wrapping_sub(1); | ||||
|         } | ||||
|         rv | ||||
|     } | ||||
|  | ||||
|     pub async fn log( | ||||
|         &mut self, | ||||
|         message_key: LogMessage, | ||||
|         number_a: u32, | ||||
|         number_b: u32, | ||||
|         txt_short: &str, | ||||
|         txt_long: &str, | ||||
|     ) { | ||||
|         let mut head: RangedU8<0, MAX_LOG_ARRAY_INDEX> = | ||||
|             RangedU8::new(self.head).unwrap_or(RangedU8::new(0).unwrap()); | ||||
|  | ||||
|         let mut txt_short_stack: heapless::String<TXT_SHORT_LENGTH> = heapless::String::new(); | ||||
|         let mut txt_long_stack: heapless::String<TXT_LONG_LENGTH> = heapless::String::new(); | ||||
|  | ||||
|         limit_length(txt_short, &mut txt_short_stack); | ||||
|         limit_length(txt_long, &mut txt_long_stack); | ||||
|  | ||||
|         let time = { | ||||
|             let guard = TIME_ACCESS.get().await.lock().await; | ||||
|             guard.current_time_us() | ||||
|         } / 1000; | ||||
|  | ||||
|         let ordinal = message_key.ordinal() as u16; | ||||
|         let template: &str = message_key.into(); | ||||
|         let mut template_string = template.to_string(); | ||||
|         template_string = template_string.replace("${number_a}", number_a.to_string().as_str()); | ||||
|         template_string = template_string.replace("${number_b}", number_b.to_string().as_str()); | ||||
|         template_string = template_string.replace("${txt_long}", txt_long); | ||||
|         template_string = template_string.replace("${txt_short}", txt_short); | ||||
|  | ||||
|         info!("{}", template_string); | ||||
|  | ||||
|         let to_modify = &mut self.buffer[head.get() as usize]; | ||||
|         to_modify.timestamp = time; | ||||
|         to_modify.message_id = ordinal; | ||||
|         to_modify.a = number_a; | ||||
|         to_modify.b = number_b; | ||||
|         to_modify | ||||
|             .txt_short | ||||
|             .clone_from_slice(&txt_short_stack.as_bytes()); | ||||
|         to_modify | ||||
|             .txt_long | ||||
|             .clone_from_slice(&txt_long_stack.as_bytes()); | ||||
|         head = head.wrapping_add(1); | ||||
|         self.head = head.get(); | ||||
|     } | ||||
| } | ||||
|  | ||||
| fn limit_length<const LIMIT: usize>(input: &str, target: &mut heapless::String<LIMIT>) { | ||||
|     for char in input.chars() { | ||||
|         match target.push(char) { | ||||
|             Ok(_) => {} //continue adding chars | ||||
|             Err(_) => { | ||||
|                 //clear space for two asci chars | ||||
|                 while target.len() + 2 >= LIMIT { | ||||
|                     target.pop().unwrap(); | ||||
|                 } | ||||
|                 //add .. to shortened strings | ||||
|                 target.push('.').unwrap(); | ||||
|                 target.push('.').unwrap(); | ||||
|                 return; | ||||
|             } | ||||
|         } | ||||
|     } | ||||
|     while target.len() < LIMIT { | ||||
|         target.push(' ').unwrap(); | ||||
|     } | ||||
| } | ||||
|  | ||||
| #[derive(IntoStaticStr, Serialize, PartialEq, Eq, PartialOrd, Ord, Clone, UnitEnum)] | ||||
| pub enum LogMessage { | ||||
|     #[strum(serialize = "")] | ||||
|     Empty, | ||||
|     #[strum( | ||||
|         serialize = "Reset due to ${txt_long} requires rtc clear ${number_a} and force config mode ${number_b}" | ||||
|     )] | ||||
|     ResetReason, | ||||
|     #[strum(serialize = "Current restart to conf mode ${number_a}")] | ||||
|     RestartToConfig, | ||||
|     #[strum(serialize = "Current low voltage detection is ${number_a}")] | ||||
|     LowVoltage, | ||||
|     #[strum(serialize = "Error communicating with battery!! ${txt_long}")] | ||||
|     BatteryCommunicationError, | ||||
|     #[strum(serialize = "Tank water level cricial! Refill tank!")] | ||||
|     TankWaterLevelLow, | ||||
|     #[strum(serialize = "Tank sensor hardware error: ${txt_long}")] | ||||
|     TankSensorBoardError, | ||||
|     #[strum(serialize = "Tank sensor not present, raw voltage measured = ${number_a} mV")] | ||||
|     TankSensorMissing, | ||||
|     #[strum( | ||||
|         serialize = "Tank sensor value out of range, min = ${number_a}%, max = ${number_b}%, value = ${text_short}%" | ||||
|     )] | ||||
|     TankSensorValueRangeError, | ||||
|     #[strum( | ||||
|         serialize = "raw measure unscaled ${number_a} hz ${number_b}, plant ${txt_short} sensor ${txt_long}" | ||||
|     )] | ||||
|     RawMeasure, | ||||
|     #[strum(serialize = "IP info: ${txt_long}")] | ||||
|     WifiInfo, | ||||
|     #[strum(serialize = "Plant:${txt_short} a:${number_a} b:${number_b}")] | ||||
|     TestSensor, | ||||
|     #[strum(serialize = "Stay alive topic is ${txt_long}")] | ||||
|     StayAlive, | ||||
|     #[strum(serialize = "Connecting mqtt ${txt_short} with id ${txt_long}")] | ||||
|     MqttInfo, | ||||
|     #[strum(serialize = "Received stay alive with value ${number_a}")] | ||||
|     MqttStayAliveRec, | ||||
|     #[strum(serialize = "Unknown topic recieved ${txt_long}")] | ||||
|     UnknownTopic, | ||||
|     #[strum(serialize = "Partition state is ${txt_long}")] | ||||
|     PartitionState, | ||||
|     #[strum(serialize = "Mounted Filesystem free ${number_a} total ${number_b} use ${txt_short}")] | ||||
|     FilesystemMount, | ||||
|     #[strum( | ||||
|         serialize = "Mounting Filesystem, this will format the first time and needs quite some time!" | ||||
|     )] | ||||
|     MountingFilesystem, | ||||
|     #[strum(serialize = "Year inplausible, force config mode")] | ||||
|     YearInplausibleForceConfig, | ||||
|     #[strum(serialize = "Going to config mode, due to request from prior run")] | ||||
|     ConfigModeSoftwareOverride, | ||||
|     #[strum(serialize = "Going to config mode, due to request via config mode button")] | ||||
|     ConfigModeButtonOverride, | ||||
|     #[strum(serialize = "Going to normal mode")] | ||||
|     NormalRun, | ||||
|     #[strum(serialize = "Missing normal config, entering config mode ${txt_long}")] | ||||
|     ConfigModeMissingConfig, | ||||
|     #[strum(serialize = "startup state wifi ${number_a} sntp ${number_b} mqtt ${txt_short}")] | ||||
|     StartupInfo, | ||||
|     #[strum( | ||||
|         serialize = "Trying to pump for ${number_b}s with pump ${number_a} now dryrun: ${txt_short}" | ||||
|     )] | ||||
|     PumpPlant, | ||||
|     #[strum(serialize = "Enable main power dryrun: ${number_a}")] | ||||
|     EnableMain, | ||||
|     #[strum( | ||||
|         serialize = "Pumped multiple times, but plant is still to try attempt: ${number_a} limit :: ${number_b} plant: ${txt_short}" | ||||
|     )] | ||||
|     ConsecutivePumpCountLimit, | ||||
|     #[strum( | ||||
|         serialize = "Pump Overcurrent error, pump: ${number_a} tripped overcurrent ${number_b} limit was ${txt_short} @s ${txt_long}" | ||||
|     )] | ||||
|     PumpOverCurrent, | ||||
|     #[strum( | ||||
|         serialize = "Pump Open loop error, pump: ${number_a} is low,  ${number_b} limit was ${txt_short} @s ${txt_long}" | ||||
|     )] | ||||
|     PumpOpenLoopCurrent, | ||||
|     #[strum(serialize = "Pump Open current sensor required but did not work: ${number_a}")] | ||||
|     PumpMissingSensorCurrent, | ||||
| } | ||||
|  | ||||
| #[derive(Serialize)] | ||||
| pub struct MessageTranslation { | ||||
|     msg_type: LogMessage, | ||||
|     message: &'static str, | ||||
| } | ||||
|  | ||||
| impl From<&LogMessage> for MessageTranslation { | ||||
|     fn from(value: &LogMessage) -> Self { | ||||
|         Self { | ||||
|             msg_type: value.clone(), | ||||
|             message: value.into(), | ||||
|         } | ||||
|     } | ||||
| } | ||||
|  | ||||
| impl LogMessage { | ||||
|     pub fn to_log_localisation_config() -> Vec<MessageTranslation> { | ||||
|         Vec::from_iter((0..LogMessage::len()).map(|i| { | ||||
|             let msg_type = LogMessage::from_ordinal(i).unwrap(); | ||||
|             (&msg_type).into() | ||||
|         })) | ||||
|     } | ||||
| } | ||||
							
								
								
									
										1116
									
								
								rust/src/main.rs
									
									
									
									
									
								
							
							
						
						
									
										1116
									
								
								rust/src/main.rs
									
									
									
									
									
								
							
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							| @@ -1,328 +0,0 @@ | ||||
| use crate::hal::Moistures; | ||||
| use crate::{ | ||||
|     config::PlantConfig, | ||||
|     hal::HAL, | ||||
|     in_time_range, | ||||
| }; | ||||
| use chrono::{DateTime, TimeDelta, Utc}; | ||||
| use chrono_tz::Tz; | ||||
| use serde::{Deserialize, Serialize}; | ||||
|  | ||||
| const MOIST_SENSOR_MAX_FREQUENCY: f32 = 7500.; // 60kHz (500Hz margin) | ||||
| const MOIST_SENSOR_MIN_FREQUENCY: f32 = 150.; // this is really, really dry, think like cactus levels | ||||
|  | ||||
| #[derive(Debug, PartialEq, Serialize)] | ||||
| pub enum MoistureSensorError { | ||||
|     ShortCircuit { hz: f32, max: f32 }, | ||||
|     OpenLoop { hz: f32, min: f32 }, | ||||
| } | ||||
|  | ||||
| #[derive(Debug, PartialEq, Serialize)] | ||||
| pub enum MoistureSensorState { | ||||
|     Disabled, | ||||
|     MoistureValue { raw_hz: f32, moisture_percent: f32 }, | ||||
|     SensorError(MoistureSensorError), | ||||
| } | ||||
|  | ||||
| impl MoistureSensorState { | ||||
|     pub fn is_err(&self) -> Option<&MoistureSensorError> { | ||||
|         match self { | ||||
|             MoistureSensorState::SensorError(moisture_sensor_error) => Some(moisture_sensor_error), | ||||
|             _ => None, | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     pub fn moisture_percent(&self) -> Option<f32> { | ||||
|         if let MoistureSensorState::MoistureValue { | ||||
|             raw_hz: _, | ||||
|             moisture_percent, | ||||
|         } = self | ||||
|         { | ||||
|             Some(*moisture_percent) | ||||
|         } else { | ||||
|             None | ||||
|         } | ||||
|     } | ||||
| } | ||||
|  | ||||
| impl MoistureSensorState {} | ||||
|  | ||||
| #[derive(Debug, PartialEq, Serialize)] | ||||
| pub enum PumpError { | ||||
|     PumpNotWorking { | ||||
|         failed_attempts: usize, | ||||
|         max_allowed_failures: usize, | ||||
|     }, | ||||
| } | ||||
|  | ||||
| #[derive(Debug, Serialize)] | ||||
| pub struct PumpState { | ||||
|     consecutive_pump_count: u32, | ||||
|     previous_pump: Option<DateTime<Utc>>, | ||||
| } | ||||
|  | ||||
| impl PumpState { | ||||
|     fn is_err(&self, plant_config: &PlantConfig) -> Option<PumpError> { | ||||
|         if self.consecutive_pump_count > plant_config.max_consecutive_pump_count as u32 { | ||||
|             Some(PumpError::PumpNotWorking { | ||||
|                 failed_attempts: self.consecutive_pump_count as usize, | ||||
|                 max_allowed_failures: plant_config.max_consecutive_pump_count as usize, | ||||
|             }) | ||||
|         } else { | ||||
|             None | ||||
|         } | ||||
|     } | ||||
| } | ||||
|  | ||||
| #[derive(Serialize, Deserialize, Clone, Copy, Debug, PartialEq)] | ||||
| pub enum PlantWateringMode { | ||||
|     OFF, | ||||
|     TargetMoisture, | ||||
|     MinMoisture, | ||||
|     TimerOnly, | ||||
| } | ||||
|  | ||||
| pub struct PlantState { | ||||
|     pub sensor_a: MoistureSensorState, | ||||
|     pub sensor_b: MoistureSensorState, | ||||
|     pub pump: PumpState, | ||||
| } | ||||
|  | ||||
| fn map_range_moisture( | ||||
|     s: f32, | ||||
|     min_frequency: Option<f32>, | ||||
|     max_frequency: Option<f32>, | ||||
| ) -> Result<f32, MoistureSensorError> { | ||||
|     // Use overrides if provided, otherwise fallback to defaults | ||||
|     let min_freq = min_frequency.unwrap_or(MOIST_SENSOR_MIN_FREQUENCY); | ||||
|     let max_freq = max_frequency.unwrap_or(MOIST_SENSOR_MAX_FREQUENCY); | ||||
|  | ||||
|     if s < min_freq { | ||||
|         return Err(MoistureSensorError::OpenLoop { | ||||
|             hz: s, | ||||
|             min: min_freq, | ||||
|         }); | ||||
|     } | ||||
|     if s > max_freq { | ||||
|         return Err(MoistureSensorError::ShortCircuit { | ||||
|             hz: s, | ||||
|             max: max_freq, | ||||
|         }); | ||||
|     } | ||||
|     let moisture_percent = (s - min_freq) * 100.0 / (max_freq - min_freq); | ||||
|  | ||||
|     Ok(moisture_percent) | ||||
| } | ||||
|  | ||||
| impl PlantState { | ||||
|     pub async fn read_hardware_state(moistures: Moistures, plant_id: usize, board: &mut HAL<'_>) -> Self { | ||||
|         let sensor_a = if board.board_hal.get_config().plants[plant_id].sensor_a { | ||||
|             let raw = moistures.sensor_a_hz[plant_id]; | ||||
|             match map_range_moisture( | ||||
|                 raw, | ||||
|                     board.board_hal.get_config().plants[plant_id].moisture_sensor_min_frequency, | ||||
|                     board.board_hal.get_config().plants[plant_id].moisture_sensor_max_frequency, | ||||
|                 ) { | ||||
|                     Ok(moisture_percent) => MoistureSensorState::MoistureValue { | ||||
|                         raw_hz: raw, | ||||
|                         moisture_percent, | ||||
|                     }, | ||||
|                     Err(err) => MoistureSensorState::SensorError(err), | ||||
|                 } | ||||
|         } else { | ||||
|             MoistureSensorState::Disabled | ||||
|         }; | ||||
|  | ||||
|         let sensor_b = if board.board_hal.get_config().plants[plant_id].sensor_b { | ||||
|             let raw = moistures.sensor_b_hz[plant_id]; | ||||
|             match map_range_moisture( | ||||
|                 raw, | ||||
|                 board.board_hal.get_config().plants[plant_id].moisture_sensor_min_frequency, | ||||
|                 board.board_hal.get_config().plants[plant_id].moisture_sensor_max_frequency, | ||||
|             ) { | ||||
|                 Ok(moisture_percent) => MoistureSensorState::MoistureValue { | ||||
|                     raw_hz: raw, | ||||
|                     moisture_percent, | ||||
|                 }, | ||||
|                 Err(err) => MoistureSensorState::SensorError(err), | ||||
|             } | ||||
|         } else { | ||||
|             MoistureSensorState::Disabled | ||||
|         }; | ||||
|  | ||||
|         let previous_pump = board.board_hal.get_esp().last_pump_time(plant_id); | ||||
|         let consecutive_pump_count = board.board_hal.get_esp().consecutive_pump_count(plant_id); | ||||
|         let state = Self { | ||||
|             sensor_a, | ||||
|             sensor_b, | ||||
|             pump: PumpState { | ||||
|                 consecutive_pump_count, | ||||
|                 previous_pump, | ||||
|             }, | ||||
|         }; | ||||
|         if state.is_err() { | ||||
|             let _ = board.board_hal.fault(plant_id, true); | ||||
|         } | ||||
|         state | ||||
|     } | ||||
|  | ||||
|     pub fn pump_in_timeout(&self, plant_conf: &PlantConfig, current_time: &DateTime<Tz>) -> bool { | ||||
|         if matches!(plant_conf.mode, PlantWateringMode::OFF) { | ||||
|             return false; | ||||
|         } | ||||
|         self.pump.previous_pump.is_some_and(|last_pump| { | ||||
|             last_pump | ||||
|                 .checked_add_signed(TimeDelta::minutes(plant_conf.pump_cooldown_min.into())) | ||||
|                 .is_some_and(|earliest_next_allowed_pump| { | ||||
|                     earliest_next_allowed_pump > *current_time | ||||
|                 }) | ||||
|         }) | ||||
|     } | ||||
|  | ||||
|     pub fn is_err(&self) -> bool { | ||||
|         self.sensor_a.is_err().is_some() || self.sensor_b.is_err().is_some() | ||||
|     } | ||||
|  | ||||
|     pub fn plant_moisture( | ||||
|         &self, | ||||
|     ) -> ( | ||||
|         Option<f32>, | ||||
|         (Option<&MoistureSensorError>, Option<&MoistureSensorError>), | ||||
|     ) { | ||||
|         match ( | ||||
|             self.sensor_a.moisture_percent(), | ||||
|             self.sensor_b.moisture_percent(), | ||||
|         ) { | ||||
|             (Some(moisture_a), Some(moisture_b)) => { | ||||
|                 (Some((moisture_a + moisture_b) / 2.), (None, None)) | ||||
|             } | ||||
|             (Some(moisture_percent), _) => (Some(moisture_percent), (None, self.sensor_b.is_err())), | ||||
|             (_, Some(moisture_percent)) => (Some(moisture_percent), (self.sensor_a.is_err(), None)), | ||||
|             _ => (None, (self.sensor_a.is_err(), self.sensor_b.is_err())), | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     pub fn needs_to_be_watered( | ||||
|         &self, | ||||
|         plant_conf: &PlantConfig, | ||||
|         current_time: &DateTime<Tz>, | ||||
|     ) -> bool { | ||||
|         match plant_conf.mode { | ||||
|             PlantWateringMode::OFF => false, | ||||
|             PlantWateringMode::TargetMoisture => { | ||||
|                 let (moisture_percent, _) = self.plant_moisture(); | ||||
|                 if let Some(moisture_percent) = moisture_percent { | ||||
|                     if self.pump_in_timeout(plant_conf, current_time) { | ||||
|                         false | ||||
|                     } else if moisture_percent < plant_conf.target_moisture { | ||||
|                         in_time_range( | ||||
|                             current_time, | ||||
|                             plant_conf.pump_hour_start, | ||||
|                             plant_conf.pump_hour_end, | ||||
|                         ) | ||||
|                     } else { | ||||
|                         false | ||||
|                     } | ||||
|                 } else { | ||||
|                     // in case no moisture can be determined, do not water the plant | ||||
|                     false | ||||
|                 } | ||||
|             } | ||||
|             PlantWateringMode::MinMoisture => { | ||||
|                 let (moisture_percent, _) = self.plant_moisture(); | ||||
|                 if let Some(_moisture_percent) = moisture_percent { | ||||
|                     if self.pump_in_timeout(plant_conf, current_time) { | ||||
|                         false | ||||
|                     } else if !in_time_range( | ||||
|                         current_time, | ||||
|                         plant_conf.pump_hour_start, | ||||
|                         plant_conf.pump_hour_end, | ||||
|                     ) { | ||||
|                         false | ||||
|                     } else if true { | ||||
|                         //if not cooldown min and below max | ||||
|                         true | ||||
|                     } else if true { | ||||
|                         //if below min disable cooldown min | ||||
|                         true | ||||
|                     } else { | ||||
|                         false | ||||
|                     } | ||||
|                 } else { | ||||
|                     false | ||||
|                 } | ||||
|             } | ||||
|             PlantWateringMode::TimerOnly => !self.pump_in_timeout(plant_conf, current_time), | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     pub fn to_mqtt_info( | ||||
|         &self, | ||||
|         plant_conf: &PlantConfig, | ||||
|         current_time: &DateTime<Tz>, | ||||
|     ) -> PlantInfo<'_> { | ||||
|         PlantInfo { | ||||
|             sensor_a: &self.sensor_a, | ||||
|             sensor_b: &self.sensor_b, | ||||
|             mode: plant_conf.mode, | ||||
|             do_water: self.needs_to_be_watered(plant_conf, current_time), | ||||
|             dry: if let Some(moisture_percent) = self.plant_moisture().0 { | ||||
|                 moisture_percent < plant_conf.target_moisture | ||||
|             } else { | ||||
|                 false | ||||
|             }, | ||||
|             cooldown: self.pump_in_timeout(plant_conf, current_time), | ||||
|             out_of_work_hour: in_time_range( | ||||
|                 current_time, | ||||
|                 plant_conf.pump_hour_start, | ||||
|                 plant_conf.pump_hour_end, | ||||
|             ), | ||||
|             consecutive_pump_count: self.pump.consecutive_pump_count, | ||||
|             pump_error: self.pump.is_err(plant_conf), | ||||
|             last_pump: self | ||||
|                 .pump | ||||
|                 .previous_pump | ||||
|                 .map(|t| t.with_timezone(¤t_time.timezone())), | ||||
|             next_pump: if matches!( | ||||
|                 plant_conf.mode, | ||||
|                 PlantWateringMode::TimerOnly | ||||
|                     | PlantWateringMode::TargetMoisture | ||||
|                     | PlantWateringMode::MinMoisture | ||||
|             ) { | ||||
|                 self.pump.previous_pump.and_then(|last_pump| { | ||||
|                     last_pump | ||||
|                         .checked_add_signed(TimeDelta::minutes(plant_conf.pump_cooldown_min.into())) | ||||
|                         .map(|t| t.with_timezone(¤t_time.timezone())) | ||||
|                 }) | ||||
|             } else { | ||||
|                 None | ||||
|             }, | ||||
|         } | ||||
|     } | ||||
| } | ||||
|  | ||||
| #[derive(Debug, PartialEq, Serialize)] | ||||
| /// State of a single plant to be tracked | ||||
| pub struct PlantInfo<'a> { | ||||
|     /// state of humidity sensor on bank a | ||||
|     sensor_a: &'a MoistureSensorState, | ||||
|     /// state of humidity sensor on bank b | ||||
|     sensor_b: &'a MoistureSensorState, | ||||
|     /// configured plant watering mode | ||||
|     mode: PlantWateringMode, | ||||
|     /// the plant needs to be watered | ||||
|     do_water: bool, | ||||
|     /// plant is considered to be dry according to settings | ||||
|     dry: bool, | ||||
|     /// plant irrigation cooldown is active | ||||
|     cooldown: bool, | ||||
|     /// plant should not be watered at this time of day TODO: does this really belong here? Isn't this a global setting? | ||||
|     out_of_work_hour: bool, | ||||
|     /// how often has the pump been watered without reaching target moisture | ||||
|     consecutive_pump_count: u32, | ||||
|     pump_error: Option<PumpError>, | ||||
|     /// last time when the pump was active | ||||
|     last_pump: Option<DateTime<Tz>>, | ||||
|     /// next time when pump should activate | ||||
|     next_pump: Option<DateTime<Tz>>, | ||||
| } | ||||
							
								
								
									
										194
									
								
								rust/src/tank.rs
									
									
									
									
									
								
							
							
						
						
									
										194
									
								
								rust/src/tank.rs
									
									
									
									
									
								
							| @@ -1,194 +0,0 @@ | ||||
| use crate::alloc::string::{String, ToString}; | ||||
| use crate::config::TankConfig; | ||||
| use crate::hal::HAL; | ||||
| use crate::fat_error::FatResult; | ||||
| use embassy_sync::blocking_mutex::raw::CriticalSectionRawMutex; | ||||
| use embassy_sync::mutex::MutexGuard; | ||||
| use serde::Serialize; | ||||
|  | ||||
| const OPEN_TANK_VOLTAGE: f32 = 3.0; | ||||
| pub const WATER_FROZEN_THRESH: f32 = 4.0; | ||||
|  | ||||
| #[derive(Debug, Clone, Serialize)] | ||||
| pub enum TankError { | ||||
|     SensorDisabled, | ||||
|     SensorMissing(f32), | ||||
|     SensorValueError { value: f32, min: f32, max: f32 }, | ||||
|     BoardError(String), | ||||
| } | ||||
|  | ||||
| pub enum TankState { | ||||
|     Present(f32), | ||||
|     Error(TankError), | ||||
|     Disabled, | ||||
| } | ||||
|  | ||||
| fn raw_voltage_to_divider_percent(raw_value_mv: f32) -> Result<f32, TankError> { | ||||
|     if raw_value_mv > OPEN_TANK_VOLTAGE { | ||||
|         return Err(TankError::SensorMissing(raw_value_mv)); | ||||
|     } | ||||
|  | ||||
|     let r2 = raw_value_mv * 50.0 / (3.3 - raw_value_mv); | ||||
|     let mut percent = r2 / 190_f32 * 100_f32; | ||||
|     percent = percent.clamp(0.0, 100.0); | ||||
|     Ok(percent) | ||||
| } | ||||
|  | ||||
| fn raw_voltage_to_tank_fill_percent( | ||||
|     raw_value_mv: f32, | ||||
|     config: &TankConfig, | ||||
| ) -> Result<f32, TankError> { | ||||
|     let divider_percent = raw_voltage_to_divider_percent(raw_value_mv)?; | ||||
|     if divider_percent < config.tank_empty_percent.into() | ||||
|         || divider_percent > config.tank_full_percent.into() | ||||
|     { | ||||
|         return Err(TankError::SensorValueError { | ||||
|             value: divider_percent, | ||||
|             min: config.tank_empty_percent.into(), | ||||
|             max: config.tank_full_percent.into(), | ||||
|         }); | ||||
|     } | ||||
|     Ok( | ||||
|         (divider_percent - f32::from(config.tank_empty_percent)) * 100. | ||||
|             / f32::from(config.tank_full_percent - config.tank_empty_percent), | ||||
|     ) | ||||
| } | ||||
|  | ||||
| impl TankState { | ||||
|     pub fn left_ml(&self, config: &TankConfig) -> Result<f32, TankError> { | ||||
|         match self { | ||||
|             TankState::Disabled => Err(TankError::SensorDisabled), | ||||
|             TankState::Error(err) => Err(err.clone()), | ||||
|             TankState::Present(raw_value_mv) => { | ||||
|                 let tank_fill_percent = raw_voltage_to_tank_fill_percent(*raw_value_mv, config)?; | ||||
|                 Ok(config.tank_useable_ml as f32 * tank_fill_percent / 100.) | ||||
|             } | ||||
|         } | ||||
|     } | ||||
|     pub fn enough_water(&self, config: &TankConfig) -> Result<bool, TankError> { | ||||
|         match self { | ||||
|             TankState::Disabled => Err(TankError::SensorDisabled), | ||||
|             TankState::Error(err) => Err(err.clone()), | ||||
|             TankState::Present(raw_value_mv) => { | ||||
|                 let tank_fill_percent = raw_voltage_to_tank_fill_percent(*raw_value_mv, config)?; | ||||
|                 if tank_fill_percent > config.tank_empty_percent.into() { | ||||
|                     Ok(true) | ||||
|                 } else { | ||||
|                     Ok(false) | ||||
|                 } | ||||
|             } | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     pub fn is_enabled(&self) -> bool { | ||||
|         !matches!(self, TankState::Disabled) | ||||
|     } | ||||
|  | ||||
|     pub fn warn_level(&self, config: &TankConfig) -> Result<bool, TankError> { | ||||
|         match self { | ||||
|             TankState::Disabled => Err(TankError::SensorDisabled), | ||||
|             TankState::Error(err) => Err(err.clone()), | ||||
|             TankState::Present(raw_value_mv) => { | ||||
|                 let tank_fill_percent = raw_voltage_to_tank_fill_percent(*raw_value_mv, config); | ||||
|                 match tank_fill_percent { | ||||
|                     Ok(value) => { | ||||
|                         if value < config.tank_warn_percent.into() { | ||||
|                             Ok(true) | ||||
|                         } else { | ||||
|                             Ok(false) | ||||
|                         } | ||||
|                     } | ||||
|                     Err(err) => match err { | ||||
|                         TankError::SensorValueError { value, min, max: _ } => Ok(value < min), | ||||
|                         _ => Err(err), | ||||
|                     }, | ||||
|                 } | ||||
|             } | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     pub fn got_error(&self, config: &TankConfig) -> Option<TankError> { | ||||
|         match self { | ||||
|             TankState::Present(raw_value_mv) => { | ||||
|                 raw_voltage_to_tank_fill_percent(*raw_value_mv, config).err() | ||||
|             } | ||||
|             TankState::Error(err) => Some(err.clone()), | ||||
|             TankState::Disabled => Some(TankError::SensorDisabled), | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     pub fn as_mqtt_info(&self, config: &TankConfig, water_temp: &FatResult<f32>) -> TankInfo { | ||||
|         let mut tank_err: Option<TankError> = None; | ||||
|         let left_ml = match self.left_ml(config) { | ||||
|             Err(err) => { | ||||
|                 tank_err = Some(err); | ||||
|                 None | ||||
|             } | ||||
|             Ok(left_ml) => Some(left_ml), | ||||
|         }; | ||||
|         let enough_water = self.enough_water(config).unwrap_or(false); //NOTE: is this correct if there is an error assume not enough water? | ||||
|         let warn_level = self.warn_level(config).unwrap_or(false); //NOTE: should warn level be triggered if there is an error? | ||||
|         let raw = match self { | ||||
|             TankState::Disabled | TankState::Error(_) => None, | ||||
|             TankState::Present(raw_value_mv) => Some(*raw_value_mv), | ||||
|         }; | ||||
|  | ||||
|         let percent = match raw { | ||||
|             Some(r) => raw_voltage_to_tank_fill_percent(r, config).ok(), | ||||
|             None => None, | ||||
|         }; | ||||
|  | ||||
|         TankInfo { | ||||
|             enough_water, | ||||
|             warn_level, | ||||
|             left_ml, | ||||
|             sensor_error: tank_err, | ||||
|             raw, | ||||
|             water_frozen: water_temp | ||||
|                 .as_ref() | ||||
|                 .is_ok_and(|temp| *temp < WATER_FROZEN_THRESH), | ||||
|             water_temp: water_temp.as_ref().copied().ok(), | ||||
|             temp_sensor_error: water_temp.as_ref().err().map(|err| err.to_string()), | ||||
|             percent, | ||||
|         } | ||||
|     } | ||||
| } | ||||
|  | ||||
| pub async fn determine_tank_state( | ||||
|     board: &mut MutexGuard<'static, CriticalSectionRawMutex, HAL<'static>>, | ||||
| ) -> TankState { | ||||
|     if board.board_hal.get_config().tank.tank_sensor_enabled { | ||||
|         match board | ||||
|             .board_hal | ||||
|             .get_tank_sensor() | ||||
|             .and_then(|f| core::prelude::v1::Ok(f.tank_sensor_voltage())) | ||||
|         { | ||||
|             Ok(raw_sensor_value_mv) => TankState::Present(raw_sensor_value_mv.await.unwrap()), | ||||
|             Err(err) => TankState::Error(TankError::BoardError(err.to_string())), | ||||
|         } | ||||
|     } else { | ||||
|         TankState::Disabled | ||||
|     } | ||||
| } | ||||
|  | ||||
| #[derive(Debug, Serialize)] | ||||
| /// Information structure send to mqtt for monitoring purposes | ||||
| pub struct TankInfo { | ||||
|     /// there is enough water in the tank | ||||
|     pub(crate) enough_water: bool, | ||||
|     /// warning that water needs to be refilled soon | ||||
|     pub(crate) warn_level: bool, | ||||
|     /// estimation how many ml are still in the tank | ||||
|     pub(crate) left_ml: Option<f32>, | ||||
|     /// if there is an issue with the water level sensor | ||||
|     pub(crate) sensor_error: Option<TankError>, | ||||
|     /// raw water sensor value | ||||
|     pub(crate) raw: Option<f32>, | ||||
|     /// percent value | ||||
|     pub(crate) percent: Option<f32>, | ||||
|     /// water in the tank might be frozen | ||||
|     pub(crate) water_frozen: bool, | ||||
|     /// water temperature | ||||
|     pub(crate) water_temp: Option<f32>, | ||||
|     pub(crate) temp_sensor_error: Option<String>, | ||||
| } | ||||
							
								
								
									
										2
									
								
								rust/src/webserver/.gitignore
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										2
									
								
								rust/src/webserver/.gitignore
									
									
									
									
										vendored
									
									
								
							| @@ -1,2 +0,0 @@ | ||||
| index.html.gz | ||||
| bundle.js.gz | ||||
| @@ -1,191 +0,0 @@ | ||||
| use crate::fat_error::{FatError, FatResult}; | ||||
| use crate::hal::rtc::X25; | ||||
| use crate::BOARD_ACCESS; | ||||
| use alloc::borrow::ToOwned; | ||||
| use alloc::format; | ||||
| use alloc::string::{String, ToString}; | ||||
| use chrono::DateTime; | ||||
| use edge_http::io::server::Connection; | ||||
| use embedded_io_async::{Read, Write}; | ||||
| use log::info; | ||||
| use serde::{Deserialize, Serialize}; | ||||
|  | ||||
| #[derive(Serialize, Deserialize, PartialEq, Debug)] | ||||
| pub struct WebBackupHeader { | ||||
|     timestamp: String, | ||||
|     size: u16, | ||||
| } | ||||
| pub(crate) async fn get_backup_config<T, const N: usize>( | ||||
|     conn: &mut Connection<'_, T, { N }>, | ||||
| ) -> FatResult<Option<u32>> | ||||
| where | ||||
|     T: Read + Write, | ||||
| { | ||||
|     // First pass: verify checksum without sending data | ||||
|     let mut checksum = X25.digest(); | ||||
|     let mut chunk = 0_usize; | ||||
|     loop { | ||||
|         let mut board = BOARD_ACCESS.get().await.lock().await; | ||||
|         board.board_hal.progress(chunk as u32).await; | ||||
|         let (buf, len, expected_crc) = board | ||||
|             .board_hal | ||||
|             .get_rtc_module() | ||||
|             .get_backup_config(chunk) | ||||
|             .await?; | ||||
|  | ||||
|         // Update checksum with the actual data bytes of this chunk | ||||
|         checksum.update(&buf[..len]); | ||||
|  | ||||
|         let is_last = len == 0 || len < buf.len(); | ||||
|         if is_last { | ||||
|             let actual_crc = checksum.finalize(); | ||||
|             if actual_crc != expected_crc { | ||||
|                 BOARD_ACCESS | ||||
|                     .get() | ||||
|                     .await | ||||
|                     .lock() | ||||
|                     .await | ||||
|                     .board_hal | ||||
|                     .clear_progress() | ||||
|                     .await; | ||||
|                 conn.initiate_response( | ||||
|                     409, | ||||
|                     Some( | ||||
|                         format!( | ||||
|                             "Checksum mismatch expected {} got {}", | ||||
|                             expected_crc, actual_crc | ||||
|                         ) | ||||
|                         .as_str(), | ||||
|                     ), | ||||
|                     &[], | ||||
|                 ) | ||||
|                 .await?; | ||||
|                 return Ok(Some(409)); | ||||
|             } | ||||
|             break; | ||||
|         } | ||||
|         chunk += 1; | ||||
|     } | ||||
|     // Second pass: stream data | ||||
|     conn.initiate_response( | ||||
|         200, | ||||
|         Some("OK"), | ||||
|         &[ | ||||
|             ("Access-Control-Allow-Origin", "*"), | ||||
|             ("Access-Control-Allow-Headers", "*"), | ||||
|             ("Access-Control-Allow-Methods", "*"), | ||||
|         ], | ||||
|     ) | ||||
|     .await?; | ||||
|  | ||||
|     let mut chunk = 0_usize; | ||||
|     loop { | ||||
|         let mut board = BOARD_ACCESS.get().await.lock().await; | ||||
|         board.board_hal.progress(chunk as u32).await; | ||||
|         let (buf, len, _expected_crc) = board | ||||
|             .board_hal | ||||
|             .get_rtc_module() | ||||
|             .get_backup_config(chunk) | ||||
|             .await?; | ||||
|  | ||||
|         if len == 0 { | ||||
|             break; | ||||
|         } | ||||
|  | ||||
|         conn.write_all(&buf[..len]).await?; | ||||
|  | ||||
|         if len < buf.len() { | ||||
|             break; | ||||
|         } | ||||
|         chunk += 1; | ||||
|     } | ||||
|     BOARD_ACCESS | ||||
|         .get() | ||||
|         .await | ||||
|         .lock() | ||||
|         .await | ||||
|         .board_hal | ||||
|         .clear_progress() | ||||
|         .await; | ||||
|     Ok(Some(200)) | ||||
| } | ||||
|  | ||||
| pub(crate) async fn backup_config<T, const N: usize>( | ||||
|     conn: &mut Connection<'_, T, N>, | ||||
| ) -> FatResult<Option<String>> | ||||
| where | ||||
|     T: Read + Write, | ||||
| { | ||||
|     let mut offset = 0_usize; | ||||
|     let mut buf = [0_u8; 32]; | ||||
|  | ||||
|     let mut checksum = X25.digest(); | ||||
|  | ||||
|     let mut counter = 0; | ||||
|     loop { | ||||
|         let to_write = conn.read(&mut buf).await?; | ||||
|         if to_write == 0 { | ||||
|             info!("backup finished"); | ||||
|             break; | ||||
|         } else { | ||||
|             let mut board = BOARD_ACCESS.get().await.lock().await; | ||||
|             board.board_hal.progress(counter).await; | ||||
|  | ||||
|             counter = counter + 1; | ||||
|             board | ||||
|                 .board_hal | ||||
|                 .get_rtc_module() | ||||
|                 .backup_config(offset, &buf[0..to_write]) | ||||
|                 .await?; | ||||
|             checksum.update(&buf[0..to_write]); | ||||
|         } | ||||
|         offset = offset + to_write; | ||||
|     } | ||||
|  | ||||
|     let mut board = BOARD_ACCESS.get().await.lock().await; | ||||
|     board | ||||
|         .board_hal | ||||
|         .get_rtc_module() | ||||
|         .backup_config_finalize(checksum.finalize(), offset) | ||||
|         .await?; | ||||
|     board.board_hal.clear_progress().await; | ||||
|     conn.initiate_response( | ||||
|         200, | ||||
|         Some("OK"), | ||||
|         &[ | ||||
|             ("Access-Control-Allow-Origin", "*"), | ||||
|             ("Access-Control-Allow-Headers", "*"), | ||||
|             ("Access-Control-Allow-Methods", "*"), | ||||
|         ], | ||||
|     ) | ||||
|     .await?; | ||||
|     Ok(Some("saved".to_owned())) | ||||
| } | ||||
|  | ||||
| pub(crate) async fn backup_info<T, const N: usize>( | ||||
|     _request: &mut Connection<'_, T, N>, | ||||
| ) -> Result<Option<String>, FatError> | ||||
| where | ||||
|     T: Read + Write, | ||||
| { | ||||
|     let mut board = BOARD_ACCESS.get().await.lock().await; | ||||
|     let header = board.board_hal.get_rtc_module().get_backup_info().await; | ||||
|     let json = match header { | ||||
|         Ok(h) => { | ||||
|             let timestamp = DateTime::from_timestamp_millis(h.timestamp).unwrap(); | ||||
|             let wbh = WebBackupHeader { | ||||
|                 timestamp: timestamp.to_rfc3339(), | ||||
|                 size: h.size, | ||||
|             }; | ||||
|             serde_json::to_string(&wbh)? | ||||
|         } | ||||
|         Err(err) => { | ||||
|             let wbh = WebBackupHeader { | ||||
|                 timestamp: err.to_string(), | ||||
|                 size: 0, | ||||
|             }; | ||||
|             serde_json::to_string(&wbh)? | ||||
|         } | ||||
|     }; | ||||
|     Ok(Some(json)) | ||||
| } | ||||
										
											Binary file not shown.
										
									
								
							| Before Width: | Height: | Size: 15 KiB | 
| @@ -1,160 +0,0 @@ | ||||
| use crate::fat_error::{FatError, FatResult}; | ||||
| use crate::webserver::read_up_to_bytes_from_request; | ||||
| use crate::BOARD_ACCESS; | ||||
| use alloc::borrow::ToOwned; | ||||
| use alloc::format; | ||||
| use alloc::string::String; | ||||
| use edge_http::io::server::Connection; | ||||
| use edge_http::Method; | ||||
| use embedded_io_async::{Read, Write}; | ||||
| use log::info; | ||||
|  | ||||
| pub(crate) async fn list_files<T, const N: usize>( | ||||
|     _request: &mut Connection<'_, T, N>, | ||||
| ) -> FatResult<Option<String>> { | ||||
|     let mut board = BOARD_ACCESS.get().await.lock().await; | ||||
|     let result = board.board_hal.get_esp().list_files().await?; | ||||
|     let file_list_json = serde_json::to_string(&result)?; | ||||
|     Ok(Some(file_list_json)) | ||||
| } | ||||
| pub(crate) async fn file_operations<T, const N: usize>( | ||||
|     conn: &mut Connection<'_, T, { N }>, | ||||
|     method: Method, | ||||
|     path: &&str, | ||||
|     prefix: &&str, | ||||
| ) -> Result<Option<u32>, FatError> | ||||
| where | ||||
|     T: Read + Write, | ||||
| { | ||||
|     let filename = &path[prefix.len()..]; | ||||
|     info!("file request for {} with method {}", filename, method); | ||||
|     Ok(match method { | ||||
|         Method::Delete => { | ||||
|             let mut board = BOARD_ACCESS.get().await.lock().await; | ||||
|             board | ||||
|                 .board_hal | ||||
|                 .get_esp() | ||||
|                 .delete_file(filename.to_owned()) | ||||
|                 .await?; | ||||
|             conn.initiate_response( | ||||
|                 200, | ||||
|                 Some("OK"), | ||||
|                 &[ | ||||
|                     ("Access-Control-Allow-Origin", "*"), | ||||
|                     ("Access-Control-Allow-Headers", "*"), | ||||
|                     ("Access-Control-Allow-Methods", "*"), | ||||
|                 ], | ||||
|             ) | ||||
|             .await?; | ||||
|             Some(200) | ||||
|         } | ||||
|         Method::Get => { | ||||
|             let disposition = format!("attachment; filename=\"{filename}\""); | ||||
|             let size = { | ||||
|                 let mut board = BOARD_ACCESS.get().await.lock().await; | ||||
|                 board | ||||
|                     .board_hal | ||||
|                     .get_esp() | ||||
|                     .get_size(filename.to_owned()) | ||||
|                     .await? | ||||
|             }; | ||||
|  | ||||
|             conn.initiate_response( | ||||
|                 200, | ||||
|                 Some("OK"), | ||||
|                 &[ | ||||
|                     ("Content-Type", "application/octet-stream"), | ||||
|                     ("Content-Disposition", disposition.as_str()), | ||||
|                     ("Content-Length", &format!("{}", size)), | ||||
|                     ("Access-Control-Allow-Origin", "*"), | ||||
|                     ("Access-Control-Allow-Headers", "*"), | ||||
|                     ("Access-Control-Allow-Methods", "*"), | ||||
|                 ], | ||||
|             ) | ||||
|             .await?; | ||||
|  | ||||
|             let mut chunk = 0; | ||||
|             loop { | ||||
|                 let mut board = BOARD_ACCESS.get().await.lock().await; | ||||
|                 board.board_hal.progress(chunk).await; | ||||
|                 let read_chunk = board | ||||
|                     .board_hal | ||||
|                     .get_esp() | ||||
|                     .get_file(filename.to_owned(), chunk) | ||||
|                     .await?; | ||||
|                 let length = read_chunk.1; | ||||
|                 if length == 0 { | ||||
|                     info!("file request for {} finished", filename); | ||||
|                     break; | ||||
|                 } | ||||
|                 let data = &read_chunk.0[0..length]; | ||||
|                 conn.write_all(data).await?; | ||||
|                 if length < read_chunk.0.len() { | ||||
|                     info!("file request for {} finished", filename); | ||||
|                     break; | ||||
|                 } | ||||
|                 chunk = chunk + 1; | ||||
|             } | ||||
|             BOARD_ACCESS | ||||
|                 .get() | ||||
|                 .await | ||||
|                 .lock() | ||||
|                 .await | ||||
|                 .board_hal | ||||
|                 .clear_progress() | ||||
|                 .await; | ||||
|             Some(200) | ||||
|         } | ||||
|         Method::Post => { | ||||
|             { | ||||
|                 let mut board = BOARD_ACCESS.get().await.lock().await; | ||||
|                 //ensure the file is deleted first; otherwise we would need to truncate the file which will not work with streaming | ||||
|                 let _ = board | ||||
|                     .board_hal | ||||
|                     .get_esp() | ||||
|                     .delete_file(filename.to_owned()) | ||||
|                     .await; | ||||
|             } | ||||
|  | ||||
|             let mut offset = 0_usize; | ||||
|             let mut chunk = 0; | ||||
|             loop { | ||||
|                 let buf = read_up_to_bytes_from_request(conn, Some(4096)).await?; | ||||
|                 if buf.len() == 0 { | ||||
|                     info!("file request for {} finished", filename); | ||||
|                     break; | ||||
|                 } else { | ||||
|                     let mut board = BOARD_ACCESS.get().await.lock().await; | ||||
|                     board.board_hal.progress(chunk as u32).await; | ||||
|                     board | ||||
|                         .board_hal | ||||
|                         .get_esp() | ||||
|                         .write_file(filename.to_owned(), offset as u32, &buf) | ||||
|                         .await?; | ||||
|                 } | ||||
|                 offset = offset + buf.len(); | ||||
|                 chunk = chunk + 1; | ||||
|             } | ||||
|             BOARD_ACCESS | ||||
|                 .get() | ||||
|                 .await | ||||
|                 .lock() | ||||
|                 .await | ||||
|                 .board_hal | ||||
|                 .clear_progress() | ||||
|                 .await; | ||||
|             conn.initiate_response( | ||||
|                 200, | ||||
|                 Some("OK"), | ||||
|                 &[ | ||||
|                     ("Access-Control-Allow-Origin", "*"), | ||||
|                     ("Access-Control-Allow-Headers", "*"), | ||||
|                     ("Access-Control-Allow-Methods", "*"), | ||||
|                 ], | ||||
|             ) | ||||
|             .await?; | ||||
|             Some(200) | ||||
|         } | ||||
|         _ => None, | ||||
|     }) | ||||
| } | ||||
| @@ -1,186 +0,0 @@ | ||||
| use core::str::FromStr; | ||||
| use crate::fat_error::{FatError, FatResult}; | ||||
| use crate::hal::{esp_time, PLANT_COUNT}; | ||||
| use crate::log::LogMessage; | ||||
| use crate::plant_state::{MoistureSensorState, PlantState}; | ||||
| use crate::tank::determine_tank_state; | ||||
| use crate::{get_version, BOARD_ACCESS}; | ||||
| use alloc::format; | ||||
| use alloc::string::{String, ToString}; | ||||
| use alloc::vec::Vec; | ||||
| use chrono_tz::Tz; | ||||
| use edge_http::io::server::Connection; | ||||
| use embedded_io_async::{Read, Write}; | ||||
| use log::info; | ||||
| use serde::Serialize; | ||||
|  | ||||
| #[derive(Serialize, Debug)] | ||||
| struct LoadData<'a> { | ||||
|     rtc: &'a str, | ||||
|     native: &'a str, | ||||
| } | ||||
| #[derive(Serialize, Debug)] | ||||
| struct Moistures { | ||||
|     moisture_a: Vec<String>, | ||||
|     moisture_b: Vec<String>, | ||||
| } | ||||
| #[derive(Serialize, Debug)] | ||||
| struct SolarState { | ||||
|     mppt_voltage: f32, | ||||
|     mppt_current: f32, | ||||
|     is_day: bool, | ||||
| } | ||||
| pub(crate) async fn get_live_moisture<T, const N: usize>( | ||||
|     _request: &mut Connection<'_, T, N>, | ||||
| ) -> FatResult<Option<String>> | ||||
| where | ||||
|     T: Read + Write, | ||||
| { | ||||
|     let mut board = BOARD_ACCESS.get().await.lock().await; | ||||
|     let moistures = board.board_hal.measure_moisture_hz().await?; | ||||
|     let mut plant_state = Vec::new(); | ||||
|     for i in 0..PLANT_COUNT { | ||||
|         plant_state.push(PlantState::read_hardware_state(moistures, i, &mut board).await); | ||||
|     } | ||||
|     let a = Vec::from_iter(plant_state.iter().map(|s| match &s.sensor_a { | ||||
|         MoistureSensorState::Disabled => "disabled".to_string(), | ||||
|         MoistureSensorState::MoistureValue { | ||||
|             raw_hz, | ||||
|             moisture_percent, | ||||
|         } => { | ||||
|             format!("{moisture_percent:.2}% {raw_hz}hz",) | ||||
|         } | ||||
|         MoistureSensorState::SensorError(err) => format!("{err:?}"), | ||||
|     })); | ||||
|     let b = Vec::from_iter(plant_state.iter().map(|s| match &s.sensor_b { | ||||
|         MoistureSensorState::Disabled => "disabled".to_string(), | ||||
|         MoistureSensorState::MoistureValue { | ||||
|             raw_hz, | ||||
|             moisture_percent, | ||||
|         } => { | ||||
|             format!("{moisture_percent:.2}% {raw_hz}hz",) | ||||
|         } | ||||
|         MoistureSensorState::SensorError(err) => format!("{err:?}"), | ||||
|     })); | ||||
|  | ||||
|     let data = Moistures { | ||||
|         moisture_a: a, | ||||
|         moisture_b: b, | ||||
|     }; | ||||
|     let json = serde_json::to_string(&data)?; | ||||
|  | ||||
|     Ok(Some(json)) | ||||
| } | ||||
|  | ||||
| pub(crate) async fn tank_info<T, const N: usize>( | ||||
|     _request: &mut Connection<'_, T, N>, | ||||
| ) -> Result<Option<String>, FatError> | ||||
| where | ||||
|     T: Read + Write, | ||||
| { | ||||
|     let mut board = BOARD_ACCESS.get().await.lock().await; | ||||
|     let tank_state = determine_tank_state(&mut board).await; | ||||
|     //should be multisampled | ||||
|     let sensor = board.board_hal.get_tank_sensor()?; | ||||
|  | ||||
|     let water_temp: FatResult<f32> = sensor.water_temperature_c().await; | ||||
|     Ok(Some(serde_json::to_string(&tank_state.as_mqtt_info( | ||||
|         &board.board_hal.get_config().tank, | ||||
|         &water_temp, | ||||
|     ))?)) | ||||
| } | ||||
|  | ||||
| pub(crate) async fn get_timezones() -> FatResult<Option<String>> { | ||||
|     // Get all timezones compiled into the binary from chrono-tz | ||||
|     let timezones: Vec<&'static str> = chrono_tz::TZ_VARIANTS.iter().map(|tz| tz.name()).collect(); | ||||
|     let json = serde_json::to_string(&timezones)?; | ||||
|     Ok(Some(json)) | ||||
| } | ||||
|  | ||||
| pub(crate) async fn get_solar_state<T, const N: usize>( | ||||
|     _request: &mut Connection<'_, T, N>, | ||||
| ) -> FatResult<Option<String>> { | ||||
|     let mut board = BOARD_ACCESS.get().await.lock().await; | ||||
|     let state = SolarState { | ||||
|         mppt_voltage: board.board_hal.get_mptt_voltage().await?.as_millivolts() as f32, | ||||
|         mppt_current: board.board_hal.get_mptt_current().await?.as_milliamperes() as f32, | ||||
|         is_day: board.board_hal.is_day(), | ||||
|     }; | ||||
|     Ok(Some(serde_json::to_string(&state)?)) | ||||
| } | ||||
|  | ||||
| pub(crate) async fn get_version_web<T, const N: usize>( | ||||
|     _request: &mut Connection<'_, T, N>, | ||||
| ) -> FatResult<Option<String>> { | ||||
|     let mut board = BOARD_ACCESS.get().await.lock().await; | ||||
|     Ok(Some(serde_json::to_string(&get_version(&mut board).await)?)) | ||||
| } | ||||
|  | ||||
| pub(crate) async fn get_config<T, const N: usize>( | ||||
|     _request: &mut Connection<'_, T, N>, | ||||
| ) -> FatResult<Option<String>> { | ||||
|     let mut board = BOARD_ACCESS.get().await.lock().await; | ||||
|     let json = serde_json::to_string(&board.board_hal.get_config())?; | ||||
|     Ok(Some(json)) | ||||
| } | ||||
|  | ||||
| pub(crate) async fn get_battery_state<T, const N: usize>( | ||||
|     _request: &mut Connection<'_, T, N>, | ||||
| ) -> FatResult<Option<String>> { | ||||
|     let mut board = BOARD_ACCESS.get().await.lock().await; | ||||
|     let battery_state = board | ||||
|         .board_hal | ||||
|         .get_battery_monitor() | ||||
|         .get_battery_state() | ||||
|         .await?; | ||||
|     Ok(Some(serde_json::to_string(&battery_state)?)) | ||||
| } | ||||
|  | ||||
| pub(crate) async fn get_time<T, const N: usize>( | ||||
|     _request: &mut Connection<'_, T, N>, | ||||
| ) -> FatResult<Option<String>> { | ||||
|     let mut board = BOARD_ACCESS.get().await.lock().await; | ||||
|     let conf = board.board_hal.get_config(); | ||||
|  | ||||
|     let tz:Tz = match conf.timezone.as_ref(){ | ||||
|         None => { | ||||
|             Tz::UTC | ||||
|         } | ||||
|         Some(tz_string) => { | ||||
|             match Tz::from_str(tz_string) { | ||||
|                 Ok(tz) => { | ||||
|                     tz | ||||
|                 } | ||||
|                 Err(err) => { | ||||
|                     info!("failed parsing timezone {}", err); | ||||
|                     Tz::UTC | ||||
|                 } | ||||
|             } | ||||
|         } | ||||
|     }; | ||||
|  | ||||
|     let native = esp_time().await.with_timezone(&tz).to_rfc3339(); | ||||
|  | ||||
|     let rtc = match board.board_hal.get_rtc_module().get_rtc_time().await { | ||||
|         Ok(time) => time.with_timezone(&tz).to_rfc3339(), | ||||
|         Err(err) => { | ||||
|             format!("Error getting time: {}", err) | ||||
|         } | ||||
|     }; | ||||
|  | ||||
|     let data = LoadData { | ||||
|         rtc: rtc.as_str(), | ||||
|         native: native.as_str(), | ||||
|     }; | ||||
|     let json = serde_json::to_string(&data)?; | ||||
|  | ||||
|     Ok(Some(json)) | ||||
| } | ||||
|  | ||||
| pub(crate) async fn get_log_localization_config<T, const N: usize>( | ||||
|     _request: &mut Connection<'_, T, N>, | ||||
| ) -> FatResult<Option<String>> { | ||||
|     Ok(Some(serde_json::to_string( | ||||
|         &LogMessage::to_log_localisation_config(), | ||||
|     )?)) | ||||
| } | ||||
| @@ -1,36 +0,0 @@ | ||||
| use crate::fat_error::FatResult; | ||||
| use crate::log::LOG_ACCESS; | ||||
| use edge_http::io::server::Connection; | ||||
| use embedded_io_async::{Read, Write}; | ||||
|  | ||||
| pub(crate) async fn get_log<T, const N: usize>( | ||||
|     conn: &mut Connection<'_, T, N>, | ||||
| ) -> FatResult<Option<u32>> | ||||
| where | ||||
|     T: Read + Write, | ||||
| { | ||||
|     let log = LOG_ACCESS.lock().await.get(); | ||||
|     conn.initiate_response( | ||||
|         200, | ||||
|         Some("OK"), | ||||
|         &[ | ||||
|             ("Content-Type", "text/javascript"), | ||||
|             ("Access-Control-Allow-Origin", "*"), | ||||
|             ("Access-Control-Allow-Headers", "*"), | ||||
|             ("Access-Control-Allow-Methods", "*"), | ||||
|         ], | ||||
|     ) | ||||
|     .await?; | ||||
|     conn.write_all("[".as_bytes()).await?; | ||||
|     let mut append = false; | ||||
|     for entry in log { | ||||
|         if append { | ||||
|             conn.write_all(",".as_bytes()).await?; | ||||
|         } | ||||
|         append = true; | ||||
|         let json = serde_json::to_string(&entry)?; | ||||
|         conn.write_all(json.as_bytes()).await?; | ||||
|     } | ||||
|     conn.write_all("]".as_bytes()).await?; | ||||
|     Ok(Some(200)) | ||||
| } | ||||
| @@ -1,50 +0,0 @@ | ||||
| use crate::fat_error::FatError; | ||||
| use edge_http::io::server::Connection; | ||||
| use embedded_io_async::{Read, Write}; | ||||
|  | ||||
| pub(crate) async fn serve_favicon<T, const N: usize>( | ||||
|     conn: &mut Connection<'_, T, { N }>, | ||||
| ) -> Result<Option<u32>, FatError> | ||||
| where | ||||
|     T: Read + Write, | ||||
| { | ||||
|     conn.initiate_response(200, Some("OK"), &[("Content-Type", "image/x-icon")]) | ||||
|         .await?; | ||||
|     conn.write_all(include_bytes!("favicon.ico")).await?; | ||||
|     Ok(Some(200)) | ||||
| } | ||||
|  | ||||
| pub(crate) async fn serve_index<T, const N: usize>( | ||||
|     conn: &mut Connection<'_, T, { N }>, | ||||
| ) -> Result<Option<u32>, FatError> | ||||
| where | ||||
|     T: Read + Write, | ||||
| { | ||||
|     conn.initiate_response( | ||||
|         200, | ||||
|         Some("OK"), | ||||
|         &[("Content-Type", "text/html"), ("Content-Encoding", "gzip")], | ||||
|     ) | ||||
|     .await?; | ||||
|     conn.write_all(include_bytes!("index.html.gz")).await?; | ||||
|     Ok(Some(200)) | ||||
| } | ||||
|  | ||||
| pub(crate) async fn serve_bundle<T, const N: usize>( | ||||
|     conn: &mut Connection<'_, T, { N }>, | ||||
| ) -> Result<Option<u32>, FatError> | ||||
| where | ||||
|     T: Read + Write, | ||||
| { | ||||
|     conn.initiate_response( | ||||
|         200, | ||||
|         Some("OK"), | ||||
|         &[ | ||||
|             ("Content-Type", "text/javascript"), | ||||
|             ("Content-Encoding", "gzip"), | ||||
|         ], | ||||
|     ) | ||||
|     .await?; | ||||
|     conn.write_all(include_bytes!("bundle.js.gz")).await?; | ||||
|     Ok(Some(200)) | ||||
| } | ||||
| @@ -1,302 +0,0 @@ | ||||
| //offer ota and config mode | ||||
|  | ||||
| mod backup_manager; | ||||
| mod file_manager; | ||||
| mod get_json; | ||||
| mod get_log; | ||||
| mod get_static; | ||||
| mod ota; | ||||
| mod post_json; | ||||
|  | ||||
| use crate::fat_error::{FatError, FatResult}; | ||||
| use crate::webserver::backup_manager::{backup_config, backup_info, get_backup_config}; | ||||
| use crate::webserver::file_manager::{file_operations, list_files}; | ||||
| use crate::webserver::get_json::{ | ||||
|     get_battery_state, get_config, get_live_moisture, get_log_localization_config, get_solar_state, | ||||
|     get_time, get_timezones, get_version_web, tank_info, | ||||
| }; | ||||
| use crate::webserver::get_log::get_log; | ||||
| use crate::webserver::get_static::{serve_bundle, serve_favicon, serve_index}; | ||||
| use crate::webserver::ota::ota_operations; | ||||
| use crate::webserver::post_json::{ | ||||
|     board_test, night_lamp_test, pump_test, set_config, wifi_scan, write_time, detect_sensors, | ||||
| }; | ||||
| use crate::{bail, BOARD_ACCESS}; | ||||
| use alloc::borrow::ToOwned; | ||||
| use alloc::string::{String, ToString}; | ||||
| use alloc::sync::Arc; | ||||
| use alloc::vec::Vec; | ||||
| use core::fmt::{Debug, Display}; | ||||
| use core::net::{IpAddr, Ipv4Addr, SocketAddr}; | ||||
| use core::result::Result::Ok; | ||||
| use core::sync::atomic::{AtomicBool, Ordering}; | ||||
| use edge_http::io::server::{Connection, Handler, Server}; | ||||
| use edge_http::Method; | ||||
| use edge_nal::TcpBind; | ||||
| use edge_nal_embassy::{Tcp, TcpBuffers}; | ||||
| use embassy_net::Stack; | ||||
| use embassy_time::Instant; | ||||
| use embedded_io_async::{Read, Write}; | ||||
| use log::{error, info}; | ||||
| // fn ota( | ||||
| //     request: &mut Request<&mut EspHttpConnection>, | ||||
| // ) -> Result<Option<std::string::String>, anyhow::Error> { | ||||
| //     let mut board = BOARD_ACCESS.lock().unwrap(); | ||||
| //     let mut ota = OtaUpdate::begin()?; | ||||
| //     log::info!("start ota"); | ||||
| // | ||||
| //     //having a larger buffer is not really faster, requires more stack and prevents the progress bar from working ;) | ||||
| //     const BUFFER_SIZE: usize = 512; | ||||
| //     let mut buffer: [u8; BUFFER_SIZE] = [0; BUFFER_SIZE]; | ||||
| //     let mut total_read: usize = 0; | ||||
| //     let mut lastiter = 0; | ||||
| //     loop { | ||||
| //         let read = request.read(&mut buffer)?; | ||||
| //         total_read += read; | ||||
| //         let to_write = &buffer[0..read]; | ||||
| //         //delay for watchdog and wifi stuff | ||||
| //         board.board_hal.get_esp().delay.delay_ms(1); | ||||
| // | ||||
| //         let iter = (total_read / 1024) % 8; | ||||
| //         if iter != lastiter { | ||||
| //             board.board_hal.general_fault(iter % 5 == 0); | ||||
| //             for i in 0..PLANT_COUNT { | ||||
| //                 let _ = board.board_hal.fault(i, iter == i); | ||||
| //             } | ||||
| //             lastiter = iter; | ||||
| //         } | ||||
| // | ||||
| //         ota.write(to_write)?; | ||||
| //         if read == 0 { | ||||
| //             break; | ||||
| //         } | ||||
| //     } | ||||
| //     log::info!("wrote bytes ota {total_read}"); | ||||
| //     log::info!("finish ota"); | ||||
| //     let partition = ota.raw_partition(); | ||||
| //     log::info!("finalizing and changing boot partition to {partition:?}"); | ||||
| // | ||||
| //     let mut finalizer = ota.finalize()?; | ||||
| //     log::info!("changing boot partition"); | ||||
| //     board.board_hal.get_esp().set_restart_to_conf(true); | ||||
| //     drop(board); | ||||
| //     finalizer.set_as_boot_partition()?; | ||||
| //     anyhow::Ok(None) | ||||
| // } | ||||
| // | ||||
|  | ||||
| struct HTTPRequestRouter { | ||||
|     reboot_now: Arc<AtomicBool>, | ||||
| } | ||||
|  | ||||
| impl Handler for HTTPRequestRouter { | ||||
|     type Error<E: Debug> = FatError; | ||||
|     async fn handle<'a, T, const N: usize>( | ||||
|         &self, | ||||
|         _task_id: impl Display + Copy, | ||||
|         conn: &mut Connection<'a, T, N>, | ||||
|     ) -> Result<(), FatError> | ||||
|     where | ||||
|         T: Read + Write, | ||||
|     { | ||||
|         let start = Instant::now(); | ||||
|         let headers = conn.headers()?; | ||||
|  | ||||
|         let method = headers.method; | ||||
|         let path = headers.path; | ||||
|  | ||||
|         let prefix = "/file?filename="; | ||||
|         let status = if path.starts_with(prefix) { | ||||
|             file_operations(conn, method, &path, &prefix).await? | ||||
|         } else if path == "/ota" { | ||||
|             ota_operations(conn, method).await.map_err(|e| { | ||||
|                 error!("Error handling ota: {}", e); | ||||
|                 e | ||||
|             })? | ||||
|         } else { | ||||
|             match method { | ||||
|                 Method::Get => match path { | ||||
|                     "/favicon.ico" => serve_favicon(conn).await?, | ||||
|                     "/" => serve_index(conn).await?, | ||||
|                     "/bundle.js" => serve_bundle(conn).await?, | ||||
|                     "/log" => get_log(conn).await?, | ||||
|                     "/get_backup_config" => get_backup_config(conn).await?, | ||||
|                     &_ => { | ||||
|                         let json = match path { | ||||
|                             "/version" => Some(get_version_web(conn).await), | ||||
|                             "/time" => Some(get_time(conn).await), | ||||
|                             "/battery" => Some(get_battery_state(conn).await), | ||||
|                             "/solar" => Some(get_solar_state(conn).await), | ||||
|                             "/get_config" => Some(get_config(conn).await), | ||||
|                             "/files" => Some(list_files(conn).await), | ||||
|                             "/log_localization" => Some(get_log_localization_config(conn).await), | ||||
|                             "/tank" => Some(tank_info(conn).await), | ||||
|                             "/backup_info" => Some(backup_info(conn).await), | ||||
|                             "/timezones" => Some(get_timezones().await), | ||||
|                             "/moisture" => Some(get_live_moisture(conn).await), | ||||
|                             _ => None, | ||||
|                         }; | ||||
|                         match json { | ||||
|                             None => None, | ||||
|                             Some(json) => Some(handle_json(conn, json).await?), | ||||
|                         } | ||||
|                     } | ||||
|                 }, | ||||
|                 Method::Post => { | ||||
|                     let json = match path { | ||||
|                         "/wifiscan" => Some(wifi_scan(conn).await), | ||||
|                         "/set_config" => Some(set_config(conn).await), | ||||
|                         "/time" => Some(write_time(conn).await), | ||||
|                         "/backup_config" => Some(backup_config(conn).await), | ||||
|                         "/pumptest" => Some(pump_test(conn).await), | ||||
|                         "/lamptest" => Some(night_lamp_test(conn).await), | ||||
|                         "/boardtest" => Some(board_test().await), | ||||
|                         "/detect_sensors" => Some(detect_sensors().await), | ||||
|                         "/reboot" => { | ||||
|                             let mut board = BOARD_ACCESS.get().await.lock().await; | ||||
|                             board.board_hal.get_esp().set_restart_to_conf(true); | ||||
|                             self.reboot_now.store(true, Ordering::Relaxed); | ||||
|                             Some(Ok(None)) | ||||
|                         } | ||||
|                         "/exit" => { | ||||
|                             let mut board = BOARD_ACCESS.get().await.lock().await; | ||||
|                             board.board_hal.get_esp().set_restart_to_conf(false); | ||||
|                             self.reboot_now.store(true, Ordering::Relaxed); | ||||
|                             Some(Ok(None)) | ||||
|                         } | ||||
|                         _ => None, | ||||
|                     }; | ||||
|                     match json { | ||||
|                         None => None, | ||||
|                         Some(json) => Some(handle_json(conn, json).await?), | ||||
|                     } | ||||
|                 } | ||||
|                 Method::Options | Method::Delete | Method::Head | Method::Put => None, | ||||
|                 _ => None, | ||||
|             } | ||||
|         }; | ||||
|         let code = match status { | ||||
|             None => { | ||||
|                 conn.initiate_response(404, Some("Not found"), &[]).await?; | ||||
|                 404 | ||||
|             } | ||||
|             Some(code) => code, | ||||
|         }; | ||||
|  | ||||
|         conn.complete().await?; | ||||
|         let response_time = Instant::now().duration_since(start).as_millis(); | ||||
|  | ||||
|         info!("\"{method} {path}\" {code} {response_time}ms"); | ||||
|         Ok(()) | ||||
|     } | ||||
| } | ||||
|  | ||||
| async fn read_up_to_bytes_from_request<T, const N: usize>( | ||||
|     request: &mut Connection<'_, T, N>, | ||||
|     limit: Option<usize>, | ||||
| ) -> FatResult<Vec<u8>> | ||||
| where | ||||
|     T: Read + Write, | ||||
| { | ||||
|     let max_read = limit.unwrap_or(1024); | ||||
|     let mut data_store = Vec::new(); | ||||
|     let mut total_read = 0; | ||||
|     loop { | ||||
|         let left = max_read - total_read; | ||||
|         let mut buf = [0_u8; 64]; | ||||
|         let s_buf = if buf.len() <= left { | ||||
|             &mut buf | ||||
|         } else { | ||||
|             &mut buf[0..left] | ||||
|         }; | ||||
|         let read = request.read(s_buf).await?; | ||||
|         if read == 0 { | ||||
|             break; | ||||
|         } | ||||
|         let actual_data = &s_buf[0..read]; | ||||
|         total_read += read; | ||||
|         if total_read > max_read { | ||||
|             bail!("Request too large {total_read} > {max_read}"); | ||||
|         } | ||||
|         data_store.push(actual_data.to_owned()); | ||||
|     } | ||||
|     let final_buffer = data_store.concat(); | ||||
|     Ok(final_buffer) | ||||
| } | ||||
|  | ||||
| #[embassy_executor::task] | ||||
| pub async fn http_server(reboot_now: Arc<AtomicBool>, stack: Stack<'static>) { | ||||
|     let buffer: TcpBuffers<2, 1024, 1024> = TcpBuffers::new(); | ||||
|     let tcp = Tcp::new(stack, &buffer); | ||||
|     let acceptor = tcp | ||||
|         .bind(SocketAddr::new(IpAddr::V4(Ipv4Addr::UNSPECIFIED), 80)) | ||||
|         .await | ||||
|         .unwrap(); | ||||
|  | ||||
|     let mut server: Server<2, 512, 15> = Server::new(); | ||||
|     server | ||||
|         .run(Some(5000), acceptor, HTTPRequestRouter { reboot_now }) | ||||
|         .await | ||||
|         .expect("Tcp stack error"); | ||||
|     info!("Webserver started and waiting for connections"); | ||||
|  | ||||
|     //TODO https if mbed_esp lands | ||||
| } | ||||
|  | ||||
| async fn handle_json<'a, T, const N: usize>( | ||||
|     conn: &mut Connection<'a, T, N>, | ||||
|     chain: FatResult<Option<String>>, | ||||
| ) -> FatResult<u32> | ||||
| where | ||||
|     T: Read + Write, | ||||
|     <T as embedded_io_async::ErrorType>::Error: Debug, | ||||
| { | ||||
|     match chain { | ||||
|         Ok(answer) => match answer { | ||||
|             Some(json) => { | ||||
|                 conn.initiate_response( | ||||
|                     200, | ||||
|                     Some("OK"), | ||||
|                     &[ | ||||
|                         ("Access-Control-Allow-Origin", "*"), | ||||
|                         ("Access-Control-Allow-Headers", "*"), | ||||
|                         ("Access-Control-Allow-Methods", "*"), | ||||
|                         ("Content-Type", "application/json"), | ||||
|                     ], | ||||
|                 ) | ||||
|                 .await?; | ||||
|                 conn.write_all(json.as_bytes()).await?; | ||||
|                 Ok(200) | ||||
|             } | ||||
|             None => { | ||||
|                 conn.initiate_response( | ||||
|                     200, | ||||
|                     Some("OK"), | ||||
|                     &[ | ||||
|                         ("Access-Control-Allow-Origin", "*"), | ||||
|                         ("Access-Control-Allow-Headers", "*"), | ||||
|                         ("Access-Control-Allow-Methods", "*"), | ||||
|                     ], | ||||
|                 ) | ||||
|                 .await?; | ||||
|                 Ok(200) | ||||
|             } | ||||
|         }, | ||||
|         Err(err) => { | ||||
|             let error_text = err.to_string(); | ||||
|             info!("error handling process {}", error_text); | ||||
|             conn.initiate_response( | ||||
|                 500, | ||||
|                 Some("OK"), | ||||
|                 &[ | ||||
|                     ("Access-Control-Allow-Origin", "*"), | ||||
|                     ("Access-Control-Allow-Headers", "*"), | ||||
|                     ("Access-Control-Allow-Methods", "*"), | ||||
|                 ], | ||||
|             ) | ||||
|             .await?; | ||||
|             conn.write_all(error_text.as_bytes()).await?; | ||||
|             Ok(500) | ||||
|         } | ||||
|     } | ||||
| } | ||||
| @@ -1,76 +0,0 @@ | ||||
| use crate::fat_error::FatError; | ||||
| use crate::webserver::read_up_to_bytes_from_request; | ||||
| use crate::BOARD_ACCESS; | ||||
| use edge_http::io::server::Connection; | ||||
| use edge_http::Method; | ||||
| use embedded_io_async::{Read, Write}; | ||||
| use log::info; | ||||
|  | ||||
| pub(crate) async fn ota_operations<T, const N: usize>( | ||||
|     conn: &mut Connection<'_, T, { N }>, | ||||
|     method: Method, | ||||
| ) -> Result<Option<u32>, FatError> | ||||
| where | ||||
|     T: Read + Write, | ||||
| { | ||||
|     Ok(match method { | ||||
|         Method::Options => { | ||||
|             conn.initiate_response( | ||||
|                 200, | ||||
|                 Some("OK"), | ||||
|                 &[ | ||||
|                     ("Access-Control-Allow-Origin", "*"), | ||||
|                     ("Access-Control-Allow-Headers", "*"), | ||||
|                     ("Access-Control-Allow-Methods", "*"), | ||||
|                 ], | ||||
|             ) | ||||
|             .await?; | ||||
|             Some(200) | ||||
|         } | ||||
|         Method::Post => { | ||||
|             let mut offset = 0_usize; | ||||
|             let mut chunk = 0; | ||||
|             loop { | ||||
|                 let buf = read_up_to_bytes_from_request(conn, Some(4096)).await?; | ||||
|                 if buf.len() == 0 { | ||||
|                     info!("file request for  ota finished"); | ||||
|                     let mut board = BOARD_ACCESS.get().await.lock().await; | ||||
|                     board.board_hal.get_esp().finalize_ota().await?; | ||||
|                     break; | ||||
|                 } else { | ||||
|                     let mut board = BOARD_ACCESS.get().await.lock().await; | ||||
|                     board.board_hal.progress(chunk as u32).await; | ||||
|                     // Erase next block if we are at a 4K boundary (including the first block at offset 0) | ||||
|                     info!("erasing and writing block 0x{offset:x}"); | ||||
|                     board | ||||
|                         .board_hal | ||||
|                         .get_esp() | ||||
|                         .write_ota(offset as u32, &*buf) | ||||
|                         .await?; | ||||
|                 } | ||||
|                 offset = offset + buf.len(); | ||||
|                 chunk = chunk + 1; | ||||
|             } | ||||
|             BOARD_ACCESS | ||||
|                 .get() | ||||
|                 .await | ||||
|                 .lock() | ||||
|                 .await | ||||
|                 .board_hal | ||||
|                 .clear_progress() | ||||
|                 .await; | ||||
|             conn.initiate_response( | ||||
|                 200, | ||||
|                 Some("OK"), | ||||
|                 &[ | ||||
|                     ("Access-Control-Allow-Origin", "*"), | ||||
|                     ("Access-Control-Allow-Headers", "*"), | ||||
|                     ("Access-Control-Allow-Methods", "*"), | ||||
|                 ], | ||||
|             ) | ||||
|             .await?; | ||||
|             Some(200) | ||||
|         } | ||||
|         _ => None, | ||||
|     }) | ||||
| } | ||||
| @@ -1,119 +0,0 @@ | ||||
| use crate::config::PlantControllerConfig; | ||||
| use crate::fat_error::FatResult; | ||||
| use crate::hal::esp_set_time; | ||||
| use crate::webserver::read_up_to_bytes_from_request; | ||||
| use crate::{do_secure_pump, BOARD_ACCESS}; | ||||
| use alloc::string::{String, ToString}; | ||||
| use alloc::vec::Vec; | ||||
| use chrono::DateTime; | ||||
| use edge_http::io::server::Connection; | ||||
| use embedded_io_async::{Read, Write}; | ||||
| use log::info; | ||||
| use serde::{Deserialize, Serialize}; | ||||
|  | ||||
| #[derive(Deserialize)] | ||||
| pub struct NightLampCommand { | ||||
|     active: bool, | ||||
| } | ||||
| #[derive(Serialize, Debug)] | ||||
| struct SSIDList { | ||||
|     ssids: Vec<String>, | ||||
| } | ||||
| #[derive(Deserialize, Debug)] | ||||
| struct SetTime<'a> { | ||||
|     time: &'a str, | ||||
| } | ||||
|  | ||||
| #[derive(Serialize, Deserialize, Clone, Debug, PartialEq)] | ||||
| pub struct TestPump { | ||||
|     pump: usize, | ||||
| } | ||||
|  | ||||
| pub(crate) async fn wifi_scan<T, const N: usize>( | ||||
|     _request: &mut Connection<'_, T, N>, | ||||
| ) -> FatResult<Option<String>> { | ||||
|     let mut board = BOARD_ACCESS.get().await.lock().await; | ||||
|     info!("start wifi scan"); | ||||
|     let mut ssids: Vec<String> = Vec::new(); | ||||
|     let scan_result = board.board_hal.get_esp().wifi_scan().await?; | ||||
|     scan_result | ||||
|         .iter() | ||||
|         .for_each(|s| ssids.push(s.ssid.to_string())); | ||||
|     let ssid_json = serde_json::to_string(&SSIDList { ssids })?; | ||||
|     info!("Sending ssid list {}", &ssid_json); | ||||
|     Ok(Some(ssid_json)) | ||||
| } | ||||
|  | ||||
| pub(crate) async fn board_test() -> FatResult<Option<String>> { | ||||
|     let mut board = BOARD_ACCESS.get().await.lock().await; | ||||
|     board.board_hal.test().await?; | ||||
|     Ok(None) | ||||
| } | ||||
|  | ||||
| pub(crate) async fn detect_sensors() -> FatResult<Option<String>> { | ||||
|     let mut board = BOARD_ACCESS.get().await.lock().await; | ||||
|     let result = board.board_hal.detect_sensors().await?; | ||||
|     let json = serde_json::to_string(&result)?; | ||||
|     Ok(Some(json)) | ||||
| } | ||||
|  | ||||
| pub(crate) async fn pump_test<T, const N: usize>( | ||||
|     request: &mut Connection<'_, T, N>, | ||||
| ) -> FatResult<Option<String>> | ||||
| where | ||||
|     T: Read + Write, | ||||
| { | ||||
|     let actual_data = read_up_to_bytes_from_request(request, None).await?; | ||||
|     let pump_test: TestPump = serde_json::from_slice(&actual_data)?; | ||||
|     let mut board = BOARD_ACCESS.get().await.lock().await; | ||||
|  | ||||
|     let config = &board.board_hal.get_config().plants[pump_test.pump].clone(); | ||||
|     let pump_result = do_secure_pump(&mut board, pump_test.pump, config, false).await; | ||||
|     //ensure it is disabled before unwrapping | ||||
|     board.board_hal.pump(pump_test.pump, false).await?; | ||||
|  | ||||
|     Ok(Some(serde_json::to_string(&pump_result?)?)) | ||||
| } | ||||
|  | ||||
| pub(crate) async fn night_lamp_test<T, const N: usize>( | ||||
|     request: &mut Connection<'_, T, N>, | ||||
| ) -> FatResult<Option<String>> | ||||
| where | ||||
|     T: Read + Write, | ||||
| { | ||||
|     let actual_data = read_up_to_bytes_from_request(request, None).await?; | ||||
|     let light_command: NightLampCommand = serde_json::from_slice(&actual_data)?; | ||||
|     let mut board = BOARD_ACCESS.get().await.lock().await; | ||||
|     board.board_hal.light(light_command.active).await?; | ||||
|     Ok(None) | ||||
| } | ||||
|  | ||||
| pub(crate) async fn write_time<T, const N: usize>( | ||||
|     request: &mut Connection<'_, T, N>, | ||||
| ) -> FatResult<Option<String>> | ||||
| where | ||||
|     T: Read + Write, | ||||
| { | ||||
|     let actual_data = read_up_to_bytes_from_request(request, None).await?; | ||||
|     let time: SetTime = serde_json::from_slice(&actual_data)?; | ||||
|     let parsed = DateTime::parse_from_rfc3339(time.time).unwrap(); | ||||
|     esp_set_time(parsed).await?; | ||||
|     Ok(None) | ||||
| } | ||||
|  | ||||
| pub(crate) async fn set_config<T, const N: usize>( | ||||
|     request: &mut Connection<'_, T, N>, | ||||
| ) -> FatResult<Option<String>> | ||||
| where | ||||
|     T: Read + Write, | ||||
| { | ||||
|     let all = read_up_to_bytes_from_request(request, Some(4096)).await?; | ||||
|     let length = all.len(); | ||||
|     let config: PlantControllerConfig = serde_json::from_slice(&all)?; | ||||
|  | ||||
|     let mut board = BOARD_ACCESS.get().await.lock().await; | ||||
|     board.board_hal.get_esp().save_config(all).await?; | ||||
|     info!("Wrote config config {:?} with size {}", config, length); | ||||
|     board.board_hal.set_config(config); | ||||
|     Ok(Some("Ok".to_string())) | ||||
| } | ||||
							
								
								
									
										4
									
								
								rust/src_webpack/.gitignore
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										4
									
								
								rust/src_webpack/.gitignore
									
									
									
									
										vendored
									
									
								
							| @@ -1,4 +0,0 @@ | ||||
| index.html.gz | ||||
| bundle.js.gz | ||||
| index.html | ||||
| bundle.js | ||||
							
								
								
									
										4832
									
								
								rust/src_webpack/package-lock.json
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										4832
									
								
								rust/src_webpack/package-lock.json
									
									
									
										generated
									
									
									
								
							
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							| @@ -1,18 +0,0 @@ | ||||
| { | ||||
|   "devDependencies": { | ||||
|     "compression-webpack-plugin": "^11.1.0", | ||||
|     "html-webpack-harddisk-plugin": "^2.0.0", | ||||
|     "html-webpack-plugin": "^5.6.3", | ||||
|     "raw-loader": "^4.0.2", | ||||
|     "ts-loader": "^9.5.1", | ||||
|     "typescript": "^5.3.3", | ||||
|     "webpack": "^5.97.1", | ||||
|     "webpack-cli": "^5.1.4", | ||||
|     "webpack-dev-server": "^5.1.0" | ||||
|   }, | ||||
|   "dependencies": { | ||||
|     "copy-webpack-plugin": "^12.0.2", | ||||
|     "fast-equals": "^5.2.2", | ||||
|     "source-map-loader": "^4.0.1" | ||||
|   } | ||||
| } | ||||
| @@ -1,203 +0,0 @@ | ||||
| export interface LogArray extends Array<LogEntry> { | ||||
| } | ||||
|  | ||||
| export interface LogEntry { | ||||
|     timestamp: string, | ||||
|     message_id: number, | ||||
|     a: number, | ||||
|     b: number, | ||||
|     txt_short: string, | ||||
|     txt_long: string | ||||
| } | ||||
|  | ||||
| export interface LogLocalisation extends Array<LogLocalisationEntry> { | ||||
| } | ||||
|  | ||||
| export interface LogLocalisationEntry { | ||||
|     msg_type: string, | ||||
|     message: string | ||||
| } | ||||
|  | ||||
| export interface BackupHeader { | ||||
|     timestamp: string, | ||||
|     size: number | ||||
| } | ||||
|  | ||||
| export interface NetworkConfig { | ||||
|     ap_ssid: string, | ||||
|     ssid: string, | ||||
|     password: string, | ||||
|     mqtt_url: string, | ||||
|     base_topic: string, | ||||
|     mqtt_user: string | null, | ||||
|     mqtt_password: string | null, | ||||
|     max_wait: number | ||||
| } | ||||
|  | ||||
| export interface FileList { | ||||
|     total: number, | ||||
|     used: number, | ||||
|     files: FileInfo[], | ||||
|     file_system_corrupt: string, | ||||
|     iter_error: string, | ||||
| } | ||||
|  | ||||
| export interface SolarState { | ||||
|     mppt_voltage: number, | ||||
|     mppt_current: number, | ||||
|     is_day: boolean | ||||
| } | ||||
|  | ||||
| export interface FileInfo { | ||||
|     filename: string, | ||||
|     size: number, | ||||
| } | ||||
|  | ||||
| export interface NightLampConfig { | ||||
|     enabled: boolean, | ||||
|     night_lamp_hour_start: number, | ||||
|     night_lamp_hour_end: number, | ||||
|     night_lamp_only_when_dark: boolean, | ||||
|     low_soc_cutoff: number, | ||||
|     low_soc_restore: number | ||||
| } | ||||
|  | ||||
| export interface NightLampCommand { | ||||
|     active: boolean | ||||
| } | ||||
|  | ||||
| export interface TankConfig { | ||||
|     tank_sensor_enabled: boolean, | ||||
|     tank_allow_pumping_if_sensor_error: boolean, | ||||
|     tank_useable_ml: number, | ||||
|     tank_warn_percent: number, | ||||
|     tank_empty_percent: number, | ||||
|     tank_full_percent: number, | ||||
|     ml_per_pulse: number | ||||
| } | ||||
|  | ||||
|  | ||||
| export enum BatteryBoardVersion { | ||||
|     Disabled = "Disabled", | ||||
|     BQ34Z100G1 = "BQ34Z100G1", | ||||
|     WchI2cSlave = "WchI2cSlave" | ||||
| } | ||||
|  | ||||
| export enum BoardVersion { | ||||
|     INITIAL = "INITIAL", | ||||
|     V3 = "V3", | ||||
|     V4 = "V4" | ||||
| } | ||||
|  | ||||
| export interface BoardHardware { | ||||
|     board: BoardVersion, | ||||
|     battery: BatteryBoardVersion, | ||||
| } | ||||
|  | ||||
| export interface PlantControllerConfig { | ||||
|     hardware: BoardHardware, | ||||
|  | ||||
|     network: NetworkConfig, | ||||
|     tank: TankConfig, | ||||
|     night_lamp: NightLampConfig, | ||||
|     plants: PlantConfig[] | ||||
|     timezone?: string, | ||||
| } | ||||
|  | ||||
| export interface PlantConfig { | ||||
|     mode: string, | ||||
|     target_moisture: number, | ||||
|     min_moisture: number, | ||||
|     pump_time_s: number, | ||||
|     pump_cooldown_min: number, | ||||
|     pump_hour_start: number, | ||||
|     pump_hour_end: number, | ||||
|     sensor_a: boolean, | ||||
|     sensor_b: boolean, | ||||
|     max_consecutive_pump_count: number, | ||||
|     moisture_sensor_min_frequency: number | null; | ||||
|     moisture_sensor_max_frequency: number | null; | ||||
|     min_pump_current_ma: number, | ||||
|     max_pump_current_ma: number, | ||||
|     ignore_current_error: boolean, | ||||
| } | ||||
|  | ||||
| export interface PumpTestResult { | ||||
|     median_current_ma: number, | ||||
|     max_current_ma: number, | ||||
|     min_current_ma: number, | ||||
|     flow_value_ml: number, | ||||
|     flow_value_count: number, | ||||
|     pump_time_s: number, | ||||
|     error: boolean, | ||||
| } | ||||
|  | ||||
| export interface SSIDList { | ||||
|     ssids: [string] | ||||
| } | ||||
|  | ||||
| export interface TestPump { | ||||
|     pump: number | ||||
| } | ||||
|  | ||||
| export interface SetTime { | ||||
|     time: string | ||||
| } | ||||
|  | ||||
| export interface GetTime { | ||||
|     rtc: string, | ||||
|     native: string | ||||
| } | ||||
|  | ||||
| export interface Moistures { | ||||
|     moisture_a: [string], | ||||
|     moisture_b: [string], | ||||
| } | ||||
|  | ||||
| export interface VersionInfo { | ||||
|     git_hash: string, | ||||
|     build_time: string, | ||||
|     current: string, | ||||
|     slot0_state: string, | ||||
|     slot1_state: string, | ||||
| } | ||||
|  | ||||
| export interface BatteryState { | ||||
|     temperature: string | ||||
|     voltage_milli_volt: string, | ||||
|     current_milli_ampere: string, | ||||
|     cycle_count: string, | ||||
|     design_milli_ampere: string, | ||||
|     remaining_milli_ampere: string, | ||||
|     state_of_charge: string, | ||||
|     state_of_health: string | ||||
| } | ||||
|  | ||||
| export interface DetectionPlant { | ||||
|     a: boolean, | ||||
|     b: boolean | ||||
| } | ||||
|  | ||||
| export interface DetectionResult { | ||||
|     plants: DetectionPlant[] | ||||
| } | ||||
|  | ||||
| export interface TankInfo { | ||||
|     /// is there enough water in the tank | ||||
|     enough_water: boolean, | ||||
|     /// warning that water needs to be refilled soon | ||||
|     warn_level: boolean, | ||||
|     /// estimation how many ml are still in tank | ||||
|     left_ml: number | null, | ||||
|     /// if there is was an issue with the water level sensor | ||||
|     sensor_error: string | null, | ||||
|     /// raw water sensor value | ||||
|     raw: number | null, | ||||
|     /// percent value | ||||
|     percent: number | null, | ||||
|     /// water in tank might be frozen | ||||
|     water_frozen: boolean, | ||||
|     /// water temperature | ||||
|     water_temp: number | null, | ||||
|     temp_sensor_error: string | null | ||||
| } | ||||
| @@ -1,49 +0,0 @@ | ||||
| <style> | ||||
|   .powerflexkey { | ||||
|     min-width: 150px; | ||||
|   } | ||||
|   .powerflexvalue { | ||||
|     text-wrap: nowrap;  | ||||
|     flex-grow: 1; | ||||
|   } | ||||
| </style> | ||||
|  | ||||
| <div class="flexcontainer"> | ||||
|     <div class="subtitle"> | ||||
|         Battery: | ||||
|     </div> | ||||
|     <input id="battery_auto_refresh" type="checkbox">⟳ | ||||
| </div> | ||||
|  | ||||
| <div class="flexcontainer"> | ||||
|     <span class="powerflexkey">V:</span> | ||||
|     <span class="powerflexvalue" id="battery_voltage_milli_volt"></span> | ||||
| </div> | ||||
| <div class="flexcontainer"> | ||||
|     <span class="powerflexkey">mA:</span> | ||||
|     <span class="powerflexvalue" id="battery_current_milli_ampere" ></span> | ||||
| </div> | ||||
| <div class="flexcontainer"> | ||||
|     <span class="powerflexkey">Cycles:</span> | ||||
|     <span class="powerflexvalue" id="battery_cycle_count" ></span> | ||||
| </div> | ||||
| <div class="flexcontainer"> | ||||
|     <span class="powerflexkey">design mA:</span> | ||||
|     <span class="powerflexvalue" id="battery_design_milli_ampere" ></span> | ||||
| </div> | ||||
| <div class="flexcontainer"> | ||||
|     <span class="powerflexkey">remaining mA:</span> | ||||
|     <span class="powerflexvalue" id="battery_remaining_milli_ampere" ></span> | ||||
| </div> | ||||
| <div class="flexcontainer"> | ||||
|     <span class="powerflexkey">charge %:</span> | ||||
|     <span class="powerflexvalue" id="battery_state_of_charge" ></span> | ||||
| </div> | ||||
| <div class="flexcontainer"> | ||||
|     <span class="powerflexkey">health %:</span> | ||||
|     <span class="powerflexvalue" id="battery_state_of_health" ></span> | ||||
| </div> | ||||
| <div class="flexcontainer"> | ||||
|     <span class="powerflexkey">Temp °C:</span> | ||||
|     <span class="powerflexvalue" id="battery_temperature" ></span> | ||||
| </div> | ||||
| @@ -1,70 +0,0 @@ | ||||
| import { Controller } from "./main"; | ||||
| import {BatteryState} from "./api"; | ||||
|  | ||||
| export class BatteryView{ | ||||
|     voltage_milli_volt: HTMLSpanElement; | ||||
|     current_milli_ampere: HTMLSpanElement; | ||||
|     cycle_count: HTMLSpanElement; | ||||
|     design_milli_ampere: HTMLSpanElement; | ||||
|     remaining_milli_ampere: HTMLSpanElement; | ||||
|     state_of_charge: HTMLSpanElement; | ||||
|     state_of_health: HTMLSpanElement; | ||||
|     temperature: HTMLSpanElement; | ||||
|     auto_refresh: HTMLInputElement; | ||||
|     timer: NodeJS.Timeout | undefined; | ||||
|     controller: Controller; | ||||
|  | ||||
|     constructor (controller:Controller) { | ||||
|       (document.getElementById("batteryview") as HTMLElement).innerHTML = require("./batteryview.html") | ||||
|         this.voltage_milli_volt = document.getElementById("battery_voltage_milli_volt") as HTMLSpanElement; | ||||
|         this.current_milli_ampere = document.getElementById("battery_current_milli_ampere") as HTMLSpanElement; | ||||
|         this.cycle_count = document.getElementById("battery_cycle_count") as HTMLSpanElement; | ||||
|         this.design_milli_ampere = document.getElementById("battery_design_milli_ampere") as HTMLSpanElement; | ||||
|         this.remaining_milli_ampere = document.getElementById("battery_remaining_milli_ampere") as HTMLSpanElement; | ||||
|         this.state_of_charge = document.getElementById("battery_state_of_charge") as HTMLSpanElement; | ||||
|         this.state_of_health = document.getElementById("battery_state_of_health") as HTMLSpanElement; | ||||
|         this.temperature = document.getElementById("battery_temperature") as HTMLSpanElement; | ||||
|         this.auto_refresh = document.getElementById("battery_auto_refresh") as HTMLInputElement; | ||||
|  | ||||
|         this.controller = controller | ||||
|         this.auto_refresh.onchange = () => { | ||||
|             if(this.timer){ | ||||
|               clearTimeout(this.timer) | ||||
|             } | ||||
|             if(this.auto_refresh.checked){ | ||||
|               controller.updateBatteryData() | ||||
|             } | ||||
|           } | ||||
|     } | ||||
|  | ||||
|     update(batterystate: BatteryState|null){ | ||||
|         if (batterystate == null) { | ||||
|             this.voltage_milli_volt.innerText = "N/A" | ||||
|             this.current_milli_ampere.innerText = "N/A" | ||||
|             this.cycle_count.innerText = "N/A" | ||||
|             this.design_milli_ampere.innerText = "N/A" | ||||
|             this.remaining_milli_ampere.innerText = "N/A" | ||||
|             this.state_of_charge.innerText = "N/A" | ||||
|             this.state_of_health.innerText = "N/A" | ||||
|             this.temperature.innerText = "N/A" | ||||
|         } else { | ||||
|             this.voltage_milli_volt.innerText = String(+batterystate.voltage_milli_volt/1000) | ||||
|             this.current_milli_ampere.innerText = batterystate.current_milli_ampere | ||||
|             this.cycle_count.innerText = batterystate.cycle_count | ||||
|             this.design_milli_ampere.innerText = batterystate.design_milli_ampere | ||||
|             this.remaining_milli_ampere.innerText = batterystate.remaining_milli_ampere | ||||
|             this.state_of_charge.innerText = batterystate.state_of_charge | ||||
|             this.state_of_health.innerText = batterystate.state_of_health | ||||
|             this.temperature.innerText = String(+batterystate.temperature / 100) | ||||
|         } | ||||
|          | ||||
|  | ||||
|         if(this.auto_refresh.checked){ | ||||
|             this.timer = setTimeout(this.controller.updateBatteryData, 1000); | ||||
|           } else { | ||||
|             if(this.timer){ | ||||
|               clearTimeout(this.timer) | ||||
|             } | ||||
|           } | ||||
|     } | ||||
|   } | ||||
| @@ -1,72 +0,0 @@ | ||||
| <style> | ||||
|   .filecheckbox { | ||||
|     margin: 0px; | ||||
|     min-width: 20px | ||||
|   } | ||||
|  | ||||
|   .filekey { | ||||
|     min-width: 200px; | ||||
|   } | ||||
|  | ||||
|   .filevalue { | ||||
|     flex-grow: 1; | ||||
|     width: 25%; | ||||
|     min-width: 200px; | ||||
|   } | ||||
|  | ||||
|   .filenumberbox { | ||||
|     min-width: 50px; | ||||
|     flex-grow: 1; | ||||
|   } | ||||
|  | ||||
|   .filetitle { | ||||
|     border-top-style: dotted; | ||||
|     flex-grow: 1; | ||||
|   } | ||||
|   .fileentryouter { | ||||
|     flex-grow: 1; | ||||
|     width: 100%; | ||||
|   } | ||||
| </style> | ||||
|  | ||||
| <div class="subtitle">Files:</div> | ||||
| <div class="flexcontainer"> | ||||
|   <div class="filekey">Total Size</div> | ||||
|   <div id="filetotalsize" class="filevalue"></div> | ||||
| </div> | ||||
| <div class="flexcontainer"> | ||||
|   <div class="filekey">Used Size</div> | ||||
|   <div id="fileusedsize" class="filevalue"></div> | ||||
| </div> | ||||
| <div class="flexcontainer"> | ||||
|   <div class="filekey">Free Size</div> | ||||
|   <div id="filefreesize" class="filevalue"></div> | ||||
| </div> | ||||
| <br> | ||||
|  | ||||
| <div class="flexcontainer"  style="border-left-style: double; border-right-style: double; border-top-style: double;"> | ||||
|   <div class="subtitle" >Upload:</div> | ||||
| </div> | ||||
| <div class="flexcontainer" style="border-left-style: double; border-right-style: double;"> | ||||
|   <div class="flexcontainer"> | ||||
|     <div class="filekey"> | ||||
|       File: | ||||
|     </div> | ||||
|     <input id="fileuploadfile" class="filevalue" type="file"> | ||||
|   </div> | ||||
|   <div class="flexcontainer"> | ||||
|     <div class="filekey"> | ||||
|       Name: | ||||
|     </div> | ||||
|     <input id="fileuploadname" class="filevalue" type="text"> | ||||
|   </div> | ||||
| </div> | ||||
| <div class="flexcontainer" style="border-left-style: double; border-right-style: double; border-bottom-style: double;"> | ||||
|   <button id="fileuploadbtn" class="subtitle">Upload</button> | ||||
| </div> | ||||
| <br> | ||||
| <div class="flexcontainer" style="border-left-style: double; border-right-style: double; border-top-style: double;"> | ||||
|   <div class="subtitle">List:</div> | ||||
| </div> | ||||
| <div id="fileList" class="flexcontainer" style="border-left-style: double; border-right-style: double; border-bottom-style: double;"> | ||||
| </div> | ||||
| @@ -1,96 +0,0 @@ | ||||
| import {Controller} from "./main"; | ||||
| import {FileInfo, FileList} from "./api"; | ||||
| const regex = /[^a-zA-Z0-9_.]/g; | ||||
|  | ||||
| function sanitize(str:string){ | ||||
|   return str.replaceAll(regex, '_') | ||||
| }  | ||||
|  | ||||
| export class FileView { | ||||
|   readonly fileListView: HTMLElement; | ||||
|   readonly controller: Controller; | ||||
|   readonly filefreesize: HTMLElement; | ||||
|   readonly filetotalsize: HTMLElement; | ||||
|   readonly fileusedsize: HTMLElement; | ||||
|  | ||||
|   constructor(controller: Controller) { | ||||
|     (document.getElementById("fileview") as HTMLElement).innerHTML = require('./fileview.html') as string; | ||||
|     this.fileListView = document.getElementById("fileList") as HTMLElement | ||||
|     this.filefreesize = document.getElementById("filefreesize") as HTMLElement | ||||
|     this.filetotalsize = document.getElementById("filetotalsize") as HTMLElement | ||||
|     this.fileusedsize = document.getElementById("fileusedsize") as HTMLElement | ||||
|  | ||||
|     let fileuploadfile = document.getElementById("fileuploadfile") as HTMLInputElement | ||||
|     let fileuploadname = document.getElementById("fileuploadname") as HTMLInputElement | ||||
|     let fileuploadbtn = document.getElementById("fileuploadbtn") as HTMLInputElement | ||||
|     fileuploadfile.onchange = () => { | ||||
|       const selectedFile = fileuploadfile.files?.[0]; | ||||
|       if (selectedFile == null) { | ||||
|         //TODO error dialog here | ||||
|         return | ||||
|       } | ||||
|        | ||||
|       fileuploadname.value = sanitize(selectedFile.name) | ||||
|     }; | ||||
|  | ||||
|     fileuploadname.onchange = () => { | ||||
|       let input = fileuploadname.value | ||||
|       let clean = sanitize(fileuploadname.value) | ||||
|       if (input != clean){ | ||||
|         fileuploadname.value = clean | ||||
|       } | ||||
|     } | ||||
|  | ||||
|     fileuploadbtn.onclick = () => { | ||||
|       const selectedFile = fileuploadfile.files?.[0]; | ||||
|       if (selectedFile == null) { | ||||
|         //TODO error dialog here | ||||
|         return | ||||
|       } | ||||
|       controller.uploadFile(selectedFile, selectedFile.name) | ||||
|     } | ||||
|  | ||||
|  | ||||
|  | ||||
|     this.controller = controller; | ||||
|   } | ||||
|  | ||||
|   setFileList(fileList: FileList, public_url: string) { | ||||
|     this.filetotalsize.innerText = Math.floor(fileList.total / 1024) + "kB" | ||||
|     this.fileusedsize.innerText = Math.ceil(fileList.used / 1024) + "kB" | ||||
|     this.filefreesize.innerText = Math.ceil((fileList.total - fileList.used) / 1024) + "kB" | ||||
|  | ||||
|     //fast clear | ||||
|     this.fileListView.textContent = "" | ||||
|     for (let i = 0; i < fileList.files.length; i++) { | ||||
|       let file = fileList.files[i] | ||||
|       new FileEntry(this.controller, i, file, this.fileListView, public_url); | ||||
|     } | ||||
|   } | ||||
| } | ||||
|  | ||||
| class FileEntry { | ||||
|   view: HTMLElement; | ||||
|   constructor(controller: Controller, fileid: number, fileinfo: FileInfo, parent: HTMLElement, public_url: string) { | ||||
|     this.view = document.createElement("div") as HTMLElement | ||||
|     parent.appendChild(this.view) | ||||
|     this.view.classList.add("fileentryouter") | ||||
|  | ||||
|     const template = require('./fileviewentry.html') as string; | ||||
|     this.view.innerHTML = template.replaceAll("${fileid}", String(fileid)) | ||||
|  | ||||
|     let name = document.getElementById("file_" + fileid + "_name") as HTMLElement; | ||||
|     let size = document.getElementById("file_" + fileid + "_size") as HTMLElement; | ||||
|     let deleteBtn = document.getElementById("file_" + fileid + "_delete") as HTMLButtonElement; | ||||
|     deleteBtn.onclick = () => { | ||||
|       controller.deleteFile(fileinfo.filename); | ||||
|     } | ||||
|  | ||||
|     let downloadBtn = document.getElementById("file_" + fileid + "_download") as HTMLAnchorElement; | ||||
|     downloadBtn.href = public_url + "/file?filename=" + fileinfo.filename | ||||
|     downloadBtn.download = fileinfo.filename | ||||
|  | ||||
|     name.innerText = fileinfo.filename; | ||||
|     size.innerText = fileinfo.size.toString() | ||||
|   } | ||||
| } | ||||
| @@ -1,11 +0,0 @@ | ||||
| <div class="flexcontainer"> | ||||
|     <div id="file_${fileid}_name" class="filetitle">Name</div> | ||||
| </div> | ||||
|  | ||||
| <div class="flexcontainer"> | ||||
|     <div class="filekey">Size</div> | ||||
|     <div id = "file_${fileid}_size" class="filevalue"></div> | ||||
|     <a id = "file_${fileid}_download" class="filevalue" target="_blank">Download</a> | ||||
|     <button id = "file_${fileid}_delete" class="filevalue">Delete</button> | ||||
| </div> | ||||
|  | ||||
| @@ -1,20 +0,0 @@ | ||||
| <style> | ||||
|   .boardkey{ | ||||
|     min-width: 200px; | ||||
|   } | ||||
|   .boardvalue{ | ||||
|     flex-grow: 1; | ||||
|   } | ||||
| </style> | ||||
|  | ||||
| <div class="subtitle">Hardware:</div> | ||||
| <div class="flexcontainer">  | ||||
|   <div class="boardkey">BoardRevision</div> | ||||
|     <select class="boardvalue" id="hardware_board_value"> | ||||
|     </select> | ||||
| </div> | ||||
| <div class="flexcontainer"> | ||||
|     <div class="boardkey">BatteryMonitor</div> | ||||
|     <select class="boardvalue" id="hardware_battery_value"> | ||||
|     </select> | ||||
| </div> | ||||
| @@ -1,45 +0,0 @@ | ||||
| import { Controller } from "./main"; | ||||
| import {BatteryBoardVersion, BoardHardware, BoardVersion} from "./api"; | ||||
|  | ||||
| export class HardwareConfigView { | ||||
|     private readonly hardware_board_value: HTMLSelectElement; | ||||
|     private readonly hardware_battery_value: HTMLSelectElement; | ||||
|     constructor(controller:Controller){ | ||||
|       (document.getElementById("hardwareview") as HTMLElement).innerHTML = require('./hardware.html') as string; | ||||
|  | ||||
|       this.hardware_board_value = document.getElementById("hardware_board_value") as HTMLSelectElement; | ||||
|       this.hardware_board_value.onchange = controller.configChanged | ||||
|  | ||||
|       Object.keys(BoardVersion).forEach(version => { | ||||
|         let option = document.createElement("option"); | ||||
|         if (version == BoardVersion.INITIAL.toString()){ | ||||
|           option.selected = true | ||||
|         } | ||||
|         option.innerText = version.toString(); | ||||
|         this.hardware_board_value.appendChild(option); | ||||
|       }) | ||||
|  | ||||
|       this.hardware_battery_value = document.getElementById("hardware_battery_value") as HTMLSelectElement; | ||||
|       this.hardware_battery_value.onchange = controller.configChanged | ||||
|       Object.keys(BatteryBoardVersion).forEach(version => { | ||||
|         let option = document.createElement("option"); | ||||
|         if (version == BatteryBoardVersion.Disabled.toString()){ | ||||
|           option.selected = true | ||||
|         } | ||||
|         option.innerText = version.toString(); | ||||
|         this.hardware_battery_value.appendChild(option); | ||||
|       }) | ||||
|     } | ||||
|    | ||||
|     setConfig(hardware: BoardHardware) { | ||||
|       this.hardware_board_value.value = hardware.board.toString() | ||||
|       this.hardware_battery_value.value = hardware.battery.toString() | ||||
|     } | ||||
|    | ||||
|     getConfig(): BoardHardware { | ||||
|       return { | ||||
|         board :  BoardVersion[this.hardware_board_value.value as keyof typeof BoardVersion], | ||||
|         battery :  BatteryBoardVersion[this.hardware_battery_value.value as keyof typeof BatteryBoardVersion], | ||||
|       } | ||||
|     } | ||||
|   } | ||||
| @@ -1,7 +0,0 @@ | ||||
| <style> | ||||
|  | ||||
| </style> | ||||
| <button id="loadLog">Load Logs</button> | ||||
| <div id="logpanel"> | ||||
|      | ||||
| </div> | ||||
| @@ -1,46 +0,0 @@ | ||||
| import { Controller } from "./main"; | ||||
| import {LogArray, LogLocalisation} from "./api"; | ||||
|  | ||||
| export class LogView { | ||||
|   private readonly logpanel: HTMLElement; | ||||
|   private readonly loadLog: HTMLButtonElement; | ||||
|   loglocale: LogLocalisation | undefined; | ||||
|  | ||||
|   constructor(controller: Controller) { | ||||
|     (document.getElementById("logview") as HTMLElement).innerHTML = require('./log.html') as string; | ||||
|     this.logpanel = document.getElementById("logpanel") as HTMLElement | ||||
|     this.loadLog = document.getElementById("loadLog") as HTMLButtonElement | ||||
|  | ||||
|     this.loadLog.onclick = () => { | ||||
|       controller.loadLog(); | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   setLogLocalisation(loglocale: LogLocalisation) { | ||||
|     this.loglocale = loglocale; | ||||
|   } | ||||
|  | ||||
|   setLog(logs: LogArray) { | ||||
|     this.logpanel.textContent = "" | ||||
|     logs.forEach(entry => { | ||||
|       let message = this.loglocale!![entry.message_id]; | ||||
|       let template = message.message | ||||
|       template = template.replace("${number_a}", entry.a.toString()); | ||||
|       template = template.replace("${number_b}", entry.b.toString()); | ||||
|       template = template.replace("${txt_short}", entry.txt_short.toString()); | ||||
|       template = template.replace("${txt_long}", entry.txt_long.toString()); | ||||
|  | ||||
|       let ts = new Date(entry.timestamp); | ||||
|  | ||||
|       let div = document.createElement("div") | ||||
|       let timestampDiv = document.createElement("div") | ||||
|       let messageDiv = document.createElement("div") | ||||
|       timestampDiv.innerText = ts.toISOString(); | ||||
|       messageDiv.innerText = template; | ||||
|       div.appendChild(timestampDiv) | ||||
|       div.appendChild(messageDiv) | ||||
|       this.logpanel.appendChild(div) | ||||
|     } | ||||
|     ) | ||||
|   } | ||||
| } | ||||
| @@ -1,194 +0,0 @@ | ||||
| <style> | ||||
|   .progressPane { | ||||
|     display: flex; | ||||
|     flex-direction: column; | ||||
|     position: fixed; | ||||
|     width: 100%; | ||||
|     height: 100%; | ||||
|     margin: 0; | ||||
|     padding: 0; | ||||
|     top: 0; | ||||
|     left: 0; | ||||
|     background-color: grey; | ||||
|     opacity: 0.8; | ||||
|   } | ||||
|  | ||||
|   .progress { | ||||
|     height: 2.5em; | ||||
|     width: 100%; | ||||
|     background-color: #555; | ||||
|     position: relative; | ||||
|   } | ||||
|  | ||||
|   .progressSpacer{ | ||||
|     flex-grow: 1; | ||||
|   } | ||||
|  | ||||
|   .progress:after { | ||||
|     content: attr(data-label); | ||||
|     font-size: 0.8em; | ||||
|     position: absolute; | ||||
|     text-align: center; | ||||
|     top: 10px; | ||||
|     left: 0; | ||||
|     right: 0; | ||||
|   } | ||||
|  | ||||
|   .progress .value { | ||||
|     background-color: darkcyan; | ||||
|     display: inline-block; | ||||
|     height: 100%; | ||||
|   } | ||||
|  | ||||
|   .progress .valueIndeterminate { | ||||
|     background-color: darkcyan; | ||||
|     display: inline-block; | ||||
|     height: 100%; | ||||
|     animation: indeterminateAnimation 1s infinite linear; | ||||
|     transform-origin: 0 50%; | ||||
|   } | ||||
|  | ||||
|  | ||||
|   @keyframes indeterminateAnimation { | ||||
|     0% { | ||||
|       transform: translateX(0%) scaleX(0.5); | ||||
|     } | ||||
|  | ||||
|     50% { | ||||
|       transform: translateX(50%) scaleX(0.5); | ||||
|     } | ||||
|  | ||||
|     100% { | ||||
|       transform: translateX(0%) scaleX(0.5); | ||||
|     } | ||||
|   } | ||||
|  | ||||
|  | ||||
|   .flexcontainer { | ||||
|     display: flex; | ||||
|     flex-wrap: wrap; | ||||
|   } | ||||
|   .flexcontainer-rev{ | ||||
|     display: flex; | ||||
|     flex-wrap: wrap-reverse; | ||||
|   } | ||||
|   .subcontainer { | ||||
|     min-width: 300px; | ||||
|     max-width: 900px; | ||||
|     flex-grow: 1; | ||||
|     border-style: solid; | ||||
|     border-width: 1px; | ||||
|     padding: 8px; | ||||
|   } | ||||
|   .subcontainercontainer{ | ||||
|     flex-grow: 1; | ||||
|   } | ||||
|  | ||||
|   .plantcontainer { | ||||
|       flex-grow: 1; | ||||
|       min-width: 100%; | ||||
|       border-style: solid; | ||||
|       border-width: 1px; | ||||
|       padding: 8px; | ||||
|     } | ||||
|   @media (min-width: 350px) { | ||||
|     .plantcontainer { | ||||
|       flex-grow: 1; | ||||
|       min-width: 40%; | ||||
|       border-style: solid; | ||||
|       border-width: 1px; | ||||
|       padding: 8px; | ||||
|     } | ||||
|   } | ||||
|   @media (min-width: 1100px) { | ||||
|     .plantcontainer { | ||||
|       flex-grow: 1; | ||||
|       min-width: 20%; | ||||
|       border-style: solid; | ||||
|       border-width: 1px; | ||||
|       padding: 8px; | ||||
|     } | ||||
|   } | ||||
|   @media (min-width: 2150px) { | ||||
|     .plantcontainer { | ||||
|       flex-grow: 1; | ||||
|       min-width: 200px; | ||||
|       border-style: solid; | ||||
|       border-width: 1px; | ||||
|       padding: 8px; | ||||
|     } | ||||
|   } | ||||
|  | ||||
|  | ||||
|  | ||||
|   .plantlist { | ||||
|     display: flex; | ||||
|     flex-wrap: wrap; | ||||
|   } | ||||
|  | ||||
|   .subtitle { | ||||
|     flex-grow: 1; | ||||
|     text-align: center; | ||||
|     font-weight: bold; | ||||
|   } | ||||
|  | ||||
|  | ||||
|  | ||||
| </style> | ||||
|  | ||||
|  | ||||
| <div class="container-xl"> | ||||
|   <div style="display:flex; flex-wrap: wrap;"> | ||||
|     <div id="hardwareview" class="subcontainer"></div> | ||||
|   </div> | ||||
|  | ||||
|   <div style="display:flex; flex-wrap: wrap;"> | ||||
|     <div id="firmwareview" class="subcontainer"> | ||||
|     </div> | ||||
|     <div id="timeview" class="subcontainer"> | ||||
|     </div> | ||||
|     <div id="batteryview" class="subcontainer"> | ||||
|     </div> | ||||
|     <div id="solarview" class="subcontainer"> | ||||
|     </div> | ||||
|   </div> | ||||
|  | ||||
|   <div class="flexcontainer"> | ||||
|     <div id="network_view" class="subcontainercontainer"></div> | ||||
|     <div id="lightview" class="subcontainer"> | ||||
|     </div> | ||||
|     <div id="tankview" class="subcontainer"> | ||||
|     </div> | ||||
|   </div> | ||||
|  | ||||
|   <h3>Plants:</h3> | ||||
|   <button id="measure_moisture">Measure Moisture</button> | ||||
|   <button id="detect_sensors" style="display:none">Detect/Test Sensors</button> | ||||
|   <div id="plants" class="plantlist"></div> | ||||
|  | ||||
|   <div class="flexcontainer-rev"> | ||||
|     <div id = "submitview" class="subcontainer"> | ||||
|     </div> | ||||
|     <div id="fileview" class="subcontainer"> | ||||
|     </div> | ||||
|   </div> | ||||
|  | ||||
|  | ||||
|  | ||||
|   <button id="exit">Exit</button> | ||||
|   <button id="reboot">Reboot</button> | ||||
|  | ||||
|   <div class="flexcontainer"> | ||||
|     <div id="logview" class="subcontainercontainer"></div> | ||||
|   </div> | ||||
|  | ||||
|   <script src="bundle.js"></script> | ||||
| </div> | ||||
|  | ||||
| <div id="progressPane" class="progressPane"> | ||||
|   <div class="progressSpacer"></div>> | ||||
|   <div id="progressPaneBar" class="progress" data-label="50% Complete"> | ||||
|     <span id="progressPaneSpan" class="value" style="width:100%;"></span> | ||||
|   </div> | ||||
|   <div class="progressSpacer"></div>> | ||||
| </div> | ||||
| @@ -1,619 +0,0 @@ | ||||
| import {deepEqual} from 'fast-equals'; | ||||
|  | ||||
| declare var PUBLIC_URL: string; | ||||
| console.log("Url is " + PUBLIC_URL); | ||||
|  | ||||
| document.body.innerHTML = require('./main.html') as string; | ||||
|  | ||||
|  | ||||
| import {TimeView} from "./timeview"; | ||||
| import {PlantViews} from "./plant"; | ||||
| import {NetworkConfigView} from "./network"; | ||||
| import {NightLampView} from "./nightlightview"; | ||||
| import {TankConfigView} from "./tankview"; | ||||
| import {SubmitView} from "./submitView"; | ||||
| import {ProgressView} from "./progress"; | ||||
| import {OTAView} from "./ota"; | ||||
| import {BatteryView} from "./batteryview"; | ||||
| import {FileView} from './fileview'; | ||||
| import {LogView} from './log'; | ||||
| import {HardwareConfigView} from "./hardware"; | ||||
| import { | ||||
|     BackupHeader, | ||||
|     BatteryState, | ||||
|     GetTime, LogArray, LogLocalisation, | ||||
|     Moistures, | ||||
|     NightLampCommand, | ||||
|     PlantControllerConfig, | ||||
|     SetTime, SSIDList, TankInfo, | ||||
|     TestPump, | ||||
|     VersionInfo, | ||||
|     FileList, SolarState, PumpTestResult | ||||
| } from "./api"; | ||||
| import {SolarView} from "./solarview"; | ||||
| import {toast} from "./toast"; | ||||
|  | ||||
| export class Controller { | ||||
|     loadTankInfo(): Promise<void> { | ||||
|         return fetch(PUBLIC_URL + "/tank") | ||||
|             .then(response => response.json()) | ||||
|             .then(json => json as TankInfo) | ||||
|             .then(tankinfo => { | ||||
|                 controller.tankView.setTankInfo(tankinfo) | ||||
|             }) | ||||
|             .catch(error => { | ||||
|                 console.log(error); | ||||
|             }); | ||||
|     } | ||||
|  | ||||
|     loadLogLocaleConfig() { | ||||
|         return fetch(PUBLIC_URL + "/log_localization") | ||||
|             .then(response => response.json()) | ||||
|             .then(json => json as LogLocalisation) | ||||
|             .then(loglocale => { | ||||
|                 controller.logView.setLogLocalisation(loglocale) | ||||
|             }) | ||||
|             .catch(error => { | ||||
|                 console.log(error); | ||||
|             }); | ||||
|     } | ||||
|  | ||||
|     loadLog() { | ||||
|         return fetch(PUBLIC_URL + "/log") | ||||
|             .then(response => response.json()) | ||||
|             .then(json => json as LogArray) | ||||
|             .then(logs => { | ||||
|                 controller.logView.setLog(logs) | ||||
|             }) | ||||
|             .catch(error => { | ||||
|                 console.log(error); | ||||
|             }); | ||||
|     } | ||||
|  | ||||
|     async getBackupInfo(): Promise<void> { | ||||
|         try { | ||||
|             const response = await fetch(PUBLIC_URL + "/backup_info"); | ||||
|             const json = await response.json(); | ||||
|             const header = json as BackupHeader; | ||||
|             controller.submitView.setBackupInfo(header); | ||||
|         } catch (error) { | ||||
|             console.log(error); | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     async populateTimezones(): Promise<void> { | ||||
|         try { | ||||
|             const response = await fetch(PUBLIC_URL + '/timezones'); | ||||
|             const json = await response.json(); | ||||
|             const timezones = json as string[]; | ||||
|             controller.timeView.timezones(timezones); | ||||
|         } catch (error) { | ||||
|             return console.error('Error fetching timezones:', error); | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     async updateFileList(): Promise<void> { | ||||
|         try { | ||||
|             const response = await fetch(PUBLIC_URL + "/files"); | ||||
|             const json = await response.json(); | ||||
|             const filelist = json as FileList; | ||||
|             controller.fileview.setFileList(filelist, PUBLIC_URL); | ||||
|         } catch (error) { | ||||
|             console.log(error); | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     uploadFile(file: File, name: string) { | ||||
|         let current = 0; | ||||
|         let max = 100; | ||||
|         controller.progressview.addProgress("file_upload", (current / max) * 100, "Uploading File " + name + "(" + current + "/" + max + ")") | ||||
|         const ajax = new XMLHttpRequest(); | ||||
|         ajax.upload.addEventListener("progress", event => { | ||||
|             current = event.loaded / 1000; | ||||
|             max = event.total / 1000; | ||||
|             controller.progressview.addProgress("file_upload", (current / max) * 100, "Uploading File " + name + "(" + current + "/" + max + ")") | ||||
|         }, false); | ||||
|         ajax.addEventListener("load", () => { | ||||
|             controller.progressview.removeProgress("file_upload") | ||||
|             controller.updateFileList() | ||||
|         }, false); | ||||
|         ajax.addEventListener("error", () => { | ||||
|             alert("Error upload") | ||||
|             controller.progressview.removeProgress("file_upload") | ||||
|             controller.updateFileList() | ||||
|         }, false); | ||||
|         ajax.addEventListener("abort", () => { | ||||
|             alert("abort upload") | ||||
|             controller.progressview.removeProgress("file_upload") | ||||
|             controller.updateFileList() | ||||
|         }, false); | ||||
|         ajax.open("POST", PUBLIC_URL + "/file?filename=" + name); | ||||
|         ajax.send(file); | ||||
|     } | ||||
|  | ||||
|     deleteFile(name: string) { | ||||
|         controller.progressview.addIndeterminate("file_delete", "Deleting " + name); | ||||
|         const ajax = new XMLHttpRequest(); | ||||
|         ajax.open("DELETE", PUBLIC_URL + "/file?filename=" + name); | ||||
|         ajax.send(); | ||||
|         ajax.addEventListener("error", () => { | ||||
|             controller.progressview.removeProgress("file_delete") | ||||
|             alert("Error delete") | ||||
|             controller.updateFileList() | ||||
|         }, false); | ||||
|         ajax.addEventListener("abort", () => { | ||||
|             controller.progressview.removeProgress("file_delete") | ||||
|             alert("Error upload") | ||||
|             controller.updateFileList() | ||||
|         }, false); | ||||
|         ajax.addEventListener("load", () => { | ||||
|             controller.progressview.removeProgress("file_delete") | ||||
|             controller.updateFileList() | ||||
|         }, false); | ||||
|         controller.updateFileList() | ||||
|     } | ||||
|  | ||||
|     async updateRTCData(): Promise<void> { | ||||
|         try { | ||||
|             const response = await fetch(PUBLIC_URL + "/time"); | ||||
|             const json = await response.json(); | ||||
|             const time = json as GetTime; | ||||
|             controller.timeView.update(time.native, time.rtc); | ||||
|         } catch (error) { | ||||
|             controller.timeView.update("n/a", "n/a"); | ||||
|             console.log(error); | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     async updateBatteryData(): Promise<void> { | ||||
|         try { | ||||
|             const response = await fetch(PUBLIC_URL + "/battery"); | ||||
|             const json = await response.json(); | ||||
|             const battery = json as BatteryState; | ||||
|             controller.batteryView.update(battery); | ||||
|         } catch (error) { | ||||
|             controller.batteryView.update(null); | ||||
|             console.log(error); | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     async updateSolarData(): Promise<void> { | ||||
|         try { | ||||
|             const response = await fetch(PUBLIC_URL + "/solar"); | ||||
|             const json = await response.json(); | ||||
|             const solar = json as SolarState; | ||||
|             controller.solarView.update(solar); | ||||
|         } catch (error) { | ||||
|             controller.solarView.update(null); | ||||
|             console.log(error); | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     uploadNewFirmware(file: File) { | ||||
|         let current = 0; | ||||
|         let max = 100; | ||||
|         controller.progressview.addProgress("ota_upload", (current / max) * 100, "Uploading firmeware (" + current + "/" + max + ")") | ||||
|         const ajax = new XMLHttpRequest(); | ||||
|         ajax.upload.addEventListener("progress", event => { | ||||
|             current = event.loaded / 1000; | ||||
|             max = event.total / 1000; | ||||
|             controller.progressview.addProgress("ota_upload", (current / max) * 100, "Uploading firmeware (" + current + "/" + max + ")") | ||||
|         }, false); | ||||
|         ajax.addEventListener("load", () => { | ||||
|             controller.progressview.removeProgress("ota_upload") | ||||
|             const status = ajax.status; | ||||
|             if (status >= 200 && status < 300) { | ||||
|                 controller.reboot(); | ||||
|             } else { | ||||
|                 const statusText = ajax.statusText || ""; | ||||
|                 const body = ajax.responseText || ""; | ||||
|                 toast.error(`OTA update error (${status}${statusText ? ' ' + statusText : ''}): ${body}`); | ||||
|             } | ||||
|         }, false); | ||||
|         ajax.addEventListener("error", () => { | ||||
|             controller.progressview.removeProgress("ota_upload") | ||||
|             toast.error("OTA upload failed due to a network error."); | ||||
|         }, false); | ||||
|         ajax.addEventListener("abort", () => { | ||||
|             controller.progressview.removeProgress("ota_upload") | ||||
|             toast.error("OTA upload was aborted."); | ||||
|         }, false); | ||||
|         ajax.open("POST", PUBLIC_URL + "/ota"); | ||||
|         ajax.send(file); | ||||
|     } | ||||
|  | ||||
|     async version(): Promise<void> { | ||||
|         controller.progressview.addIndeterminate("version", "Getting buildVersion") | ||||
|         const response = await fetch(PUBLIC_URL + "/version"); | ||||
|         const json = await response.json(); | ||||
|         const versionInfo = json as VersionInfo; | ||||
|         controller.progressview.removeProgress("version"); | ||||
|         controller.firmWareView.setVersion(versionInfo); | ||||
|     } | ||||
|  | ||||
|     getBackupConfig() { | ||||
|         controller.progressview.addIndeterminate("get_backup_config", "Downloading Backup") | ||||
|         fetch(PUBLIC_URL + "/get_backup_config") | ||||
|             .then(response => response.text()) | ||||
|             .then(loaded => { | ||||
|                 controller.progressview.removeProgress("get_backup_config") | ||||
|                 controller.submitView.setBackupJson(loaded); | ||||
|             }) | ||||
|     } | ||||
|  | ||||
|     async downloadConfig(): Promise<void> { | ||||
|         controller.progressview.addIndeterminate("get_config", "Downloading Config") | ||||
|         const response = await fetch(PUBLIC_URL + "/get_config"); | ||||
|         const loaded = await response.json(); | ||||
|         const currentConfig = loaded as PlantControllerConfig; | ||||
|         controller.setInitialConfig(currentConfig); | ||||
|         controller.setConfig(currentConfig); | ||||
|         //sync json view initially | ||||
|         controller.configChanged(); | ||||
|         controller.progressview.removeProgress("get_config"); | ||||
|     } | ||||
|  | ||||
|     setInitialConfig(currentConfig: PlantControllerConfig) { | ||||
|         this.initialConfig = currentConfig | ||||
|     } | ||||
|  | ||||
|     uploadConfig(json: string, statusCallback: (status: string) => void) { | ||||
|         controller.progressview.addIndeterminate("set_config", "Uploading Config") | ||||
|         fetch(PUBLIC_URL + "/set_config", { | ||||
|             method: "POST", | ||||
|             body: json, | ||||
|         }) | ||||
|           .then(response => response.text()) | ||||
|           .then(text => statusCallback(text)) | ||||
|           .then( _ => { | ||||
|             controller.progressview.removeProgress("set_config"); | ||||
|             setTimeout(() => { controller.downloadConfig() }, 250) | ||||
|           }) | ||||
|     } | ||||
|  | ||||
|     async backupConfig(json: string): Promise<string> { | ||||
|         const response = await fetch(PUBLIC_URL + "/backup_config", { | ||||
|             method: "POST", | ||||
|             body: json, | ||||
|         }); | ||||
|         return await response.text(); | ||||
|     } | ||||
|  | ||||
|     syncRTCFromBrowser() { | ||||
|         controller.progressview.addIndeterminate("write_rtc", "Writing RTC") | ||||
|         const value: SetTime = { | ||||
|             time: new Date().toISOString() | ||||
|         }; | ||||
|         const pretty = JSON.stringify(value, undefined, 1); | ||||
|         fetch(PUBLIC_URL + "/time", { | ||||
|             method: "POST", | ||||
|             body: pretty | ||||
|         }).then( | ||||
|             _ => controller.progressview.removeProgress("write_rtc") | ||||
|         ) | ||||
|     } | ||||
|  | ||||
|     configChanged() { | ||||
|         const current = controller.getConfig(); | ||||
|         var pretty = JSON.stringify(current, undefined, 0); | ||||
|         controller.submitView.setJson(pretty); | ||||
|  | ||||
|  | ||||
|         if (deepEqual(current, controller.initialConfig)) { | ||||
|             document.title = "PlantCtrl" | ||||
|         } else { | ||||
|             document.title = "*PlantCtrl" | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     selfTest() { | ||||
|         fetch(PUBLIC_URL + "/boardtest", { | ||||
|             method: "POST" | ||||
|         }) | ||||
|     } | ||||
|  | ||||
|     testNightLamp(active: boolean) { | ||||
|         const body: NightLampCommand = { | ||||
|             active: active | ||||
|         }; | ||||
|         var pretty = JSON.stringify(body, undefined, 1); | ||||
|         fetch(PUBLIC_URL + "/lamptest", { | ||||
|             method: "POST", | ||||
|             body: pretty | ||||
|         }) | ||||
|     } | ||||
|  | ||||
|     testPlant(plantId: number) { | ||||
|         let counter = 0 | ||||
|         let limit = 30 | ||||
|         controller.progressview.addProgress("test_pump", counter / limit * 100, "Testing pump " + (plantId + 1) + " for " + (limit - counter) + "s") | ||||
|  | ||||
|         let timerId: string | number | NodeJS.Timeout | undefined | ||||
|  | ||||
|         function updateProgress() { | ||||
|             counter++; | ||||
|             controller.progressview.addProgress("test_pump", counter / limit * 100, "Testing pump " + (plantId + 1) + " for " + (limit - counter) + "s") | ||||
|             timerId = setTimeout(updateProgress, 1000); | ||||
|  | ||||
|         } | ||||
|  | ||||
|         timerId = setTimeout(updateProgress, 1000); | ||||
|  | ||||
|         var body: TestPump = { | ||||
|             pump: plantId | ||||
|         } | ||||
|         var pretty = JSON.stringify(body, undefined, 1); | ||||
|  | ||||
|         fetch(PUBLIC_URL + "/pumptest", { | ||||
|             method: "POST", | ||||
|             body: pretty | ||||
|         }) | ||||
|             .then(response => response.json() as Promise<PumpTestResult>) | ||||
|             .then( | ||||
|                 response => { | ||||
|                     controller.plantViews.setPumpTestCurrent(plantId, response); | ||||
|                     clearTimeout(timerId); | ||||
|                     controller.progressview.removeProgress("test_pump"); | ||||
|                 } | ||||
|             ) | ||||
|     } | ||||
|  | ||||
|     async detectSensors() { | ||||
|         let counter = 0 | ||||
|         let limit = 5 | ||||
|         controller.progressview.addProgress("detect_sensors", counter / limit * 100, "Detecting sensors " + (limit - counter) + "s") | ||||
|  | ||||
|         let timerId: string | number | NodeJS.Timeout | undefined | ||||
|  | ||||
|         function updateProgress() { | ||||
|             counter++; | ||||
|             controller.progressview.addProgress("detect_sensors", counter / limit * 100, "Detecting sensors " + (limit - counter) + "s") | ||||
|             timerId = setTimeout(updateProgress, 1000); | ||||
|         } | ||||
|  | ||||
|         timerId = setTimeout(updateProgress, 1000); | ||||
|  | ||||
|         fetch(PUBLIC_URL + "/detect_sensors", { method: "POST" }) | ||||
|             .then(response => response.json()) | ||||
|             .then(json => { | ||||
|                 clearTimeout(timerId); | ||||
|                 controller.progressview.removeProgress("detect_sensors"); | ||||
|                 const pretty = JSON.stringify(json); | ||||
|                 toast.info("Detection result: " + pretty); | ||||
|             }) | ||||
|             .catch(error => { | ||||
|                 clearTimeout(timerId); | ||||
|                 controller.progressview.removeProgress("detect_sensors"); | ||||
|                 toast.error("Autodetect failed: " + error); | ||||
|             }); | ||||
|     } | ||||
|  | ||||
|     getConfig(): PlantControllerConfig { | ||||
|         return { | ||||
|             hardware: controller.hardwareView.getConfig(), | ||||
|             network: controller.networkView.getConfig(), | ||||
|             tank: controller.tankView.getConfig(), | ||||
|             night_lamp: controller.nightLampView.getConfig(), | ||||
|             plants: controller.plantViews.getConfig(), | ||||
|             timezone: controller.timeView.getTimeZone() | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     scanWifi() { | ||||
|         let counter = 0 | ||||
|         let limit = 5 | ||||
|         controller.progressview.addProgress("scan_ssid", counter / limit * 100, "Scanning for SSIDs for " + (limit - counter) + "s") | ||||
|  | ||||
|         let timerId: string | number | NodeJS.Timeout | undefined | ||||
|  | ||||
|         function updateProgress() { | ||||
|             counter++; | ||||
|             controller.progressview.addProgress("scan_ssid", counter / limit * 100, "Scanning for SSIDs for " + (limit - counter) + "s") | ||||
|             timerId = setTimeout(updateProgress, 1000); | ||||
|  | ||||
|         } | ||||
|  | ||||
|         timerId = setTimeout(updateProgress, 1000); | ||||
|  | ||||
|  | ||||
|         var ajax = new XMLHttpRequest(); | ||||
|         ajax.responseType = 'json'; | ||||
|         ajax.onreadystatechange = () => { | ||||
|             if (ajax.readyState === 4) { | ||||
|                 clearTimeout(timerId); | ||||
|                 controller.progressview.removeProgress("scan_ssid"); | ||||
|                 this.networkView.setScanResult(ajax.response as SSIDList) | ||||
|             } | ||||
|         }; | ||||
|         ajax.onerror = (evt) => { | ||||
|             clearTimeout(timerId); | ||||
|             controller.progressview.removeProgress("scan_ssid"); | ||||
|             alert("Failed to start see console") | ||||
|         } | ||||
|         ajax.open("POST", PUBLIC_URL + "/wifiscan"); | ||||
|         ajax.send(); | ||||
|     } | ||||
|  | ||||
|     setConfig(current: PlantControllerConfig) { | ||||
|         // Show Detect/Test button only for V4 HAL | ||||
|         if (current.hardware && (current.hardware as any).board === "V4") { | ||||
|             this.detectBtn.style.display = "inline-block"; | ||||
|         } else { | ||||
|             this.detectBtn.style.display = "none"; | ||||
|         } | ||||
|         this.tankView.setConfig(current.tank); | ||||
|         this.networkView.setConfig(current.network); | ||||
|         this.nightLampView.setConfig(current.night_lamp); | ||||
|         this.plantViews.setConfig(current.plants); | ||||
|         this.timeView.setTimeZone(current.timezone); | ||||
|         this.hardwareView.setConfig(current.hardware); | ||||
|     } | ||||
|  | ||||
|     measure_moisture() { | ||||
|         let counter = 0 | ||||
|         let limit = 2 | ||||
|         controller.progressview.addProgress("measure_moisture", counter / limit * 100, "Measure Moisture " + (limit - counter) + "s") | ||||
|  | ||||
|         let timerId: string | number | NodeJS.Timeout | undefined | ||||
|  | ||||
|         function updateProgress() { | ||||
|             counter++; | ||||
|             controller.progressview.addProgress("measure_moisture", counter / limit * 100, "Measure Moisture " + (limit - counter) + "s") | ||||
|             timerId = setTimeout(updateProgress, 1000); | ||||
|  | ||||
|         } | ||||
|  | ||||
|         timerId = setTimeout(updateProgress, 1000); | ||||
|  | ||||
|  | ||||
|         fetch(PUBLIC_URL + "/moisture") | ||||
|             .then(response => response.json()) | ||||
|             .then(json => json as Moistures) | ||||
|             .then(time => { | ||||
|                 controller.plantViews.update(time.moisture_a, time.moisture_b) | ||||
|                 clearTimeout(timerId); | ||||
|                 controller.progressview.removeProgress("measure_moisture"); | ||||
|             }) | ||||
|             .catch(error => { | ||||
|                 clearTimeout(timerId); | ||||
|                 controller.progressview.removeProgress("measure_moisture"); | ||||
|                 console.log(error); | ||||
|             }); | ||||
|     } | ||||
|  | ||||
|     exit() { | ||||
|         fetch(PUBLIC_URL + "/exit", { | ||||
|             method: "POST", | ||||
|         }) | ||||
|         controller.progressview.addIndeterminate("rebooting", "Returned to normal mode, you can close this site now") | ||||
|  | ||||
|     } | ||||
|  | ||||
|     waitForReboot() { | ||||
|         console.log("Check if controller online again") | ||||
|         fetch(PUBLIC_URL + "/version", { | ||||
|             method: "GET", | ||||
|             signal: AbortSignal.timeout(5000) | ||||
|         }).then(response => { | ||||
|             if (response.status != 200) { | ||||
|                 console.log("Not reached yet, retrying") | ||||
|                 setTimeout(controller.waitForReboot, 1000) | ||||
|             } else { | ||||
|                 console.log("Reached controller, reloading") | ||||
|                 controller.progressview.addIndeterminate("rebooting", "Reached Controller, reloading") | ||||
|                 setTimeout(function () { | ||||
|                     window.location.reload() | ||||
|                 }, 2000); | ||||
|             } | ||||
|         }) | ||||
|             .catch(err => { | ||||
|                 console.log("Not reached yet, retrying") | ||||
|                 setTimeout(controller.waitForReboot, 1000) | ||||
|             }) | ||||
|     } | ||||
|  | ||||
|     reboot() { | ||||
|         fetch(PUBLIC_URL + "/reboot", { | ||||
|             method: "POST", | ||||
|         }) | ||||
|         controller.progressview.addIndeterminate("rebooting", "Rebooting") | ||||
|         setTimeout(this.waitForReboot, 1000) | ||||
|     } | ||||
|  | ||||
|     initialConfig: PlantControllerConfig | null = null | ||||
|     readonly rebootBtn: HTMLButtonElement | ||||
|     readonly exitBtn: HTMLButtonElement | ||||
|     readonly timeView: TimeView; | ||||
|     readonly plantViews: PlantViews; | ||||
|     readonly networkView: NetworkConfigView; | ||||
|     readonly hardwareView: HardwareConfigView; | ||||
|     readonly tankView: TankConfigView; | ||||
|     readonly nightLampView: NightLampView; | ||||
|     readonly submitView: SubmitView; | ||||
|     readonly firmWareView: OTAView; | ||||
|     readonly progressview: ProgressView; | ||||
|     readonly batteryView: BatteryView; | ||||
|     readonly solarView: SolarView; | ||||
|     readonly fileview: FileView; | ||||
|     readonly logView: LogView | ||||
|     readonly detectBtn: HTMLButtonElement | ||||
|  | ||||
|     constructor() { | ||||
|         this.timeView = new TimeView(this) | ||||
|         this.plantViews = new PlantViews(this) | ||||
|         this.networkView = new NetworkConfigView(this, PUBLIC_URL) | ||||
|         this.tankView = new TankConfigView(this) | ||||
|         this.batteryView = new BatteryView(this) | ||||
|         this.solarView = new SolarView(this) | ||||
|         this.nightLampView = new NightLampView(this) | ||||
|         this.submitView = new SubmitView(this) | ||||
|         this.firmWareView = new OTAView(this) | ||||
|         this.progressview = new ProgressView(this) | ||||
|         this.fileview = new FileView(this) | ||||
|         this.logView = new LogView(this) | ||||
|         this.hardwareView = new HardwareConfigView(this) | ||||
|         this.detectBtn = document.getElementById("detect_sensors") as HTMLButtonElement | ||||
|         this.detectBtn.onclick = () => { controller.detectSensors(); } | ||||
|         this.rebootBtn = document.getElementById("reboot") as HTMLButtonElement | ||||
|         this.rebootBtn.onclick = () => { | ||||
|             controller.reboot(); | ||||
|         } | ||||
|         this.exitBtn = document.getElementById("exit") as HTMLButtonElement | ||||
|         this.exitBtn.onclick = () => { | ||||
|             controller.exit(); | ||||
|         } | ||||
|     } | ||||
| } | ||||
|  | ||||
| const controller = new Controller(); | ||||
| controller.progressview.removeProgress("rebooting"); | ||||
|  | ||||
|  | ||||
| const tasks = [ | ||||
|     {task: controller.populateTimezones, displayString: "Populating Timezones"}, | ||||
|     {task: controller.updateRTCData, displayString: "Updating RTC Data"}, | ||||
|     {task: controller.updateBatteryData, displayString: "Updating Battery Data"}, | ||||
|     {task: controller.updateSolarData, displayString: "Updating Solar Data"}, | ||||
|     {task: controller.downloadConfig, displayString: "Downloading Configuration"}, | ||||
|     {task: controller.version, displayString: "Fetching Version Information"}, | ||||
|     {task: controller.updateFileList, displayString: "Updating File List"}, | ||||
|     {task: controller.getBackupInfo, displayString: "Fetching Backup Information"}, | ||||
|     {task: controller.loadLogLocaleConfig, displayString: "Loading Log Localization Config"}, | ||||
|     {task: controller.loadTankInfo, displayString: "Loading Tank Information"}, | ||||
| ]; | ||||
|  | ||||
| async function executeTasksSequentially() { | ||||
|     let current = 0; | ||||
|     for (const {task, displayString} of tasks) { | ||||
|         current++; | ||||
|         let ratio = current / tasks.length; | ||||
|         controller.progressview.addProgress("initial", ratio * 100, displayString); | ||||
|         try { | ||||
|             await task(); | ||||
|         } catch (error) { | ||||
|             console.error(`Error executing task '${displayString}':`, error); | ||||
|             // Optionally, you can decide whether to continue or break on errors | ||||
|             break; | ||||
|         } | ||||
|     } | ||||
| } | ||||
|  | ||||
| executeTasksSequentially().then(r => { | ||||
|     controller.progressview.removeProgress("initial") | ||||
| }); | ||||
|  | ||||
| controller.progressview.removeProgress("rebooting"); | ||||
|  | ||||
| window.addEventListener("beforeunload", (event) => { | ||||
|     const currentConfig = controller.getConfig(); | ||||
|  | ||||
|     // Check if the current state differs from the initial configuration | ||||
|     if (!deepEqual(currentConfig, controller.initialConfig)) { | ||||
|         const confirmationMessage = "You have unsaved changes. Are you sure you want to leave this page?"; | ||||
|  | ||||
|         // Standard behavior for displaying the confirmation dialog | ||||
|         event.preventDefault(); | ||||
|         event.returnValue = confirmationMessage; // This will trigger the browser's default dialog | ||||
|         return confirmationMessage; | ||||
|     } | ||||
| }); | ||||
| @@ -1,91 +0,0 @@ | ||||
| <style> | ||||
|   .basicnetworkkey{ | ||||
|     min-width: 200px; | ||||
|   } | ||||
|   .basicnetworkvalue{ | ||||
|     flex-grow: 1; | ||||
|   } | ||||
|   .basicnetworkkeyssid1{ | ||||
|     flex-grow: 1; | ||||
|   } | ||||
|   .basicnetworkkeyssid2{ | ||||
|     min-width: 50px;  | ||||
|     flex-grow: 1; | ||||
|   } | ||||
|  | ||||
|   .mqttkey{ | ||||
|     min-width: 100px; | ||||
|   } | ||||
|   .mqttvalue{ | ||||
|     flex-grow: 1; | ||||
|   } | ||||
| </style> | ||||
| <div> | ||||
|     <div class="flexcontainer"> | ||||
|         <div class="subcontainer"> | ||||
|             <div class="subtitle">Basic network</div> | ||||
|             <div class="flexcontainer"> | ||||
|                 <span class="basicnetworkkey">Api Redirection to:</span> | ||||
|                 <span class="basicnetworkvalue" id="remote_ip">remote ip</span> | ||||
|             </div> | ||||
|              | ||||
|             <div class="flexcontainer"> | ||||
|                 <label class="basicnetworkkey" for="ap_ssid">AP SSID:</label> | ||||
|                 <input class="basicnetworkvalue" type="text" id="ap_ssid" list="ssidlist"> | ||||
|             </div> | ||||
|          | ||||
|             <div class="flexcontainer"> | ||||
|                 <label class="basicnetworkkey" for="ssid">Station Mode:</label> | ||||
|                 <input class="basicnetworkkeyssid1" type="search" id="ssid" list="ssidlist"> | ||||
|                 <datalist id="ssidlist"> | ||||
|                     <option value="Not scanned yet"> | ||||
|                 </datalist> | ||||
|                 <input class="basicnetworkkeyssid2" type="button" id="scan" value="Scan"> | ||||
|             </div> | ||||
|  | ||||
|             <div class="flexcontainer"> | ||||
|                 <label class="basicnetworkkey" for="max_wait">Max wait:</label> | ||||
|                 <input class="basicnetworkvalue" type="number" id="max_wait"> | ||||
|             </div> | ||||
|          | ||||
|             <div class="flexcontainer"> | ||||
|                 <label class="basicnetworkkey" for="ssid">Password:</label> | ||||
|                 <input class="basicnetworkvalue" type="text" id="password">     | ||||
|             </div> | ||||
|         </div> | ||||
|         <div class="subcontainer"> | ||||
|             <div class="flexcontainer"> | ||||
|                 <div class="subtitle"> | ||||
|                     Mqtt Reporting | ||||
|                 </div> | ||||
|             </div> | ||||
|              | ||||
|             <div class="flexcontainer"> | ||||
|                 <div class="mqttkey"> | ||||
|                     MQTT Url | ||||
|                 </div> | ||||
|                 <input class="mqttvalue" type="text" id="mqtt_url" placeholder="mqtt://192.168.1.1:1883"> | ||||
|             </div> | ||||
|             <div class="flexcontainer"> | ||||
|                 <div class="mqttkey"> | ||||
|                     Base Topic | ||||
|                 </div> | ||||
|                 <input class="mqttvalue" type="text" id="base_topic" placeholder="plants/one">         | ||||
|             </div> | ||||
|             <div class="flexcontainer"> | ||||
|                 <div class="mqttkey"> | ||||
|                     MQTT User | ||||
|                 </div> | ||||
|                 <input class="mqttvalue" type="text" id="mqtt_user" placeholder=""> | ||||
|             </div> | ||||
|             <div class="flexcontainer"> | ||||
|                 <div class="mqttkey"> | ||||
|                     MQTT Password | ||||
|                 </div> | ||||
|                 <input class="mqttvalue" type="text" id="mqtt_password" placeholder=""> | ||||
|             </div> | ||||
|         </div> | ||||
|     </div> | ||||
|      | ||||
|  | ||||
| </div> | ||||
| @@ -1,78 +0,0 @@ | ||||
| import { Controller } from "./main"; | ||||
| import {NetworkConfig, SSIDList} from "./api"; | ||||
|  | ||||
| export class NetworkConfigView { | ||||
|     setScanResult(ssidList: SSIDList) { | ||||
|       this.ssidlist.innerHTML = '' | ||||
|       for (const ssid of ssidList.ssids) { | ||||
|           const wi = document.createElement("option"); | ||||
|           wi.value = ssid; | ||||
|         this.ssidlist.appendChild(wi); | ||||
|       } | ||||
|     } | ||||
|     private readonly ap_ssid: HTMLInputElement; | ||||
|     private readonly ssid: HTMLInputElement; | ||||
|     private readonly password: HTMLInputElement; | ||||
|     private readonly mqtt_url: HTMLInputElement; | ||||
|     private readonly base_topic: HTMLInputElement; | ||||
|     private readonly max_wait: HTMLInputElement; | ||||
|     private readonly mqtt_user: HTMLInputElement; | ||||
|     private readonly mqtt_password: HTMLInputElement; | ||||
|     private readonly ssidlist: HTMLElement; | ||||
|      | ||||
|     constructor(controller: Controller, publicIp: string) { | ||||
|       (document.getElementById("network_view") as HTMLElement).innerHTML = require('./network.html') as string; | ||||
|  | ||||
|       (document.getElementById("remote_ip") as HTMLElement).innerText = publicIp; | ||||
|  | ||||
|       this.ap_ssid = (document.getElementById("ap_ssid") as HTMLInputElement); | ||||
|       this.ap_ssid.onchange = controller.configChanged | ||||
|    | ||||
|       this.ssid = (document.getElementById("ssid") as HTMLInputElement); | ||||
|       this.ssid.onchange = controller.configChanged | ||||
|       this.password = (document.getElementById("password") as HTMLInputElement); | ||||
|       this.password.onchange = controller.configChanged | ||||
|       this.max_wait = (document.getElementById("max_wait") as HTMLInputElement); | ||||
|       this.max_wait.onchange = controller.configChanged | ||||
|  | ||||
|       this.mqtt_url = document.getElementById("mqtt_url") as HTMLInputElement; | ||||
|       this.mqtt_url.onchange = controller.configChanged | ||||
|       this.base_topic = document.getElementById("base_topic") as HTMLInputElement; | ||||
|       this.base_topic.onchange = controller.configChanged | ||||
|       this.mqtt_user = document.getElementById("mqtt_user") as HTMLInputElement; | ||||
|       this.mqtt_user.onchange = controller.configChanged | ||||
|       this.mqtt_password = document.getElementById("mqtt_password") as HTMLInputElement; | ||||
|       this.mqtt_password.onchange = controller.configChanged | ||||
|  | ||||
|       this.ssidlist = document.getElementById("ssidlist") as HTMLElement | ||||
|  | ||||
|       let scanWifiBtn = document.getElementById("scan") as HTMLButtonElement; | ||||
|       scanWifiBtn.onclick = function (){ | ||||
|         controller.scanWifi(); | ||||
|       } | ||||
|     } | ||||
|  | ||||
|     setConfig(network: NetworkConfig) { | ||||
|       this.ap_ssid.value = network.ap_ssid; | ||||
|       this.ssid.value = network.ssid; | ||||
|       this.password.value = network.password; | ||||
|       this.mqtt_url.value = network.mqtt_url; | ||||
|       this.base_topic.value = network.base_topic; | ||||
|       this.mqtt_user.value = network.mqtt_user ?? ""; | ||||
|       this.mqtt_password.value = network.mqtt_password ?? ""; | ||||
|       this.max_wait.value = network.max_wait.toString(); | ||||
|     } | ||||
|      | ||||
|     getConfig(): NetworkConfig { | ||||
|       return { | ||||
|         max_wait: +this.max_wait.value, | ||||
|         ap_ssid: this.ap_ssid.value, | ||||
|         ssid: this.ssid.value ?? null, | ||||
|         password: this.password.value ?? null, | ||||
|         mqtt_url: this.mqtt_url.value ?? null, | ||||
|         mqtt_user: this.mqtt_user.value ? this.mqtt_user.value : null, | ||||
|         mqtt_password: this.mqtt_password.value ? this.mqtt_password.value : null, | ||||
|         base_topic: this.base_topic.value ?? null | ||||
|       } | ||||
|     } | ||||
|   } | ||||
| @@ -1,48 +0,0 @@ | ||||
| <style> | ||||
|   .lightcheckbox{ | ||||
|     margin: 0px; | ||||
|     min-width: 20px | ||||
|   } | ||||
|   .lightkey{ | ||||
|     min-width: 200px; | ||||
|   } | ||||
|   .lightvalue{ | ||||
|     flex-grow: 1; | ||||
|   } | ||||
|   .lightnumberbox{ | ||||
|     min-width: 50px; | ||||
|     flex-grow: 1; | ||||
|   } | ||||
| </style> | ||||
|  | ||||
| <div class="subtitle">Light:</div> | ||||
| <div class="flexcontainer">  | ||||
|   <div class="lightkey">Test Nightlight</div> | ||||
|   <input class="lightcheckbox" type="checkbox" id="night_lamp_test"> | ||||
| </div> | ||||
| <div class="flexcontainer"> | ||||
|     <div class="lightkey">Enable Nightlight</div> | ||||
|     <input class="lightcheckbox" type="checkbox" id="night_lamp_enabled"> | ||||
| </div> | ||||
| <div class="flexcontainer"> | ||||
|     <div class="lightkey">Light only when dark</div> | ||||
|     <input class="lightcheckbox" type="checkbox" id="night_lamp_only_when_dark"> | ||||
| </div> | ||||
| <div class="flexcontainer"> | ||||
|     <div class="lightkey">Start</div> | ||||
|     <select class="lightnumberbox" type="time" id="night_lamp_time_start"> | ||||
|     </select> | ||||
| </div> | ||||
| <div class="flexcontainer"> | ||||
|     <div class="lightkey">Stop</div> | ||||
|     <select class="lightnumberbox" type="time" id="night_lamp_time_end"> | ||||
|     </select> | ||||
| </div> | ||||
| <div class="flexcontainer"> | ||||
|   <div class="lightkey">Disable if Battery below %</div> | ||||
|   <input class="lightcheckbox" type="number" id="night_lamp_soc_low" min="0" max="100"> | ||||
| </div> | ||||
| <div class="flexcontainer"> | ||||
|   <div class="lightkey">Reenable if Battery higher %</div> | ||||
|   <input class="lightcheckbox" type="number" id="night_lamp_soc_restore" min="0" max="100"> | ||||
| </div> | ||||
| @@ -1,76 +0,0 @@ | ||||
| import { Controller } from "./main"; | ||||
| import {NightLampConfig} from "./api"; | ||||
|  | ||||
| export class NightLampView { | ||||
|     private readonly night_lamp_only_when_dark: HTMLInputElement; | ||||
|     private readonly night_lamp_time_start: HTMLSelectElement; | ||||
|     private readonly night_lamp_time_end: HTMLSelectElement; | ||||
|     private readonly night_lamp_test: HTMLInputElement; | ||||
|     private readonly night_lamp_enabled: HTMLInputElement; | ||||
|     private readonly night_lamp_soc_low: HTMLInputElement; | ||||
|     private readonly night_lamp_soc_restore: HTMLInputElement; | ||||
|  | ||||
|     constructor(controller:Controller){ | ||||
|       (document.getElementById("lightview") as HTMLElement).innerHTML = require('./nightlightview.html') as string; | ||||
|  | ||||
|       this.night_lamp_only_when_dark = document.getElementById("night_lamp_only_when_dark") as HTMLInputElement; | ||||
|       this.night_lamp_only_when_dark.onchange = controller.configChanged | ||||
|  | ||||
|       this.night_lamp_enabled = document.getElementById("night_lamp_enabled") as HTMLInputElement; | ||||
|       this.night_lamp_enabled.onchange = controller.configChanged | ||||
|  | ||||
|       this.night_lamp_soc_low = document.getElementById("night_lamp_soc_low") as HTMLInputElement; | ||||
|       this.night_lamp_soc_low.onchange = controller.configChanged | ||||
|  | ||||
|       this.night_lamp_soc_restore = document.getElementById("night_lamp_soc_restore") as HTMLInputElement; | ||||
|       this.night_lamp_soc_restore.onchange = controller.configChanged | ||||
|  | ||||
|       this.night_lamp_time_start = document.getElementById("night_lamp_time_start") as HTMLSelectElement; | ||||
|       this.night_lamp_time_start.onchange = controller.configChanged | ||||
|       for (let i = 0; i < 24; i++) { | ||||
|         let option = document.createElement("option"); | ||||
|         if (i == 20){ | ||||
|             option.selected = true | ||||
|         } | ||||
|         option.innerText = i.toString(); | ||||
|         this.night_lamp_time_start.appendChild(option); | ||||
|       } | ||||
|       this.night_lamp_time_end = document.getElementById("night_lamp_time_end") as HTMLSelectElement; | ||||
|       this.night_lamp_time_end.onchange = controller.configChanged | ||||
|    | ||||
|       for (let i = 0; i < 24; i++) { | ||||
|         let option = document.createElement("option"); | ||||
|         if (i == 1){ | ||||
|             option.selected = true | ||||
|         } | ||||
|         option.innerText = i.toString(); | ||||
|         this.night_lamp_time_end.appendChild(option); | ||||
|       } | ||||
|  | ||||
|       let night_lamp_test = document.getElementById("night_lamp_test") as HTMLInputElement; | ||||
|       this.night_lamp_test = night_lamp_test | ||||
|       this.night_lamp_test.onchange = () => { | ||||
|         controller.testNightLamp(night_lamp_test.checked) | ||||
|       } | ||||
|     } | ||||
|    | ||||
|     setConfig(nightLamp: NightLampConfig) { | ||||
|       this.night_lamp_only_when_dark.checked = nightLamp.night_lamp_only_when_dark | ||||
|       this.night_lamp_time_start.value = nightLamp.night_lamp_hour_start.toString(); | ||||
|       this.night_lamp_time_end.value = nightLamp.night_lamp_hour_end.toString(); | ||||
|       this.night_lamp_enabled.checked = nightLamp.enabled; | ||||
|       this.night_lamp_soc_low.value = nightLamp.low_soc_cutoff.toString(); | ||||
|       this.night_lamp_soc_restore.value = nightLamp.low_soc_restore.toString(); | ||||
|     } | ||||
|    | ||||
|     getConfig(): NightLampConfig { | ||||
|       return { | ||||
|         night_lamp_hour_start: +this.night_lamp_time_start.value, | ||||
|         night_lamp_hour_end: +this.night_lamp_time_end.value, | ||||
|         night_lamp_only_when_dark: this.night_lamp_only_when_dark.checked, | ||||
|         enabled: this.night_lamp_enabled.checked, | ||||
|         low_soc_cutoff: +this.night_lamp_soc_low.value, | ||||
|         low_soc_restore: +this.night_lamp_soc_restore.value | ||||
|       } | ||||
|     } | ||||
|   } | ||||
| @@ -1,53 +0,0 @@ | ||||
| <style> | ||||
|     .otakey { | ||||
|         min-width: 100px; | ||||
|     } | ||||
|  | ||||
|     .otavalue { | ||||
|         flex-grow: 1; | ||||
|     } | ||||
|  | ||||
|     .otaform { | ||||
|         min-width: 100px; | ||||
|         flex-grow: 1; | ||||
|     } | ||||
|  | ||||
|     .otachooser { | ||||
|         min-width: 100px; | ||||
|         width: 100%; | ||||
|     } | ||||
| </style> | ||||
| <div class="flexcontainer"> | ||||
|     <div class="subtitle"> | ||||
|         Current Firmware | ||||
|     </div> | ||||
| </div> | ||||
| <div class="flexcontainer"> | ||||
|     <span class="otakey">Buildtime:</span> | ||||
|     <span class="otavalue" id="firmware_buildtime"></span> | ||||
| </div> | ||||
| <div class="flexcontainer"> | ||||
|     <span class="otakey">Buildhash:</span> | ||||
|     <span class="otavalue" id="firmware_githash"></span> | ||||
| </div> | ||||
| <div class="flexcontainer"> | ||||
|     <span class="otakey">Partition:</span> | ||||
|     <span class="otavalue" id="firmware_partition"></span> | ||||
| </div> | ||||
| <div class="flexcontainer"> | ||||
|     <span class="otakey">State0:</span> | ||||
|     <span class="otavalue" id="firmware_state0"></span> | ||||
| </div> | ||||
| <div class="flexcontainer"> | ||||
|     <span class="otakey">State1:</span> | ||||
|     <span class="otavalue" id="firmware_state1"></span> | ||||
| </div> | ||||
|  | ||||
| <div class="flexcontainer"> | ||||
|     <form class="otaform" id="upload_form" method="post"> | ||||
|         <input class="otachooser" type="file" name="file1" id="firmware_file"><br> | ||||
|     </form> | ||||
| </div> | ||||
| <div class="display:flex"> | ||||
|     <button style="margin-left: 16px; margin-top: 8px;" class="col-6" type="button" id="test">Self-Test</button> | ||||
| </div> | ||||
| @@ -1,48 +0,0 @@ | ||||
| import {Controller} from "./main"; | ||||
| import {VersionInfo} from "./api"; | ||||
|  | ||||
| export class OTAView { | ||||
|     readonly file1Upload: HTMLInputElement; | ||||
|     readonly firmware_buildtime: HTMLDivElement; | ||||
|     readonly firmware_githash: HTMLDivElement; | ||||
|     readonly firmware_partition: HTMLDivElement; | ||||
|     readonly firmware_state0: HTMLDivElement; | ||||
|     readonly firmware_state1: HTMLDivElement; | ||||
|  | ||||
|     constructor(controller: Controller) { | ||||
|         (document.getElementById("firmwareview") as HTMLElement).innerHTML = require("./ota.html") | ||||
|  | ||||
|         let test = document.getElementById("test") as HTMLButtonElement; | ||||
|  | ||||
|         this.firmware_buildtime = document.getElementById("firmware_buildtime") as HTMLDivElement; | ||||
|         this.firmware_githash = document.getElementById("firmware_githash") as HTMLDivElement; | ||||
|         this.firmware_partition = document.getElementById("firmware_partition") as HTMLDivElement; | ||||
|  | ||||
|         this.firmware_state0 = document.getElementById("firmware_state0") as HTMLDivElement; | ||||
|         this.firmware_state1 = document.getElementById("firmware_state1") as HTMLDivElement; | ||||
|  | ||||
|  | ||||
|         const file = document.getElementById("firmware_file") as HTMLInputElement; | ||||
|         this.file1Upload = file | ||||
|         this.file1Upload.onchange = () => { | ||||
|             const selectedFile = file.files?.[0]; | ||||
|             if (selectedFile == null) { | ||||
|                 //TODO error dialog here | ||||
|                 return | ||||
|             } | ||||
|             controller.uploadNewFirmware(selectedFile); | ||||
|         }; | ||||
|  | ||||
|         test.onclick = () => { | ||||
|             controller.selfTest(); | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     setVersion(versionInfo: VersionInfo) { | ||||
|         this.firmware_buildtime.innerText = versionInfo.build_time; | ||||
|         this.firmware_githash.innerText = versionInfo.git_hash; | ||||
|         this.firmware_partition.innerText = versionInfo.current; | ||||
|         this.firmware_state0.innerText = versionInfo.slot0_state; | ||||
|         this.firmware_state1.innerText = versionInfo.slot1_state; | ||||
|     } | ||||
| } | ||||
| @@ -1,162 +0,0 @@ | ||||
| <style> | ||||
|     .plantsensorkey { | ||||
|         min-width: 100px; | ||||
|     } | ||||
|  | ||||
|     .plantsensorvalue { | ||||
|         flex-grow: 1; | ||||
|     } | ||||
|  | ||||
|     .plantkey { | ||||
|         min-width: 195px; | ||||
|     } | ||||
|  | ||||
|     .plantvalue { | ||||
|         flex-grow: 1; | ||||
|     } | ||||
|  | ||||
|     .plantcheckbox { | ||||
|         min-width: 20px; | ||||
|         margin: 0; | ||||
|     } | ||||
|  | ||||
|     .plantTargetEnabledOnly_ ${plantId} { | ||||
|     } | ||||
|  | ||||
|     .plantPumpEnabledOnly_ ${plantId} { | ||||
|     } | ||||
|  | ||||
|     .plantSensorEnabledOnly_ ${plantId} { | ||||
|     } | ||||
|  | ||||
|     .plantHidden_ ${plantId} { | ||||
|         display: none; | ||||
|     } | ||||
| </style> | ||||
|  | ||||
|  | ||||
| <div> | ||||
|     <div class="subtitle" | ||||
|          id="plant_${plantId}_header"> | ||||
|         Plant ${plantId} | ||||
|     </div> | ||||
|     <div class="flexcontainer"> | ||||
|         <div class="plantkey">Sensor A installed:</div> | ||||
|         <input class="plantcheckbox" id="plant_${plantId}_sensor_a" type="checkbox"> | ||||
|     </div> | ||||
|     <div class="flexcontainer"> | ||||
|         <div class="plantkey">Sensor B installed:</div> | ||||
|         <input class="plantcheckbox" id="plant_${plantId}_sensor_b" type="checkbox"> | ||||
|     </div> | ||||
|     <div class="flexcontainer"> | ||||
|         <div class="plantkey"> | ||||
|             Mode: | ||||
|         </div> | ||||
|         <select class="plantvalue" id="plant_${plantId}_mode"> | ||||
|             <option value="OFF">Off</option> | ||||
|             <option value="TargetMoisture">Target</option> | ||||
|             <option value="MinMoisture">Min Moisture</option> | ||||
|             <option value="TimerOnly">Timer</option> | ||||
|         </select> | ||||
|  | ||||
|     </div> | ||||
|     <div class="flexcontainer plantTargetEnabledOnly_${plantId}"> | ||||
|         <div class="plantkey">Target Moisture:</div> | ||||
|         <input class="plantvalue" id="plant_${plantId}_target_moisture" type="number" min="0" max="100" placeholder="0"> | ||||
|     </div> | ||||
|     <div class="flexcontainer plantMinEnabledOnly_${plantId}"> | ||||
|         <div class="plantkey">Minimum Moisture:</div> | ||||
|         <input class="plantvalue" id="plant_${plantId}_min_moisture" type="number" min="0" max="100" placeholder="0"> | ||||
|     </div> | ||||
|     <div class="flexcontainer plantPumpEnabledOnly_${plantId}"> | ||||
|         <div class="plantkey">Pump Time (s):</div> | ||||
|         <input class="plantvalue" id="plant_${plantId}_pump_time_s" type="number" min="0" max="600" placeholder="30"> | ||||
|     </div> | ||||
|  | ||||
|     <div class="flexcontainer plantPumpEnabledOnly_${plantId}"> | ||||
|         <div class="plantkey">Pump Cooldown (m):</div> | ||||
|         <input class="plantvalue" id="plant_${plantId}_pump_cooldown_min" type="number" min="0" max="600" | ||||
|                placeholder="30"> | ||||
|     </div> | ||||
|     <div class="flexcontainer plantPumpEnabledOnly_${plantId}"> | ||||
|         <div class="plantkey">"Pump Hour Start":</div> | ||||
|         <select class="plantvalue" id="plant_${plantId}_pump_hour_start">10</select> | ||||
|     </div> | ||||
|     <div class="flexcontainer plantPumpEnabledOnly_${plantId}"> | ||||
|         <div class="plantkey">"Pump Hour End":</div> | ||||
|         <select class="plantvalue" id="plant_${plantId}_pump_hour_end">19</select> | ||||
|     </div> | ||||
|     <div class="flexcontainer plantTargetEnabledOnly_${plantId}"> | ||||
|         <div class="plantkey">Warn Pump Count:</div> | ||||
|         <input class="plantvalue" id="plant_${plantId}_max_consecutive_pump_count" type="number" min="1" max="50" | ||||
|                placeholder="10"> | ||||
|     </div> | ||||
|     <div class="flexcontainer plantSensorEnabledOnly_${plantId}"> | ||||
|         <div class="plantkey">Min Frequency Override</div> | ||||
|         <input class="plantvalue" id="plant_${plantId}_min_frequency" type="number" min="1000" max="25000"> | ||||
|     </div> | ||||
|     <div class="flexcontainer plantSensorEnabledOnly_${plantId}"> | ||||
|         <div class="plantkey">Max Frequency Override</div> | ||||
|         <input class="plantvalue" id="plant_${plantId}_max_frequency" type="number" min="1000" max="25000"> | ||||
|     </div> | ||||
|  | ||||
|  | ||||
|     <div class="flexcontainer plantPumpEnabledOnly_${plantId}"> | ||||
|         <h2 class="plantkey">Current config:</h2> | ||||
|     </div> | ||||
|     <div class="flexcontainer plantPumpEnabledOnly_${plantId}"> | ||||
|         <div class="plantkey">Min current</div> | ||||
|         <input class="plantvalue" id="plant_${plantId}_min_pump_current_ma" type="number" min="0" max="4500"> | ||||
|     </div> | ||||
|     <div class="flexcontainer plantPumpEnabledOnly_${plantId}"> | ||||
|         <div class="plantkey">Max current</div> | ||||
|         <input class="plantvalue" id="plant_${plantId}_max_pump_current_ma" type="number" min="0" max="4500"> | ||||
|     </div> | ||||
|     <div class="flexcontainer plantPumpEnabledOnly_${plantId}"> | ||||
|         <div class="plantkey">Ignore current sensor error</div> | ||||
|         <input class="plantcheckbox" id="plant_${plantId}_ignore_current_error" type="checkbox"> | ||||
|     </div> | ||||
|  | ||||
|  | ||||
|     <div class="flexcontainer plantPumpEnabledOnly_${plantId}"> | ||||
|         <button class="subtitle" id="plant_${plantId}_test">Test Pump</button> | ||||
|     </div> | ||||
|  | ||||
|     <div class="flexcontainer plantSensorEnabledOnly_${plantId}"> | ||||
|         <div class="subtitle">Live:</div> | ||||
|     </div> | ||||
|     <div class="flexcontainer plantSensorEnabledOnly_${plantId}"> | ||||
|         <span class="plantsensorkey">Sensor A:</span> | ||||
|         <span class="plantsensorvalue" id="plant_${plantId}_moisture_a">not measured</span> | ||||
|     </div> | ||||
|     <div class="flexcontainer plantSensorEnabledOnly_${plantId}"> | ||||
|         <div class="plantsensorkey">Sensor B:</div> | ||||
|         <span class="plantsensorvalue" id="plant_${plantId}_moisture_b">not measured</span> | ||||
|     </div> | ||||
|     <div class="flexcontainer plantPumpEnabledOnly_${plantId}"> | ||||
|         <div class="plantsensorkey">Max Current</div> | ||||
|         <span class="plantsensorvalue" id="plant_${plantId}_pump_test_current_max">not_tested</span> | ||||
|     </div> | ||||
|     <div class="flexcontainer plantPumpEnabledOnly_${plantId}"> | ||||
|         <div class="plantsensorkey">Min Current</div> | ||||
|         <span class="plantsensorvalue" id="plant_${plantId}_pump_test_current_min">not_tested</span> | ||||
|     </div> | ||||
|     <div class="flexcontainer plantPumpEnabledOnly_${plantId}"> | ||||
|         <div class="plantsensorkey">Average</div> | ||||
|         <span class="plantsensorvalue" id="plant_${plantId}_pump_test_current_average">not_tested</span> | ||||
|     </div> | ||||
|     <div class="flexcontainer plantPumpEnabledOnly_${plantId}"> | ||||
|         <div class="plantsensorkey">Pump Time</div> | ||||
|         <span class="plantsensorvalue" id="plant_${plantId}_pump_test_pump_time">not_tested</span> | ||||
|     </div> | ||||
|     <div class="flexcontainer plantPumpEnabledOnly_${plantId}"> | ||||
|         <div class="plantsensorkey">Flow ml</div> | ||||
|         <span class="plantsensorvalue" id="plant_${plantId}_pump_test_flow_ml">not_tested</span> | ||||
|     </div> | ||||
|     <div class="flexcontainer plantPumpEnabledOnly_${plantId}"> | ||||
|         <div class="plantsensorkey">Flow raw</div> | ||||
|         <span class="plantsensorvalue" id="plant_${plantId}_pump_test_flow_raw">not_tested</span> | ||||
|     </div> | ||||
|  | ||||
|  | ||||
| </div> | ||||
| @@ -1,317 +0,0 @@ | ||||
| import {PlantConfig, PumpTestResult} from "./api"; | ||||
|  | ||||
| const PLANT_COUNT = 8; | ||||
|  | ||||
|  | ||||
| import {Controller} from "./main"; | ||||
|  | ||||
| export class PlantViews { | ||||
|     private readonly measure_moisture: HTMLButtonElement; | ||||
|     private readonly plants: PlantView[] = [] | ||||
|     private readonly plantsDiv: HTMLDivElement | ||||
|  | ||||
|     constructor(syncConfig: Controller) { | ||||
|         this.measure_moisture = document.getElementById("measure_moisture") as HTMLButtonElement | ||||
|         this.measure_moisture.onclick = syncConfig.measure_moisture | ||||
|         this.plantsDiv = document.getElementById("plants") as HTMLDivElement; | ||||
|         for (let plantId = 0; plantId < PLANT_COUNT; plantId++) { | ||||
|             this.plants[plantId] = new PlantView(plantId, this.plantsDiv, syncConfig); | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     getConfig(): PlantConfig[] { | ||||
|         const rv: PlantConfig[] = []; | ||||
|         for (let i = 0; i < PLANT_COUNT; i++) { | ||||
|             rv[i] = this.plants[i].getConfig(); | ||||
|         } | ||||
|         return rv | ||||
|     } | ||||
|  | ||||
|     update(moisture_a: [string], moisture_b: [string]) { | ||||
|         for (let plantId = 0; plantId < PLANT_COUNT; plantId++) { | ||||
|             const a = moisture_a[plantId] | ||||
|             const b = moisture_b[plantId] | ||||
|             this.plants[plantId].setMeasurementResult(a, b) | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     setConfig(plants: PlantConfig[]) { | ||||
|         for (let plantId = 0; plantId < PLANT_COUNT; plantId++) { | ||||
|             const plantConfig = plants[plantId]; | ||||
|             const plantView = this.plants[plantId]; | ||||
|             plantView.setConfig(plantConfig) | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     setPumpTestCurrent(plantId: number, response: PumpTestResult) { | ||||
|         const plantView = this.plants[plantId]; | ||||
|         plantView.setTestResult(response) | ||||
|     } | ||||
| } | ||||
|  | ||||
|  | ||||
|  | ||||
| export class PlantView { | ||||
|     private readonly moistureSensorMinFrequency: HTMLInputElement; | ||||
|     private readonly moistureSensorMaxFrequency: HTMLInputElement; | ||||
|     private readonly plantId: number; | ||||
|     private readonly plantDiv: HTMLDivElement; | ||||
|     private readonly header: HTMLElement; | ||||
|     private readonly testButton: HTMLButtonElement; | ||||
|     private readonly targetMoisture: HTMLInputElement; | ||||
|     private readonly minMoisture: HTMLInputElement; | ||||
|     private readonly pumpTimeS: HTMLInputElement; | ||||
|     private readonly pumpCooldown: HTMLInputElement; | ||||
|     private readonly pumpHourStart: HTMLSelectElement; | ||||
|     private readonly pumpHourEnd: HTMLSelectElement; | ||||
|     private readonly sensorAInstalled: HTMLInputElement; | ||||
|     private readonly sensorBInstalled: HTMLInputElement; | ||||
|     private readonly mode: HTMLSelectElement; | ||||
|     private readonly moistureA: HTMLElement; | ||||
|     private readonly moistureB: HTMLElement; | ||||
|     private readonly maxConsecutivePumpCount: HTMLInputElement; | ||||
|     private readonly minPumpCurrentMa: HTMLInputElement; | ||||
|     private readonly maxPumpCurrentMa: HTMLInputElement; | ||||
|     private readonly ignoreCurrentError: HTMLInputElement; | ||||
|  | ||||
|     private readonly pump_test_current_max: HTMLElement; | ||||
|     private readonly pump_test_current_min: HTMLElement; | ||||
|     private readonly pump_test_current_average: HTMLElement; | ||||
|     private readonly pump_test_pump_time: HTMLElement; | ||||
|     private readonly pump_test_flow_ml: HTMLElement; | ||||
|     private readonly pump_test_flow_raw: HTMLElement; | ||||
|  | ||||
|  | ||||
|     constructor(plantId: number, parent: HTMLDivElement, controller: Controller) { | ||||
|         this.plantId = plantId; | ||||
|         this.plantDiv = document.createElement("div")! as HTMLDivElement | ||||
|         const template = require('./plant.html') as string; | ||||
|         this.plantDiv.innerHTML = template.replaceAll("${plantId}", String(plantId)) | ||||
|  | ||||
|         this.plantDiv.classList.add("plantcontainer") | ||||
|         parent.appendChild(this.plantDiv) | ||||
|  | ||||
|         this.header = document.getElementById("plant_" + plantId + "_header")! | ||||
|         this.header.innerText = "Plant " + (this.plantId + 1) | ||||
|  | ||||
|         this.moistureA = document.getElementById("plant_" + plantId + "_moisture_a")! as HTMLElement; | ||||
|         this.moistureB = document.getElementById("plant_" + plantId + "_moisture_b")! as HTMLElement; | ||||
|  | ||||
|         this.pump_test_current_max = document.getElementById("plant_" + plantId + "_pump_test_current_max")! as HTMLElement; | ||||
|         this.pump_test_current_min = document.getElementById("plant_" + plantId + "_pump_test_current_min")! as HTMLElement; | ||||
|         this.pump_test_current_average = document.getElementById("plant_" + plantId + "_pump_test_current_average")! as HTMLElement; | ||||
|         this.pump_test_pump_time = document.getElementById("plant_" + plantId + "_pump_test_pump_time")! as HTMLElement; | ||||
|         this.pump_test_flow_ml = document.getElementById("plant_" + plantId + "_pump_test_flow_ml")! as HTMLElement; | ||||
|         this.pump_test_flow_raw = document.getElementById("plant_" + plantId + "_pump_test_flow_raw")! as HTMLElement; | ||||
|  | ||||
|         this.testButton = document.getElementById("plant_" + plantId + "_test")! as HTMLButtonElement; | ||||
|         this.testButton.onclick = function () { | ||||
|             controller.testPlant(plantId) | ||||
|         } | ||||
|  | ||||
|         this.mode = document.getElementById("plant_" + plantId + "_mode") as HTMLSelectElement | ||||
|         this.mode.onchange = function () { | ||||
|             controller.configChanged() | ||||
|         } | ||||
|  | ||||
|         this.targetMoisture = document.getElementById("plant_" + plantId + "_target_moisture")! as HTMLInputElement; | ||||
|         this.targetMoisture.onchange = function () { | ||||
|             controller.configChanged() | ||||
|         } | ||||
|  | ||||
|         this.minMoisture = document.getElementById("plant_" + plantId + "_min_moisture")! as HTMLInputElement; | ||||
|         this.minMoisture.onchange = function () { | ||||
|             controller.configChanged() | ||||
|         } | ||||
|  | ||||
|         this.pumpTimeS = document.getElementById("plant_" + plantId + "_pump_time_s") as HTMLInputElement; | ||||
|         this.pumpTimeS.onchange = function () { | ||||
|             controller.configChanged() | ||||
|         } | ||||
|  | ||||
|         this.pumpCooldown = document.getElementById("plant_" + plantId + "_pump_cooldown_min") as HTMLInputElement; | ||||
|         this.pumpCooldown.onchange = function () { | ||||
|             controller.configChanged() | ||||
|         } | ||||
|  | ||||
|         this.pumpHourStart = document.getElementById("plant_" + plantId + "_pump_hour_start") as HTMLSelectElement; | ||||
|         this.pumpHourStart.onchange = function () { | ||||
|             controller.configChanged() | ||||
|         } | ||||
|         for (let i = 0; i < 24; i++) { | ||||
|             let option = document.createElement("option"); | ||||
|             if (i == 10) { | ||||
|                 option.selected = true | ||||
|             } | ||||
|             option.innerText = i.toString(); | ||||
|             this.pumpHourStart.appendChild(option); | ||||
|         } | ||||
|  | ||||
|         this.pumpHourEnd = document.getElementById("plant_" + plantId + "_pump_hour_end") as HTMLSelectElement; | ||||
|         this.pumpHourEnd.onchange = function () { | ||||
|             controller.configChanged() | ||||
|         } | ||||
|         for (let i = 0; i < 24; i++) { | ||||
|             let option = document.createElement("option"); | ||||
|             if (i == 19) { | ||||
|                 option.selected = true | ||||
|             } | ||||
|             option.innerText = i.toString(); | ||||
|             this.pumpHourEnd.appendChild(option); | ||||
|         } | ||||
|  | ||||
|         this.sensorAInstalled = document.getElementById("plant_" + plantId + "_sensor_a") as HTMLInputElement; | ||||
|         this.sensorAInstalled.onchange = function () { | ||||
|             controller.configChanged() | ||||
|         } | ||||
|  | ||||
|         this.sensorBInstalled = document.getElementById("plant_" + plantId + "_sensor_b") as HTMLInputElement; | ||||
|         this.sensorBInstalled.onchange = function () { | ||||
|             controller.configChanged() | ||||
|         } | ||||
|  | ||||
|         this.minPumpCurrentMa = document.getElementById("plant_" + plantId + "_min_pump_current_ma") as HTMLInputElement; | ||||
|         this.minPumpCurrentMa.onchange = function () { | ||||
|             controller.configChanged() | ||||
|         } | ||||
|  | ||||
|         this.maxPumpCurrentMa = document.getElementById("plant_" + plantId + "_max_pump_current_ma") as HTMLInputElement; | ||||
|         this.maxPumpCurrentMa.onchange = function () { | ||||
|             controller.configChanged() | ||||
|         } | ||||
|  | ||||
|         this.ignoreCurrentError = document.getElementById("plant_" + plantId + "_ignore_current_error") as HTMLInputElement; | ||||
|         this.ignoreCurrentError.onchange = function () { | ||||
|             controller.configChanged() | ||||
|         } | ||||
|  | ||||
|  | ||||
|         this.maxConsecutivePumpCount = document.getElementById("plant_" + plantId + "_max_consecutive_pump_count") as HTMLInputElement; | ||||
|         this.maxConsecutivePumpCount.onchange = function () { | ||||
|             controller.configChanged() | ||||
|         } | ||||
|  | ||||
|         this.moistureSensorMinFrequency = document.getElementById("plant_" + plantId + "_min_frequency") as HTMLInputElement; | ||||
|         this.moistureSensorMinFrequency.onchange = function () { | ||||
|             controller.configChanged() | ||||
|         } | ||||
|         this.moistureSensorMinFrequency.onchange = () => { | ||||
|             controller.configChanged(); | ||||
|         }; | ||||
|  | ||||
|         this.moistureSensorMaxFrequency = document.getElementById("plant_" + plantId + "_max_frequency") as HTMLInputElement; | ||||
|         this.moistureSensorMaxFrequency.onchange = () => { | ||||
|             controller.configChanged(); | ||||
|         }; | ||||
|     } | ||||
|  | ||||
|     updateVisibility(plantConfig: PlantConfig) { | ||||
|         let sensorOnly = document.getElementsByClassName("plantSensorEnabledOnly_"+ this.plantId) | ||||
|         let pumpOnly = document.getElementsByClassName("plantPumpEnabledOnly_"+ this.plantId) | ||||
|         let targetOnly = document.getElementsByClassName("plantTargetEnabledOnly_"+ this.plantId) | ||||
|         let minOnly = document.getElementsByClassName("plantMinEnabledOnly_"+ this.plantId) | ||||
|  | ||||
|         console.log("updateVisibility plantConfig: " + plantConfig.mode) | ||||
|         let showSensor = plantConfig.sensor_a || plantConfig.sensor_b | ||||
|         let showPump = plantConfig.mode !== "OFF" | ||||
|         let showTarget = plantConfig.mode === "TargetMoisture" | ||||
|         let showMin = plantConfig.mode === "MinMoisture" | ||||
|  | ||||
|         console.log("updateVisibility showsensor: " + showSensor + " pump " + showPump + " target " +showTarget + " min " + showMin) | ||||
|  | ||||
|         for (const element of Array.from(sensorOnly)) { | ||||
|             if (showSensor) { | ||||
|                 element.classList.remove("plantHidden_" + this.plantId) | ||||
|             } else { | ||||
|                 element.classList.add("plantHidden_" + this.plantId) | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         for (const element of Array.from(pumpOnly)) { | ||||
|             if (showPump) { | ||||
|                 element.classList.remove("plantHidden_" + this.plantId) | ||||
|             } else { | ||||
|                 element.classList.add("plantHidden_" + this.plantId) | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         for (const element of Array.from(targetOnly)) { | ||||
|             if (showTarget) { | ||||
|                 element.classList.remove("plantHidden_" + this.plantId) | ||||
|             } else { | ||||
|                 element.classList.add("plantHidden_" + this.plantId) | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         for (const element of Array.from(minOnly)) { | ||||
|             if (showMin) { | ||||
|                 element.classList.remove("plantHidden_" + this.plantId) | ||||
|             } else { | ||||
|                 element.classList.add("plantHidden_" + this.plantId) | ||||
|             } | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     setTestResult(result: PumpTestResult) { | ||||
|         this.pump_test_current_max.innerText = result.max_current_ma.toString() | ||||
|         this.pump_test_current_min.innerText = result.min_current_ma.toString() | ||||
|         this.pump_test_current_average.innerText = result.median_current_ma.toString() | ||||
|  | ||||
|         this.pump_test_flow_raw.innerText = result.flow_value_count.toString() | ||||
|         this.pump_test_flow_ml.innerText = result.flow_value_ml.toString() | ||||
|  | ||||
|         this.pump_test_pump_time.innerText = result.pump_time_s.toString() | ||||
|     } | ||||
|  | ||||
|     setMeasurementResult(a: string, b: string) { | ||||
|         this.moistureA.innerText = a | ||||
|         this.moistureB.innerText = b | ||||
|     } | ||||
|  | ||||
|     setConfig(plantConfig: PlantConfig) { | ||||
|         this.mode.value = plantConfig.mode; | ||||
|         this.targetMoisture.value = plantConfig.target_moisture.toString(); | ||||
|         this.minMoisture.value = plantConfig.min_moisture?.toString() || ""; | ||||
|         this.pumpTimeS.value = plantConfig.pump_time_s.toString(); | ||||
|         this.pumpCooldown.value = plantConfig.pump_cooldown_min.toString(); | ||||
|         this.pumpHourStart.value = plantConfig.pump_hour_start.toString(); | ||||
|         this.pumpHourEnd.value = plantConfig.pump_hour_end.toString(); | ||||
|         this.sensorBInstalled.checked = plantConfig.sensor_b; | ||||
|         this.sensorAInstalled.checked = plantConfig.sensor_a; | ||||
|         this.maxConsecutivePumpCount.value = plantConfig.max_consecutive_pump_count.toString(); | ||||
|         this.minPumpCurrentMa.value = plantConfig.min_pump_current_ma.toString(); | ||||
|         this.maxPumpCurrentMa.value = plantConfig.max_pump_current_ma.toString(); | ||||
|         this.ignoreCurrentError.checked = plantConfig.ignore_current_error; | ||||
|  | ||||
|         // Set new fields | ||||
|         this.moistureSensorMinFrequency.value = | ||||
|             plantConfig.moisture_sensor_min_frequency?.toString() || ""; | ||||
|         this.moistureSensorMaxFrequency.value = | ||||
|             plantConfig.moisture_sensor_max_frequency?.toString() || ""; | ||||
|  | ||||
|         this.updateVisibility(plantConfig); | ||||
|     } | ||||
|  | ||||
|     getConfig(): PlantConfig { | ||||
|  | ||||
|         let conv: PlantConfig =  { | ||||
|             mode: this.mode.value, | ||||
|             target_moisture: this.targetMoisture.valueAsNumber, | ||||
|             min_moisture: this.minMoisture.valueAsNumber, | ||||
|             pump_time_s: this.pumpTimeS.valueAsNumber, | ||||
|             pump_cooldown_min: this.pumpCooldown.valueAsNumber, | ||||
|             pump_hour_start: +this.pumpHourStart.value, | ||||
|             pump_hour_end: +this.pumpHourEnd.value, | ||||
|             sensor_b: this.sensorBInstalled.checked, | ||||
|             sensor_a: this.sensorAInstalled.checked, | ||||
|             max_consecutive_pump_count: this.maxConsecutivePumpCount.valueAsNumber, | ||||
|             moisture_sensor_min_frequency: this.moistureSensorMinFrequency.valueAsNumber || null, | ||||
|             moisture_sensor_max_frequency: this.moistureSensorMaxFrequency.valueAsNumber || null, | ||||
|             min_pump_current_ma: this.minPumpCurrentMa.valueAsNumber, | ||||
|             max_pump_current_ma: this.maxPumpCurrentMa.valueAsNumber, | ||||
|             ignore_current_error: this.ignoreCurrentError.checked, | ||||
|         }; | ||||
|         this.updateVisibility(conv); | ||||
|         return conv; | ||||
|     } | ||||
| } | ||||
| @@ -1,62 +0,0 @@ | ||||
| import { Controller } from "./main"; | ||||
|  | ||||
| class ProgressInfo{ | ||||
|     displayText:string; | ||||
|     percentValue:number; | ||||
|     indeterminate:boolean; | ||||
|     constructor(displayText:string, percentValue: number, indeterminate:boolean ){ | ||||
|       this.displayText = displayText | ||||
|       this.percentValue = percentValue <0 ? 0 : percentValue > 100? 100: percentValue | ||||
|       this.indeterminate = indeterminate | ||||
|     } | ||||
|   } | ||||
|  | ||||
| export class ProgressView{ | ||||
|   progressPane: HTMLElement; | ||||
|   progress: HTMLElement; | ||||
|   progressPaneSpan: HTMLSpanElement; | ||||
|   progresses: Map<string,ProgressInfo> = new Map; | ||||
|   progressPaneBar: HTMLDivElement; | ||||
|   constructor(controller:Controller){ | ||||
|     this.progressPane = document.getElementById("progressPane") as HTMLElement; | ||||
|     this.progress = document.getElementById("progress") as HTMLElement; | ||||
|     this.progressPaneSpan = document.getElementById("progressPaneSpan") as HTMLSpanElement; | ||||
|     this.progressPaneBar = document.getElementById("progressPaneBar") as HTMLDivElement; | ||||
|      | ||||
|   } | ||||
|  | ||||
|   updateView() { | ||||
|     if (this.progresses.size == 0){ | ||||
|       this.progressPane.style.display = "none" | ||||
|     } else{ | ||||
|       const first = this.progresses.entries().next().value![1] | ||||
|       this.progressPaneBar.setAttribute("data-label", first.displayText) | ||||
|       if (first.indeterminate){ | ||||
|         this.progressPaneSpan.className = "valueIndeterminate" | ||||
|         this.progressPaneSpan.style.width = "100%" | ||||
|  | ||||
|       } else { | ||||
|         this.progressPaneSpan.className = "value" | ||||
|         this.progressPaneSpan.style.width = first.percentValue+"%" | ||||
|       } | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   addIndeterminate(id:string, displayText:string){ | ||||
|     this.progresses.set(id, new ProgressInfo(displayText,0,true)) | ||||
|     this.progressPane.style.display = "flex" | ||||
|     this.updateView(); | ||||
|   | ||||
|   } | ||||
|    | ||||
|   addProgress(id:string, value:number, displayText:string) { | ||||
|     this.progresses.set(id, new ProgressInfo(displayText,value, false)) | ||||
|     this.progressPane.style.display = "flex" | ||||
|     this.updateView(); | ||||
|   } | ||||
|   removeProgress(id:string){ | ||||
|     this.progresses.delete(id) | ||||
|     this.updateView(); | ||||
|  | ||||
|   } | ||||
| } | ||||
| @@ -1,29 +0,0 @@ | ||||
| <style> | ||||
|   .solarflexkey { | ||||
|     min-width: 150px; | ||||
|   } | ||||
|   .solarflexvalue { | ||||
|     text-wrap: nowrap;  | ||||
|     flex-grow: 1; | ||||
|   } | ||||
| </style> | ||||
|  | ||||
| <div class="flexcontainer"> | ||||
|     <div class="subtitle"> | ||||
|         Mppt: | ||||
|     </div> | ||||
|     <input id="solar_auto_refresh" type="checkbox">⟳ | ||||
| </div> | ||||
|  | ||||
| <div class="flexcontainer"> | ||||
|     <span class="solarflexkey">Mppt mV:</span> | ||||
|     <span class="solarflexvalue" id="solar_voltage_milli_volt"></span> | ||||
| </div> | ||||
| <div class="flexcontainer"> | ||||
|     <span class="solarflexkey">Mppt mA:</span> | ||||
|     <span class="solarflexvalue" id="solar_current_milli_ampere" ></span> | ||||
| </div> | ||||
| <div class="flexcontainer"> | ||||
|     <span class="solarflexkey">is Day:</span> | ||||
|     <span class="solarflexvalue" id="solar_is_day" ></span> | ||||
| </div> | ||||
| @@ -1,49 +0,0 @@ | ||||
| import { Controller } from "./main"; | ||||
| import {BatteryState, SolarState} from "./api"; | ||||
|  | ||||
| export class SolarView{ | ||||
|   solar_voltage_milli_volt: HTMLSpanElement; | ||||
|   solar_current_milli_ampere: HTMLSpanElement; | ||||
|   solar_is_day: HTMLSpanElement; | ||||
|   solar_auto_refresh: HTMLInputElement; | ||||
|     timer: NodeJS.Timeout | undefined; | ||||
|     controller: Controller; | ||||
|  | ||||
|     constructor (controller:Controller) { | ||||
|       (document.getElementById("solarview") as HTMLElement).innerHTML = require("./solarview.html") | ||||
|         this.solar_voltage_milli_volt = document.getElementById("solar_voltage_milli_volt") as HTMLSpanElement; | ||||
|         this.solar_current_milli_ampere = document.getElementById("solar_current_milli_ampere") as HTMLSpanElement; | ||||
|         this.solar_is_day = document.getElementById("solar_is_day") as HTMLSpanElement; | ||||
|         this.solar_auto_refresh = document.getElementById("solar_auto_refresh") as HTMLInputElement; | ||||
|  | ||||
|         this.controller = controller | ||||
|         this.solar_auto_refresh.onchange = () => { | ||||
|             if(this.timer){ | ||||
|               clearTimeout(this.timer) | ||||
|             } | ||||
|             if(this.solar_auto_refresh.checked){ | ||||
|               controller.updateSolarData() | ||||
|             } | ||||
|           } | ||||
|     } | ||||
|  | ||||
|     update(solarState: SolarState|null){ | ||||
|         if (solarState == null) { | ||||
|             this.solar_voltage_milli_volt.innerText = "N/A" | ||||
|             this.solar_current_milli_ampere.innerText = "N/A" | ||||
|             this.solar_is_day.innerText = "N/A" | ||||
|         } else { | ||||
|             this.solar_voltage_milli_volt.innerText = solarState.mppt_voltage.toFixed(0) | ||||
|             this.solar_current_milli_ampere.innerText = solarState.mppt_current.toFixed(0) | ||||
|             this.solar_is_day.innerText = solarState.is_day?"🌞":"🌙" | ||||
|         } | ||||
|  | ||||
|         if(this.solar_auto_refresh.checked){ | ||||
|             this.timer = setTimeout(this.controller.updateSolarData, 1000); | ||||
|           } else { | ||||
|             if(this.timer){ | ||||
|               clearTimeout(this.timer) | ||||
|             } | ||||
|           } | ||||
|     } | ||||
|   } | ||||
| @@ -1,74 +0,0 @@ | ||||
| import {Controller} from "./main"; | ||||
| import {BackupHeader} from "./api"; | ||||
|  | ||||
| export class SubmitView { | ||||
|     json: HTMLDivElement; | ||||
|     submitFormBtn: HTMLButtonElement; | ||||
|     submit_status: HTMLElement; | ||||
|     backupBtn: HTMLButtonElement; | ||||
|     restoreBackupBtn: HTMLButtonElement; | ||||
|     backuptimestamp: HTMLElement; | ||||
|     backupsize: HTMLElement; | ||||
|     backupjson: HTMLElement; | ||||
|  | ||||
|     constructor(controller: Controller) { | ||||
|         (document.getElementById("submitview") as HTMLElement).innerHTML = require("./submitview.html") | ||||
|  | ||||
|         let showJson = document.getElementById('showJson') as HTMLButtonElement | ||||
|         let rawdata = document.getElementById('rawdata') as HTMLElement | ||||
|         this.json = document.getElementById('json') as HTMLDivElement | ||||
|         this.backupjson = document.getElementById('backupjson') as HTMLDivElement | ||||
|         this.submitFormBtn = document.getElementById("submit") as HTMLButtonElement | ||||
|         this.backupBtn = document.getElementById("backup") as HTMLButtonElement | ||||
|         this.restoreBackupBtn = document.getElementById("restorebackup") as HTMLButtonElement | ||||
|         this.backuptimestamp = document.getElementById("backuptimestamp") as HTMLElement | ||||
|         this.backupsize = document.getElementById("backupsize") as HTMLElement | ||||
|         this.submit_status = document.getElementById("submit_status") as HTMLElement | ||||
|         this.submitFormBtn.onclick = () => { | ||||
|             controller.uploadConfig(this.json.textContent as string, (status: string) => { | ||||
|                 if (status != "OK") { | ||||
|                     // Show error toast (click to dismiss only) | ||||
|                     const { toast } = require('./toast'); | ||||
|                     toast.error(status); | ||||
|                 } else { | ||||
|                     // Show info toast (auto hides after 5s, or click to dismiss sooner) | ||||
|                     const { toast } = require('./toast'); | ||||
|                     toast.info('Config uploaded successfully'); | ||||
|                 } | ||||
|                 this.submit_status.innerHTML = status; | ||||
|             }); | ||||
|         } | ||||
|         this.backupBtn.onclick = () => { | ||||
|             controller.progressview.addIndeterminate("backup", "Backup to EEPROM running") | ||||
|             controller.backupConfig(this.json.textContent as string).then(saveStatus => { | ||||
|                 controller.getBackupInfo().then(r => { | ||||
|                     controller.progressview.removeProgress("backup") | ||||
|                     this.submit_status.innerHTML = saveStatus; | ||||
|                 }); | ||||
|             }); | ||||
|         } | ||||
|         this.restoreBackupBtn.onclick = () => { | ||||
|             controller.getBackupConfig(); | ||||
|         } | ||||
|         showJson.onclick = () => { | ||||
|             if (rawdata.style.display == "none") { | ||||
|                 rawdata.style.display = "flex"; | ||||
|             } else { | ||||
|                 rawdata.style.display = "none"; | ||||
|             } | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     setBackupInfo(header: BackupHeader) { | ||||
|         this.backuptimestamp.innerText = header.timestamp | ||||
|         this.backupsize.innerText = header.size.toString() | ||||
|     } | ||||
|  | ||||
|     setJson(pretty: string) { | ||||
|         this.json.textContent = pretty | ||||
|     } | ||||
|  | ||||
|     setBackupJson(pretty: string) { | ||||
|         this.backupjson.textContent = pretty | ||||
|     } | ||||
| } | ||||
| @@ -1,40 +0,0 @@ | ||||
| <style> | ||||
|     .submitarea{ | ||||
|         flex-grow: 1; | ||||
|         border-style: groove; | ||||
|         border-width: 1px; | ||||
|         overflow-wrap: break-word; | ||||
|         word-wrap: break-word; | ||||
|         overflow: scroll; | ||||
|     } | ||||
|     .submitbutton{ | ||||
|         padding: 1em 1em; | ||||
|         background: #667eea; | ||||
|         color: white; | ||||
|         border: none; | ||||
|         border-radius: 8px; | ||||
|         font-size: 1.1em; | ||||
|         font-weight: bold; | ||||
|         cursor: pointer; | ||||
|         transition: all 0.3s ease; | ||||
|         letter-spacing: 1px; | ||||
|         margin: 1em 0; | ||||
|     } | ||||
|  | ||||
|     .submitbutton:hover { | ||||
|         background: #1c4e63; | ||||
|     } | ||||
| </style> | ||||
| <button class="submitbutton" id="submit">Submit</button> | ||||
| <br> | ||||
| <button id="showJson">Show Json</button> | ||||
| <div id="rawdata" class="flexcontainer" style="display: none;"> | ||||
|     <div class="submitarea" id="json" contenteditable="true"></div> | ||||
|     <div class="submitarea" id="backupjson">backup will be here</div>     | ||||
| </div> | ||||
| <div>BackupStatus:</div> | ||||
| <div id="backuptimestamp"></div> | ||||
| <div id="backupsize"></div> | ||||
| <button id="backup">Backup</button> | ||||
| <button id="restorebackup">Restore</button> | ||||
| <div id="submit_status"></div> | ||||
| @@ -1,89 +0,0 @@ | ||||
| <style> | ||||
|   .tankcheckbox { | ||||
|     min-width: 20px; | ||||
|     margin: 0; | ||||
|   } | ||||
|   .tankkey{ | ||||
|     min-width: 250px; | ||||
|   } | ||||
|   .tankvalue{ | ||||
|     flex-grow: 1; | ||||
|     margin: 0; | ||||
|   } | ||||
|   .hidden { | ||||
|     display: none; | ||||
|   } | ||||
| </style> | ||||
|  | ||||
|  | ||||
| <div class="flexcontainer"> | ||||
|   <span style="flex-grow: 1; text-align: center; font-weight: bold;"> | ||||
|     Tank: | ||||
|   </span> | ||||
|   <input id="tankview_auto_refresh" type="checkbox">⟳        | ||||
| </div> | ||||
| <div class="flexcontainer"> | ||||
|   <span class="tankkey">Enable Tank Sensor</span> | ||||
|   <input class="tankcheckbox" type="checkbox" id="tank_sensor_enabled"> | ||||
| </div> | ||||
| <div class="flexcontainer"> | ||||
|   <div class="tankkey">Ignore Sensor Error</div> | ||||
|   <input class="tankcheckbox" type="checkbox" id="tank_allow_pumping_if_sensor_error"> | ||||
| </div> | ||||
|  | ||||
|  | ||||
| <div class="flexcontainer"> | ||||
|   <div class="tankkey">Useable ml full% to empty%</div> | ||||
|   <input class="tankvalue" type="number" min="2" max="500000" id="tank_useable_ml"> | ||||
| </div> | ||||
| <div class="flexcontainer"> | ||||
|   <div class="tankkey">Warn below %</div> | ||||
|   <input class="tankvalue" type="number" min="1" max="500000" id="tank_warn_percent"> | ||||
| </div> | ||||
| <div class="flexcontainer"> | ||||
|   <div class="tankkey">Empty at %</div> | ||||
|   <input class="tankvalue" type="number" min="0" max="100" id="tank_empty_percent"> | ||||
| </div> | ||||
| <div class="flexcontainer"> | ||||
|   <div class="tankkey">Full at %</div> | ||||
|   <input class="tankvalue" type="number" min="0" max="100" id="tank_full_percent"> | ||||
| </div> | ||||
| <div class="flexcontainer"> | ||||
|   <div class="tankkey">Flow Sensor ml per pulse</div> | ||||
|   <input class="tankvalue" type="number" min="0" max="1000" step="0.01" id="ml_per_pulse"> | ||||
| </div> | ||||
| <button id="tank_update">Update Tank</button> | ||||
|  | ||||
|  | ||||
| <div id="tank_measure_error_container" class="flexcontainer hidden"> | ||||
|   <div class="tankkey">Sensor Error</div> | ||||
|   <label class="tankvalue"  id="tank_measure_error"></label> | ||||
| </div> | ||||
| <div id="tank_measure_ml_container" class="flexcontainer"> | ||||
|   <div class="tankkey">Left ml</div> | ||||
|   <label class="tankvalue"  id="tank_measure_ml"></label> | ||||
| </div> | ||||
| <div id="tank_measure_percent_container" class="flexcontainer"> | ||||
|   <div class="tankkey">Current %</div> | ||||
|   <label class="tankvalue"  id="tank_measure_percent"></label> | ||||
| </div> | ||||
| <div id="tank_measure_temperature_container" class="flexcontainer"> | ||||
|   <div class="tankkey">Temperature °C</div> | ||||
|   <label class="tankvalue"  id="tank_measure_temperature"></label> | ||||
| </div> | ||||
| <div id="tank_measure_rawvolt_container" class="flexcontainer"> | ||||
|   <div class="tankkey">Probe Voltage</div> | ||||
|   <label class="tankvalue"  id="tank_measure_rawvolt"></label> | ||||
| </div> | ||||
| <div id="tank_measure_temperature_error_container" class="flexcontainer"> | ||||
|   <div class="tankkey">Temperature Error</div> | ||||
|   <label class="tankvalue"  id="tank_measure_temperature_error"></label> | ||||
| </div> | ||||
| <div class="flexcontainer"> | ||||
|   <div class="tankkey">Enough Water</div> | ||||
|   <label class="tankvalue"  id="tank_measure_enoughwater"></label> | ||||
| </div> | ||||
| <div class="flexcontainer"> | ||||
|   <div class="tankkey">Warn Level</div> | ||||
|   <label class="tankvalue"  id="tank_measure_warnlevel"></label> | ||||
| </div> | ||||
| @@ -1,160 +0,0 @@ | ||||
| import { Controller } from "./main"; | ||||
| import {TankConfig, TankInfo} from "./api"; | ||||
|  | ||||
| export class TankConfigView { | ||||
|     private readonly tank_useable_ml: HTMLInputElement; | ||||
|     private readonly tank_empty_percent: HTMLInputElement; | ||||
|     private readonly tank_full_percent: HTMLInputElement; | ||||
|     private readonly tank_warn_percent: HTMLInputElement; | ||||
|     private readonly tank_sensor_enabled: HTMLInputElement; | ||||
|     private readonly tank_allow_pumping_if_sensor_error: HTMLInputElement; | ||||
|     private readonly ml_per_pulse: HTMLInputElement; | ||||
|     private readonly tank_measure_error: HTMLLabelElement; | ||||
|     private readonly tank_measure_ml: HTMLLabelElement; | ||||
|     private readonly tank_measure_percent: HTMLLabelElement; | ||||
|     private readonly tank_measure_temperature: HTMLLabelElement; | ||||
|     private readonly tank_measure_rawvolt: HTMLLabelElement; | ||||
|     private readonly tank_measure_enoughwater: HTMLLabelElement; | ||||
|     private readonly tank_measure_warnlevel: HTMLLabelElement; | ||||
|     private readonly tank_measure_temperature_error: HTMLLabelElement; | ||||
|     private readonly tank_measure_error_container: HTMLDivElement; | ||||
|     private readonly tank_measure_ml_container: HTMLDivElement; | ||||
|     private readonly tank_measure_percent_container: HTMLDivElement; | ||||
|     private readonly tank_measure_temperature_container: HTMLDivElement; | ||||
|     private readonly tank_measure_rawvolt_container: HTMLDivElement; | ||||
|     private readonly tank_measure_temperature_error_container: HTMLDivElement; | ||||
|      | ||||
|     private readonly auto_refresh: HTMLInputElement; | ||||
|     private timer: NodeJS.Timeout | undefined; | ||||
|     private readonly controller: Controller; | ||||
|    | ||||
|     constructor(controller:Controller){ | ||||
|       (document.getElementById("tankview") as HTMLElement).innerHTML = require("./tankview.html") | ||||
|       this.controller = controller; | ||||
|  | ||||
|       this.auto_refresh = document.getElementById("tankview_auto_refresh") as HTMLInputElement; | ||||
|  | ||||
|       this.auto_refresh.onchange = () => { | ||||
|         if(this.timer){ | ||||
|           clearTimeout(this.timer) | ||||
|         } | ||||
|         if(this.auto_refresh.checked){ | ||||
|           controller.loadTankInfo() | ||||
|         } | ||||
|       } | ||||
|  | ||||
|       this.tank_useable_ml = document.getElementById("tank_useable_ml") as HTMLInputElement; | ||||
|       this.tank_useable_ml.onchange = controller.configChanged | ||||
|       this.tank_empty_percent = document.getElementById("tank_empty_percent") as HTMLInputElement; | ||||
|       this.tank_empty_percent.onchange = controller.configChanged | ||||
|       this.tank_full_percent = document.getElementById("tank_full_percent") as HTMLInputElement; | ||||
|       this.tank_full_percent.onchange = controller.configChanged | ||||
|       this.tank_warn_percent = document.getElementById("tank_warn_percent") as HTMLInputElement; | ||||
|       this.tank_warn_percent.onchange = controller.configChanged | ||||
|       this.tank_sensor_enabled = document.getElementById("tank_sensor_enabled") as HTMLInputElement; | ||||
|       this.tank_sensor_enabled.onchange = controller.configChanged | ||||
|       this.tank_allow_pumping_if_sensor_error = document.getElementById("tank_allow_pumping_if_sensor_error") as HTMLInputElement; | ||||
|       this.tank_allow_pumping_if_sensor_error.onchange = controller.configChanged | ||||
|       this.ml_per_pulse = document.getElementById("ml_per_pulse") as HTMLInputElement; | ||||
|       this.ml_per_pulse.onchange = controller.configChanged | ||||
|  | ||||
|       let tank_update = document.getElementById("tank_update") as HTMLInputElement; | ||||
|       tank_update.onclick = () => { | ||||
|         controller.loadTankInfo() | ||||
|       } | ||||
|        | ||||
|  | ||||
|       this.tank_measure_error = document.getElementById("tank_measure_error") as HTMLLabelElement; | ||||
|       this.tank_measure_error_container = document.getElementById("tank_measure_error_container") as HTMLDivElement; | ||||
|        | ||||
|       this.tank_measure_ml = document.getElementById("tank_measure_ml") as HTMLLabelElement; | ||||
|       this.tank_measure_ml_container = document.getElementById("tank_measure_ml_container") as HTMLDivElement; | ||||
|        | ||||
|       this.tank_measure_percent = document.getElementById("tank_measure_percent") as HTMLLabelElement; | ||||
|       this.tank_measure_percent_container = document.getElementById("tank_measure_percent_container") as HTMLDivElement; | ||||
|  | ||||
|       this.tank_measure_temperature = document.getElementById("tank_measure_temperature") as HTMLLabelElement; | ||||
|       this.tank_measure_temperature_container = document.getElementById("tank_measure_temperature_container") as HTMLDivElement; | ||||
|        | ||||
|       this.tank_measure_rawvolt = document.getElementById("tank_measure_rawvolt") as HTMLLabelElement; | ||||
|       this.tank_measure_rawvolt_container = document.getElementById("tank_measure_rawvolt_container") as HTMLDivElement; | ||||
|        | ||||
|       this.tank_measure_temperature_error = document.getElementById("tank_measure_temperature_error") as HTMLLabelElement; | ||||
|       this.tank_measure_temperature_error_container = document.getElementById("tank_measure_temperature_error_container") as HTMLDivElement; | ||||
|        | ||||
|       this.tank_measure_enoughwater = document.getElementById("tank_measure_enoughwater") as HTMLLabelElement; | ||||
|       this.tank_measure_warnlevel = document.getElementById("tank_measure_warnlevel") as HTMLLabelElement; | ||||
|     } | ||||
|  | ||||
|     setTankInfo(tankinfo: TankInfo) { | ||||
|       if (tankinfo.sensor_error == null){ | ||||
|         this.tank_measure_error_container.classList.add("hidden") | ||||
|       } else { | ||||
|         this.tank_measure_error.innerText = JSON.stringify(tankinfo.sensor_error) ; | ||||
|         this.tank_measure_error_container.classList.remove("hidden") | ||||
|       } | ||||
|       if (tankinfo.left_ml == null){ | ||||
|         this.tank_measure_ml_container.classList.add("hidden") | ||||
|       } else { | ||||
|         this.tank_measure_ml.innerText = tankinfo.left_ml.toString(); | ||||
|         this.tank_measure_ml_container.classList.remove("hidden") | ||||
|       } | ||||
|       if (tankinfo.percent == null){ | ||||
|         this.tank_measure_percent_container.classList.add("hidden") | ||||
|       } else { | ||||
|         this.tank_measure_percent.innerText = tankinfo.percent.toString(); | ||||
|         this.tank_measure_percent_container.classList.remove("hidden") | ||||
|       } | ||||
|       if (tankinfo.water_temp == null){ | ||||
|         this.tank_measure_temperature_container.classList.add("hidden") | ||||
|       } else { | ||||
|         this.tank_measure_temperature.innerText = tankinfo.water_temp.toString(); | ||||
|         this.tank_measure_temperature_container.classList.remove("hidden") | ||||
|       } | ||||
|       if (tankinfo.raw == null){ | ||||
|         this.tank_measure_rawvolt_container.classList.add("hidden") | ||||
|       } else { | ||||
|         this.tank_measure_rawvolt.innerText = tankinfo.raw.toString(); | ||||
|         this.tank_measure_rawvolt_container.classList.remove("hidden") | ||||
|       } | ||||
|  | ||||
|       if (tankinfo.temp_sensor_error == null){ | ||||
|         this.tank_measure_temperature_error_container.classList.add("hidden") | ||||
|       } else { | ||||
|         this.tank_measure_temperature_error.innerText = tankinfo.temp_sensor_error; | ||||
|         this.tank_measure_temperature_error_container.classList.remove("hidden") | ||||
|       } | ||||
|       | ||||
|       this.tank_measure_enoughwater.innerText = tankinfo.enough_water.toString() | ||||
|      this.tank_measure_warnlevel.innerText = tankinfo.warn_level.toString() | ||||
|  | ||||
|      if(this.auto_refresh.checked){ | ||||
|       this.timer = setTimeout(this.controller.loadTankInfo, 1000); | ||||
|     } else { | ||||
|       if(this.timer){ | ||||
|         clearTimeout(this.timer) | ||||
|       } | ||||
|     } | ||||
|     } | ||||
|    | ||||
|     setConfig(tank: TankConfig) { | ||||
|       this.tank_allow_pumping_if_sensor_error.checked = tank.tank_allow_pumping_if_sensor_error; | ||||
|       this.tank_empty_percent.value = String(tank.tank_empty_percent) | ||||
|       this.tank_warn_percent.value = String(tank.tank_warn_percent) | ||||
|       this.tank_full_percent.value = String(tank.tank_full_percent) | ||||
|       this.tank_sensor_enabled.checked = tank.tank_sensor_enabled | ||||
|       this.tank_useable_ml.value = String(tank.tank_useable_ml) | ||||
|       this.ml_per_pulse.value = String(tank.ml_per_pulse) | ||||
|     } | ||||
|     getConfig(): TankConfig { | ||||
|       return { | ||||
|         tank_allow_pumping_if_sensor_error: this.tank_allow_pumping_if_sensor_error.checked, | ||||
|         tank_empty_percent : this.tank_empty_percent.valueAsNumber, | ||||
|         tank_full_percent: this.tank_full_percent.valueAsNumber, | ||||
|         tank_sensor_enabled: this.tank_sensor_enabled.checked, | ||||
|         tank_useable_ml: this.tank_useable_ml.valueAsNumber, | ||||
|         tank_warn_percent: this.tank_warn_percent.valueAsNumber, | ||||
|         ml_per_pulse: this.ml_per_pulse.valueAsNumber | ||||
|       } | ||||
|     } | ||||
|   } | ||||
| @@ -1,28 +0,0 @@ | ||||
|     <div style="display:flex"> | ||||
|         <span style="flex-grow: 1; text-align: center; font-weight: bold;"> | ||||
|             Time: | ||||
|         </span> | ||||
|         <input id="timeview_auto_refresh" type="checkbox">⟳        | ||||
|     </div> | ||||
|  | ||||
|     <div style="display:flex"> | ||||
|         <span style="min-width: 50px;">MCU:</span> | ||||
|         <div  id="timeview_esp_time"  style="text-wrap: nowrap; flex-grow: 1;">Esp time</div> | ||||
|     </div> | ||||
|     <div style="display:flex"> | ||||
|         <span style="min-width: 50px;">RTC:</span> | ||||
|         <div  id="timeview_rtc_time"  style="text-wrap: nowrap; flex-grow: 1;">Rtc time</div> | ||||
|     </div> | ||||
|     <div style="display:flex"> | ||||
|         <span style="min-width: 50px;">Local:</span> | ||||
|         <div  id="timeview_browser_time"  style="text-wrap: nowrap; flex-grow: 1;">Local time</div> | ||||
|     </div> | ||||
|  | ||||
|     <div style="display:flex"> | ||||
|         <span style="min-width: 50px;">Timezone:</span> | ||||
|         <select id="timezone_select" style="text-wrap: nowrap; flex-grow: 1;"> | ||||
|             <option value="" disabled selected>Select Timezone</option> | ||||
|         </select> | ||||
|     </div> | ||||
|  | ||||
|     <button id="timeview_time_upload">Store Browser time into esp and rtc</button> | ||||
| @@ -1,74 +0,0 @@ | ||||
| import { Controller } from "./main"; | ||||
|  | ||||
| export class TimeView { | ||||
|     esp_time: HTMLDivElement | ||||
|     rtc_time: HTMLDivElement | ||||
|     browser_time: HTMLDivElement | ||||
|     sync: HTMLButtonElement | ||||
|     auto_refresh: HTMLInputElement; | ||||
|   controller: Controller; | ||||
|   timer: NodeJS.Timeout | undefined; | ||||
|   timezoneSelect: HTMLSelectElement; | ||||
|    | ||||
|     constructor(controller:Controller) { | ||||
|       (document.getElementById("timeview") as HTMLElement).innerHTML = require("./timeview.html") | ||||
|       this.timezoneSelect = document.getElementById('timezone_select') as HTMLSelectElement; | ||||
|       this.timezoneSelect.onchange = function(){ | ||||
|         controller.configChanged() | ||||
|       } | ||||
|  | ||||
|       this.auto_refresh = document.getElementById("timeview_auto_refresh") as HTMLInputElement; | ||||
|       this.esp_time = document.getElementById("timeview_esp_time") as HTMLDivElement; | ||||
|       this.rtc_time = document.getElementById("timeview_rtc_time") as HTMLDivElement; | ||||
|       this.browser_time = document.getElementById("timeview_browser_time") as HTMLDivElement; | ||||
|       this.sync = document.getElementById("timeview_time_upload") as HTMLButtonElement; | ||||
|       this.sync.onclick = controller.syncRTCFromBrowser; | ||||
|       this.controller = controller; | ||||
|  | ||||
|       this.auto_refresh.onchange = () => { | ||||
|         if(this.timer){ | ||||
|           clearTimeout(this.timer) | ||||
|         } | ||||
|         if(this.auto_refresh.checked){ | ||||
|           controller.updateRTCData() | ||||
|         } | ||||
|       } | ||||
|     } | ||||
|    | ||||
|     update(native: string, rtc: string) { | ||||
|       this.esp_time.innerText = native; | ||||
|       this.rtc_time.innerText = rtc; | ||||
|       const date = new Date(); | ||||
|       this.browser_time.innerText = date.toISOString(); | ||||
|       if(this.auto_refresh.checked){ | ||||
|         this.timer = setTimeout(this.controller.updateRTCData, 1000); | ||||
|       } else { | ||||
|         if(this.timer){ | ||||
|           clearTimeout(this.timer) | ||||
|         } | ||||
|       } | ||||
|        | ||||
|     } | ||||
|  | ||||
|     timezones(timezones: string[]) { | ||||
|       timezones.forEach(tz => { | ||||
|         const option = document.createElement('option'); | ||||
|         option.value = tz; | ||||
|         option.textContent = tz; | ||||
|         this.timezoneSelect.appendChild(option); | ||||
|       }); | ||||
|  | ||||
|     } | ||||
|  | ||||
|   getTimeZone() { | ||||
|     return this.timezoneSelect.value; | ||||
|   } | ||||
|  | ||||
|   setTimeZone(timezone: string | undefined) { | ||||
|     if (timezone != undefined) { | ||||
|       this.timezoneSelect.value = timezone; | ||||
|     } else { | ||||
|       this.timezoneSelect.value = "UTC"; | ||||
|     } | ||||
|   } | ||||
| } | ||||
| @@ -1,93 +0,0 @@ | ||||
| class ToastService { | ||||
|   private container: HTMLElement; | ||||
|   private stylesInjected = false; | ||||
|  | ||||
|   constructor() { | ||||
|     this.container = this.ensureContainer(); | ||||
|     this.injectStyles(); | ||||
|   } | ||||
|  | ||||
|   info(message: string, timeoutMs: number = 5000) { | ||||
|     const el = this.createToast(message, 'info'); | ||||
|     this.container.appendChild(el); | ||||
|     // Auto-dismiss after timeout | ||||
|     const timer = window.setTimeout(() => this.dismiss(el), timeoutMs); | ||||
|     // Dismiss on click immediately | ||||
|     el.addEventListener('click', () => { | ||||
|       window.clearTimeout(timer); | ||||
|       this.dismiss(el); | ||||
|     }); | ||||
|   } | ||||
|  | ||||
|   error(message: string) { | ||||
|     const el = this.createToast(message, 'error'); | ||||
|     this.container.appendChild(el); | ||||
|     // Only dismiss on click | ||||
|     el.addEventListener('click', () => this.dismiss(el)); | ||||
|   } | ||||
|  | ||||
|   private dismiss(el: HTMLElement) { | ||||
|     if (!el.parentElement) return; | ||||
|     el.parentElement.removeChild(el); | ||||
|   } | ||||
|  | ||||
|   private createToast(message: string, type: 'info' | 'error'): HTMLElement { | ||||
|     const div = document.createElement('div'); | ||||
|     div.className = `toast ${type}`; | ||||
|     div.textContent = message; | ||||
|     div.setAttribute('role', 'status'); | ||||
|     div.setAttribute('aria-live', 'polite'); | ||||
|     return div; | ||||
|   } | ||||
|  | ||||
|   private ensureContainer(): HTMLElement { | ||||
|     let container = document.getElementById('toast-container'); | ||||
|     if (!container) { | ||||
|       container = document.createElement('div'); | ||||
|       container.id = 'toast-container'; | ||||
|       document.body.appendChild(container); | ||||
|     } | ||||
|     return container; | ||||
|   } | ||||
|  | ||||
|   private injectStyles() { | ||||
|     if (this.stylesInjected) return; | ||||
|     const style = document.createElement('style'); | ||||
|     style.textContent = ` | ||||
| #toast-container { | ||||
|   position: fixed; | ||||
|   top: 12px; | ||||
|   right: 12px; | ||||
|   display: flex; | ||||
|   flex-direction: column; | ||||
|   gap: 8px; | ||||
|   z-index: 9999; | ||||
| } | ||||
| .toast { | ||||
|   max-width: 320px; | ||||
|   padding: 10px 12px; | ||||
|   border-radius: 6px; | ||||
|   box-shadow: 0 2px 6px rgba(0,0,0,0.2); | ||||
|   cursor: pointer; | ||||
|   user-select: none; | ||||
|   font-family: sans-serif; | ||||
|   font-size: 14px; | ||||
|   line-height: 1.3; | ||||
| } | ||||
| .toast.info { | ||||
|   background-color: #d4edda; /* green-ish */ | ||||
|   color: #155724; | ||||
|   border-left: 4px solid #28a745; | ||||
| } | ||||
| .toast.error { | ||||
|   background-color: #f8d7da; /* red-ish */ | ||||
|   color: #721c24; | ||||
|   border-left: 4px solid #dc3545; | ||||
| } | ||||
| `; | ||||
|     document.head.appendChild(style); | ||||
|     this.stylesInjected = true; | ||||
|   } | ||||
| } | ||||
|  | ||||
| export const toast = new ToastService(); | ||||
| @@ -1,18 +0,0 @@ | ||||
|   | ||||
| { | ||||
|   "compilerOptions": { | ||||
|     "outDir": "./dist/", | ||||
|     "noImplicitAny": true, | ||||
|     "module": "es6", | ||||
|     "target": "es5", | ||||
|     "jsx": "react", | ||||
|     "allowJs": true, | ||||
|     "moduleResolution": "node", | ||||
|     "sourceMap": true, | ||||
|     "strict": true, | ||||
|     "lib": [ | ||||
|       "es2021", | ||||
|       "DOM" | ||||
|     ] | ||||
|   } | ||||
| } | ||||
Some files were not shown because too many files have changed in this diff Show More
		Reference in New Issue
	
	Block a user