Compare commits
110 Commits
79b2ad12d8
...
1.0.1
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 | |||
| 5608b4e610 | |||
| b4220a3254 | |||
| 4d07d83904 | |||
| 274b7089d1 | |||
| e574bed96f | |||
| ce8427b0aa | |||
| 58abf4ec9b | |||
| 5704f2b57f | |||
| 51fc7cc710 | |||
| b800b90337 | |||
| b5acda292d | |||
| 77c3702986 | |||
| 7f9db9f2cc | |||
| cb798dfd14 | |||
| 3f41269c0b | |||
| 7895b9eca8 | |||
| 903f1fa8ce | |||
| 2e72634abf | |||
| 821b4644a2 | |||
| e0f0067e89 | |||
| 3c2fa18d9a | |||
| 78ace02d15 | |||
| d0a80c7960 | |||
| 228e1a7293 | |||
| 0e0da14fde | |||
| d98be00a40 | |||
| fa431857ee | |||
| c55c55d56d | |||
| cf31de5b67 | |||
| 561c49226b | |||
| 5db7593a4f | |||
| 8316746377 | |||
| 419d395241 | |||
| f0bac090e4 | |||
| ae366a84a2 | |||
| 923f726bb4 | |||
| b7635935ba | |||
| 719b0a0c5c | |||
| fe0bc03c03 | |||
| 09c25a67c5 | |||
| 92878e6548 | |||
| 565db05fb0 | |||
| 368eb13089 | |||
| 20bc71851d |
70
.drone.yml
Normal file
70
.drone.yml
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
---
|
||||||
|
kind: pipeline
|
||||||
|
type: docker
|
||||||
|
name: default
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: web_build
|
||||||
|
image: node:21
|
||||||
|
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
|
||||||
|
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
|
||||||
|
|
||||||
|
- name: esp32_compile
|
||||||
|
image: espressif/idf:v5.3.1
|
||||||
|
commands:
|
||||||
|
- cd esp32_device
|
||||||
|
- /opt/esp/entrypoint.sh idf.py build
|
||||||
|
- ls -lah build/main.bin
|
||||||
|
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
- name: rust_registry
|
||||||
|
temp: {}
|
||||||
|
- name: web_app
|
||||||
|
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
|
# 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
|
||||||
753
central_backend/Cargo.lock
generated
753
central_backend/Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
@@ -7,32 +7,38 @@ edition = "2021"
|
|||||||
log = "0.4.22"
|
log = "0.4.22"
|
||||||
env_logger = "0.11.5"
|
env_logger = "0.11.5"
|
||||||
lazy_static = "1.5.0"
|
lazy_static = "1.5.0"
|
||||||
clap = { version = "4.5.15", features = ["derive", "env"] }
|
dotenvy = "0.15.7"
|
||||||
anyhow = "1.0.86"
|
clap = { version = "4.5.20", features = ["derive", "env"] }
|
||||||
thiserror = "1.0.63"
|
anyhow = "1.0.89"
|
||||||
|
thiserror = "1.0.64"
|
||||||
openssl = { version = "0.10.66" }
|
openssl = { version = "0.10.66" }
|
||||||
openssl-sys = "0.9.102"
|
openssl-sys = "0.9.102"
|
||||||
libc = "0.2.155"
|
libc = "0.2.159"
|
||||||
foreign-types-shared = "0.1.1"
|
foreign-types-shared = "0.1.1"
|
||||||
asn1 = "0.17"
|
asn1 = "0.17"
|
||||||
actix-web = { version = "4", features = ["openssl"] }
|
actix-web = { version = "4", features = ["openssl"] }
|
||||||
futures = "0.3.30"
|
futures = "0.3.31"
|
||||||
serde = { version = "1.0.206", features = ["derive"] }
|
serde = { version = "1.0.210", features = ["derive"] }
|
||||||
reqwest = "0.12.5"
|
reqwest = { version = "0.12.7", features = ["json"] }
|
||||||
serde_json = "1.0.123"
|
serde_json = "1.0.128"
|
||||||
rand = "0.8.5"
|
rand = "0.8.5"
|
||||||
actix = "0.13.5"
|
actix = "0.13.5"
|
||||||
actix-identity = "0.7.1"
|
actix-identity = "0.8.0"
|
||||||
actix-session = { version = "0.9.0", features = ["cookie-session"] }
|
actix-session = { version = "0.10.1", features = ["cookie-session"] }
|
||||||
actix-cors = "0.7.0"
|
actix-cors = "0.7.0"
|
||||||
|
actix-multipart = { version ="0.7.2", features = ["derive"] }
|
||||||
actix-remote-ip = "0.1.0"
|
actix-remote-ip = "0.1.0"
|
||||||
futures-util = "0.3.30"
|
futures-util = "0.3.31"
|
||||||
uuid = { version = "1.10.0", features = ["v4", "serde"] }
|
uuid = { version = "1.10.0", features = ["v4", "serde"] }
|
||||||
semver = { version = "1.0.23", features = ["serde"] }
|
semver = { version = "1.0.23", features = ["serde"] }
|
||||||
lazy-regex = "3.2.0"
|
lazy-regex = "3.3.0"
|
||||||
tokio = { version = "1.39.2", features = ["full"] }
|
tokio = { version = "1.40.0", features = ["full"] }
|
||||||
tokio_schedule = "0.3.2"
|
tokio_schedule = "0.3.2"
|
||||||
mime_guess = "2.0.5"
|
mime_guess = "2.0.5"
|
||||||
rust-embed = "8.5.0"
|
rust-embed = "8.5.0"
|
||||||
jsonwebtoken = { version = "9.3.0", features = ["use_pem"] }
|
jsonwebtoken = { version = "9.3.0", features = ["use_pem"] }
|
||||||
prettytable-rs = "0.10.0"
|
prettytable-rs = "0.10.0"
|
||||||
|
chrono = "0.4.38"
|
||||||
|
serde_yml = "0.0.12"
|
||||||
|
bincode = "=2.0.0-rc.3"
|
||||||
|
fs4 = { version = "0.10.0", features = ["sync"] }
|
||||||
|
|||||||
44
central_backend/engine_test/test_blocking_on_dependency.yaml
Normal file
44
central_backend/engine_test/test_blocking_on_dependency.yaml
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
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: 1000
|
||||||
|
depends_on: []
|
||||||
|
conflicts_with: []
|
||||||
|
|
||||||
|
on: true
|
||||||
|
for: 50000
|
||||||
|
should_be_on: true
|
||||||
|
|
||||||
|
- id: 88888888-bf9b-4de3-99e5-92c1c7dd72e8
|
||||||
|
name: R2
|
||||||
|
enabled: true
|
||||||
|
priority: 1
|
||||||
|
consumption: 100
|
||||||
|
minimal_uptime: 10
|
||||||
|
minimal_downtime: 1000
|
||||||
|
depends_on: [ dcb3fd91-bf9b-4de3-99e5-92c1c7dd72e9 ]
|
||||||
|
conflicts_with: [ ]
|
||||||
|
|
||||||
|
on: true
|
||||||
|
for: 5
|
||||||
|
should_be_on: true
|
||||||
|
|
||||||
|
online: true
|
||||||
|
|
||||||
|
curr_consumption: 100000
|
||||||
44
central_backend/engine_test/test_conflict.yaml
Normal file
44
central_backend/engine_test/test_conflict.yaml
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
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: true
|
||||||
|
for: 5
|
||||||
|
should_be_on: true
|
||||||
|
|
||||||
|
- id: dcb3fd91-bf9b-4de3-99e5-92c1c7dd72e8
|
||||||
|
name: R2
|
||||||
|
enabled: true
|
||||||
|
priority: 1
|
||||||
|
consumption: 100
|
||||||
|
minimal_uptime: 10
|
||||||
|
minimal_downtime: 10
|
||||||
|
depends_on: [ ]
|
||||||
|
conflicts_with: [ dcb3fd91-bf9b-4de3-99e5-92c1c7dd72e9 ]
|
||||||
|
|
||||||
|
on: false
|
||||||
|
for: 500
|
||||||
|
should_be_on: false
|
||||||
|
|
||||||
|
online: true
|
||||||
|
|
||||||
|
curr_consumption: -10000
|
||||||
44
central_backend/engine_test/test_missing_dependency.yaml
Normal file
44
central_backend/engine_test/test_missing_dependency.yaml
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
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: 1000
|
||||||
|
depends_on: []
|
||||||
|
conflicts_with: []
|
||||||
|
|
||||||
|
on: false
|
||||||
|
for: 5
|
||||||
|
should_be_on: false
|
||||||
|
|
||||||
|
- id: 88888888-bf9b-4de3-99e5-92c1c7dd72e8
|
||||||
|
name: R1
|
||||||
|
enabled: true
|
||||||
|
priority: 1
|
||||||
|
consumption: 100
|
||||||
|
minimal_uptime: 10
|
||||||
|
minimal_downtime: 1000
|
||||||
|
depends_on: [ dcb3fd91-bf9b-4de3-99e5-92c1c7dd72e9 ]
|
||||||
|
conflicts_with: [ ]
|
||||||
|
|
||||||
|
on: false
|
||||||
|
for: 5000
|
||||||
|
should_be_on: false
|
||||||
|
|
||||||
|
online: true
|
||||||
|
|
||||||
|
curr_consumption: -100000
|
||||||
30
central_backend/engine_test/test_stay_off_min_downtime.yaml
Normal file
30
central_backend/engine_test/test_stay_off_min_downtime.yaml
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
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: 1000
|
||||||
|
depends_on: []
|
||||||
|
conflicts_with: []
|
||||||
|
|
||||||
|
on: false
|
||||||
|
for: 500
|
||||||
|
should_be_on: false
|
||||||
|
|
||||||
|
online: true
|
||||||
|
|
||||||
|
curr_consumption: -1000
|
||||||
30
central_backend/engine_test/test_stay_on_min_uptime.yaml
Normal file
30
central_backend/engine_test/test_stay_on_min_uptime.yaml
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
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: 1000
|
||||||
|
minimal_uptime: 10
|
||||||
|
minimal_downtime: 1000
|
||||||
|
depends_on: []
|
||||||
|
conflicts_with: []
|
||||||
|
|
||||||
|
on: true
|
||||||
|
for: 5
|
||||||
|
should_be_on: true
|
||||||
|
|
||||||
|
online: true
|
||||||
|
|
||||||
|
curr_consumption: 10000
|
||||||
30
central_backend/engine_test/test_turn_on_consumption.yaml
Normal file
30
central_backend/engine_test/test_turn_on_consumption.yaml
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
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
|
||||||
|
should_be_on: true
|
||||||
|
|
||||||
|
online: true
|
||||||
|
|
||||||
|
curr_consumption: -1000
|
||||||
44
central_backend/engine_test/test_turn_on_priority.yaml
Normal file
44
central_backend/engine_test/test_turn_on_priority.yaml
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
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: 900
|
||||||
|
minimal_uptime: 10
|
||||||
|
minimal_downtime: 10
|
||||||
|
depends_on: []
|
||||||
|
conflicts_with: []
|
||||||
|
|
||||||
|
on: true
|
||||||
|
for: 500
|
||||||
|
should_be_on: false
|
||||||
|
|
||||||
|
- id: 88888888-bf9b-4de3-99e5-92c1c7dd72e9
|
||||||
|
name: R2
|
||||||
|
enabled: true
|
||||||
|
priority: 2
|
||||||
|
consumption: 1000
|
||||||
|
minimal_uptime: 10
|
||||||
|
minimal_downtime: 10
|
||||||
|
depends_on: [ ]
|
||||||
|
conflicts_with: [ ]
|
||||||
|
|
||||||
|
on: false
|
||||||
|
for: 500
|
||||||
|
should_be_on: true
|
||||||
|
|
||||||
|
online: true
|
||||||
|
|
||||||
|
curr_consumption: -900
|
||||||
@@ -1,7 +1,14 @@
|
|||||||
use crate::devices::device::DeviceId;
|
use crate::devices::device::{DeviceId, DeviceRelayID};
|
||||||
|
use crate::ota::ota_update::OTAPlatform;
|
||||||
use clap::{Parser, Subcommand};
|
use clap::{Parser, Subcommand};
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
|
#[derive(Copy, Clone, Debug)]
|
||||||
|
pub enum ConsumptionHistoryType {
|
||||||
|
GridConsumption,
|
||||||
|
RelayConsumption,
|
||||||
|
}
|
||||||
|
|
||||||
/// Electrical consumption fetcher backend
|
/// Electrical consumption fetcher backend
|
||||||
#[derive(Subcommand, Debug, Clone)]
|
#[derive(Subcommand, Debug, Clone)]
|
||||||
pub enum ConsumptionBackend {
|
pub enum ConsumptionBackend {
|
||||||
@@ -28,12 +35,27 @@ pub enum ConsumptionBackend {
|
|||||||
#[clap(short, long, default_value = "/dev/shm/consumption.txt")]
|
#[clap(short, long, default_value = "/dev/shm/consumption.txt")]
|
||||||
path: String,
|
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
|
/// Solar system central backend
|
||||||
#[derive(Parser, Debug)]
|
#[derive(Parser, Debug)]
|
||||||
#[command(version, about, long_about = None)]
|
#[command(version, about, long_about = None)]
|
||||||
pub struct AppConfig {
|
pub struct AppConfig {
|
||||||
|
/// Read arguments from env file
|
||||||
|
#[clap(short, long, env)]
|
||||||
|
pub config: Option<String>,
|
||||||
|
|
||||||
/// Proxy IP, might end with a star "*"
|
/// Proxy IP, might end with a star "*"
|
||||||
#[clap(short, long, env)]
|
#[clap(short, long, env)]
|
||||||
pub proxy_ip: Option<String>,
|
pub proxy_ip: Option<String>,
|
||||||
@@ -76,6 +98,18 @@ pub struct AppConfig {
|
|||||||
#[arg(short, long, env, default_value = "storage")]
|
#[arg(short, long, env, default_value = "storage")]
|
||||||
storage: String,
|
storage: String,
|
||||||
|
|
||||||
|
/// The minimal production that must be excluded when selecting relays to turn on
|
||||||
|
#[arg(short('m'), long, env, default_value_t = -500)]
|
||||||
|
pub production_margin: i32,
|
||||||
|
|
||||||
|
/// Energy refresh operations interval, in seconds
|
||||||
|
#[arg(short('i'), long, env, default_value_t = 25)]
|
||||||
|
pub refresh_interval: u64,
|
||||||
|
|
||||||
|
/// Energy refresh operations interval, in seconds
|
||||||
|
#[arg(short('f'), long, env, default_value_t = 5)]
|
||||||
|
pub energy_fetch_interval: u64,
|
||||||
|
|
||||||
/// Consumption backend provider
|
/// Consumption backend provider
|
||||||
#[clap(subcommand)]
|
#[clap(subcommand)]
|
||||||
pub consumption_backend: Option<ConsumptionBackend>,
|
pub consumption_backend: Option<ConsumptionBackend>,
|
||||||
@@ -88,6 +122,21 @@ lazy_static::lazy_static! {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl AppConfig {
|
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
|
/// Get parsed command line arguments
|
||||||
pub fn get() -> &'static AppConfig {
|
pub fn get() -> &'static AppConfig {
|
||||||
&ARGS
|
&ARGS
|
||||||
@@ -227,6 +276,69 @@ impl AppConfig {
|
|||||||
pub fn device_csr_path(&self, id: &DeviceId) -> PathBuf {
|
pub fn device_csr_path(&self, id: &DeviceId) -> PathBuf {
|
||||||
self.devices_config_path().join(format!("{}.csr", id.0))
|
self.devices_config_path().join(format!("{}.csr", id.0))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Get relays runtime storage path
|
||||||
|
pub fn relays_runtime_stats_storage_path(&self) -> PathBuf {
|
||||||
|
self.storage_path().join("relays_runtime")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get relay runtime stats path for a given relay
|
||||||
|
pub fn relay_runtime_stats_dir(&self, relay_id: DeviceRelayID) -> PathBuf {
|
||||||
|
self.relays_runtime_stats_storage_path()
|
||||||
|
.join(relay_id.0.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get relay runtime stats path for a given relay for a given day
|
||||||
|
pub fn relay_runtime_day_file_path(&self, relay_id: DeviceRelayID, day: u64) -> PathBuf {
|
||||||
|
self.relay_runtime_stats_dir(relay_id).join(day.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get energy consumption history path
|
||||||
|
pub fn energy_consumption_history(&self) -> PathBuf {
|
||||||
|
self.storage_path().join("consumption_history")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get energy consumption history file path for a given day
|
||||||
|
pub fn energy_consumption_history_day(
|
||||||
|
&self,
|
||||||
|
number: u64,
|
||||||
|
r#type: ConsumptionHistoryType,
|
||||||
|
) -> PathBuf {
|
||||||
|
self.storage_path()
|
||||||
|
.join("consumption_history")
|
||||||
|
.join(format!(
|
||||||
|
"{number}-{}",
|
||||||
|
match r#type {
|
||||||
|
ConsumptionHistoryType::GridConsumption => "grid",
|
||||||
|
ConsumptionHistoryType::RelayConsumption => "relay-consumption",
|
||||||
|
}
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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)]
|
#[cfg(test)]
|
||||||
|
|||||||
@@ -1,11 +1,6 @@
|
|||||||
use std::time::Duration;
|
|
||||||
|
|
||||||
/// Name of the cookie that contains session information
|
/// Name of the cookie that contains session information
|
||||||
pub const SESSION_COOKIE_NAME: &str = "X-session-cookie";
|
pub const SESSION_COOKIE_NAME: &str = "X-session-cookie";
|
||||||
|
|
||||||
/// Energy refresh operations interval
|
|
||||||
pub const ENERGY_REFRESH_INTERVAL: Duration = Duration::from_secs(30);
|
|
||||||
|
|
||||||
/// Maximum time after a ping during which a device is considered "up"
|
/// Maximum time after a ping during which a device is considered "up"
|
||||||
pub const DEVICE_MAX_PING_TIME: u64 = 30;
|
pub const DEVICE_MAX_PING_TIME: u64 = 30;
|
||||||
|
|
||||||
@@ -18,6 +13,9 @@ pub const MAX_INACTIVITY_DURATION: u64 = 3600;
|
|||||||
/// Maximum session duration (1 day)
|
/// Maximum session duration (1 day)
|
||||||
pub const MAX_SESSION_DURATION: u64 = 3600 * 24;
|
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
|
/// List of routes that do not require authentication
|
||||||
pub const ROUTES_WITHOUT_AUTH: [&str; 2] =
|
pub const ROUTES_WITHOUT_AUTH: [&str; 2] =
|
||||||
["/web_api/server/config", "/web_api/auth/password_auth"];
|
["/web_api/server/config", "/web_api/auth/password_auth"];
|
||||||
|
|||||||
@@ -9,9 +9,9 @@ use std::collections::{HashMap, HashSet};
|
|||||||
#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
|
#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
|
||||||
pub struct DeviceInfo {
|
pub struct DeviceInfo {
|
||||||
/// Device reference
|
/// Device reference
|
||||||
reference: String,
|
pub reference: String,
|
||||||
/// Device firmware / software version
|
/// Device firmware / software version
|
||||||
version: semver::Version,
|
pub version: semver::Version,
|
||||||
/// Maximum number of relay that the device can support
|
/// Maximum number of relay that the device can support
|
||||||
pub max_relays: usize,
|
pub max_relays: usize,
|
||||||
}
|
}
|
||||||
@@ -62,6 +62,9 @@ pub struct Device {
|
|||||||
///
|
///
|
||||||
/// There cannot be more than [info.max_relays] relays
|
/// There cannot be more than [info.max_relays] relays
|
||||||
pub relays: Vec<DeviceRelay>,
|
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
|
/// Structure that contains information about the minimal expected execution
|
||||||
@@ -73,11 +76,11 @@ pub struct DailyMinRuntime {
|
|||||||
/// The seconds in the days (from 00:00) where the counter is reset
|
/// The seconds in the days (from 00:00) where the counter is reset
|
||||||
pub reset_time: usize,
|
pub reset_time: usize,
|
||||||
/// The hours during which the relay should be turned on to reach expected runtime
|
/// The hours during which the relay should be turned on to reach expected runtime
|
||||||
pub catch_up_hours: Vec<usize>,
|
pub catch_up_hours: Vec<u32>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Copy, Debug, serde::Serialize, serde::Deserialize, Eq, PartialEq, Hash)]
|
#[derive(Clone, Copy, Debug, serde::Serialize, serde::Deserialize, Eq, PartialEq, Hash)]
|
||||||
pub struct DeviceRelayID(uuid::Uuid);
|
pub struct DeviceRelayID(pub uuid::Uuid);
|
||||||
|
|
||||||
impl Default for DeviceRelayID {
|
impl Default for DeviceRelayID {
|
||||||
fn default() -> Self {
|
fn default() -> Self {
|
||||||
|
|||||||
@@ -84,6 +84,7 @@ impl DevicesList {
|
|||||||
validated: false,
|
validated: false,
|
||||||
enabled: false,
|
enabled: false,
|
||||||
relays: vec![],
|
relays: vec![],
|
||||||
|
desired_version: None,
|
||||||
};
|
};
|
||||||
|
|
||||||
// First, write CSR
|
// First, write CSR
|
||||||
@@ -115,6 +116,11 @@ impl DevicesList {
|
|||||||
self.0.clone().into_values().collect()
|
self.0.clone().into_values().collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Get a reference on the full list of devices
|
||||||
|
pub fn full_list_ref(&self) -> Vec<&Device> {
|
||||||
|
self.0.values().collect()
|
||||||
|
}
|
||||||
|
|
||||||
/// Get the information about a single device
|
/// Get the information about a single device
|
||||||
pub fn get_single(&self, id: &DeviceId) -> Option<Device> {
|
pub fn get_single(&self, id: &DeviceId) -> Option<Device> {
|
||||||
self.0.get(id).cloned()
|
self.0.get(id).cloned()
|
||||||
@@ -181,6 +187,24 @@ impl DevicesList {
|
|||||||
Ok(())
|
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
|
/// Get single certificate information
|
||||||
fn get_cert(&self, id: &DeviceId) -> anyhow::Result<X509> {
|
fn get_cert(&self, id: &DeviceId) -> anyhow::Result<X509> {
|
||||||
let dev = self
|
let dev = self
|
||||||
@@ -315,6 +339,12 @@ impl DevicesList {
|
|||||||
.into());
|
.into());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Delete relay energy information
|
||||||
|
let stats_dir = AppConfig::get().relay_runtime_stats_dir(relay_id);
|
||||||
|
if stats_dir.is_dir() {
|
||||||
|
std::fs::remove_dir_all(stats_dir)?;
|
||||||
|
}
|
||||||
|
|
||||||
// Delete the relay
|
// Delete the relay
|
||||||
let device = self
|
let device = self
|
||||||
.relay_get_device(relay_id)
|
.relay_get_device(relay_id)
|
||||||
|
|||||||
@@ -9,10 +9,36 @@ pub enum ConsumptionError {
|
|||||||
NonExistentFile,
|
NonExistentFile,
|
||||||
#[error("The file that should contain the consumption has an invalid content!")]
|
#[error("The file that should contain the consumption has an invalid content!")]
|
||||||
FileInvalidContent(#[source] ParseIntError),
|
FileInvalidContent(#[source] ParseIntError),
|
||||||
|
#[error("Failed to execute cURL request!")]
|
||||||
|
CurlReqFailed,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub type EnergyConsumption = i32;
|
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
|
/// Get current electrical energy consumption
|
||||||
pub async fn get_curr_consumption() -> anyhow::Result<EnergyConsumption> {
|
pub async fn get_curr_consumption() -> anyhow::Result<EnergyConsumption> {
|
||||||
let backend = AppConfig::get()
|
let backend = AppConfig::get()
|
||||||
@@ -38,5 +64,24 @@ pub async fn get_curr_consumption() -> anyhow::Result<EnergyConsumption> {
|
|||||||
.parse()
|
.parse()
|
||||||
.map_err(ConsumptionError::FileInvalidContent)?)
|
.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(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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
79
central_backend/src/energy/consumption_cache.rs
Normal file
79
central_backend/src/energy/consumption_cache.rs
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
use crate::constants;
|
||||||
|
use crate::energy::consumption::EnergyConsumption;
|
||||||
|
use crate::utils::math_utils::median;
|
||||||
|
|
||||||
|
pub struct ConsumptionCache {
|
||||||
|
nb_vals: usize,
|
||||||
|
values: Vec<EnergyConsumption>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ConsumptionCache {
|
||||||
|
pub fn new(nb_vals: usize) -> Self {
|
||||||
|
Self {
|
||||||
|
nb_vals,
|
||||||
|
values: vec![],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn add_value(&mut self, value: EnergyConsumption) {
|
||||||
|
if self.values.len() >= self.nb_vals {
|
||||||
|
self.values.remove(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
self.values.push(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn median_value(&self) -> EnergyConsumption {
|
||||||
|
if self.values.is_empty() {
|
||||||
|
return constants::FALLBACK_PRODUCTION_VALUE;
|
||||||
|
}
|
||||||
|
|
||||||
|
median(&self.values)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
pub mod test {
|
||||||
|
use crate::constants;
|
||||||
|
use crate::energy::consumption_cache::ConsumptionCache;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn empty_vec() {
|
||||||
|
let cache = ConsumptionCache::new(10);
|
||||||
|
assert_eq!(cache.median_value(), constants::FALLBACK_PRODUCTION_VALUE);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn single_value() {
|
||||||
|
let mut cache = ConsumptionCache::new(10);
|
||||||
|
cache.add_value(-10);
|
||||||
|
assert_eq!(cache.median_value(), -10);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn four_values() {
|
||||||
|
let mut cache = ConsumptionCache::new(10);
|
||||||
|
cache.add_value(50);
|
||||||
|
cache.add_value(-10);
|
||||||
|
cache.add_value(-10);
|
||||||
|
cache.add_value(-10000);
|
||||||
|
assert_eq!(cache.median_value(), -10);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn many_values() {
|
||||||
|
let mut cache = ConsumptionCache::new(6);
|
||||||
|
|
||||||
|
for i in 0..1000 {
|
||||||
|
cache.add_value(-i);
|
||||||
|
}
|
||||||
|
|
||||||
|
cache.add_value(10);
|
||||||
|
cache.add_value(50);
|
||||||
|
cache.add_value(-10);
|
||||||
|
cache.add_value(-10);
|
||||||
|
cache.add_value(-30);
|
||||||
|
cache.add_value(-10000);
|
||||||
|
assert_eq!(cache.median_value(), -10);
|
||||||
|
}
|
||||||
|
}
|
||||||
164
central_backend/src/energy/consumption_history_file.rs
Normal file
164
central_backend/src/energy/consumption_history_file.rs
Normal file
@@ -0,0 +1,164 @@
|
|||||||
|
use crate::app_config::{AppConfig, ConsumptionHistoryType};
|
||||||
|
use crate::energy::consumption::EnergyConsumption;
|
||||||
|
use crate::utils::math_utils::median;
|
||||||
|
use crate::utils::time_utils::day_number;
|
||||||
|
|
||||||
|
const TIME_INTERVAL: usize = 10;
|
||||||
|
|
||||||
|
#[derive(thiserror::Error, Debug)]
|
||||||
|
pub enum ConsumptionHistoryError {
|
||||||
|
#[error("Given time is out of file bounds!")]
|
||||||
|
TimeOutOfFileBound,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// # ConsumptionHistoryFile
|
||||||
|
///
|
||||||
|
/// Stores the history of house consumption
|
||||||
|
pub struct ConsumptionHistoryFile {
|
||||||
|
day: u64,
|
||||||
|
buff: Vec<EnergyConsumption>,
|
||||||
|
r#type: ConsumptionHistoryType,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ConsumptionHistoryFile {
|
||||||
|
/// Open consumption history file, if it exists, or create an empty one
|
||||||
|
pub fn open(time: u64, r#type: ConsumptionHistoryType) -> anyhow::Result<Self> {
|
||||||
|
let day = day_number(time);
|
||||||
|
let path = AppConfig::get().energy_consumption_history_day(day, r#type);
|
||||||
|
|
||||||
|
if path.exists() {
|
||||||
|
Ok(Self {
|
||||||
|
day,
|
||||||
|
buff: bincode::decode_from_slice(
|
||||||
|
&std::fs::read(path)?,
|
||||||
|
bincode::config::standard(),
|
||||||
|
)?
|
||||||
|
.0,
|
||||||
|
r#type,
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
log::debug!(
|
||||||
|
"Energy consumption stats for day {day} does not exists yet, creating memory buffer"
|
||||||
|
);
|
||||||
|
Ok(Self::new_memory(day, r#type))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create a new in memory consumption history
|
||||||
|
fn new_memory(day: u64, r#type: ConsumptionHistoryType) -> Self {
|
||||||
|
Self {
|
||||||
|
day,
|
||||||
|
buff: vec![0; (3600 * 24 / TIME_INTERVAL) + 1],
|
||||||
|
r#type,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resolve time offset of a given time in buffer
|
||||||
|
fn resolve_offset(&self, time: u64) -> anyhow::Result<usize> {
|
||||||
|
let start_of_day = self.day * 3600 * 24;
|
||||||
|
|
||||||
|
if time < start_of_day || time >= start_of_day + 3600 * 24 {
|
||||||
|
return Err(ConsumptionHistoryError::TimeOutOfFileBound.into());
|
||||||
|
}
|
||||||
|
|
||||||
|
let relative_time = (time - start_of_day) / TIME_INTERVAL as u64;
|
||||||
|
|
||||||
|
Ok(relative_time as usize)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check if a time is contained in this history
|
||||||
|
pub fn contains_time(&self, time: u64) -> bool {
|
||||||
|
self.resolve_offset(time).is_ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Set new state of relay
|
||||||
|
pub fn set_consumption(
|
||||||
|
&mut self,
|
||||||
|
time: u64,
|
||||||
|
consumption: EnergyConsumption,
|
||||||
|
) -> anyhow::Result<()> {
|
||||||
|
let idx = self.resolve_offset(time)?;
|
||||||
|
self.buff[idx] = consumption;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get the consumption recorded at a given time
|
||||||
|
pub fn get_consumption(&self, time: u64) -> anyhow::Result<EnergyConsumption> {
|
||||||
|
let idx = self.resolve_offset(time)?;
|
||||||
|
|
||||||
|
Ok(self.buff[idx])
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Persist device relay state history
|
||||||
|
pub fn save(&self) -> anyhow::Result<()> {
|
||||||
|
let path = AppConfig::get().energy_consumption_history_day(self.day, self.r#type);
|
||||||
|
std::fs::write(
|
||||||
|
path,
|
||||||
|
bincode::encode_to_vec(&self.buff, bincode::config::standard())?,
|
||||||
|
)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get the total runtime of a relay during a given time window
|
||||||
|
pub fn get_history(
|
||||||
|
r#type: ConsumptionHistoryType,
|
||||||
|
from: u64,
|
||||||
|
to: u64,
|
||||||
|
interval: u64,
|
||||||
|
) -> anyhow::Result<Vec<EnergyConsumption>> {
|
||||||
|
let mut res = Vec::with_capacity(((to - from) / interval) as usize);
|
||||||
|
let mut file = Self::open(from, r#type)?;
|
||||||
|
let mut curr_time = from;
|
||||||
|
|
||||||
|
let mut intermediate_values = Vec::new();
|
||||||
|
while curr_time < to {
|
||||||
|
if !file.contains_time(curr_time) {
|
||||||
|
file = Self::open(curr_time, r#type)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
intermediate_values.push(file.get_consumption(curr_time)?);
|
||||||
|
|
||||||
|
if curr_time % interval == from % interval {
|
||||||
|
res.push(median(&intermediate_values));
|
||||||
|
intermediate_values = Vec::new();
|
||||||
|
}
|
||||||
|
|
||||||
|
curr_time += TIME_INTERVAL as u64;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(res)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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, ConsumptionHistoryType::GridConsumption);
|
||||||
|
|
||||||
|
for i in 0..50 {
|
||||||
|
assert_eq!(
|
||||||
|
history.get_consumption(i * TIME_INTERVAL as u64).unwrap(),
|
||||||
|
0
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
for i in 0..50 {
|
||||||
|
history
|
||||||
|
.set_consumption(i * TIME_INTERVAL as u64, i as EnergyConsumption * 2)
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
for i in 0..50 {
|
||||||
|
assert_eq!(
|
||||||
|
history.get_consumption(i * TIME_INTERVAL as u64).unwrap(),
|
||||||
|
i as EnergyConsumption * 2
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
use crate::app_config::{AppConfig, ConsumptionHistoryType};
|
||||||
use crate::constants;
|
use crate::constants;
|
||||||
use crate::devices::device::{
|
use crate::devices::device::{
|
||||||
Device, DeviceGeneralInfo, DeviceId, DeviceInfo, DeviceRelay, DeviceRelayID,
|
Device, DeviceGeneralInfo, DeviceId, DeviceInfo, DeviceRelay, DeviceRelayID,
|
||||||
@@ -5,29 +6,44 @@ use crate::devices::device::{
|
|||||||
use crate::devices::devices_list::DevicesList;
|
use crate::devices::devices_list::DevicesList;
|
||||||
use crate::energy::consumption;
|
use crate::energy::consumption;
|
||||||
use crate::energy::consumption::EnergyConsumption;
|
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;
|
||||||
use crate::utils::time_utils::time_secs;
|
use crate::utils::time_utils::time_secs;
|
||||||
use actix::prelude::*;
|
use actix::prelude::*;
|
||||||
use openssl::x509::X509Req;
|
use openssl::x509::X509Req;
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
pub struct EnergyActor {
|
pub struct EnergyActor {
|
||||||
curr_consumption: EnergyConsumption,
|
consumption_cache: ConsumptionCache,
|
||||||
devices: DevicesList,
|
devices: DevicesList,
|
||||||
engine: EnergyEngine,
|
engine: EnergyEngine,
|
||||||
|
last_engine_refresh: u64,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl EnergyActor {
|
impl EnergyActor {
|
||||||
pub async fn new() -> anyhow::Result<Self> {
|
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 mut consumption_cache = ConsumptionCache::new(consumption_cache_size as usize);
|
||||||
|
consumption_cache.add_value(curr_consumption);
|
||||||
|
|
||||||
|
if consumption_cache_size < 1 {
|
||||||
|
panic!("Energy fetch interval must be equal or smaller than refresh interval!");
|
||||||
|
}
|
||||||
|
|
||||||
Ok(Self {
|
Ok(Self {
|
||||||
curr_consumption: consumption::get_curr_consumption().await?,
|
consumption_cache,
|
||||||
devices: DevicesList::load()?,
|
devices: DevicesList::load()?,
|
||||||
engine: EnergyEngine::default(),
|
engine: EnergyEngine::default(),
|
||||||
|
last_engine_refresh: 0,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn refresh(&mut self) -> anyhow::Result<()> {
|
async fn refresh(&mut self) -> anyhow::Result<()> {
|
||||||
// Refresh energy
|
// Refresh energy
|
||||||
self.curr_consumption = consumption::get_curr_consumption()
|
let latest_consumption = consumption::get_curr_consumption()
|
||||||
.await
|
.await
|
||||||
.unwrap_or_else(|e| {
|
.unwrap_or_else(|e| {
|
||||||
log::error!(
|
log::error!(
|
||||||
@@ -35,9 +51,32 @@ impl EnergyActor {
|
|||||||
);
|
);
|
||||||
constants::FALLBACK_PRODUCTION_VALUE
|
constants::FALLBACK_PRODUCTION_VALUE
|
||||||
});
|
});
|
||||||
|
self.consumption_cache.add_value(latest_consumption);
|
||||||
|
|
||||||
|
let devices_list = self.devices.full_list_ref();
|
||||||
|
|
||||||
|
let mut history =
|
||||||
|
ConsumptionHistoryFile::open(time_secs(), ConsumptionHistoryType::GridConsumption)?;
|
||||||
|
history.set_consumption(time_secs(), latest_consumption)?;
|
||||||
|
history.save()?;
|
||||||
|
|
||||||
|
let mut relays_consumption =
|
||||||
|
ConsumptionHistoryFile::open(time_secs(), ConsumptionHistoryType::RelayConsumption)?;
|
||||||
|
relays_consumption.set_consumption(
|
||||||
|
time_secs(),
|
||||||
|
self.engine.sum_relays_consumption(&devices_list) as EnergyConsumption,
|
||||||
|
)?;
|
||||||
|
relays_consumption.save()?;
|
||||||
|
|
||||||
|
if self.last_engine_refresh + AppConfig::get().refresh_interval > time_secs() {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
self.last_engine_refresh = time_secs();
|
||||||
|
|
||||||
self.engine
|
self.engine
|
||||||
.refresh(self.curr_consumption, &self.devices.full_list());
|
.refresh(self.consumption_cache.median_value(), &devices_list);
|
||||||
|
|
||||||
|
self.engine.persist_relays_state(&devices_list)?;
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -49,12 +88,15 @@ impl Actor for EnergyActor {
|
|||||||
fn started(&mut self, ctx: &mut Self::Context) {
|
fn started(&mut self, ctx: &mut Self::Context) {
|
||||||
log::info!("Energy actor successfully started!");
|
log::info!("Energy actor successfully started!");
|
||||||
|
|
||||||
ctx.run_interval(constants::ENERGY_REFRESH_INTERVAL, |act, _ctx| {
|
ctx.run_interval(
|
||||||
log::info!("Performing energy refresh operation");
|
Duration::from_secs(AppConfig::get().energy_fetch_interval),
|
||||||
if let Err(e) = futures::executor::block_on(act.refresh()) {
|
|act, _ctx| {
|
||||||
log::error!("Energy refresh failed! {e}")
|
log::info!("Performing energy refresh operation");
|
||||||
}
|
if let Err(e) = futures::executor::block_on(act.refresh()) {
|
||||||
});
|
log::error!("Energy refresh failed! {e}")
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
fn stopped(&mut self, _ctx: &mut Self::Context) {
|
fn stopped(&mut self, _ctx: &mut Self::Context) {
|
||||||
@@ -73,11 +115,25 @@ impl Handler<GetCurrConsumption> for EnergyActor {
|
|||||||
type Result = EnergyConsumption;
|
type Result = EnergyConsumption;
|
||||||
|
|
||||||
fn handle(&mut self, _msg: GetCurrConsumption, _ctx: &mut Context<Self>) -> Self::Result {
|
fn handle(&mut self, _msg: GetCurrConsumption, _ctx: &mut Context<Self>) -> Self::Result {
|
||||||
self.curr_consumption
|
self.consumption_cache.median_value()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get current consumption
|
/// Get relays consumption
|
||||||
|
#[derive(Message)]
|
||||||
|
#[rtype(result = "usize")]
|
||||||
|
pub struct RelaysConsumption;
|
||||||
|
|
||||||
|
impl Handler<RelaysConsumption> for EnergyActor {
|
||||||
|
type Result = usize;
|
||||||
|
|
||||||
|
fn handle(&mut self, _msg: RelaysConsumption, _ctx: &mut Context<Self>) -> Self::Result {
|
||||||
|
self.engine
|
||||||
|
.sum_relays_consumption(&self.devices.full_list_ref())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check if device exists
|
||||||
#[derive(Message)]
|
#[derive(Message)]
|
||||||
#[rtype(result = "bool")]
|
#[rtype(result = "bool")]
|
||||||
pub struct CheckDeviceExists(pub DeviceId);
|
pub struct CheckDeviceExists(pub DeviceId);
|
||||||
@@ -139,6 +195,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
|
/// Delete a device
|
||||||
#[derive(Message)]
|
#[derive(Message)]
|
||||||
#[rtype(result = "anyhow::Result<()>")]
|
#[rtype(result = "anyhow::Result<()>")]
|
||||||
@@ -149,8 +226,19 @@ impl Handler<DeleteDevice> for EnergyActor {
|
|||||||
|
|
||||||
fn handle(&mut self, msg: DeleteDevice, _ctx: &mut Context<Self>) -> Self::Result {
|
fn handle(&mut self, msg: DeleteDevice, _ctx: &mut Context<Self>) -> Self::Result {
|
||||||
log::info!("Requested to delete device {:?}...", &msg.0);
|
log::info!("Requested to delete device {:?}...", &msg.0);
|
||||||
|
|
||||||
|
let Some(device) = self.devices.get_single(&msg.0) else {
|
||||||
|
log::warn!("Requested to delete non-existent device!");
|
||||||
|
return Ok(());
|
||||||
|
};
|
||||||
|
|
||||||
|
// Delete device relays
|
||||||
|
for relay in device.relays {
|
||||||
|
self.devices.relay_delete(relay.id)?;
|
||||||
|
}
|
||||||
|
|
||||||
self.devices.delete(&msg.0)?;
|
self.devices.delete(&msg.0)?;
|
||||||
// TODO : delete energy related information
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -263,11 +351,14 @@ impl Handler<SynchronizeDevice> for EnergyActor {
|
|||||||
self.devices.synchronise_dev_info(&msg.0, msg.1.clone())?;
|
self.devices.synchronise_dev_info(&msg.0, msg.1.clone())?;
|
||||||
self.engine.device_state(&msg.0).record_ping();
|
self.engine.device_state(&msg.0).record_ping();
|
||||||
|
|
||||||
// TODO : implement real code
|
let Some(device) = self.devices.get_single(&msg.0) else {
|
||||||
|
return Ok(vec![]);
|
||||||
|
};
|
||||||
|
|
||||||
let mut v = vec![];
|
let mut v = vec![];
|
||||||
for i in 0..msg.1.max_relays {
|
for d in &device.relays {
|
||||||
v.push(RelaySyncStatus {
|
v.push(RelaySyncStatus {
|
||||||
enabled: i % 2 == 0,
|
enabled: self.engine.relay_state(d.id).is_on(),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
Ok(v)
|
Ok(v)
|
||||||
@@ -304,3 +395,34 @@ impl Handler<GetDevicesState> for EnergyActor {
|
|||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(serde::Serialize)]
|
||||||
|
pub struct ResRelayState {
|
||||||
|
pub id: DeviceRelayID,
|
||||||
|
pub on: bool,
|
||||||
|
pub r#for: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get the state of all relays
|
||||||
|
#[derive(Message)]
|
||||||
|
#[rtype(result = "Vec<ResRelayState>")]
|
||||||
|
pub struct GetAllRelaysState;
|
||||||
|
|
||||||
|
impl Handler<GetAllRelaysState> for EnergyActor {
|
||||||
|
type Result = Vec<ResRelayState>;
|
||||||
|
|
||||||
|
fn handle(&mut self, _msg: GetAllRelaysState, _ctx: &mut Context<Self>) -> Self::Result {
|
||||||
|
let mut list = vec![];
|
||||||
|
|
||||||
|
for d in &self.devices.relays_list() {
|
||||||
|
let state = self.engine.relay_state(d.id);
|
||||||
|
list.push(ResRelayState {
|
||||||
|
id: d.id,
|
||||||
|
on: state.is_on(),
|
||||||
|
r#for: state.state_for(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
list
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,11 +1,14 @@
|
|||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
|
|
||||||
|
use crate::app_config::AppConfig;
|
||||||
use prettytable::{row, Table};
|
use prettytable::{row, Table};
|
||||||
|
|
||||||
use crate::constants;
|
use crate::constants;
|
||||||
use crate::devices::device::{Device, DeviceId, DeviceRelay, DeviceRelayID};
|
use crate::devices::device::{Device, DeviceId, DeviceRelay, DeviceRelayID};
|
||||||
use crate::energy::consumption::EnergyConsumption;
|
use crate::energy::consumption::EnergyConsumption;
|
||||||
use crate::utils::time_utils::time_secs;
|
use crate::energy::relay_state_history;
|
||||||
|
use crate::energy::relay_state_history::RelayStateHistory;
|
||||||
|
use crate::utils::time_utils::{curr_hour, time_secs};
|
||||||
|
|
||||||
#[derive(Default)]
|
#[derive(Default)]
|
||||||
pub struct DeviceState {
|
pub struct DeviceState {
|
||||||
@@ -29,13 +32,17 @@ pub struct RelayState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl RelayState {
|
impl RelayState {
|
||||||
fn is_on(&self) -> bool {
|
pub fn is_on(&self) -> bool {
|
||||||
self.on
|
self.on
|
||||||
}
|
}
|
||||||
|
|
||||||
fn is_off(&self) -> bool {
|
fn is_off(&self) -> bool {
|
||||||
!self.on
|
!self.on
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn state_for(&self) -> usize {
|
||||||
|
(time_secs() - self.since as u64) as usize
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
type RelaysState = HashMap<DeviceRelayID, RelayState>;
|
type RelaysState = HashMap<DeviceRelayID, RelayState>;
|
||||||
@@ -48,7 +55,7 @@ pub struct EnergyEngine {
|
|||||||
|
|
||||||
impl DeviceRelay {
|
impl DeviceRelay {
|
||||||
// Note : this function is not recursive
|
// Note : this function is not recursive
|
||||||
fn has_running_dependencies(&self, s: &RelaysState, devices: &[Device]) -> bool {
|
fn has_running_dependencies(&self, s: &RelaysState, devices: &[&Device]) -> bool {
|
||||||
for d in devices {
|
for d in devices {
|
||||||
for r in &d.relays {
|
for r in &d.relays {
|
||||||
if r.depends_on.contains(&self.id) && s.get(&r.id).unwrap().is_on() {
|
if r.depends_on.contains(&self.id) && s.get(&r.id).unwrap().is_on() {
|
||||||
@@ -65,7 +72,7 @@ impl DeviceRelay {
|
|||||||
self.depends_on.iter().any(|id| s.get(id).unwrap().is_off())
|
self.depends_on.iter().any(|id| s.get(id).unwrap().is_off())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn is_having_conflict(&self, s: &RelaysState, devices: &[Device]) -> bool {
|
fn is_having_conflict(&self, s: &RelaysState, devices: &[&Device]) -> bool {
|
||||||
if self
|
if self
|
||||||
.conflicts_with
|
.conflicts_with
|
||||||
.iter()
|
.iter()
|
||||||
@@ -87,6 +94,20 @@ impl DeviceRelay {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn sum_relays_consumption(state: &RelaysState, devices: &[&Device]) -> usize {
|
||||||
|
let mut consumption = 0;
|
||||||
|
|
||||||
|
for d in devices {
|
||||||
|
for r in &d.relays {
|
||||||
|
if matches!(state.get(&r.id).map(|r| r.on), Some(true)) {
|
||||||
|
consumption += r.consumption;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
consumption
|
||||||
|
}
|
||||||
|
|
||||||
impl EnergyEngine {
|
impl EnergyEngine {
|
||||||
pub fn device_state(&mut self, dev_id: &DeviceId) -> &mut DeviceState {
|
pub fn device_state(&mut self, dev_id: &DeviceId) -> &mut DeviceState {
|
||||||
self.devices_state.entry(dev_id.clone()).or_default();
|
self.devices_state.entry(dev_id.clone()).or_default();
|
||||||
@@ -98,26 +119,69 @@ impl EnergyEngine {
|
|||||||
self.relays_state.get_mut(&relay_id).unwrap()
|
self.relays_state.get_mut(&relay_id).unwrap()
|
||||||
}
|
}
|
||||||
|
|
||||||
fn print_summary(&mut self, curr_consumption: EnergyConsumption, devices: &[Device]) {
|
pub fn sum_relays_consumption(&self, devices: &[&Device]) -> usize {
|
||||||
|
sum_relays_consumption(&self.relays_state, devices)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn print_summary(&mut self, curr_consumption: EnergyConsumption, devices: &[&Device]) {
|
||||||
log::info!("Current consumption: {curr_consumption}");
|
log::info!("Current consumption: {curr_consumption}");
|
||||||
|
|
||||||
let mut table = Table::new();
|
let mut table = Table::new();
|
||||||
table.add_row(row!["Device", "Relay", "On", "Since"]);
|
table.add_row(row![
|
||||||
|
"Device",
|
||||||
|
"Relay",
|
||||||
|
"Consumption",
|
||||||
|
"Min downtime / uptime",
|
||||||
|
"On",
|
||||||
|
"Since",
|
||||||
|
"Online",
|
||||||
|
"Enabled device / relay"
|
||||||
|
]);
|
||||||
for d in devices {
|
for d in devices {
|
||||||
|
let dev_online = self.device_state(&d.id).is_online();
|
||||||
for r in &d.relays {
|
for r in &d.relays {
|
||||||
let status = self.relay_state(r.id);
|
let status = self.relay_state(r.id);
|
||||||
table.add_row(row![
|
table.add_row(row![
|
||||||
d.name,
|
d.name,
|
||||||
r.name,
|
r.name,
|
||||||
|
r.consumption,
|
||||||
|
format!("{} / {}", r.minimal_downtime, r.minimal_uptime),
|
||||||
status.is_on().to_string(),
|
status.is_on().to_string(),
|
||||||
status.since
|
status.since,
|
||||||
|
match dev_online {
|
||||||
|
true => "Online",
|
||||||
|
false => "Offline",
|
||||||
|
},
|
||||||
|
format!(
|
||||||
|
"{} / {}",
|
||||||
|
match d.enabled {
|
||||||
|
true => "Enabled",
|
||||||
|
false => "Disabled",
|
||||||
|
},
|
||||||
|
match r.enabled {
|
||||||
|
true => "Enabled",
|
||||||
|
false => "Disabled",
|
||||||
|
}
|
||||||
|
)
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
table.printstd();
|
table.printstd();
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn refresh(&mut self, curr_consumption: EnergyConsumption, devices: &[Device]) {
|
pub fn estimated_consumption_without_relays(
|
||||||
|
&self,
|
||||||
|
curr_consumption: EnergyConsumption,
|
||||||
|
devices: &[&Device],
|
||||||
|
) -> EnergyConsumption {
|
||||||
|
curr_consumption - self.sum_relays_consumption(devices) as i32
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Refresh energy engine; this method shall never fail !
|
||||||
|
pub fn refresh(&mut self, curr_consumption: EnergyConsumption, devices: &[&Device]) {
|
||||||
|
let base_production = self.estimated_consumption_without_relays(curr_consumption, devices);
|
||||||
|
log::info!("Estimated base production: {base_production}");
|
||||||
|
|
||||||
// Force creation of missing relays state
|
// Force creation of missing relays state
|
||||||
for d in devices {
|
for d in devices {
|
||||||
for r in &d.relays {
|
for r in &d.relays {
|
||||||
@@ -200,7 +264,35 @@ impl EnergyEngine {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO Turn on relays with running constraints (only ENABLED)
|
// Turn on relays with running constraints (only ENABLED)
|
||||||
|
for d in devices {
|
||||||
|
for r in &d.relays {
|
||||||
|
if !r.enabled || !d.enabled || !self.device_state(&d.id).is_online() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if new_relays_state.get(&r.id).unwrap().is_on() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let Some(constraints) = &r.daily_runtime else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
|
||||||
|
if !constraints.catch_up_hours.contains(&curr_hour()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
new_relays_state.get_mut(&r.id).unwrap().on = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Order relays
|
// Order relays
|
||||||
let mut ordered_relays = devices
|
let mut ordered_relays = devices
|
||||||
@@ -238,7 +330,13 @@ impl EnergyEngine {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO : check consumption
|
let new_consumption = base_production
|
||||||
|
+ sum_relays_consumption(&new_relays_state, devices) as EnergyConsumption;
|
||||||
|
|
||||||
|
if new_consumption + relay.consumption as i32 > AppConfig::get().production_margin {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
log::info!("Turn on relay {}", relay.name);
|
log::info!("Turn on relay {}", relay.name);
|
||||||
new_relays_state.get_mut(&relay.id).unwrap().on = true;
|
new_relays_state.get_mut(&relay.id).unwrap().on = true;
|
||||||
changed = true;
|
changed = true;
|
||||||
@@ -261,4 +359,125 @@ impl EnergyEngine {
|
|||||||
|
|
||||||
self.print_summary(curr_consumption, devices);
|
self.print_summary(curr_consumption, devices);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Save relays state to disk
|
||||||
|
pub fn persist_relays_state(&mut self, devices: &[&Device]) -> anyhow::Result<()> {
|
||||||
|
// Save all relays state
|
||||||
|
for d in devices {
|
||||||
|
for r in &d.relays {
|
||||||
|
let mut file = RelayStateHistory::open(r.id, time_secs())?;
|
||||||
|
file.set_state(time_secs(), self.relay_state(r.id).is_on())?;
|
||||||
|
file.save()?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod test {
|
||||||
|
use crate::devices::device::{Device, DeviceId, DeviceRelayID};
|
||||||
|
use crate::energy::consumption::EnergyConsumption;
|
||||||
|
use crate::energy::engine::EnergyEngine;
|
||||||
|
use crate::utils::time_utils::time_secs;
|
||||||
|
use rust_embed::Embed;
|
||||||
|
|
||||||
|
#[derive(serde::Deserialize)]
|
||||||
|
struct TestRelayState {
|
||||||
|
id: DeviceRelayID,
|
||||||
|
on: bool,
|
||||||
|
r#for: usize,
|
||||||
|
should_be_on: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(serde::Deserialize)]
|
||||||
|
struct TestDeviceState {
|
||||||
|
id: DeviceId,
|
||||||
|
online: bool,
|
||||||
|
relays: Vec<TestRelayState>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(serde::Deserialize)]
|
||||||
|
struct TestConfig {
|
||||||
|
curr_consumption: EnergyConsumption,
|
||||||
|
devices: Vec<Device>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(serde::Deserialize)]
|
||||||
|
struct TestConfigState {
|
||||||
|
devices: Vec<TestDeviceState>,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_test_config(
|
||||||
|
conf: &str,
|
||||||
|
) -> (
|
||||||
|
Vec<Device>,
|
||||||
|
EnergyEngine,
|
||||||
|
EnergyConsumption,
|
||||||
|
Vec<TestDeviceState>,
|
||||||
|
) {
|
||||||
|
let config: TestConfig = serde_yml::from_str(conf).unwrap();
|
||||||
|
let test_config: TestConfigState = serde_yml::from_str(conf).unwrap();
|
||||||
|
|
||||||
|
let mut engine = EnergyEngine {
|
||||||
|
devices_state: Default::default(),
|
||||||
|
relays_state: Default::default(),
|
||||||
|
};
|
||||||
|
|
||||||
|
for d in &test_config.devices {
|
||||||
|
engine.device_state(&d.id).last_ping = match d.online {
|
||||||
|
true => time_secs() - 1,
|
||||||
|
false => 10,
|
||||||
|
};
|
||||||
|
|
||||||
|
for r in &d.relays {
|
||||||
|
let s = engine.relay_state(r.id);
|
||||||
|
s.on = r.on;
|
||||||
|
s.since = time_secs() as usize - r.r#for;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
(
|
||||||
|
config.devices,
|
||||||
|
engine,
|
||||||
|
config.curr_consumption,
|
||||||
|
test_config.devices,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn run_test(name: &str, conf: &str) {
|
||||||
|
let (devices, mut energy_engine, consumption, states) = parse_test_config(conf);
|
||||||
|
|
||||||
|
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) {
|
||||||
|
let is_on = energy_engine.relay_state(relay_s.id).on;
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
energy_engine.relay_state(relay_s.id).on,
|
||||||
|
relay_s.should_be_on,
|
||||||
|
"For test {name} on relay {} got state {is_on} instead of {}",
|
||||||
|
relay.name,
|
||||||
|
relay_s.should_be_on
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Embed)]
|
||||||
|
#[folder = "engine_test/"]
|
||||||
|
struct Asset;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_confs() {
|
||||||
|
for file in Asset::iter() {
|
||||||
|
let content = Asset::get(&file).unwrap();
|
||||||
|
|
||||||
|
log::info!("Testing {file}");
|
||||||
|
|
||||||
|
run_test(&file, &String::from_utf8(content.data.to_vec()).unwrap())
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,6 @@
|
|||||||
pub mod consumption;
|
pub mod consumption;
|
||||||
|
pub mod consumption_cache;
|
||||||
|
pub mod consumption_history_file;
|
||||||
pub mod energy_actor;
|
pub mod energy_actor;
|
||||||
pub mod engine;
|
pub mod engine;
|
||||||
|
pub mod relay_state_history;
|
||||||
|
|||||||
189
central_backend/src/energy/relay_state_history.rs
Normal file
189
central_backend/src/energy/relay_state_history.rs
Normal file
@@ -0,0 +1,189 @@
|
|||||||
|
use crate::app_config::AppConfig;
|
||||||
|
use crate::devices::device::{DeviceRelay, DeviceRelayID};
|
||||||
|
use crate::utils::files_utils;
|
||||||
|
use crate::utils::time_utils::{day_number, time_start_of_day};
|
||||||
|
|
||||||
|
const TIME_INTERVAL: usize = 30;
|
||||||
|
|
||||||
|
#[derive(thiserror::Error, Debug)]
|
||||||
|
pub enum RelayStateHistoryError {
|
||||||
|
#[error("Given time is out of file bounds!")]
|
||||||
|
TimeOutOfFileBound,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// # RelayStateHistory
|
||||||
|
///
|
||||||
|
/// This structures handles the manipulation of relay state history files
|
||||||
|
///
|
||||||
|
/// These file are binary file optimizing used space.
|
||||||
|
pub struct RelayStateHistory {
|
||||||
|
id: DeviceRelayID,
|
||||||
|
day: u64,
|
||||||
|
buff: Vec<u8>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RelayStateHistory {
|
||||||
|
/// Open relay state history file, if it exists, or create an empty one
|
||||||
|
pub fn open(id: DeviceRelayID, time: u64) -> anyhow::Result<Self> {
|
||||||
|
let day = day_number(time);
|
||||||
|
let path = AppConfig::get().relay_runtime_day_file_path(id, day);
|
||||||
|
|
||||||
|
if path.exists() {
|
||||||
|
Ok(Self {
|
||||||
|
id,
|
||||||
|
day,
|
||||||
|
buff: std::fs::read(path)?,
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
log::debug!(
|
||||||
|
"Stats for relay {id:?} for day {day} does not exists yet, creating memory buffer"
|
||||||
|
);
|
||||||
|
Ok(Self::new_memory(id, day))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create a new in memory dev relay state history
|
||||||
|
fn new_memory(id: DeviceRelayID, day: u64) -> Self {
|
||||||
|
Self {
|
||||||
|
id,
|
||||||
|
day,
|
||||||
|
buff: vec![0; (3600 * 24 / (TIME_INTERVAL * 8)) + 1],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resolve time offset of a given time in buffer
|
||||||
|
fn resolve_offset(&self, time: u64) -> anyhow::Result<(usize, u8)> {
|
||||||
|
let start_of_day = self.day * 3600 * 24;
|
||||||
|
|
||||||
|
if time < start_of_day || time >= start_of_day + 3600 * 24 {
|
||||||
|
return Err(RelayStateHistoryError::TimeOutOfFileBound.into());
|
||||||
|
}
|
||||||
|
|
||||||
|
let relative_time = (time - start_of_day) / TIME_INTERVAL as u64;
|
||||||
|
|
||||||
|
Ok(((relative_time / 8) as usize, (relative_time % 8) as u8))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check if a time is contained in this history
|
||||||
|
pub fn contains_time(&self, time: u64) -> bool {
|
||||||
|
self.resolve_offset(time).is_ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Set new state of relay
|
||||||
|
pub fn set_state(&mut self, time: u64, on: bool) -> anyhow::Result<()> {
|
||||||
|
let (idx, offset) = self.resolve_offset(time)?;
|
||||||
|
|
||||||
|
self.buff[idx] = if on {
|
||||||
|
self.buff[idx] | (0x1 << offset)
|
||||||
|
} else {
|
||||||
|
self.buff[idx] & !(0x1 << offset)
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get the state of relay at a given time
|
||||||
|
pub fn get_state(&self, time: u64) -> anyhow::Result<bool> {
|
||||||
|
let (idx, offset) = self.resolve_offset(time)?;
|
||||||
|
|
||||||
|
Ok(self.buff[idx] & (0x1 << offset) != 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Persist device relay state history
|
||||||
|
pub fn save(&self) -> anyhow::Result<()> {
|
||||||
|
let path = AppConfig::get().relay_runtime_day_file_path(self.id, self.day);
|
||||||
|
files_utils::create_directory_if_missing(path.parent().unwrap())?;
|
||||||
|
std::fs::write(path, &self.buff)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get the total runtime of a relay during a given time window
|
||||||
|
pub fn relay_total_runtime(device_id: DeviceRelayID, from: u64, to: u64) -> anyhow::Result<usize> {
|
||||||
|
let mut total = 0;
|
||||||
|
let mut file = RelayStateHistory::open(device_id, from)?;
|
||||||
|
let mut curr_time = from;
|
||||||
|
|
||||||
|
while curr_time < to {
|
||||||
|
if !file.contains_time(curr_time) {
|
||||||
|
file = RelayStateHistory::open(device_id, curr_time)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
if file.get_state(curr_time)? {
|
||||||
|
total += TIME_INTERVAL;
|
||||||
|
}
|
||||||
|
|
||||||
|
curr_time += TIME_INTERVAL as u64;
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use crate::devices::device::DeviceRelayID;
|
||||||
|
use crate::energy::relay_state_history::{relay_total_runtime, RelayStateHistory};
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_relay_state_history() {
|
||||||
|
let mut history = RelayStateHistory::new_memory(DeviceRelayID::default(), 0);
|
||||||
|
|
||||||
|
let val_1 = 5 * 30;
|
||||||
|
let val_2 = 7 * 30;
|
||||||
|
|
||||||
|
for i in 0..500 {
|
||||||
|
assert!(!history.get_state(i).unwrap())
|
||||||
|
}
|
||||||
|
|
||||||
|
history.set_state(val_1, true).unwrap();
|
||||||
|
|
||||||
|
for i in 0..500 {
|
||||||
|
assert_eq!(history.get_state(i).unwrap(), (i / 30) * 30 == val_1);
|
||||||
|
}
|
||||||
|
|
||||||
|
history.set_state(val_2, true).unwrap();
|
||||||
|
|
||||||
|
for i in 0..500 {
|
||||||
|
assert_eq!(
|
||||||
|
history.get_state(i).unwrap(),
|
||||||
|
(i / 30) * 30 == val_1 || (i / 30) * 30 == val_2
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
history.set_state(val_2, false).unwrap();
|
||||||
|
|
||||||
|
for i in 0..500 {
|
||||||
|
assert_eq!(history.get_state(i).unwrap(), (i / 30) * 30 == val_1);
|
||||||
|
}
|
||||||
|
|
||||||
|
history.set_state(val_1, false).unwrap();
|
||||||
|
|
||||||
|
for i in 0..500 {
|
||||||
|
assert!(!history.get_state(i).unwrap())
|
||||||
|
}
|
||||||
|
|
||||||
|
assert!(history.get_state(8989898).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_relay_total_runtime() {
|
||||||
|
assert_eq!(
|
||||||
|
relay_total_runtime(DeviceRelayID::default(), 50, 3600 * 24 * 60 + 500).unwrap(),
|
||||||
|
0
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,5 +3,7 @@ pub mod constants;
|
|||||||
pub mod crypto;
|
pub mod crypto;
|
||||||
pub mod devices;
|
pub mod devices;
|
||||||
pub mod energy;
|
pub mod energy;
|
||||||
|
pub mod logs;
|
||||||
|
pub mod ota;
|
||||||
pub mod server;
|
pub mod server;
|
||||||
pub mod utils;
|
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()?;
|
||||||
|
file.unlock()?;
|
||||||
|
|
||||||
|
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,
|
||||||
|
}
|
||||||
@@ -9,6 +9,9 @@ use tokio_schedule::{every, Job};
|
|||||||
|
|
||||||
#[actix_web::main]
|
#[actix_web::main]
|
||||||
async fn main() -> std::io::Result<()> {
|
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
|
// Initialize OpenSSL
|
||||||
openssl_sys::init();
|
openssl_sys::init();
|
||||||
|
|
||||||
@@ -17,6 +20,10 @@ async fn main() -> std::io::Result<()> {
|
|||||||
// Initialize storage
|
// Initialize storage
|
||||||
create_directory_if_missing(AppConfig::get().pki_path()).unwrap();
|
create_directory_if_missing(AppConfig::get().pki_path()).unwrap();
|
||||||
create_directory_if_missing(AppConfig::get().devices_config_path()).unwrap();
|
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
|
// Initialize PKI
|
||||||
pki::initialize_root_ca().expect("Failed to initialize Root CA!");
|
pki::initialize_root_ca().expect("Failed to initialize Root CA!");
|
||||||
@@ -40,8 +47,8 @@ async fn main() -> std::io::Result<()> {
|
|||||||
.expect("Failed to initialize energy actor!")
|
.expect("Failed to initialize energy actor!")
|
||||||
.start();
|
.start();
|
||||||
|
|
||||||
let s1 = servers::secure_server(actor);
|
let s1 = servers::secure_server(actor.clone());
|
||||||
let s2 = servers::unsecure_server();
|
let s2 = servers::unsecure_server(actor);
|
||||||
future::try_join(s1, s2)
|
future::try_join(s1, s2)
|
||||||
.await
|
.await
|
||||||
.expect("Failed to start servers!");
|
.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,
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
use crate::logs::logs_manager;
|
||||||
|
use crate::logs::severity::LogSeverity;
|
||||||
|
use crate::server::custom_error::HttpResult;
|
||||||
|
use crate::server::devices_api::jwt_parser::JWTRequest;
|
||||||
|
use crate::server::WebEnergyActor;
|
||||||
|
use actix_web::{web, HttpResponse};
|
||||||
|
|
||||||
|
#[derive(Debug, serde::Deserialize)]
|
||||||
|
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::{web, HttpResponse};
|
||||||
|
|
||||||
|
#[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>(
|
||||||
|
&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,14 +1,16 @@
|
|||||||
use crate::app_config::AppConfig;
|
use crate::app_config::AppConfig;
|
||||||
use crate::crypto::pki;
|
|
||||||
use crate::devices::device::{DeviceId, DeviceInfo};
|
use crate::devices::device::{DeviceId, DeviceInfo};
|
||||||
use crate::energy::energy_actor;
|
use crate::energy::energy_actor;
|
||||||
|
use crate::energy::energy_actor::RelaySyncStatus;
|
||||||
|
use crate::ota::ota_manager;
|
||||||
|
use crate::ota::ota_update::OTAPlatform;
|
||||||
use crate::server::custom_error::HttpResult;
|
use crate::server::custom_error::HttpResult;
|
||||||
|
use crate::server::devices_api::jwt_parser::JWTRequest;
|
||||||
use crate::server::WebEnergyActor;
|
use crate::server::WebEnergyActor;
|
||||||
use actix_web::{web, HttpResponse};
|
use actix_web::{web, HttpResponse};
|
||||||
use jsonwebtoken::{Algorithm, DecodingKey, Validation};
|
|
||||||
use openssl::nid::Nid;
|
use openssl::nid::Nid;
|
||||||
use openssl::x509::{X509Req, X509};
|
use openssl::x509::X509Req;
|
||||||
use std::collections::HashSet;
|
use std::str::FromStr;
|
||||||
|
|
||||||
#[derive(Debug, serde::Deserialize)]
|
#[derive(Debug, serde::Deserialize)]
|
||||||
pub struct EnrollRequest {
|
pub struct EnrollRequest {
|
||||||
@@ -128,80 +130,41 @@ pub async fn get_certificate(query: web::Query<ReqWithDevID>, actor: WebEnergyAc
|
|||||||
.body(cert))
|
.body(cert))
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(serde::Deserialize)]
|
|
||||||
pub struct SyncRequest {
|
|
||||||
payload: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, serde::Serialize, serde::Deserialize)]
|
#[derive(Debug, serde::Serialize, serde::Deserialize)]
|
||||||
struct Claims {
|
struct Claims {
|
||||||
info: DeviceInfo,
|
info: DeviceInfo,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(serde::Serialize)]
|
||||||
|
struct SyncResult {
|
||||||
|
relays: Vec<RelaySyncStatus>,
|
||||||
|
available_update: Option<semver::Version>,
|
||||||
|
}
|
||||||
|
|
||||||
/// Synchronize device
|
/// Synchronize device
|
||||||
pub async fn sync_device(body: web::Json<SyncRequest>, actor: WebEnergyActor) -> HttpResult {
|
pub async fn sync_device(body: web::Json<JWTRequest>, actor: WebEnergyActor) -> HttpResult {
|
||||||
// First, we need to extract device kid from query
|
let (device, claims) = body.0.parse_jwt::<Claims>(actor.clone()).await?;
|
||||||
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 {
|
let relays = actor
|
||||||
log::error!("Missing KID in JWT!");
|
.send(energy_actor::SynchronizeDevice(
|
||||||
return Ok(HttpResponse::BadRequest().json("Missing KID in JWT!"));
|
device.id,
|
||||||
};
|
claims.info.clone(),
|
||||||
|
))
|
||||||
// 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!"));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
let res = actor
|
|
||||||
.send(energy_actor::SynchronizeDevice(device.id, c.claims.info))
|
|
||||||
.await??;
|
.await??;
|
||||||
|
|
||||||
Ok(HttpResponse::Ok().json(res))
|
let mut available_update = None;
|
||||||
|
|
||||||
|
// Check if the version is available
|
||||||
|
if let Some(desired) = device.desired_version {
|
||||||
|
if 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 mgmt_controller;
|
||||||
pub mod utils_controller;
|
pub mod utils_controller;
|
||||||
|
|||||||
@@ -3,7 +3,9 @@ use crate::constants;
|
|||||||
use crate::crypto::pki;
|
use crate::crypto::pki;
|
||||||
use crate::energy::energy_actor::EnergyActorAddr;
|
use crate::energy::energy_actor::EnergyActorAddr;
|
||||||
use crate::server::auth_middleware::AuthChecker;
|
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::unsecure_server::*;
|
||||||
use crate::server::web_api::*;
|
use crate::server::web_api::*;
|
||||||
use crate::server::web_app_controller;
|
use crate::server::web_app_controller;
|
||||||
@@ -20,14 +22,15 @@ use openssl::ssl::{SslAcceptor, SslMethod};
|
|||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
/// Start unsecure (HTTP) server
|
/// Start unsecure (HTTP) server
|
||||||
pub async fn unsecure_server() -> anyhow::Result<()> {
|
pub async fn unsecure_server(energy_actor: EnergyActorAddr) -> anyhow::Result<()> {
|
||||||
log::info!(
|
log::info!(
|
||||||
"Unsecure server starting to listen on {} for {}",
|
"Unsecure server starting to listen on {} for {}",
|
||||||
AppConfig::get().unsecure_listen_address,
|
AppConfig::get().unsecure_listen_address,
|
||||||
AppConfig::get().unsecure_origin()
|
AppConfig::get().unsecure_origin()
|
||||||
);
|
);
|
||||||
HttpServer::new(|| {
|
HttpServer::new(move || {
|
||||||
App::new()
|
App::new()
|
||||||
|
.app_data(web::Data::new(energy_actor.clone()))
|
||||||
.wrap(Logger::default())
|
.wrap(Logger::default())
|
||||||
.route(
|
.route(
|
||||||
"/",
|
"/",
|
||||||
@@ -41,6 +44,10 @@ pub async fn unsecure_server() -> anyhow::Result<()> {
|
|||||||
"/pki/{file}",
|
"/pki/{file}",
|
||||||
web::get().to(unsecure_pki_controller::serve_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)?
|
.bind(&AppConfig::get().unsecure_listen_address)?
|
||||||
.run()
|
.run()
|
||||||
@@ -131,10 +138,22 @@ pub async fn secure_server(energy_actor: EnergyActorAddr) -> anyhow::Result<()>
|
|||||||
"/web_api/energy/curr_consumption",
|
"/web_api/energy/curr_consumption",
|
||||||
web::get().to(energy_controller::curr_consumption),
|
web::get().to(energy_controller::curr_consumption),
|
||||||
)
|
)
|
||||||
|
.route(
|
||||||
|
"/web_api/energy/curr_consumption/history",
|
||||||
|
web::get().to(energy_controller::curr_consumption_history),
|
||||||
|
)
|
||||||
.route(
|
.route(
|
||||||
"/web_api/energy/cached_consumption",
|
"/web_api/energy/cached_consumption",
|
||||||
web::get().to(energy_controller::cached_consumption),
|
web::get().to(energy_controller::cached_consumption),
|
||||||
)
|
)
|
||||||
|
.route(
|
||||||
|
"/web_api/energy/relays_consumption",
|
||||||
|
web::get().to(energy_controller::relays_consumption),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/web_api/energy/relays_consumption/history",
|
||||||
|
web::get().to(energy_controller::relays_consumption_history),
|
||||||
|
)
|
||||||
// Devices controller
|
// Devices controller
|
||||||
.route(
|
.route(
|
||||||
"/web_api/devices/list_pending",
|
"/web_api/devices/list_pending",
|
||||||
@@ -168,6 +187,37 @@ pub async fn secure_server(energy_actor: EnergyActorAddr) -> anyhow::Result<()>
|
|||||||
"/web_api/device/{id}",
|
"/web_api/device/{id}",
|
||||||
web::delete().to(devices_controller::delete_device),
|
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
|
// Relays API
|
||||||
.route(
|
.route(
|
||||||
"/web_api/relays/list",
|
"/web_api/relays/list",
|
||||||
@@ -185,6 +235,14 @@ pub async fn secure_server(energy_actor: EnergyActorAddr) -> anyhow::Result<()>
|
|||||||
"/web_api/relay/{id}",
|
"/web_api/relay/{id}",
|
||||||
web::delete().to(relays_controller::delete),
|
web::delete().to(relays_controller::delete),
|
||||||
)
|
)
|
||||||
|
.route(
|
||||||
|
"/web_api/relays/status",
|
||||||
|
web::get().to(relays_controller::status_all),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/web_api/relay/{id}/status",
|
||||||
|
web::get().to(relays_controller::status_single),
|
||||||
|
)
|
||||||
// Devices API
|
// Devices API
|
||||||
.route(
|
.route(
|
||||||
"/devices_api/utils/time",
|
"/devices_api/utils/time",
|
||||||
@@ -206,6 +264,14 @@ pub async fn secure_server(energy_actor: EnergyActorAddr) -> anyhow::Result<()>
|
|||||||
"/devices_api/mgmt/sync",
|
"/devices_api/mgmt/sync",
|
||||||
web::post().to(mgmt_controller::sync_device),
|
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
|
// Web app
|
||||||
.route("/", web::get().to(web_app_controller::root_index))
|
.route("/", web::get().to(web_app_controller::root_index))
|
||||||
.route(
|
.route(
|
||||||
|
|||||||
@@ -1,2 +1,3 @@
|
|||||||
pub mod unsecure_pki_controller;
|
pub mod unsecure_pki_controller;
|
||||||
|
pub mod unsecure_relay_controller;
|
||||||
pub mod unsecure_server_controller;
|
pub mod unsecure_server_controller;
|
||||||
|
|||||||
@@ -0,0 +1,60 @@
|
|||||||
|
use crate::devices::device::DeviceRelayID;
|
||||||
|
use crate::energy::{energy_actor, relay_state_history};
|
||||||
|
use crate::server::custom_error::HttpResult;
|
||||||
|
use crate::server::WebEnergyActor;
|
||||||
|
use actix_web::{web, HttpResponse};
|
||||||
|
|
||||||
|
#[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),
|
||||||
|
}))
|
||||||
|
}
|
||||||
@@ -1,6 +1,10 @@
|
|||||||
|
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::energy::{consumption, energy_actor};
|
||||||
use crate::server::custom_error::HttpResult;
|
use crate::server::custom_error::HttpResult;
|
||||||
use crate::server::WebEnergyActor;
|
use crate::server::WebEnergyActor;
|
||||||
|
use crate::utils::time_utils::time_secs;
|
||||||
use actix_web::HttpResponse;
|
use actix_web::HttpResponse;
|
||||||
|
|
||||||
#[derive(serde::Serialize)]
|
#[derive(serde::Serialize)]
|
||||||
@@ -15,9 +19,38 @@ pub async fn curr_consumption() -> HttpResult {
|
|||||||
Ok(HttpResponse::Ok().json(Consumption { consumption }))
|
Ok(HttpResponse::Ok().json(Consumption { consumption }))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Get curr consumption history
|
||||||
|
pub async fn curr_consumption_history() -> HttpResult {
|
||||||
|
let history = ConsumptionHistoryFile::get_history(
|
||||||
|
ConsumptionHistoryType::GridConsumption,
|
||||||
|
time_secs() - 3600 * 24,
|
||||||
|
time_secs(),
|
||||||
|
60 * 10,
|
||||||
|
)?;
|
||||||
|
Ok(HttpResponse::Ok().json(history))
|
||||||
|
}
|
||||||
|
|
||||||
/// Get cached energy consumption
|
/// Get cached energy consumption
|
||||||
pub async fn cached_consumption(energy_actor: WebEnergyActor) -> HttpResult {
|
pub async fn cached_consumption(energy_actor: WebEnergyActor) -> HttpResult {
|
||||||
let consumption = energy_actor.send(energy_actor::GetCurrConsumption).await?;
|
let consumption = energy_actor.send(energy_actor::GetCurrConsumption).await?;
|
||||||
|
|
||||||
Ok(HttpResponse::Ok().json(Consumption { consumption }))
|
Ok(HttpResponse::Ok().json(Consumption { consumption }))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Get current relays consumption
|
||||||
|
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 }))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn relays_consumption_history() -> HttpResult {
|
||||||
|
let history = ConsumptionHistoryFile::get_history(
|
||||||
|
ConsumptionHistoryType::RelayConsumption,
|
||||||
|
time_secs() - 3600 * 24,
|
||||||
|
time_secs(),
|
||||||
|
60 * 10,
|
||||||
|
)?;
|
||||||
|
Ok(HttpResponse::Ok().json(history))
|
||||||
|
}
|
||||||
|
|||||||
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::{web, HttpResponse};
|
||||||
|
|
||||||
|
#[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))
|
||||||
|
}
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
pub mod auth_controller;
|
pub mod auth_controller;
|
||||||
pub mod devices_controller;
|
pub mod devices_controller;
|
||||||
pub mod energy_controller;
|
pub mod energy_controller;
|
||||||
|
pub mod logging_controller;
|
||||||
|
pub mod ota_controller;
|
||||||
pub mod relays_controller;
|
pub mod relays_controller;
|
||||||
pub mod server_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::custom_error::HttpResult;
|
||||||
|
use crate::server::WebEnergyActor;
|
||||||
|
use actix_multipart::form::tempfile::TempFile;
|
||||||
|
use actix_multipart::form::MultipartForm;
|
||||||
|
use actix_web::{web, HttpResponse};
|
||||||
|
|
||||||
|
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 {
|
||||||
|
if d.info.reference != p.to_string() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter per device
|
||||||
|
if let Some(ids) = &body.devices {
|
||||||
|
if !ids.contains(&d.id) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
actor
|
||||||
|
.send(energy_actor::SetDesiredVersion(
|
||||||
|
d.id,
|
||||||
|
Some(body.version.clone()),
|
||||||
|
))
|
||||||
|
.await??;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(HttpResponse::Ok().finish())
|
||||||
|
}
|
||||||
@@ -93,3 +93,20 @@ pub async fn delete(actor: WebEnergyActor, path: web::Path<RelayIDInPath>) -> Ht
|
|||||||
|
|
||||||
Ok(HttpResponse::Accepted().finish())
|
Ok(HttpResponse::Accepted().finish())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Get the status of all relays
|
||||||
|
pub async fn status_all(actor: WebEnergyActor) -> HttpResult {
|
||||||
|
let list = actor.send(energy_actor::GetAllRelaysState).await?;
|
||||||
|
|
||||||
|
Ok(HttpResponse::Ok().json(list))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get the state of a single relay
|
||||||
|
pub async fn status_single(actor: WebEnergyActor, path: web::Path<RelayIDInPath>) -> HttpResult {
|
||||||
|
let list = actor.send(energy_actor::GetAllRelaysState).await?;
|
||||||
|
let Some(state) = list.into_iter().find(|r| r.id == path.id) else {
|
||||||
|
return Ok(HttpResponse::NotFound().json("Relay not found!"));
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(HttpResponse::Ok().json(state))
|
||||||
|
}
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ pub async fn secure_home() -> HttpResponse {
|
|||||||
struct ServerConfig {
|
struct ServerConfig {
|
||||||
auth_disabled: bool,
|
auth_disabled: bool,
|
||||||
constraints: StaticConstraints,
|
constraints: StaticConstraints,
|
||||||
|
unsecure_origin: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for ServerConfig {
|
impl Default for ServerConfig {
|
||||||
@@ -19,6 +20,7 @@ impl Default for ServerConfig {
|
|||||||
Self {
|
Self {
|
||||||
auth_disabled: AppConfig::get().unsecure_disable_login,
|
auth_disabled: AppConfig::get().unsecure_disable_login,
|
||||||
constraints: Default::default(),
|
constraints: Default::default(),
|
||||||
|
unsecure_origin: AppConfig::get().unsecure_origin(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
8
central_backend/src/utils/math_utils.rs
Normal file
8
central_backend/src/utils/math_utils.rs
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
use std::ops::Div;
|
||||||
|
|
||||||
|
pub fn median<E: Div + Copy + Ord>(numbers: &[E]) -> E {
|
||||||
|
let mut numbers = numbers.to_vec();
|
||||||
|
numbers.sort();
|
||||||
|
let mid = numbers.len() / 2;
|
||||||
|
numbers[mid]
|
||||||
|
}
|
||||||
@@ -1,2 +1,3 @@
|
|||||||
pub mod files_utils;
|
pub mod files_utils;
|
||||||
|
pub mod math_utils;
|
||||||
pub mod time_utils;
|
pub mod time_utils;
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
use chrono::prelude::*;
|
||||||
use std::time::{SystemTime, UNIX_EPOCH};
|
use std::time::{SystemTime, UNIX_EPOCH};
|
||||||
|
|
||||||
/// Get the current time since epoch
|
/// Get the current time since epoch
|
||||||
@@ -15,3 +16,40 @@ pub fn time_millis() -> u128 {
|
|||||||
.unwrap()
|
.unwrap()
|
||||||
.as_millis()
|
.as_millis()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Get the number of the day since 01-01-1970 of a given UNIX timestamp (UTC)
|
||||||
|
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();
|
||||||
|
local.hour()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get the first second of the day (local time)
|
||||||
|
pub fn time_start_of_day() -> anyhow::Result<u64> {
|
||||||
|
let local: DateTime<Local> = Local::now()
|
||||||
|
.with_time(NaiveTime::from_hms_opt(0, 0, 0).unwrap())
|
||||||
|
.unwrap();
|
||||||
|
Ok(local.timestamp() as u64)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod test {
|
||||||
|
use crate::utils::time_utils::day_number;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_time_of_day() {
|
||||||
|
assert_eq!(day_number(500), 0);
|
||||||
|
assert_eq!(day_number(1726592301), 19983);
|
||||||
|
assert_eq!(day_number(1726592401), 19983);
|
||||||
|
assert_eq!(day_number(1726498701), 19982);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
|
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<link rel="icon" type="image/svg+xml" href="/sunny.svg" />
|
<link rel="icon" type="image/svg+xml" href="/assets/sunny.svg" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>SolarEnergy</title>
|
<title>SolarEnergy</title>
|
||||||
</head>
|
</head>
|
||||||
|
|||||||
1299
central_frontend/package-lock.json
generated
1299
central_frontend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -12,29 +12,32 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@emotion/react": "^11.13.3",
|
"@emotion/react": "^11.13.3",
|
||||||
"@emotion/styled": "^11.13.0",
|
"@emotion/styled": "^11.13.0",
|
||||||
"@fontsource/roboto": "^5.0.14",
|
"@fontsource/roboto": "^5.1.0",
|
||||||
"@mdi/js": "^7.4.47",
|
"@mdi/js": "^7.4.47",
|
||||||
"@mdi/react": "^1.6.1",
|
"@mdi/react": "^1.6.1",
|
||||||
"@mui/icons-material": "^6.0.1",
|
"@mui/icons-material": "^6.1.3",
|
||||||
"@mui/material": "^6.0.1",
|
"@mui/material": "^6.1.3",
|
||||||
"@mui/x-charts": "^7.15.0",
|
"@mui/x-charts": "^7.20.0",
|
||||||
"@mui/x-date-pickers": "^7.15.0",
|
"@mui/x-date-pickers": "^7.20.0",
|
||||||
"date-and-time": "^3.5.0",
|
"@types/semver": "^7.5.8",
|
||||||
|
"date-and-time": "^3.6.0",
|
||||||
"dayjs": "^1.11.13",
|
"dayjs": "^1.11.13",
|
||||||
|
"filesize": "^10.1.6",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-dom": "^18.3.1",
|
"react-dom": "^18.3.1",
|
||||||
"react-router-dom": "^6.26.1"
|
"react-router-dom": "^6.27.0",
|
||||||
|
"semver": "^7.6.3"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/react": "^18.3.5",
|
"@types/react": "^18.3.11",
|
||||||
"@types/react-dom": "^18.3.0",
|
"@types/react-dom": "^18.3.1",
|
||||||
"@typescript-eslint/eslint-plugin": "^8.3.0",
|
"@typescript-eslint/eslint-plugin": "^8.8.0",
|
||||||
"@typescript-eslint/parser": "^8.3.0",
|
"@typescript-eslint/parser": "^8.8.0",
|
||||||
"@vitejs/plugin-react": "^4.3.1",
|
"@vitejs/plugin-react": "^4.3.2",
|
||||||
"eslint": "^8.57.0",
|
"eslint": "^8.57.1",
|
||||||
"eslint-plugin-react-hooks": "^4.6.2",
|
"eslint-plugin-react-hooks": "^5.0.0",
|
||||||
"eslint-plugin-react-refresh": "^0.4.11",
|
"eslint-plugin-react-refresh": "^0.4.12",
|
||||||
"typescript": "^5.5.4",
|
"typescript": "^5.6.3",
|
||||||
"vite": "^5.4.2"
|
"vite": "^5.4.8"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 407 KiB After Width: | Height: | Size: 407 KiB |
|
Before Width: | Height: | Size: 557 B After Width: | Height: | Size: 557 B |
@@ -10,10 +10,12 @@ import { DeviceRoute } from "./routes/DeviceRoute/DeviceRoute";
|
|||||||
import { DevicesRoute } from "./routes/DevicesRoute";
|
import { DevicesRoute } from "./routes/DevicesRoute";
|
||||||
import { HomeRoute } from "./routes/HomeRoute";
|
import { HomeRoute } from "./routes/HomeRoute";
|
||||||
import { LoginRoute } from "./routes/LoginRoute";
|
import { LoginRoute } from "./routes/LoginRoute";
|
||||||
|
import { LogsRoute } from "./routes/LogsRoute";
|
||||||
import { NotFoundRoute } from "./routes/NotFoundRoute";
|
import { NotFoundRoute } from "./routes/NotFoundRoute";
|
||||||
import { PendingDevicesRoute } from "./routes/PendingDevicesRoute";
|
import { PendingDevicesRoute } from "./routes/PendingDevicesRoute";
|
||||||
import { RelaysListRoute } from "./routes/RelaysListRoute";
|
import { RelaysListRoute } from "./routes/RelaysListRoute";
|
||||||
import { BaseAuthenticatedPage } from "./widgets/BaseAuthenticatedPage";
|
import { BaseAuthenticatedPage } from "./widgets/BaseAuthenticatedPage";
|
||||||
|
import { OTARoute } from "./routes/OTARoute";
|
||||||
|
|
||||||
export function App() {
|
export function App() {
|
||||||
if (!AuthApi.SignedIn && !ServerApi.Config.auth_disabled)
|
if (!AuthApi.SignedIn && !ServerApi.Config.auth_disabled)
|
||||||
@@ -27,6 +29,8 @@ export function App() {
|
|||||||
<Route path="devices" element={<DevicesRoute />} />
|
<Route path="devices" element={<DevicesRoute />} />
|
||||||
<Route path="dev/:id" element={<DeviceRoute />} />
|
<Route path="dev/:id" element={<DeviceRoute />} />
|
||||||
<Route path="relays" element={<RelaysListRoute />} />
|
<Route path="relays" element={<RelaysListRoute />} />
|
||||||
|
<Route path="ota" element={<OTARoute />} />
|
||||||
|
<Route path="logs" element={<LogsRoute />} />
|
||||||
<Route path="*" element={<NotFoundRoute />} />
|
<Route path="*" element={<NotFoundRoute />} />
|
||||||
</Route>
|
</Route>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ export interface Device {
|
|||||||
validated: boolean;
|
validated: boolean;
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
relays: DeviceRelay[];
|
relays: DeviceRelay[];
|
||||||
|
desired_version?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface UpdatedInfo {
|
export interface UpdatedInfo {
|
||||||
|
|||||||
@@ -1,11 +1,10 @@
|
|||||||
import { Api } from "@mui/icons-material";
|
|
||||||
import { APIClient } from "./ApiClient";
|
import { APIClient } from "./ApiClient";
|
||||||
|
|
||||||
export class EnergyApi {
|
export class EnergyApi {
|
||||||
/**
|
/**
|
||||||
* Get current house consumption
|
* Get current grid consumption
|
||||||
*/
|
*/
|
||||||
static async CurrConsumption(): Promise<number> {
|
static async GridConsumption(): Promise<number> {
|
||||||
const data = await APIClient.exec({
|
const data = await APIClient.exec({
|
||||||
method: "GET",
|
method: "GET",
|
||||||
uri: "/energy/curr_consumption",
|
uri: "/energy/curr_consumption",
|
||||||
@@ -13,6 +12,18 @@ export class EnergyApi {
|
|||||||
return data.data.consumption;
|
return data.data.consumption;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get grid consumption history
|
||||||
|
*/
|
||||||
|
static async GridConsumptionHistory(): Promise<number[]> {
|
||||||
|
return (
|
||||||
|
await APIClient.exec({
|
||||||
|
method: "GET",
|
||||||
|
uri: "/energy/curr_consumption/history",
|
||||||
|
})
|
||||||
|
).data;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get current cached consumption
|
* Get current cached consumption
|
||||||
*/
|
*/
|
||||||
@@ -23,4 +34,28 @@ export class EnergyApi {
|
|||||||
});
|
});
|
||||||
return data.data.consumption;
|
return data.data.consumption;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get relays consumption
|
||||||
|
*/
|
||||||
|
static async RelaysConsumption(): Promise<number> {
|
||||||
|
return (
|
||||||
|
await APIClient.exec({
|
||||||
|
method: "GET",
|
||||||
|
uri: "/energy/relays_consumption",
|
||||||
|
})
|
||||||
|
).data.consumption;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get relays consumption history
|
||||||
|
*/
|
||||||
|
static async RelaysConsumptionHistory(): Promise<number[]> {
|
||||||
|
return (
|
||||||
|
await APIClient.exec({
|
||||||
|
method: "GET",
|
||||||
|
uri: "/energy/relays_consumption/history",
|
||||||
|
})
|
||||||
|
).data;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
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,6 +1,14 @@
|
|||||||
import { APIClient } from "./ApiClient";
|
import { APIClient } from "./ApiClient";
|
||||||
import { Device, DeviceRelay } from "./DeviceApi";
|
import { Device, DeviceRelay } from "./DeviceApi";
|
||||||
|
|
||||||
|
export interface RelayStatus {
|
||||||
|
id: string;
|
||||||
|
on: boolean;
|
||||||
|
for: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type RelaysStatus = Map<string, RelayStatus>;
|
||||||
|
|
||||||
export class RelayApi {
|
export class RelayApi {
|
||||||
/**
|
/**
|
||||||
* Get the full list of relays
|
* Get the full list of relays
|
||||||
@@ -49,4 +57,34 @@ export class RelayApi {
|
|||||||
uri: `/relay/${relay.id}`,
|
uri: `/relay/${relay.id}`,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the status of all relays
|
||||||
|
*/
|
||||||
|
static async GetRelaysStatus(): Promise<RelaysStatus> {
|
||||||
|
const data: any[] = (
|
||||||
|
await APIClient.exec({
|
||||||
|
method: "GET",
|
||||||
|
uri: `/relays/status`,
|
||||||
|
})
|
||||||
|
).data;
|
||||||
|
|
||||||
|
const map = new Map();
|
||||||
|
for (let r of data) {
|
||||||
|
map.set(r.id, r);
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the status of a single relay
|
||||||
|
*/
|
||||||
|
static async SingleStatus(relay: DeviceRelay): Promise<RelayStatus> {
|
||||||
|
return (
|
||||||
|
await APIClient.exec({
|
||||||
|
method: "GET",
|
||||||
|
uri: `/relay/${relay.id}/status`,
|
||||||
|
})
|
||||||
|
).data;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { APIClient } from "./ApiClient";
|
|||||||
export interface ServerConfig {
|
export interface ServerConfig {
|
||||||
auth_disabled: boolean;
|
auth_disabled: boolean;
|
||||||
constraints: ServerConstraint;
|
constraints: ServerConstraint;
|
||||||
|
unsecure_origin: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ServerConstraint {
|
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>Gender</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -3,7 +3,7 @@ import { TableCell, TableRow } from "@mui/material";
|
|||||||
export function DeviceInfoProperty(p: {
|
export function DeviceInfoProperty(p: {
|
||||||
icon?: React.ReactElement;
|
icon?: React.ReactElement;
|
||||||
label: string;
|
label: string;
|
||||||
value: string;
|
value: string | React.ReactElement;
|
||||||
color?: string;
|
color?: string;
|
||||||
}): React.ReactElement {
|
}): React.ReactElement {
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -14,9 +14,12 @@ import { EditDeviceRelaysDialog } from "../../dialogs/EditDeviceRelaysDialog";
|
|||||||
import { DeviceRouteCard } from "./DeviceRouteCard";
|
import { DeviceRouteCard } from "./DeviceRouteCard";
|
||||||
import { useConfirm } from "../../hooks/context_providers/ConfirmDialogProvider";
|
import { useConfirm } from "../../hooks/context_providers/ConfirmDialogProvider";
|
||||||
import { useLoadingMessage } from "../../hooks/context_providers/LoadingMessageProvider";
|
import { useLoadingMessage } from "../../hooks/context_providers/LoadingMessageProvider";
|
||||||
import { RelayApi } from "../../api/RelayApi";
|
import { RelayApi, RelayStatus } from "../../api/RelayApi";
|
||||||
import { useSnackbar } from "../../hooks/context_providers/SnackbarProvider";
|
import { useSnackbar } from "../../hooks/context_providers/SnackbarProvider";
|
||||||
import { useAlert } from "../../hooks/context_providers/AlertDialogProvider";
|
import { useAlert } from "../../hooks/context_providers/AlertDialogProvider";
|
||||||
|
import { AsyncWidget } from "../../widgets/AsyncWidget";
|
||||||
|
import { TimeWidget } from "../../widgets/TimeWidget";
|
||||||
|
import { BoolText } from "../../widgets/BoolText";
|
||||||
|
|
||||||
export function DeviceRelays(p: {
|
export function DeviceRelays(p: {
|
||||||
device: Device;
|
device: Device;
|
||||||
@@ -115,10 +118,37 @@ export function DeviceRelays(p: {
|
|||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<ListItemText primary={r.name} secondary={"TODO: status"} />
|
<ListItemText
|
||||||
|
primary={r.name}
|
||||||
|
secondary={<RelayEntryStatus relay={r} />}
|
||||||
|
/>
|
||||||
</ListItem>
|
</ListItem>
|
||||||
))}
|
))}
|
||||||
</DeviceRouteCard>
|
</DeviceRouteCard>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function RelayEntryStatus(
|
||||||
|
p: Readonly<{ relay: DeviceRelay }>
|
||||||
|
): React.ReactElement {
|
||||||
|
const [state, setState] = React.useState<RelayStatus | undefined>();
|
||||||
|
|
||||||
|
const load = async () => {
|
||||||
|
setState(await RelayApi.SingleStatus(p.relay));
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AsyncWidget
|
||||||
|
loadKey={p.relay.id}
|
||||||
|
load={load}
|
||||||
|
errMsg="Failed to load relay status!"
|
||||||
|
build={() => (
|
||||||
|
<>
|
||||||
|
<BoolText val={state!.on} positive="ON" negative="OFF" /> for{" "}
|
||||||
|
<TimeWidget diff time={state!.for} />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
|
import { Table, TableBody } from "@mui/material";
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import { Device, DeviceApi, DeviceState } from "../../api/DeviceApi";
|
import { Device, DeviceApi, DeviceState } from "../../api/DeviceApi";
|
||||||
import { AsyncWidget } from "../../widgets/AsyncWidget";
|
import { AsyncWidget } from "../../widgets/AsyncWidget";
|
||||||
import { DeviceRouteCard } from "./DeviceRouteCard";
|
import { BoolText } from "../../widgets/BoolText";
|
||||||
import { Table, TableBody } from "@mui/material";
|
|
||||||
import { DeviceInfoProperty } from "./DeviceInfoProperty";
|
|
||||||
import { timeDiff } from "../../widgets/TimeWidget";
|
import { timeDiff } from "../../widgets/TimeWidget";
|
||||||
|
import { DeviceInfoProperty } from "./DeviceInfoProperty";
|
||||||
|
import { DeviceRouteCard } from "./DeviceRouteCard";
|
||||||
|
|
||||||
export function DeviceStateBlock(p: { device: Device }): React.ReactElement {
|
export function DeviceStateBlock(p: { device: Device }): React.ReactElement {
|
||||||
const [state, setState] = React.useState<DeviceState>();
|
const [state, setState] = React.useState<DeviceState>();
|
||||||
@@ -32,7 +33,13 @@ function DeviceStateInner(p: { state: DeviceState }): React.ReactElement {
|
|||||||
<TableBody>
|
<TableBody>
|
||||||
<DeviceInfoProperty
|
<DeviceInfoProperty
|
||||||
label="Status"
|
label="Status"
|
||||||
value={p.state.online ? "Online" : "Offline"}
|
value={
|
||||||
|
<BoolText
|
||||||
|
val={p.state.online}
|
||||||
|
positive="ONLINE"
|
||||||
|
negative="Offline"
|
||||||
|
/>
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
<DeviceInfoProperty
|
<DeviceInfoProperty
|
||||||
label="Last ping"
|
label="Last ping"
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { IconButton, Table, TableBody, Tooltip } from "@mui/material";
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import { Device } from "../../api/DeviceApi";
|
import { Device } from "../../api/DeviceApi";
|
||||||
import { EditDeviceMetadataDialog } from "../../dialogs/EditDeviceMetadataDialog";
|
import { EditDeviceMetadataDialog } from "../../dialogs/EditDeviceMetadataDialog";
|
||||||
|
import { BoolText } from "../../widgets/BoolText";
|
||||||
import { formatDate } from "../../widgets/TimeWidget";
|
import { formatDate } from "../../widgets/TimeWidget";
|
||||||
import { DeviceInfoProperty } from "./DeviceInfoProperty";
|
import { DeviceInfoProperty } from "./DeviceInfoProperty";
|
||||||
import { DeviceRouteCard } from "./DeviceRouteCard";
|
import { DeviceRouteCard } from "./DeviceRouteCard";
|
||||||
@@ -40,6 +41,10 @@ export function GeneralDeviceInfo(p: {
|
|||||||
value={p.device.info.reference}
|
value={p.device.info.reference}
|
||||||
/>
|
/>
|
||||||
<DeviceInfoProperty label="Version" value={p.device.info.version} />
|
<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="Name" value={p.device.name} />
|
||||||
<DeviceInfoProperty
|
<DeviceInfoProperty
|
||||||
label="Description"
|
label="Description"
|
||||||
@@ -55,8 +60,9 @@ export function GeneralDeviceInfo(p: {
|
|||||||
/>
|
/>
|
||||||
<DeviceInfoProperty
|
<DeviceInfoProperty
|
||||||
label="Enabled"
|
label="Enabled"
|
||||||
value={p.device.enabled ? "YES" : "NO"}
|
value={
|
||||||
color={p.device.enabled ? "green" : "red"}
|
<BoolText val={p.device.enabled} positive="YES" negative="NO" />
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
<DeviceInfoProperty
|
<DeviceInfoProperty
|
||||||
label="Maximum number of relays"
|
label="Maximum number of relays"
|
||||||
|
|||||||
@@ -15,10 +15,11 @@ import React from "react";
|
|||||||
import { Link, useNavigate } from "react-router-dom";
|
import { Link, useNavigate } from "react-router-dom";
|
||||||
import { Device, DeviceApi, DevicesState, DeviceURL } from "../api/DeviceApi";
|
import { Device, DeviceApi, DevicesState, DeviceURL } from "../api/DeviceApi";
|
||||||
import { AsyncWidget } from "../widgets/AsyncWidget";
|
import { AsyncWidget } from "../widgets/AsyncWidget";
|
||||||
|
import { BoolText } from "../widgets/BoolText";
|
||||||
import { SolarEnergyRouteContainer } from "../widgets/SolarEnergyRouteContainer";
|
import { SolarEnergyRouteContainer } from "../widgets/SolarEnergyRouteContainer";
|
||||||
import { TimeWidget } from "../widgets/TimeWidget";
|
import { TimeWidget } from "../widgets/TimeWidget";
|
||||||
|
|
||||||
export function DevicesRoute(): React.ReactElement {
|
export function DevicesRoute(p: { homeWidget?: boolean }): React.ReactElement {
|
||||||
const loadKey = React.useRef(1);
|
const loadKey = React.useRef(1);
|
||||||
|
|
||||||
const [list, setList] = React.useState<Device[] | undefined>();
|
const [list, setList] = React.useState<Device[] | undefined>();
|
||||||
@@ -37,6 +38,7 @@ export function DevicesRoute(): React.ReactElement {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<SolarEnergyRouteContainer
|
<SolarEnergyRouteContainer
|
||||||
|
homeWidget={p.homeWidget}
|
||||||
label="Devices"
|
label="Devices"
|
||||||
actions={
|
actions={
|
||||||
<Tooltip title="Refresh table">
|
<Tooltip title="Refresh table">
|
||||||
@@ -80,12 +82,12 @@ function ValidatedDevicesList(p: {
|
|||||||
<TableHead>
|
<TableHead>
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableCell>#</TableCell>
|
<TableCell>#</TableCell>
|
||||||
<TableCell>Model</TableCell>
|
<TableCell align="center">Model</TableCell>
|
||||||
<TableCell>Version</TableCell>
|
<TableCell align="center">Version</TableCell>
|
||||||
<TableCell>Max number of relays</TableCell>
|
<TableCell align="center">Max relays</TableCell>
|
||||||
<TableCell>Created</TableCell>
|
<TableCell align="center">Created</TableCell>
|
||||||
<TableCell>Updated</TableCell>
|
<TableCell align="center">Updated</TableCell>
|
||||||
<TableCell>Status</TableCell>
|
<TableCell align="center">Status</TableCell>
|
||||||
<TableCell></TableCell>
|
<TableCell></TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHead>
|
</TableHead>
|
||||||
@@ -99,21 +101,21 @@ function ValidatedDevicesList(p: {
|
|||||||
<TableCell component="th" scope="row">
|
<TableCell component="th" scope="row">
|
||||||
{dev.id}
|
{dev.id}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>{dev.info.reference}</TableCell>
|
<TableCell align="center">{dev.info.reference}</TableCell>
|
||||||
<TableCell>{dev.info.version}</TableCell>
|
<TableCell align="center">{dev.info.version}</TableCell>
|
||||||
<TableCell>{dev.info.max_relays}</TableCell>
|
<TableCell align="center">{dev.info.max_relays}</TableCell>
|
||||||
<TableCell>
|
<TableCell align="center">
|
||||||
<TimeWidget time={dev.time_create} />
|
<TimeWidget time={dev.time_create} />
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell align="center">
|
||||||
<TimeWidget time={dev.time_update} />
|
<TimeWidget time={dev.time_update} />
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell align="center">
|
<TableCell align="center">
|
||||||
{p.states.get(dev.id)!.online ? (
|
<BoolText
|
||||||
<strong>Online</strong>
|
val={p.states.get(dev.id)!.online}
|
||||||
) : (
|
positive="Online"
|
||||||
<em>Offline</em>
|
negative="Offline"
|
||||||
)}
|
/>
|
||||||
<br />
|
<br />
|
||||||
<TimeWidget diff time={p.states.get(dev.id)!.last_ping} />
|
<TimeWidget diff time={p.states.get(dev.id)!.last_ping} />
|
||||||
</TableCell>
|
</TableCell>
|
||||||
|
|||||||
@@ -2,6 +2,9 @@ import { Typography } from "@mui/material";
|
|||||||
import { CurrConsumptionWidget } from "./HomeRoute/CurrConsumptionWidget";
|
import { CurrConsumptionWidget } from "./HomeRoute/CurrConsumptionWidget";
|
||||||
import Grid from "@mui/material/Grid2";
|
import Grid from "@mui/material/Grid2";
|
||||||
import { CachedConsumptionWidget } from "./HomeRoute/CachedConsumptionWidget";
|
import { CachedConsumptionWidget } from "./HomeRoute/CachedConsumptionWidget";
|
||||||
|
import { RelayConsumptionWidget } from "./HomeRoute/RelayConsumptionWidget";
|
||||||
|
import { RelaysListRoute } from "./RelaysListRoute";
|
||||||
|
import { DevicesRoute } from "./DevicesRoute";
|
||||||
|
|
||||||
export function HomeRoute(): React.ReactElement {
|
export function HomeRoute(): React.ReactElement {
|
||||||
return (
|
return (
|
||||||
@@ -18,9 +21,20 @@ export function HomeRoute(): React.ReactElement {
|
|||||||
<Grid size={{ xs: 12, sm: 6, lg: 3 }}>
|
<Grid size={{ xs: 12, sm: 6, lg: 3 }}>
|
||||||
<CurrConsumptionWidget />
|
<CurrConsumptionWidget />
|
||||||
</Grid>
|
</Grid>
|
||||||
|
<Grid size={{ xs: 12, sm: 6, lg: 3 }}>
|
||||||
|
<RelayConsumptionWidget />
|
||||||
|
</Grid>
|
||||||
<Grid size={{ xs: 12, sm: 6, lg: 3 }}>
|
<Grid size={{ xs: 12, sm: 6, lg: 3 }}>
|
||||||
<CachedConsumptionWidget />
|
<CachedConsumptionWidget />
|
||||||
</Grid>
|
</Grid>
|
||||||
|
|
||||||
|
<Grid size={{ xs: 12, sm: 12, lg: 9 }}>
|
||||||
|
<DevicesRoute homeWidget />
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
<Grid size={{ xs: 12, sm: 12, lg: 9 }}>
|
||||||
|
<RelaysListRoute homeWidget />
|
||||||
|
</Grid>
|
||||||
</Grid>
|
</Grid>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -26,12 +26,6 @@ export function CachedConsumptionWidget(): React.ReactElement {
|
|||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<StatCard
|
<StatCard title="Cached consumption" value={val?.toString() ?? "Loading"} />
|
||||||
title="Cached consumption"
|
|
||||||
data={[]}
|
|
||||||
interval="Current data"
|
|
||||||
trend="neutral"
|
|
||||||
value={val?.toString() ?? "Loading"}
|
|
||||||
/>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,11 +7,14 @@ export function CurrConsumptionWidget(): React.ReactElement {
|
|||||||
const snackbar = useSnackbar();
|
const snackbar = useSnackbar();
|
||||||
|
|
||||||
const [val, setVal] = React.useState<undefined | number>();
|
const [val, setVal] = React.useState<undefined | number>();
|
||||||
|
const [history, setHistory] = React.useState<number[] | undefined>();
|
||||||
|
|
||||||
const refresh = async () => {
|
const refresh = async () => {
|
||||||
try {
|
try {
|
||||||
const s = await EnergyApi.CurrConsumption();
|
const s = await EnergyApi.GridConsumption();
|
||||||
|
const history = await EnergyApi.GridConsumptionHistory();
|
||||||
setVal(s);
|
setVal(s);
|
||||||
|
setHistory(history);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(e);
|
console.error(e);
|
||||||
snackbar("Failed to refresh current consumption!");
|
snackbar("Failed to refresh current consumption!");
|
||||||
@@ -19,7 +22,6 @@ export function CurrConsumptionWidget(): React.ReactElement {
|
|||||||
};
|
};
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
refresh();
|
|
||||||
const i = setInterval(() => refresh(), 3000);
|
const i = setInterval(() => refresh(), 3000);
|
||||||
|
|
||||||
return () => clearInterval(i);
|
return () => clearInterval(i);
|
||||||
@@ -28,9 +30,8 @@ export function CurrConsumptionWidget(): React.ReactElement {
|
|||||||
return (
|
return (
|
||||||
<StatCard
|
<StatCard
|
||||||
title="Current consumption"
|
title="Current consumption"
|
||||||
data={[]}
|
data={history ?? []}
|
||||||
interval="Current data"
|
interval="Last day"
|
||||||
trend="neutral"
|
|
||||||
value={val?.toString() ?? "Loading"}
|
value={val?.toString() ?? "Loading"}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { EnergyApi } from "../../api/EnergyApi";
|
||||||
|
import { useSnackbar } from "../../hooks/context_providers/SnackbarProvider";
|
||||||
|
import StatCard from "../../widgets/StatCard";
|
||||||
|
|
||||||
|
export function RelayConsumptionWidget(): React.ReactElement {
|
||||||
|
const snackbar = useSnackbar();
|
||||||
|
|
||||||
|
const [val, setVal] = React.useState<undefined | number>();
|
||||||
|
const [history, setHistory] = React.useState<number[] | undefined>();
|
||||||
|
|
||||||
|
const refresh = async () => {
|
||||||
|
try {
|
||||||
|
const s = await EnergyApi.RelaysConsumption();
|
||||||
|
const history = await EnergyApi.RelaysConsumptionHistory();
|
||||||
|
setVal(s);
|
||||||
|
setHistory(history);
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
snackbar("Failed to refresh current relays consumption!");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
const i = setInterval(() => refresh(), 3000);
|
||||||
|
|
||||||
|
return () => clearInterval(i);
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<StatCard
|
||||||
|
title="Relays consumption"
|
||||||
|
data={history ?? []}
|
||||||
|
interval="Last day"
|
||||||
|
value={val?.toString() ?? "Loading"}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -62,7 +62,7 @@ export function LoginRoute() {
|
|||||||
<Grid
|
<Grid
|
||||||
size={{ sm: 4, md: 7, xs: false }}
|
size={{ sm: 4, md: 7, xs: false }}
|
||||||
sx={{
|
sx={{
|
||||||
backgroundImage: 'url("/sun.jpg")',
|
backgroundImage: 'url("/assets/sun.jpg")',
|
||||||
backgroundColor: (t) =>
|
backgroundColor: (t) =>
|
||||||
t.palette.mode === "light"
|
t.palette.mode === "light"
|
||||||
? t.palette.grey[50]
|
? t.palette.grey[50]
|
||||||
|
|||||||
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
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 RefreshIcon from "@mui/icons-material/Refresh";
|
||||||
import {
|
import {
|
||||||
IconButton,
|
IconButton,
|
||||||
@@ -9,20 +10,32 @@ import {
|
|||||||
TableHead,
|
TableHead,
|
||||||
TableRow,
|
TableRow,
|
||||||
Tooltip,
|
Tooltip,
|
||||||
|
Typography,
|
||||||
} from "@mui/material";
|
} from "@mui/material";
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import { DeviceRelay } from "../api/DeviceApi";
|
import { useNavigate } from "react-router-dom";
|
||||||
import { RelayApi } from "../api/RelayApi";
|
import { Device, DeviceApi, DeviceRelay, DeviceURL } from "../api/DeviceApi";
|
||||||
|
import { RelayApi, RelaysStatus } from "../api/RelayApi";
|
||||||
import { AsyncWidget } from "../widgets/AsyncWidget";
|
import { AsyncWidget } from "../widgets/AsyncWidget";
|
||||||
|
import { BoolText } from "../widgets/BoolText";
|
||||||
import { SolarEnergyRouteContainer } from "../widgets/SolarEnergyRouteContainer";
|
import { SolarEnergyRouteContainer } from "../widgets/SolarEnergyRouteContainer";
|
||||||
|
import { TimeWidget } from "../widgets/TimeWidget";
|
||||||
|
import { CopyToClipboard } from "../widgets/CopyToClipboard";
|
||||||
|
import { ServerApi } from "../api/ServerApi";
|
||||||
|
|
||||||
export function RelaysListRoute(): React.ReactElement {
|
export function RelaysListRoute(p: {
|
||||||
|
homeWidget?: boolean;
|
||||||
|
}): React.ReactElement {
|
||||||
const loadKey = React.useRef(1);
|
const loadKey = React.useRef(1);
|
||||||
|
|
||||||
const [list, setList] = React.useState<DeviceRelay[] | undefined>();
|
const [list, setList] = React.useState<DeviceRelay[] | undefined>();
|
||||||
|
const [devices, setDevices] = React.useState<Device[] | undefined>();
|
||||||
|
const [status, setStatus] = React.useState<RelaysStatus | undefined>();
|
||||||
|
|
||||||
const load = async () => {
|
const load = async () => {
|
||||||
setList(await RelayApi.GetList());
|
setList(await RelayApi.GetList());
|
||||||
|
setDevices(await DeviceApi.ValidatedList());
|
||||||
|
setStatus(await RelayApi.GetRelaysStatus());
|
||||||
|
|
||||||
list?.sort((a, b) => b.priority - a.priority);
|
list?.sort((a, b) => b.priority - a.priority);
|
||||||
};
|
};
|
||||||
@@ -33,34 +46,57 @@ export function RelaysListRoute(): React.ReactElement {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SolarEnergyRouteContainer
|
<>
|
||||||
label="Relays list"
|
<SolarEnergyRouteContainer
|
||||||
actions={
|
label="Relays list"
|
||||||
<Tooltip title="Refresh list">
|
homeWidget={p.homeWidget}
|
||||||
<IconButton onClick={reload}>
|
actions={
|
||||||
<RefreshIcon />
|
<Tooltip title="Refresh list">
|
||||||
</IconButton>
|
<IconButton onClick={reload}>
|
||||||
</Tooltip>
|
<RefreshIcon />
|
||||||
}
|
</IconButton>
|
||||||
>
|
</Tooltip>
|
||||||
<AsyncWidget
|
}
|
||||||
loadKey={loadKey.current}
|
>
|
||||||
ready={!!list}
|
<AsyncWidget
|
||||||
errMsg="Failed to load the list of relays!"
|
loadKey={loadKey.current}
|
||||||
load={load}
|
ready={!!list}
|
||||||
build={() => <RelaysList onReload={reload} list={list!} />}
|
errMsg="Failed to load the list of relays!"
|
||||||
/>
|
load={load}
|
||||||
</SolarEnergyRouteContainer>
|
build={() => (
|
||||||
|
<RelaysList
|
||||||
|
onReload={reload}
|
||||||
|
list={list!}
|
||||||
|
devices={devices!}
|
||||||
|
status={status!}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</SolarEnergyRouteContainer>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function RelaysList(p: {
|
function RelaysList(p: {
|
||||||
list: DeviceRelay[];
|
list: DeviceRelay[];
|
||||||
|
devices: Device[];
|
||||||
|
status: RelaysStatus;
|
||||||
onReload: () => void;
|
onReload: () => void;
|
||||||
}): React.ReactElement {
|
}): React.ReactElement {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
const openDevicePage = (relay: DeviceRelay) => {
|
||||||
|
const dev = p.devices.find((d) => d.relays.find((r) => r.id === relay.id));
|
||||||
|
navigate(DeviceURL(dev!));
|
||||||
|
};
|
||||||
|
|
||||||
|
if (p.list.length === 0) {
|
||||||
|
return <Typography>There is no configured relay yet!</Typography>;
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TableContainer component={Paper}>
|
<TableContainer component={Paper}>
|
||||||
<Table sx={{ minWidth: 650 }} aria-label="simple table">
|
<Table sx={{ minWidth: 650 }}>
|
||||||
<TableHead>
|
<TableHead>
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableCell>Name</TableCell>
|
<TableCell>Name</TableCell>
|
||||||
@@ -68,6 +104,7 @@ function RelaysList(p: {
|
|||||||
<TableCell>Priority</TableCell>
|
<TableCell>Priority</TableCell>
|
||||||
<TableCell>Consumption</TableCell>
|
<TableCell>Consumption</TableCell>
|
||||||
<TableCell>Status</TableCell>
|
<TableCell>Status</TableCell>
|
||||||
|
<TableCell></TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHead>
|
</TableHead>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
@@ -75,18 +112,37 @@ function RelaysList(p: {
|
|||||||
<TableRow
|
<TableRow
|
||||||
key={row.name}
|
key={row.name}
|
||||||
sx={{ "&:last-child td, &:last-child th": { border: 0 } }}
|
sx={{ "&:last-child td, &:last-child th": { border: 0 } }}
|
||||||
|
hover
|
||||||
|
onDoubleClick={() => openDevicePage(row)}
|
||||||
>
|
>
|
||||||
<TableCell>{row.name}</TableCell>
|
<TableCell>{row.name}</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
{row.enabled ? (
|
<BoolText val={row.enabled} positive="YES" negative="NO" />
|
||||||
<span style={{ color: "green" }}>YES</span>
|
|
||||||
) : (
|
|
||||||
<span style={{ color: "red" }}>NO</span>
|
|
||||||
)}
|
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>{row.priority}</TableCell>
|
<TableCell>{row.priority}</TableCell>
|
||||||
<TableCell>{row.consumption}</TableCell>
|
<TableCell>{row.consumption}</TableCell>
|
||||||
<TableCell>TODO</TableCell>
|
<TableCell>
|
||||||
|
<BoolText
|
||||||
|
val={p.status.get(row.id)!.on}
|
||||||
|
positive="ON"
|
||||||
|
negative="OFF"
|
||||||
|
/>{" "}
|
||||||
|
for <TimeWidget diff time={p.status.get(row.id)!.for} />
|
||||||
|
</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>
|
</TableRow>
|
||||||
))}
|
))}
|
||||||
</TableBody>
|
</TableBody>
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { SemVer } from "semver";
|
||||||
import { LenConstraint } from "../api/ServerApi";
|
import { LenConstraint } from "../api/ServerApi";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -6,3 +7,16 @@ import { LenConstraint } from "../api/ServerApi";
|
|||||||
export function lenValid(s: string, c: LenConstraint): boolean {
|
export function lenValid(s: string, c: LenConstraint): boolean {
|
||||||
return s.length >= c.min && s.length <= c.max;
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
11
central_frontend/src/widgets/BoolText.tsx
Normal file
11
central_frontend/src/widgets/BoolText.tsx
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
export function BoolText(p: {
|
||||||
|
val: boolean;
|
||||||
|
positive: string;
|
||||||
|
negative: string;
|
||||||
|
}): React.ReactElement {
|
||||||
|
return p.val ? (
|
||||||
|
<span style={{ color: "green" }}>{p.positive}</span>
|
||||||
|
) : (
|
||||||
|
<span style={{ color: "red" }}>{p.negative}</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,4 +1,11 @@
|
|||||||
import { mdiChip, mdiElectricSwitch, mdiHome, mdiNewBox } from "@mdi/js";
|
import {
|
||||||
|
mdiChip,
|
||||||
|
mdiElectricSwitch,
|
||||||
|
mdiHome,
|
||||||
|
mdiMonitorArrowDown,
|
||||||
|
mdiNewBox,
|
||||||
|
mdiNotebookMultiple,
|
||||||
|
} from "@mdi/js";
|
||||||
import Icon from "@mdi/react";
|
import Icon from "@mdi/react";
|
||||||
import {
|
import {
|
||||||
List,
|
List,
|
||||||
@@ -35,6 +42,16 @@ export function SolarEnergyNavList(): React.ReactElement {
|
|||||||
uri="/relays"
|
uri="/relays"
|
||||||
icon={<Icon path={mdiElectricSwitch} size={1} />}
|
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} />}
|
||||||
|
/>
|
||||||
</List>
|
</List>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,20 +4,28 @@ import React, { PropsWithChildren } from "react";
|
|||||||
export function SolarEnergyRouteContainer(
|
export function SolarEnergyRouteContainer(
|
||||||
p: {
|
p: {
|
||||||
label: string;
|
label: string;
|
||||||
|
homeWidget?: boolean;
|
||||||
actions?: React.ReactElement;
|
actions?: React.ReactElement;
|
||||||
} & PropsWithChildren
|
} & PropsWithChildren
|
||||||
): React.ReactElement {
|
): React.ReactElement {
|
||||||
return (
|
return (
|
||||||
<div style={{ margin: "50px" }}>
|
<div
|
||||||
|
style={{
|
||||||
|
margin: p.homeWidget ? "0px" : "50px",
|
||||||
|
flex: 1,
|
||||||
|
maxWidth: "1300px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
display: "flex",
|
display: "flex",
|
||||||
justifyContent: "space-between",
|
justifyContent: "space-between",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
marginBottom: "20px",
|
marginBottom: "20px",
|
||||||
|
flex: 1,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Typography variant="h4">{p.label}</Typography>
|
<Typography variant={p.homeWidget ? "h6" : "h4"}>{p.label}</Typography>
|
||||||
{p.actions ?? <></>}
|
{p.actions ?? <></>}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -11,24 +11,25 @@ import { areaElementClasses } from "@mui/x-charts/LineChart";
|
|||||||
export type StatCardProps = {
|
export type StatCardProps = {
|
||||||
title: string;
|
title: string;
|
||||||
value: string;
|
value: string;
|
||||||
interval: string;
|
interval?: string;
|
||||||
trend: "up" | "down" | "neutral";
|
trend?: "up" | "down" | "neutral";
|
||||||
data: number[];
|
data?: number[];
|
||||||
};
|
};
|
||||||
|
|
||||||
function getDaysInMonth(month: number, year: number) {
|
function last24Hours(): string[] {
|
||||||
const date = new Date(year, month, 0);
|
let res: Array<string> = [];
|
||||||
const monthName = date.toLocaleDateString("en-US", {
|
|
||||||
month: "short",
|
for (let index = 0; index < 3600 * 24; index += 60 * 10) {
|
||||||
});
|
const date = new Date();
|
||||||
const daysInMonth = date.getDate();
|
date.setTime(date.getTime() - index * 1000);
|
||||||
const days = [];
|
res.push(date.getHours() + "h" + date.getMinutes());
|
||||||
let i = 1;
|
|
||||||
while (days.length < daysInMonth) {
|
|
||||||
days.push(`${monthName} ${i}`);
|
|
||||||
i += 1;
|
|
||||||
}
|
}
|
||||||
return days;
|
|
||||||
|
res.reverse();
|
||||||
|
|
||||||
|
console.log(res);
|
||||||
|
|
||||||
|
return res;
|
||||||
}
|
}
|
||||||
|
|
||||||
function AreaGradient({ color, id }: { color: string; id: string }) {
|
function AreaGradient({ color, id }: { color: string; id: string }) {
|
||||||
@@ -50,7 +51,6 @@ export default function StatCard({
|
|||||||
data,
|
data,
|
||||||
}: StatCardProps) {
|
}: StatCardProps) {
|
||||||
const theme = useTheme();
|
const theme = useTheme();
|
||||||
const daysInWeek = getDaysInMonth(4, 2024);
|
|
||||||
|
|
||||||
const trendColors = {
|
const trendColors = {
|
||||||
up:
|
up:
|
||||||
@@ -73,8 +73,8 @@ export default function StatCard({
|
|||||||
neutral: "default" as const,
|
neutral: "default" as const,
|
||||||
};
|
};
|
||||||
|
|
||||||
const color = labelColors[trend];
|
const color = labelColors[trend ?? "neutral"];
|
||||||
const chartColor = trendColors[trend];
|
const chartColor = trendColors[trend ?? "neutral"];
|
||||||
const trendValues = { up: "+25%", down: "-25%", neutral: "+5%" };
|
const trendValues = { up: "+25%", down: "-25%", neutral: "+5%" };
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -95,31 +95,38 @@ export default function StatCard({
|
|||||||
<Typography variant="h4" component="p">
|
<Typography variant="h4" component="p">
|
||||||
{value}
|
{value}
|
||||||
</Typography>
|
</Typography>
|
||||||
<Chip size="small" color={color} label={trendValues[trend]} />
|
{trend && (
|
||||||
|
<Chip size="small" color={color} label={trendValues[trend]} />
|
||||||
|
)}
|
||||||
</Stack>
|
</Stack>
|
||||||
<Typography variant="caption" sx={{ color: "text.secondary" }}>
|
<Typography variant="caption" sx={{ color: "text.secondary" }}>
|
||||||
{interval}
|
{interval}
|
||||||
</Typography>
|
</Typography>
|
||||||
</Stack>
|
</Stack>
|
||||||
<Box sx={{ width: "100%", height: 50 }}>
|
<Box sx={{ width: "100%", height: 100 }}>
|
||||||
<SparkLineChart
|
{data && interval && (
|
||||||
colors={[chartColor]}
|
<SparkLineChart
|
||||||
data={data}
|
colors={[chartColor]}
|
||||||
area
|
data={data}
|
||||||
showHighlight
|
area
|
||||||
showTooltip
|
showHighlight
|
||||||
xAxis={{
|
showTooltip
|
||||||
scaleType: "band",
|
xAxis={{
|
||||||
data: daysInWeek, // Use the correct property 'data' for xAxis
|
scaleType: "band",
|
||||||
}}
|
data: last24Hours(),
|
||||||
sx={{
|
}}
|
||||||
[`& .${areaElementClasses.root}`]: {
|
sx={{
|
||||||
fill: `url(#area-gradient-${value})`,
|
[`& .${areaElementClasses.root}`]: {
|
||||||
},
|
fill: `url(#area-gradient-${value})`,
|
||||||
}}
|
},
|
||||||
>
|
}}
|
||||||
<AreaGradient color={chartColor} id={`area-gradient-${value}`} />
|
>
|
||||||
</SparkLineChart>
|
<AreaGradient
|
||||||
|
color={chartColor}
|
||||||
|
id={`area-gradient-${value}`}
|
||||||
|
/>
|
||||||
|
</SparkLineChart>
|
||||||
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
</Stack>
|
</Stack>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
|
|||||||
@@ -61,7 +61,10 @@ export function TimeWidget(p: {
|
|||||||
}): React.ReactElement {
|
}): React.ReactElement {
|
||||||
if (!p.time) return <></>;
|
if (!p.time) return <></>;
|
||||||
return (
|
return (
|
||||||
<Tooltip title={formatDate(p.time)} arrow>
|
<Tooltip
|
||||||
|
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)}</span>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
);
|
);
|
||||||
|
|||||||
169
custom_consumption/Cargo.lock
generated
169
custom_consumption/Cargo.lock
generated
@@ -176,9 +176,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "anstyle"
|
name = "anstyle"
|
||||||
version = "1.0.7"
|
version = "1.0.8"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "038dfcf04a5feb68e9c60b21c9625a54c2c0616e79b72b0fd87075a056ae1d1b"
|
checksum = "1bec1de6f59aedf83baf9ff929c98f2ad654b97c9510f4e70cf6f661d49fd5b1"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "anstyle-parse"
|
name = "anstyle-parse"
|
||||||
@@ -619,6 +619,12 @@ version = "1.5.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b"
|
checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "byteorder-lite"
|
||||||
|
version = "0.1.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "bytes"
|
name = "bytes"
|
||||||
version = "1.6.0"
|
version = "1.6.0"
|
||||||
@@ -691,9 +697,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "clap"
|
name = "clap"
|
||||||
version = "4.5.8"
|
version = "4.5.20"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "84b3edb18336f4df585bc9aa31dd99c036dfa5dc5e9a2939a722a188f3a8970d"
|
checksum = "b97f376d85a664d5837dbae44bf546e6477a679ff6610010f17276f686d867e8"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"clap_builder",
|
"clap_builder",
|
||||||
"clap_derive",
|
"clap_derive",
|
||||||
@@ -701,9 +707,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "clap_builder"
|
name = "clap_builder"
|
||||||
version = "4.5.8"
|
version = "4.5.20"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "c1c09dd5ada6c6c78075d6fd0da3f90d8080651e2d6cc8eb2f1aaa4034ced708"
|
checksum = "19bc80abd44e4bed93ca373a0704ccbd1b710dc5749406201bb018272808dc54"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anstream",
|
"anstream",
|
||||||
"anstyle",
|
"anstyle",
|
||||||
@@ -713,9 +719,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "clap_derive"
|
name = "clap_derive"
|
||||||
version = "4.5.8"
|
version = "4.5.18"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "2bac35c6dafb060fd4d275d9a4ffae97917c13a6327903a8be2153cd964f7085"
|
checksum = "4ac6a0c7b1a9e9a5186361f67dfa1b88213572f427fb9ab038efb2bd8c582dab"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"heck",
|
"heck",
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
@@ -738,36 +744,6 @@ dependencies = [
|
|||||||
"error-code",
|
"error-code",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "cocoa"
|
|
||||||
version = "0.25.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "f6140449f97a6e97f9511815c5632d84c8aacf8ac271ad77c559218161a1373c"
|
|
||||||
dependencies = [
|
|
||||||
"bitflags 1.3.2",
|
|
||||||
"block",
|
|
||||||
"cocoa-foundation",
|
|
||||||
"core-foundation",
|
|
||||||
"core-graphics",
|
|
||||||
"foreign-types",
|
|
||||||
"libc",
|
|
||||||
"objc",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "cocoa-foundation"
|
|
||||||
version = "0.1.2"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "8c6234cbb2e4c785b456c0644748b1ac416dd045799740356f8363dfe00c93f7"
|
|
||||||
dependencies = [
|
|
||||||
"bitflags 1.3.2",
|
|
||||||
"block",
|
|
||||||
"core-foundation",
|
|
||||||
"core-graphics-types",
|
|
||||||
"libc",
|
|
||||||
"objc",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "codespan-reporting"
|
name = "codespan-reporting"
|
||||||
version = "0.11.1"
|
version = "0.11.1"
|
||||||
@@ -778,12 +754,6 @@ dependencies = [
|
|||||||
"unicode-width",
|
"unicode-width",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "color_quant"
|
|
||||||
version = "1.1.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b"
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "colorchoice"
|
name = "colorchoice"
|
||||||
version = "1.0.1"
|
version = "1.0.1"
|
||||||
@@ -985,21 +955,22 @@ checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ecolor"
|
name = "ecolor"
|
||||||
version = "0.27.2"
|
version = "0.28.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "20930a432bbd57a6d55e07976089708d4893f3d556cf42a0d79e9e321fa73b10"
|
checksum = "2e6b451ff1143f6de0f33fc7f1b68fecfd2c7de06e104de96c4514de3f5396f8"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bytemuck",
|
"bytemuck",
|
||||||
|
"emath",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "eframe"
|
name = "eframe"
|
||||||
version = "0.27.2"
|
version = "0.28.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "020e2ccef6bbcec71dbc542f7eed64a5846fc3076727f5746da8fd307c91bab2"
|
checksum = "6490ef800b2e41ee129b1f32f9ac15f713233fe3bc18e241a1afe1e4fb6811e0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"ahash",
|
||||||
"bytemuck",
|
"bytemuck",
|
||||||
"cocoa",
|
|
||||||
"document-features",
|
"document-features",
|
||||||
"egui",
|
"egui",
|
||||||
"egui-wgpu",
|
"egui-wgpu",
|
||||||
@@ -1011,13 +982,14 @@ dependencies = [
|
|||||||
"image",
|
"image",
|
||||||
"js-sys",
|
"js-sys",
|
||||||
"log",
|
"log",
|
||||||
"objc",
|
"objc2 0.5.2",
|
||||||
|
"objc2-app-kit",
|
||||||
|
"objc2-foundation",
|
||||||
"parking_lot",
|
"parking_lot",
|
||||||
"percent-encoding",
|
"percent-encoding",
|
||||||
"raw-window-handle 0.5.2",
|
"raw-window-handle 0.5.2",
|
||||||
"raw-window-handle 0.6.2",
|
"raw-window-handle 0.6.2",
|
||||||
"static_assertions",
|
"static_assertions",
|
||||||
"thiserror",
|
|
||||||
"wasm-bindgen",
|
"wasm-bindgen",
|
||||||
"wasm-bindgen-futures",
|
"wasm-bindgen-futures",
|
||||||
"web-sys",
|
"web-sys",
|
||||||
@@ -1028,12 +1000,13 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "egui"
|
name = "egui"
|
||||||
version = "0.27.2"
|
version = "0.28.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "584c5d1bf9a67b25778a3323af222dbe1a1feb532190e103901187f92c7fe29a"
|
checksum = "20c97e70a2768de630f161bb5392cbd3874fcf72868f14df0e002e82e06cb798"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"accesskit",
|
"accesskit",
|
||||||
"ahash",
|
"ahash",
|
||||||
|
"emath",
|
||||||
"epaint",
|
"epaint",
|
||||||
"log",
|
"log",
|
||||||
"nohash-hasher",
|
"nohash-hasher",
|
||||||
@@ -1041,10 +1014,11 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "egui-wgpu"
|
name = "egui-wgpu"
|
||||||
version = "0.27.2"
|
version = "0.28.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "469ff65843f88a702b731a1532b7d03b0e8e96d283e70f3a22b0e06c46cb9b37"
|
checksum = "47c7a7c707877c3362a321ebb4f32be811c0b91f7aebf345fb162405c0218b4c"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"ahash",
|
||||||
"bytemuck",
|
"bytemuck",
|
||||||
"document-features",
|
"document-features",
|
||||||
"egui",
|
"egui",
|
||||||
@@ -1059,11 +1033,12 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "egui-winit"
|
name = "egui-winit"
|
||||||
version = "0.27.2"
|
version = "0.28.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "2e3da0cbe020f341450c599b35b92de4af7b00abde85624fd16f09c885573609"
|
checksum = "fac4e066af341bf92559f60dbdf2020b2a03c963415349af5f3f8d79ff7a4926"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"accesskit_winit",
|
"accesskit_winit",
|
||||||
|
"ahash",
|
||||||
"arboard",
|
"arboard",
|
||||||
"egui",
|
"egui",
|
||||||
"log",
|
"log",
|
||||||
@@ -1076,10 +1051,11 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "egui_glow"
|
name = "egui_glow"
|
||||||
version = "0.27.2"
|
version = "0.28.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "e0e5d975f3c86edc3d35b1db88bb27c15dde7c55d3b5af164968ab5ede3f44ca"
|
checksum = "4e2bdc8b38cfa17cc712c4ae079e30c71c00cd4c2763c9e16dc7860a02769103"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"ahash",
|
||||||
"bytemuck",
|
"bytemuck",
|
||||||
"egui",
|
"egui",
|
||||||
"glow",
|
"glow",
|
||||||
@@ -1092,9 +1068,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "emath"
|
name = "emath"
|
||||||
version = "0.27.2"
|
version = "0.28.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "e4c3a552cfca14630702449d35f41c84a0d15963273771c6059175a803620f3f"
|
checksum = "0a6a21708405ea88f63d8309650b4d77431f4bc28fb9d8e6f77d3963b51249e6"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bytemuck",
|
"bytemuck",
|
||||||
]
|
]
|
||||||
@@ -1132,9 +1108,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "env_logger"
|
name = "env_logger"
|
||||||
version = "0.11.3"
|
version = "0.11.5"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "38b35839ba51819680ba087cd351788c9a3c476841207e0b8cee0b04722343b9"
|
checksum = "e13fa619b91fb2381732789fc5de83b45675e882f66623b7d8cb4f643017018d"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anstream",
|
"anstream",
|
||||||
"anstyle",
|
"anstyle",
|
||||||
@@ -1145,9 +1121,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "epaint"
|
name = "epaint"
|
||||||
version = "0.27.2"
|
version = "0.28.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "b381f8b149657a4acf837095351839f32cd5c4aec1817fc4df84e18d76334176"
|
checksum = "3f0dcc0a0771e7500e94cd1cb797bd13c9f23b9409bdc3c824e2cbc562b7fa01"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"ab_glyph",
|
"ab_glyph",
|
||||||
"ahash",
|
"ahash",
|
||||||
@@ -1510,9 +1486,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "gpu-descriptor"
|
name = "gpu-descriptor"
|
||||||
version = "0.2.4"
|
version = "0.3.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "cc11df1ace8e7e564511f53af41f3e42ddc95b56fd07b3f4445d2a6048bc682c"
|
checksum = "9c08c1f623a8d0b722b8b99f821eb0ba672a1618f0d3b16ddbee1cedd2dd8557"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags 2.6.0",
|
"bitflags 2.6.0",
|
||||||
"gpu-descriptor-types",
|
"gpu-descriptor-types",
|
||||||
@@ -1521,9 +1497,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "gpu-descriptor-types"
|
name = "gpu-descriptor-types"
|
||||||
version = "0.1.2"
|
version = "0.2.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "6bf0b36e6f090b7e1d8a4b49c0cb81c1f8376f72198c65dd3ad9ff3556b8b78c"
|
checksum = "fdf242682df893b86f33a73828fb09ca4b2d3bb6cc95249707fc684d27484b91"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags 2.6.0",
|
"bitflags 2.6.0",
|
||||||
]
|
]
|
||||||
@@ -1621,13 +1597,12 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "image"
|
name = "image"
|
||||||
version = "0.24.9"
|
version = "0.25.2"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "5690139d2f55868e080017335e4b94cb7414274c74f1669c84fb5feba2c9f69d"
|
checksum = "99314c8a2152b8ddb211f924cdae532d8c5e4c8bb54728e12fff1b0cd5963a10"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bytemuck",
|
"bytemuck",
|
||||||
"byteorder",
|
"byteorder-lite",
|
||||||
"color_quant",
|
|
||||||
"num-traits",
|
"num-traits",
|
||||||
"png",
|
"png",
|
||||||
]
|
]
|
||||||
@@ -1754,7 +1729,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "e310b3a6b5907f99202fcdb4960ff45b93735d7c7d96b760fcff8db2dc0e103d"
|
checksum = "e310b3a6b5907f99202fcdb4960ff45b93735d7c7d96b760fcff8db2dc0e103d"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"cfg-if",
|
"cfg-if",
|
||||||
"windows-targets 0.52.5",
|
"windows-targets 0.48.5",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -1846,9 +1821,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "metal"
|
name = "metal"
|
||||||
version = "0.27.0"
|
version = "0.28.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "c43f73953f8cbe511f021b58f18c3ce1c3d1ae13fe953293e13345bf83217f25"
|
checksum = "5637e166ea14be6063a3f8ba5ccb9a4159df7d8f6d61c02fc3d480b1f90dcfcb"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags 2.6.0",
|
"bitflags 2.6.0",
|
||||||
"block",
|
"block",
|
||||||
@@ -1871,10 +1846,11 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "naga"
|
name = "naga"
|
||||||
version = "0.19.2"
|
version = "0.20.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "50e3524642f53d9af419ab5e8dd29d3ba155708267667c2f3f06c88c9e130843"
|
checksum = "e536ae46fcab0876853bd4a632ede5df4b1c2527a58f6c5a4150fe86be858231"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"arrayvec",
|
||||||
"bit-set",
|
"bit-set",
|
||||||
"bitflags 2.6.0",
|
"bitflags 2.6.0",
|
||||||
"codespan-reporting",
|
"codespan-reporting",
|
||||||
@@ -1975,7 +1951,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "915b1b472bc21c53464d6c8461c9d3af805ba1ef837e1cac254428f4a77177b1"
|
checksum = "915b1b472bc21c53464d6c8461c9d3af805ba1ef837e1cac254428f4a77177b1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"malloc_buf",
|
"malloc_buf",
|
||||||
"objc_exception",
|
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -2119,15 +2094,6 @@ dependencies = [
|
|||||||
"objc2-metal",
|
"objc2-metal",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "objc_exception"
|
|
||||||
version = "0.1.2"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "ad970fb455818ad6cba4c122ad012fae53ae8b4795f86378bce65e4f6bab2ca4"
|
|
||||||
dependencies = [
|
|
||||||
"cc",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "once_cell"
|
name = "once_cell"
|
||||||
version = "1.19.0"
|
version = "1.19.0"
|
||||||
@@ -3149,30 +3115,32 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "webbrowser"
|
name = "webbrowser"
|
||||||
version = "0.8.15"
|
version = "1.0.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "db67ae75a9405634f5882791678772c94ff5f16a66535aae186e26aa0841fc8b"
|
checksum = "425ba64c1e13b1c6e8c5d2541c8fac10022ca584f33da781db01b5756aef1f4e"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"block2 0.5.1",
|
||||||
"core-foundation",
|
"core-foundation",
|
||||||
"home",
|
"home",
|
||||||
"jni",
|
"jni",
|
||||||
"log",
|
"log",
|
||||||
"ndk-context",
|
"ndk-context",
|
||||||
"objc",
|
"objc2 0.5.2",
|
||||||
"raw-window-handle 0.5.2",
|
"objc2-foundation",
|
||||||
"url",
|
"url",
|
||||||
"web-sys",
|
"web-sys",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "wgpu"
|
name = "wgpu"
|
||||||
version = "0.19.4"
|
version = "0.20.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "cbd7311dbd2abcfebaabf1841a2824ed7c8be443a0f29166e5d3c6a53a762c01"
|
checksum = "90e37c7b9921b75dfd26dd973fdcbce36f13dfa6e2dc82aece584e0ed48c355c"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"arrayvec",
|
"arrayvec",
|
||||||
"cfg-if",
|
"cfg-if",
|
||||||
"cfg_aliases",
|
"cfg_aliases",
|
||||||
|
"document-features",
|
||||||
"js-sys",
|
"js-sys",
|
||||||
"log",
|
"log",
|
||||||
"parking_lot",
|
"parking_lot",
|
||||||
@@ -3190,15 +3158,16 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "wgpu-core"
|
name = "wgpu-core"
|
||||||
version = "0.19.4"
|
version = "0.21.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "28b94525fc99ba9e5c9a9e24764f2bc29bad0911a7446c12f446a8277369bf3a"
|
checksum = "d50819ab545b867d8a454d1d756b90cd5f15da1f2943334ca314af10583c9d39"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"arrayvec",
|
"arrayvec",
|
||||||
"bit-vec",
|
"bit-vec",
|
||||||
"bitflags 2.6.0",
|
"bitflags 2.6.0",
|
||||||
"cfg_aliases",
|
"cfg_aliases",
|
||||||
"codespan-reporting",
|
"codespan-reporting",
|
||||||
|
"document-features",
|
||||||
"indexmap",
|
"indexmap",
|
||||||
"log",
|
"log",
|
||||||
"naga",
|
"naga",
|
||||||
@@ -3216,9 +3185,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "wgpu-hal"
|
name = "wgpu-hal"
|
||||||
version = "0.19.4"
|
version = "0.21.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "fc1a4924366df7ab41a5d8546d6534f1f33231aa5b3f72b9930e300f254e39c3"
|
checksum = "172e490a87295564f3fcc0f165798d87386f6231b04d4548bca458cbbfd63222"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"android_system_properties",
|
"android_system_properties",
|
||||||
"arrayvec",
|
"arrayvec",
|
||||||
@@ -3257,9 +3226,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "wgpu-types"
|
name = "wgpu-types"
|
||||||
version = "0.19.2"
|
version = "0.20.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "b671ff9fb03f78b46ff176494ee1ebe7d603393f42664be55b64dc8d53969805"
|
checksum = "1353d9a46bff7f955a680577f34c69122628cc2076e1d6f3a9be6ef00ae793ef"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags 2.6.0",
|
"bitflags 2.6.0",
|
||||||
"js-sys",
|
"js-sys",
|
||||||
|
|||||||
@@ -4,9 +4,9 @@ version = "0.1.0"
|
|||||||
edition = "2021"
|
edition = "2021"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
env_logger = "0.11.3"
|
env_logger = "0.11.5"
|
||||||
log = "0.4.22"
|
log = "0.4.22"
|
||||||
clap = { version = "4.5.8", features = ["derive", "env"] }
|
clap = { version = "4.5.18", features = ["derive", "env"] }
|
||||||
egui = "0.27.2"
|
egui = "0.28.1"
|
||||||
eframe = "0.27.2"
|
eframe = "0.28.1"
|
||||||
lazy_static = "1.5.0"
|
lazy_static = "1.5.0"
|
||||||
@@ -11,7 +11,7 @@ fn main() {
|
|||||||
eframe::run_native(
|
eframe::run_native(
|
||||||
"Custom consumption",
|
"Custom consumption",
|
||||||
options,
|
options,
|
||||||
Box::new(|_cc| Box::<MyApp>::default()),
|
Box::new(|_cc| Ok(Box::<MyApp>::default())),
|
||||||
)
|
)
|
||||||
.unwrap()
|
.unwrap()
|
||||||
}
|
}
|
||||||
|
|||||||
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
|
||||||
|
```
|
||||||
125
docs/SETUP_PROD.md
Normal file
125
docs/SETUP_PROD.md
Normal file
@@ -0,0 +1,125 @@
|
|||||||
|
# Configure project for production
|
||||||
|
|
||||||
|
## 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 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.local
|
||||||
|
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 |
11
esp32_device/.vscode/settings.json
vendored
11
esp32_device/.vscode/settings.json
vendored
@@ -46,6 +46,15 @@
|
|||||||
"string_view": "c",
|
"string_view": "c",
|
||||||
"format": "c",
|
"format": "c",
|
||||||
"span": "c",
|
"span": "c",
|
||||||
"regex": "c"
|
"regex": "c",
|
||||||
|
"stdlib.h": "c",
|
||||||
|
"secure_api.h": "c",
|
||||||
|
"jwt.h": "c",
|
||||||
|
"sync_response.h": "c",
|
||||||
|
"gpio.h": "c",
|
||||||
|
"esp_system.h": "c",
|
||||||
|
"relays.h": "c",
|
||||||
|
"esp_app_desc.h": "c",
|
||||||
|
"ota.h": "c"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,18 @@
|
|||||||
# ESP32 device
|
# ESP32 device
|
||||||
|
|
||||||
ESP32 client device, using `W32-ETH01` device
|
ESP32 client device, using `W32-ETH01` device
|
||||||
|
|
||||||
|
## 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)\"}"
|
||||||
BIN
esp32_device/docs/pinout.fzz
Normal file
BIN
esp32_device/docs/pinout.fzz
Normal file
Binary file not shown.
@@ -1,4 +1,4 @@
|
|||||||
idf_component_register(SRCS "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"
|
"dev_name.c"
|
||||||
INCLUDE_DIRS ".")
|
INCLUDE_DIRS ".")
|
||||||
|
|
||||||
|
|||||||
@@ -5,16 +5,6 @@
|
|||||||
*/
|
*/
|
||||||
#define DEV_REFERENCE "Wt32-Eth01"
|
#define DEV_REFERENCE "Wt32-Eth01"
|
||||||
|
|
||||||
/**
|
|
||||||
* Device version
|
|
||||||
*/
|
|
||||||
#define DEV_VERSION "0.0.1"
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Device max number of relays
|
|
||||||
*/
|
|
||||||
#define DEV_MAX_RELAYS 1
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Backend unsecure API URL
|
* Backend unsecure API URL
|
||||||
*/
|
*/
|
||||||
@@ -44,3 +34,13 @@
|
|||||||
* Secure origin len
|
* Secure origin len
|
||||||
*/
|
*/
|
||||||
#define SEC_ORIG_LEN 255
|
#define SEC_ORIG_LEN 255
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Interval of time (in seconds) between two synchronisations
|
||||||
|
*/
|
||||||
|
#define SYNC_TIME_INTERVAL 5
|
||||||
|
|
||||||
|
/**
|
||||||
|
* OTA download timeout (in milliseconds)
|
||||||
|
*/
|
||||||
|
#define OTA_REC_TIMEOUT 15000
|
||||||
@@ -13,6 +13,7 @@
|
|||||||
#include <mbedtls/sha256.h>
|
#include <mbedtls/sha256.h>
|
||||||
#include <mbedtls/pk.h>
|
#include <mbedtls/pk.h>
|
||||||
#include <mbedtls/x509_csr.h>
|
#include <mbedtls/x509_csr.h>
|
||||||
|
#include <mbedtls/base64.h>
|
||||||
#include "esp_log.h"
|
#include "esp_log.h"
|
||||||
|
|
||||||
#define ECPARAMS MBEDTLS_ECP_DP_SECP256R1
|
#define ECPARAMS MBEDTLS_ECP_DP_SECP256R1
|
||||||
@@ -28,12 +29,12 @@ static void seed_ctr_drbg_context(mbedtls_entropy_context *entropy, mbedtls_ctr_
|
|||||||
mbedtls_entropy_init(entropy);
|
mbedtls_entropy_init(entropy);
|
||||||
mbedtls_ctr_drbg_init(ctr_drbg);
|
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,
|
if ((ret = mbedtls_ctr_drbg_seed(ctr_drbg, mbedtls_entropy_func, entropy,
|
||||||
(const unsigned char *)pers,
|
(const unsigned char *)pers,
|
||||||
strlen(pers))) != 0)
|
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();
|
reboot();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -53,7 +54,7 @@ bool crypto_gen_priv_key()
|
|||||||
mbedtls_ctr_drbg_context ctr_drbg;
|
mbedtls_ctr_drbg_context ctr_drbg;
|
||||||
seed_ctr_drbg_context(&entropy, &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)
|
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);
|
ESP_LOGE(TAG, " failed\n ! mbedtls_pk_setup returned -0x%04x", (unsigned int)-ret);
|
||||||
@@ -61,7 +62,7 @@ bool crypto_gen_priv_key()
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Generate private key
|
// Generate private key
|
||||||
ESP_LOGI(TAG, "Generate private key\n");
|
ESP_LOGI(TAG, "Generate private key");
|
||||||
ret = mbedtls_ecp_gen_key(ECPARAMS,
|
ret = mbedtls_ecp_gen_key(ECPARAMS,
|
||||||
mbedtls_pk_ec(key),
|
mbedtls_pk_ec(key),
|
||||||
mbedtls_ctr_drbg_random, &ctr_drbg);
|
mbedtls_ctr_drbg_random, &ctr_drbg);
|
||||||
@@ -73,7 +74,7 @@ bool crypto_gen_priv_key()
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Export private 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);
|
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)
|
if ((ret = mbedtls_pk_write_key_der(&key, key_buff, PRV_KEY_DER_MAX_BYTES)) < 1)
|
||||||
{
|
{
|
||||||
@@ -107,7 +108,7 @@ void crypto_print_priv_key()
|
|||||||
mbedtls_ctr_drbg_context ctr_drbg;
|
mbedtls_ctr_drbg_context ctr_drbg;
|
||||||
seed_ctr_drbg_context(&entropy, &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)
|
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",
|
ESP_LOGE(TAG, " failed\n ! mbedtls_pk_parse_key returned -0x%04x",
|
||||||
@@ -116,7 +117,7 @@ void crypto_print_priv_key()
|
|||||||
}
|
}
|
||||||
free(key_buff);
|
free(key_buff);
|
||||||
|
|
||||||
ESP_LOGI(TAG, "Show private key\n");
|
ESP_LOGI(TAG, "Show private key");
|
||||||
unsigned char *out = malloc(16000);
|
unsigned char *out = malloc(16000);
|
||||||
memset(out, 0, 16000);
|
memset(out, 0, 16000);
|
||||||
if ((ret = mbedtls_pk_write_key_pem(&key, out, 16000)) != 0)
|
if ((ret = mbedtls_pk_write_key_pem(&key, out, 16000)) != 0)
|
||||||
@@ -134,6 +135,46 @@ void crypto_print_priv_key()
|
|||||||
mbedtls_entropy_free(&entropy);
|
mbedtls_entropy_free(&entropy);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get secret point value of our private key
|
||||||
|
*/
|
||||||
|
static bool crypto_get_priv_key_mpi(mbedtls_mpi *dst)
|
||||||
|
{
|
||||||
|
int ret;
|
||||||
|
|
||||||
|
unsigned char *key_buff = malloc(PRV_KEY_DER_MAX_BYTES);
|
||||||
|
size_t key_len = storage_get_priv_key(key_buff);
|
||||||
|
assert(key_len > 0);
|
||||||
|
|
||||||
|
mbedtls_pk_context key;
|
||||||
|
mbedtls_pk_init(&key);
|
||||||
|
|
||||||
|
mbedtls_entropy_context entropy;
|
||||||
|
mbedtls_ctr_drbg_context ctr_drbg;
|
||||||
|
seed_ctr_drbg_context(&entropy, &ctr_drbg);
|
||||||
|
|
||||||
|
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",
|
||||||
|
(unsigned int)-ret);
|
||||||
|
reboot();
|
||||||
|
}
|
||||||
|
free(key_buff);
|
||||||
|
|
||||||
|
ESP_LOGI(TAG, "Extract private key");
|
||||||
|
mbedtls_ecp_keypair *kp = mbedtls_pk_ec(key);
|
||||||
|
|
||||||
|
mbedtls_mpi_init(dst);
|
||||||
|
mbedtls_mpi_copy(dst, &kp->private_d);
|
||||||
|
|
||||||
|
mbedtls_pk_free(&key);
|
||||||
|
mbedtls_ctr_drbg_free(&ctr_drbg);
|
||||||
|
mbedtls_entropy_free(&entropy);
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
char *crypto_get_csr()
|
char *crypto_get_csr()
|
||||||
{
|
{
|
||||||
int ret;
|
int ret;
|
||||||
@@ -149,7 +190,7 @@ char *crypto_get_csr()
|
|||||||
mbedtls_ctr_drbg_context ctr_drbg;
|
mbedtls_ctr_drbg_context ctr_drbg;
|
||||||
seed_ctr_drbg_context(&entropy, &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)
|
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",
|
ESP_LOGE(TAG, " failed\n ! mbedtls_pk_parse_key returned -0x%04x",
|
||||||
@@ -173,7 +214,7 @@ char *crypto_get_csr()
|
|||||||
reboot();
|
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);
|
mbedtls_x509write_csr_set_key(&req, &key);
|
||||||
|
|
||||||
char *csr = malloc(4096);
|
char *csr = malloc(4096);
|
||||||
@@ -190,3 +231,118 @@ char *crypto_get_csr()
|
|||||||
|
|
||||||
return csr;
|
return csr;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
char *crypto_encode_base64_safe_url(const char *src, size_t srclen)
|
||||||
|
{
|
||||||
|
size_t olen = 0;
|
||||||
|
mbedtls_base64_encode(NULL, 0, &olen, (unsigned char *)src, srclen);
|
||||||
|
|
||||||
|
if (olen < 1)
|
||||||
|
{
|
||||||
|
ESP_LOGE(TAG, "Failed to determine base64 buffer size!");
|
||||||
|
return NULL;
|
||||||
|
}
|
||||||
|
|
||||||
|
char *out = calloc(1, olen);
|
||||||
|
if (!out)
|
||||||
|
{
|
||||||
|
ESP_LOGE(TAG, "Failed to allocate memory for destination buffer!");
|
||||||
|
return NULL;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mbedtls_base64_encode((unsigned char *)out, olen, &olen, (unsigned char *)src, srclen) != 0)
|
||||||
|
{
|
||||||
|
ESP_LOGE(TAG, "Failed to perfom base64 encoding!");
|
||||||
|
free(out);
|
||||||
|
return NULL;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert base64 encoding to base64URL
|
||||||
|
for (size_t i = 0; i < olen; i++)
|
||||||
|
{
|
||||||
|
switch (out[i])
|
||||||
|
{
|
||||||
|
case '+':
|
||||||
|
out[i] = '-';
|
||||||
|
break;
|
||||||
|
case '/':
|
||||||
|
out[i] = '_';
|
||||||
|
break;
|
||||||
|
case '=':
|
||||||
|
out[i] = '\0';
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
#define HASH_LEN 32
|
||||||
|
|
||||||
|
char *crypto_sign_sha256_payload(const char *src, const size_t src_len, size_t *dstlen)
|
||||||
|
{
|
||||||
|
int ret;
|
||||||
|
uint8_t r_be[32] = {0};
|
||||||
|
uint8_t s_be[32] = {0};
|
||||||
|
|
||||||
|
// Load private key
|
||||||
|
mbedtls_mpi key_mpi;
|
||||||
|
if (!crypto_get_priv_key_mpi(&key_mpi))
|
||||||
|
{
|
||||||
|
ESP_LOGE(TAG, "Failed to load private key MPI!");
|
||||||
|
return NULL;
|
||||||
|
}
|
||||||
|
|
||||||
|
mbedtls_entropy_context entropy;
|
||||||
|
mbedtls_ctr_drbg_context ctr_drbg;
|
||||||
|
seed_ctr_drbg_context(&entropy, &ctr_drbg);
|
||||||
|
|
||||||
|
mbedtls_mpi r, s;
|
||||||
|
mbedtls_mpi_init(&r);
|
||||||
|
mbedtls_mpi_init(&s);
|
||||||
|
|
||||||
|
mbedtls_ecdsa_context ecdsa_context;
|
||||||
|
mbedtls_ecdsa_init(&ecdsa_context);
|
||||||
|
|
||||||
|
mbedtls_ecp_group_load(&ecdsa_context.MBEDTLS_PRIVATE(grp), MBEDTLS_ECP_DP_SECP256R1);
|
||||||
|
|
||||||
|
// Compute sha256
|
||||||
|
uint8_t sha256_out[HASH_LEN] = {0};
|
||||||
|
mbedtls_sha256((unsigned char *)src, src_len, sha256_out, 0);
|
||||||
|
|
||||||
|
// Compute signature
|
||||||
|
ret = mbedtls_ecdsa_sign(&ecdsa_context.MBEDTLS_PRIVATE(grp), &r, &s, &key_mpi, sha256_out, HASH_LEN, mbedtls_ctr_drbg_random, &ctr_drbg);
|
||||||
|
|
||||||
|
// Extract R & S (as per RFC 7518)
|
||||||
|
mbedtls_mpi_write_binary(&r, r_be, 32);
|
||||||
|
mbedtls_mpi_write_binary(&s, s_be, 32);
|
||||||
|
|
||||||
|
mbedtls_ctr_drbg_free(&ctr_drbg);
|
||||||
|
mbedtls_entropy_free(&entropy);
|
||||||
|
|
||||||
|
mbedtls_mpi_free(&r);
|
||||||
|
mbedtls_mpi_free(&s);
|
||||||
|
mbedtls_mpi_free(&key_mpi);
|
||||||
|
|
||||||
|
mbedtls_ecdsa_free(&ecdsa_context);
|
||||||
|
|
||||||
|
if (ret != 0)
|
||||||
|
{
|
||||||
|
ESP_LOGE(TAG, "Failed to perfom base64 encoding!");
|
||||||
|
return NULL;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prepare output
|
||||||
|
char *out = calloc(1, 64);
|
||||||
|
if (!out)
|
||||||
|
{
|
||||||
|
ESP_LOGE(TAG, "Failed to allocate memory to store signature!");
|
||||||
|
return NULL;
|
||||||
|
}
|
||||||
|
|
||||||
|
memcpy(out, &r_be, 32);
|
||||||
|
memcpy(out + 32, &s_be, 32);
|
||||||
|
|
||||||
|
*dstlen = 64;
|
||||||
|
return out;
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@
|
|||||||
#pragma once
|
#pragma once
|
||||||
|
|
||||||
#include <stdbool.h>
|
#include <stdbool.h>
|
||||||
|
#include <stddef.h>
|
||||||
|
|
||||||
#ifdef __cplusplus
|
#ifdef __cplusplus
|
||||||
extern "C"
|
extern "C"
|
||||||
@@ -31,6 +32,18 @@ extern "C"
|
|||||||
*/
|
*/
|
||||||
char *crypto_get_csr();
|
char *crypto_get_csr();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Encode buffer to base64 safe URL string
|
||||||
|
*
|
||||||
|
* @return A buffer that needs to be freed or NULL in case of failure
|
||||||
|
*/
|
||||||
|
char *crypto_encode_base64_safe_url(const char *src, size_t srclen);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sign some data using sha256
|
||||||
|
*/
|
||||||
|
char *crypto_sign_sha256_payload(const char *src, const size_t src_len, size_t *dstlen);
|
||||||
|
|
||||||
#ifdef __cplusplus
|
#ifdef __cplusplus
|
||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ char *dev_name()
|
|||||||
char *dev = malloc(len + strlen(DEV_PREFIX) + 1);
|
char *dev = malloc(len + strlen(DEV_PREFIX) + 1);
|
||||||
if (dev == NULL)
|
if (dev == NULL)
|
||||||
{
|
{
|
||||||
ESP_LOGE(TAG, "Failed to allocate memory to store dev name!\n");
|
ESP_LOGE(TAG, "Failed to allocate memory to store dev name!");
|
||||||
return NULL;
|
return NULL;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user