Compare commits
544 Commits
5608b4e610
...
1.0.3
| Author | SHA1 | Date | |
|---|---|---|---|
| 4d46f9c52c | |||
| c90a05fcfd | |||
| abdca20a66 | |||
| 88a24565b4 | |||
| 3625188706 | |||
| 58eceeda2d | |||
| c44c6c3bf5 | |||
| 4ef714fdbd | |||
| bab4525908 | |||
| 2ed4299032 | |||
| bcc92da065 | |||
| af83130b63 | |||
| 950b576373 | |||
| 2459b0cf99 | |||
| 814204b4ce | |||
| de8fcfbcfb | |||
| 49a7011d84 | |||
| 39aff8054a | |||
| 850f828dca | |||
| 59383debd2 | |||
| 58cf0a9614 | |||
| f30aaf71dd | |||
| 27ce9e9a96 | |||
| 903dd104f3 | |||
| 205981fbc8 | |||
| 623b0a4671 | |||
| fd25e71cf8 | |||
| 842b48e782 | |||
| e8374a8ef7 | |||
| 347c247285 | |||
| c59aeed4ab | |||
| b7f1beb1b7 | |||
| 4bd700c2db | |||
| 3f1c5e4ac0 | |||
| f77506dc46 | |||
| 5b7554b6bf | |||
| 8fdbcc4f3a | |||
| 9f4c2b0e35 | |||
| 0a7138a82b | |||
| 2ce09a94b1 | |||
| 355b2a71ce | |||
| 6b04bf4261 | |||
| 99c6963210 | |||
| 89cfd3ce21 | |||
| 0c40ff2750 | |||
| 75be1ed1d2 | |||
| 6a2baca3f2 | |||
| 4d0d20b424 | |||
| 05cf488be7 | |||
| b8ecc83668 | |||
| c1912717e4 | |||
| a8780e60d3 | |||
| 5005cf84f9 | |||
| 561992010c | |||
| 4725e67ee1 | |||
| fbb55628be | |||
| 79e49ed5a9 | |||
| 138b9d2dbe | |||
| 3413a1ee21 | |||
| 64055568e0 | |||
| af71b574cc | |||
| 6088237b79 | |||
| 05a033b51c | |||
| 3d3ccf5242 | |||
| 68521c238c | |||
| f03278f8c5 | |||
| a63d1f17de | |||
| bb6d8a8be1 | |||
| 693414b1eb | |||
| f74e86a8db | |||
| 53d4cb1de7 | |||
| 498ca3925a | |||
| 155806df78 | |||
| 79f3668021 | |||
| 660e6e8a5b | |||
| 0e3182434f | |||
| cf2f034e6c | |||
| ddd27519a9 | |||
| e8d2e8b318 | |||
| 0dfc25a918 | |||
| 3078b3c645 | |||
| 9d3bed68af | |||
| 9fe00f149c | |||
| 65164055d6 | |||
| 08680122f2 | |||
| 642366540e | |||
| 482da63a3b | |||
| cade9dc02b | |||
| 9b87025b27 | |||
| 46d3f3580c | |||
| bda9f6a9c9 | |||
| 41380a103f | |||
| 2573778b82 | |||
| 527126d926 | |||
| 9a1104c57b | |||
| bf3e703bb1 | |||
| d7607bc483 | |||
| 306be8272a | |||
| 742bb8e2ed | |||
| e10d7ef478 | |||
| 18ba6c60d6 | |||
| d70562f97a | |||
| b0d192a9aa | |||
| 99f6b17f4e | |||
| 3fff4d624e | |||
| e2f6212a75 | |||
| bff1c88da8 | |||
| 7933c2dae3 | |||
| 15b8a69700 | |||
| 157c77142a | |||
| 25f6e808ed | |||
| 6f298d3238 | |||
| b75e868cc3 | |||
| 6a2b5e320d | |||
| 52637fc401 | |||
| 8ea9da9443 | |||
| b1b4eaa341 | |||
| 94652a82fe | |||
| 832ab86536 | |||
| 32dbbf4678 | |||
| 1f48203564 | |||
| 0b6526f901 | |||
| 741db9e13b | |||
| 061ae8c208 | |||
| 4a68800907 | |||
| 6738d47507 | |||
| acbce81b46 | |||
| 1615531d67 | |||
| 5355b351d1 | |||
| ad10df3e7f | |||
| edf70cb8fc | |||
| 8af3018b34 | |||
| dd96cc4bf3 | |||
| 9e9a227332 | |||
| d701c406d6 | |||
| 74915f109d | |||
| 40af167c1a | |||
| 124fc71c9a | |||
| ac6f93dd4a | |||
| 695fa1fcf4 | |||
| 3131c757a9 | |||
| 60d5be58e1 | |||
| 4b90e0ede1 | |||
| d351aebce4 | |||
| 24d95fff89 | |||
| 11e536fee0 | |||
| e2328b47e5 | |||
| bc790df8e6 | |||
| 104e4f2c15 | |||
| 709671c35d | |||
| e769b2d83b | |||
| 33c3769818 | |||
| 7c5eeaeaa0 | |||
| bcdb72e217 | |||
| 7173d206f2 | |||
| 74a8d3ffbf | |||
| 31bc7898ee | |||
| 27a6d8f7ed | |||
| 43c988b80e | |||
| 9531281fd2 | |||
| e589e2e80c | |||
| 36c127a9c9 | |||
| f0b9683519 | |||
| 2e793770f1 | |||
| ceac6a2683 | |||
| f232a63496 | |||
| adfe006270 | |||
| aa6ca82edd | |||
| d939752aed | |||
| ca6532aee3 | |||
| bd5fc18bab | |||
| 80023ddee3 | |||
| d48094aca6 | |||
| 47e59b7035 | |||
| 6eb9e3df09 | |||
| 0d5f2b90eb | |||
| 8f181ec485 | |||
| 7bb92f7e05 | |||
| 2f2a77f780 | |||
| 789c30faf8 | |||
| 2a9bc65f54 | |||
| 2b96e2a294 | |||
| 0092d09ff9 | |||
| 4bdda28ebf | |||
| 83e130d232 | |||
| 15c394a797 | |||
| 66257b2866 | |||
| 140f1449a7 | |||
| 71b0e54529 | |||
| 4fff948382 | |||
| 13100a728f | |||
| de2fc4a62f | |||
| b5b883df46 | |||
| 285c80926b | |||
| a4eb8f3465 | |||
| 03b1dc1fd7 | |||
| b55215149f | |||
| ad02303a16 | |||
| a2de65d454 | |||
| 2d8d1cf28d | |||
| a8dd9fb08c | |||
| 317de38662 | |||
| 57f06690c4 | |||
| 93554d3719 | |||
| 56f119008b | |||
| e1a82e205e | |||
| 2e3175505f | |||
| 6108a58919 | |||
| 1e5762bb4f | |||
| 71ecf18a2f | |||
| a7069fd0ec | |||
| 5d44bb8a21 | |||
| 78da2b181c | |||
| 9e1de0854d | |||
| edb6279b74 | |||
| fd00a87216 | |||
| 00050c62c5 | |||
| 6824120652 | |||
| 5ed4cc6fed | |||
| c805b03370 | |||
| d97d6bfe56 | |||
| 7151b199f8 | |||
| 40f938efe7 | |||
| ffb8c25428 | |||
| 593719e373 | |||
| 13a9905281 | |||
| 55b354228f | |||
| d751ddc671 | |||
| c5061fdb4d | |||
| 7adbafb831 | |||
| 02397d10f0 | |||
| e3ae017279 | |||
| 30b5155a4d | |||
| 0d04f5d7b2 | |||
| a40dff2820 | |||
| 6fbec9f0cd | |||
| 055e512f77 | |||
| ee769f043f | |||
| 926b265f91 | |||
| b115ba9307 | |||
| 8ada40a5ee | |||
| 100e42ec6d | |||
| cab51c9623 | |||
| 76df0ecf3e | |||
| 85cb7d6a75 | |||
| 69a51e11d3 | |||
| 0ff1d48b90 | |||
| 0d478a10f7 | |||
| a8e2f2d7bf | |||
| e961ea0911 | |||
| 1c1eb53b6e | |||
| 1a2badc138 | |||
| 9323a4a3f5 | |||
| 35cfc73c9d | |||
| dad54c638b | |||
| 4f5be4d08c | |||
| b89aee2dcc | |||
| bbe2c3ebc5 | |||
| 62037db6e3 | |||
| 0bf3bdbaea | |||
| f65df5f22a | |||
| 406a920d7e | |||
| 889ba9b85f | |||
| 12606ba336 | |||
| cb2e17581a | |||
| 4dd5fb4e55 | |||
| 0a162e4a78 | |||
| ba45faf017 | |||
| c4dedb946f | |||
| 5004194567 | |||
| 768f8fc112 | |||
| adf1477c4b | |||
| 7474e25209 | |||
| f33c408c67 | |||
| ccd4125500 | |||
| 9825f2628b | |||
| 9e5797e4ca | |||
| fb562f908c | |||
| ffb00ee668 | |||
| 3ad64e55b8 | |||
| f01df2818c | |||
| da60a57f53 | |||
| 0629bd60c3 | |||
| 995977fd37 | |||
| 9bf15f28b8 | |||
| 8941ec2aef | |||
| b19961ed6a | |||
| 082efa367c | |||
| 3ffcdad666 | |||
| 65db36d097 | |||
| 57bb552950 | |||
| 1d9c539cd1 | |||
| 11d718cfe8 | |||
| 0125b16177 | |||
| d97dcddb96 | |||
| 9eafbd8aeb | |||
| 6aa7fc3a75 | |||
| 345b3566ae | |||
| 22cd346330 | |||
| aee9303f91 | |||
| 6462645d26 | |||
| 4bb76777db | |||
| d79b55b86d | |||
| 665a04c8a0 | |||
| 658b10f5f8 | |||
| c0374e35b1 | |||
| 15f701668f | |||
| 8fdfa19806 | |||
| 22d84e9464 | |||
| a5c5663390 | |||
| 7878fb9686 | |||
| b24642b10d | |||
| ce45d841b2 | |||
| 1b4e5eda9d | |||
| bb1917d1b4 | |||
| b285323bd7 | |||
| ecb161ee82 | |||
| 00c6ae338b | |||
| 814046146c | |||
| f52e992d84 | |||
| dc73882347 | |||
| 5ed8c42b99 | |||
| 0fcb902e9e | |||
| cfafbda77b | |||
| dace42aef2 | |||
| 67401e8faf | |||
| 77a278bd53 | |||
| 6df43fcc0e | |||
| 8add37fc42 | |||
| bfde6531c2 | |||
| 5f6ac7bcfd | |||
| c01f1ca484 | |||
| c6975c2097 | |||
| 2d079403c5 | |||
| 2d408871ad | |||
| 22fd077380 | |||
| 0fba1caf62 | |||
| 7e99cfc086 | |||
| 511011bb4b | |||
| dfca6a04bc | |||
| 4f639522b9 | |||
| 7d9af6af64 | |||
| e1136926a1 | |||
| 4206d9529b | |||
| b606aed10e | |||
| 9a2ceb9804 | |||
| f6bd7b1061 | |||
| 34460500a0 | |||
| 72afa3df62 | |||
| e74f7d6f6d | |||
| 5b09aec93a | |||
| 9f93f76d8e | |||
| 211369a1b2 | |||
| d7c4cd6635 | |||
| 4f78e99f65 | |||
| 4309a19f24 | |||
| 67a0436d02 | |||
| 5ff169d8c2 | |||
| 541f7cbe95 | |||
| 166ac5c8c2 | |||
| 0b53037140 | |||
| 901f6b0e6f | |||
| fc5f9735bf | |||
| 094ff457ac | |||
| ffbbd14ac3 | |||
| e4447e9dcb | |||
| 973190f5b9 | |||
| ededf48977 | |||
| 9a5211812e | |||
| 6f589f3ee4 | |||
| 499c5cb81e | |||
| d72cdbf3cd | |||
| 7ab60c6fe6 | |||
| bf28d1c926 | |||
| 07ca3aa80e | |||
| 4cc18d407d | |||
| 7bcafd782f | |||
| 404fa716f5 | |||
| 43bbb444db | |||
| 71a139af59 | |||
| c40b4acf7a | |||
| 41a228484f | |||
| 9965de686d | |||
| 3cf808df1c | |||
| 40b41688e0 | |||
| 124b0b825c | |||
| 8e4bed012d | |||
| ff79fd968e | |||
| ac26065f10 | |||
| b8b172f17d | |||
| 12fe1abb0f | |||
| acfacf574b | |||
| d0e426bbbc | |||
| 92a9a5741c | |||
| 9a480dfa98 | |||
| f8eafe31bd | |||
| 4330e64489 | |||
| fd5730cfce | |||
| 76acf07b17 | |||
| 315e11a2bb | |||
| 5722ccc2c4 | |||
| 71718151d0 | |||
| 9efd8db8cf | |||
| fc0c86bf8b | |||
| 1a8c3ff9ff | |||
| 4cb05b375e | |||
| f67ccc7cda | |||
| 8a08ff53df | |||
| 2607ac7355 | |||
| e644aa1390 | |||
| e015f01539 | |||
| 37aed38174 | |||
| aa3677a787 | |||
| 549193632c | |||
| 407aeaaf6e | |||
| 36d269dde7 | |||
| 0c4f352815 | |||
| e1abc68292 | |||
| bb0226577d | |||
| 9fcd16784a | |||
| d6e0eccb00 | |||
| dc621984fb | |||
| b2878510d6 | |||
| a059076323 | |||
| f594802523 | |||
| 747d2d819b | |||
| a52868a3fb | |||
| fce38386eb | |||
| cb88a19352 | |||
| c6c34efebd | |||
| eed9637f1e | |||
| 3a64b2b09c | |||
| 8d2f0cb38a | |||
| 5b1cf61832 | |||
| 6cd5d5f93a | |||
| fc409b2584 | |||
| 5a1942cb15 | |||
| 4af8904294 | |||
| 0d1605169d | |||
| 346ea8db11 | |||
| f534a9c61b | |||
| 2ef056da30 | |||
| af16091fab | |||
| 28f81248bf | |||
| cc43f6c78b | |||
| 060ff08c1e | |||
| 57ce643163 | |||
| 7a536ac850 | |||
| 685eef5c5b | |||
| 180126d22a | |||
| e29f01bc62 | |||
| f2abdfe302 | |||
| 598286d1cb | |||
| 30f196aa7a | |||
| 08f1ec6d4d | |||
| d31a568c00 | |||
| 1b25c07e50 | |||
| 96f1640378 | |||
| ccb4ae22f8 | |||
| d66e2b9bf7 | |||
| 0660066941 | |||
| 7476924e0e | |||
| 65c3c534f4 | |||
| 995e1fa07e | |||
| 1735077db3 | |||
| 31bb956a29 | |||
| ff0e548422 | |||
| 837835da7e | |||
| 2d262bb4c9 | |||
| f594ebfbaa | |||
| 57a9c03308 | |||
| 7b9db9c7c3 | |||
| b7720df305 | |||
| 445c1b014e | |||
| aa732af571 | |||
| c365f959e7 | |||
| 9a4c6d2de2 | |||
| 3c20cca915 | |||
| 5aaad54de3 | |||
| b3edfb05d9 | |||
| 45029f24cc | |||
| ec594c0e4d | |||
| fdfbdf093f | |||
| c789056ccf | |||
| f5f783698f | |||
| 823783f307 | |||
| 187a8b3657 | |||
| 2c300109a3 | |||
| 6528de4a9e | |||
| 7846b093da | |||
| a2acbb53b0 | |||
| 9fd87b6452 | |||
| e151b50efa | |||
| 20abc120b5 | |||
| 540d10bf78 | |||
| f8bcb63513 | |||
| f920c4d9fa | |||
| 26bb6647b2 | |||
| 8e1c2bc8bd | |||
| 16b03f0a05 | |||
| e1b5f2f282 | |||
| 8a2ac0c434 | |||
| ebb4f8bdd2 | |||
| be77e7cb04 | |||
| 13f8b5a592 | |||
| 5408cd3a9c | |||
| 784fe58c3e | |||
| 5857892a4b | |||
| 2f8b8aa59f | |||
| e244fff700 | |||
| cbca7c8f4c | |||
| 521ccc899a | |||
| a088ddc5b8 | |||
| 7e12e0df00 | |||
| d90d033e74 | |||
| e7190bab71 | |||
| 386f0439e4 | |||
| e4da44b5ce | |||
| 138dd6c6b8 | |||
| eafa8e6a4b | |||
| 2e4a2b68dd | |||
| 6cf7c2cae1 | |||
| 2924d14281 | |||
| cef5b5aa5b | |||
| 42f459f88b | |||
| f4dda44d15 | |||
| 80452e10de | |||
| aa262879f0 | |||
| 37844ae5fa | |||
| d38040cb98 | |||
| 2feb3f6490 | |||
| 2f971c0055 | |||
| e1a94acdcb | |||
| 382c44a18a | |||
| 17f8931f0f | |||
| cc63906ec1 | |||
| 59f8b59efe | |||
| 06659404c1 | |||
| 436bcd5677 | |||
| caf05d9126 | |||
| 7dfb172aeb | |||
| 75753051f9 | |||
| 63bdeed952 | |||
| 7f952700ad |
105
.drone.yml
Normal file
105
.drone.yml
Normal file
@@ -0,0 +1,105 @@
|
||||
---
|
||||
kind: pipeline
|
||||
type: docker
|
||||
name: default
|
||||
|
||||
steps:
|
||||
- name: web_build
|
||||
image: node:23
|
||||
volumes:
|
||||
- name: web_app
|
||||
path: /tmp/web_build
|
||||
commands:
|
||||
- cd central_frontend
|
||||
- npm install
|
||||
- npm run build
|
||||
- mv dist /tmp/web_build
|
||||
|
||||
- name: backend_check
|
||||
image: rust
|
||||
volumes:
|
||||
- name: rust_registry
|
||||
path: /usr/local/cargo/registry
|
||||
commands:
|
||||
- rustup component add clippy
|
||||
- cd central_backend
|
||||
- cargo clippy -- -D warnings
|
||||
- cargo test
|
||||
|
||||
- name: custom_consumption_check
|
||||
image: rust
|
||||
volumes:
|
||||
- name: rust_registry
|
||||
path: /usr/local/cargo/registry
|
||||
commands:
|
||||
- rustup component add clippy
|
||||
- cd custom_consumption
|
||||
- cargo clippy -- -D warnings
|
||||
- cargo test
|
||||
depends_on:
|
||||
- backend_check
|
||||
|
||||
- name: backend_compile
|
||||
image: rust
|
||||
volumes:
|
||||
- name: rust_registry
|
||||
path: /usr/local/cargo/registry
|
||||
- name: web_app
|
||||
path: /tmp/web_build
|
||||
- name: releases
|
||||
path: /tmp/releases
|
||||
when:
|
||||
event:
|
||||
- tag
|
||||
depends_on:
|
||||
- backend_check
|
||||
- web_build
|
||||
commands:
|
||||
- cd central_backend
|
||||
- mv /tmp/web_build/dist static
|
||||
- cargo build --release
|
||||
- ls -lah target/release/central_backend
|
||||
- mv target/release/central_backend /tmp/releases/central_backend
|
||||
|
||||
# Build ESP32 program
|
||||
- name: esp32_compile
|
||||
image: espressif/idf:v5.5.1
|
||||
volumes:
|
||||
- name: releases
|
||||
path: /tmp/releases
|
||||
commands:
|
||||
- cd esp32_device
|
||||
- /opt/esp/entrypoint.sh idf.py build
|
||||
- ls -lah build/main.bin
|
||||
- cp build/main.bin /tmp/releases/wt32-eth01.bin
|
||||
|
||||
# Auto-release to Gitea
|
||||
- name: gitea_release
|
||||
image: plugins/gitea-release
|
||||
depends_on:
|
||||
- backend_compile
|
||||
- esp32_compile
|
||||
when:
|
||||
event:
|
||||
- tag
|
||||
volumes:
|
||||
- name: releases
|
||||
path: /tmp/releases
|
||||
environment:
|
||||
PLUGIN_API_KEY:
|
||||
from_secret: GITEA_API_KEY # needs permission write:repository
|
||||
settings:
|
||||
base_url: https://gitea.communiquons.org
|
||||
files:
|
||||
- /tmp/releases/central_backend
|
||||
- /tmp/releases/wt32-eth01.bin
|
||||
checksum: sha512
|
||||
|
||||
|
||||
volumes:
|
||||
- name: rust_registry
|
||||
temp: {}
|
||||
- name: web_app
|
||||
temp: {}
|
||||
- name: releases
|
||||
temp: {}
|
||||
675
LICENSE
Normal file
675
LICENSE
Normal file
@@ -0,0 +1,675 @@
|
||||
GNU GENERAL PUBLIC LICENSE
|
||||
Version 3, 29 June 2007
|
||||
|
||||
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
|
||||
Everyone is permitted to copy and distribute verbatim copies
|
||||
of this license document, but changing it is not allowed.
|
||||
|
||||
Preamble
|
||||
|
||||
The GNU General Public License is a free, copyleft license for
|
||||
software and other kinds of works.
|
||||
|
||||
The licenses for most software and other practical works are designed
|
||||
to take away your freedom to share and change the works. By contrast,
|
||||
the GNU General Public License is intended to guarantee your freedom to
|
||||
share and change all versions of a program--to make sure it remains free
|
||||
software for all its users. We, the Free Software Foundation, use the
|
||||
GNU General Public License for most of our software; it applies also to
|
||||
any other work released this way by its authors. You can apply it to
|
||||
your programs, too.
|
||||
|
||||
When we speak of free software, we are referring to freedom, not
|
||||
price. Our General Public Licenses are designed to make sure that you
|
||||
have the freedom to distribute copies of free software (and charge for
|
||||
them if you wish), that you receive source code or can get it if you
|
||||
want it, that you can change the software or use pieces of it in new
|
||||
free programs, and that you know you can do these things.
|
||||
|
||||
To protect your rights, we need to prevent others from denying you
|
||||
these rights or asking you to surrender the rights. Therefore, you have
|
||||
certain responsibilities if you distribute copies of the software, or if
|
||||
you modify it: responsibilities to respect the freedom of others.
|
||||
|
||||
For example, if you distribute copies of such a program, whether
|
||||
gratis or for a fee, you must pass on to the recipients the same
|
||||
freedoms that you received. You must make sure that they, too, receive
|
||||
or can get the source code. And you must show them these terms so they
|
||||
know their rights.
|
||||
|
||||
Developers that use the GNU GPL protect your rights with two steps:
|
||||
(1) assert copyright on the software, and (2) offer you this License
|
||||
giving you legal permission to copy, distribute and/or modify it.
|
||||
|
||||
For the developers' and authors' protection, the GPL clearly explains
|
||||
that there is no warranty for this free software. For both users' and
|
||||
authors' sake, the GPL requires that modified versions be marked as
|
||||
changed, so that their problems will not be attributed erroneously to
|
||||
authors of previous versions.
|
||||
|
||||
Some devices are designed to deny users access to install or run
|
||||
modified versions of the software inside them, although the manufacturer
|
||||
can do so. This is fundamentally incompatible with the aim of
|
||||
protecting users' freedom to change the software. The systematic
|
||||
pattern of such abuse occurs in the area of products for individuals to
|
||||
use, which is precisely where it is most unacceptable. Therefore, we
|
||||
have designed this version of the GPL to prohibit the practice for those
|
||||
products. If such problems arise substantially in other domains, we
|
||||
stand ready to extend this provision to those domains in future versions
|
||||
of the GPL, as needed to protect the freedom of users.
|
||||
|
||||
Finally, every program is threatened constantly by software patents.
|
||||
States should not allow patents to restrict development and use of
|
||||
software on general-purpose computers, but in those that do, we wish to
|
||||
avoid the special danger that patents applied to a free program could
|
||||
make it effectively proprietary. To prevent this, the GPL assures that
|
||||
patents cannot be used to render the program non-free.
|
||||
|
||||
The precise terms and conditions for copying, distribution and
|
||||
modification follow.
|
||||
|
||||
TERMS AND CONDITIONS
|
||||
|
||||
0. Definitions.
|
||||
|
||||
"This License" refers to version 3 of the GNU General Public License.
|
||||
|
||||
"Copyright" also means copyright-like laws that apply to other kinds of
|
||||
works, such as semiconductor masks.
|
||||
|
||||
"The Program" refers to any copyrightable work licensed under this
|
||||
License. Each licensee is addressed as "you". "Licensees" and
|
||||
"recipients" may be individuals or organizations.
|
||||
|
||||
To "modify" a work means to copy from or adapt all or part of the work
|
||||
in a fashion requiring copyright permission, other than the making of an
|
||||
exact copy. The resulting work is called a "modified version" of the
|
||||
earlier work or a work "based on" the earlier work.
|
||||
|
||||
A "covered work" means either the unmodified Program or a work based
|
||||
on the Program.
|
||||
|
||||
To "propagate" a work means to do anything with it that, without
|
||||
permission, would make you directly or secondarily liable for
|
||||
infringement under applicable copyright law, except executing it on a
|
||||
computer or modifying a private copy. Propagation includes copying,
|
||||
distribution (with or without modification), making available to the
|
||||
public, and in some countries other activities as well.
|
||||
|
||||
To "convey" a work means any kind of propagation that enables other
|
||||
parties to make or receive copies. Mere interaction with a user through
|
||||
a computer network, with no transfer of a copy, is not conveying.
|
||||
|
||||
An interactive user interface displays "Appropriate Legal Notices"
|
||||
to the extent that it includes a convenient and prominently visible
|
||||
feature that (1) displays an appropriate copyright notice, and (2)
|
||||
tells the user that there is no warranty for the work (except to the
|
||||
extent that warranties are provided), that licensees may convey the
|
||||
work under this License, and how to view a copy of this License. If
|
||||
the interface presents a list of user commands or options, such as a
|
||||
menu, a prominent item in the list meets this criterion.
|
||||
|
||||
1. Source Code.
|
||||
|
||||
The "source code" for a work means the preferred form of the work
|
||||
for making modifications to it. "Object code" means any non-source
|
||||
form of a work.
|
||||
|
||||
A "Standard Interface" means an interface that either is an official
|
||||
standard defined by a recognized standards body, or, in the case of
|
||||
interfaces specified for a particular programming language, one that
|
||||
is widely used among developers working in that language.
|
||||
|
||||
The "System Libraries" of an executable work include anything, other
|
||||
than the work as a whole, that (a) is included in the normal form of
|
||||
packaging a Major Component, but which is not part of that Major
|
||||
Component, and (b) serves only to enable use of the work with that
|
||||
Major Component, or to implement a Standard Interface for which an
|
||||
implementation is available to the public in source code form. A
|
||||
"Major Component", in this context, means a major essential component
|
||||
(kernel, window system, and so on) of the specific operating system
|
||||
(if any) on which the executable work runs, or a compiler used to
|
||||
produce the work, or an object code interpreter used to run it.
|
||||
|
||||
The "Corresponding Source" for a work in object code form means all
|
||||
the source code needed to generate, install, and (for an executable
|
||||
work) run the object code and to modify the work, including scripts to
|
||||
control those activities. However, it does not include the work's
|
||||
System Libraries, or general-purpose tools or generally available free
|
||||
programs which are used unmodified in performing those activities but
|
||||
which are not part of the work. For example, Corresponding Source
|
||||
includes interface definition files associated with source files for
|
||||
the work, and the source code for shared libraries and dynamically
|
||||
linked subprograms that the work is specifically designed to require,
|
||||
such as by intimate data communication or control flow between those
|
||||
subprograms and other parts of the work.
|
||||
|
||||
The Corresponding Source need not include anything that users
|
||||
can regenerate automatically from other parts of the Corresponding
|
||||
Source.
|
||||
|
||||
The Corresponding Source for a work in source code form is that
|
||||
same work.
|
||||
|
||||
2. Basic Permissions.
|
||||
|
||||
All rights granted under this License are granted for the term of
|
||||
copyright on the Program, and are irrevocable provided the stated
|
||||
conditions are met. This License explicitly affirms your unlimited
|
||||
permission to run the unmodified Program. The output from running a
|
||||
covered work is covered by this License only if the output, given its
|
||||
content, constitutes a covered work. This License acknowledges your
|
||||
rights of fair use or other equivalent, as provided by copyright law.
|
||||
|
||||
You may make, run and propagate covered works that you do not
|
||||
convey, without conditions so long as your license otherwise remains
|
||||
in force. You may convey covered works to others for the sole purpose
|
||||
of having them make modifications exclusively for you, or provide you
|
||||
with facilities for running those works, provided that you comply with
|
||||
the terms of this License in conveying all material for which you do
|
||||
not control copyright. Those thus making or running the covered works
|
||||
for you must do so exclusively on your behalf, under your direction
|
||||
and control, on terms that prohibit them from making any copies of
|
||||
your copyrighted material outside their relationship with you.
|
||||
|
||||
Conveying under any other circumstances is permitted solely under
|
||||
the conditions stated below. Sublicensing is not allowed; section 10
|
||||
makes it unnecessary.
|
||||
|
||||
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
|
||||
|
||||
No covered work shall be deemed part of an effective technological
|
||||
measure under any applicable law fulfilling obligations under article
|
||||
11 of the WIPO copyright treaty adopted on 20 December 1996, or
|
||||
similar laws prohibiting or restricting circumvention of such
|
||||
measures.
|
||||
|
||||
When you convey a covered work, you waive any legal power to forbid
|
||||
circumvention of technological measures to the extent such circumvention
|
||||
is effected by exercising rights under this License with respect to
|
||||
the covered work, and you disclaim any intention to limit operation or
|
||||
modification of the work as a means of enforcing, against the work's
|
||||
users, your or third parties' legal rights to forbid circumvention of
|
||||
technological measures.
|
||||
|
||||
4. Conveying Verbatim Copies.
|
||||
|
||||
You may convey verbatim copies of the Program's source code as you
|
||||
receive it, in any medium, provided that you conspicuously and
|
||||
appropriately publish on each copy an appropriate copyright notice;
|
||||
keep intact all notices stating that this License and any
|
||||
non-permissive terms added in accord with section 7 apply to the code;
|
||||
keep intact all notices of the absence of any warranty; and give all
|
||||
recipients a copy of this License along with the Program.
|
||||
|
||||
You may charge any price or no price for each copy that you convey,
|
||||
and you may offer support or warranty protection for a fee.
|
||||
|
||||
5. Conveying Modified Source Versions.
|
||||
|
||||
You may convey a work based on the Program, or the modifications to
|
||||
produce it from the Program, in the form of source code under the
|
||||
terms of section 4, provided that you also meet all of these conditions:
|
||||
|
||||
a) The work must carry prominent notices stating that you modified
|
||||
it, and giving a relevant date.
|
||||
|
||||
b) The work must carry prominent notices stating that it is
|
||||
released under this License and any conditions added under section
|
||||
7. This requirement modifies the requirement in section 4 to
|
||||
"keep intact all notices".
|
||||
|
||||
c) You must license the entire work, as a whole, under this
|
||||
License to anyone who comes into possession of a copy. This
|
||||
License will therefore apply, along with any applicable section 7
|
||||
additional terms, to the whole of the work, and all its parts,
|
||||
regardless of how they are packaged. This License gives no
|
||||
permission to license the work in any other way, but it does not
|
||||
invalidate such permission if you have separately received it.
|
||||
|
||||
d) If the work has interactive user interfaces, each must display
|
||||
Appropriate Legal Notices; however, if the Program has interactive
|
||||
interfaces that do not display Appropriate Legal Notices, your
|
||||
work need not make them do so.
|
||||
|
||||
A compilation of a covered work with other separate and independent
|
||||
works, which are not by their nature extensions of the covered work,
|
||||
and which are not combined with it such as to form a larger program,
|
||||
in or on a volume of a storage or distribution medium, is called an
|
||||
"aggregate" if the compilation and its resulting copyright are not
|
||||
used to limit the access or legal rights of the compilation's users
|
||||
beyond what the individual works permit. Inclusion of a covered work
|
||||
in an aggregate does not cause this License to apply to the other
|
||||
parts of the aggregate.
|
||||
|
||||
6. Conveying Non-Source Forms.
|
||||
|
||||
You may convey a covered work in object code form under the terms
|
||||
of sections 4 and 5, provided that you also convey the
|
||||
machine-readable Corresponding Source under the terms of this License,
|
||||
in one of these ways:
|
||||
|
||||
a) Convey the object code in, or embodied in, a physical product
|
||||
(including a physical distribution medium), accompanied by the
|
||||
Corresponding Source fixed on a durable physical medium
|
||||
customarily used for software interchange.
|
||||
|
||||
b) Convey the object code in, or embodied in, a physical product
|
||||
(including a physical distribution medium), accompanied by a
|
||||
written offer, valid for at least three years and valid for as
|
||||
long as you offer spare parts or customer support for that product
|
||||
model, to give anyone who possesses the object code either (1) a
|
||||
copy of the Corresponding Source for all the software in the
|
||||
product that is covered by this License, on a durable physical
|
||||
medium customarily used for software interchange, for a price no
|
||||
more than your reasonable cost of physically performing this
|
||||
conveying of source, or (2) access to copy the
|
||||
Corresponding Source from a network server at no charge.
|
||||
|
||||
c) Convey individual copies of the object code with a copy of the
|
||||
written offer to provide the Corresponding Source. This
|
||||
alternative is allowed only occasionally and noncommercially, and
|
||||
only if you received the object code with such an offer, in accord
|
||||
with subsection 6b.
|
||||
|
||||
d) Convey the object code by offering access from a designated
|
||||
place (gratis or for a charge), and offer equivalent access to the
|
||||
Corresponding Source in the same way through the same place at no
|
||||
further charge. You need not require recipients to copy the
|
||||
Corresponding Source along with the object code. If the place to
|
||||
copy the object code is a network server, the Corresponding Source
|
||||
may be on a different server (operated by you or a third party)
|
||||
that supports equivalent copying facilities, provided you maintain
|
||||
clear directions next to the object code saying where to find the
|
||||
Corresponding Source. Regardless of what server hosts the
|
||||
Corresponding Source, you remain obligated to ensure that it is
|
||||
available for as long as needed to satisfy these requirements.
|
||||
|
||||
e) Convey the object code using peer-to-peer transmission, provided
|
||||
you inform other peers where the object code and Corresponding
|
||||
Source of the work are being offered to the general public at no
|
||||
charge under subsection 6d.
|
||||
|
||||
A separable portion of the object code, whose source code is excluded
|
||||
from the Corresponding Source as a System Library, need not be
|
||||
included in conveying the object code work.
|
||||
|
||||
A "User Product" is either (1) a "consumer product", which means any
|
||||
tangible personal property which is normally used for personal, family,
|
||||
or household purposes, or (2) anything designed or sold for incorporation
|
||||
into a dwelling. In determining whether a product is a consumer product,
|
||||
doubtful cases shall be resolved in favor of coverage. For a particular
|
||||
product received by a particular user, "normally used" refers to a
|
||||
typical or common use of that class of product, regardless of the status
|
||||
of the particular user or of the way in which the particular user
|
||||
actually uses, or expects or is expected to use, the product. A product
|
||||
is a consumer product regardless of whether the product has substantial
|
||||
commercial, industrial or non-consumer uses, unless such uses represent
|
||||
the only significant mode of use of the product.
|
||||
|
||||
"Installation Information" for a User Product means any methods,
|
||||
procedures, authorization keys, or other information required to install
|
||||
and execute modified versions of a covered work in that User Product from
|
||||
a modified version of its Corresponding Source. The information must
|
||||
suffice to ensure that the continued functioning of the modified object
|
||||
code is in no case prevented or interfered with solely because
|
||||
modification has been made.
|
||||
|
||||
If you convey an object code work under this section in, or with, or
|
||||
specifically for use in, a User Product, and the conveying occurs as
|
||||
part of a transaction in which the right of possession and use of the
|
||||
User Product is transferred to the recipient in perpetuity or for a
|
||||
fixed term (regardless of how the transaction is characterized), the
|
||||
Corresponding Source conveyed under this section must be accompanied
|
||||
by the Installation Information. But this requirement does not apply
|
||||
if neither you nor any third party retains the ability to install
|
||||
modified object code on the User Product (for example, the work has
|
||||
been installed in ROM).
|
||||
|
||||
The requirement to provide Installation Information does not include a
|
||||
requirement to continue to provide support service, warranty, or updates
|
||||
for a work that has been modified or installed by the recipient, or for
|
||||
the User Product in which it has been modified or installed. Access to a
|
||||
network may be denied when the modification itself materially and
|
||||
adversely affects the operation of the network or violates the rules and
|
||||
protocols for communication across the network.
|
||||
|
||||
Corresponding Source conveyed, and Installation Information provided,
|
||||
in accord with this section must be in a format that is publicly
|
||||
documented (and with an implementation available to the public in
|
||||
source code form), and must require no special password or key for
|
||||
unpacking, reading or copying.
|
||||
|
||||
7. Additional Terms.
|
||||
|
||||
"Additional permissions" are terms that supplement the terms of this
|
||||
License by making exceptions from one or more of its conditions.
|
||||
Additional permissions that are applicable to the entire Program shall
|
||||
be treated as though they were included in this License, to the extent
|
||||
that they are valid under applicable law. If additional permissions
|
||||
apply only to part of the Program, that part may be used separately
|
||||
under those permissions, but the entire Program remains governed by
|
||||
this License without regard to the additional permissions.
|
||||
|
||||
When you convey a copy of a covered work, you may at your option
|
||||
remove any additional permissions from that copy, or from any part of
|
||||
it. (Additional permissions may be written to require their own
|
||||
removal in certain cases when you modify the work.) You may place
|
||||
additional permissions on material, added by you to a covered work,
|
||||
for which you have or can give appropriate copyright permission.
|
||||
|
||||
Notwithstanding any other provision of this License, for material you
|
||||
add to a covered work, you may (if authorized by the copyright holders of
|
||||
that material) supplement the terms of this License with terms:
|
||||
|
||||
a) Disclaiming warranty or limiting liability differently from the
|
||||
terms of sections 15 and 16 of this License; or
|
||||
|
||||
b) Requiring preservation of specified reasonable legal notices or
|
||||
author attributions in that material or in the Appropriate Legal
|
||||
Notices displayed by works containing it; or
|
||||
|
||||
c) Prohibiting misrepresentation of the origin of that material, or
|
||||
requiring that modified versions of such material be marked in
|
||||
reasonable ways as different from the original version; or
|
||||
|
||||
d) Limiting the use for publicity purposes of names of licensors or
|
||||
authors of the material; or
|
||||
|
||||
e) Declining to grant rights under trademark law for use of some
|
||||
trade names, trademarks, or service marks; or
|
||||
|
||||
f) Requiring indemnification of licensors and authors of that
|
||||
material by anyone who conveys the material (or modified versions of
|
||||
it) with contractual assumptions of liability to the recipient, for
|
||||
any liability that these contractual assumptions directly impose on
|
||||
those licensors and authors.
|
||||
|
||||
All other non-permissive additional terms are considered "further
|
||||
restrictions" within the meaning of section 10. If the Program as you
|
||||
received it, or any part of it, contains a notice stating that it is
|
||||
governed by this License along with a term that is a further
|
||||
restriction, you may remove that term. If a license document contains
|
||||
a further restriction but permits relicensing or conveying under this
|
||||
License, you may add to a covered work material governed by the terms
|
||||
of that license document, provided that the further restriction does
|
||||
not survive such relicensing or conveying.
|
||||
|
||||
If you add terms to a covered work in accord with this section, you
|
||||
must place, in the relevant source files, a statement of the
|
||||
additional terms that apply to those files, or a notice indicating
|
||||
where to find the applicable terms.
|
||||
|
||||
Additional terms, permissive or non-permissive, may be stated in the
|
||||
form of a separately written license, or stated as exceptions;
|
||||
the above requirements apply either way.
|
||||
|
||||
8. Termination.
|
||||
|
||||
You may not propagate or modify a covered work except as expressly
|
||||
provided under this License. Any attempt otherwise to propagate or
|
||||
modify it is void, and will automatically terminate your rights under
|
||||
this License (including any patent licenses granted under the third
|
||||
paragraph of section 11).
|
||||
|
||||
However, if you cease all violation of this License, then your
|
||||
license from a particular copyright holder is reinstated (a)
|
||||
provisionally, unless and until the copyright holder explicitly and
|
||||
finally terminates your license, and (b) permanently, if the copyright
|
||||
holder fails to notify you of the violation by some reasonable means
|
||||
prior to 60 days after the cessation.
|
||||
|
||||
Moreover, your license from a particular copyright holder is
|
||||
reinstated permanently if the copyright holder notifies you of the
|
||||
violation by some reasonable means, this is the first time you have
|
||||
received notice of violation of this License (for any work) from that
|
||||
copyright holder, and you cure the violation prior to 30 days after
|
||||
your receipt of the notice.
|
||||
|
||||
Termination of your rights under this section does not terminate the
|
||||
licenses of parties who have received copies or rights from you under
|
||||
this License. If your rights have been terminated and not permanently
|
||||
reinstated, you do not qualify to receive new licenses for the same
|
||||
material under section 10.
|
||||
|
||||
9. Acceptance Not Required for Having Copies.
|
||||
|
||||
You are not required to accept this License in order to receive or
|
||||
run a copy of the Program. Ancillary propagation of a covered work
|
||||
occurring solely as a consequence of using peer-to-peer transmission
|
||||
to receive a copy likewise does not require acceptance. However,
|
||||
nothing other than this License grants you permission to propagate or
|
||||
modify any covered work. These actions infringe copyright if you do
|
||||
not accept this License. Therefore, by modifying or propagating a
|
||||
covered work, you indicate your acceptance of this License to do so.
|
||||
|
||||
10. Automatic Licensing of Downstream Recipients.
|
||||
|
||||
Each time you convey a covered work, the recipient automatically
|
||||
receives a license from the original licensors, to run, modify and
|
||||
propagate that work, subject to this License. You are not responsible
|
||||
for enforcing compliance by third parties with this License.
|
||||
|
||||
An "entity transaction" is a transaction transferring control of an
|
||||
organization, or substantially all assets of one, or subdividing an
|
||||
organization, or merging organizations. If propagation of a covered
|
||||
work results from an entity transaction, each party to that
|
||||
transaction who receives a copy of the work also receives whatever
|
||||
licenses to the work the party's predecessor in interest had or could
|
||||
give under the previous paragraph, plus a right to possession of the
|
||||
Corresponding Source of the work from the predecessor in interest, if
|
||||
the predecessor has it or can get it with reasonable efforts.
|
||||
|
||||
You may not impose any further restrictions on the exercise of the
|
||||
rights granted or affirmed under this License. For example, you may
|
||||
not impose a license fee, royalty, or other charge for exercise of
|
||||
rights granted under this License, and you may not initiate litigation
|
||||
(including a cross-claim or counterclaim in a lawsuit) alleging that
|
||||
any patent claim is infringed by making, using, selling, offering for
|
||||
sale, or importing the Program or any portion of it.
|
||||
|
||||
11. Patents.
|
||||
|
||||
A "contributor" is a copyright holder who authorizes use under this
|
||||
License of the Program or a work on which the Program is based. The
|
||||
work thus licensed is called the contributor's "contributor version".
|
||||
|
||||
A contributor's "essential patent claims" are all patent claims
|
||||
owned or controlled by the contributor, whether already acquired or
|
||||
hereafter acquired, that would be infringed by some manner, permitted
|
||||
by this License, of making, using, or selling its contributor version,
|
||||
but do not include claims that would be infringed only as a
|
||||
consequence of further modification of the contributor version. For
|
||||
purposes of this definition, "control" includes the right to grant
|
||||
patent sublicenses in a manner consistent with the requirements of
|
||||
this License.
|
||||
|
||||
Each contributor grants you a non-exclusive, worldwide, royalty-free
|
||||
patent license under the contributor's essential patent claims, to
|
||||
make, use, sell, offer for sale, import and otherwise run, modify and
|
||||
propagate the contents of its contributor version.
|
||||
|
||||
In the following three paragraphs, a "patent license" is any express
|
||||
agreement or commitment, however denominated, not to enforce a patent
|
||||
(such as an express permission to practice a patent or covenant not to
|
||||
sue for patent infringement). To "grant" such a patent license to a
|
||||
party means to make such an agreement or commitment not to enforce a
|
||||
patent against the party.
|
||||
|
||||
If you convey a covered work, knowingly relying on a patent license,
|
||||
and the Corresponding Source of the work is not available for anyone
|
||||
to copy, free of charge and under the terms of this License, through a
|
||||
publicly available network server or other readily accessible means,
|
||||
then you must either (1) cause the Corresponding Source to be so
|
||||
available, or (2) arrange to deprive yourself of the benefit of the
|
||||
patent license for this particular work, or (3) arrange, in a manner
|
||||
consistent with the requirements of this License, to extend the patent
|
||||
license to downstream recipients. "Knowingly relying" means you have
|
||||
actual knowledge that, but for the patent license, your conveying the
|
||||
covered work in a country, or your recipient's use of the covered work
|
||||
in a country, would infringe one or more identifiable patents in that
|
||||
country that you have reason to believe are valid.
|
||||
|
||||
If, pursuant to or in connection with a single transaction or
|
||||
arrangement, you convey, or propagate by procuring conveyance of, a
|
||||
covered work, and grant a patent license to some of the parties
|
||||
receiving the covered work authorizing them to use, propagate, modify
|
||||
or convey a specific copy of the covered work, then the patent license
|
||||
you grant is automatically extended to all recipients of the covered
|
||||
work and works based on it.
|
||||
|
||||
A patent license is "discriminatory" if it does not include within
|
||||
the scope of its coverage, prohibits the exercise of, or is
|
||||
conditioned on the non-exercise of one or more of the rights that are
|
||||
specifically granted under this License. You may not convey a covered
|
||||
work if you are a party to an arrangement with a third party that is
|
||||
in the business of distributing software, under which you make payment
|
||||
to the third party based on the extent of your activity of conveying
|
||||
the work, and under which the third party grants, to any of the
|
||||
parties who would receive the covered work from you, a discriminatory
|
||||
patent license (a) in connection with copies of the covered work
|
||||
conveyed by you (or copies made from those copies), or (b) primarily
|
||||
for and in connection with specific products or compilations that
|
||||
contain the covered work, unless you entered into that arrangement,
|
||||
or that patent license was granted, prior to 28 March 2007.
|
||||
|
||||
Nothing in this License shall be construed as excluding or limiting
|
||||
any implied license or other defenses to infringement that may
|
||||
otherwise be available to you under applicable patent law.
|
||||
|
||||
12. No Surrender of Others' Freedom.
|
||||
|
||||
If conditions are imposed on you (whether by court order, agreement or
|
||||
otherwise) that contradict the conditions of this License, they do not
|
||||
excuse you from the conditions of this License. If you cannot convey a
|
||||
covered work so as to satisfy simultaneously your obligations under this
|
||||
License and any other pertinent obligations, then as a consequence you may
|
||||
not convey it at all. For example, if you agree to terms that obligate you
|
||||
to collect a royalty for further conveying from those to whom you convey
|
||||
the Program, the only way you could satisfy both those terms and this
|
||||
License would be to refrain entirely from conveying the Program.
|
||||
|
||||
13. Use with the GNU Affero General Public License.
|
||||
|
||||
Notwithstanding any other provision of this License, you have
|
||||
permission to link or combine any covered work with a work licensed
|
||||
under version 3 of the GNU Affero General Public License into a single
|
||||
combined work, and to convey the resulting work. The terms of this
|
||||
License will continue to apply to the part which is the covered work,
|
||||
but the special requirements of the GNU Affero General Public License,
|
||||
section 13, concerning interaction through a network will apply to the
|
||||
combination as such.
|
||||
|
||||
14. Revised Versions of this License.
|
||||
|
||||
The Free Software Foundation may publish revised and/or new versions of
|
||||
the GNU General Public License from time to time. Such new versions will
|
||||
be similar in spirit to the present version, but may differ in detail to
|
||||
address new problems or concerns.
|
||||
|
||||
Each version is given a distinguishing version number. If the
|
||||
Program specifies that a certain numbered version of the GNU General
|
||||
Public License "or any later version" applies to it, you have the
|
||||
option of following the terms and conditions either of that numbered
|
||||
version or of any later version published by the Free Software
|
||||
Foundation. If the Program does not specify a version number of the
|
||||
GNU General Public License, you may choose any version ever published
|
||||
by the Free Software Foundation.
|
||||
|
||||
If the Program specifies that a proxy can decide which future
|
||||
versions of the GNU General Public License can be used, that proxy's
|
||||
public statement of acceptance of a version permanently authorizes you
|
||||
to choose that version for the Program.
|
||||
|
||||
Later license versions may give you additional or different
|
||||
permissions. However, no additional obligations are imposed on any
|
||||
author or copyright holder as a result of your choosing to follow a
|
||||
later version.
|
||||
|
||||
15. Disclaimer of Warranty.
|
||||
|
||||
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
|
||||
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
|
||||
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
|
||||
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
|
||||
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
|
||||
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
|
||||
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
|
||||
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
|
||||
|
||||
16. Limitation of Liability.
|
||||
|
||||
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
|
||||
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
|
||||
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
|
||||
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
|
||||
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
|
||||
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
|
||||
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
|
||||
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
|
||||
SUCH DAMAGES.
|
||||
|
||||
17. Interpretation of Sections 15 and 16.
|
||||
|
||||
If the disclaimer of warranty and limitation of liability provided
|
||||
above cannot be given local legal effect according to their terms,
|
||||
reviewing courts shall apply local law that most closely approximates
|
||||
an absolute waiver of all civil liability in connection with the
|
||||
Program, unless a warranty or assumption of liability accompanies a
|
||||
copy of the Program in return for a fee.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
How to Apply These Terms to Your New Programs
|
||||
|
||||
If you develop a new program, and you want it to be of the greatest
|
||||
possible use to the public, the best way to achieve this is to make it
|
||||
free software which everyone can redistribute and change under these terms.
|
||||
|
||||
To do so, attach the following notices to the program. It is safest
|
||||
to attach them to the start of each source file to most effectively
|
||||
state the exclusion of warranty; and each file should have at least
|
||||
the "copyright" line and a pointer to where the full notice is found.
|
||||
|
||||
<one line to give the program's name and a brief idea of what it does.>
|
||||
Copyright (C) <year> <name of author>
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
Also add information on how to contact you by electronic and paper mail.
|
||||
|
||||
If the program does terminal interaction, make it output a short
|
||||
notice like this when it starts in an interactive mode:
|
||||
|
||||
<program> Copyright (C) <year> <name of author>
|
||||
This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
|
||||
This is free software, and you are welcome to redistribute it
|
||||
under certain conditions; type `show c' for details.
|
||||
|
||||
The hypothetical commands `show w' and `show c' should show the appropriate
|
||||
parts of the General Public License. Of course, your program's commands
|
||||
might be different; for a GUI interface, you would use an "about box".
|
||||
|
||||
You should also get your employer (if you work as a programmer) or school,
|
||||
if any, to sign a "copyright disclaimer" for the program, if necessary.
|
||||
For more information on this, and how to apply and follow the GNU GPL, see
|
||||
<https://www.gnu.org/licenses/>.
|
||||
|
||||
The GNU General Public License does not permit incorporating your program
|
||||
into proprietary programs. If your program is a subroutine library, you
|
||||
may consider it more useful to permit linking proprietary applications with
|
||||
the library. If this is what you want to do, use the GNU Lesser General
|
||||
Public License instead of this License. But first, please read
|
||||
<https://www.gnu.org/licenses/why-not-lgpl.html>.
|
||||
|
||||
18
README.md
18
README.md
@@ -1,2 +1,18 @@
|
||||
# SolarEnergy
|
||||
WIP project
|
||||
A project to optimize solar energy production and consumption. It connect to a current meter to decides whether some appliances controlled by relays shall be turned on or not, based on criterias such as:
|
||||
* Minimal uptime
|
||||
* Minimal downtime
|
||||
* Daily minimal runtime
|
||||
* Estimated consumption
|
||||
* Dependencies, conflicts
|
||||
|
||||
## Components
|
||||
* [`central_backend`](./central_backend): The core component that connects all the other one and make the decisions to turn on or off devices
|
||||
* [`central_frontend`](./central_frontend): Web UI to configure the devices and monitor them
|
||||
* [`custom_consumtion`](./custom_consumption): Development tool used to test different production values
|
||||
* [`esp32_device`](./esp32_device/): The code installed in the MCU that controls relays (Wt32-Eth01 devices)
|
||||
* [`python_device`](./python_device/): An alternative to the esp32 to control relays. Not production ready.
|
||||
|
||||
## Documentation
|
||||
* [Setup for development](./docs/SETUP_DEV.md) guide
|
||||
* [Setup for production](./docs/SETUP_PROD.md) guide
|
||||
2099
central_backend/Cargo.lock
generated
2099
central_backend/Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
@@ -1,41 +1,46 @@
|
||||
[package]
|
||||
name = "central_backend"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
version = "1.0.3"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
log = "0.4.22"
|
||||
env_logger = "0.11.5"
|
||||
log = "0.4.28"
|
||||
env_logger = "0.11.8"
|
||||
lazy_static = "1.5.0"
|
||||
clap = { version = "4.5.18", features = ["derive", "env"] }
|
||||
anyhow = "1.0.89"
|
||||
thiserror = "1.0.63"
|
||||
openssl = { version = "0.10.66" }
|
||||
openssl-sys = "0.9.102"
|
||||
libc = "0.2.158"
|
||||
dotenvy = "0.15.7"
|
||||
clap = { version = "4.5.50", features = ["derive", "env"] }
|
||||
anyhow = "1.0.100"
|
||||
thiserror = "2.0.17"
|
||||
openssl = { version = "0.10.74" }
|
||||
openssl-sys = "0.9.110"
|
||||
libc = "0.2.177"
|
||||
foreign-types-shared = "0.1.1"
|
||||
asn1 = "0.17"
|
||||
actix-web = { version = "4", features = ["openssl"] }
|
||||
futures = "0.3.30"
|
||||
serde = { version = "1.0.210", features = ["derive"] }
|
||||
reqwest = "0.12.7"
|
||||
serde_json = "1.0.128"
|
||||
rand = "0.8.5"
|
||||
asn1 = "0.23.0"
|
||||
actix-web = { version = "4.11.0", features = ["openssl"] }
|
||||
futures = "0.3.31"
|
||||
serde = { version = "1.0.228", features = ["derive"] }
|
||||
reqwest = { version = "0.12.24", features = ["json"] }
|
||||
serde_json = "1.0.145"
|
||||
rand = "0.10.0-rc.0"
|
||||
actix = "0.13.5"
|
||||
actix-identity = "0.8.0"
|
||||
actix-session = { version = "0.10.1", features = ["cookie-session"] }
|
||||
actix-cors = "0.7.0"
|
||||
actix-identity = "0.9.0"
|
||||
actix-session = { version = "0.11.0", features = ["cookie-session"] }
|
||||
actix-cors = "0.7.1"
|
||||
actix-multipart = { version = "0.7.2", features = ["derive"] }
|
||||
actix-remote-ip = "0.1.0"
|
||||
futures-util = "0.3.30"
|
||||
uuid = { version = "1.10.0", features = ["v4", "serde"] }
|
||||
semver = { version = "1.0.23", features = ["serde"] }
|
||||
lazy-regex = "3.3.0"
|
||||
tokio = { version = "1.40.0", features = ["full"] }
|
||||
futures-util = "0.3.31"
|
||||
uuid = { version = "1.18.1", features = ["v4", "serde"] }
|
||||
semver = { version = "1.0.27", features = ["serde"] }
|
||||
lazy-regex = "3.4.1"
|
||||
tokio = { version = "1.48.0", features = ["full"] }
|
||||
tokio_schedule = "0.3.2"
|
||||
mime_guess = "2.0.5"
|
||||
rust-embed = "8.5.0"
|
||||
jsonwebtoken = { version = "9.3.0", features = ["use_pem"] }
|
||||
rust-embed = "8.8.0"
|
||||
jsonwebtoken = { version = "10.1.0", features = ["use_pem", "rust_crypto"] }
|
||||
prettytable-rs = "0.10.0"
|
||||
chrono = "0.4.38"
|
||||
chrono = "0.4.42"
|
||||
serde_yml = "0.0.12"
|
||||
bincode = "=2.0.0-rc.3"
|
||||
bincode = "2.0.1"
|
||||
fs4 = { version = "0.13.1", features = ["sync"] }
|
||||
zip = { version = "6.0.0", features = ["bzip2"] }
|
||||
walkdir = "2.5.0"
|
||||
|
||||
33
central_backend/engine_test/test_turn_forced_off.yaml
Normal file
33
central_backend/engine_test/test_turn_forced_off.yaml
Normal file
@@ -0,0 +1,33 @@
|
||||
devices:
|
||||
- id: dev1
|
||||
info:
|
||||
reference: A
|
||||
version: 0.0.1
|
||||
max_relays: 1
|
||||
time_create: 1
|
||||
time_update: 1
|
||||
name: Dev1
|
||||
description: Day1
|
||||
validated: true
|
||||
enabled: true
|
||||
relays:
|
||||
- id: dcb3fd91-bf9b-4de3-99e5-92c1c7dd72e9
|
||||
name: R1
|
||||
enabled: true
|
||||
priority: 1
|
||||
consumption: 100
|
||||
minimal_uptime: 10
|
||||
minimal_downtime: 10
|
||||
depends_on: []
|
||||
conflicts_with: []
|
||||
|
||||
on: false
|
||||
for: 5000
|
||||
forced_state:
|
||||
type: Off
|
||||
for_secs: 500
|
||||
should_be_on: false
|
||||
|
||||
online: true
|
||||
|
||||
curr_consumption: -10000
|
||||
49
central_backend/engine_test/test_turn_forced_on.yaml
Normal file
49
central_backend/engine_test/test_turn_forced_on.yaml
Normal file
@@ -0,0 +1,49 @@
|
||||
devices:
|
||||
- id: dev1
|
||||
info:
|
||||
reference: A
|
||||
version: 0.0.1
|
||||
max_relays: 1
|
||||
time_create: 1
|
||||
time_update: 1
|
||||
name: Dev1
|
||||
description: Day1
|
||||
validated: true
|
||||
enabled: true
|
||||
relays:
|
||||
- id: dcb3fd91-bf9b-4de3-99e5-92c1c7dd72e9
|
||||
name: R1
|
||||
enabled: true
|
||||
priority: 1
|
||||
consumption: 100
|
||||
minimal_uptime: 10
|
||||
minimal_downtime: 10
|
||||
depends_on: []
|
||||
conflicts_with: []
|
||||
|
||||
on: false
|
||||
for: 500
|
||||
forced_state:
|
||||
type: On
|
||||
for_secs: 500
|
||||
should_be_on: true
|
||||
|
||||
- id: dcb3fd91-bf9b-4de3-99e5-92c1c7dd72f0
|
||||
name: R2
|
||||
enabled: true
|
||||
priority: 1
|
||||
consumption: 100
|
||||
minimal_uptime: 10
|
||||
minimal_downtime: 10
|
||||
depends_on: [ ]
|
||||
conflicts_with: [ ]
|
||||
|
||||
on: false
|
||||
for: 500
|
||||
forced_state:
|
||||
type: None
|
||||
should_be_on: false
|
||||
|
||||
online: true
|
||||
|
||||
curr_consumption: 10000
|
||||
@@ -1,4 +1,5 @@
|
||||
use crate::devices::device::{DeviceId, DeviceRelayID};
|
||||
use crate::ota::ota_update::OTAPlatform;
|
||||
use clap::{Parser, Subcommand};
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
@@ -9,7 +10,7 @@ pub enum ConsumptionHistoryType {
|
||||
}
|
||||
|
||||
/// Electrical consumption fetcher backend
|
||||
#[derive(Subcommand, Debug, Clone)]
|
||||
#[derive(Subcommand, Debug, Clone, serde::Serialize)]
|
||||
pub enum ConsumptionBackend {
|
||||
/// Constant consumption value
|
||||
Constant {
|
||||
@@ -34,12 +35,27 @@ pub enum ConsumptionBackend {
|
||||
#[clap(short, long, default_value = "/dev/shm/consumption.txt")]
|
||||
path: String,
|
||||
},
|
||||
|
||||
/// Fronius inverter consumption
|
||||
Fronius {
|
||||
/// The origin of the domain where the webserver of the Fronius Symo can be reached
|
||||
#[clap(short, long, env)]
|
||||
fronius_orig: String,
|
||||
|
||||
/// Use cURL instead of reqwest to perform request
|
||||
#[clap(short, long)]
|
||||
curl: bool,
|
||||
},
|
||||
}
|
||||
|
||||
/// Solar system central backend
|
||||
#[derive(Parser, Debug)]
|
||||
#[derive(Parser, Debug, serde::Serialize)]
|
||||
#[command(version, about, long_about = None)]
|
||||
pub struct AppConfig {
|
||||
/// Read arguments from env file
|
||||
#[clap(short, long, env)]
|
||||
pub config: Option<String>,
|
||||
|
||||
/// Proxy IP, might end with a star "*"
|
||||
#[clap(short, long, env)]
|
||||
pub proxy_ip: Option<String>,
|
||||
@@ -94,6 +110,18 @@ pub struct AppConfig {
|
||||
#[arg(short('f'), long, env, default_value_t = 5)]
|
||||
pub energy_fetch_interval: u64,
|
||||
|
||||
/// Custom current consumption title in dashboard
|
||||
#[arg(long, env)]
|
||||
pub dashboard_custom_current_consumption_title: Option<String>,
|
||||
|
||||
/// Custom relays consumption title in dashboard
|
||||
#[arg(long, env)]
|
||||
pub dashboard_custom_relays_consumption_title: Option<String>,
|
||||
|
||||
/// Custom cached consumption title in dashboard
|
||||
#[arg(long, env)]
|
||||
pub dashboard_custom_cached_consumption_title: Option<String>,
|
||||
|
||||
/// Consumption backend provider
|
||||
#[clap(subcommand)]
|
||||
pub consumption_backend: Option<ConsumptionBackend>,
|
||||
@@ -106,6 +134,21 @@ lazy_static::lazy_static! {
|
||||
}
|
||||
|
||||
impl AppConfig {
|
||||
/// Parse environment variables from file, if requedst
|
||||
pub fn parse_env_file() -> anyhow::Result<()> {
|
||||
if let Some(c) = Self::parse().config {
|
||||
log::info!("Load additional environment variables from {c}");
|
||||
let conf_file = Path::new(&c);
|
||||
|
||||
if !conf_file.is_file() {
|
||||
panic!("Specified configuration is not a file!");
|
||||
}
|
||||
dotenvy::from_path(conf_file)?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Get parsed command line arguments
|
||||
pub fn get() -> &'static AppConfig {
|
||||
&ARGS
|
||||
@@ -283,6 +326,31 @@ impl AppConfig {
|
||||
}
|
||||
))
|
||||
}
|
||||
|
||||
/// Get logs directory
|
||||
pub fn logs_dir(&self) -> PathBuf {
|
||||
self.storage_path().join("logs")
|
||||
}
|
||||
|
||||
/// Get the logs for a given day
|
||||
pub fn log_of_day(&self, day: u64) -> PathBuf {
|
||||
self.logs_dir().join(format!("{day}.log"))
|
||||
}
|
||||
|
||||
/// Get the directory that will store OTA updates
|
||||
pub fn ota_dir(&self) -> PathBuf {
|
||||
self.storage_path().join("ota")
|
||||
}
|
||||
|
||||
/// Get the directory that will store OTA updates for a given platform
|
||||
pub fn ota_platform_dir(&self, platform: OTAPlatform) -> PathBuf {
|
||||
self.ota_dir().join(platform.to_string())
|
||||
}
|
||||
|
||||
/// Get the path to the file that will contain an OTA update
|
||||
pub fn path_ota_update(&self, platform: OTAPlatform, version: &semver::Version) -> PathBuf {
|
||||
self.ota_platform_dir(platform).join(version.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -13,6 +13,9 @@ pub const MAX_INACTIVITY_DURATION: u64 = 3600;
|
||||
/// Maximum session duration (1 day)
|
||||
pub const MAX_SESSION_DURATION: u64 = 3600 * 24;
|
||||
|
||||
/// Maximum firmware size (in bytes)
|
||||
pub const MAX_FIRMWARE_SIZE: usize = 50 * 1000 * 1000;
|
||||
|
||||
/// List of routes that do not require authentication
|
||||
pub const ROUTES_WITHOUT_AUTH: [&str; 2] =
|
||||
["/web_api/server/config", "/web_api/auth/password_auth"];
|
||||
|
||||
@@ -16,11 +16,13 @@ impl CRLDistributionPointExt {
|
||||
let crl_bytes = asn1::write(|w| {
|
||||
w.write_element(&asn1::SequenceWriter::new(&|w| {
|
||||
w.write_element(&asn1::SequenceWriter::new(&|w| {
|
||||
w.write_tlv(tag_a0, |w| {
|
||||
w.write_tlv(tag_a0, None, |w: &mut asn1::WriteBuf| {
|
||||
w.push_slice(&asn1::write(|w| {
|
||||
w.write_tlv(tag_a0, |w| {
|
||||
w.write_tlv(tag_a0, None, |w: &mut asn1::WriteBuf| {
|
||||
w.push_slice(&asn1::write(|w| {
|
||||
w.write_tlv(tag_86, |b| b.push_slice(self.url.as_bytes()))?;
|
||||
w.write_tlv(tag_86, None, |b| {
|
||||
b.push_slice(self.url.as_bytes())
|
||||
})?;
|
||||
Ok(())
|
||||
})?)
|
||||
})?;
|
||||
|
||||
@@ -13,10 +13,10 @@ use openssl::pkey::{PKey, Private};
|
||||
use openssl::x509::extension::{
|
||||
BasicConstraints, ExtendedKeyUsage, KeyUsage, SubjectAlternativeName, SubjectKeyIdentifier,
|
||||
};
|
||||
use openssl::x509::{CrlStatus, X509Crl, X509Name, X509NameBuilder, X509Req, X509};
|
||||
use openssl::x509::{CrlStatus, X509, X509Crl, X509Name, X509NameBuilder, X509Req};
|
||||
use openssl_sys::{
|
||||
X509_CRL_add0_revoked, X509_CRL_new, X509_CRL_set1_lastUpdate, X509_CRL_set1_nextUpdate,
|
||||
X509_CRL_set_issuer_name, X509_CRL_set_version, X509_CRL_sign, X509_REVOKED_dup,
|
||||
X509_CRL_add0_revoked, X509_CRL_new, X509_CRL_set_issuer_name, X509_CRL_set_version,
|
||||
X509_CRL_set1_lastUpdate, X509_CRL_set1_nextUpdate, X509_CRL_sign, X509_REVOKED_dup,
|
||||
X509_REVOKED_new, X509_REVOKED_set_revocationDate, X509_REVOKED_set_serialNumber,
|
||||
};
|
||||
|
||||
@@ -120,7 +120,7 @@ enum GenCertificatSubjectReq<'a> {
|
||||
CSR { csr: &'a X509Req },
|
||||
}
|
||||
|
||||
impl<'a> Default for GenCertificatSubjectReq<'a> {
|
||||
impl Default for GenCertificatSubjectReq<'_> {
|
||||
fn default() -> Self {
|
||||
Self::Subject { cn: "" }
|
||||
}
|
||||
@@ -174,17 +174,16 @@ fn gen_certificate(req: GenCertificateReq) -> anyhow::Result<(Option<Vec<u8>>, V
|
||||
cert_builder.set_not_after(¬_after)?;
|
||||
|
||||
// Specify CRL URL
|
||||
if let Some(issuer) = req.issuer {
|
||||
if let Some(crl) = &issuer.crl {
|
||||
if let Some(issuer) = req.issuer
|
||||
&& let Some(crl) = &issuer.crl
|
||||
{
|
||||
let crl_url = format!(
|
||||
"{}/pki/{}",
|
||||
AppConfig::get().unsecure_origin(),
|
||||
crl.file_name().unwrap().to_string_lossy()
|
||||
);
|
||||
|
||||
cert_builder
|
||||
.append_extension(CRLDistributionPointExt { url: crl_url }.as_extension()?)?;
|
||||
}
|
||||
cert_builder.append_extension(CRLDistributionPointExt { url: crl_url }.as_extension()?)?;
|
||||
}
|
||||
|
||||
// If cert is a CA or not
|
||||
@@ -424,15 +423,15 @@ fn refresh_crl(d: &CertData, new_cert: Option<&X509>) -> anyhow::Result<()> {
|
||||
}
|
||||
|
||||
// Add old entries
|
||||
if let Some(old_crl) = old_crl {
|
||||
if let Some(entries) = old_crl.get_revoked() {
|
||||
if let Some(old_crl) = old_crl
|
||||
&& let Some(entries) = old_crl.get_revoked()
|
||||
{
|
||||
for entry in entries {
|
||||
if X509_CRL_add0_revoked(crl, X509_REVOKED_dup(entry.as_ptr())) == 0 {
|
||||
return Err(PKIError::GenCRLError("X509_CRL_add0_revoked").into());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If requested, add new entry
|
||||
if let Some(new_cert) = new_cert {
|
||||
|
||||
@@ -9,9 +9,9 @@ use std::collections::{HashMap, HashSet};
|
||||
#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
|
||||
pub struct DeviceInfo {
|
||||
/// Device reference
|
||||
reference: String,
|
||||
pub reference: String,
|
||||
/// Device firmware / software version
|
||||
version: semver::Version,
|
||||
pub version: semver::Version,
|
||||
/// Maximum number of relay that the device can support
|
||||
pub max_relays: usize,
|
||||
}
|
||||
@@ -62,6 +62,9 @@ pub struct Device {
|
||||
///
|
||||
/// There cannot be more than [info.max_relays] relays
|
||||
pub relays: Vec<DeviceRelay>,
|
||||
/// Desired version, ie. the version of the software we would to seen run on the device
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub desired_version: Option<semver::Version>,
|
||||
}
|
||||
|
||||
/// Structure that contains information about the minimal expected execution
|
||||
@@ -322,9 +325,11 @@ mod tests {
|
||||
..Default::default()
|
||||
};
|
||||
dep_cycle_1.depends_on = vec![dep_cycle_3.id];
|
||||
assert!(dep_cycle_1
|
||||
assert!(
|
||||
dep_cycle_1
|
||||
.error(&[dep_cycle_2.clone(), dep_cycle_3.clone()])
|
||||
.is_some());
|
||||
.is_some()
|
||||
);
|
||||
|
||||
dep_cycle_1.depends_on = vec![];
|
||||
assert!(dep_cycle_1.error(&[dep_cycle_2, dep_cycle_3]).is_none());
|
||||
@@ -348,21 +353,29 @@ mod tests {
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
assert!(target_relay
|
||||
assert!(
|
||||
target_relay
|
||||
.error(&[other_dep.clone(), second_dep.clone()])
|
||||
.is_some());
|
||||
assert!(target_relay
|
||||
.is_some()
|
||||
);
|
||||
assert!(
|
||||
target_relay
|
||||
.error(&[other_dep.clone(), second_dep.clone(), target_relay.clone()])
|
||||
.is_some());
|
||||
.is_some()
|
||||
);
|
||||
|
||||
second_dep.conflicts_with = vec![];
|
||||
|
||||
assert!(target_relay
|
||||
assert!(
|
||||
target_relay
|
||||
.error(&[other_dep.clone(), second_dep.clone()])
|
||||
.is_none());
|
||||
assert!(target_relay
|
||||
.is_none()
|
||||
);
|
||||
assert!(
|
||||
target_relay
|
||||
.error(&[other_dep.clone(), second_dep.clone(), target_relay.clone()])
|
||||
.is_none());
|
||||
.is_none()
|
||||
);
|
||||
|
||||
// self loop
|
||||
let mut self_loop = DeviceRelay {
|
||||
|
||||
@@ -4,7 +4,7 @@ use crate::devices::device::{
|
||||
Device, DeviceGeneralInfo, DeviceId, DeviceInfo, DeviceRelay, DeviceRelayID,
|
||||
};
|
||||
use crate::utils::time_utils::time_secs;
|
||||
use openssl::x509::{X509Req, X509};
|
||||
use openssl::x509::{X509, X509Req};
|
||||
use std::collections::HashMap;
|
||||
|
||||
#[derive(thiserror::Error, Debug)]
|
||||
@@ -84,6 +84,7 @@ impl DevicesList {
|
||||
validated: false,
|
||||
enabled: false,
|
||||
relays: vec![],
|
||||
desired_version: None,
|
||||
};
|
||||
|
||||
// First, write CSR
|
||||
@@ -186,6 +187,24 @@ impl DevicesList {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Set a device desired version
|
||||
pub fn set_desired_version(
|
||||
&mut self,
|
||||
id: &DeviceId,
|
||||
version: Option<semver::Version>,
|
||||
) -> anyhow::Result<()> {
|
||||
let dev = self
|
||||
.0
|
||||
.get_mut(id)
|
||||
.ok_or(DevicesListError::UpdateDeviceFailedDeviceNotFound)?;
|
||||
|
||||
dev.desired_version = version;
|
||||
|
||||
self.persist_dev_config(id)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Get single certificate information
|
||||
fn get_cert(&self, id: &DeviceId) -> anyhow::Result<X509> {
|
||||
let dev = self
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
use crate::app_config::{AppConfig, ConsumptionBackend};
|
||||
use rand::{thread_rng, Rng};
|
||||
use rand::{Rng, rng};
|
||||
use std::num::ParseIntError;
|
||||
use std::path::Path;
|
||||
|
||||
@@ -9,10 +9,36 @@ pub enum ConsumptionError {
|
||||
NonExistentFile,
|
||||
#[error("The file that should contain the consumption has an invalid content!")]
|
||||
FileInvalidContent(#[source] ParseIntError),
|
||||
#[error("Failed to execute cURL request!")]
|
||||
CurlReqFailed,
|
||||
}
|
||||
|
||||
pub type EnergyConsumption = i32;
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
struct FroniusResponse {
|
||||
#[serde(rename = "Body")]
|
||||
body: FroniusResponseBody,
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
struct FroniusResponseBody {
|
||||
#[serde(rename = "Data")]
|
||||
data: FroniusResponseBodyData,
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
struct FroniusResponseBodyData {
|
||||
#[serde(rename = "Site")]
|
||||
site: FroniusResponseSite,
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
struct FroniusResponseSite {
|
||||
#[serde(rename = "P_Grid")]
|
||||
grid_production: f64,
|
||||
}
|
||||
|
||||
/// Get current electrical energy consumption
|
||||
pub async fn get_curr_consumption() -> anyhow::Result<EnergyConsumption> {
|
||||
let backend = AppConfig::get()
|
||||
@@ -23,7 +49,7 @@ pub async fn get_curr_consumption() -> anyhow::Result<EnergyConsumption> {
|
||||
match backend {
|
||||
ConsumptionBackend::Constant { value } => Ok(*value),
|
||||
|
||||
ConsumptionBackend::Random { min, max } => Ok(thread_rng().gen_range(*min..*max)),
|
||||
ConsumptionBackend::Random { min, max } => Ok(rng().random_range(*min..*max)),
|
||||
|
||||
ConsumptionBackend::File { path } => {
|
||||
let path = Path::new(path);
|
||||
@@ -38,5 +64,28 @@ pub async fn get_curr_consumption() -> anyhow::Result<EnergyConsumption> {
|
||||
.parse()
|
||||
.map_err(ConsumptionError::FileInvalidContent)?)
|
||||
}
|
||||
|
||||
ConsumptionBackend::Fronius { fronius_orig, curl } => {
|
||||
let url = format!("{fronius_orig}/solar_api/v1/GetPowerFlowRealtimeData.fcgi");
|
||||
|
||||
let response = match curl {
|
||||
false => reqwest::get(url).await?.json::<FroniusResponse>().await?,
|
||||
true => {
|
||||
let res = std::process::Command::new("curl")
|
||||
.arg("--connect-timeout")
|
||||
.arg("1.5")
|
||||
.arg(url)
|
||||
.output()?;
|
||||
|
||||
if !res.status.success() {
|
||||
return Err(ConsumptionError::CurlReqFailed.into());
|
||||
}
|
||||
|
||||
serde_json::from_slice::<FroniusResponse>(&res.stdout)?
|
||||
}
|
||||
};
|
||||
|
||||
Ok(response.body.data.site.grid_production as i32)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -132,12 +132,14 @@ impl ConsumptionHistoryFile {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::app_config::ConsumptionHistoryType;
|
||||
use crate::energy::consumption::EnergyConsumption;
|
||||
use crate::energy::consumption_history_file::{ConsumptionHistoryFile, TIME_INTERVAL};
|
||||
|
||||
#[test]
|
||||
fn test_consumption_history() {
|
||||
let mut history = ConsumptionHistoryFile::new_memory(0);
|
||||
let mut history =
|
||||
ConsumptionHistoryFile::new_memory(0, ConsumptionHistoryType::GridConsumption);
|
||||
|
||||
for i in 0..50 {
|
||||
assert_eq!(
|
||||
|
||||
@@ -8,7 +8,7 @@ use crate::energy::consumption;
|
||||
use crate::energy::consumption::EnergyConsumption;
|
||||
use crate::energy::consumption_cache::ConsumptionCache;
|
||||
use crate::energy::consumption_history_file::ConsumptionHistoryFile;
|
||||
use crate::energy::engine::EnergyEngine;
|
||||
use crate::energy::engine::{EnergyEngine, RelayForcedState};
|
||||
use crate::utils::time_utils::time_secs;
|
||||
use actix::prelude::*;
|
||||
use openssl::x509::X509Req;
|
||||
@@ -25,7 +25,14 @@ impl EnergyActor {
|
||||
pub async fn new() -> anyhow::Result<Self> {
|
||||
let consumption_cache_size =
|
||||
AppConfig::get().refresh_interval / AppConfig::get().energy_fetch_interval;
|
||||
let curr_consumption = consumption::get_curr_consumption().await?;
|
||||
let curr_consumption = match consumption::get_curr_consumption().await {
|
||||
Ok(v) => v,
|
||||
Err(e) => {
|
||||
log::warn!("Failed to fetch consumption, using default value! {e}");
|
||||
constants::FALLBACK_PRODUCTION_VALUE
|
||||
}
|
||||
};
|
||||
log::info!("Initial consumption value: {curr_consumption}");
|
||||
let mut consumption_cache = ConsumptionCache::new(consumption_cache_size as usize);
|
||||
consumption_cache.add_value(curr_consumption);
|
||||
|
||||
@@ -195,6 +202,27 @@ impl Handler<UpdateDeviceGeneralInfo> for EnergyActor {
|
||||
}
|
||||
}
|
||||
|
||||
/// Set device desired version
|
||||
#[derive(Message)]
|
||||
#[rtype(result = "anyhow::Result<()>")]
|
||||
pub struct SetDesiredVersion(pub DeviceId, pub Option<semver::Version>);
|
||||
|
||||
impl Handler<SetDesiredVersion> for EnergyActor {
|
||||
type Result = anyhow::Result<()>;
|
||||
|
||||
fn handle(&mut self, msg: SetDesiredVersion, _ctx: &mut Context<Self>) -> Self::Result {
|
||||
log::info!(
|
||||
"Requested to update device desired version {:?} => {:#?}",
|
||||
&msg.0,
|
||||
&msg.1
|
||||
);
|
||||
|
||||
self.devices.set_desired_version(&msg.0, msg.1)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Delete a device
|
||||
#[derive(Message)]
|
||||
#[rtype(result = "anyhow::Result<()>")]
|
||||
@@ -300,6 +328,19 @@ impl Handler<UpdateDeviceRelay> for EnergyActor {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Message)]
|
||||
#[rtype(result = "anyhow::Result<()>")]
|
||||
pub struct SetRelayForcedState(pub DeviceRelayID, pub RelayForcedState);
|
||||
|
||||
impl Handler<SetRelayForcedState> for EnergyActor {
|
||||
type Result = anyhow::Result<()>;
|
||||
|
||||
fn handle(&mut self, msg: SetRelayForcedState, _ctx: &mut Context<Self>) -> Self::Result {
|
||||
self.engine.relay_state(msg.0).set_forced(msg.1);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Delete a device relay
|
||||
#[derive(Message)]
|
||||
#[rtype(result = "anyhow::Result<()>")]
|
||||
@@ -378,8 +419,9 @@ impl Handler<GetDevicesState> for EnergyActor {
|
||||
#[derive(serde::Serialize)]
|
||||
pub struct ResRelayState {
|
||||
pub id: DeviceRelayID,
|
||||
on: bool,
|
||||
r#for: usize,
|
||||
pub on: bool,
|
||||
pub r#for: usize,
|
||||
pub forced_state: RelayForcedState,
|
||||
}
|
||||
|
||||
/// Get the state of all relays
|
||||
@@ -399,6 +441,7 @@ impl Handler<GetAllRelaysState> for EnergyActor {
|
||||
id: d.id,
|
||||
on: state.is_on(),
|
||||
r#for: state.state_for(),
|
||||
forced_state: state.actual_forced_state(),
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
use std::collections::HashMap;
|
||||
|
||||
use crate::app_config::AppConfig;
|
||||
use prettytable::{row, Table};
|
||||
use prettytable::{Table, row};
|
||||
|
||||
use crate::constants;
|
||||
use crate::devices::device::{Device, DeviceId, DeviceRelay, DeviceRelayID};
|
||||
use crate::energy::consumption::EnergyConsumption;
|
||||
use crate::energy::relay_state_history;
|
||||
use crate::energy::relay_state_history::RelayStateHistory;
|
||||
use crate::utils::time_utils::{curr_hour, time_secs, time_start_of_day};
|
||||
use crate::utils::time_utils::{curr_hour, time_secs};
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct DeviceState {
|
||||
@@ -25,19 +25,83 @@ impl DeviceState {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default, Debug, Clone, Eq, PartialEq, serde::Serialize, serde::Deserialize)]
|
||||
#[serde(tag = "type")]
|
||||
pub enum SetRelayForcedStateReq {
|
||||
#[default]
|
||||
None,
|
||||
Off {
|
||||
for_secs: u64,
|
||||
},
|
||||
On {
|
||||
for_secs: u64,
|
||||
},
|
||||
}
|
||||
|
||||
impl SetRelayForcedStateReq {
|
||||
pub fn to_forced_state(&self) -> RelayForcedState {
|
||||
match &self {
|
||||
SetRelayForcedStateReq::None => RelayForcedState::None,
|
||||
SetRelayForcedStateReq::Off { for_secs } => RelayForcedState::Off {
|
||||
until: time_secs() + for_secs,
|
||||
},
|
||||
SetRelayForcedStateReq::On { for_secs } => RelayForcedState::On {
|
||||
until: time_secs() + for_secs,
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default, Debug, Clone, Eq, PartialEq, serde::Serialize, serde::Deserialize)]
|
||||
#[serde(tag = "type")]
|
||||
pub enum RelayForcedState {
|
||||
#[default]
|
||||
None,
|
||||
Off {
|
||||
until: u64,
|
||||
},
|
||||
On {
|
||||
until: u64,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Default, Clone)]
|
||||
pub struct RelayState {
|
||||
on: bool,
|
||||
since: usize,
|
||||
forced_state: RelayForcedState,
|
||||
}
|
||||
|
||||
impl RelayState {
|
||||
/// Get actual forced state (returns None if state is expired)
|
||||
pub fn actual_forced_state(&self) -> RelayForcedState {
|
||||
match self.forced_state {
|
||||
RelayForcedState::Off { until } if until > time_secs() => {
|
||||
RelayForcedState::Off { until }
|
||||
}
|
||||
RelayForcedState::On { until } if until > time_secs() => RelayForcedState::On { until },
|
||||
_ => RelayForcedState::None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn is_on(&self) -> bool {
|
||||
self.on
|
||||
let forced_state = self.actual_forced_state();
|
||||
(self.on || matches!(forced_state, RelayForcedState::On { .. }))
|
||||
&& !matches!(forced_state, RelayForcedState::Off { .. })
|
||||
}
|
||||
|
||||
fn is_off(&self) -> bool {
|
||||
!self.on
|
||||
!self.is_on()
|
||||
}
|
||||
|
||||
/// Check if relay state is enforced
|
||||
pub fn is_forced(&self) -> bool {
|
||||
self.actual_forced_state() != RelayForcedState::None
|
||||
}
|
||||
|
||||
pub fn set_forced(&mut self, s: RelayForcedState) {
|
||||
self.since = time_secs() as usize;
|
||||
self.forced_state = s;
|
||||
}
|
||||
|
||||
pub fn state_for(&self) -> usize {
|
||||
@@ -146,7 +210,11 @@ impl EnergyEngine {
|
||||
r.name,
|
||||
r.consumption,
|
||||
format!("{} / {}", r.minimal_downtime, r.minimal_uptime),
|
||||
status.is_on().to_string(),
|
||||
status.is_on().to_string()
|
||||
+ match status.is_forced() {
|
||||
true => " (Forced)",
|
||||
false => "",
|
||||
},
|
||||
status.since,
|
||||
match dev_online {
|
||||
true => "Online",
|
||||
@@ -192,19 +260,28 @@ impl EnergyEngine {
|
||||
|
||||
let mut new_relays_state = self.relays_state.clone();
|
||||
|
||||
// Forcefully turn off relays that belongs to offline devices
|
||||
// Forcefully turn off disabled relays
|
||||
for d in devices {
|
||||
if !self.device_state(&d.id).is_online() {
|
||||
for r in &d.relays {
|
||||
if !r.enabled || !d.enabled {
|
||||
new_relays_state.get_mut(&r.id).unwrap().on = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Forcefully turn off disabled relays
|
||||
// Apply forced relays state
|
||||
for d in devices {
|
||||
for r in &d.relays {
|
||||
if !r.enabled || !d.enabled {
|
||||
if self.relay_state(r.id).is_forced() {
|
||||
new_relays_state.get_mut(&r.id).unwrap().on = self.relay_state(r.id).is_on();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Forcefully turn off relays that belongs to offline devices
|
||||
for d in devices {
|
||||
if !self.device_state(&d.id).is_online() {
|
||||
for r in &d.relays {
|
||||
new_relays_state.get_mut(&r.id).unwrap().on = false;
|
||||
}
|
||||
}
|
||||
@@ -216,7 +293,9 @@ impl EnergyEngine {
|
||||
|
||||
for d in devices {
|
||||
for r in &d.relays {
|
||||
if new_relays_state.get(&r.id).unwrap().is_off() {
|
||||
if new_relays_state.get(&r.id).unwrap().is_off()
|
||||
|| new_relays_state.get(&r.id).unwrap().is_forced()
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -240,7 +319,7 @@ impl EnergyEngine {
|
||||
for d in devices {
|
||||
for r in &d.relays {
|
||||
let state = new_relays_state.get(&r.id).unwrap();
|
||||
if state.is_off() {
|
||||
if state.is_off() || state.is_forced() {
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -271,7 +350,9 @@ impl EnergyEngine {
|
||||
continue;
|
||||
}
|
||||
|
||||
if new_relays_state.get(&r.id).unwrap().is_on() {
|
||||
if new_relays_state.get(&r.id).unwrap().is_on()
|
||||
|| new_relays_state.get(&r.id).unwrap().is_forced()
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -283,23 +364,22 @@ impl EnergyEngine {
|
||||
continue;
|
||||
}
|
||||
|
||||
let time_start_day = time_start_of_day().unwrap_or(1726696800);
|
||||
let start_time = time_start_day + constraints.reset_time as u64;
|
||||
let end_time = time_start_day + 3600 * 24 + constraints.reset_time as u64;
|
||||
let total_runtime =
|
||||
relay_state_history::relay_total_runtime(r.id, start_time, end_time)
|
||||
.unwrap_or(3600 * 24);
|
||||
let total_runtime = relay_state_history::relay_total_runtime_adjusted(r);
|
||||
|
||||
if total_runtime > constraints.min_runtime {
|
||||
continue;
|
||||
}
|
||||
|
||||
log::info!("Forcefully turn on relay {} to catch up running constraints (only {}s this day)", r.name, total_runtime);
|
||||
log::info!(
|
||||
"Forcefully turn on relay {} to catch up running constraints (only {}s this day)",
|
||||
r.name,
|
||||
total_runtime
|
||||
);
|
||||
new_relays_state.get_mut(&r.id).unwrap().on = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Order relays
|
||||
// Order relays to select the ones with the most elevated priorities
|
||||
let mut ordered_relays = devices
|
||||
.iter()
|
||||
.filter(|d| self.device_state(&d.id).is_online() && d.enabled)
|
||||
@@ -309,10 +389,13 @@ impl EnergyEngine {
|
||||
ordered_relays.sort_by_key(|r| r.priority);
|
||||
ordered_relays.reverse();
|
||||
|
||||
// Select relays to start, starting with those with highest priorities
|
||||
loop {
|
||||
let mut changed = false;
|
||||
for relay in &ordered_relays {
|
||||
if new_relays_state.get(&relay.id).unwrap().is_on() {
|
||||
if new_relays_state.get(&relay.id).unwrap().is_on()
|
||||
|| new_relays_state.get(&relay.id).unwrap().is_forced()
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -384,7 +467,7 @@ impl EnergyEngine {
|
||||
mod test {
|
||||
use crate::devices::device::{Device, DeviceId, DeviceRelayID};
|
||||
use crate::energy::consumption::EnergyConsumption;
|
||||
use crate::energy::engine::EnergyEngine;
|
||||
use crate::energy::engine::{EnergyEngine, SetRelayForcedStateReq};
|
||||
use crate::utils::time_utils::time_secs;
|
||||
use rust_embed::Embed;
|
||||
|
||||
@@ -393,6 +476,8 @@ mod test {
|
||||
id: DeviceRelayID,
|
||||
on: bool,
|
||||
r#for: usize,
|
||||
#[serde(default)]
|
||||
forced_state: SetRelayForcedStateReq,
|
||||
should_be_on: bool,
|
||||
}
|
||||
|
||||
@@ -440,6 +525,7 @@ mod test {
|
||||
let s = engine.relay_state(r.id);
|
||||
s.on = r.on;
|
||||
s.since = time_secs() as usize - r.r#for;
|
||||
s.forced_state = r.forced_state.to_forced_state()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -454,7 +540,7 @@ mod test {
|
||||
fn run_test(name: &str, conf: &str) {
|
||||
let (devices, mut energy_engine, consumption, states) = parse_test_config(conf);
|
||||
|
||||
energy_engine.refresh(consumption, &devices);
|
||||
energy_engine.refresh(consumption, &devices.iter().collect::<Vec<_>>());
|
||||
|
||||
for (device_s, device) in states.iter().zip(&devices) {
|
||||
for (relay_s, relay) in device_s.relays.iter().zip(&device.relays) {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
use crate::app_config::AppConfig;
|
||||
use crate::devices::device::DeviceRelayID;
|
||||
use crate::devices::device::{DeviceRelay, DeviceRelayID};
|
||||
use crate::utils::files_utils;
|
||||
use crate::utils::time_utils::day_number;
|
||||
use crate::utils::time_utils::{day_number, time_secs, time_start_of_day};
|
||||
|
||||
const TIME_INTERVAL: usize = 30;
|
||||
|
||||
@@ -119,10 +119,35 @@ pub fn relay_total_runtime(device_id: DeviceRelayID, from: u64, to: u64) -> anyh
|
||||
Ok(total)
|
||||
}
|
||||
|
||||
/// Get the total runtime of a relay taking account of daily reset time
|
||||
pub fn relay_total_runtime_adjusted(relay: &DeviceRelay) -> usize {
|
||||
let reset_time = relay
|
||||
.daily_runtime
|
||||
.as_ref()
|
||||
.map(|r| r.reset_time)
|
||||
.unwrap_or(0);
|
||||
|
||||
let time_start_day = time_start_of_day().unwrap_or(1726696800);
|
||||
|
||||
// Check if we have reached reset_time today yet or not
|
||||
if time_start_day + reset_time as u64 <= time_secs() {
|
||||
let start_time = time_start_day + reset_time as u64;
|
||||
let end_time = time_start_day + 3600 * 24 + reset_time as u64;
|
||||
relay_total_runtime(relay.id, start_time, end_time).unwrap_or(3600 * 24)
|
||||
}
|
||||
// If we have not reached reset time yet, we need to focus on previous day
|
||||
else {
|
||||
let time_start_yesterday = time_start_day - 3600 * 24;
|
||||
let start_time = time_start_yesterday + reset_time as u64;
|
||||
let end_time = time_start_day + reset_time as u64;
|
||||
relay_total_runtime(relay.id, start_time, end_time).unwrap_or(3600 * 24)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::devices::device::DeviceRelayID;
|
||||
use crate::energy::relay_state_history::{relay_total_runtime, RelayStateHistory};
|
||||
use crate::energy::relay_state_history::{RelayStateHistory, relay_total_runtime};
|
||||
|
||||
#[test]
|
||||
fn test_relay_state_history() {
|
||||
|
||||
@@ -3,5 +3,7 @@ pub mod constants;
|
||||
pub mod crypto;
|
||||
pub mod devices;
|
||||
pub mod energy;
|
||||
pub mod logs;
|
||||
pub mod ota;
|
||||
pub mod server;
|
||||
pub mod utils;
|
||||
|
||||
17
central_backend/src/logs/log_entry.rs
Normal file
17
central_backend/src/logs/log_entry.rs
Normal file
@@ -0,0 +1,17 @@
|
||||
use crate::devices::device::DeviceId;
|
||||
use crate::logs::severity::LogSeverity;
|
||||
|
||||
#[derive(serde::Serialize, serde::Deserialize, Debug, Clone)]
|
||||
pub struct LogEntry {
|
||||
/// If no device is specified then the message comes from the backend
|
||||
pub device_id: Option<DeviceId>,
|
||||
pub time: u64,
|
||||
pub severity: LogSeverity,
|
||||
pub message: String,
|
||||
}
|
||||
|
||||
impl LogEntry {
|
||||
pub fn serialize(&self) -> anyhow::Result<String> {
|
||||
Ok(serde_json::to_string(self)?)
|
||||
}
|
||||
}
|
||||
58
central_backend/src/logs/logs_manager.rs
Normal file
58
central_backend/src/logs/logs_manager.rs
Normal file
@@ -0,0 +1,58 @@
|
||||
use crate::app_config::AppConfig;
|
||||
use crate::devices::device::DeviceId;
|
||||
use crate::logs::log_entry::LogEntry;
|
||||
use crate::logs::severity::LogSeverity;
|
||||
use crate::utils::time_utils::{curr_day_number, time_secs};
|
||||
use fs4::fs_std::FileExt;
|
||||
use std::fs::OpenOptions;
|
||||
use std::io::{Seek, SeekFrom, Write};
|
||||
|
||||
pub fn save_log(
|
||||
device: Option<&DeviceId>,
|
||||
severity: LogSeverity,
|
||||
message: String,
|
||||
) -> anyhow::Result<()> {
|
||||
let log_path = AppConfig::get().log_of_day(curr_day_number());
|
||||
|
||||
let mut file = OpenOptions::new()
|
||||
.append(true)
|
||||
.create(true)
|
||||
.open(&log_path)?;
|
||||
|
||||
file.lock_exclusive()?;
|
||||
file.seek(SeekFrom::End(0))?;
|
||||
file.write_all(
|
||||
format!(
|
||||
"{}\n",
|
||||
(LogEntry {
|
||||
device_id: device.cloned(),
|
||||
time: time_secs(),
|
||||
severity,
|
||||
message,
|
||||
})
|
||||
.serialize()?
|
||||
)
|
||||
.as_bytes(),
|
||||
)?;
|
||||
file.flush()?;
|
||||
fs4::fs_std::FileExt::unlock(&file)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Make a logs extraction
|
||||
pub fn get_logs(day: u64) -> anyhow::Result<Vec<LogEntry>> {
|
||||
let file = AppConfig::get().log_of_day(day);
|
||||
|
||||
if !file.exists() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
|
||||
let content = std::fs::read_to_string(file)?
|
||||
.split('\n')
|
||||
.filter(|l| !l.is_empty())
|
||||
.map(serde_json::from_str)
|
||||
.collect::<Result<Vec<_>, _>>()?;
|
||||
|
||||
Ok(content)
|
||||
}
|
||||
3
central_backend/src/logs/mod.rs
Normal file
3
central_backend/src/logs/mod.rs
Normal file
@@ -0,0 +1,3 @@
|
||||
pub mod log_entry;
|
||||
pub mod logs_manager;
|
||||
pub mod severity;
|
||||
7
central_backend/src/logs/severity.rs
Normal file
7
central_backend/src/logs/severity.rs
Normal file
@@ -0,0 +1,7 @@
|
||||
#[derive(serde::Serialize, serde::Deserialize, Copy, Clone, Debug, PartialOrd, Eq, PartialEq)]
|
||||
pub enum LogSeverity {
|
||||
Debug = 0,
|
||||
Info,
|
||||
Warn,
|
||||
Error,
|
||||
}
|
||||
@@ -5,10 +5,13 @@ use central_backend::energy::energy_actor::EnergyActor;
|
||||
use central_backend::server::servers;
|
||||
use central_backend::utils::files_utils::create_directory_if_missing;
|
||||
use futures::future;
|
||||
use tokio_schedule::{every, Job};
|
||||
use tokio_schedule::{Job, every};
|
||||
|
||||
#[actix_web::main]
|
||||
async fn main() -> std::io::Result<()> {
|
||||
// Load additional config from file, if requested
|
||||
AppConfig::parse_env_file().expect("Failed to parse environment file!");
|
||||
|
||||
// Initialize OpenSSL
|
||||
openssl_sys::init();
|
||||
|
||||
@@ -19,6 +22,8 @@ async fn main() -> std::io::Result<()> {
|
||||
create_directory_if_missing(AppConfig::get().devices_config_path()).unwrap();
|
||||
create_directory_if_missing(AppConfig::get().relays_runtime_stats_storage_path()).unwrap();
|
||||
create_directory_if_missing(AppConfig::get().energy_consumption_history()).unwrap();
|
||||
create_directory_if_missing(AppConfig::get().logs_dir()).unwrap();
|
||||
create_directory_if_missing(AppConfig::get().ota_dir()).unwrap();
|
||||
|
||||
// Initialize PKI
|
||||
pki::initialize_root_ca().expect("Failed to initialize Root CA!");
|
||||
@@ -42,8 +47,8 @@ async fn main() -> std::io::Result<()> {
|
||||
.expect("Failed to initialize energy actor!")
|
||||
.start();
|
||||
|
||||
let s1 = servers::secure_server(actor);
|
||||
let s2 = servers::unsecure_server();
|
||||
let s1 = servers::secure_server(actor.clone());
|
||||
let s2 = servers::unsecure_server(actor);
|
||||
future::try_join(s1, s2)
|
||||
.await
|
||||
.expect("Failed to start servers!");
|
||||
|
||||
2
central_backend/src/ota/mod.rs
Normal file
2
central_backend/src/ota/mod.rs
Normal file
@@ -0,0 +1,2 @@
|
||||
pub mod ota_manager;
|
||||
pub mod ota_update;
|
||||
74
central_backend/src/ota/ota_manager.rs
Normal file
74
central_backend/src/ota/ota_manager.rs
Normal file
@@ -0,0 +1,74 @@
|
||||
use crate::app_config::AppConfig;
|
||||
use crate::ota::ota_update::{OTAPlatform, OTAUpdate};
|
||||
use crate::utils::files_utils;
|
||||
use std::os::unix::fs::MetadataExt;
|
||||
use std::str::FromStr;
|
||||
|
||||
/// Check out whether a given update exists or not
|
||||
pub fn update_exists(platform: OTAPlatform, version: &semver::Version) -> anyhow::Result<bool> {
|
||||
Ok(AppConfig::get()
|
||||
.path_ota_update(platform, version)
|
||||
.is_file())
|
||||
}
|
||||
|
||||
/// Save a new firmware update
|
||||
pub fn save_update(
|
||||
platform: OTAPlatform,
|
||||
version: &semver::Version,
|
||||
update: &[u8],
|
||||
) -> anyhow::Result<()> {
|
||||
let path = AppConfig::get().path_ota_update(platform, version);
|
||||
files_utils::create_directory_if_missing(path.parent().unwrap())?;
|
||||
|
||||
std::fs::write(path, update)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Get the content of an OTA update
|
||||
pub fn get_ota_update(platform: OTAPlatform, version: &semver::Version) -> anyhow::Result<Vec<u8>> {
|
||||
let path = AppConfig::get().path_ota_update(platform, version);
|
||||
Ok(std::fs::read(path)?)
|
||||
}
|
||||
|
||||
/// Delete an OTA update
|
||||
pub fn delete_update(platform: OTAPlatform, version: &semver::Version) -> anyhow::Result<()> {
|
||||
let path = AppConfig::get().path_ota_update(platform, version);
|
||||
std::fs::remove_file(path)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Get the list of OTA software updates for a platform
|
||||
pub fn get_ota_updates_for_platform(platform: OTAPlatform) -> anyhow::Result<Vec<OTAUpdate>> {
|
||||
let ota_path = AppConfig::get().ota_platform_dir(platform);
|
||||
|
||||
// Check if the directory dedicated to the updates of the platform exists
|
||||
if !ota_path.is_dir() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
|
||||
let mut out = Vec::new();
|
||||
|
||||
for e in std::fs::read_dir(ota_path)? {
|
||||
let e = e?;
|
||||
|
||||
out.push(OTAUpdate {
|
||||
platform,
|
||||
version: semver::Version::from_str(e.file_name().to_str().unwrap_or("bad"))?,
|
||||
file_size: e.metadata()?.size(),
|
||||
});
|
||||
}
|
||||
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Get all the available OTA updates
|
||||
pub fn get_all_ota_updates() -> anyhow::Result<Vec<OTAUpdate>> {
|
||||
let mut out = vec![];
|
||||
|
||||
for p in OTAPlatform::supported_platforms() {
|
||||
out.append(&mut get_ota_updates_for_platform(*p)?)
|
||||
}
|
||||
|
||||
Ok(out)
|
||||
}
|
||||
38
central_backend/src/ota/ota_update.rs
Normal file
38
central_backend/src/ota/ota_update.rs
Normal file
@@ -0,0 +1,38 @@
|
||||
use std::fmt::{Display, Formatter};
|
||||
use std::str::FromStr;
|
||||
|
||||
#[derive(serde::Serialize, serde::Deserialize, Debug, Copy, Clone, Eq, PartialEq)]
|
||||
pub enum OTAPlatform {
|
||||
#[serde(rename = "Wt32-Eth01")]
|
||||
Wt32Eth01,
|
||||
}
|
||||
|
||||
impl OTAPlatform {
|
||||
/// Get the list of supported platforms
|
||||
pub fn supported_platforms() -> &'static [Self] {
|
||||
&[OTAPlatform::Wt32Eth01]
|
||||
}
|
||||
}
|
||||
|
||||
impl Display for OTAPlatform {
|
||||
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
|
||||
let s = serde_json::to_string(&self).unwrap().replace('"', "");
|
||||
write!(f, "{s}")
|
||||
}
|
||||
}
|
||||
|
||||
impl FromStr for OTAPlatform {
|
||||
type Err = anyhow::Error;
|
||||
|
||||
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
||||
Ok(serde_json::from_str::<Self>(&format!("\"{s}\""))?)
|
||||
}
|
||||
}
|
||||
|
||||
/// Single OTA update information
|
||||
#[derive(serde::Serialize, serde::Deserialize, Debug, Clone, Eq, PartialEq)]
|
||||
pub struct OTAUpdate {
|
||||
pub platform: OTAPlatform,
|
||||
pub version: semver::Version,
|
||||
pub file_size: u64,
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
use actix_identity::Identity;
|
||||
use std::future::{ready, Ready};
|
||||
use std::future::{Ready, ready};
|
||||
use std::rc::Rc;
|
||||
|
||||
use crate::app_config::AppConfig;
|
||||
@@ -7,8 +7,8 @@ use crate::constants;
|
||||
use actix_web::body::EitherBody;
|
||||
use actix_web::dev::Payload;
|
||||
use actix_web::{
|
||||
dev::{forward_ready, Service, ServiceRequest, ServiceResponse, Transform},
|
||||
Error, FromRequest, HttpResponse,
|
||||
dev::{Service, ServiceRequest, ServiceResponse, Transform, forward_ready},
|
||||
};
|
||||
use futures_util::future::LocalBoxFuture;
|
||||
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
use actix_web::HttpResponse;
|
||||
use actix_web::body::BoxBody;
|
||||
use actix_web::http::StatusCode;
|
||||
use actix_web::HttpResponse;
|
||||
use std::error::Error;
|
||||
use std::fmt::{Display, Formatter};
|
||||
use std::io::ErrorKind;
|
||||
use zip::result::ZipError;
|
||||
|
||||
/// Custom error to ease controller writing
|
||||
#[derive(Debug)]
|
||||
@@ -31,7 +31,7 @@ impl actix_web::error::ResponseError for HttpErr {
|
||||
}
|
||||
}
|
||||
fn error_response(&self) -> HttpResponse<BoxBody> {
|
||||
log::error!("Error while processing request! {}", self);
|
||||
log::error!("Error while processing request! {self}");
|
||||
|
||||
HttpResponse::InternalServerError().body("Failed to execute request!")
|
||||
}
|
||||
@@ -51,7 +51,7 @@ impl From<serde_json::Error> for HttpErr {
|
||||
|
||||
impl From<Box<dyn Error>> for HttpErr {
|
||||
fn from(value: Box<dyn Error>) -> Self {
|
||||
HttpErr::Err(std::io::Error::new(ErrorKind::Other, value.to_string()).into())
|
||||
HttpErr::Err(std::io::Error::other(value.to_string()).into())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -81,31 +81,43 @@ impl From<reqwest::header::ToStrError> for HttpErr {
|
||||
|
||||
impl From<actix_web::Error> for HttpErr {
|
||||
fn from(value: actix_web::Error) -> Self {
|
||||
HttpErr::Err(std::io::Error::new(ErrorKind::Other, value.to_string()).into())
|
||||
HttpErr::Err(std::io::Error::other(value.to_string()).into())
|
||||
}
|
||||
}
|
||||
|
||||
impl From<actix::MailboxError> for HttpErr {
|
||||
fn from(value: actix::MailboxError) -> Self {
|
||||
HttpErr::Err(std::io::Error::new(ErrorKind::Other, value.to_string()).into())
|
||||
HttpErr::Err(std::io::Error::other(value.to_string()).into())
|
||||
}
|
||||
}
|
||||
|
||||
impl From<actix_identity::error::GetIdentityError> for HttpErr {
|
||||
fn from(value: actix_identity::error::GetIdentityError) -> Self {
|
||||
HttpErr::Err(std::io::Error::new(ErrorKind::Other, value.to_string()).into())
|
||||
HttpErr::Err(std::io::Error::other(value.to_string()).into())
|
||||
}
|
||||
}
|
||||
|
||||
impl From<actix_identity::error::LoginError> for HttpErr {
|
||||
fn from(value: actix_identity::error::LoginError) -> Self {
|
||||
HttpErr::Err(std::io::Error::new(ErrorKind::Other, value.to_string()).into())
|
||||
HttpErr::Err(std::io::Error::other(value.to_string()).into())
|
||||
}
|
||||
}
|
||||
|
||||
impl From<openssl::error::ErrorStack> for HttpErr {
|
||||
fn from(value: openssl::error::ErrorStack) -> Self {
|
||||
HttpErr::Err(std::io::Error::new(ErrorKind::Other, value.to_string()).into())
|
||||
HttpErr::Err(std::io::Error::other(value.to_string()).into())
|
||||
}
|
||||
}
|
||||
|
||||
impl From<ZipError> for HttpErr {
|
||||
fn from(value: ZipError) -> Self {
|
||||
HttpErr::Err(std::io::Error::other(value.to_string()).into())
|
||||
}
|
||||
}
|
||||
|
||||
impl From<walkdir::Error> for HttpErr {
|
||||
fn from(value: walkdir::Error) -> Self {
|
||||
HttpErr::Err(std::io::Error::other(value.to_string()).into())
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
use crate::logs::logs_manager;
|
||||
use crate::logs::severity::LogSeverity;
|
||||
use crate::server::WebEnergyActor;
|
||||
use crate::server::custom_error::HttpResult;
|
||||
use crate::server::devices_api::jwt_parser::JWTRequest;
|
||||
use actix_web::{HttpResponse, web};
|
||||
|
||||
#[derive(Debug, serde::Deserialize, Clone)]
|
||||
pub struct LogRequest {
|
||||
severity: LogSeverity,
|
||||
message: String,
|
||||
}
|
||||
|
||||
/// Report log message from device
|
||||
pub async fn report_log(body: web::Json<JWTRequest>, actor: WebEnergyActor) -> HttpResult {
|
||||
let (device, request) = body.parse_jwt::<LogRequest>(actor).await?;
|
||||
|
||||
log::info!("Save log message from device: {request:#?}");
|
||||
logs_manager::save_log(Some(&device.id), request.severity, request.message)?;
|
||||
|
||||
Ok(HttpResponse::Accepted().finish())
|
||||
}
|
||||
21
central_backend/src/server/devices_api/devices_ota.rs
Normal file
21
central_backend/src/server/devices_api/devices_ota.rs
Normal file
@@ -0,0 +1,21 @@
|
||||
use crate::ota::ota_manager;
|
||||
use crate::ota::ota_update::OTAPlatform;
|
||||
use crate::server::custom_error::HttpResult;
|
||||
use actix_web::{HttpResponse, web};
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
pub struct FirmwarePath {
|
||||
platform: OTAPlatform,
|
||||
version: semver::Version,
|
||||
}
|
||||
|
||||
/// Download firmware update
|
||||
pub async fn retrieve_firmware(path: web::Path<FirmwarePath>) -> HttpResult {
|
||||
if !ota_manager::update_exists(path.platform, &path.version)? {
|
||||
return Ok(HttpResponse::NotFound().json("The requested firmware was not found!"));
|
||||
}
|
||||
|
||||
let firmware = ota_manager::get_ota_update(path.platform, &path.version)?;
|
||||
|
||||
Ok(HttpResponse::Ok().body(firmware))
|
||||
}
|
||||
96
central_backend/src/server/devices_api/jwt_parser.rs
Normal file
96
central_backend/src/server/devices_api/jwt_parser.rs
Normal file
@@ -0,0 +1,96 @@
|
||||
use crate::app_config::AppConfig;
|
||||
use crate::crypto::pki;
|
||||
use crate::devices::device::{Device, DeviceId};
|
||||
use crate::energy::energy_actor;
|
||||
use crate::server::WebEnergyActor;
|
||||
use jsonwebtoken::{Algorithm, DecodingKey, Validation};
|
||||
use openssl::x509::X509;
|
||||
use serde::de::DeserializeOwned;
|
||||
use std::collections::HashSet;
|
||||
|
||||
#[derive(thiserror::Error, Debug)]
|
||||
pub enum JWTError {
|
||||
#[error("Failed to decode JWT header")]
|
||||
FailedDecodeJWT,
|
||||
#[error("Missing KID in JWT!")]
|
||||
MissingKidInJWT,
|
||||
#[error("Sent a JWT for a device which does not exists!")]
|
||||
DeviceDoesNotExists,
|
||||
#[error("Sent a JWT for a device which is not validated!")]
|
||||
DeviceNotValidated,
|
||||
#[error("Sent a JWT using a revoked certificate!")]
|
||||
RevokedCertificate,
|
||||
#[error("Failed to validate JWT!")]
|
||||
FailedValidateJWT,
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
pub struct JWTRequest {
|
||||
pub payload: String,
|
||||
}
|
||||
|
||||
impl JWTRequest {
|
||||
pub async fn parse_jwt<E: DeserializeOwned + std::clone::Clone>(
|
||||
&self,
|
||||
actor: WebEnergyActor,
|
||||
) -> anyhow::Result<(Device, E)> {
|
||||
// First, we need to extract device kid from query
|
||||
let Ok(jwt_header) = jsonwebtoken::decode_header(&self.payload) else {
|
||||
log::error!("Failed to decode JWT header!");
|
||||
return Err(JWTError::FailedDecodeJWT.into());
|
||||
};
|
||||
|
||||
let Some(kid) = jwt_header.kid else {
|
||||
log::error!("Missing KID in JWT!");
|
||||
return Err(JWTError::MissingKidInJWT.into());
|
||||
};
|
||||
|
||||
// Fetch device information
|
||||
let Some(device) = actor
|
||||
.send(energy_actor::GetSingleDevice(DeviceId(kid)))
|
||||
.await?
|
||||
else {
|
||||
log::error!("Sent a JWT for a device which does not exists!");
|
||||
return Err(JWTError::DeviceDoesNotExists.into());
|
||||
};
|
||||
|
||||
if !device.validated {
|
||||
log::error!("Sent a JWT for a device which is not validated!");
|
||||
return Err(JWTError::DeviceNotValidated.into());
|
||||
}
|
||||
|
||||
// Check certificate revocation status
|
||||
let cert_bytes = std::fs::read(AppConfig::get().device_cert_path(&device.id))?;
|
||||
let certificate = X509::from_pem(&cert_bytes)?;
|
||||
|
||||
if pki::CertData::load_devices_ca()?.is_revoked(&certificate)? {
|
||||
log::error!("Sent a JWT using a revoked certificate!");
|
||||
return Err(JWTError::RevokedCertificate.into());
|
||||
}
|
||||
|
||||
let (key, alg) = match DecodingKey::from_ec_pem(&cert_bytes) {
|
||||
Ok(key) => (key, Algorithm::ES256),
|
||||
Err(e) => {
|
||||
log::warn!("Failed to decode certificate as EC certificate {e}, trying RSA...");
|
||||
(
|
||||
DecodingKey::from_rsa_pem(&cert_bytes)
|
||||
.expect("Failed to decode RSA certificate"),
|
||||
Algorithm::RS256,
|
||||
)
|
||||
}
|
||||
};
|
||||
let mut validation = Validation::new(alg);
|
||||
validation.validate_exp = false;
|
||||
validation.required_spec_claims = HashSet::default();
|
||||
|
||||
let c = match jsonwebtoken::decode::<E>(&self.payload, &key, &validation) {
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
log::error!("Failed to validate JWT! {e}");
|
||||
return Err(JWTError::FailedValidateJWT.into());
|
||||
}
|
||||
};
|
||||
|
||||
Ok((device, c.claims))
|
||||
}
|
||||
}
|
||||
@@ -1,15 +1,16 @@
|
||||
use crate::app_config::AppConfig;
|
||||
use crate::crypto::pki;
|
||||
use crate::devices::device::{DeviceId, DeviceInfo};
|
||||
use crate::energy::energy_actor;
|
||||
use crate::energy::energy_actor::RelaySyncStatus;
|
||||
use crate::server::custom_error::HttpResult;
|
||||
use crate::ota::ota_manager;
|
||||
use crate::ota::ota_update::OTAPlatform;
|
||||
use crate::server::WebEnergyActor;
|
||||
use actix_web::{web, HttpResponse};
|
||||
use jsonwebtoken::{Algorithm, DecodingKey, Validation};
|
||||
use crate::server::custom_error::HttpResult;
|
||||
use crate::server::devices_api::jwt_parser::JWTRequest;
|
||||
use actix_web::{HttpResponse, web};
|
||||
use openssl::nid::Nid;
|
||||
use openssl::x509::{X509Req, X509};
|
||||
use std::collections::HashSet;
|
||||
use openssl::x509::X509Req;
|
||||
use std::str::FromStr;
|
||||
|
||||
#[derive(Debug, serde::Deserialize)]
|
||||
pub struct EnrollRequest {
|
||||
@@ -129,12 +130,7 @@ pub async fn get_certificate(query: web::Query<ReqWithDevID>, actor: WebEnergyAc
|
||||
.body(cert))
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
pub struct SyncRequest {
|
||||
payload: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, serde::Serialize, serde::Deserialize)]
|
||||
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
|
||||
struct Claims {
|
||||
info: DeviceInfo,
|
||||
}
|
||||
@@ -142,72 +138,32 @@ struct Claims {
|
||||
#[derive(serde::Serialize)]
|
||||
struct SyncResult {
|
||||
relays: Vec<RelaySyncStatus>,
|
||||
available_update: Option<semver::Version>,
|
||||
}
|
||||
|
||||
/// Synchronize device
|
||||
pub async fn sync_device(body: web::Json<SyncRequest>, actor: WebEnergyActor) -> HttpResult {
|
||||
// First, we need to extract device kid from query
|
||||
let Ok(jwt_header) = jsonwebtoken::decode_header(&body.payload) else {
|
||||
log::error!("Failed to decode JWT header!");
|
||||
return Ok(HttpResponse::BadRequest().json("Failed to decode JWT header!"));
|
||||
};
|
||||
|
||||
let Some(kid) = jwt_header.kid else {
|
||||
log::error!("Missing KID in JWT!");
|
||||
return Ok(HttpResponse::BadRequest().json("Missing KID in JWT!"));
|
||||
};
|
||||
|
||||
// Fetch device information
|
||||
let Some(device) = actor
|
||||
.send(energy_actor::GetSingleDevice(DeviceId(kid)))
|
||||
.await?
|
||||
else {
|
||||
log::error!("Sent a JWT for a device which does not exists!");
|
||||
return Ok(HttpResponse::NotFound().json("Sent a JWT for a device which does not exists!"));
|
||||
};
|
||||
|
||||
if !device.validated {
|
||||
log::error!("Sent a JWT for a device which is not validated!");
|
||||
return Ok(HttpResponse::PreconditionFailed()
|
||||
.json("Sent a JWT for a device which is not validated!"));
|
||||
}
|
||||
|
||||
// Check certificate revocation status
|
||||
let cert_bytes = std::fs::read(AppConfig::get().device_cert_path(&device.id))?;
|
||||
let certificate = X509::from_pem(&cert_bytes)?;
|
||||
|
||||
if pki::CertData::load_devices_ca()?.is_revoked(&certificate)? {
|
||||
log::error!("Sent a JWT using a revoked certificate!");
|
||||
return Ok(
|
||||
HttpResponse::PreconditionFailed().json("Sent a JWT using a revoked certificate!")
|
||||
);
|
||||
}
|
||||
|
||||
let (key, alg) = match DecodingKey::from_ec_pem(&cert_bytes) {
|
||||
Ok(key) => (key, Algorithm::ES256),
|
||||
Err(e) => {
|
||||
log::warn!("Failed to decode certificate as EC certificate {e}, trying RSA...");
|
||||
(
|
||||
DecodingKey::from_rsa_pem(&cert_bytes).expect("Failed to decode RSA certificate"),
|
||||
Algorithm::RS256,
|
||||
)
|
||||
}
|
||||
};
|
||||
let mut validation = Validation::new(alg);
|
||||
validation.validate_exp = false;
|
||||
validation.required_spec_claims = HashSet::default();
|
||||
|
||||
let c = match jsonwebtoken::decode::<Claims>(&body.payload, &key, &validation) {
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
log::error!("Failed to validate JWT! {e}");
|
||||
return Ok(HttpResponse::PreconditionFailed().json("Failed to validate JWT!"));
|
||||
}
|
||||
};
|
||||
pub async fn sync_device(body: web::Json<JWTRequest>, actor: WebEnergyActor) -> HttpResult {
|
||||
let (device, claims) = body.0.parse_jwt::<Claims>(actor.clone()).await?;
|
||||
|
||||
let relays = actor
|
||||
.send(energy_actor::SynchronizeDevice(device.id, c.claims.info))
|
||||
.send(energy_actor::SynchronizeDevice(
|
||||
device.id,
|
||||
claims.info.clone(),
|
||||
))
|
||||
.await??;
|
||||
|
||||
Ok(HttpResponse::Ok().json(SyncResult { relays }))
|
||||
let mut available_update = None;
|
||||
|
||||
// Check if the version is available
|
||||
if let Some(desired) = device.desired_version
|
||||
&& claims.info.version < desired
|
||||
&& ota_manager::update_exists(OTAPlatform::from_str(&claims.info.reference)?, &desired)?
|
||||
{
|
||||
available_update = Some(desired);
|
||||
}
|
||||
|
||||
Ok(HttpResponse::Ok().json(SyncResult {
|
||||
relays,
|
||||
available_update,
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -1,2 +1,5 @@
|
||||
pub mod device_logging_controller;
|
||||
pub mod devices_ota;
|
||||
pub mod jwt_parser;
|
||||
pub mod mgmt_controller;
|
||||
pub mod utils_controller;
|
||||
|
||||
@@ -3,31 +3,34 @@ use crate::constants;
|
||||
use crate::crypto::pki;
|
||||
use crate::energy::energy_actor::EnergyActorAddr;
|
||||
use crate::server::auth_middleware::AuthChecker;
|
||||
use crate::server::devices_api::{mgmt_controller, utils_controller};
|
||||
use crate::server::devices_api::{
|
||||
device_logging_controller, devices_ota, mgmt_controller, utils_controller,
|
||||
};
|
||||
use crate::server::unsecure_server::*;
|
||||
use crate::server::web_api::*;
|
||||
use crate::server::web_app_controller;
|
||||
use actix_cors::Cors;
|
||||
use actix_identity::config::LogoutBehaviour;
|
||||
use actix_identity::IdentityMiddleware;
|
||||
use actix_identity::config::LogoutBehavior;
|
||||
use actix_remote_ip::RemoteIPConfig;
|
||||
use actix_session::storage::CookieSessionStore;
|
||||
use actix_session::SessionMiddleware;
|
||||
use actix_session::storage::CookieSessionStore;
|
||||
use actix_web::cookie::{Key, SameSite};
|
||||
use actix_web::middleware::Logger;
|
||||
use actix_web::{web, App, HttpServer};
|
||||
use actix_web::{App, HttpServer, web};
|
||||
use openssl::ssl::{SslAcceptor, SslMethod};
|
||||
use std::time::Duration;
|
||||
|
||||
/// Start unsecure (HTTP) server
|
||||
pub async fn unsecure_server() -> anyhow::Result<()> {
|
||||
pub async fn unsecure_server(energy_actor: EnergyActorAddr) -> anyhow::Result<()> {
|
||||
log::info!(
|
||||
"Unsecure server starting to listen on {} for {}",
|
||||
AppConfig::get().unsecure_listen_address,
|
||||
AppConfig::get().unsecure_origin()
|
||||
);
|
||||
HttpServer::new(|| {
|
||||
HttpServer::new(move || {
|
||||
App::new()
|
||||
.app_data(web::Data::new(energy_actor.clone()))
|
||||
.wrap(Logger::default())
|
||||
.route(
|
||||
"/",
|
||||
@@ -41,6 +44,10 @@ pub async fn unsecure_server() -> anyhow::Result<()> {
|
||||
"/pki/{file}",
|
||||
web::get().to(unsecure_pki_controller::serve_pki_file),
|
||||
)
|
||||
.route(
|
||||
"/relay/{id}/legacy_state",
|
||||
web::get().to(unsecure_relay_controller::legacy_state),
|
||||
)
|
||||
})
|
||||
.bind(&AppConfig::get().unsecure_listen_address)?
|
||||
.run()
|
||||
@@ -77,7 +84,7 @@ pub async fn secure_server(energy_actor: EnergyActorAddr) -> anyhow::Result<()>
|
||||
.build();
|
||||
|
||||
let identity_middleware = IdentityMiddleware::builder()
|
||||
.logout_behaviour(LogoutBehaviour::PurgeSession)
|
||||
.logout_behavior(LogoutBehavior::PurgeSession)
|
||||
.visit_deadline(Some(Duration::from_secs(
|
||||
constants::MAX_INACTIVITY_DURATION,
|
||||
)))
|
||||
@@ -180,6 +187,37 @@ pub async fn secure_server(energy_actor: EnergyActorAddr) -> anyhow::Result<()>
|
||||
"/web_api/device/{id}",
|
||||
web::delete().to(devices_controller::delete_device),
|
||||
)
|
||||
// OTA API
|
||||
.route(
|
||||
"/web_api/ota/supported_platforms",
|
||||
web::get().to(ota_controller::supported_platforms),
|
||||
)
|
||||
.route(
|
||||
"/web_api/ota/{platform}/{version}",
|
||||
web::post().to(ota_controller::upload_firmware),
|
||||
)
|
||||
.route(
|
||||
"/web_api/ota/{platform}/{version}",
|
||||
web::get().to(ota_controller::download_firmware),
|
||||
)
|
||||
.route(
|
||||
"/web_api/ota/{platform}/{version}",
|
||||
web::delete().to(ota_controller::delete_update),
|
||||
)
|
||||
.route("/web_api/ota", web::get().to(ota_controller::list_all_ota))
|
||||
.route(
|
||||
"/web_api/ota/{platform}",
|
||||
web::get().to(ota_controller::list_updates_platform),
|
||||
)
|
||||
.route(
|
||||
"/web_api/ota/set_desired_version",
|
||||
web::post().to(ota_controller::set_desired_version),
|
||||
)
|
||||
// Logging controller API
|
||||
.route(
|
||||
"/web_api/logging/logs",
|
||||
web::get().to(logging_controller::get_log),
|
||||
)
|
||||
// Relays API
|
||||
.route(
|
||||
"/web_api/relays/list",
|
||||
@@ -193,6 +231,10 @@ pub async fn secure_server(energy_actor: EnergyActorAddr) -> anyhow::Result<()>
|
||||
"/web_api/relay/{id}",
|
||||
web::put().to(relays_controller::update),
|
||||
)
|
||||
.route(
|
||||
"/web_api/relay/{id}/forced_state",
|
||||
web::put().to(relays_controller::set_forced_state),
|
||||
)
|
||||
.route(
|
||||
"/web_api/relay/{id}",
|
||||
web::delete().to(relays_controller::delete),
|
||||
@@ -205,6 +247,11 @@ pub async fn secure_server(energy_actor: EnergyActorAddr) -> anyhow::Result<()>
|
||||
"/web_api/relay/{id}/status",
|
||||
web::get().to(relays_controller::status_single),
|
||||
)
|
||||
// Management API
|
||||
.route(
|
||||
"/web_api/management/download_storage",
|
||||
web::get().to(management_controller::download_storage),
|
||||
)
|
||||
// Devices API
|
||||
.route(
|
||||
"/devices_api/utils/time",
|
||||
@@ -226,6 +273,14 @@ pub async fn secure_server(energy_actor: EnergyActorAddr) -> anyhow::Result<()>
|
||||
"/devices_api/mgmt/sync",
|
||||
web::post().to(mgmt_controller::sync_device),
|
||||
)
|
||||
.route(
|
||||
"/devices_api/ota/{platform}/{version}",
|
||||
web::get().to(devices_ota::retrieve_firmware),
|
||||
)
|
||||
.route(
|
||||
"/devices_api/logging/record",
|
||||
web::post().to(device_logging_controller::report_log),
|
||||
)
|
||||
// Web app
|
||||
.route("/", web::get().to(web_app_controller::root_index))
|
||||
.route(
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
pub mod unsecure_pki_controller;
|
||||
pub mod unsecure_relay_controller;
|
||||
pub mod unsecure_server_controller;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use crate::app_config::AppConfig;
|
||||
use crate::server::custom_error::HttpResult;
|
||||
use actix_web::{web, HttpResponse};
|
||||
use actix_web::{HttpResponse, web};
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
pub struct ServeCRLPath {
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
use crate::devices::device::DeviceRelayID;
|
||||
use crate::energy::{energy_actor, relay_state_history};
|
||||
use crate::server::WebEnergyActor;
|
||||
use crate::server::custom_error::HttpResult;
|
||||
use actix_web::{HttpResponse, web};
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
pub struct LegacyStateRelay {
|
||||
id: DeviceRelayID,
|
||||
}
|
||||
|
||||
/// Legacy relay state
|
||||
#[derive(serde::Serialize)]
|
||||
pub struct LegacyState {
|
||||
/// Indicates if relay is on or off
|
||||
is_on: bool,
|
||||
/// Relay name
|
||||
name: String,
|
||||
/// Duration since last change of state
|
||||
r#for: usize,
|
||||
/// Current grid consumption
|
||||
prod: i32,
|
||||
/// Total uptime since last reset
|
||||
total_uptime: usize,
|
||||
/// Required uptime during a day
|
||||
///
|
||||
/// Will be 0 if there is no daily requirements
|
||||
required_uptime: usize,
|
||||
}
|
||||
|
||||
/// Get the state of a relay, adapted for old system components
|
||||
pub async fn legacy_state(
|
||||
energy_actor: WebEnergyActor,
|
||||
path: web::Path<LegacyStateRelay>,
|
||||
) -> HttpResult {
|
||||
let Some(relay) = energy_actor
|
||||
.send(energy_actor::GetSingleRelay(path.id))
|
||||
.await?
|
||||
else {
|
||||
return Ok(HttpResponse::NotFound().body("Relay not found!"));
|
||||
};
|
||||
|
||||
let all_states = energy_actor.send(energy_actor::GetAllRelaysState).await?;
|
||||
let Some(state) = all_states.into_iter().find(|r| r.id == path.id) else {
|
||||
return Ok(HttpResponse::InternalServerError().body("Relay status unavailable!"));
|
||||
};
|
||||
|
||||
let production = energy_actor.send(energy_actor::GetCurrConsumption).await?;
|
||||
|
||||
let total_uptime = relay_state_history::relay_total_runtime_adjusted(&relay);
|
||||
|
||||
Ok(HttpResponse::Ok().json(LegacyState {
|
||||
name: relay.name,
|
||||
is_on: state.on,
|
||||
r#for: state.r#for.min(3600 * 24 * 7),
|
||||
prod: production,
|
||||
total_uptime,
|
||||
required_uptime: relay.daily_runtime.map(|r| r.min_runtime).unwrap_or(0),
|
||||
}))
|
||||
}
|
||||
@@ -2,7 +2,7 @@ use crate::app_config::AppConfig;
|
||||
use crate::server::custom_error::HttpResult;
|
||||
use actix_identity::Identity;
|
||||
use actix_remote_ip::RemoteIP;
|
||||
use actix_web::{web, HttpMessage, HttpRequest, HttpResponse};
|
||||
use actix_web::{HttpMessage, HttpRequest, HttpResponse, web};
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
pub struct AuthRequest {
|
||||
@@ -17,11 +17,11 @@ pub async fn password_auth(
|
||||
remote_ip: RemoteIP,
|
||||
) -> HttpResult {
|
||||
if r.user != AppConfig::get().admin_username || r.password != AppConfig::get().admin_password {
|
||||
log::error!("Failed login attempt from {}!", remote_ip.0.to_string());
|
||||
log::error!("Failed login attempt from {}!", remote_ip.0);
|
||||
return Ok(HttpResponse::Unauthorized().json("Invalid credentials!"));
|
||||
}
|
||||
|
||||
log::info!("Successful login attempt from {}!", remote_ip.0.to_string());
|
||||
log::info!("Successful login attempt from {}!", remote_ip.0);
|
||||
Identity::login(&request.extensions(), r.user.to_string())?;
|
||||
Ok(HttpResponse::Ok().finish())
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
use crate::devices::device::{DeviceGeneralInfo, DeviceId};
|
||||
use crate::energy::energy_actor;
|
||||
use crate::server::custom_error::HttpResult;
|
||||
use crate::server::WebEnergyActor;
|
||||
use actix_web::{web, HttpResponse};
|
||||
use crate::server::custom_error::HttpResult;
|
||||
use actix_web::{HttpResponse, web};
|
||||
|
||||
/// Get the list of pending (not accepted yet) devices
|
||||
pub async fn list_pending(actor: WebEnergyActor) -> HttpResult {
|
||||
|
||||
@@ -2,21 +2,27 @@ use crate::app_config::ConsumptionHistoryType;
|
||||
use crate::energy::consumption::EnergyConsumption;
|
||||
use crate::energy::consumption_history_file::ConsumptionHistoryFile;
|
||||
use crate::energy::{consumption, energy_actor};
|
||||
use crate::server::custom_error::HttpResult;
|
||||
use crate::server::WebEnergyActor;
|
||||
use crate::server::custom_error::HttpResult;
|
||||
use crate::utils::time_utils::time_secs;
|
||||
use actix_web::HttpResponse;
|
||||
|
||||
#[derive(serde::Serialize)]
|
||||
struct Consumption {
|
||||
consumption: i32,
|
||||
consumption: Option<i32>,
|
||||
}
|
||||
|
||||
/// Get current energy consumption
|
||||
pub async fn curr_consumption() -> HttpResult {
|
||||
let consumption = consumption::get_curr_consumption().await?;
|
||||
|
||||
Ok(HttpResponse::Ok().json(Consumption { consumption }))
|
||||
Ok(match consumption::get_curr_consumption().await {
|
||||
Ok(v) => HttpResponse::Ok().json(Consumption {
|
||||
consumption: Some(v),
|
||||
}),
|
||||
Err(e) => {
|
||||
log::error!("Failed to fetch current consumption! {e}");
|
||||
HttpResponse::Ok().json(Consumption { consumption: None })
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// Get curr consumption history
|
||||
@@ -34,7 +40,9 @@ pub async fn curr_consumption_history() -> HttpResult {
|
||||
pub async fn cached_consumption(energy_actor: WebEnergyActor) -> HttpResult {
|
||||
let consumption = energy_actor.send(energy_actor::GetCurrConsumption).await?;
|
||||
|
||||
Ok(HttpResponse::Ok().json(Consumption { consumption }))
|
||||
Ok(HttpResponse::Ok().json(Consumption {
|
||||
consumption: Some(consumption),
|
||||
}))
|
||||
}
|
||||
|
||||
/// Get current relays consumption
|
||||
@@ -42,7 +50,9 @@ pub async fn relays_consumption(energy_actor: WebEnergyActor) -> HttpResult {
|
||||
let consumption =
|
||||
energy_actor.send(energy_actor::RelaysConsumption).await? as EnergyConsumption;
|
||||
|
||||
Ok(HttpResponse::Ok().json(Consumption { consumption }))
|
||||
Ok(HttpResponse::Ok().json(Consumption {
|
||||
consumption: Some(consumption),
|
||||
}))
|
||||
}
|
||||
|
||||
pub async fn relays_consumption_history() -> HttpResult {
|
||||
|
||||
30
central_backend/src/server/web_api/logging_controller.rs
Normal file
30
central_backend/src/server/web_api/logging_controller.rs
Normal file
@@ -0,0 +1,30 @@
|
||||
use crate::devices::device::DeviceId;
|
||||
use crate::logs::logs_manager;
|
||||
use crate::logs::severity::LogSeverity;
|
||||
use crate::server::custom_error::HttpResult;
|
||||
use crate::utils::time_utils::curr_day_number;
|
||||
use actix_web::{HttpResponse, web};
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
pub struct LogRequest {
|
||||
// Day number
|
||||
day: Option<u64>,
|
||||
min_severity: Option<LogSeverity>,
|
||||
device: Option<DeviceId>,
|
||||
}
|
||||
|
||||
/// Get some logs
|
||||
pub async fn get_log(req: web::Query<LogRequest>) -> HttpResult {
|
||||
let day = req.day.unwrap_or_else(curr_day_number);
|
||||
let mut logs = logs_manager::get_logs(day)?;
|
||||
|
||||
if let Some(min_severity) = req.min_severity {
|
||||
logs.retain(|d| d.severity >= min_severity);
|
||||
}
|
||||
|
||||
if let Some(dev_id) = &req.device {
|
||||
logs.retain(|d| d.device_id.as_ref() == Some(dev_id));
|
||||
}
|
||||
|
||||
Ok(HttpResponse::Ok().json(logs))
|
||||
}
|
||||
66
central_backend/src/server/web_api/management_controller.rs
Normal file
66
central_backend/src/server/web_api/management_controller.rs
Normal file
@@ -0,0 +1,66 @@
|
||||
use crate::app_config::AppConfig;
|
||||
use crate::server::custom_error::HttpResult;
|
||||
use crate::utils::time_utils::current_day;
|
||||
use actix_web::HttpResponse;
|
||||
use anyhow::Context;
|
||||
use std::fs::File;
|
||||
use std::io::{Cursor, Read, Write};
|
||||
use walkdir::WalkDir;
|
||||
use zip::write::SimpleFileOptions;
|
||||
|
||||
/// Download a full copy of the storage data
|
||||
pub async fn download_storage() -> HttpResult {
|
||||
let mut zip_buff = Cursor::new(Vec::new());
|
||||
let mut zip = zip::ZipWriter::new(&mut zip_buff);
|
||||
|
||||
let options = SimpleFileOptions::default()
|
||||
.compression_method(zip::CompressionMethod::Bzip2)
|
||||
.unix_permissions(0o700);
|
||||
|
||||
let storage = AppConfig::get().storage_path();
|
||||
|
||||
let mut file_buff = Vec::new();
|
||||
for entry in WalkDir::new(&storage) {
|
||||
let entry = entry?;
|
||||
|
||||
let path = entry.path();
|
||||
let name = path.strip_prefix(&storage).unwrap();
|
||||
let path_as_string = name
|
||||
.to_str()
|
||||
.map(str::to_owned)
|
||||
.with_context(|| format!("{name:?} Is a Non UTF-8 Path"))?;
|
||||
|
||||
// Write file or directory explicitly
|
||||
// Some unzip tools unzip files with directory paths correctly, some do not!
|
||||
if path.is_file() {
|
||||
log::debug!("adding file {path:?} as {name:?} ...");
|
||||
zip.start_file(path_as_string, options)?;
|
||||
let mut f = File::open(path)?;
|
||||
|
||||
f.read_to_end(&mut file_buff)?;
|
||||
zip.write_all(&file_buff)?;
|
||||
file_buff.clear();
|
||||
} else if !name.as_os_str().is_empty() {
|
||||
// Only if not root! Avoids path spec / warning
|
||||
// and mapname conversion failed error on unzip
|
||||
log::debug!("adding dir {path_as_string:?} as {name:?} ...");
|
||||
zip.add_directory(path_as_string, options)?;
|
||||
}
|
||||
}
|
||||
|
||||
// Inject runtime configuration
|
||||
zip.start_file("/app_config.json", options)?;
|
||||
zip.write_all(&serde_json::to_vec_pretty(&AppConfig::get())?)?;
|
||||
|
||||
zip.finish()?;
|
||||
|
||||
let filename = format!("storage-{}.zip", current_day());
|
||||
|
||||
Ok(HttpResponse::Ok()
|
||||
.content_type("application/zip")
|
||||
.insert_header((
|
||||
"content-disposition",
|
||||
format!("attachment; filename=\"{filename}\""),
|
||||
))
|
||||
.body(zip_buff.into_inner()))
|
||||
}
|
||||
@@ -1,5 +1,8 @@
|
||||
pub mod auth_controller;
|
||||
pub mod devices_controller;
|
||||
pub mod energy_controller;
|
||||
pub mod logging_controller;
|
||||
pub mod management_controller;
|
||||
pub mod ota_controller;
|
||||
pub mod relays_controller;
|
||||
pub mod server_controller;
|
||||
|
||||
148
central_backend/src/server/web_api/ota_controller.rs
Normal file
148
central_backend/src/server/web_api/ota_controller.rs
Normal file
@@ -0,0 +1,148 @@
|
||||
use crate::constants;
|
||||
use crate::devices::device::DeviceId;
|
||||
use crate::energy::energy_actor;
|
||||
use crate::ota::ota_manager;
|
||||
use crate::ota::ota_update::OTAPlatform;
|
||||
use crate::server::WebEnergyActor;
|
||||
use crate::server::custom_error::HttpResult;
|
||||
use actix_multipart::form::MultipartForm;
|
||||
use actix_multipart::form::tempfile::TempFile;
|
||||
use actix_web::{HttpResponse, web};
|
||||
|
||||
pub async fn supported_platforms() -> HttpResult {
|
||||
Ok(HttpResponse::Ok().json(OTAPlatform::supported_platforms()))
|
||||
}
|
||||
|
||||
#[derive(Debug, MultipartForm)]
|
||||
pub struct UploadForm {
|
||||
#[multipart(rename = "firmware")]
|
||||
firmware: Vec<TempFile>,
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
pub struct SpecificOTAVersionPath {
|
||||
platform: OTAPlatform,
|
||||
version: semver::Version,
|
||||
}
|
||||
|
||||
/// Upload a new firmware update
|
||||
pub async fn upload_firmware(
|
||||
MultipartForm(form): MultipartForm<UploadForm>,
|
||||
path: web::Path<SpecificOTAVersionPath>,
|
||||
) -> HttpResult {
|
||||
if ota_manager::update_exists(path.platform, &path.version)? {
|
||||
return Ok(HttpResponse::Conflict()
|
||||
.json("A firmware with the same version has already been uploaded on the platform!"));
|
||||
}
|
||||
|
||||
let Some(file) = form.firmware.first() else {
|
||||
return Ok(HttpResponse::BadRequest().json("No firmware specified!"));
|
||||
};
|
||||
|
||||
if file.size == 0 {
|
||||
return Ok(HttpResponse::BadRequest().json("Uploaded file is empty!"));
|
||||
}
|
||||
|
||||
if file.size > constants::MAX_FIRMWARE_SIZE {
|
||||
return Ok(HttpResponse::BadRequest().json("Uploaded file is too heavy!"));
|
||||
}
|
||||
|
||||
let content = std::fs::read(file.file.path())?;
|
||||
|
||||
ota_manager::save_update(path.platform, &path.version, &content)?;
|
||||
|
||||
Ok(HttpResponse::Accepted().body("OTA update successfully saved."))
|
||||
}
|
||||
|
||||
/// Download a firmware update
|
||||
pub async fn download_firmware(path: web::Path<SpecificOTAVersionPath>) -> HttpResult {
|
||||
if !ota_manager::update_exists(path.platform, &path.version)? {
|
||||
return Ok(HttpResponse::NotFound().json("The requested firmware update was not found!"));
|
||||
}
|
||||
|
||||
let firmware = ota_manager::get_ota_update(path.platform, &path.version)?;
|
||||
|
||||
Ok(HttpResponse::Ok()
|
||||
.content_type("application/octet-stream")
|
||||
.append_header((
|
||||
"content-disposition",
|
||||
format!(
|
||||
"attachment; filename=\"{}-{}.bin\"",
|
||||
path.platform, path.version
|
||||
),
|
||||
))
|
||||
.body(firmware))
|
||||
}
|
||||
|
||||
/// Delete an uploaded firmware update
|
||||
pub async fn delete_update(path: web::Path<SpecificOTAVersionPath>) -> HttpResult {
|
||||
if !ota_manager::update_exists(path.platform, &path.version)? {
|
||||
return Ok(HttpResponse::NotFound().json("The requested firmware update was not found!"));
|
||||
}
|
||||
|
||||
ota_manager::delete_update(path.platform, &path.version)?;
|
||||
|
||||
Ok(HttpResponse::Accepted().finish())
|
||||
}
|
||||
|
||||
/// Get the list of all OTA updates
|
||||
pub async fn list_all_ota() -> HttpResult {
|
||||
Ok(HttpResponse::Ok().json(ota_manager::get_all_ota_updates()?))
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
pub struct ListOTAPath {
|
||||
platform: OTAPlatform,
|
||||
}
|
||||
|
||||
/// List OTA software updates for a given platform
|
||||
pub async fn list_updates_platform(path: web::Path<ListOTAPath>) -> HttpResult {
|
||||
let list = ota_manager::get_ota_updates_for_platform(path.platform)?;
|
||||
|
||||
Ok(HttpResponse::Ok().json(list))
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
pub struct SetDesiredDeviceVersion {
|
||||
devices: Option<Vec<DeviceId>>,
|
||||
platform: Option<OTAPlatform>,
|
||||
version: semver::Version,
|
||||
}
|
||||
|
||||
pub async fn set_desired_version(
|
||||
actor: WebEnergyActor,
|
||||
body: web::Json<SetDesiredDeviceVersion>,
|
||||
) -> HttpResult {
|
||||
if body.devices.is_none() && body.platform.is_none() {
|
||||
return Ok(
|
||||
HttpResponse::BadRequest().json("Must specify one filter to select target devices!")
|
||||
);
|
||||
}
|
||||
|
||||
let devices = actor.send(energy_actor::GetDeviceLists).await?;
|
||||
|
||||
for d in devices {
|
||||
// Filter per platform
|
||||
if let Some(p) = body.platform
|
||||
&& d.info.reference != p.to_string()
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
// Filter per device
|
||||
if let Some(ids) = &body.devices
|
||||
&& !ids.contains(&d.id)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
actor
|
||||
.send(energy_actor::SetDesiredVersion(
|
||||
d.id,
|
||||
Some(body.version.clone()),
|
||||
))
|
||||
.await??;
|
||||
}
|
||||
|
||||
Ok(HttpResponse::Ok().finish())
|
||||
}
|
||||
@@ -1,8 +1,9 @@
|
||||
use crate::devices::device::{DeviceId, DeviceRelay, DeviceRelayID};
|
||||
use crate::energy::energy_actor;
|
||||
use crate::server::custom_error::HttpResult;
|
||||
use crate::energy::engine::SetRelayForcedStateReq;
|
||||
use crate::server::WebEnergyActor;
|
||||
use actix_web::{web, HttpResponse};
|
||||
use crate::server::custom_error::HttpResult;
|
||||
use actix_web::{HttpResponse, web};
|
||||
|
||||
/// Get the full list of relays
|
||||
pub async fn get_list(actor: WebEnergyActor) -> HttpResult {
|
||||
@@ -85,6 +86,29 @@ pub async fn update(
|
||||
Ok(HttpResponse::Accepted().finish())
|
||||
}
|
||||
|
||||
/// Set relay forced status
|
||||
pub async fn set_forced_state(
|
||||
actor: WebEnergyActor,
|
||||
req: web::Json<SetRelayForcedStateReq>,
|
||||
path: web::Path<RelayIDInPath>,
|
||||
) -> HttpResult {
|
||||
// Check if relay exists first
|
||||
let list = actor.send(energy_actor::GetAllRelaysState).await?;
|
||||
if !list.into_iter().any(|r| r.id == path.id) {
|
||||
return Ok(HttpResponse::NotFound().json("Relay not found!"));
|
||||
};
|
||||
|
||||
// Update relay forced state
|
||||
actor
|
||||
.send(energy_actor::SetRelayForcedState(
|
||||
path.id,
|
||||
req.to_forced_state(),
|
||||
))
|
||||
.await??;
|
||||
|
||||
Ok(HttpResponse::Accepted().finish())
|
||||
}
|
||||
|
||||
/// Delete an existing relay
|
||||
pub async fn delete(actor: WebEnergyActor, path: web::Path<RelayIDInPath>) -> HttpResult {
|
||||
actor
|
||||
|
||||
@@ -12,6 +12,11 @@ pub async fn secure_home() -> HttpResponse {
|
||||
struct ServerConfig {
|
||||
auth_disabled: bool,
|
||||
constraints: StaticConstraints,
|
||||
unsecure_origin: String,
|
||||
backend_version: &'static str,
|
||||
dashboard_custom_current_consumption_title: Option<&'static str>,
|
||||
dashboard_custom_relays_consumption_title: Option<&'static str>,
|
||||
dashboard_custom_cached_consumption_title: Option<&'static str>,
|
||||
}
|
||||
|
||||
impl Default for ServerConfig {
|
||||
@@ -19,6 +24,17 @@ impl Default for ServerConfig {
|
||||
Self {
|
||||
auth_disabled: AppConfig::get().unsecure_disable_login,
|
||||
constraints: Default::default(),
|
||||
unsecure_origin: AppConfig::get().unsecure_origin(),
|
||||
backend_version: env!("CARGO_PKG_VERSION"),
|
||||
dashboard_custom_current_consumption_title: AppConfig::get()
|
||||
.dashboard_custom_current_consumption_title
|
||||
.as_deref(),
|
||||
dashboard_custom_relays_consumption_title: AppConfig::get()
|
||||
.dashboard_custom_relays_consumption_title
|
||||
.as_deref(),
|
||||
dashboard_custom_cached_consumption_title: AppConfig::get()
|
||||
.dashboard_custom_cached_consumption_title
|
||||
.as_deref(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,7 +19,7 @@ mod serve_static_debug {
|
||||
|
||||
#[cfg(not(debug_assertions))]
|
||||
mod serve_static_release {
|
||||
use actix_web::{web, HttpResponse, Responder};
|
||||
use actix_web::{HttpResponse, Responder, web};
|
||||
use rust_embed::RustEmbed;
|
||||
|
||||
#[derive(RustEmbed)]
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
use chrono::prelude::*;
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
/// Get the current time since epoch
|
||||
/// Get the current time since epoch, in seconds
|
||||
pub fn time_secs() -> u64 {
|
||||
SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
@@ -22,6 +22,11 @@ pub fn day_number(time: u64) -> u64 {
|
||||
time / (3600 * 24)
|
||||
}
|
||||
|
||||
/// Get current day number
|
||||
pub fn curr_day_number() -> u64 {
|
||||
day_number(time_secs())
|
||||
}
|
||||
|
||||
/// Get current hour, 00 => 23 (local time)
|
||||
pub fn curr_hour() -> u32 {
|
||||
let local: DateTime<Local> = Local::now();
|
||||
@@ -36,6 +41,12 @@ pub fn time_start_of_day() -> anyhow::Result<u64> {
|
||||
Ok(local.timestamp() as u64)
|
||||
}
|
||||
|
||||
/// Get formatted string containing current day information
|
||||
pub fn current_day() -> String {
|
||||
let dt = Local::now();
|
||||
format!("{}-{:0>2}-{:0>2}", dt.year(), dt.month(), dt.day())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod test {
|
||||
use crate::utils::time_utils::day_number;
|
||||
|
||||
@@ -1,30 +0,0 @@
|
||||
# React + TypeScript + Vite
|
||||
|
||||
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
|
||||
|
||||
Currently, two official plugins are available:
|
||||
|
||||
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react/README.md) uses [Babel](https://babeljs.io/) for Fast Refresh
|
||||
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
|
||||
|
||||
## Expanding the ESLint configuration
|
||||
|
||||
If you are developing a production application, we recommend updating the configuration to enable type aware lint rules:
|
||||
|
||||
- Configure the top-level `parserOptions` property like this:
|
||||
|
||||
```js
|
||||
export default {
|
||||
// other rules...
|
||||
parserOptions: {
|
||||
ecmaVersion: 'latest',
|
||||
sourceType: 'module',
|
||||
project: ['./tsconfig.json', './tsconfig.node.json'],
|
||||
tsconfigRootDir: __dirname,
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
- Replace `plugin:@typescript-eslint/recommended` to `plugin:@typescript-eslint/recommended-type-checked` or `plugin:@typescript-eslint/strict-type-checked`
|
||||
- Optionally add `plugin:@typescript-eslint/stylistic-type-checked`
|
||||
- Install [eslint-plugin-react](https://github.com/jsx-eslint/eslint-plugin-react) and add `plugin:react/recommended` & `plugin:react/jsx-runtime` to the `extends` list
|
||||
28
central_frontend/eslint.config.js
Normal file
28
central_frontend/eslint.config.js
Normal file
@@ -0,0 +1,28 @@
|
||||
import js from '@eslint/js'
|
||||
import globals from 'globals'
|
||||
import reactHooks from 'eslint-plugin-react-hooks'
|
||||
import reactRefresh from 'eslint-plugin-react-refresh'
|
||||
import tseslint from 'typescript-eslint'
|
||||
|
||||
export default tseslint.config(
|
||||
{ ignores: ['dist'] },
|
||||
{
|
||||
extends: [js.configs.recommended, ...tseslint.configs.recommended],
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
languageOptions: {
|
||||
ecmaVersion: 2020,
|
||||
globals: globals.browser,
|
||||
},
|
||||
plugins: {
|
||||
'react-hooks': reactHooks,
|
||||
'react-refresh': reactRefresh,
|
||||
},
|
||||
rules: {
|
||||
...reactHooks.configs.recommended.rules,
|
||||
'react-refresh/only-export-components': [
|
||||
'warn',
|
||||
{ allowConstantExport: true },
|
||||
],
|
||||
},
|
||||
},
|
||||
)
|
||||
3239
central_frontend/package-lock.json
generated
3239
central_frontend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -6,35 +6,40 @@
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
|
||||
"lint": "eslint .",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@emotion/react": "^11.13.3",
|
||||
"@emotion/styled": "^11.13.0",
|
||||
"@fontsource/roboto": "^5.1.0",
|
||||
"@emotion/react": "^11.14.0",
|
||||
"@emotion/styled": "^11.14.1",
|
||||
"@fontsource/roboto": "^5.2.8",
|
||||
"@mdi/js": "^7.4.47",
|
||||
"@mdi/react": "^1.6.1",
|
||||
"@mui/icons-material": "^6.1.1",
|
||||
"@mui/material": "^6.1.1",
|
||||
"@mui/x-charts": "^7.18.0",
|
||||
"@mui/x-date-pickers": "^7.18.0",
|
||||
"date-and-time": "^3.5.0",
|
||||
"dayjs": "^1.11.13",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-router-dom": "^6.26.2"
|
||||
"@mui/icons-material": "^7.3.4",
|
||||
"@mui/material": "^7.3.4",
|
||||
"@mui/x-charts": "^8.15.0",
|
||||
"@mui/x-date-pickers": "^8.15.0",
|
||||
"date-and-time": "^4.1.0",
|
||||
"dayjs": "^1.11.18",
|
||||
"filesize": "^11.0.13",
|
||||
"react": "^19.2.0",
|
||||
"react-dom": "^19.2.0",
|
||||
"react-router-dom": "^7.9.4",
|
||||
"semver": "^7.7.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.3.10",
|
||||
"@types/react-dom": "^18.3.0",
|
||||
"@typescript-eslint/eslint-plugin": "^8.7.0",
|
||||
"@typescript-eslint/parser": "^8.7.0",
|
||||
"@vitejs/plugin-react": "^4.3.1",
|
||||
"eslint": "^8.57.1",
|
||||
"eslint-plugin-react-hooks": "^4.6.2",
|
||||
"eslint-plugin-react-refresh": "^0.4.12",
|
||||
"typescript": "^5.6.2",
|
||||
"vite": "^5.4.8"
|
||||
"@types/react": "^19.2.2",
|
||||
"@types/react-dom": "^19.2.2",
|
||||
"@types/semver": "^7.7.1",
|
||||
"@typescript-eslint/eslint-plugin": "^8.46.2",
|
||||
"@typescript-eslint/parser": "^8.46.2",
|
||||
"@vitejs/plugin-react": "^5.1.0",
|
||||
"eslint": "^9.38.0",
|
||||
"eslint-plugin-react-hooks": "^7.0.1",
|
||||
"eslint-plugin-react-refresh": "^0.4.24",
|
||||
"globals": "^16.4.0",
|
||||
"typescript": "^5.9.3",
|
||||
"typescript-eslint": "^8.46.2",
|
||||
"vite": "^7.1.12"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,10 +10,13 @@ import { DeviceRoute } from "./routes/DeviceRoute/DeviceRoute";
|
||||
import { DevicesRoute } from "./routes/DevicesRoute";
|
||||
import { HomeRoute } from "./routes/HomeRoute";
|
||||
import { LoginRoute } from "./routes/LoginRoute";
|
||||
import { LogsRoute } from "./routes/LogsRoute";
|
||||
import { NotFoundRoute } from "./routes/NotFoundRoute";
|
||||
import { PendingDevicesRoute } from "./routes/PendingDevicesRoute";
|
||||
import { RelaysListRoute } from "./routes/RelaysListRoute";
|
||||
import { BaseAuthenticatedPage } from "./widgets/BaseAuthenticatedPage";
|
||||
import { OTARoute } from "./routes/OTARoute";
|
||||
import { ManagementRoute } from "./routes/ManagementRoute";
|
||||
|
||||
export function App() {
|
||||
if (!AuthApi.SignedIn && !ServerApi.Config.auth_disabled)
|
||||
@@ -27,6 +30,9 @@ export function App() {
|
||||
<Route path="devices" element={<DevicesRoute />} />
|
||||
<Route path="dev/:id" element={<DeviceRoute />} />
|
||||
<Route path="relays" element={<RelaysListRoute />} />
|
||||
<Route path="ota" element={<OTARoute />} />
|
||||
<Route path="logs" element={<LogsRoute />} />
|
||||
<Route path="management" element={<ManagementRoute />} />
|
||||
<Route path="*" element={<NotFoundRoute />} />
|
||||
</Route>
|
||||
)
|
||||
|
||||
@@ -37,6 +37,7 @@ export interface Device {
|
||||
validated: boolean;
|
||||
enabled: boolean;
|
||||
relays: DeviceRelay[];
|
||||
desired_version?: string;
|
||||
}
|
||||
|
||||
export interface UpdatedInfo {
|
||||
|
||||
29
central_frontend/src/api/LogsAPI.ts
Normal file
29
central_frontend/src/api/LogsAPI.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { Dayjs } from "dayjs";
|
||||
import { APIClient } from "./ApiClient";
|
||||
|
||||
export type LogSeverity = "Debug" | "Info" | "Warn" | "Error";
|
||||
|
||||
export interface LogEntry {
|
||||
device_id: string;
|
||||
time: number;
|
||||
severity: LogSeverity;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export class LogsAPI {
|
||||
/**
|
||||
* Request the logs from the server
|
||||
*
|
||||
* @param date The date that contains the requested date
|
||||
*/
|
||||
static async GetLogs(date: Dayjs): Promise<LogEntry[]> {
|
||||
const day = Math.floor(date.unix() / (3600 * 24));
|
||||
|
||||
const res = await APIClient.exec({
|
||||
uri: `/logging/logs?day=${day}`,
|
||||
method: "GET",
|
||||
});
|
||||
|
||||
return res.data;
|
||||
}
|
||||
}
|
||||
87
central_frontend/src/api/OTAApi.ts
Normal file
87
central_frontend/src/api/OTAApi.ts
Normal file
@@ -0,0 +1,87 @@
|
||||
import { APIClient } from "./ApiClient";
|
||||
|
||||
export interface OTAUpdate {
|
||||
platform: string;
|
||||
version: string;
|
||||
file_size: number;
|
||||
}
|
||||
|
||||
export class OTAAPI {
|
||||
/**
|
||||
* Get the list of supported OTA platforms
|
||||
*/
|
||||
static async SupportedPlatforms(): Promise<Array<string>> {
|
||||
return (
|
||||
await APIClient.exec({
|
||||
method: "GET",
|
||||
uri: "/ota/supported_platforms",
|
||||
})
|
||||
).data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Upload new OTA firwmare
|
||||
*/
|
||||
static async UploadFirmware(
|
||||
platform: string,
|
||||
version: string,
|
||||
firmware: File
|
||||
): Promise<void> {
|
||||
const fd = new FormData();
|
||||
fd.append("firmware", firmware);
|
||||
|
||||
await APIClient.exec({
|
||||
method: "POST",
|
||||
uri: `/ota/${platform}/${version}`,
|
||||
formData: fd,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the link to download an OTA update
|
||||
*/
|
||||
static DownloadOTAUpdateURL(update: OTAUpdate): string {
|
||||
return APIClient.backendURL() + `/ota/${update.platform}/${update.version}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete an update
|
||||
*/
|
||||
static async DeleteUpdate(update: OTAUpdate): Promise<void> {
|
||||
await APIClient.exec({
|
||||
method: "DELETE",
|
||||
uri: `/ota/${update.platform}/${update.version}`,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the list of OTA updates
|
||||
*/
|
||||
static async ListOTAUpdates(): Promise<OTAUpdate[]> {
|
||||
return (
|
||||
await APIClient.exec({
|
||||
method: "GET",
|
||||
uri: "/ota",
|
||||
})
|
||||
).data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set desired version for one or mor devices
|
||||
*/
|
||||
static async SetDesiredVersion(
|
||||
update: OTAUpdate,
|
||||
all_devices: boolean,
|
||||
devices?: string[]
|
||||
): Promise<void> {
|
||||
await APIClient.exec({
|
||||
method: "POST",
|
||||
uri: "/ota/set_desired_version",
|
||||
jsonData: {
|
||||
version: update.version,
|
||||
platform: update.platform,
|
||||
devices: all_devices ? undefined : devices!,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,19 @@
|
||||
import { APIClient } from "./ApiClient";
|
||||
import { Device, DeviceRelay } from "./DeviceApi";
|
||||
|
||||
export type RelayForcedState =
|
||||
| { type: "None" }
|
||||
| { type: "Off" | "On"; until: number };
|
||||
|
||||
export type SetRelayForcedState =
|
||||
| { type: "None" }
|
||||
| { type: "Off" | "On"; for_secs: number };
|
||||
|
||||
export interface RelayStatus {
|
||||
id: string;
|
||||
on: boolean;
|
||||
for: number;
|
||||
forced_state: RelayForcedState;
|
||||
}
|
||||
|
||||
export type RelaysStatus = Map<string, RelayStatus>;
|
||||
@@ -48,6 +57,20 @@ export class RelayApi {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Set relay forced state
|
||||
*/
|
||||
static async SetForcedState(
|
||||
relay: DeviceRelay,
|
||||
forced: SetRelayForcedState
|
||||
): Promise<void> {
|
||||
await APIClient.exec({
|
||||
method: "PUT",
|
||||
uri: `/relay/${relay.id}/forced_state`,
|
||||
jsonData: forced,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a relay configuration
|
||||
*/
|
||||
|
||||
@@ -3,6 +3,11 @@ import { APIClient } from "./ApiClient";
|
||||
export interface ServerConfig {
|
||||
auth_disabled: boolean;
|
||||
constraints: ServerConstraint;
|
||||
unsecure_origin: string;
|
||||
backend_version: string;
|
||||
dashboard_custom_current_consumption_title?: string;
|
||||
dashboard_custom_relays_consumption_title?: string;
|
||||
dashboard_custom_cached_consumption_title?: string;
|
||||
}
|
||||
|
||||
export interface ServerConstraint {
|
||||
|
||||
148
central_frontend/src/dialogs/DeployOTAUpdateDialogProvider.tsx
Normal file
148
central_frontend/src/dialogs/DeployOTAUpdateDialogProvider.tsx
Normal file
@@ -0,0 +1,148 @@
|
||||
import {
|
||||
Button,
|
||||
Checkbox,
|
||||
Dialog,
|
||||
DialogActions,
|
||||
DialogContent,
|
||||
DialogContentText,
|
||||
DialogTitle,
|
||||
FormControl,
|
||||
FormControlLabel,
|
||||
FormGroup,
|
||||
FormLabel,
|
||||
Radio,
|
||||
RadioGroup,
|
||||
Typography,
|
||||
} from "@mui/material";
|
||||
import React from "react";
|
||||
import { Device, DeviceApi } from "../api/DeviceApi";
|
||||
import { OTAAPI, OTAUpdate } from "../api/OTAApi";
|
||||
import { useAlert } from "../hooks/context_providers/AlertDialogProvider";
|
||||
import { useConfirm } from "../hooks/context_providers/ConfirmDialogProvider";
|
||||
import { useLoadingMessage } from "../hooks/context_providers/LoadingMessageProvider";
|
||||
import { useSnackbar } from "../hooks/context_providers/SnackbarProvider";
|
||||
import { AsyncWidget } from "../widgets/AsyncWidget";
|
||||
|
||||
export function DeployOTAUpdateDialogProvider(p: {
|
||||
update: OTAUpdate;
|
||||
onClose: () => void;
|
||||
}): React.ReactElement {
|
||||
const loadingMessage = useLoadingMessage();
|
||||
const alert = useAlert();
|
||||
const confirm = useConfirm();
|
||||
const snackbar = useSnackbar();
|
||||
|
||||
const [devicesList, setDevicesList] = React.useState<Device[] | undefined>();
|
||||
|
||||
const loadDevicesList = async () => {
|
||||
let list = await DeviceApi.ValidatedList();
|
||||
list = list.filter((e) => e.info.reference == p.update.platform);
|
||||
setDevicesList(list);
|
||||
};
|
||||
|
||||
const [allDevices, setAllDevices] = React.useState(false);
|
||||
const [selectedDevices, setSelectedDevices] = React.useState<string[]>([]);
|
||||
|
||||
const startDeployment = async () => {
|
||||
if (
|
||||
allDevices &&
|
||||
!(await confirm(
|
||||
"Do you really want to deploy the update to all devices?"
|
||||
))
|
||||
)
|
||||
return;
|
||||
try {
|
||||
loadingMessage.show("Applying OTA update...");
|
||||
|
||||
await OTAAPI.SetDesiredVersion(p.update, allDevices, selectedDevices);
|
||||
|
||||
snackbar("The update was successfully applied!");
|
||||
p.onClose();
|
||||
} catch (e) {
|
||||
console.error("Failed to deploy the udpate!", e);
|
||||
alert(`Failed to deploy the udpate! ${e}`);
|
||||
} finally {
|
||||
loadingMessage.hide();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={true} onClose={p.onClose}>
|
||||
<DialogTitle>
|
||||
Deploy update <i>{p.update.version}</i> for platform{" "}
|
||||
<i>{p.update.platform}</i>
|
||||
</DialogTitle>
|
||||
<AsyncWidget
|
||||
loadKey={1}
|
||||
load={loadDevicesList}
|
||||
errMsg="Failed to load the list of devices!"
|
||||
build={() => (
|
||||
<DialogContent>
|
||||
<DialogContentText>
|
||||
You can choose to deploy update to all device or to target only a
|
||||
part of devices:
|
||||
</DialogContentText>
|
||||
|
||||
<FormControl>
|
||||
<FormLabel>Deployment target</FormLabel>
|
||||
<RadioGroup
|
||||
name="radio-buttons-group"
|
||||
value={allDevices}
|
||||
onChange={(v) => setAllDevices(v.target.value == "true")}
|
||||
>
|
||||
<FormControlLabel
|
||||
value={true}
|
||||
control={<Radio />}
|
||||
label="Deploy the update to all the devices of the platform"
|
||||
/>
|
||||
<FormControlLabel
|
||||
value={false}
|
||||
control={<Radio />}
|
||||
label="Deploy the update to a limited range of devices"
|
||||
/>
|
||||
</RadioGroup>
|
||||
</FormControl>
|
||||
{!allDevices && (
|
||||
<Typography>
|
||||
There are no devices to which the update can be deployed.
|
||||
</Typography>
|
||||
)}
|
||||
{!allDevices && (
|
||||
<FormGroup>
|
||||
{devicesList?.map((d) => (
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Checkbox
|
||||
checked={selectedDevices.includes(d.id)}
|
||||
onChange={(_e, v) => {
|
||||
if (v) {
|
||||
selectedDevices.push(d.id);
|
||||
setSelectedDevices([...selectedDevices]);
|
||||
} else
|
||||
setSelectedDevices(
|
||||
selectedDevices.filter((e) => e != d.id)
|
||||
);
|
||||
}}
|
||||
/>
|
||||
}
|
||||
label={d.name}
|
||||
/>
|
||||
))}
|
||||
</FormGroup>
|
||||
)}
|
||||
</DialogContent>
|
||||
)}
|
||||
/>
|
||||
<DialogActions>
|
||||
<Button onClick={p.onClose}>Cancel</Button>
|
||||
<Button
|
||||
onClick={startDeployment}
|
||||
autoFocus
|
||||
disabled={!allDevices && selectedDevices.length == 0}
|
||||
>
|
||||
Start deployment
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
DialogTitle,
|
||||
Typography,
|
||||
} from "@mui/material";
|
||||
import Grid from "@mui/material/Grid2";
|
||||
import Grid from "@mui/material/Grid";
|
||||
import { TimePicker } from "@mui/x-date-pickers";
|
||||
import React from "react";
|
||||
import { Device, DeviceRelay } from "../api/DeviceApi";
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
import {
|
||||
Button,
|
||||
Dialog,
|
||||
DialogActions,
|
||||
DialogContent,
|
||||
DialogContentText,
|
||||
DialogTitle,
|
||||
TextField,
|
||||
} from "@mui/material";
|
||||
import { DeviceRelay } from "../api/DeviceApi";
|
||||
import React from "react";
|
||||
|
||||
export function SelectForcedStateDurationDialog(p: {
|
||||
relay: DeviceRelay;
|
||||
forcedState: string;
|
||||
onCancel: () => void;
|
||||
onSubmit: (duration: number) => void;
|
||||
}): React.ReactElement {
|
||||
const [duration, setDuration] = React.useState(60);
|
||||
|
||||
return (
|
||||
<Dialog open onClose={p.onCancel}>
|
||||
<DialogTitle>Set forced relay state</DialogTitle>
|
||||
<DialogContent>
|
||||
<DialogContentText>
|
||||
Please specify the number of minutes the relay <i>{p.relay.name}</i>{" "}
|
||||
will remain in forced state <i>{p.forcedState}</i>:
|
||||
</DialogContentText>
|
||||
|
||||
<TextField
|
||||
label="Duration (min)"
|
||||
variant="standard"
|
||||
value={Math.floor(duration / 60)}
|
||||
onChange={(e) => {
|
||||
const val = Number.parseInt(e.target.value);
|
||||
setDuration((Number.isNaN(val) ? 1 : val) * 60);
|
||||
}}
|
||||
fullWidth
|
||||
style={{ marginTop: "5px" }}
|
||||
/>
|
||||
|
||||
<p>Equivalent in seconds: {duration} secs</p>
|
||||
<p>Equivalent in hours: {duration / 3600} hours</p>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={p.onCancel}>Cancel</Button>
|
||||
<Button onClick={() => p.onSubmit(duration)} autoFocus>
|
||||
Start timer
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
132
central_frontend/src/dialogs/UploadUpdateDialog.tsx
Normal file
132
central_frontend/src/dialogs/UploadUpdateDialog.tsx
Normal file
@@ -0,0 +1,132 @@
|
||||
import CloudUploadIcon from "@mui/icons-material/CloudUpload";
|
||||
import {
|
||||
Button,
|
||||
Dialog,
|
||||
DialogActions,
|
||||
DialogContent,
|
||||
DialogContentText,
|
||||
DialogTitle,
|
||||
FormControl,
|
||||
InputLabel,
|
||||
MenuItem,
|
||||
Select,
|
||||
styled,
|
||||
} from "@mui/material";
|
||||
import React from "react";
|
||||
import { OTAAPI } from "../api/OTAApi";
|
||||
import { useAlert } from "../hooks/context_providers/AlertDialogProvider";
|
||||
import { useLoadingMessage } from "../hooks/context_providers/LoadingMessageProvider";
|
||||
import { useSnackbar } from "../hooks/context_providers/SnackbarProvider";
|
||||
import { checkVersion } from "../utils/StringsUtils";
|
||||
import { TextInput } from "../widgets/forms/TextInput";
|
||||
|
||||
const VisuallyHiddenInput = styled("input")({
|
||||
clip: "rect(0 0 0 0)",
|
||||
clipPath: "inset(50%)",
|
||||
height: 1,
|
||||
overflow: "hidden",
|
||||
position: "absolute",
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
whiteSpace: "nowrap",
|
||||
width: 1,
|
||||
});
|
||||
|
||||
export function UploadUpdateDialog(p: {
|
||||
platforms: string[];
|
||||
onClose: () => void;
|
||||
onCreated: () => void;
|
||||
}): React.ReactElement {
|
||||
const alert = useAlert();
|
||||
const snackbar = useSnackbar();
|
||||
const loadingMessage = useLoadingMessage();
|
||||
|
||||
const [platform, setPlatform] = React.useState<string | undefined>();
|
||||
const [version, setVersion] = React.useState<string | undefined>();
|
||||
const [file, setFile] = React.useState<File | undefined>();
|
||||
|
||||
const canSubmit = platform && version && checkVersion(version) && file;
|
||||
|
||||
const upload = async () => {
|
||||
try {
|
||||
loadingMessage.show("Uploading firmware...");
|
||||
await OTAAPI.UploadFirmware(platform!, version!, file!);
|
||||
|
||||
snackbar("Successfully uploaded new firmware!");
|
||||
|
||||
p.onCreated();
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
alert(`Failed to upload firmware: ${e}`);
|
||||
} finally {
|
||||
loadingMessage.hide();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={true} onClose={p.onClose}>
|
||||
<DialogTitle>Submit a new update</DialogTitle>
|
||||
<DialogContent>
|
||||
<DialogContentText>
|
||||
You can upload a new firmware using this form.
|
||||
</DialogContentText>
|
||||
<br />
|
||||
<FormControl fullWidth>
|
||||
<InputLabel>Platform</InputLabel>
|
||||
<Select
|
||||
label="Platform"
|
||||
value={platform}
|
||||
onChange={(e) => setPlatform(e.target.value)}
|
||||
variant="standard"
|
||||
>
|
||||
{p.platforms.map((p) => (
|
||||
<MenuItem key={p} value={p}>
|
||||
{p}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
<br />
|
||||
<br />
|
||||
<TextInput
|
||||
editable
|
||||
label="Version"
|
||||
helperText="The version shall follow semantics requirements"
|
||||
value={version}
|
||||
onValueChange={setVersion}
|
||||
checkValue={checkVersion}
|
||||
/>
|
||||
|
||||
<br />
|
||||
|
||||
<Button
|
||||
fullWidth
|
||||
component="label"
|
||||
role={undefined}
|
||||
variant={file ? "contained" : "outlined"}
|
||||
tabIndex={-1}
|
||||
startIcon={<CloudUploadIcon />}
|
||||
>
|
||||
Upload file
|
||||
<VisuallyHiddenInput
|
||||
type="file"
|
||||
onChange={(event) =>
|
||||
setFile(
|
||||
(event.target.files?.length ?? 0) > 0
|
||||
? event.target.files![0]
|
||||
: undefined
|
||||
)
|
||||
}
|
||||
multiple
|
||||
/>
|
||||
</Button>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={p.onClose}>Cancel</Button>
|
||||
<Button type="submit" disabled={!canSubmit} onClick={upload}>
|
||||
Upload
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -10,16 +10,16 @@ import {
|
||||
} from "@mui/material";
|
||||
import React from "react";
|
||||
import { Device, DeviceRelay } from "../../api/DeviceApi";
|
||||
import { RelayApi, RelayStatus } from "../../api/RelayApi";
|
||||
import { EditDeviceRelaysDialog } from "../../dialogs/EditDeviceRelaysDialog";
|
||||
import { DeviceRouteCard } from "./DeviceRouteCard";
|
||||
import { useAlert } from "../../hooks/context_providers/AlertDialogProvider";
|
||||
import { useConfirm } from "../../hooks/context_providers/ConfirmDialogProvider";
|
||||
import { useLoadingMessage } from "../../hooks/context_providers/LoadingMessageProvider";
|
||||
import { RelayApi, RelayStatus } from "../../api/RelayApi";
|
||||
import { useSnackbar } from "../../hooks/context_providers/SnackbarProvider";
|
||||
import { useAlert } from "../../hooks/context_providers/AlertDialogProvider";
|
||||
import { AsyncWidget } from "../../widgets/AsyncWidget";
|
||||
import { TimeWidget } from "../../widgets/TimeWidget";
|
||||
import { BoolText } from "../../widgets/BoolText";
|
||||
import { TimeWidget } from "../../widgets/TimeWidget";
|
||||
import { DeviceRouteCard } from "./DeviceRouteCard";
|
||||
|
||||
export function DeviceRelays(p: {
|
||||
device: Device;
|
||||
@@ -129,7 +129,9 @@ export function DeviceRelays(p: {
|
||||
);
|
||||
}
|
||||
|
||||
function RelayEntryStatus(p: { relay: DeviceRelay }): React.ReactElement {
|
||||
function RelayEntryStatus(
|
||||
p: Readonly<{ relay: DeviceRelay }>
|
||||
): React.ReactElement {
|
||||
const [state, setState] = React.useState<RelayStatus | undefined>();
|
||||
|
||||
const load = async () => {
|
||||
@@ -143,7 +145,8 @@ function RelayEntryStatus(p: { relay: DeviceRelay }): React.ReactElement {
|
||||
errMsg="Failed to load relay status!"
|
||||
build={() => (
|
||||
<>
|
||||
<BoolText val={state!.on} positive="ON" negative="OFF" /> for{" "}
|
||||
<BoolText val={state!.on} positive="ON" negative="OFF" />{" "}
|
||||
{state?.forced_state.type !== "None" && <b>Forced</b>} for{" "}
|
||||
<TimeWidget diff time={state!.for} />
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import DeleteIcon from "@mui/icons-material/Delete";
|
||||
import RefreshIcon from "@mui/icons-material/Refresh";
|
||||
import { IconButton, Tooltip } from "@mui/material";
|
||||
import Grid from "@mui/material/Grid2";
|
||||
import Grid from "@mui/material/Grid";
|
||||
import React from "react";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import { Device, DeviceApi } from "../../api/DeviceApi";
|
||||
|
||||
@@ -41,6 +41,10 @@ export function GeneralDeviceInfo(p: {
|
||||
value={p.device.info.reference}
|
||||
/>
|
||||
<DeviceInfoProperty label="Version" value={p.device.info.version} />
|
||||
<DeviceInfoProperty
|
||||
label="Desired version"
|
||||
value={p.device.desired_version ?? "None"}
|
||||
/>
|
||||
<DeviceInfoProperty label="Name" value={p.device.name} />
|
||||
<DeviceInfoProperty
|
||||
label="Description"
|
||||
|
||||
@@ -81,7 +81,7 @@ function ValidatedDevicesList(p: {
|
||||
<Table sx={{ minWidth: 650 }} aria-label="simple table">
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell>#</TableCell>
|
||||
<TableCell>Name</TableCell>
|
||||
<TableCell align="center">Model</TableCell>
|
||||
<TableCell align="center">Version</TableCell>
|
||||
<TableCell align="center">Max relays</TableCell>
|
||||
@@ -99,7 +99,7 @@ function ValidatedDevicesList(p: {
|
||||
onDoubleClick={() => navigate(DeviceURL(dev))}
|
||||
>
|
||||
<TableCell component="th" scope="row">
|
||||
{dev.id}
|
||||
{dev.name}
|
||||
</TableCell>
|
||||
<TableCell align="center">{dev.info.reference}</TableCell>
|
||||
<TableCell align="center">{dev.info.version}</TableCell>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Typography } from "@mui/material";
|
||||
import { CurrConsumptionWidget } from "./HomeRoute/CurrConsumptionWidget";
|
||||
import Grid from "@mui/material/Grid2";
|
||||
import Grid from "@mui/material/Grid";
|
||||
import { CachedConsumptionWidget } from "./HomeRoute/CachedConsumptionWidget";
|
||||
import { RelayConsumptionWidget } from "./HomeRoute/RelayConsumptionWidget";
|
||||
import { RelaysListRoute } from "./RelaysListRoute";
|
||||
|
||||
@@ -2,6 +2,7 @@ import React from "react";
|
||||
import { EnergyApi } from "../../api/EnergyApi";
|
||||
import { useSnackbar } from "../../hooks/context_providers/SnackbarProvider";
|
||||
import StatCard from "../../widgets/StatCard";
|
||||
import { ServerApi } from "../../api/ServerApi";
|
||||
|
||||
export function CachedConsumptionWidget(): React.ReactElement {
|
||||
const snackbar = useSnackbar();
|
||||
@@ -26,6 +27,12 @@ export function CachedConsumptionWidget(): React.ReactElement {
|
||||
});
|
||||
|
||||
return (
|
||||
<StatCard title="Cached consumption" value={val?.toString() ?? "Loading"} />
|
||||
<StatCard
|
||||
title={
|
||||
ServerApi.Config.dashboard_custom_cached_consumption_title ??
|
||||
"Cached consumption"
|
||||
}
|
||||
value={val?.toString() ?? "Loading"}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import React from "react";
|
||||
import { EnergyApi } from "../../api/EnergyApi";
|
||||
import { useSnackbar } from "../../hooks/context_providers/SnackbarProvider";
|
||||
import StatCard from "../../widgets/StatCard";
|
||||
import { ServerApi } from "../../api/ServerApi";
|
||||
|
||||
export function CurrConsumptionWidget(): React.ReactElement {
|
||||
const snackbar = useSnackbar();
|
||||
@@ -29,7 +30,10 @@ export function CurrConsumptionWidget(): React.ReactElement {
|
||||
|
||||
return (
|
||||
<StatCard
|
||||
title="Current consumption"
|
||||
title={
|
||||
ServerApi.Config.dashboard_custom_current_consumption_title ??
|
||||
"Current consumption"
|
||||
}
|
||||
data={history ?? []}
|
||||
interval="Last day"
|
||||
value={val?.toString() ?? "Loading"}
|
||||
|
||||
@@ -2,6 +2,7 @@ import React from "react";
|
||||
import { EnergyApi } from "../../api/EnergyApi";
|
||||
import { useSnackbar } from "../../hooks/context_providers/SnackbarProvider";
|
||||
import StatCard from "../../widgets/StatCard";
|
||||
import { ServerApi } from "../../api/ServerApi";
|
||||
|
||||
export function RelayConsumptionWidget(): React.ReactElement {
|
||||
const snackbar = useSnackbar();
|
||||
@@ -29,7 +30,10 @@ export function RelayConsumptionWidget(): React.ReactElement {
|
||||
|
||||
return (
|
||||
<StatCard
|
||||
title="Relays consumption"
|
||||
title={
|
||||
ServerApi.Config.dashboard_custom_relays_consumption_title ??
|
||||
"Relays consumption"
|
||||
}
|
||||
data={history ?? []}
|
||||
interval="Last day"
|
||||
value={val?.toString() ?? "Loading"}
|
||||
|
||||
@@ -11,7 +11,7 @@ import Typography from "@mui/material/Typography";
|
||||
import * as React from "react";
|
||||
import { useLoadingMessage } from "../hooks/context_providers/LoadingMessageProvider";
|
||||
import { AuthApi } from "../api/AuthApi";
|
||||
import Grid from "@mui/material/Grid2";
|
||||
import Grid from "@mui/material/Grid";
|
||||
|
||||
function Copyright(props: any) {
|
||||
return (
|
||||
|
||||
124
central_frontend/src/routes/LogsRoute.tsx
Normal file
124
central_frontend/src/routes/LogsRoute.tsx
Normal file
@@ -0,0 +1,124 @@
|
||||
import NavigateBeforeIcon from "@mui/icons-material/NavigateBefore";
|
||||
import NavigateNextIcon from "@mui/icons-material/NavigateNext";
|
||||
import RefreshIcon from "@mui/icons-material/Refresh";
|
||||
import {
|
||||
IconButton,
|
||||
Paper,
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableContainer,
|
||||
TableHead,
|
||||
TableRow,
|
||||
Tooltip,
|
||||
Typography,
|
||||
} from "@mui/material";
|
||||
import { DatePicker } from "@mui/x-date-pickers";
|
||||
import dayjs from "dayjs";
|
||||
import React from "react";
|
||||
import { LogEntry, LogsAPI } from "../api/LogsAPI";
|
||||
import { AsyncWidget } from "../widgets/AsyncWidget";
|
||||
import { SolarEnergyRouteContainer } from "../widgets/SolarEnergyRouteContainer";
|
||||
|
||||
export function LogsRoute(): React.ReactElement {
|
||||
const loadKey = React.useRef(1);
|
||||
|
||||
const [currDate, setCurrDate] = React.useState(dayjs());
|
||||
|
||||
const [logs, setLogs] = React.useState<LogEntry[] | undefined>();
|
||||
|
||||
const load = async () => {
|
||||
const logs = await LogsAPI.GetLogs(currDate);
|
||||
logs.reverse();
|
||||
setLogs(logs);
|
||||
};
|
||||
|
||||
const reload = () => {
|
||||
setLogs(undefined);
|
||||
loadKey.current += 1;
|
||||
};
|
||||
return (
|
||||
<SolarEnergyRouteContainer
|
||||
label="Logs"
|
||||
actions={
|
||||
<Tooltip title="Refresh logs">
|
||||
<IconButton onClick={reload}>
|
||||
<RefreshIcon />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
width: "100%",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
marginBottom: "10px",
|
||||
}}
|
||||
>
|
||||
<Tooltip title="Previous day">
|
||||
<IconButton onClick={() => setCurrDate(currDate.add(-1, "day"))}>
|
||||
<NavigateBeforeIcon />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
<DatePicker
|
||||
label="Shown day"
|
||||
value={currDate}
|
||||
onChange={(d) => setCurrDate(d === null ? currDate : d)}
|
||||
/>
|
||||
<Tooltip title="Next day">
|
||||
<IconButton onClick={() => setCurrDate(currDate.add(1, "day"))}>
|
||||
<NavigateNextIcon />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<AsyncWidget
|
||||
ready={!!logs}
|
||||
loadKey={loadKey.current + currDate.toString()}
|
||||
errMsg="Failed to load the logs!"
|
||||
load={load}
|
||||
build={() => <LogsView logs={logs!} />}
|
||||
/>
|
||||
</SolarEnergyRouteContainer>
|
||||
);
|
||||
}
|
||||
|
||||
function LogsView(p: { logs: LogEntry[] }): React.ReactElement {
|
||||
if (p.logs.length == 0) {
|
||||
return (
|
||||
<Typography style={{ textAlign: "center" }}>
|
||||
There was no log recorded on this day.
|
||||
</Typography>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<TableContainer component={Paper}>
|
||||
<Table sx={{ minWidth: 650 }} size="small" aria-label="a dense table">
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell>Device ID</TableCell>
|
||||
<TableCell>Time</TableCell>
|
||||
<TableCell>Severity</TableCell>
|
||||
<TableCell>Message</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{p.logs.map((row, id) => (
|
||||
<TableRow key={id} hover>
|
||||
<TableCell component="th" scope="row">
|
||||
{row.device_id ?? "Backend"}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{new Date(row.time * 1000).toLocaleTimeString()}
|
||||
</TableCell>
|
||||
<TableCell>{row.severity}</TableCell>
|
||||
<TableCell>{row.message}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
);
|
||||
}
|
||||
31
central_frontend/src/routes/ManagementRoute.tsx
Normal file
31
central_frontend/src/routes/ManagementRoute.tsx
Normal file
@@ -0,0 +1,31 @@
|
||||
import { Button } from "@mui/material";
|
||||
import { useConfirm } from "../hooks/context_providers/ConfirmDialogProvider";
|
||||
import { SolarEnergyRouteContainer } from "../widgets/SolarEnergyRouteContainer";
|
||||
import { APIClient } from "../api/ApiClient";
|
||||
|
||||
export function ManagementRoute(): React.ReactElement {
|
||||
const confirm = useConfirm();
|
||||
|
||||
const downloadBackup = async () => {
|
||||
try {
|
||||
if (
|
||||
!(await confirm(
|
||||
`Do you really want to download a copy of the storage? It will contain sensitive information!`
|
||||
))
|
||||
)
|
||||
return;
|
||||
|
||||
location.href = APIClient.backendURL() + "/management/download_storage";
|
||||
} catch (e) {
|
||||
console.error(`Failed to donwload a backup of the storage! Error: ${e}`);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<SolarEnergyRouteContainer label="Management">
|
||||
<Button variant="outlined" onClick={downloadBackup}>
|
||||
Download a backup of storage
|
||||
</Button>
|
||||
</SolarEnergyRouteContainer>
|
||||
);
|
||||
}
|
||||
203
central_frontend/src/routes/OTARoute.tsx
Normal file
203
central_frontend/src/routes/OTARoute.tsx
Normal file
@@ -0,0 +1,203 @@
|
||||
import { mdiFolderUploadOutline } from "@mdi/js";
|
||||
import Icon from "@mdi/react";
|
||||
import DeleteIcon from "@mui/icons-material/Delete";
|
||||
import DownloadIcon from "@mui/icons-material/Download";
|
||||
import FileUploadIcon from "@mui/icons-material/FileUpload";
|
||||
import RefreshIcon from "@mui/icons-material/Refresh";
|
||||
import {
|
||||
IconButton,
|
||||
Paper,
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableContainer,
|
||||
TableHead,
|
||||
TableRow,
|
||||
Tooltip,
|
||||
Typography,
|
||||
} from "@mui/material";
|
||||
import { filesize } from "filesize";
|
||||
import React from "react";
|
||||
import { OTAAPI, OTAUpdate } from "../api/OTAApi";
|
||||
import { DeployOTAUpdateDialogProvider } from "../dialogs/DeployOTAUpdateDialogProvider";
|
||||
import { UploadUpdateDialog } from "../dialogs/UploadUpdateDialog";
|
||||
import { useAlert } from "../hooks/context_providers/AlertDialogProvider";
|
||||
import { useConfirm } from "../hooks/context_providers/ConfirmDialogProvider";
|
||||
import { useLoadingMessage } from "../hooks/context_providers/LoadingMessageProvider";
|
||||
import { useSnackbar } from "../hooks/context_providers/SnackbarProvider";
|
||||
import { AsyncWidget } from "../widgets/AsyncWidget";
|
||||
import { RouterLink } from "../widgets/RouterLink";
|
||||
import { SolarEnergyRouteContainer } from "../widgets/SolarEnergyRouteContainer";
|
||||
|
||||
export function OTARoute(): React.ReactElement {
|
||||
const [list, setList] = React.useState<string[] | undefined>();
|
||||
const load = async () => {
|
||||
setList(await OTAAPI.SupportedPlatforms());
|
||||
};
|
||||
|
||||
return (
|
||||
<AsyncWidget
|
||||
loadKey={1}
|
||||
ready={!!list}
|
||||
load={load}
|
||||
errMsg="Failed to load OTA screen!"
|
||||
build={() => <_OTARoute platforms={list!} />}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function _OTARoute(p: { platforms: Array<string> }): React.ReactElement {
|
||||
const key = React.useRef(1);
|
||||
const [showUploadDialog, setShowUploadDialog] = React.useState(false);
|
||||
|
||||
const [list, setList] = React.useState<undefined | OTAUpdate[]>();
|
||||
|
||||
const load = async () => {
|
||||
const list = await OTAAPI.ListOTAUpdates();
|
||||
list.sort((a, b) =>
|
||||
`${a.platform}#${a.version}`.localeCompare(`${b.platform}#${b.version}`)
|
||||
);
|
||||
list.reverse();
|
||||
setList(list);
|
||||
};
|
||||
|
||||
const reload = async () => {
|
||||
key.current += 1;
|
||||
setList(undefined);
|
||||
};
|
||||
|
||||
return (
|
||||
<SolarEnergyRouteContainer
|
||||
label="OTA"
|
||||
actions={
|
||||
<span>
|
||||
<Tooltip title="Refresh the list of updates">
|
||||
<IconButton onClick={reload}>
|
||||
<RefreshIcon />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
<Tooltip title="Upload a new update">
|
||||
<IconButton onClick={() => setShowUploadDialog(true)}>
|
||||
<FileUploadIcon />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</span>
|
||||
}
|
||||
>
|
||||
{showUploadDialog && (
|
||||
<UploadUpdateDialog
|
||||
platforms={p.platforms}
|
||||
onClose={() => setShowUploadDialog(false)}
|
||||
onCreated={() => {
|
||||
setShowUploadDialog(false);
|
||||
reload();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<AsyncWidget
|
||||
loadKey={key.current}
|
||||
ready={!!list}
|
||||
errMsg="Failed to load the list of OTA updates!"
|
||||
load={load}
|
||||
build={() => <_OTAList list={list!} onReload={reload} />}
|
||||
/>
|
||||
</SolarEnergyRouteContainer>
|
||||
);
|
||||
}
|
||||
|
||||
function _OTAList(p: {
|
||||
list: OTAUpdate[];
|
||||
onReload: () => void;
|
||||
}): React.ReactElement {
|
||||
const alert = useAlert();
|
||||
const confirm = useConfirm();
|
||||
const loadingMessage = useLoadingMessage();
|
||||
const snackbar = useSnackbar();
|
||||
|
||||
const [deployUpdate, setDeployUpdate] = React.useState<
|
||||
OTAUpdate | undefined
|
||||
>();
|
||||
|
||||
const deleteUpdate = async (update: OTAUpdate) => {
|
||||
if (
|
||||
!(await confirm(
|
||||
`Do you really want to delete the update for platform ${update.platform} version ${update.version}?`
|
||||
))
|
||||
)
|
||||
return;
|
||||
|
||||
try {
|
||||
loadingMessage.show("Deleting update...");
|
||||
|
||||
await OTAAPI.DeleteUpdate(update);
|
||||
|
||||
snackbar("The update was successfully deleted!");
|
||||
|
||||
p.onReload();
|
||||
} catch (e) {
|
||||
console.error("Failed to delete update!", e);
|
||||
alert(`Failed to delete the update! ${e}`);
|
||||
} finally {
|
||||
loadingMessage.hide();
|
||||
}
|
||||
};
|
||||
|
||||
if (p.list.length === 0) {
|
||||
return (
|
||||
<Typography>
|
||||
There is no OTA update uploaded on the platform yet.
|
||||
</Typography>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{deployUpdate && (
|
||||
<DeployOTAUpdateDialogProvider
|
||||
update={deployUpdate!}
|
||||
onClose={() => setDeployUpdate(undefined)}
|
||||
/>
|
||||
)}
|
||||
<TableContainer component={Paper}>
|
||||
<Table sx={{ minWidth: 650 }} aria-label="list of updates">
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell align="center">Platform</TableCell>
|
||||
<TableCell align="center">Version</TableCell>
|
||||
<TableCell align="center">File size</TableCell>
|
||||
<TableCell align="center"></TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{p.list.map((row, num) => (
|
||||
<TableRow hover key={num}>
|
||||
<TableCell align="center">{row.platform}</TableCell>
|
||||
<TableCell align="center">{row.version}</TableCell>
|
||||
<TableCell align="center">{filesize(row.file_size)}</TableCell>
|
||||
<TableCell align="center">
|
||||
<Tooltip title="Deploy the update to devices">
|
||||
<IconButton onClick={() => setDeployUpdate(row)}>
|
||||
<Icon path={mdiFolderUploadOutline} size={1} />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
<Tooltip title="Download a copy of the firmware">
|
||||
<RouterLink to={OTAAPI.DownloadOTAUpdateURL(row)}>
|
||||
<IconButton>
|
||||
<DownloadIcon />
|
||||
</IconButton>
|
||||
</RouterLink>
|
||||
</Tooltip>
|
||||
<Tooltip title="Delete firmware update">
|
||||
<IconButton onClick={() => deleteUpdate(row)}>
|
||||
<DeleteIcon />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import LinkIcon from "@mui/icons-material/Link";
|
||||
import RefreshIcon from "@mui/icons-material/Refresh";
|
||||
import {
|
||||
IconButton,
|
||||
@@ -9,15 +10,19 @@ import {
|
||||
TableHead,
|
||||
TableRow,
|
||||
Tooltip,
|
||||
Typography,
|
||||
} from "@mui/material";
|
||||
import React from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { Device, DeviceApi, DeviceRelay, DeviceURL } from "../api/DeviceApi";
|
||||
import { RelayApi, RelaysStatus } from "../api/RelayApi";
|
||||
import { ServerApi } from "../api/ServerApi";
|
||||
import { AsyncWidget } from "../widgets/AsyncWidget";
|
||||
import { BoolText } from "../widgets/BoolText";
|
||||
import { CopyToClipboard } from "../widgets/CopyToClipboard";
|
||||
import { RelayForcedState } from "../widgets/RelayForcedState";
|
||||
import { SolarEnergyRouteContainer } from "../widgets/SolarEnergyRouteContainer";
|
||||
import { TimeWidget } from "../widgets/TimeWidget";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
export function RelaysListRoute(p: {
|
||||
homeWidget?: boolean;
|
||||
@@ -86,6 +91,10 @@ function RelaysList(p: {
|
||||
navigate(DeviceURL(dev!));
|
||||
};
|
||||
|
||||
if (p.list.length === 0) {
|
||||
return <Typography>There is no configured relay yet!</Typography>;
|
||||
}
|
||||
|
||||
return (
|
||||
<TableContainer component={Paper}>
|
||||
<Table sx={{ minWidth: 650 }}>
|
||||
@@ -96,6 +105,8 @@ function RelaysList(p: {
|
||||
<TableCell>Priority</TableCell>
|
||||
<TableCell>Consumption</TableCell>
|
||||
<TableCell>Status</TableCell>
|
||||
<TableCell>Forced state</TableCell>
|
||||
<TableCell></TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
@@ -120,6 +131,27 @@ function RelaysList(p: {
|
||||
/>{" "}
|
||||
for <TimeWidget diff time={p.status.get(row.id)!.for} />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<RelayForcedState
|
||||
relay={row}
|
||||
state={p.status.get(row.id)!}
|
||||
onUpdated={p.onReload}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Tooltip title="Copy legacy api status">
|
||||
<CopyToClipboard
|
||||
content={
|
||||
ServerApi.Config.unsecure_origin +
|
||||
`/relay/${row.id}/legacy_state`
|
||||
}
|
||||
>
|
||||
<IconButton>
|
||||
<LinkIcon />
|
||||
</IconButton>
|
||||
</CopyToClipboard>
|
||||
</Tooltip>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { SemVer } from "semver";
|
||||
import { LenConstraint } from "../api/ServerApi";
|
||||
|
||||
/**
|
||||
@@ -6,3 +7,16 @@ import { LenConstraint } from "../api/ServerApi";
|
||||
export function lenValid(s: string, c: LenConstraint): boolean {
|
||||
return s.length >= c.min && s.length <= c.max;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check out whether a given version number respect semantics requirements or not
|
||||
*/
|
||||
export function checkVersion(v: string): boolean {
|
||||
try {
|
||||
new SemVer(v, { loose: false });
|
||||
return true;
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
30
central_frontend/src/widgets/CopyToClipboard.tsx
Normal file
30
central_frontend/src/widgets/CopyToClipboard.tsx
Normal file
@@ -0,0 +1,30 @@
|
||||
import { ButtonBase } from "@mui/material";
|
||||
import { PropsWithChildren } from "react";
|
||||
import { useSnackbar } from "../hooks/context_providers/SnackbarProvider";
|
||||
|
||||
export function CopyToClipboard(
|
||||
p: PropsWithChildren<{ content: string }>
|
||||
): React.ReactElement {
|
||||
const snackbar = useSnackbar();
|
||||
|
||||
const copy = () => {
|
||||
navigator.clipboard.writeText(p.content);
|
||||
snackbar(`${p.content} copied to clipboard.`);
|
||||
};
|
||||
|
||||
return (
|
||||
<ButtonBase
|
||||
onClick={copy}
|
||||
style={{
|
||||
display: "inline-block",
|
||||
alignItems: "unset",
|
||||
textAlign: "unset",
|
||||
position: "relative",
|
||||
padding: "0px",
|
||||
}}
|
||||
disableRipple
|
||||
>
|
||||
{p.children}
|
||||
</ButtonBase>
|
||||
);
|
||||
}
|
||||
79
central_frontend/src/widgets/RelayForcedState.tsx
Normal file
79
central_frontend/src/widgets/RelayForcedState.tsx
Normal file
@@ -0,0 +1,79 @@
|
||||
import { MenuItem, Select, SelectChangeEvent } from "@mui/material";
|
||||
import { DeviceRelay } from "../api/DeviceApi";
|
||||
import { RelayApi, RelayStatus, SetRelayForcedState } from "../api/RelayApi";
|
||||
import { TimeWidget } from "./TimeWidget";
|
||||
import { useLoadingMessage } from "../hooks/context_providers/LoadingMessageProvider";
|
||||
import { useAlert } from "../hooks/context_providers/AlertDialogProvider";
|
||||
import { useSnackbar } from "../hooks/context_providers/SnackbarProvider";
|
||||
import React from "react";
|
||||
import { SelectForcedStateDurationDialog } from "../dialogs/SelectForcedStateDurationDialog";
|
||||
|
||||
export function RelayForcedState(p: {
|
||||
relay: DeviceRelay;
|
||||
state: RelayStatus;
|
||||
onUpdated: () => void;
|
||||
}): React.ReactElement {
|
||||
const loadingMessage = useLoadingMessage();
|
||||
const alert = useAlert();
|
||||
const snackbar = useSnackbar();
|
||||
|
||||
const [futureStateType, setFutureStateType] = React.useState<
|
||||
string | undefined
|
||||
>();
|
||||
|
||||
const handleChange = (event: SelectChangeEvent) => {
|
||||
if (event.target.value == "None") {
|
||||
submitChange({ type: "None" });
|
||||
} else {
|
||||
setFutureStateType(event.target.value);
|
||||
}
|
||||
};
|
||||
|
||||
const submitChange = async (state: SetRelayForcedState) => {
|
||||
try {
|
||||
loadingMessage.show("Setting forced state...");
|
||||
await RelayApi.SetForcedState(p.relay, state);
|
||||
p.onUpdated();
|
||||
snackbar("Forced state successfully updated!");
|
||||
} catch (e) {
|
||||
console.error(`Failed to set relay forced state! ${e}`);
|
||||
alert(`Failed to set loading state for relay! ${e}`);
|
||||
} finally {
|
||||
loadingMessage.hide();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Select
|
||||
value={p.state.forced_state.type}
|
||||
onChange={handleChange}
|
||||
size="small"
|
||||
variant="standard"
|
||||
>
|
||||
<MenuItem value={"None"}>None</MenuItem>
|
||||
<MenuItem value={"Off"}>Off</MenuItem>
|
||||
<MenuItem value={"On"}>On</MenuItem>
|
||||
</Select>
|
||||
{p.state.forced_state.type !== "None" && (
|
||||
<>
|
||||
<TimeWidget future time={p.state.forced_state.until} /> left
|
||||
</>
|
||||
)}
|
||||
|
||||
{futureStateType !== undefined && (
|
||||
<SelectForcedStateDurationDialog
|
||||
{...p}
|
||||
forcedState={futureStateType}
|
||||
onCancel={() => setFutureStateType(undefined)}
|
||||
onSubmit={(d) =>
|
||||
submitChange({
|
||||
type: futureStateType as any,
|
||||
for_secs: d,
|
||||
})
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,13 +1,23 @@
|
||||
import { mdiChip, mdiElectricSwitch, mdiHome, mdiNewBox } from "@mdi/js";
|
||||
import {
|
||||
mdiChip,
|
||||
mdiCog,
|
||||
mdiElectricSwitch,
|
||||
mdiHome,
|
||||
mdiMonitorArrowDown,
|
||||
mdiNewBox,
|
||||
mdiNotebookMultiple,
|
||||
} from "@mdi/js";
|
||||
import Icon from "@mdi/react";
|
||||
import {
|
||||
List,
|
||||
ListItemButton,
|
||||
ListItemIcon,
|
||||
ListItemText,
|
||||
Typography,
|
||||
} from "@mui/material";
|
||||
import { useLocation } from "react-router-dom";
|
||||
import { RouterLink } from "./RouterLink";
|
||||
import { ServerApi } from "../api/ServerApi";
|
||||
|
||||
export function SolarEnergyNavList(): React.ReactElement {
|
||||
return (
|
||||
@@ -35,6 +45,28 @@ export function SolarEnergyNavList(): React.ReactElement {
|
||||
uri="/relays"
|
||||
icon={<Icon path={mdiElectricSwitch} size={1} />}
|
||||
/>
|
||||
<NavLink
|
||||
label="OTA"
|
||||
uri="/ota"
|
||||
icon={<Icon path={mdiMonitorArrowDown} size={1} />}
|
||||
/>
|
||||
<NavLink
|
||||
label="Logging"
|
||||
uri="/logs"
|
||||
icon={<Icon path={mdiNotebookMultiple} size={1} />}
|
||||
/>
|
||||
<NavLink
|
||||
label="Management"
|
||||
uri="/management"
|
||||
icon={<Icon path={mdiCog} size={1} />}
|
||||
/>
|
||||
<Typography
|
||||
variant="caption"
|
||||
component="div"
|
||||
style={{ textAlign: "center", width: "100%", marginTop: "30px" }}
|
||||
>
|
||||
Version {ServerApi.Config.backend_version}
|
||||
</Typography>
|
||||
</List>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -9,13 +9,20 @@ export function SolarEnergyRouteContainer(
|
||||
} & PropsWithChildren
|
||||
): React.ReactElement {
|
||||
return (
|
||||
<div style={{ margin: p.homeWidget ? "0px" : "50px" }}>
|
||||
<div
|
||||
style={{
|
||||
margin: p.homeWidget ? "0px" : "50px",
|
||||
flex: 1,
|
||||
maxWidth: "1300px",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
marginBottom: "20px",
|
||||
flex: 1,
|
||||
}}
|
||||
>
|
||||
<Typography variant={p.homeWidget ? "h6" : "h4"}>{p.label}</Typography>
|
||||
|
||||
@@ -106,7 +106,7 @@ export default function StatCard({
|
||||
<Box sx={{ width: "100%", height: 100 }}>
|
||||
{data && interval && (
|
||||
<SparkLineChart
|
||||
colors={[chartColor]}
|
||||
color={chartColor}
|
||||
data={data}
|
||||
area
|
||||
showHighlight
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { Tooltip } from "@mui/material";
|
||||
import date from "date-and-time";
|
||||
import { format } from "date-and-time";
|
||||
import { time } from "../utils/DateUtils";
|
||||
|
||||
export function formatDate(time: number): string {
|
||||
const t = new Date();
|
||||
t.setTime(1000 * time);
|
||||
return date.format(t, "DD/MM/YYYY HH:mm:ss");
|
||||
return format(t, "DD/MM/YYYY HH:mm:ss");
|
||||
}
|
||||
|
||||
export function timeDiff(a: number, b: number): string {
|
||||
@@ -21,7 +21,7 @@ export function timeDiff(a: number, b: number): string {
|
||||
diff = Math.floor(diff / 60);
|
||||
|
||||
if (diff === 1) return "1 minute";
|
||||
if (diff < 24) {
|
||||
if (diff < 60) {
|
||||
return `${diff} minutes`;
|
||||
}
|
||||
|
||||
@@ -51,13 +51,14 @@ export function timeDiff(a: number, b: number): string {
|
||||
return `${diffYears} years`;
|
||||
}
|
||||
|
||||
export function timeDiffFromNow(t: number): string {
|
||||
return timeDiff(t, time());
|
||||
export function timeDiffFromNow(t: number, future?: boolean): string {
|
||||
return future ? timeDiff(time(), t) : timeDiff(t, time());
|
||||
}
|
||||
|
||||
export function TimeWidget(p: {
|
||||
time?: number;
|
||||
diff?: boolean;
|
||||
future?: boolean;
|
||||
}): React.ReactElement {
|
||||
if (!p.time) return <></>;
|
||||
return (
|
||||
@@ -65,7 +66,9 @@ export function TimeWidget(p: {
|
||||
title={formatDate(p.diff ? new Date().getTime() / 1000 - p.time : p.time)}
|
||||
arrow
|
||||
>
|
||||
<span>{p.diff ? timeDiff(0, p.time) : timeDiffFromNow(p.time)}</span>
|
||||
<span>
|
||||
{p.diff ? timeDiff(0, p.time) : timeDiffFromNow(p.time, p.future)}
|
||||
</span>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||
"target": "ES2020",
|
||||
"useDefineForClassFields": true,
|
||||
@@ -11,7 +10,6 @@
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
@@ -21,7 +19,8 @@
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noUncheckedSideEffectImports": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
|
||||
@@ -1,11 +1,7 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{
|
||||
"path": "./tsconfig.app.json"
|
||||
},
|
||||
{
|
||||
"path": "./tsconfig.node.json"
|
||||
}
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./tsconfig.node.json" }
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1,13 +1,24 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
||||
"skipLibCheck": true,
|
||||
"target": "ES2022",
|
||||
"lib": ["ES2023"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"allowImportingTsExtensions": true,
|
||||
"isolatedModules": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
|
||||
/* Linting */
|
||||
"strict": true,
|
||||
"noEmit": true
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noUncheckedSideEffectImports": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
|
||||
// https://vitejs.dev/config/
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
})
|
||||
|
||||
2672
custom_consumption/Cargo.lock
generated
2672
custom_consumption/Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
@@ -1,12 +1,12 @@
|
||||
[package]
|
||||
name = "custom_consumption"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
env_logger = "0.11.5"
|
||||
log = "0.4.22"
|
||||
clap = { version = "4.5.18", features = ["derive", "env"] }
|
||||
egui = "0.28.1"
|
||||
eframe = "0.28.1"
|
||||
env_logger = "0.11.8"
|
||||
log = "0.4.28"
|
||||
clap = { version = "4.5.50", features = ["derive", "env"] }
|
||||
egui = "0.32.3"
|
||||
eframe = "0.32.3"
|
||||
lazy_static = "1.5.0"
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
/// Get the current time since epoch
|
||||
|
||||
pub fn time_millis() -> u128 {
|
||||
SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
|
||||
106
docs/SETUP_DEV.md
Normal file
106
docs/SETUP_DEV.md
Normal file
@@ -0,0 +1,106 @@
|
||||
# Setup development environment
|
||||
|
||||
## Backend
|
||||
To build the backend, you will need to install:
|
||||
|
||||
* Rust: https://www.rust-lang.org/
|
||||
* An IDE with Rust support. I would definitly recommend [RustRover](https://www.jetbrains.com/rust/) (the tool from IntelliJ).
|
||||
|
||||
Check if your environment is working using the following command:
|
||||
|
||||
```bash
|
||||
cargo fmt && cargo clippy && cargo run -- --help
|
||||
```
|
||||
|
||||
For development, the following flags might prove being useful:
|
||||
* `--unsecure-disable-login`: Disable authentication on all web API endpoints
|
||||
* `--hostname`: Change public server hostname, if you want to expose your test instance to network device, such as an ESP32
|
||||
|
||||
## Custom consumption
|
||||
Same requirements as for the backend. This tool spawns a gui that allows to set arbitrary consumption values:
|
||||
|
||||

|
||||
|
||||
|
||||
To use it, first launch this tool:
|
||||
|
||||
```bash
|
||||
cd custom_consumption
|
||||
cargo run
|
||||
```
|
||||
|
||||
Set a custom value to force file creation (in the UI).
|
||||
|
||||
Then launch central backend (in another terminal):
|
||||
|
||||
```bash
|
||||
cd central_backend
|
||||
cargo fmt && cargo clippy && RUST_LOG=debug cargo run -- file
|
||||
```
|
||||
|
||||
|
||||
## Central frontend
|
||||
The frontend has been built using [NodeJS](https://nodejs.org/en), [Vite](https://vite.dev/) and [MUI](https://mui.com/).
|
||||
|
||||
Launch it using this command:
|
||||
|
||||
```bash
|
||||
cd central_frontend
|
||||
npm run dev
|
||||
```
|
||||
|
||||
## Python device
|
||||
This component has been built using ... Python. Waw!
|
||||
|
||||
You will need to install this dependency, first:
|
||||
```bash
|
||||
apt install python3-jwt
|
||||
```
|
||||
|
||||
|
||||
Run the client:
|
||||
|
||||
```bash
|
||||
cd python_device
|
||||
python3 -m src.main
|
||||
|
||||
# Get CLI help
|
||||
python3 -m src.main --help
|
||||
```
|
||||
|
||||
|
||||
Reformat code:
|
||||
|
||||
```bash
|
||||
black src/*.py
|
||||
```
|
||||
|
||||
## ESP32 device
|
||||
The ESP32 device is in reality a [Wt32-Eth01](https://en.wireless-tag.com/product-item-2.html) device. Use the following mapping to setup dev env:
|
||||
|
||||

|
||||
|
||||
You can use a [CP2102](https://fr.aliexpress.com/item/4000120687489.html) to flash the ESP32.
|
||||
|
||||
I recommend to use VSCode with the following extensions:
|
||||
* [ESP-IDF](https://marketplace.visualstudio.com/items?itemName=espressif.esp-idf-extension)
|
||||
* [C/C++](https://marketplace.visualstudio.com/items?itemName=ms-vscode.cpptools)
|
||||
* [Prettier](https://marketplace.visualstudio.com/items?itemName=esbenp.prettier-vscode)
|
||||
|
||||
To build the project, use this command (in an ESP-IDF terminal):
|
||||
|
||||
```bash
|
||||
idf.py build
|
||||
```
|
||||
|
||||
To flash the ESP32, use this one:
|
||||
|
||||
```bash
|
||||
idf.py flash
|
||||
```
|
||||
|
||||
To capture logs from device, use either `cu` or the following command:
|
||||
|
||||
```
|
||||
idf.py monitor
|
||||
```
|
||||
191
docs/SETUP_PROD.md
Normal file
191
docs/SETUP_PROD.md
Normal file
@@ -0,0 +1,191 @@
|
||||
# Configure project for production
|
||||
|
||||
Note: This guide assumes that you use the default hostname, `central.internal` as hostname for your central system.
|
||||
|
||||
## Create production build
|
||||
|
||||
### Central
|
||||
The production release of central backend and frontend can be realised on a computer which has NodeJS and Rust installed by executing the following command at the root of the project:
|
||||
|
||||
```bash
|
||||
make
|
||||
```
|
||||
|
||||
The backend will be available at this location:
|
||||
|
||||
```
|
||||
central_backend/target/release/central_backend
|
||||
```
|
||||
|
||||
### Python device
|
||||
The Python device isn't production ready yet.
|
||||
|
||||
|
||||
### ESP32 device
|
||||
|
||||
#### Flashing the device directly
|
||||
Use the following commands to flash a device (inside ESP-IDF environnment):
|
||||
|
||||
```bash
|
||||
idf.py build
|
||||
idf.py flash
|
||||
```
|
||||
|
||||
|
||||
#### Getting an OTA update
|
||||
Use the following command to build an OTA update:
|
||||
|
||||
```bash
|
||||
idf.py build
|
||||
```
|
||||
|
||||
The OTA update is then located in `build/main.bin`
|
||||
|
||||
|
||||
## Pre-requisites
|
||||
* A server running a recent Linux (Debian / Ubuntu preferred) with `central` as hostname
|
||||
* DHCP configured on the network
|
||||
|
||||
## Configure DNS server
|
||||
|
||||
If you need to setup a DNS server / proxy to point `central.internal` to the central server IP, you can follow this guide.
|
||||
|
||||
### Retrieve DNS server binary
|
||||
Use [DNSProxy](https://gitlab.com/pierre42100/dnsproxy) as DNS server. Get and compile the sources:
|
||||
|
||||
```bash
|
||||
git clone https://gitlab.com/pierre42100/dnsproxy
|
||||
cd dnsproxy
|
||||
cargo build --release
|
||||
scp target/release/dns_proxy USER@CENTRAL_IP:/home/USER
|
||||
```
|
||||
|
||||
Then, on the target server, install the binary to its final destination:
|
||||
|
||||
```bash
|
||||
sudo mv dns_proxy /usr/local/bin/
|
||||
```
|
||||
|
||||
### Configure DNS server
|
||||
Configure the server as a service `/etc/systemd/system/dns.service`:
|
||||
|
||||
```conf
|
||||
[Unit]
|
||||
Description=DNS server
|
||||
After=syslog.target
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
RestartSec=2s
|
||||
Type=simple
|
||||
User=root
|
||||
Group=root
|
||||
WorkingDirectory=/tmp
|
||||
ExecStart=/usr/local/bin/dns_proxy -l "CENTRAL_IP:53" -c "central.internal. A CENTRAL_IP"
|
||||
Restart=always
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
```
|
||||
|
||||
Enable and start the new service:
|
||||
|
||||
```bash
|
||||
sudo systemctl enable dns
|
||||
sudo systemctl start dns
|
||||
```
|
||||
|
||||
Check that it works correctly:
|
||||
|
||||
```bash
|
||||
dig central.internal. @CENTRAL_IP
|
||||
```
|
||||
|
||||
You should get an entry like this if it works:
|
||||
|
||||
```
|
||||
;; ANSWER SECTION:
|
||||
central.internal. 0 IN A CENTRAL_IP
|
||||
```
|
||||
|
||||
Then, in your DHCP service, define the central as the DNS server.
|
||||
|
||||
## Configure server
|
||||
|
||||
### Create a user dedicated to the central
|
||||
```bash
|
||||
sudo adduser --disabled-login central
|
||||
```
|
||||
|
||||
### Install binary
|
||||
You can use `scp` to copy the binary to the target server:
|
||||
|
||||
```bash
|
||||
scp central_backend/target/release/central_backend pierre@central:/home/pierre
|
||||
```
|
||||
|
||||
Then the executable must be installed system-wide:
|
||||
|
||||
```bash
|
||||
sudo mv central_backend /usr/local/bin/
|
||||
```
|
||||
|
||||
### Create configuration file
|
||||
Create a configuration file in `/home/central/config.yaml`:
|
||||
|
||||
```bash
|
||||
sudo touch /home/central/config.yaml
|
||||
sudo chown central:central /home/central/config.yaml
|
||||
sudo chmod 400 /home/central/config.yaml
|
||||
sudo nano /home/central/config.yaml
|
||||
```
|
||||
|
||||
Sample configuration:
|
||||
|
||||
```conf
|
||||
SECRET=RANDOM_VALUE
|
||||
COOKIE_SECURE=true
|
||||
LISTEN_ADDRESS=0.0.0.0:443
|
||||
ADMIN_USERNAME=admin
|
||||
ADMIN_PASSWORD=FIXME
|
||||
HOSTNAME=central.internal
|
||||
STORAGE=/home/central/storage
|
||||
FRONIUS_ORIG=http://10.0.0.10
|
||||
```
|
||||
|
||||
### Test configuration
|
||||
Run the following command to check if the configuration is working:
|
||||
|
||||
```bash
|
||||
sudo -u central central_backend -c /home/central/config.yaml fronius -c
|
||||
```
|
||||
|
||||
### Create systemd unit file
|
||||
Once you confirmed the configuration is working, you can configure a system service, in `/etc/systemd/system/central.service`:
|
||||
|
||||
```conf
|
||||
[Unit]
|
||||
Description=Central backend server
|
||||
After=syslog.target
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
RestartSec=2s
|
||||
Type=simple
|
||||
User=central
|
||||
Group=central
|
||||
WorkingDirectory=/home/central
|
||||
ExecStart=/usr/local/bin/central_backend -c /home/central/config.yaml fronius -c
|
||||
Restart=always
|
||||
Environment=USER=central
|
||||
HOME=/home/central
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
```
|
||||
|
||||
Enable & start service:
|
||||
```bash
|
||||
sudo systemctl enable central
|
||||
sudo systemctl start central
|
||||
```
|
||||
BIN
docs/img/custom_consumption.png
Normal file
BIN
docs/img/custom_consumption.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 6.6 KiB |
BIN
docs/img/esp_mapping.png
Normal file
BIN
docs/img/esp_mapping.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 66 KiB |
4
esp32_device/.vscode/settings.json
vendored
4
esp32_device/.vscode/settings.json
vendored
@@ -53,6 +53,8 @@
|
||||
"sync_response.h": "c",
|
||||
"gpio.h": "c",
|
||||
"esp_system.h": "c",
|
||||
"relays.h": "c"
|
||||
"relays.h": "c",
|
||||
"esp_app_desc.h": "c",
|
||||
"ota.h": "c"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,23 @@
|
||||
# ESP32 device
|
||||
|
||||
ESP32 client device, using `W32-ETH01` device
|
||||
|
||||
## Pins for relays
|
||||
The pins are the following (in the order of definition): 4, 14, 15, 2
|
||||
|
||||
**WARNING!** The Pin 2 MUST be disconnect to reflash the card!
|
||||
|
||||
## Some commands
|
||||
|
||||
Create a new firmware build:
|
||||
|
||||
```bash
|
||||
idf.py build
|
||||
```
|
||||
|
||||
Upload firmware to central backend, in dev mode:
|
||||
|
||||
```bash
|
||||
curl -k -X POST https://localhost:8443/web_api/ota/Wt32-Eth01/$(cat version.txt) --form firmware="@build/main.bin"
|
||||
curl -k -X POST https://localhost:8443/web_api/ota/set_desired_version --header "Content-Type: application/json" --data "{\"platform\": \"Wt32-Eth01\", \"version\": \"$(cat version.txt)\"}"
|
||||
```
|
||||
5
esp32_device/build_upload_dev.sh
Executable file
5
esp32_device/build_upload_dev.sh
Executable file
@@ -0,0 +1,5 @@
|
||||
#!/bin/sh
|
||||
|
||||
idf.py build && \
|
||||
curl -k -X POST https://localhost:8443/web_api/ota/Wt32-Eth01/$(cat version.txt) --form firmware="@build/main.bin" && \
|
||||
curl -k -X POST https://localhost:8443/web_api/ota/set_desired_version --header "Content-Type: application/json" --data "{\"platform\": \"Wt32-Eth01\", \"version\": \"$(cat version.txt)\"}"
|
||||
@@ -1,4 +1,4 @@
|
||||
idf_component_register(SRCS "relays.c" "sync_response.c" "jwt.c" "secure_api.c" "http_client.c" "ethernet.c" "unsecure_api.c" "system.c" "crypto.c" "random.c" "storage.c" "main.c"
|
||||
idf_component_register(SRCS "ota.c" "relays.c" "sync_response.c" "jwt.c" "secure_api.c" "http_client.c" "ethernet.c" "unsecure_api.c" "system.c" "crypto.c" "random.c" "storage.c" "main.c"
|
||||
"dev_name.c"
|
||||
INCLUDE_DIRS ".")
|
||||
|
||||
|
||||
@@ -5,15 +5,10 @@
|
||||
*/
|
||||
#define DEV_REFERENCE "Wt32-Eth01"
|
||||
|
||||
/**
|
||||
* Device version
|
||||
*/
|
||||
#define DEV_VERSION "0.0.1"
|
||||
|
||||
/**
|
||||
* Backend unsecure API URL
|
||||
*/
|
||||
#define BACKEND_UNSECURE_URL "http://devweb.internal:8080"
|
||||
#define BACKEND_UNSECURE_URL "http://central.internal:8080"
|
||||
|
||||
/**
|
||||
* Device name len
|
||||
@@ -44,3 +39,8 @@
|
||||
* Interval of time (in seconds) between two synchronisations
|
||||
*/
|
||||
#define SYNC_TIME_INTERVAL 5
|
||||
|
||||
/**
|
||||
* OTA download timeout (in milliseconds)
|
||||
*/
|
||||
#define OTA_REC_TIMEOUT 15000
|
||||
@@ -29,12 +29,12 @@ static void seed_ctr_drbg_context(mbedtls_entropy_context *entropy, mbedtls_ctr_
|
||||
mbedtls_entropy_init(entropy);
|
||||
mbedtls_ctr_drbg_init(ctr_drbg);
|
||||
|
||||
ESP_LOGI(TAG, "Seed Mbedtls\n");
|
||||
ESP_LOGI(TAG, "Seed Mbedtls");
|
||||
if ((ret = mbedtls_ctr_drbg_seed(ctr_drbg, mbedtls_entropy_func, entropy,
|
||||
(const unsigned char *)pers,
|
||||
strlen(pers))) != 0)
|
||||
{
|
||||
ESP_LOGE(TAG, " failed\n ! mbedtls_ctr_drbg_seed returned %d\n", ret);
|
||||
ESP_LOGE(TAG, " failed\n ! mbedtls_ctr_drbg_seed returned %d", ret);
|
||||
reboot();
|
||||
}
|
||||
}
|
||||
@@ -54,7 +54,7 @@ bool crypto_gen_priv_key()
|
||||
mbedtls_ctr_drbg_context ctr_drbg;
|
||||
seed_ctr_drbg_context(&entropy, &ctr_drbg);
|
||||
|
||||
ESP_LOGI(TAG, "PK info from type\n");
|
||||
ESP_LOGI(TAG, "PK info from type");
|
||||
if ((ret = mbedtls_pk_setup(&key, mbedtls_pk_info_from_type(MBEDTLS_PK_ECKEY))) != 0)
|
||||
{
|
||||
ESP_LOGE(TAG, " failed\n ! mbedtls_pk_setup returned -0x%04x", (unsigned int)-ret);
|
||||
@@ -62,7 +62,7 @@ bool crypto_gen_priv_key()
|
||||
}
|
||||
|
||||
// Generate private key
|
||||
ESP_LOGI(TAG, "Generate private key\n");
|
||||
ESP_LOGI(TAG, "Generate private key");
|
||||
ret = mbedtls_ecp_gen_key(ECPARAMS,
|
||||
mbedtls_pk_ec(key),
|
||||
mbedtls_ctr_drbg_random, &ctr_drbg);
|
||||
@@ -74,7 +74,7 @@ bool crypto_gen_priv_key()
|
||||
}
|
||||
|
||||
// Export private key
|
||||
ESP_LOGI(TAG, "Export private key\n");
|
||||
ESP_LOGI(TAG, "Export private key");
|
||||
unsigned char *key_buff = malloc(PRV_KEY_DER_MAX_BYTES);
|
||||
if ((ret = mbedtls_pk_write_key_der(&key, key_buff, PRV_KEY_DER_MAX_BYTES)) < 1)
|
||||
{
|
||||
@@ -108,7 +108,7 @@ void crypto_print_priv_key()
|
||||
mbedtls_ctr_drbg_context ctr_drbg;
|
||||
seed_ctr_drbg_context(&entropy, &ctr_drbg);
|
||||
|
||||
ESP_LOGI(TAG, "Parse private key (len = %d)\n", key_len);
|
||||
ESP_LOGI(TAG, "Parse private key (len = %d)", key_len);
|
||||
if ((ret = mbedtls_pk_parse_key(&key, key_buff, key_len, NULL, 0, mbedtls_ctr_drbg_random, &ctr_drbg)) != 0)
|
||||
{
|
||||
ESP_LOGE(TAG, " failed\n ! mbedtls_pk_parse_key returned -0x%04x",
|
||||
@@ -117,7 +117,7 @@ void crypto_print_priv_key()
|
||||
}
|
||||
free(key_buff);
|
||||
|
||||
ESP_LOGI(TAG, "Show private key\n");
|
||||
ESP_LOGI(TAG, "Show private key");
|
||||
unsigned char *out = malloc(16000);
|
||||
memset(out, 0, 16000);
|
||||
if ((ret = mbedtls_pk_write_key_pem(&key, out, 16000)) != 0)
|
||||
@@ -153,7 +153,7 @@ static bool crypto_get_priv_key_mpi(mbedtls_mpi *dst)
|
||||
mbedtls_ctr_drbg_context ctr_drbg;
|
||||
seed_ctr_drbg_context(&entropy, &ctr_drbg);
|
||||
|
||||
ESP_LOGI(TAG, "Parse private key (len = %d)\n", key_len);
|
||||
ESP_LOGI(TAG, "Parse private key (len = %d)", key_len);
|
||||
if ((ret = mbedtls_pk_parse_key(&key, key_buff, key_len, NULL, 0, mbedtls_ctr_drbg_random, &ctr_drbg)) != 0)
|
||||
{
|
||||
ESP_LOGE(TAG, " failed\n ! mbedtls_pk_parse_key returned -0x%04x",
|
||||
@@ -190,7 +190,7 @@ char *crypto_get_csr()
|
||||
mbedtls_ctr_drbg_context ctr_drbg;
|
||||
seed_ctr_drbg_context(&entropy, &ctr_drbg);
|
||||
|
||||
ESP_LOGI(TAG, "Parse private key (len = %d)\n", key_len);
|
||||
ESP_LOGI(TAG, "Parse private key (len = %d)", key_len);
|
||||
if ((ret = mbedtls_pk_parse_key(&key, key_buff, key_len, NULL, 0, mbedtls_ctr_drbg_random, &ctr_drbg)) != 0)
|
||||
{
|
||||
ESP_LOGE(TAG, " failed\n ! mbedtls_pk_parse_key returned -0x%04x",
|
||||
@@ -214,7 +214,7 @@ char *crypto_get_csr()
|
||||
reboot();
|
||||
}
|
||||
|
||||
ESP_LOGI(TAG, "Sign CSR with private key\n");
|
||||
ESP_LOGI(TAG, "Sign CSR with private key");
|
||||
mbedtls_x509write_csr_set_key(&req, &key);
|
||||
|
||||
char *csr = malloc(4096);
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user