Merge pull request #60 from MikaylaFischler/devel

Alpha PLC, RTU, and Supervisor Release
This commit is contained in:
Mikayla 2022-05-27 18:43:01 -04:00 committed by GitHub
commit e21c7d92fe
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
58 changed files with 8100 additions and 3309 deletions

13
.vscode/settings.json vendored Normal file
View File

@ -0,0 +1,13 @@
{
"Lua.diagnostics.globals": [
"term",
"fs",
"peripheral",
"rs",
"bit",
"parallel",
"colors",
"textutils",
"shell"
]
}

695
LICENSE
View File

@ -1,674 +1,21 @@
GNU GENERAL PUBLIC LICENSE MIT License
Version 3, 29 June 2007
Copyright (c) 2022 Mikayla Fischler
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
Everyone is permitted to copy and distribute verbatim copies Permission is hereby granted, free of charge, to any person obtaining a copy
of this license document, but changing it is not allowed. of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
Preamble to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
The GNU General Public License is a free, copyleft license for furnished to do so, subject to the following conditions:
software and other kinds of works.
The above copyright notice and this permission notice shall be included in all
The licenses for most software and other practical works are designed copies or substantial portions of the Software.
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 THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
share and change all versions of a program--to make sure it remains free IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
software for all its users. We, the Free Software Foundation, use the FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
GNU General Public License for most of our software; it applies also to AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
any other work released this way by its authors. You can apply it to LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
your programs, too. OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
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>.

View File

@ -1,6 +1,8 @@
# cc-mek-scada # cc-mek-scada
Configurable ComputerCraft SCADA system for multi-reactor control of Mekanism fission reactors with a GUI, automatic safety features, waste processing control, and more! Configurable ComputerCraft SCADA system for multi-reactor control of Mekanism fission reactors with a GUI, automatic safety features, waste processing control, and more!
This requires CC: Tweaked and Mekanism v10.0+ (10.1 recommended for full feature set).
## [SCADA](https://en.wikipedia.org/wiki/SCADA) ## [SCADA](https://en.wikipedia.org/wiki/SCADA)
> Supervisory control and data acquisition (SCADA) is a control system architecture comprising computers, networked data communications and graphical user interfaces for high-level supervision of machines and processes. It also covers sensors and other devices, such as programmable logic controllers, which interface with process plant or machinery. > Supervisory control and data acquisition (SCADA) is a control system architecture comprising computers, networked data communications and graphical user interfaces for high-level supervision of machines and processes. It also covers sensors and other devices, such as programmable logic controllers, which interface with process plant or machinery.
@ -10,20 +12,20 @@ This project implements concepts of a SCADA system in ComputerCraft (because why
![Architecture](https://upload.wikimedia.org/wikipedia/commons/thumb/1/10/Functional_levels_of_a_Distributed_Control_System.svg/1000px-Functional_levels_of_a_Distributed_Control_System.svg.png) ![Architecture](https://upload.wikimedia.org/wikipedia/commons/thumb/1/10/Functional_levels_of_a_Distributed_Control_System.svg/1000px-Functional_levels_of_a_Distributed_Control_System.svg.png)
SCADA and industrial automation terminology is used throughout the project, such as: SCADA and industrial automation terminology is used throughout the project, such as:
- Supervisory Computer: Gathers data and control the process - Supervisory Computer: Gathers data and controls the process
- Coordinating Computer: Used as the HMI component, user requests high-level processing operations - Coordinating Computer: Used as the HMI component, user requests high-level processing operations
- RTU: Remote Terminal Unit - RTU: Remote Terminal Unit
- PLC: Programmable Logic Controller - PLC: Programmable Logic Controller
## ComputerCraft Architecture ## ComputerCraft Architecture
### Coordinating Computers ### Coordinator Server
There can be one or more of these. They can be either an Advanced Computer or a Pocket Computer. There can only be one of these. This server acts as a hybrid of levels 3 & 4 in the SCADA diagram above. In addition to viewing status and controlling processes with advanced monitors, it can host access for one or more Pocket computers.
### Supervisory Computers ### Supervisory Computers
There can be at most two of these in an active-backup configuration. If a backup is configured, it will act as a hot backup. This means it will be live, all data will be recieved by both it and the active computer, but it will not be commanding anything unless it hears that the active supervisor is shutting down or loses communication with the active supervisor. There should be one of these per facility system. Currently, that means only one. In the future, multiple supervisors would provide the capability of coordinating between multiple facilities (like a fission facility, fusion facility, etc).
### RTUs ### RTUs
@ -52,3 +54,8 @@ TBD, I am planning on AES symmetric encryption for security + HMAC to prevent re
This is somewhat important here as otherwise anyone can just control your setup, which is undeseriable. Unlike normal Minecraft PVP chaos, it would be very difficult to identify who is messing with your system, as with an Ender Modem they can do it from effectively anywhere and the server operators would have to check every computer's filesystem to find suspicious code. This is somewhat important here as otherwise anyone can just control your setup, which is undeseriable. Unlike normal Minecraft PVP chaos, it would be very difficult to identify who is messing with your system, as with an Ender Modem they can do it from effectively anywhere and the server operators would have to check every computer's filesystem to find suspicious code.
The only other possible security mitigation for commanding (no effect on monitoring) is to enforce a maximum authorized transmission range (which I will probably also do, or maybe fall back to), as modem message events contain the transmission distance. The only other possible security mitigation for commanding (no effect on monitoring) is to enforce a maximum authorized transmission range (which I will probably also do, or maybe fall back to), as modem message events contain the transmission distance.
## Known Issues
GitHub issue \#29:
It appears that with Mekanism 10.0, a boiler peripheral may rapidly disconnect/reconnect constantly while running. This will prevent that RTU from operating correctly while also filling up the log file. This may be due to a very specific version interaction of CC: Tweaked and Mekansim, so you are welcome to try this on Mekanism 10.0 servers, but do be aware it may not work.

View File

@ -1,8 +1,12 @@
-- #REQUIRES comms.lua local comms = require("scada-common.comms")
local coordinator = {}
-- coordinator communications -- coordinator communications
function coord_comms() coordinator.coord_comms = function ()
local self = { local self = {
reactor_struct_cache = nil reactor_struct_cache = nil
} }
end end
return coordinator

View File

@ -1,135 +0,0 @@
-- mekanism reactor controller
-- monitors and regulates mekanism reactors
os.loadAPI("reactor.lua")
os.loadAPI("defs.lua")
os.loadAPI("log.lua")
os.loadAPI("render.lua")
os.loadAPI("server.lua")
os.loadAPI("regulator.lua")
-- constants, aliases, properties
local header = "MEKANISM REACTOR CONTROLLER - v" .. defs.CTRL_VERSION
local monitor_0 = peripheral.wrap(defs.MONITOR_0)
local monitor_1 = peripheral.wrap(defs.MONITOR_1)
local monitor_2 = peripheral.wrap(defs.MONITOR_2)
local monitor_3 = peripheral.wrap(defs.MONITOR_3)
monitor_0.setBackgroundColor(colors.black)
monitor_0.setTextColor(colors.white)
monitor_0.clear()
monitor_1.setBackgroundColor(colors.black)
monitor_1.setTextColor(colors.white)
monitor_1.clear()
monitor_2.setBackgroundColor(colors.black)
monitor_2.setTextColor(colors.white)
monitor_2.clear()
log.init(monitor_3)
local main_w, main_h = monitor_0.getSize()
local view = window.create(monitor_0, 1, 1, main_w, main_h)
view.setBackgroundColor(colors.black)
view.clear()
local stat_w, stat_h = monitor_1.getSize()
local stat_view = window.create(monitor_1, 1, 1, stat_w, stat_h)
stat_view.setBackgroundColor(colors.black)
stat_view.clear()
local reactors = {
reactor.create(1, view, stat_view, 62, 3, 63, 2),
reactor.create(2, view, stat_view, 42, 3, 43, 2),
reactor.create(3, view, stat_view, 22, 3, 23, 2),
reactor.create(4, view, stat_view, 2, 3, 3, 2)
}
print("[debug] reactor tables created")
server.init(reactors)
print("[debug] modem server started")
regulator.init(reactors)
print("[debug] regulator started")
-- header
view.setBackgroundColor(colors.white)
view.setTextColor(colors.black)
view.setCursorPos(1, 1)
local header_pad_x = (main_w - string.len(header)) / 2
view.write(string.rep(" ", header_pad_x) .. header .. string.rep(" ", header_pad_x))
-- inital draw of each reactor
for key, rctr in pairs(reactors) do
render.draw_reactor_system(rctr)
render.draw_reactor_status(rctr)
end
-- inital draw of clock
monitor_2.setTextScale(2)
monitor_2.setCursorPos(1, 1)
monitor_2.write(os.date("%Y/%m/%d %H:%M:%S"))
local clock_update_timer = os.startTimer(1)
while true do
event, param1, param2, param3, param4, param5 = os.pullEvent()
if event == "redstone" then
-- redstone state change
regulator.handle_redstone()
elseif event == "modem_message" then
-- received signal router packet
packet = {
side = param1,
sender = param2,
reply = param3,
message = param4,
distance = param5
}
server.handle_message(packet, reactors)
elseif event == "monitor_touch" then
if param1 == "monitor_5" then
local tap_x = param2
local tap_y = param3
for key, rctr in pairs(reactors) do
if tap_x >= rctr.render.stat_x and tap_x <= (rctr.render.stat_x + 15) then
local old_val = rctr.waste_production
-- width in range
if tap_y == (rctr.render.stat_y + 12) then
rctr.waste_production = "plutonium"
elseif tap_y == (rctr.render.stat_y + 14) then
rctr.waste_production = "polonium"
elseif tap_y == (rctr.render.stat_y + 16) then
rctr.waste_production = "antimatter"
end
-- notify reactor of changes
if old_val ~= rctr.waste_production then
server.send(rctr.id, rctr.waste_production)
end
end
end
end
elseif event == "timer" then
-- update the clock about every second
monitor_2.setCursorPos(1, 1)
monitor_2.write(os.date("%Y/%m/%d %H:%M:%S"))
clock_update_timer = os.startTimer(1)
-- send keep-alive
server.broadcast(1)
end
-- update reactor display
for key, rctr in pairs(reactors) do
render.draw_reactor_system(rctr)
render.draw_reactor_status(rctr)
end
-- update system status monitor
render.update_system_monitor(monitor_2, regulator.is_scrammed(), reactors)
end

View File

@ -1,23 +0,0 @@
-- configuration definitions
CTRL_VERSION = "0.7"
-- monitors
MONITOR_0 = "monitor_6"
MONITOR_1 = "monitor_5"
MONITOR_2 = "monitor_7"
MONITOR_3 = "monitor_8"
-- modem server
LISTEN_PORT = 1000
-- regulator (should match the number of reactors present)
BUNDLE_DEF = { colors.red, colors.orange, colors.yellow, colors.lime }
-- stats calculation
REACTOR_MB_T = 39
TURBINE_MRF_T = 3.114
PLUTONIUM_PER_WASTE = 0.1
POLONIUM_PER_WASTE = 0.1
SPENT_PER_BYPRODUCT = 1
ANTIMATTER_PER_POLONIUM = 0.001

View File

@ -1,52 +0,0 @@
os.loadAPI("defs.lua")
local out, out_w, out_h
local output_full = false
-- initialize the logger to the given monitor
-- monitor: monitor to write to (in addition to calling print())
function init(monitor)
out = monitor
out_w, out_h = out.getSize()
out.clear()
out.setTextColor(colors.white)
out.setBackgroundColor(colors.black)
out.setCursorPos(1, 1)
out.write("version " .. defs.CTRL_VERSION)
out.setCursorPos(1, 2)
out.write("system startup at " .. os.date("%Y/%m/%d %H:%M:%S"))
print("server v" .. defs.CTRL_VERSION .. " started at " .. os.date("%Y/%m/%d %H:%M:%S"))
end
-- write a log message to the log screen and console
-- msg: message to write
-- color: (optional) color to print in, defaults to white
function write(msg, color)
color = color or colors.white
local _x, _y = out.getCursorPos()
if output_full then
out.scroll(1)
out.setCursorPos(1, _y)
else
if _y == out_h then
output_full = true
out.scroll(1)
out.setCursorPos(1, _y)
else
out.setCursorPos(1, _y + 1)
end
end
-- output to screen
out.setTextColor(colors.lightGray)
out.write(os.date("[%H:%M:%S] "))
out.setTextColor(color)
out.write(msg)
-- output to console
print(os.date("[%H:%M:%S] ") .. msg)
end

View File

@ -1,28 +0,0 @@
-- create a new reactor 'object'
-- reactor_id: the ID for this reactor
-- main_view: the parent window/monitor for the main display (components)
-- status_view: the parent window/monitor for the status display
-- main_x: where to create the main window, x coordinate
-- main_y: where to create the main window, y coordinate
-- status_x: where to create the status window, x coordinate
-- status_y: where to create the status window, y coordinate
function create(reactor_id, main_view, status_view, main_x, main_y, status_x, status_y)
return {
id = reactor_id,
render = {
win_main = window.create(main_view, main_x, main_y, 20, 60, true),
win_stat = window.create(status_view, status_x, status_y, 20, 20, true),
stat_x = status_x,
stat_y = status_y
},
control_state = false,
waste_production = "antimatter", -- "plutonium", "polonium", "antimatter"
state = {
run = false,
no_fuel = false,
full_waste = false,
high_temp = false,
damage_crit = false
}
}
end

View File

@ -1,128 +0,0 @@
os.loadAPI("defs.lua")
os.loadAPI("log.lua")
os.loadAPI("server.lua")
local reactors
local scrammed
local auto_scram
-- initialize the system regulator which provides safety measures, SCRAM functionality, and handles redstone
-- _reactors: reactor table
function init(_reactors)
reactors = _reactors
scrammed = false
auto_scram = false
-- scram all reactors
server.broadcast(false, reactors)
-- check initial states
regulator.handle_redstone()
end
-- check if the system is scrammed
function is_scrammed()
return scrammed
end
-- handle redstone state changes
function handle_redstone()
-- check scram button
if not rs.getInput("right") then
if not scrammed then
log.write("user SCRAM", colors.red)
scram()
end
-- toggling scram will release auto scram state
auto_scram = false
else
scrammed = false
end
-- check individual control buttons
local input = rs.getBundledInput("left")
for key, rctr in pairs(reactors) do
if colors.test(input, defs.BUNDLE_DEF[key]) ~= rctr.control_state then
-- state changed
rctr.control_state = colors.test(input, defs.BUNDLE_DEF[key])
if not scrammed then
local safe = true
if rctr.control_state then
safe = check_enable_safety(reactors[key])
if safe then
log.write("reactor " .. reactors[key].id .. " enabled", colors.lime)
end
else
log.write("reactor " .. reactors[key].id .. " disabled", colors.cyan)
end
-- start/stop reactor
if safe then
server.send(rctr.id, rctr.control_state)
end
elseif colors.test(input, defs.BUNDLE_DEF[key]) then
log.write("scrammed: state locked off", colors.yellow)
end
end
end
end
-- make sure enabling the provided reactor is safe
-- reactor: reactor to check
function check_enable_safety(reactor)
if reactor.state.no_fuel or reactor.state.full_waste or reactor.state.high_temp or reactor.state.damage_crit then
log.write("RCT-" .. reactor.id .. ": unsafe enable denied", colors.yellow)
return false
else
return true
end
end
-- make sure no running reactors are in a bad state
function enforce_safeties()
for key, reactor in pairs(reactors) do
local overridden = false
local state = reactor.state
-- check for problems
if state.damage_crit and state.run then
reactor.control_state = false
log.write("RCT-" .. reactor.id .. ": shut down (damage)", colors.yellow)
-- scram all, so ignore setting overridden
log.write("auto SCRAM all reactors", colors.red)
auto_scram = true
scram()
elseif state.high_temp and state.run then
reactor.control_state = false
overridden = true
log.write("RCT-" .. reactor.id .. ": shut down (temp)", colors.yellow)
elseif state.full_waste and state.run then
reactor.control_state = false
overridden = true
log.write("RCT-" .. reactor.id .. ": shut down (waste)", colors.yellow)
elseif state.no_fuel and state.run then
reactor.control_state = false
overridden = true
log.write("RCT-" .. reactor.id .. ": shut down (fuel)", colors.yellow)
end
if overridden then
server.send(reactor.id, false)
end
end
end
-- shut down all reactors and prevent enabling them until the scram button is toggled/released
function scram()
scrammed = true
server.broadcast(false, reactors)
for key, rctr in pairs(reactors) do
if rctr.control_state then
log.write("reactor " .. reactors[key].id .. " disabled", colors.cyan)
end
end
end

View File

@ -1,370 +0,0 @@
os.loadAPI("defs.lua")
-- draw pipes between machines
-- win: window to render in
-- x: starting x coord
-- y: starting y coord
-- spacing: spacing between the pipes
-- color_out: output pipe contents color
-- color_ret: return pipe contents color
-- tick: tick the pipes for an animation
function draw_pipe(win, x, y, spacing, color_out, color_ret, tick)
local _color
local _off
tick = tick or 0
for i = 0, 4, 1
do
_off = (i + tick) % 2 == 0 or (tick == 1 and i == 0) or (tick == 3 and i == 4)
if _off then
_color = colors.lightGray
else
_color = color_out
end
win.setBackgroundColor(_color)
win.setCursorPos(x, y + i)
win.write(" ")
if not _off then
_color = color_ret
end
win.setBackgroundColor(_color)
win.setCursorPos(x + spacing, y + i)
win.write(" ")
end
end
-- draw a reactor view consisting of the reactor, boiler, turbine, and pipes
-- data: reactor table
function draw_reactor_system(data)
local win = data.render.win_main
local win_w, win_h = win.getSize()
win.setBackgroundColor(colors.black)
win.setTextColor(colors.black)
win.clear()
win.setCursorPos(1, 1)
-- draw header --
local header = "REACTOR " .. data.id
local header_pad_x = (win_w - string.len(header) - 2) / 2
local header_color
if data.state.no_fuel then
if data.state.run then
header_color = colors.purple
else
header_color = colors.brown
end
elseif data.state.full_waste then
header_color = colors.yellow
elseif data.state.high_temp then
header_color = colors.orange
elseif data.state.damage_crit then
header_color = colors.red
elseif data.state.run then
header_color = colors.green
else
header_color = colors.lightGray
end
local running = data.state.run and not data.state.no_fuel
win.write(" ")
win.setBackgroundColor(header_color)
win.write(string.rep(" ", win_w - 2))
win.setBackgroundColor(colors.black)
win.write(" ")
win.setCursorPos(1, 2)
win.write(" ")
win.setBackgroundColor(header_color)
win.write(string.rep(" ", header_pad_x) .. header .. string.rep(" ", header_pad_x))
win.setBackgroundColor(colors.black)
win.write(" ")
-- create strings for use in blit
local line_text = string.rep(" ", 14)
local line_text_color = string.rep("0", 14)
-- draw components --
-- draw reactor
local rod = "88"
if data.state.high_temp then
rod = "11"
elseif running then
rod = "99"
end
win.setCursorPos(4, 4)
win.setBackgroundColor(colors.gray)
win.write(line_text)
win.setCursorPos(4, 5)
win.blit(line_text, line_text_color, "77" .. rod .. "77" .. rod .. "77" .. rod .. "77")
win.setCursorPos(4, 6)
win.blit(line_text, line_text_color, "7777" .. rod .. "77" .. rod .. "7777")
win.setCursorPos(4, 7)
win.blit(line_text, line_text_color, "77" .. rod .. "77" .. rod .. "77" .. rod .. "77")
win.setCursorPos(4, 8)
win.blit(line_text, line_text_color, "7777" .. rod .. "77" .. rod .. "7777")
win.setCursorPos(4, 9)
win.blit(line_text, line_text_color, "77" .. rod .. "77" .. rod .. "77" .. rod .. "77")
win.setCursorPos(4, 10)
win.write(line_text)
-- boiler
local steam = "ffffffffff"
if running then
steam = "0000000000"
end
win.setCursorPos(4, 16)
win.setBackgroundColor(colors.gray)
win.write(line_text)
win.setCursorPos(4, 17)
win.blit(line_text, line_text_color, "77" .. steam .. "77")
win.setCursorPos(4, 18)
win.blit(line_text, line_text_color, "77" .. steam .. "77")
win.setCursorPos(4, 19)
win.blit(line_text, line_text_color, "77888888888877")
win.setCursorPos(4, 20)
win.blit(line_text, line_text_color, "77bbbbbbbbbb77")
win.setCursorPos(4, 21)
win.blit(line_text, line_text_color, "77bbbbbbbbbb77")
win.setCursorPos(4, 22)
win.blit(line_text, line_text_color, "77bbbbbbbbbb77")
win.setCursorPos(4, 23)
win.setBackgroundColor(colors.gray)
win.write(line_text)
-- turbine
win.setCursorPos(4, 29)
win.setBackgroundColor(colors.gray)
win.write(line_text)
win.setCursorPos(4, 30)
if running then
win.blit(line_text, line_text_color, "77000000000077")
else
win.blit(line_text, line_text_color, "77ffffffffff77")
end
win.setCursorPos(4, 31)
if running then
win.blit(line_text, line_text_color, "77008000080077")
else
win.blit(line_text, line_text_color, "77ff8ffff8ff77")
end
win.setCursorPos(4, 32)
if running then
win.blit(line_text, line_text_color, "77000800800077")
else
win.blit(line_text, line_text_color, "77fff8ff8fff77")
end
win.setCursorPos(4, 33)
if running then
win.blit(line_text, line_text_color, "77000088000077")
else
win.blit(line_text, line_text_color, "77ffff88ffff77")
end
win.setCursorPos(4, 34)
if running then
win.blit(line_text, line_text_color, "77000800800077")
else
win.blit(line_text, line_text_color, "77fff8ff8fff77")
end
win.setCursorPos(4, 35)
if running then
win.blit(line_text, line_text_color, "77008000080077")
else
win.blit(line_text, line_text_color, "77ff8ffff8ff77")
end
win.setCursorPos(4, 36)
if running then
win.blit(line_text, line_text_color, "77000000000077")
else
win.blit(line_text, line_text_color, "77ffffffffff77")
end
win.setCursorPos(4, 37)
win.setBackgroundColor(colors.gray)
win.write(line_text)
-- draw reactor coolant pipes
draw_pipe(win, 7, 11, 6, colors.orange, colors.lightBlue)
-- draw turbine pipes
draw_pipe(win, 7, 24, 6, colors.white, colors.blue)
end
-- draw the reactor statuses on the status screen
-- data: reactor table
function draw_reactor_status(data)
local win = data.render.win_stat
win.setBackgroundColor(colors.black)
win.setTextColor(colors.white)
win.clear()
-- show control state
win.setCursorPos(1, 1)
if data.control_state then
win.blit(" + ENABLED", "00000000000", "dddffffffff")
else
win.blit(" - DISABLED", "000000000000", "eeefffffffff")
end
-- show run state
win.setCursorPos(1, 2)
if data.state.run then
win.blit(" + RUNNING", "00000000000", "dddffffffff")
else
win.blit(" - STOPPED", "00000000000", "888ffffffff")
end
-- show fuel state
win.setCursorPos(1, 4)
if data.state.no_fuel then
win.blit(" - NO FUEL", "00000000000", "eeeffffffff")
else
win.blit(" + FUEL OK", "00000000000", "999ffffffff")
end
-- show waste state
win.setCursorPos(1, 5)
if data.state.full_waste then
win.blit(" - WASTE FULL", "00000000000000", "eeefffffffffff")
else
win.blit(" + WASTE OK", "000000000000", "999fffffffff")
end
-- show high temp state
win.setCursorPos(1, 6)
if data.state.high_temp then
win.blit(" - HIGH TEMP", "0000000000000", "eeeffffffffff")
else
win.blit(" + TEMP OK", "00000000000", "999ffffffff")
end
-- show damage state
win.setCursorPos(1, 7)
if data.state.damage_crit then
win.blit(" - CRITICAL DAMAGE", "0000000000000000000", "eeeffffffffffffffff")
else
win.blit(" + CASING INTACT", "00000000000000000", "999ffffffffffffff")
end
-- waste processing options --
win.setTextColor(colors.black)
win.setBackgroundColor(colors.white)
win.setCursorPos(1, 10)
win.write(" ")
win.setCursorPos(1, 11)
win.write(" WASTE OUTPUT ")
win.setCursorPos(1, 13)
win.setBackgroundColor(colors.cyan)
if data.waste_production == "plutonium" then
win.write(" > plutonium ")
else
win.write(" plutonium ")
end
win.setCursorPos(1, 15)
win.setBackgroundColor(colors.green)
if data.waste_production == "polonium" then
win.write(" > polonium ")
else
win.write(" polonium ")
end
win.setCursorPos(1, 17)
win.setBackgroundColor(colors.purple)
if data.waste_production == "antimatter" then
win.write(" > antimatter ")
else
win.write(" antimatter ")
end
end
-- update the system monitor screen
-- mon: monitor to update
-- is_scrammed:
function update_system_monitor(mon, is_scrammed, reactors)
if is_scrammed then
-- display scram banner
mon.setTextColor(colors.white)
mon.setBackgroundColor(colors.black)
mon.setCursorPos(1, 2)
mon.clearLine()
mon.setBackgroundColor(colors.red)
mon.setCursorPos(1, 3)
mon.write(" ")
mon.setCursorPos(1, 4)
mon.write(" SCRAM ")
mon.setCursorPos(1, 5)
mon.write(" ")
mon.setBackgroundColor(colors.black)
mon.setCursorPos(1, 6)
mon.clearLine()
mon.setTextColor(colors.white)
else
-- clear where scram banner would be
mon.setCursorPos(1, 3)
mon.clearLine()
mon.setCursorPos(1, 4)
mon.clearLine()
mon.setCursorPos(1, 5)
mon.clearLine()
-- show production statistics--
local mrf_t = 0
local mb_t = 0
local plutonium = 0
local polonium = 0
local spent_waste = 0
local antimatter = 0
-- determine production values
for key, rctr in pairs(reactors) do
if rctr.state.run then
mrf_t = mrf_t + defs.TURBINE_MRF_T
mb_t = mb_t + defs.REACTOR_MB_T
if rctr.waste_production == "plutonium" then
plutonium = plutonium + (defs.REACTOR_MB_T * defs.PLUTONIUM_PER_WASTE)
spent_waste = spent_waste + (defs.REACTOR_MB_T * defs.PLUTONIUM_PER_WASTE * defs.SPENT_PER_BYPRODUCT)
elseif rctr.waste_production == "polonium" then
polonium = polonium + (defs.REACTOR_MB_T * defs.POLONIUM_PER_WASTE)
spent_waste = spent_waste + (defs.REACTOR_MB_T * defs.POLONIUM_PER_WASTE * defs.SPENT_PER_BYPRODUCT)
elseif rctr.waste_production == "antimatter" then
antimatter = antimatter + (defs.REACTOR_MB_T * defs.POLONIUM_PER_WASTE * defs.ANTIMATTER_PER_POLONIUM)
end
end
end
-- draw stats
mon.setTextColor(colors.lightGray)
mon.setCursorPos(1, 2)
mon.clearLine()
mon.write("ENERGY: " .. string.format("%0.2f", mrf_t) .. " MRF/t")
-- mon.setCursorPos(1, 3)
-- mon.clearLine()
-- mon.write("FUEL: " .. mb_t .. " mB/t")
mon.setCursorPos(1, 3)
mon.clearLine()
mon.write("Pu: " .. string.format("%0.2f", plutonium) .. " mB/t")
mon.setCursorPos(1, 4)
mon.clearLine()
mon.write("Po: " .. string.format("%0.2f", polonium) .. " mB/t")
mon.setCursorPos(1, 5)
mon.clearLine()
mon.write("SPENT: " .. string.format("%0.2f", spent_waste) .. " mB/t")
mon.setCursorPos(1, 6)
mon.clearLine()
mon.write("ANTI-M: " .. string.format("%0.2f", antimatter * 1000) .. " uB/t")
mon.setTextColor(colors.white)
end
end

View File

@ -1,109 +0,0 @@
os.loadAPI("defs.lua")
os.loadAPI("log.lua")
os.loadAPI("regulator.lua")
local modem
local reactors
-- initalize the listener running on the wireless modem
-- _reactors: reactor table
function init(_reactors)
modem = peripheral.wrap("top")
reactors = _reactors
-- open listening port
if not modem.isOpen(defs.LISTEN_PORT) then
modem.open(defs.LISTEN_PORT)
end
-- send out a greeting to solicit responses for clients that are already running
broadcast(0, reactors)
end
-- handle an incoming message from the modem
-- packet: table containing message fields
function handle_message(packet)
if type(packet.message) == "number" then
-- this is a greeting
log.write("reactor " .. packet.message .. " connected", colors.green)
-- send current control command
for key, rctr in pairs(reactors) do
if rctr.id == packet.message then
send(rctr.id, rctr.control_state)
break
end
end
else
-- got reactor status
local eval_safety = false
for key, value in pairs(reactors) do
if value.id == packet.message.id then
local tag = "RCT-" .. value.id .. ": "
if value.state.run ~= packet.message.run then
value.state.run = packet.message.run
if value.state.run then
eval_safety = true
log.write(tag .. "running", colors.green)
end
end
if value.state.no_fuel ~= packet.message.no_fuel then
value.state.no_fuel = packet.message.no_fuel
if value.state.no_fuel then
eval_safety = true
log.write(tag .. "insufficient fuel", colors.gray)
end
end
if value.state.full_waste ~= packet.message.full_waste then
value.state.full_waste = packet.message.full_waste
if value.state.full_waste then
eval_safety = true
log.write(tag .. "waste tank full", colors.brown)
end
end
if value.state.high_temp ~= packet.message.high_temp then
value.state.high_temp = packet.message.high_temp
if value.state.high_temp then
eval_safety = true
log.write(tag .. "high temperature", colors.orange)
end
end
if value.state.damage_crit ~= packet.message.damage_crit then
value.state.damage_crit = packet.message.damage_crit
if value.state.damage_crit then
eval_safety = true
log.write(tag .. "critical damage", colors.red)
end
end
break
end
end
-- check to ensure safe operation
if eval_safety then
regulator.enforce_safeties()
end
end
end
-- send a message to a given reactor
-- dest: reactor ID
-- message: true or false for enable control or another value for other functionality, like 0 for greeting
function send(dest, message)
modem.transmit(dest + defs.LISTEN_PORT, defs.LISTEN_PORT, message)
end
-- broadcast a message to all reactors
-- message: true or false for enable control or another value for other functionality, like 0 for greeting
function broadcast(message)
for key, value in pairs(reactors) do
modem.transmit(value.id + defs.LISTEN_PORT, defs.LISTEN_PORT, message)
end
end

View File

@ -2,26 +2,36 @@
-- Nuclear Generation Facility SCADA Coordinator -- Nuclear Generation Facility SCADA Coordinator
-- --
os.loadAPI("scada-common/log.lua") require("/initenv").init_env()
os.loadAPI("scada-common/util.lua")
os.loadAPI("scada-common/ppm.lua")
os.loadAPI("scada-common/comms.lua")
os.loadAPI("coordinator/config.lua") local log = require("scada-common.log")
os.loadAPI("coordinator/coordinator.lua") local ppm = require("scada-common.ppm")
local util = require("scada-common.util")
local COORDINATOR_VERSION = "alpha-v0.1.0" local config = require("coordinator.config")
local coordinator = require("coordinator.coordinator")
local COORDINATOR_VERSION = "alpha-v0.1.2"
local print = util.print
local println = util.println
local print_ts = util.print_ts local print_ts = util.print_ts
local println_ts = util.println_ts
log.init("/log.txt", log.MODE.APPEND)
log.info("========================================")
log.info("BOOTING coordinator.startup " .. COORDINATOR_VERSION)
log.info("========================================")
println(">> SCADA Coordinator " .. COORDINATOR_VERSION .. " <<")
-- mount connected devices
ppm.mount_all() ppm.mount_all()
local modem = ppm.get_device("modem") local modem = ppm.get_wireless_modem()
print("| SCADA Coordinator - " .. COORDINATOR_VERSION .. " |")
-- we need a modem -- we need a modem
if modem == nil then if modem == nil then
print("Please connect a modem.") println("please connect a wireless modem")
return return
end end

18
initenv.lua Normal file
View File

@ -0,0 +1,18 @@
--
-- Initialize the Post-Boot Module Environment
--
-- initialize booted environment
local init_env = function ()
local _require = require("cc.require")
local _env = setmetatable({}, { __index = _ENV })
-- overwrite require/package globals
require, package = _require.make(_env, "/")
-- reset terminal
term.clear()
term.setCursorPos(1, 1)
end
return { init_env = init_env }

View File

@ -1,3 +1,5 @@
-- --
-- SCADA Coordinator Access on a Pocket Computer -- SCADA Coordinator Access on a Pocket Computer
-- --
require("/initenv").init_env()

View File

@ -1,8 +1,18 @@
-- set to false to run in standalone mode (safety regulation only) local config = {}
NETWORKED = true
-- set to false to run in offline mode (safety regulation only)
config.NETWORKED = true
-- unique reactor ID -- unique reactor ID
REACTOR_ID = 1 config.REACTOR_ID = 1
-- port to send packets TO server -- port to send packets TO server
SERVER_PORT = 16000 config.SERVER_PORT = 16000
-- port to listen to incoming packets FROM server -- port to listen to incoming packets FROM server
LISTEN_PORT = 14001 config.LISTEN_PORT = 14001
-- log path
config.LOG_PATH = "/log.txt"
-- log mode
-- 0 = APPEND (adds to existing file on start)
-- 1 = NEW (replaces existing file on start)
config.LOG_MODE = 0
return config

File diff suppressed because it is too large Load Diff

View File

@ -2,57 +2,98 @@
-- Reactor Programmable Logic Controller -- Reactor Programmable Logic Controller
-- --
os.loadAPI("scada-common/log.lua") require("/initenv").init_env()
os.loadAPI("scada-common/util.lua")
os.loadAPI("scada-common/ppm.lua")
os.loadAPI("scada-common/comms.lua")
os.loadAPI("config.lua") local log = require("scada-common.log")
os.loadAPI("plc.lua") local mqueue = require("scada-common.mqueue")
local ppm = require("scada-common.ppm")
local util = require("scada-common.util")
local R_PLC_VERSION = "alpha-v0.2.0" local config = require("reactor-plc.config")
local plc = require("reactor-plc.plc")
local threads = require("reactor-plc.threads")
local R_PLC_VERSION = "alpha-v0.7.2"
local print = util.print local print = util.print
local println = util.println local println = util.println
local print_ts = util.print_ts local print_ts = util.print_ts
local println_ts = util.println_ts local println_ts = util.println_ts
log._info("========================================") log.init(config.LOG_PATH, config.LOG_MODE)
log._info("BOOTING reactor-plc.startup " .. R_PLC_VERSION)
log._info("========================================") log.info("========================================")
log.info("BOOTING reactor-plc.startup " .. R_PLC_VERSION)
log.info("========================================")
println(">> Reactor PLC " .. R_PLC_VERSION .. " <<") println(">> Reactor PLC " .. R_PLC_VERSION .. " <<")
-- mount connected devices -- mount connected devices
ppm.mount_all() ppm.mount_all()
local reactor = ppm.get_fission_reactor() -- shared memory across threads
local modem = ppm.get_wireless_modem() ---@class plc_shared_memory
local __shared_memory = {
-- networked setting
networked = config.NETWORKED, ---@type boolean
local networked = config.NETWORKED -- PLC system state flags
---@class plc_state
local plc_state = { plc_state = {
init_ok = true, init_ok = true,
scram = true, shutdown = false,
degraded = false, degraded = false,
no_reactor = false, no_reactor = false,
no_modem = false no_modem = false
},
-- control setpoints
---@class setpoints
setpoints = {
burn_rate_en = false,
burn_rate = 0.0
},
-- core PLC devices
plc_dev = {
reactor = ppm.get_fission_reactor(),
modem = ppm.get_wireless_modem()
},
-- system objects
plc_sys = {
rps = nil, ---@type rps
plc_comms = nil, ---@type plc_comms
conn_watchdog = nil ---@type watchdog
},
-- message queues
q = {
mq_rps = mqueue.new(),
mq_comms_tx = mqueue.new(),
mq_comms_rx = mqueue.new()
}
} }
local smem_dev = __shared_memory.plc_dev
local smem_sys = __shared_memory.plc_sys
local plc_state = __shared_memory.plc_state
-- we need a reactor and a modem -- we need a reactor and a modem
if reactor == nil then if smem_dev.reactor == nil then
println("boot> fission reactor not found"); println("boot> fission reactor not found");
log._warning("no reactor on startup") log.warning("no reactor on startup")
plc_state.init_ok = false plc_state.init_ok = false
plc_state.degraded = true plc_state.degraded = true
plc_state.no_reactor = true plc_state.no_reactor = true
end end
if networked and modem == nil then if __shared_memory.networked and smem_dev.modem == nil then
println("boot> wireless modem not found") println("boot> wireless modem not found")
log._warning("no wireless modem on startup") log.warning("no wireless modem on startup")
if reactor ~= nil then if smem_dev.reactor ~= nil then
reactor.scram() smem_dev.reactor.scram()
end end
plc_state.init_ok = false plc_state.init_ok = false
@ -60,238 +101,75 @@ if networked and modem == nil then
plc_state.no_modem = true plc_state.no_modem = true
end end
local iss = nil -- PLC init
local plc_comms = nil local init = function ()
local conn_watchdog = nil
-- send status updates at ~3.33Hz (every 6 server ticks) (every 3 loop ticks)
-- send link requests at 0.5Hz (every 40 server ticks) (every 20 loop ticks)
local UPDATE_TICKS = 3
local LINK_TICKS = 20
local loop_clock = nil
local ticks_to_update = LINK_TICKS -- start by linking
function init()
if plc_state.init_ok then if plc_state.init_ok then
-- just booting up, no fission allowed (neutrons stay put thanks) -- just booting up, no fission allowed (neutrons stay put thanks)
reactor.scram() smem_dev.reactor.scram()
-- init internal safety system -- init reactor protection system
iss = plc.iss_init(reactor) smem_sys.rps = plc.rps_init(smem_dev.reactor)
log._debug("iss init") log.debug("init> rps init")
if networked then
-- start comms
plc_comms = plc.comms_init(config.REACTOR_ID, modem, config.LISTEN_PORT, config.SERVER_PORT, reactor, iss)
log._debug("comms init")
if __shared_memory.networked then
-- comms watchdog, 3 second timeout -- comms watchdog, 3 second timeout
conn_watchdog = util.new_watchdog(3) smem_sys.conn_watchdog = util.new_watchdog(3)
log._debug("conn watchdog started") log.debug("init> conn watchdog started")
-- start comms
smem_sys.plc_comms = plc.comms(config.REACTOR_ID, R_PLC_VERSION, smem_dev.modem, config.LISTEN_PORT, config.SERVER_PORT,
smem_dev.reactor, smem_sys.rps, smem_sys.conn_watchdog)
log.debug("init> comms init")
else else
log._debug("running without networking") println("boot> starting in offline mode");
log.debug("init> running without networking")
end end
-- loop clock (10Hz, 2 ticks) ---@diagnostic disable-next-line: undefined-field
loop_clock = os.startTimer(0.05) os.queueEvent("clock_start")
log._debug("loop clock started")
println("boot> completed"); println("boot> completed");
log.debug("init> boot completed")
else else
println("boot> system in degraded state, awaiting devices...") println("boot> system in degraded state, awaiting devices...")
log._warning("booted in a degraded state, awaiting peripheral connections...") log.warning("init> booted in a degraded state, awaiting peripheral connections...")
end end
end end
----------------------------------------
-- start system
----------------------------------------
-- initialize PLC -- initialize PLC
init() init()
-- event loop -- init threads
while true do local main_thread = threads.thread__main(__shared_memory, init)
local event, param1, param2, param3, param4, param5 = os.pullEventRaw() local rps_thread = threads.thread__rps(__shared_memory)
if __shared_memory.networked then
-- init comms threads
local comms_thread_tx = threads.thread__comms_tx(__shared_memory)
local comms_thread_rx = threads.thread__comms_rx(__shared_memory)
-- setpoint control only needed when networked
local sp_ctrl_thread = threads.thread__setpoint_control(__shared_memory)
-- run threads
parallel.waitForAll(main_thread.p_exec, rps_thread.p_exec, comms_thread_tx.p_exec, comms_thread_rx.p_exec, sp_ctrl_thread.p_exec)
if plc_state.init_ok then if plc_state.init_ok then
-- if we tried to SCRAM but failed, keep trying -- send status one last time after RPS shutdown
-- if it disconnected, isPowered will return nil (and error logs will get spammed at 10Hz, so disable reporting) smem_sys.plc_comms.send_status(plc_state.degraded)
-- in that case, SCRAM won't be called until it reconnects (this is the expected use of this check) smem_sys.plc_comms.send_rps_status()
ppm.disable_reporting()
if plc_state.scram and reactor.getStatus() then
reactor.scram()
end
ppm.enable_reporting()
end
-- check for peripheral changes before ISS checks -- close connection
if event == "peripheral_detach" then smem_sys.plc_comms.close()
local device = ppm.handle_unmount(param1)
if device.type == "fissionReactor" then
println_ts("reactor disconnected!")
log._error("reactor disconnected!")
plc_state.no_reactor = true
plc_state.degraded = true
-- send an alarm: plc_comms.send_alarm(ALARMS.PLC_PERI_DC) ?
elseif networked and device.type == "modem" then
-- we only care if this is our wireless modem
if device.dev == modem then
println_ts("wireless modem disconnected!")
log._error("comms modem disconnected!")
plc_state.no_modem = true
if plc_state.init_ok then
-- try to scram reactor if it is still connected
plc_state.scram = true
if reactor.scram() then
println_ts("successful reactor SCRAM")
log._error("successful reactor SCRAM")
else
println_ts("failed reactor SCRAM")
log._error("failed reactor SCRAM")
end
end
plc_state.degraded = true
else
log._warning("non-comms modem disconnected")
end
end
elseif event == "peripheral" then
local type, device = ppm.mount(param1)
if type == "fissionReactor" then
-- reconnected reactor
reactor = device
plc_state.scram = true
reactor.scram()
println_ts("reactor reconnected.")
log._info("reactor reconnected.")
plc_state.no_reactor = false
if plc_state.init_ok then
iss.reconnect_reactor(reactor)
if networked then
plc_comms.reconnect_reactor(reactor)
end
end
-- determine if we are still in a degraded state
if not networked or ppm.get_device("modem") ~= nil then
plc_state.degraded = false
end
elseif networked and type == "modem" then
if device.isWireless() then
-- reconnected modem
modem = device
if plc_state.init_ok then
plc_comms.reconnect_modem(modem)
end
println_ts("wireless modem reconnected.")
log._info("comms modem reconnected.")
plc_state.no_modem = false
-- determine if we are still in a degraded state
if ppm.get_device("fissionReactor") ~= nil then
plc_state.degraded = false
end
else
log._info("wired modem reconnected.")
end
end
if not plc_state.init_ok and not plc_state.degraded then
plc_state.init_ok = true
init()
end
end
-- ISS
if plc_state.init_ok then
-- if we are in standalone mode, continuously reset ISS
-- ISS will trip again if there are faults, but if it isn't cleared, the user can't re-enable
if not networked then
plc_state.scram = false
iss.reset()
end
-- check safety (SCRAM occurs if tripped)
if not plc_state.degraded then
local iss_tripped, iss_status, iss_first = iss.check()
plc_state.scram = plc_state.scram or iss_tripped
if iss_first then
println_ts("[ISS] reactor shutdown, safety tripped: " .. iss_status)
if networked then
plc_comms.send_iss_alarm(iss_status)
end
end
else
reactor.scram()
end
end
-- handle event
if event == "timer" and param1 == loop_clock then
-- basic event tick, send updated data if it is time (~3.33Hz)
-- iss was already checked (that's the main reason for this tick rate)
if networked and not plc_state.no_modem then
ticks_to_update = ticks_to_update - 1
if plc_comms.is_linked() then
if ticks_to_update <= 0 then
plc_comms.send_status(iss_tripped, plc_state.degraded)
ticks_to_update = UPDATE_TICKS
end
else
if ticks_to_update <= 0 then
plc_comms.send_link_req()
ticks_to_update = LINK_TICKS
end
end
end
-- start next clock timer
loop_clock = os.startTimer(0.05)
elseif event == "modem_message" and networked and not plc_state.no_modem then
-- got a packet
-- feed the watchdog first so it doesn't uhh...eat our packets
conn_watchdog.feed()
-- handle the packet (plc_state passed to allow clearing SCRAM flag)
local packet = plc_comms.parse_packet(p1, p2, p3, p4, p5)
plc_comms.handle_packet(packet, plc_state)
elseif event == "timer" and networked and param1 == conn_watchdog.get_timer() then
-- haven't heard from server recently? shutdown reactor
plc_state.scram = true
plc_comms.unlink()
iss.trip_timeout()
println_ts("server timeout, reactor disabled")
log._warning("server timeout, reactor disabled")
end
-- check for termination request
if event == "terminate" or ppm.should_terminate() then
log._warning("terminate requested, exiting...")
-- safe exit
if plc_state.init_ok then
plc_state.scram = true
if reactor.scram() ~= ppm.ACCESS_FAULT then
println_ts("reactor disabled")
else
-- send an alarm: plc_comms.send_alarm(ALARMS.PLC_LOST_CONTROL) ?
println_ts("exiting, reactor failed to disable")
end
end
break
end end
else
-- run threads, excluding comms
parallel.waitForAll(main_thread.p_exec, rps_thread.p_exec)
end end
-- send an alarm: plc_comms.send_alarm(ALARMS.PLC_SHUTDOWN) ?
println_ts("exited") println_ts("exited")
log._info("exited") log.info("exited")

627
reactor-plc/threads.lua Normal file
View File

@ -0,0 +1,627 @@
local log = require("scada-common.log")
local mqueue = require("scada-common.mqueue")
local ppm = require("scada-common.ppm")
local util = require("scada-common.util")
local threads = {}
local print = util.print
local println = util.println
local print_ts = util.print_ts
local println_ts = util.println_ts
local MAIN_CLOCK = 1 -- (1Hz, 20 ticks)
local RPS_SLEEP = 250 -- (250ms, 5 ticks)
local COMMS_SLEEP = 150 -- (150ms, 3 ticks)
local SP_CTRL_SLEEP = 250 -- (250ms, 5 ticks)
local BURN_RATE_RAMP_mB_s = 5.0
local MQ__RPS_CMD = {
SCRAM = 1,
DEGRADED_SCRAM = 2,
TRIP_TIMEOUT = 3
}
local MQ__COMM_CMD = {
SEND_STATUS = 1
}
-- main thread
---@param smem plc_shared_memory
---@param init function
threads.thread__main = function (smem, init)
local public = {} ---@class thread
-- execute thread
public.exec = function ()
log.debug("main thread init, clock inactive")
-- send status updates at 2Hz (every 10 server ticks) (every loop tick)
-- send link requests at 0.5Hz (every 40 server ticks) (every 4 loop ticks)
local LINK_TICKS = 4
local ticks_to_update = 0
local loop_clock = util.new_clock(MAIN_CLOCK)
-- load in from shared memory
local networked = smem.networked
local plc_state = smem.plc_state
local plc_dev = smem.plc_dev
-- event loop
while true do
-- get plc_sys fields (may have been set late due to degraded boot)
local rps = smem.plc_sys.rps
local plc_comms = smem.plc_sys.plc_comms
local conn_watchdog = smem.plc_sys.conn_watchdog
---@diagnostic disable-next-line: undefined-field
local event, param1, param2, param3, param4, param5 = os.pullEventRaw()
-- handle event
if event == "timer" and loop_clock.is_clock(param1) then
-- core clock tick
if networked then
-- start next clock timer
loop_clock.start()
-- send updated data
if not plc_state.no_modem then
if plc_comms.is_linked() then
smem.q.mq_comms_tx.push_command(MQ__COMM_CMD.SEND_STATUS)
else
if ticks_to_update == 0 then
plc_comms.send_link_req()
ticks_to_update = LINK_TICKS
else
ticks_to_update = ticks_to_update - 1
end
end
end
end
elseif event == "modem_message" and networked and plc_state.init_ok and not plc_state.no_modem then
-- got a packet
local packet = plc_comms.parse_packet(param1, param2, param3, param4, param5)
if packet ~= nil then
-- pass the packet onto the comms message queue
smem.q.mq_comms_rx.push_packet(packet)
end
elseif event == "timer" and networked and plc_state.init_ok and conn_watchdog.is_timer(param1) then
-- haven't heard from server recently? shutdown reactor
plc_comms.unlink()
smem.q.mq_rps.push_command(MQ__RPS_CMD.TRIP_TIMEOUT)
elseif event == "peripheral_detach" then
-- peripheral disconnect
local type, device = ppm.handle_unmount(param1)
if type ~= nil and device ~= nil then
if type == "fissionReactor" then
println_ts("reactor disconnected!")
log.error("reactor disconnected!")
plc_state.no_reactor = true
plc_state.degraded = true
elseif networked and type == "modem" then
-- we only care if this is our wireless modem
if device == plc_dev.modem then
println_ts("wireless modem disconnected!")
log.error("comms modem disconnected!")
plc_state.no_modem = true
if plc_state.init_ok then
-- try to scram reactor if it is still connected
smem.q.mq_rps.push_command(MQ__RPS_CMD.DEGRADED_SCRAM)
end
plc_state.degraded = true
else
log.warning("non-comms modem disconnected")
end
end
end
elseif event == "peripheral" then
-- peripheral connect
local type, device = ppm.mount(param1)
if type ~= nil and device ~= nil then
if type == "fissionReactor" then
-- reconnected reactor
plc_dev.reactor = device
smem.q.mq_rps.push_command(MQ__RPS_CMD.SCRAM)
println_ts("reactor reconnected.")
log.info("reactor reconnected")
plc_state.no_reactor = false
if plc_state.init_ok then
rps.reconnect_reactor(plc_dev.reactor)
if networked then
plc_comms.reconnect_reactor(plc_dev.reactor)
end
end
-- determine if we are still in a degraded state
if not networked or not plc_state.no_modem then
plc_state.degraded = false
end
elseif networked and type == "modem" then
if device.isWireless() then
-- reconnected modem
plc_dev.modem = device
if plc_state.init_ok then
plc_comms.reconnect_modem(plc_dev.modem)
end
println_ts("wireless modem reconnected.")
log.info("comms modem reconnected")
plc_state.no_modem = false
-- determine if we are still in a degraded state
if not plc_state.no_reactor then
plc_state.degraded = false
end
else
log.info("wired modem reconnected")
end
end
end
-- if not init'd and no longer degraded, proceed to init
if not plc_state.init_ok and not plc_state.degraded then
plc_state.init_ok = true
init()
end
elseif event == "clock_start" then
-- start loop clock
loop_clock.start()
log.debug("main thread clock started")
end
-- check for termination request
if event == "terminate" or ppm.should_terminate() then
log.info("terminate requested, main thread exiting")
-- rps handles reactor shutdown
plc_state.shutdown = true
break
end
end
end
-- execute the thread in a protected mode, retrying it on return if not shutting down
public.p_exec = function ()
local plc_state = smem.plc_state
while not plc_state.shutdown do
local status, result = pcall(public.exec)
if status == false then
log.fatal(result)
end
-- if status is true, then we are probably exiting, so this won't matter
-- if not, we need to restart the clock
-- this thread cannot be slept because it will miss events (namely "terminate" otherwise)
if not plc_state.shutdown then
log.info("main thread restarting now...")
---@diagnostic disable-next-line: undefined-field
os.queueEvent("clock_start")
end
end
end
return public
end
-- RPS operation thread
---@param smem plc_shared_memory
threads.thread__rps = function (smem)
local public = {} ---@class thread
-- execute thread
public.exec = function ()
log.debug("rps thread start")
-- load in from shared memory
local networked = smem.networked
local plc_state = smem.plc_state
local plc_dev = smem.plc_dev
local rps_queue = smem.q.mq_rps
local was_linked = false
local last_update = util.time()
-- thread loop
while true do
-- get plc_sys fields (may have been set late due to degraded boot)
local rps = smem.plc_sys.rps
local plc_comms = smem.plc_sys.plc_comms
-- get reactor, may have changed do to disconnect/reconnect
local reactor = plc_dev.reactor
-- RPS checks
if plc_state.init_ok then
-- SCRAM if no open connection
if networked and not plc_comms.is_linked() then
if was_linked then
was_linked = false
rps.trip_timeout()
end
else
-- would do elseif not networked but there is no reason to do that extra operation
was_linked = true
end
-- if we tried to SCRAM but failed, keep trying
-- in that case, SCRAM won't be called until it reconnects (this is the expected use of this check)
---@diagnostic disable-next-line: need-check-nil
if not plc_state.no_reactor and rps.is_tripped() and reactor.getStatus() then
rps.scram()
end
-- if we are in standalone mode, continuously reset RPS
-- RPS will trip again if there are faults, but if it isn't cleared, the user can't re-enable
if not networked then rps.reset() end
-- check safety (SCRAM occurs if tripped)
if not plc_state.no_reactor then
local rps_tripped, rps_status_string, rps_first = rps.check()
if rps_tripped and rps_first then
println_ts("[RPS] SCRAM! safety trip: " .. rps_status_string)
if networked and not plc_state.no_modem then
plc_comms.send_rps_alarm(rps_status_string)
end
end
end
end
-- check for messages in the message queue
while rps_queue.ready() and not plc_state.shutdown do
local msg = rps_queue.pop()
if msg ~= nil then
if msg.qtype == mqueue.TYPE.COMMAND then
-- received a command
if plc_state.init_ok then
if msg.message == MQ__RPS_CMD.SCRAM then
-- SCRAM
rps.scram()
elseif msg.message == MQ__RPS_CMD.DEGRADED_SCRAM then
-- lost peripheral(s)
rps.trip_fault()
elseif msg.message == MQ__RPS_CMD.TRIP_TIMEOUT then
-- watchdog tripped
rps.trip_timeout()
println_ts("server timeout")
log.warning("server timeout")
end
end
elseif msg.qtype == mqueue.TYPE.DATA then
-- received data
elseif msg.qtype == mqueue.TYPE.PACKET then
-- received a packet
end
end
-- quick yield
util.nop()
end
-- check for termination request
if plc_state.shutdown then
-- safe exit
log.info("rps thread shutdown initiated")
if plc_state.init_ok then
if rps.scram() then
println_ts("reactor disabled")
log.info("rps thread reactor SCRAM OK")
else
println_ts("exiting, reactor failed to disable")
log.error("rps thread failed to SCRAM reactor on exit")
end
end
log.info("rps thread exiting")
break
end
-- delay before next check
last_update = util.adaptive_delay(RPS_SLEEP, last_update)
end
end
-- execute the thread in a protected mode, retrying it on return if not shutting down
public.p_exec = function ()
local plc_state = smem.plc_state
while not plc_state.shutdown do
local status, result = pcall(public.exec)
if status == false then
log.fatal(result)
end
if not plc_state.shutdown then
if plc_state.init_ok then smem.plc_sys.rps.scram() end
log.info("rps thread restarting in 5 seconds...")
util.psleep(5)
end
end
end
return public
end
-- communications sender thread
---@param smem plc_shared_memory
threads.thread__comms_tx = function (smem)
local public = {} ---@class thread
-- execute thread
public.exec = function ()
log.debug("comms tx thread start")
-- load in from shared memory
local plc_state = smem.plc_state
local comms_queue = smem.q.mq_comms_tx
local last_update = util.time()
-- thread loop
while true do
-- get plc_sys fields (may have been set late due to degraded boot)
local plc_comms = smem.plc_sys.plc_comms
-- check for messages in the message queue
while comms_queue.ready() and not plc_state.shutdown do
local msg = comms_queue.pop()
if msg ~= nil and plc_state.init_ok then
if msg.qtype == mqueue.TYPE.COMMAND then
-- received a command
if msg.message == MQ__COMM_CMD.SEND_STATUS then
-- send PLC/RPS status
plc_comms.send_status(plc_state.degraded)
plc_comms.send_rps_status()
end
elseif msg.qtype == mqueue.TYPE.DATA then
-- received data
elseif msg.qtype == mqueue.TYPE.PACKET then
-- received a packet
end
end
-- quick yield
util.nop()
end
-- check for termination request
if plc_state.shutdown then
log.info("comms tx thread exiting")
break
end
-- delay before next check
last_update = util.adaptive_delay(COMMS_SLEEP, last_update)
end
end
-- execute the thread in a protected mode, retrying it on return if not shutting down
public.p_exec = function ()
local plc_state = smem.plc_state
while not plc_state.shutdown do
local status, result = pcall(public.exec)
if status == false then
log.fatal(result)
end
if not plc_state.shutdown then
log.info("comms tx thread restarting in 5 seconds...")
util.psleep(5)
end
end
end
return public
end
-- communications handler thread
---@param smem plc_shared_memory
threads.thread__comms_rx = function (smem)
local public = {} ---@class thread
-- execute thread
public.exec = function ()
log.debug("comms rx thread start")
-- load in from shared memory
local plc_state = smem.plc_state
local setpoints = smem.setpoints
local comms_queue = smem.q.mq_comms_rx
local last_update = util.time()
-- thread loop
while true do
-- get plc_sys fields (may have been set late due to degraded boot)
local plc_comms = smem.plc_sys.plc_comms
-- check for messages in the message queue
while comms_queue.ready() and not plc_state.shutdown do
local msg = comms_queue.pop()
if msg ~= nil and plc_state.init_ok then
if msg.qtype == mqueue.TYPE.COMMAND then
-- received a command
elseif msg.qtype == mqueue.TYPE.DATA then
-- received data
elseif msg.qtype == mqueue.TYPE.PACKET then
-- received a packet
-- handle the packet (setpoints passed to update burn rate setpoint)
-- (plc_state passed to check if degraded)
plc_comms.handle_packet(msg.message, setpoints, plc_state)
end
end
-- quick yield
util.nop()
end
-- check for termination request
if plc_state.shutdown then
log.info("comms rx thread exiting")
break
end
-- delay before next check
last_update = util.adaptive_delay(COMMS_SLEEP, last_update)
end
end
-- execute the thread in a protected mode, retrying it on return if not shutting down
public.p_exec = function ()
local plc_state = smem.plc_state
while not plc_state.shutdown do
local status, result = pcall(public.exec)
if status == false then
log.fatal(result)
end
if not plc_state.shutdown then
log.info("comms rx thread restarting in 5 seconds...")
util.psleep(5)
end
end
end
return public
end
-- apply setpoints
---@param smem plc_shared_memory
threads.thread__setpoint_control = function (smem)
local public = {} ---@class thread
-- execute thread
public.exec = function ()
log.debug("setpoint control thread start")
-- load in from shared memory
local plc_state = smem.plc_state
local setpoints = smem.setpoints
local plc_dev = smem.plc_dev
local last_update = util.time()
local running = false
local last_sp_burn = 0.0
-- do not use the actual elapsed time, it could spike
-- we do not want to have big jumps as that is what we are trying to avoid in the first place
local min_elapsed_s = SP_CTRL_SLEEP / 1000.0
-- thread loop
while true do
-- get plc_sys fields (may have been set late due to degraded boot)
local rps = smem.plc_sys.rps
-- get reactor, may have changed do to disconnect/reconnect
local reactor = plc_dev.reactor
if plc_state.init_ok and not plc_state.no_reactor then
-- check if we should start ramping
if setpoints.burn_rate_en and setpoints.burn_rate ~= last_sp_burn then
if rps.is_active() then
if math.abs(setpoints.burn_rate - last_sp_burn) <= 5 then
-- update without ramp if <= 5 mB/t change
log.debug("setting burn rate directly to " .. setpoints.burn_rate .. "mB/t")
---@diagnostic disable-next-line: need-check-nil
reactor.setBurnRate(setpoints.burn_rate)
else
log.debug("starting burn rate ramp from " .. last_sp_burn .. "mB/t to " .. setpoints.burn_rate .. "mB/t")
running = true
end
last_sp_burn = setpoints.burn_rate
else
last_sp_burn = 0.0
end
end
-- only check I/O if active to save on processing time
if running then
-- clear so we can later evaluate if we should keep running
running = false
-- adjust burn rate (setpoints.burn_rate)
if setpoints.burn_rate_en then
if rps.is_active() then
---@diagnostic disable-next-line: need-check-nil
local current_burn_rate = reactor.getBurnRate()
-- we yielded, check enable again
if setpoints.burn_rate_en and (current_burn_rate ~= ppm.ACCESS_FAULT) and (current_burn_rate ~= setpoints.burn_rate) then
-- calculate new burn rate
local new_burn_rate = current_burn_rate
if setpoints.burn_rate > current_burn_rate then
-- need to ramp up
local new_burn_rate = current_burn_rate + (BURN_RATE_RAMP_mB_s * min_elapsed_s)
if new_burn_rate > setpoints.burn_rate then
new_burn_rate = setpoints.burn_rate
end
else
-- need to ramp down
local new_burn_rate = current_burn_rate - (BURN_RATE_RAMP_mB_s * min_elapsed_s)
if new_burn_rate < setpoints.burn_rate then
new_burn_rate = setpoints.burn_rate
end
end
-- set the burn rate
---@diagnostic disable-next-line: need-check-nil
reactor.setBurnRate(new_burn_rate)
running = running or (new_burn_rate ~= setpoints.burn_rate)
end
else
last_sp_burn = 0.0
end
end
end
end
-- check for termination request
if plc_state.shutdown then
log.info("setpoint control thread exiting")
break
end
-- delay before next check
last_update = util.adaptive_delay(SP_CTRL_SLEEP, last_update)
end
end
-- execute the thread in a protected mode, retrying it on return if not shutting down
public.p_exec = function ()
local plc_state = smem.plc_state
while not plc_state.shutdown do
local status, result = pcall(public.exec)
if status == false then
log.fatal(result)
end
if not plc_state.shutdown then
log.info("setpoint control thread restarting in 5 seconds...")
util.psleep(5)
end
end
end
return public
end
return threads

View File

@ -1,45 +1,52 @@
-- #REQUIRES rsio.lua local rsio = require("scada-common.rsio")
local config = {}
-- port to send packets TO server -- port to send packets TO server
SERVER_PORT = 16000 config.SERVER_PORT = 16000
-- port to listen to incoming packets FROM server -- port to listen to incoming packets FROM server
LISTEN_PORT = 15001 config.LISTEN_PORT = 15001
-- log path
config.LOG_PATH = "/log.txt"
-- log mode
-- 0 = APPEND (adds to existing file on start)
-- 1 = NEW (replaces existing file on start)
config.LOG_MODE = 0
-- RTU peripheral devices (named: side/network device name) -- RTU peripheral devices (named: side/network device name)
RTU_DEVICES = { config.RTU_DEVICES = {
{ {
name = "boiler_0", name = "boiler_1",
index = 1, index = 1,
for_reactor = 1 for_reactor = 1
}, },
{ {
name = "turbine_0", name = "turbine_1",
index = 1, index = 1,
for_reactor = 1 for_reactor = 1
} }
} }
-- RTU redstone interface definitions -- RTU redstone interface definitions
RTU_REDSTONE = { config.RTU_REDSTONE = {
{ {
for_reactor = 1, for_reactor = 1,
io = { io = {
{ {
channel = rsio.RS_IO.WASTE_PO, channel = rsio.IO.WASTE_PO,
side = "top", side = "top",
bundled_color = colors.blue, bundled_color = colors.blue
for_reactor = 1
}, },
{ {
channel = rsio.RS_IO.WASTE_PU, channel = rsio.IO.WASTE_PU,
side = "top", side = "top",
bundled_color = colors.cyan, bundled_color = colors.cyan
for_reactor = 1
}, },
{ {
channel = rsio.RS_IO.WASTE_AM, channel = rsio.IO.WASTE_AM,
side = "top", side = "top",
bundled_color = colors.purple, bundled_color = colors.purple
for_reactor = 1
} }
} }
} }
} }
return config

View File

@ -1,15 +1,15 @@
-- #REQUIRES rtu.lua local rtu = require("rtu.rtu")
function new(boiler) local boiler_rtu = {}
-- create new boiler (mek 10.0) device
---@param boiler table
boiler_rtu.new = function (boiler)
local self = { local self = {
rtu = rtu.rtu_init(), rtu = rtu.init_unit(),
boiler = boiler boiler = boiler
} }
local rtu_interface = function ()
return self.rtu
end
-- discrete inputs -- -- discrete inputs --
-- none -- none
@ -45,7 +45,7 @@ function new(boiler)
-- holding registers -- -- holding registers --
-- none -- none
return { return self.rtu.interface()
rtu_interface = rtu_interface
}
end end
return boiler_rtu

58
rtu/dev/boilerv_rtu.lua Normal file
View File

@ -0,0 +1,58 @@
local rtu = require("rtu.rtu")
local boilerv_rtu = {}
-- create new boiler (mek 10.1+) device
---@param boiler table
boilerv_rtu.new = function (boiler)
local self = {
rtu = rtu.init_unit(),
boiler = boiler
}
-- discrete inputs --
self.rtu.connect_di(self.boiler.isFormed)
-- coils --
-- none
-- input registers --
-- multiblock properties
self.rtu.connect_input_reg(self.boiler.getLength)
self.rtu.connect_input_reg(self.boiler.getWidth)
self.rtu.connect_input_reg(self.boiler.getHeight)
self.rtu.connect_input_reg(self.boiler.getMinPos)
self.rtu.connect_input_reg(self.boiler.getMaxPos)
-- build properties
self.rtu.connect_input_reg(self.boiler.getBoilCapacity)
self.rtu.connect_input_reg(self.boiler.getSteamCapacity)
self.rtu.connect_input_reg(self.boiler.getWaterCapacity)
self.rtu.connect_input_reg(self.boiler.getHeatedCoolantCapacity)
self.rtu.connect_input_reg(self.boiler.getCooledCoolantCapacity)
self.rtu.connect_input_reg(self.boiler.getSuperheaters)
self.rtu.connect_input_reg(self.boiler.getMaxBoilRate)
self.rtu.connect_input_reg(self.boiler.getEnvironmentalLoss)
-- current state
self.rtu.connect_input_reg(self.boiler.getTemperature)
self.rtu.connect_input_reg(self.boiler.getBoilRate)
-- tanks
self.rtu.connect_input_reg(self.boiler.getSteam)
self.rtu.connect_input_reg(self.boiler.getSteamNeeded)
self.rtu.connect_input_reg(self.boiler.getSteamFilledPercentage)
self.rtu.connect_input_reg(self.boiler.getWater)
self.rtu.connect_input_reg(self.boiler.getWaterNeeded)
self.rtu.connect_input_reg(self.boiler.getWaterFilledPercentage)
self.rtu.connect_input_reg(self.boiler.getHeatedCoolant)
self.rtu.connect_input_reg(self.boiler.getHeatedCoolantNeeded)
self.rtu.connect_input_reg(self.boiler.getHeatedCoolantFilledPercentage)
self.rtu.connect_input_reg(self.boiler.getCooledCoolant)
self.rtu.connect_input_reg(self.boiler.getCooledCoolantNeeded)
self.rtu.connect_input_reg(self.boiler.getCooledCoolantFilledPercentage)
-- holding registers --
-- none
return self.rtu.interface()
end
return boilerv_rtu

View File

@ -0,0 +1,39 @@
local rtu = require("rtu.rtu")
local energymachine_rtu = {}
-- create new energy machine device
---@param machine table
energymachine_rtu.new = function (machine)
local self = {
rtu = rtu.init_unit(),
machine = machine
}
---@class rtu_device
local public = {}
-- get the RTU interface
public.rtu_interface = function () return self.rtu end
-- discrete inputs --
-- none
-- coils --
-- none
-- input registers --
-- build properties
self.rtu.connect_input_reg(self.machine.getTotalMaxEnergy)
-- containers
self.rtu.connect_input_reg(self.machine.getTotalEnergy)
self.rtu.connect_input_reg(self.machine.getTotalEnergyNeeded)
self.rtu.connect_input_reg(self.machine.getTotalEnergyFilledPercentage)
-- holding registers --
-- none
return public
end
return energymachine_rtu

View File

@ -1,33 +1,45 @@
-- #REQUIRES rtu.lua local rtu = require("rtu.rtu")
function new(imatrix) local imatrix_rtu = {}
-- create new induction matrix (mek 10.1+) device
---@param imatrix table
imatrix_rtu.new = function (imatrix)
local self = { local self = {
rtu = rtu.rtu_init(), rtu = rtu.init_unit(),
imatrix = imatrix imatrix = imatrix
} }
local rtu_interface = function ()
return self.rtu
end
-- discrete inputs -- -- discrete inputs --
-- none self.rtu.connect_di(self.boiler.isFormed)
-- coils -- -- coils --
-- none -- none
-- input registers -- -- input registers --
-- multiblock properties
self.rtu.connect_input_reg(self.boiler.getLength)
self.rtu.connect_input_reg(self.boiler.getWidth)
self.rtu.connect_input_reg(self.boiler.getHeight)
self.rtu.connect_input_reg(self.boiler.getMinPos)
self.rtu.connect_input_reg(self.boiler.getMaxPos)
-- build properties -- build properties
self.rtu.connect_input_reg(self.imatrix.getTotalMaxEnergy) self.rtu.connect_input_reg(self.imatrix.getMaxEnergy)
self.rtu.connect_input_reg(self.imatrix.getTransferCap)
self.rtu.connect_input_reg(self.imatrix.getInstalledCells)
self.rtu.connect_input_reg(self.imatrix.getInstalledProviders)
-- containers -- containers
self.rtu.connect_input_reg(self.imatrix.getTotalEnergy) self.rtu.connect_input_reg(self.imatrix.getEnergy)
self.rtu.connect_input_reg(self.imatrix.getTotalEnergyNeeded) self.rtu.connect_input_reg(self.imatrix.getEnergyNeeded)
self.rtu.connect_input_reg(self.imatrix.getTotalEnergyFilledPercentage) self.rtu.connect_input_reg(self.imatrix.getEnergyFilledPercentage)
-- I/O rates
self.rtu.connect_input_reg(self.imatrix.getLastInput)
self.rtu.connect_input_reg(self.imatrix.getLastOutput)
-- holding registers -- -- holding registers --
-- none -- none
return { return self.rtu.interface()
rtu_interface = rtu_interface
}
end end
return imatrix_rtu

View File

@ -1,20 +1,37 @@
-- #REQUIRES rtu.lua local rtu = require("rtu.rtu")
-- #REQUIRES rsio.lua local rsio = require("scada-common.rsio")
-- note: this RTU makes extensive use of the programming concept of closures
local redstone_rtu = {}
local digital_read = rsio.digital_read local digital_read = rsio.digital_read
local digital_write = rsio.digital_write
local digital_is_active = rsio.digital_is_active local digital_is_active = rsio.digital_is_active
function new() -- create new redstone device
redstone_rtu.new = function ()
local self = { local self = {
rtu = rtu.rtu_init() rtu = rtu.init_unit()
} }
local rtu_interface = function () -- get RTU interface
return self.rtu local interface = self.rtu.interface()
end
local link_di = function (channel, side, color) ---@class rtu_rs_device
--- extends rtu_device; fields added manually to please Lua diagnostics
local public = {
io_count = interface.io_count,
read_coil = interface.read_coil,
read_di = interface.read_di,
read_holding_reg = interface.read_holding_reg,
read_input_reg = interface.read_input_reg,
write_coil = interface.write_coil,
write_holding_reg = interface.write_holding_reg
}
-- link digital input
---@param side string
---@param color integer
public.link_di = function (side, color)
local f_read = nil local f_read = nil
if color then if color then
@ -30,7 +47,11 @@ function new()
self.rtu.connect_di(f_read) self.rtu.connect_di(f_read)
end end
local link_do = function (channel, side, color) -- link digital output
---@param channel RS_IO
---@param side string
---@param color integer
public.link_do = function (channel, side, color)
local f_read = nil local f_read = nil
local f_write = nil local f_write = nil
@ -41,12 +62,11 @@ function new()
f_write = function (level) f_write = function (level)
local output = rs.getBundledOutput(side) local output = rs.getBundledOutput(side)
local active = digital_is_active(channel, level)
if active then if digital_write(channel, level) then
colors.combine(output, color) output = colors.combine(output, color)
else else
colors.subtract(output, color) output = colors.subtract(output, color)
end end
rs.setBundledOutput(side, output) rs.setBundledOutput(side, output)
@ -64,7 +84,9 @@ function new()
self.rtu.connect_coil(f_read, f_write) self.rtu.connect_coil(f_read, f_write)
end end
local link_ai = function (channel, side) -- link analog input
---@param side string
public.link_ai = function (side)
self.rtu.connect_input_reg( self.rtu.connect_input_reg(
function () function ()
return rs.getAnalogInput(side) return rs.getAnalogInput(side)
@ -72,7 +94,9 @@ function new()
) )
end end
local link_ao = function (channel, side) -- link analog output
---@param side string
public.link_ao = function (side)
self.rtu.connect_holding_reg( self.rtu.connect_holding_reg(
function () function ()
return rs.getAnalogOutput(side) return rs.getAnalogOutput(side)
@ -83,11 +107,7 @@ function new()
) )
end end
return { return public
rtu_interface = rtu_interface,
link_di = link_di,
link_do = link_do,
link_ai = link_ai,
link_ao = link_ao
}
end end
return redstone_rtu

View File

@ -1,15 +1,15 @@
-- #REQUIRES rtu.lua local rtu = require("rtu.rtu")
function new(turbine) local turbine_rtu = {}
-- create new turbine (mek 10.0) device
---@param turbine table
turbine_rtu.new = function (turbine)
local self = { local self = {
rtu = rtu.rtu_init(), rtu = rtu.init_unit(),
turbine = turbine turbine = turbine
} }
local rtu_interface = function ()
return self.rtu
end
-- discrete inputs -- -- discrete inputs --
-- none -- none
@ -23,15 +23,15 @@ function new(turbine)
self.rtu.connect_input_reg(self.turbine.getVents) self.rtu.connect_input_reg(self.turbine.getVents)
self.rtu.connect_input_reg(self.turbine.getDispersers) self.rtu.connect_input_reg(self.turbine.getDispersers)
self.rtu.connect_input_reg(self.turbine.getCondensers) self.rtu.connect_input_reg(self.turbine.getCondensers)
self.rtu.connect_input_reg(self.turbine.getDumpingMode)
self.rtu.connect_input_reg(self.turbine.getSteamCapacity) self.rtu.connect_input_reg(self.turbine.getSteamCapacity)
self.rtu.connect_input_reg(self.turbine.getMaxFlowRate) self.rtu.connect_input_reg(self.turbine.getMaxFlowRate)
self.rtu.connect_input_reg(self.turbine.getMaxWaterOutput)
self.rtu.connect_input_reg(self.turbine.getMaxProduction) self.rtu.connect_input_reg(self.turbine.getMaxProduction)
self.rtu.connect_input_reg(self.turbine.getMaxWaterOutput)
-- current state -- current state
self.rtu.connect_input_reg(self.turbine.getFlowRate) self.rtu.connect_input_reg(self.turbine.getFlowRate)
self.rtu.connect_input_reg(self.turbine.getProductionRate) self.rtu.connect_input_reg(self.turbine.getProductionRate)
self.rtu.connect_input_reg(self.turbine.getLastSteamInputRate) self.rtu.connect_input_reg(self.turbine.getLastSteamInputRate)
self.rtu.connect_input_reg(self.turbine.getDumpingMode)
-- tanks -- tanks
self.rtu.connect_input_reg(self.turbine.getSteam) self.rtu.connect_input_reg(self.turbine.getSteam)
self.rtu.connect_input_reg(self.turbine.getSteamNeeded) self.rtu.connect_input_reg(self.turbine.getSteamNeeded)
@ -40,7 +40,7 @@ function new(turbine)
-- holding registers -- -- holding registers --
-- none -- none
return { return self.rtu.interface()
rtu_interface = rtu_interface
}
end end
return turbine_rtu

57
rtu/dev/turbinev_rtu.lua Normal file
View File

@ -0,0 +1,57 @@
local rtu = require("rtu.rtu")
local turbinev_rtu = {}
-- create new turbine (mek 10.1+) device
---@param turbine table
turbinev_rtu.new = function (turbine)
local self = {
rtu = rtu.init_unit(),
turbine = turbine
}
-- discrete inputs --
self.rtu.connect_di(self.boiler.isFormed)
-- coils --
self.rtu.connect_coil(function () self.turbine.incrementDumpingMode() end, function () end)
self.rtu.connect_coil(function () self.turbine.decrementDumpingMode() end, function () end)
-- input registers --
-- multiblock properties
self.rtu.connect_input_reg(self.boiler.getLength)
self.rtu.connect_input_reg(self.boiler.getWidth)
self.rtu.connect_input_reg(self.boiler.getHeight)
self.rtu.connect_input_reg(self.boiler.getMinPos)
self.rtu.connect_input_reg(self.boiler.getMaxPos)
-- build properties
self.rtu.connect_input_reg(self.turbine.getBlades)
self.rtu.connect_input_reg(self.turbine.getCoils)
self.rtu.connect_input_reg(self.turbine.getVents)
self.rtu.connect_input_reg(self.turbine.getDispersers)
self.rtu.connect_input_reg(self.turbine.getCondensers)
self.rtu.connect_input_reg(self.turbine.getDumpingMode)
self.rtu.connect_input_reg(self.turbine.getSteamCapacity)
self.rtu.connect_input_reg(self.turbine.getMaxEnergy)
self.rtu.connect_input_reg(self.turbine.getMaxFlowRate)
self.rtu.connect_input_reg(self.turbine.getMaxWaterOutput)
self.rtu.connect_input_reg(self.turbine.getMaxProduction)
-- current state
self.rtu.connect_input_reg(self.turbine.getFlowRate)
self.rtu.connect_input_reg(self.turbine.getProductionRate)
self.rtu.connect_input_reg(self.turbine.getLastSteamInputRate)
-- tanks/containers
self.rtu.connect_input_reg(self.turbine.getSteam)
self.rtu.connect_input_reg(self.turbine.getSteamNeeded)
self.rtu.connect_input_reg(self.turbine.getSteamFilledPercentage)
self.rtu.connect_input_reg(self.turbine.getEnergy)
self.rtu.connect_input_reg(self.turbine.getEnergyNeeded)
self.rtu.connect_input_reg(self.turbine.getEnergyFilledPercentage)
-- holding registers --
self.rtu.connect_holding_reg(self.turbine.setDumpingMode, self.turbine.getDumpingMode)
return self.rtu.interface()
end
return turbinev_rtu

439
rtu/modbus.lua Normal file
View File

@ -0,0 +1,439 @@
local comms = require("scada-common.comms")
local types = require("scada-common.types")
local modbus = {}
local MODBUS_FCODE = types.MODBUS_FCODE
local MODBUS_EXCODE = types.MODBUS_EXCODE
-- new modbus comms handler object
---@param rtu_dev rtu_device|rtu_rs_device RTU device
---@param use_parallel_read boolean whether or not to use parallel calls when reading
modbus.new = function (rtu_dev, use_parallel_read)
local self = {
rtu = rtu_dev,
use_parallel = use_parallel_read
}
---@class modbus
local public = {}
local insert = table.insert
---@param c_addr_start integer
---@param count integer
---@return boolean ok, table readings
local _1_read_coils = function (c_addr_start, count)
local tasks = {}
local readings = {}
local access_fault = false
local _, coils, _, _ = self.rtu.io_count()
local return_ok = ((c_addr_start + count) <= (coils + 1)) and (count > 0)
if return_ok then
for i = 1, count do
local addr = c_addr_start + i - 1
if self.use_parallel then
insert(tasks, function ()
local reading, fault = self.rtu.read_coil(addr)
if fault then access_fault = true else readings[i] = reading end
end)
else
readings[i], access_fault = self.rtu.read_coil(addr)
if access_fault then
return_ok = false
readings = MODBUS_EXCODE.SERVER_DEVICE_FAIL
break
end
end
end
-- run parallel tasks if configured
if self.use_parallel then
parallel.waitForAll(table.unpack(tasks))
if access_fault then
return_ok = false
readings = MODBUS_EXCODE.SERVER_DEVICE_FAIL
end
end
else
readings = MODBUS_EXCODE.ILLEGAL_DATA_ADDR
end
return return_ok, readings
end
---@param di_addr_start integer
---@param count integer
---@return boolean ok, table readings
local _2_read_discrete_inputs = function (di_addr_start, count)
local tasks = {}
local readings = {}
local access_fault = false
local discrete_inputs, _, _, _ = self.rtu.io_count()
local return_ok = ((di_addr_start + count) <= (discrete_inputs + 1)) and (count > 0)
if return_ok then
for i = 1, count do
local addr = di_addr_start + i - 1
if self.use_parallel then
insert(tasks, function ()
local reading, fault = self.rtu.read_di(addr)
if fault then access_fault = true else readings[i] = reading end
end)
else
readings[i], access_fault = self.rtu.read_di(addr)
if access_fault then
return_ok = false
readings = MODBUS_EXCODE.SERVER_DEVICE_FAIL
break
end
end
end
-- run parallel tasks if configured
if self.use_parallel then
parallel.waitForAll(table.unpack(tasks))
if access_fault then
return_ok = false
readings = MODBUS_EXCODE.SERVER_DEVICE_FAIL
end
end
else
readings = MODBUS_EXCODE.ILLEGAL_DATA_ADDR
end
return return_ok, readings
end
---@param hr_addr_start integer
---@param count integer
---@return boolean ok, table readings
local _3_read_multiple_holding_registers = function (hr_addr_start, count)
local tasks = {}
local readings = {}
local access_fault = false
local _, _, _, hold_regs = self.rtu.io_count()
local return_ok = ((hr_addr_start + count) <= (hold_regs + 1)) and (count > 0)
if return_ok then
for i = 1, count do
local addr = hr_addr_start + i - 1
if self.use_parallel then
insert(tasks, function ()
local reading, fault = self.rtu.read_holding_reg(addr)
if fault then access_fault = true else readings[i] = reading end
end)
else
readings[i], access_fault = self.rtu.read_holding_reg(addr)
if access_fault then
return_ok = false
readings = MODBUS_EXCODE.SERVER_DEVICE_FAIL
break
end
end
end
-- run parallel tasks if configured
if self.use_parallel then
parallel.waitForAll(table.unpack(tasks))
if access_fault then
return_ok = false
readings = MODBUS_EXCODE.SERVER_DEVICE_FAIL
end
end
else
readings = MODBUS_EXCODE.ILLEGAL_DATA_ADDR
end
return return_ok, readings
end
---@param ir_addr_start integer
---@param count integer
---@return boolean ok, table readings
local _4_read_input_registers = function (ir_addr_start, count)
local tasks = {}
local readings = {}
local access_fault = false
local _, _, input_regs, _ = self.rtu.io_count()
local return_ok = ((ir_addr_start + count) <= (input_regs + 1)) and (count > 0)
if return_ok then
for i = 1, count do
local addr = ir_addr_start + i - 1
if self.use_parallel then
insert(tasks, function ()
local reading, fault = self.rtu.read_input_reg(addr)
if fault then access_fault = true else readings[i] = reading end
end)
else
readings[i], access_fault = self.rtu.read_input_reg(addr)
if access_fault then
return_ok = false
readings = MODBUS_EXCODE.SERVER_DEVICE_FAIL
break
end
end
end
-- run parallel tasks if configured
if self.use_parallel then
parallel.waitForAll(table.unpack(tasks))
if access_fault then
return_ok = false
readings = MODBUS_EXCODE.SERVER_DEVICE_FAIL
end
end
else
readings = MODBUS_EXCODE.ILLEGAL_DATA_ADDR
end
return return_ok, readings
end
---@param c_addr integer
---@param value any
---@return boolean ok, MODBUS_EXCODE|nil
local _5_write_single_coil = function (c_addr, value)
local response = nil
local _, coils, _, _ = self.rtu.io_count()
local return_ok = c_addr <= coils
if return_ok then
local access_fault = self.rtu.write_coil(c_addr, value)
if access_fault then
return_ok = false
response = MODBUS_EXCODE.SERVER_DEVICE_FAIL
end
else
response = MODBUS_EXCODE.ILLEGAL_DATA_ADDR
end
return return_ok, response
end
---@param hr_addr integer
---@param value any
---@return boolean ok, MODBUS_EXCODE|nil
local _6_write_single_holding_register = function (hr_addr, value)
local response = nil
local _, _, _, hold_regs = self.rtu.io_count()
local return_ok = hr_addr <= hold_regs
if return_ok then
local access_fault = self.rtu.write_holding_reg(hr_addr, value)
if access_fault then
return_ok = false
response = MODBUS_EXCODE.SERVER_DEVICE_FAIL
end
else
response = MODBUS_EXCODE.ILLEGAL_DATA_ADDR
end
return return_ok, response
end
---@param c_addr_start integer
---@param values any
---@return boolean ok, MODBUS_EXCODE|nil
local _15_write_multiple_coils = function (c_addr_start, values)
local response = nil
local _, coils, _, _ = self.rtu.io_count()
local count = #values
local return_ok = ((c_addr_start + count) <= (coils + 1)) and (count > 0)
if return_ok then
for i = 1, count do
local addr = c_addr_start + i - 1
local access_fault = self.rtu.write_coil(addr, values[i])
if access_fault then
return_ok = false
response = MODBUS_EXCODE.SERVER_DEVICE_FAIL
break
end
end
else
response = MODBUS_EXCODE.ILLEGAL_DATA_ADDR
end
return return_ok, response
end
---@param hr_addr_start integer
---@param values any
---@return boolean ok, MODBUS_EXCODE|nil
local _16_write_multiple_holding_registers = function (hr_addr_start, values)
local response = nil
local _, _, _, hold_regs = self.rtu.io_count()
local count = #values
local return_ok = ((hr_addr_start + count) <= (hold_regs + 1)) and (count > 0)
if return_ok then
for i = 1, count do
local addr = hr_addr_start + i - 1
local access_fault = self.rtu.write_holding_reg(addr, values[i])
if access_fault then
return_ok = false
response = MODBUS_EXCODE.SERVER_DEVICE_FAIL
break
end
end
else
response = MODBUS_EXCODE.ILLEGAL_DATA_ADDR
end
return return_ok, response
end
-- validate a request without actually executing it
---@param packet modbus_frame
---@return boolean return_code, modbus_packet reply
public.check_request = function (packet)
local return_code = true
local response = { MODBUS_EXCODE.ACKNOWLEDGE }
if packet.length == 2 then
-- handle by function code
if packet.func_code == MODBUS_FCODE.READ_COILS then
elseif packet.func_code == MODBUS_FCODE.READ_DISCRETE_INPUTS then
elseif packet.func_code == MODBUS_FCODE.READ_MUL_HOLD_REGS then
elseif packet.func_code == MODBUS_FCODE.READ_INPUT_REGS then
elseif packet.func_code == MODBUS_FCODE.WRITE_SINGLE_COIL then
elseif packet.func_code == MODBUS_FCODE.WRITE_SINGLE_HOLD_REG then
elseif packet.func_code == MODBUS_FCODE.WRITE_MUL_COILS then
elseif packet.func_code == MODBUS_FCODE.WRITE_MUL_HOLD_REGS then
else
-- unknown function
return_code = false
response = { MODBUS_EXCODE.ILLEGAL_FUNCTION }
end
else
-- invalid length
return_code = false
response = { MODBUS_EXCODE.NEG_ACKNOWLEDGE }
end
-- default is to echo back
local func_code = packet.func_code
if not return_code then
-- echo back with error flag
func_code = bit.bor(packet.func_code, MODBUS_FCODE.ERROR_FLAG)
end
-- create reply
local reply = comms.modbus_packet()
reply.make(packet.txn_id, packet.unit_id, func_code, response)
return return_code, reply
end
-- handle a MODBUS TCP packet and generate a reply
---@param packet modbus_frame
---@return boolean return_code, modbus_packet reply
public.handle_packet = function (packet)
local return_code = true
local response = nil
if packet.length == 2 then
-- handle by function code
if packet.func_code == MODBUS_FCODE.READ_COILS then
return_code, response = _1_read_coils(packet.data[1], packet.data[2])
elseif packet.func_code == MODBUS_FCODE.READ_DISCRETE_INPUTS then
return_code, response = _2_read_discrete_inputs(packet.data[1], packet.data[2])
elseif packet.func_code == MODBUS_FCODE.READ_MUL_HOLD_REGS then
return_code, response = _3_read_multiple_holding_registers(packet.data[1], packet.data[2])
elseif packet.func_code == MODBUS_FCODE.READ_INPUT_REGS then
return_code, response = _4_read_input_registers(packet.data[1], packet.data[2])
elseif packet.func_code == MODBUS_FCODE.WRITE_SINGLE_COIL then
return_code, response = _5_write_single_coil(packet.data[1], packet.data[2])
elseif packet.func_code == MODBUS_FCODE.WRITE_SINGLE_HOLD_REG then
return_code, response = _6_write_single_holding_register(packet.data[1], packet.data[2])
elseif packet.func_code == MODBUS_FCODE.WRITE_MUL_COILS then
return_code, response = _15_write_multiple_coils(packet.data[1], packet.data[2])
elseif packet.func_code == MODBUS_FCODE.WRITE_MUL_HOLD_REGS then
return_code, response = _16_write_multiple_holding_registers(packet.data[1], packet.data[2])
else
-- unknown function
return_code = false
response = MODBUS_EXCODE.ILLEGAL_FUNCTION
end
else
-- invalid length
return_code = false
end
-- default is to echo back
local func_code = packet.func_code
if not return_code then
-- echo back with error flag
func_code = bit.bor(packet.func_code, MODBUS_FCODE.ERROR_FLAG)
end
if type(response) == "table" then
elseif type(response) == "nil" then
response = {}
else
response = { response }
end
-- create reply
local reply = comms.modbus_packet()
reply.make(packet.txn_id, packet.unit_id, func_code, response)
return return_code, reply
end
-- return a SERVER_DEVICE_BUSY error reply
---@return modbus_packet reply
public.reply__srv_device_busy = function (packet)
-- reply back with error flag and exception code
local reply = comms.modbus_packet()
local fcode = bit.bor(packet.func_code, MODBUS_FCODE.ERROR_FLAG)
local data = { MODBUS_EXCODE.SERVER_DEVICE_BUSY }
reply.make(packet.txn_id, packet.unit_id, fcode, data)
return reply
end
-- return a NEG_ACKNOWLEDGE error reply
---@return modbus_packet reply
public.reply__neg_ack = function (packet)
-- reply back with error flag and exception code
local reply = comms.modbus_packet()
local fcode = bit.bor(packet.func_code, MODBUS_FCODE.ERROR_FLAG)
local data = { MODBUS_EXCODE.NEG_ACKNOWLEDGE }
reply.make(packet.txn_id, packet.unit_id, fcode, data)
return reply
end
-- return a GATEWAY_PATH_UNAVAILABLE error reply
---@return modbus_packet reply
public.reply__gw_unavailable = function (packet)
-- reply back with error flag and exception code
local reply = comms.modbus_packet()
local fcode = bit.bor(packet.func_code, MODBUS_FCODE.ERROR_FLAG)
local data = { MODBUS_EXCODE.GATEWAY_PATH_UNAVAILABLE }
reply.make(packet.txn_id, packet.unit_id, fcode, data)
return reply
end
return public
end
return modbus

View File

@ -1,12 +1,26 @@
-- #REQUIRES comms.lua local comms = require("scada-common.comms")
-- #REQUIRES modbus.lua local ppm = require("scada-common.ppm")
-- #REQUIRES ppm.lua local log = require("scada-common.log")
local types = require("scada-common.types")
local util = require("scada-common.util")
local modbus = require("rtu.modbus")
local rtu = {}
local rtu_t = types.rtu_t
local PROTOCOLS = comms.PROTOCOLS local PROTOCOLS = comms.PROTOCOLS
local SCADA_MGMT_TYPES = comms.SCADA_MGMT_TYPES local SCADA_MGMT_TYPES = comms.SCADA_MGMT_TYPES
local RTU_ADVERT_TYPES = comms.RTU_ADVERT_TYPES local RTU_UNIT_TYPES = comms.RTU_UNIT_TYPES
function rtu_init() local print = util.print
local println = util.println
local print_ts = util.print_ts
local println_ts = util.println_ts
-- create a new RTU
rtu.init_unit = function ()
local self = { local self = {
discrete_inputs = {}, discrete_inputs = {},
coils = {}, coils = {},
@ -15,49 +29,71 @@ function rtu_init()
io_count_cache = { 0, 0, 0, 0 } io_count_cache = { 0, 0, 0, 0 }
} }
local insert = table.insert
---@class rtu_device
local public = {}
---@class rtu
local protected = {}
-- refresh IO count
local _count_io = function () local _count_io = function ()
self.io_count_cache = { #self.discrete_inputs, #self.coils, #self.input_regs, #self.holding_regs } self.io_count_cache = { #self.discrete_inputs, #self.coils, #self.input_regs, #self.holding_regs }
end end
-- return : IO count table -- return IO count
local io_count = function () ---@return integer discrete_inputs, integer coils, integer input_regs, integer holding_regs
return self.io_count_cache[0], self.io_count_cache[1], self.io_count_cache[2], self.io_count_cache[3] public.io_count = function ()
return self.io_count_cache[1], self.io_count_cache[2], self.io_count_cache[3], self.io_count_cache[4]
end end
-- discrete inputs: single bit read-only -- discrete inputs: single bit read-only
-- return : count of discrete inputs -- connect discrete input
local connect_di = function (f) ---@param f function
table.insert(self.discrete_inputs, f) ---@return integer count count of discrete inputs
protected.connect_di = function (f)
insert(self.discrete_inputs, { read = f })
_count_io() _count_io()
return #self.discrete_inputs return #self.discrete_inputs
end end
-- return : value, access fault -- read discrete input
local read_di = function (di_addr) ---@param di_addr integer
---@return any value, boolean access_fault
public.read_di = function (di_addr)
ppm.clear_fault() ppm.clear_fault()
local value = self.discrete_inputs[di_addr]() local value = self.discrete_inputs[di_addr].read()
return value, ppm.is_faulted() return value, ppm.is_faulted()
end end
-- coils: single bit read-write -- coils: single bit read-write
-- return : count of coils -- connect coil
local connect_coil = function (f_read, f_write) ---@param f_read function
table.insert(self.coils, { read = f_read, write = f_write }) ---@param f_write function
---@return integer count count of coils
protected.connect_coil = function (f_read, f_write)
insert(self.coils, { read = f_read, write = f_write })
_count_io() _count_io()
return #self.coils return #self.coils
end end
-- return : value, access fault -- read coil
local read_coil = function (coil_addr) ---@param coil_addr integer
---@return any value, boolean access_fault
public.read_coil = function (coil_addr)
ppm.clear_fault() ppm.clear_fault()
local value = self.coils[coil_addr].read() local value = self.coils[coil_addr].read()
return value, ppm.is_faulted() return value, ppm.is_faulted()
end end
-- return : access fault -- write coil
local write_coil = function (coil_addr, value) ---@param coil_addr integer
---@param value any
---@return boolean access_fault
public.write_coil = function (coil_addr, value)
ppm.clear_fault() ppm.clear_fault()
self.coils[coil_addr].write(value) self.coils[coil_addr].write(value)
return ppm.is_faulted() return ppm.is_faulted()
@ -65,67 +101,88 @@ function rtu_init()
-- input registers: multi-bit read-only -- input registers: multi-bit read-only
-- return : count of input registers -- connect input register
local connect_input_reg = function (f) ---@param f function
table.insert(self.input_regs, f) ---@return integer count count of input registers
protected.connect_input_reg = function (f)
insert(self.input_regs, { read = f })
_count_io() _count_io()
return #self.input_regs return #self.input_regs
end end
-- return : value, access fault -- read input register
local read_input_reg = function (reg_addr) ---@param reg_addr integer
---@return any value, boolean access_fault
public.read_input_reg = function (reg_addr)
ppm.clear_fault() ppm.clear_fault()
local value = self.coils[reg_addr]() local value = self.input_regs[reg_addr].read()
return value, ppm.is_faulted() return value, ppm.is_faulted()
end end
-- holding registers: multi-bit read-write -- holding registers: multi-bit read-write
-- return : count of holding registers -- connect holding register
local connect_holding_reg = function (f_read, f_write) ---@param f_read function
table.insert(self.holding_regs, { read = f_read, write = f_write }) ---@param f_write function
---@return integer count count of holding registers
protected.connect_holding_reg = function (f_read, f_write)
insert(self.holding_regs, { read = f_read, write = f_write })
_count_io() _count_io()
return #self.holding_regs return #self.holding_regs
end end
-- return : value, access fault -- read holding register
local read_holding_reg = function (reg_addr) ---@param reg_addr integer
---@return any value, boolean access_fault
public.read_holding_reg = function (reg_addr)
ppm.clear_fault() ppm.clear_fault()
local value = self.coils[reg_addr].read() local value = self.holding_regs[reg_addr].read()
return value, ppm.is_faulted() return value, ppm.is_faulted()
end end
-- return : access fault -- write holding register
local write_holding_reg = function (reg_addr, value) ---@param reg_addr integer
---@param value any
---@return boolean access_fault
public.write_holding_reg = function (reg_addr, value)
ppm.clear_fault() ppm.clear_fault()
self.coils[reg_addr].write(value) self.holding_regs[reg_addr].write(value)
return ppm.is_faulted() return ppm.is_faulted()
end end
return { -- public RTU device access
io_count = io_count,
connect_di = connect_di, -- get the public interface to this RTU
read_di = read_di, protected.interface = function ()
connect_coil = connect_coil, return public
read_coil = read_coil, end
write_coil = write_coil,
connect_input_reg = connect_input_reg, return protected
read_input_reg = read_input_reg,
connect_holding_reg = connect_holding_reg,
read_holding_reg = read_holding_reg,
write_holding_reg = write_holding_reg
}
end end
function rtu_comms(modem, local_port, server_port) -- RTU Communications
---@param version string
---@param modem table
---@param local_port integer
---@param server_port integer
---@param conn_watchdog watchdog
rtu.comms = function (version, modem, local_port, server_port, conn_watchdog)
local self = { local self = {
version = version,
seq_num = 0, seq_num = 0,
r_seq_num = nil,
txn_id = 0, txn_id = 0,
modem = modem, modem = modem,
s_port = server_port, s_port = server_port,
l_port = local_port l_port = local_port,
conn_watchdog = conn_watchdog
} }
---@class rtu_comms
local public = {}
local insert = table.insert
-- open modem -- open modem
if not self.modem.isOpen(self.l_port) then if not self.modem.isOpen(self.l_port) then
self.modem.open(self.l_port) self.modem.open(self.l_port)
@ -133,27 +190,109 @@ function rtu_comms(modem, local_port, server_port)
-- PRIVATE FUNCTIONS -- -- PRIVATE FUNCTIONS --
local _send = function (protocol, msg) -- send a scada management packet
local packet = comms.scada_packet() ---@param msg_type SCADA_MGMT_TYPES
packet.make(self.seq_num, protocol, msg) ---@param msg table
self.modem.transmit(self.s_port, self.l_port, packet.raw()) local _send = function (msg_type, msg)
local s_pkt = comms.scada_packet()
local m_pkt = comms.mgmt_packet()
m_pkt.make(msg_type, msg)
s_pkt.make(self.seq_num, PROTOCOLS.SCADA_MGMT, m_pkt.raw_sendable())
self.modem.transmit(self.s_port, self.l_port, s_pkt.raw_sendable())
self.seq_num = self.seq_num + 1 self.seq_num = self.seq_num + 1
end end
-- keep alive ack
---@param srv_time integer
local _send_keep_alive_ack = function (srv_time)
_send(SCADA_MGMT_TYPES.KEEP_ALIVE, { srv_time, util.time() })
end
-- PUBLIC FUNCTIONS -- -- PUBLIC FUNCTIONS --
-- send a MODBUS TCP packet
---@param m_pkt modbus_packet
public.send_modbus = function (m_pkt)
local s_pkt = comms.scada_packet()
s_pkt.make(self.seq_num, PROTOCOLS.MODBUS_TCP, m_pkt.raw_sendable())
self.modem.transmit(self.s_port, self.l_port, s_pkt.raw_sendable())
self.seq_num = self.seq_num + 1
end
-- reconnect a newly connected modem
---@param modem table
---@diagnostic disable-next-line: redefined-local
public.reconnect_modem = function (modem)
self.modem = modem
-- open modem
if not self.modem.isOpen(self.l_port) then
self.modem.open(self.l_port)
end
end
-- unlink from the server
---@param rtu_state rtu_state
public.unlink = function (rtu_state)
rtu_state.linked = false
self.r_seq_num = nil
end
-- close the connection to the server
---@param rtu_state rtu_state
public.close = function (rtu_state)
self.conn_watchdog.cancel()
public.unlink(rtu_state)
_send(SCADA_MGMT_TYPES.CLOSE, {})
end
-- send capability advertisement
---@param units table
public.send_advertisement = function (units)
local advertisement = { self.version }
for i = 1, #units do
local unit = units[i] --@type rtu_unit_registry_entry
local type = comms.rtu_t_to_unit_type(unit.type)
if type ~= nil then
local advert = {
type,
unit.index,
unit.reactor
}
if type == RTU_UNIT_TYPES.REDSTONE then
insert(advert, unit.device)
end
insert(advertisement, advert)
end
end
_send(SCADA_MGMT_TYPES.RTU_ADVERT, advertisement)
end
-- parse a MODBUS/SCADA packet -- parse a MODBUS/SCADA packet
local parse_packet = function(side, sender, reply_to, message, distance) ---@param side string
---@param sender integer
---@param reply_to integer
---@param message any
---@param distance integer
---@return modbus_frame|mgmt_frame|nil packet
public.parse_packet = function(side, sender, reply_to, message, distance)
local pkt = nil local pkt = nil
local s_pkt = comms.scada_packet() local s_pkt = comms.scada_packet()
-- parse packet as generic SCADA packet -- parse packet as generic SCADA packet
s_pkt.recieve(side, sender, reply_to, message, distance) s_pkt.receive(side, sender, reply_to, message, distance)
if s_pkt.is_valid() then if s_pkt.is_valid() then
-- get as MODBUS TCP packet -- get as MODBUS TCP packet
if s_pkt.protocol() == PROTOCOLS.MODBUS_TCP then if s_pkt.protocol() == PROTOCOLS.MODBUS_TCP then
local m_pkt = modbus.packet() local m_pkt = comms.modbus_packet()
if m_pkt.decode(s_pkt) then if m_pkt.decode(s_pkt) then
pkt = m_pkt.get() pkt = m_pkt.get()
end end
@ -161,109 +300,120 @@ function rtu_comms(modem, local_port, server_port)
elseif s_pkt.protocol() == PROTOCOLS.SCADA_MGMT then elseif s_pkt.protocol() == PROTOCOLS.SCADA_MGMT then
local mgmt_pkt = comms.mgmt_packet() local mgmt_pkt = comms.mgmt_packet()
if mgmt_pkt.decode(s_pkt) then if mgmt_pkt.decode(s_pkt) then
pkt = mgmt_packet.get() pkt = mgmt_pkt.get()
end end
else else
log._error("illegal packet type " .. s_pkt.protocol(), true) log.error("illegal packet type " .. s_pkt.protocol(), true)
end end
end end
return pkt return pkt
end end
local handle_packet = function(packet, units, ref) -- handle a MODBUS/SCADA packet
---@param packet modbus_frame|mgmt_frame
---@param units table
---@param rtu_state rtu_state
public.handle_packet = function(packet, units, rtu_state)
if packet ~= nil then if packet ~= nil then
-- check sequence number
if self.r_seq_num == nil then
self.r_seq_num = packet.scada_frame.seq_num()
elseif rtu_state.linked and self.r_seq_num >= packet.scada_frame.seq_num() then
log.warning("sequence out-of-order: last = " .. self.r_seq_num .. ", new = " .. packet.scada_frame.seq_num())
return
else
self.r_seq_num = packet.scada_frame.seq_num()
end
-- feed watchdog on valid sequence number
self.conn_watchdog.feed()
local protocol = packet.scada_frame.protocol() local protocol = packet.scada_frame.protocol()
if protocol == PROTOCOLS.MODBUS_TCP then if protocol == PROTOCOLS.MODBUS_TCP then
-- MODBUS instruction local return_code = false
if packet.unit_id <= #units then local reply = modbus.reply__neg_ack(packet)
local unit = units[packet.unit_id]
local return_code, response = unit.modbus_io.handle_packet(packet)
_send(PROTOCOLS.MODBUS_TCP, response)
-- handle MODBUS instruction
if packet.unit_id <= #units then
local unit = units[packet.unit_id] ---@type rtu_unit_registry_entry
local unit_dbg_tag = " (unit " .. packet.unit_id .. ")"
if unit.name == "redstone_io" then
-- immediately execute redstone RTU requests
return_code, reply = unit.modbus_io.handle_packet(packet)
if not return_code then if not return_code then
log._warning("MODBUS operation failed") log.warning("requested MODBUS operation failed" .. unit_dbg_tag)
end
else
-- check validity then pass off to unit comms thread
return_code, reply = unit.modbus_io.check_request(packet)
if return_code then
-- check if there are more than 3 active transactions
-- still queue the packet, but this may indicate a problem
if unit.pkt_queue.length() > 3 then
reply = unit.modbus_io.reply__srv_device_busy(packet)
log.debug("queueing new request with " .. unit.pkt_queue.length() ..
" transactions already in the queue" .. unit_dbg_tag)
end
-- always queue the command even if busy
unit.pkt_queue.push_packet(packet)
else
log.warning("cannot perform requested MODBUS operation" .. unit_dbg_tag)
end
end end
else else
-- unit ID out of range? -- unit ID out of range?
log._error("MODBUS packet requesting non-existent unit") reply = modbus.reply__gw_unavailable(packet)
log.error("received MODBUS packet for non-existent unit")
end end
public.send_modbus(reply)
elseif protocol == PROTOCOLS.SCADA_MGMT then elseif protocol == PROTOCOLS.SCADA_MGMT then
-- SCADA management packet -- SCADA management packet
if packet.type == SCADA_MGMT_TYPES.REMOTE_LINKED then if packet.type == SCADA_MGMT_TYPES.KEEP_ALIVE then
-- keep alive request received, echo back
if packet.length == 1 then
local timestamp = packet.data[1]
local trip_time = util.time() - timestamp
if trip_time > 500 then
log.warning("RTU KEEP_ALIVE trip time > 500ms (" .. trip_time .. "ms)")
end
-- log.debug("RTU RTT = ".. trip_time .. "ms")
_send_keep_alive_ack(timestamp)
else
log.debug("SCADA keep alive packet length mismatch")
end
elseif packet.type == SCADA_MGMT_TYPES.CLOSE then
-- close connection
self.conn_watchdog.cancel()
public.unlink(rtu_state)
println_ts("server connection closed by remote host")
log.warning("server connection closed by remote host")
elseif packet.type == SCADA_MGMT_TYPES.REMOTE_LINKED then
-- acknowledgement -- acknowledgement
ref.linked = true rtu_state.linked = true
self.r_seq_num = nil
elseif packet.type == SCADA_MGMT_TYPES.RTU_ADVERT then elseif packet.type == SCADA_MGMT_TYPES.RTU_ADVERT then
-- request for capabilities again -- request for capabilities again
send_advertisement(units) public.send_advertisement(units)
else else
-- not supported -- not supported
log._warning("RTU got unexpected SCADA message type " .. packet.type, true) log.warning("RTU got unexpected SCADA message type " .. packet.type)
end end
else else
-- should be unreachable assuming packet is from parse_packet() -- should be unreachable assuming packet is from parse_packet()
log._error("illegal packet type " .. protocol, true) log.error("illegal packet type " .. protocol, true)
end end
end end
end end
-- send capability advertisement return public
local send_advertisement = function (units)
local advertisement = {
type = SCADA_MGMT_TYPES.RTU_ADVERT,
units = {}
}
for i = 1, #units do
local type = nil
if units[i].type == "boiler" then
type = RTU_ADVERT_TYPES.BOILER
elseif units[i].type == "turbine" then
type = RTU_ADVERT_TYPES.TURBINE
elseif units[i].type == "imatrix" then
type = RTU_ADVERT_TYPES.IMATRIX
elseif units[i].type == "redstone" then
type = RTU_ADVERT_TYPES.REDSTONE
end
if type ~= nil then
if type == RTU_ADVERT_TYPES.REDSTONE then
table.insert(advertisement.units, {
unit = i,
type = type,
index = units[i].index,
reactor = units[i].for_reactor,
rsio = units[i].device
})
else
table.insert(advertisement.units, {
unit = i,
type = type,
index = units[i].index,
reactor = units[i].for_reactor,
rsio = nil
})
end
end
end
_send(PROTOCOLS.SCADA_MGMT, advertisement)
end
local send_heartbeat = function ()
local heartbeat = {
type = SCADA_MGMT_TYPES.RTU_HEARTBEAT
}
_send(PROTOCOLS.SCADA_MGMT, heartbeat)
end
return {
parse_packet = parse_packet,
handle_packet = handle_packet,
send_advertisement = send_advertisement,
send_heartbeat = send_heartbeat
}
end end
return rtu

View File

@ -2,122 +2,189 @@
-- RTU: Remote Terminal Unit -- RTU: Remote Terminal Unit
-- --
os.loadAPI("scada-common/log.lua") require("/initenv").init_env()
os.loadAPI("scada-common/util.lua")
os.loadAPI("scada-common/ppm.lua")
os.loadAPI("scada-common/comms.lua")
os.loadAPI("scada-common/modbus.lua")
os.loadAPI("scada-common/rsio.lua")
os.loadAPI("config.lua") local log = require("scada-common.log")
os.loadAPI("rtu.lua") local mqueue = require("scada-common.mqueue")
local ppm = require("scada-common.ppm")
local rsio = require("scada-common.rsio")
local types = require("scada-common.types")
local util = require("scada-common.util")
os.loadAPI("dev/redstone_rtu.lua") local config = require("rtu.config")
os.loadAPI("dev/boiler_rtu.lua") local modbus = require("rtu.modbus")
os.loadAPI("dev/imatrix_rtu.lua") local rtu = require("rtu.rtu")
os.loadAPI("dev/turbine_rtu.lua") local threads = require("rtu.threads")
local RTU_VERSION = "alpha-v0.2.0" local redstone_rtu = require("rtu.dev.redstone_rtu")
local boiler_rtu = require("rtu.dev.boiler_rtu")
local boilerv_rtu = require("rtu.dev.boilerv_rtu")
local energymachine_rtu = require("rtu.dev.energymachine_rtu")
local imatrix_rtu = require("rtu.dev.imatrix_rtu")
local turbine_rtu = require("rtu.dev.turbine_rtu")
local turbinev_rtu = require("rtu.dev.turbinev_rtu")
local RTU_VERSION = "alpha-v0.7.1"
local rtu_t = types.rtu_t
local print = util.print local print = util.print
local println = util.println local println = util.println
local print_ts = util.print_ts local print_ts = util.print_ts
local println_ts = util.println_ts local println_ts = util.println_ts
log._info("========================================") log.init(config.LOG_PATH, config.LOG_MODE)
log._info("BOOTING rtu.startup " .. RTU_VERSION)
log._info("========================================") log.info("========================================")
log.info("BOOTING rtu.startup " .. RTU_VERSION)
log.info("========================================")
println(">> RTU " .. RTU_VERSION .. " <<") println(">> RTU " .. RTU_VERSION .. " <<")
---------------------------------------- ----------------------------------------
-- startup -- startup
---------------------------------------- ----------------------------------------
local units = {}
local linked = false
-- mount connected devices -- mount connected devices
ppm.mount_all() ppm.mount_all()
---@class rtu_shared_memory
local __shared_memory = {
-- RTU system state flags
---@class rtu_state
rtu_state = {
linked = false,
shutdown = false
},
-- core RTU devices
rtu_dev = {
modem = ppm.get_wireless_modem()
},
-- system objects
rtu_sys = {
rtu_comms = nil, ---@type rtu_comms
conn_watchdog = nil, ---@type watchdog
units = {} ---@type table
},
-- message queues
q = {
mq_comms = mqueue.new()
}
}
local smem_dev = __shared_memory.rtu_dev
local smem_sys = __shared_memory.rtu_sys
-- get modem -- get modem
local modem = ppm.get_wireless_modem() if smem_dev.modem == nil then
if modem == nil then
println("boot> wireless modem not found") println("boot> wireless modem not found")
log._warning("no wireless modem on startup") log.warning("no wireless modem on startup")
return return
end end
local rtu_comms = rtu.rtu_comms(modem, config.LISTEN_PORT, config.SERVER_PORT) ----------------------------------------
-- interpret config and init units
----------------------------------------
---------------------------------------- local units = __shared_memory.rtu_sys.units
-- determine configuration
----------------------------------------
local rtu_redstone = config.RTU_REDSTONE local rtu_redstone = config.RTU_REDSTONE
local rtu_devices = config.RTU_DEVICES local rtu_devices = config.RTU_DEVICES
-- redstone interfaces -- redstone interfaces
for reactor_idx = 1, #rtu_redstone do for entry_idx = 1, #rtu_redstone do
local rs_rtu = redstone_rtu.new() local rs_rtu = redstone_rtu.new()
local io_table = rtu_redstone[reactor_idx].io local io_table = rtu_redstone[entry_idx].io
local io_reactor = rtu_redstone[entry_idx].for_reactor
local capabilities = {} local capabilities = {}
log._debug("init> starting redstone RTU I/O linking for reactor " .. rtu_redstone[reactor_idx].for_reactor .. "...") log.debug("init> starting redstone RTU I/O linking for reactor " .. io_reactor .. "...")
local continue = true
for i = 1, #units do
local unit = units[i] ---@type rtu_unit_registry_entry
if unit.reactor == io_reactor and unit.type == rtu_t.redstone then
-- duplicate entry
log.warning("init> skipping definition block #" .. entry_idx .. " for reactor " .. io_reactor .. " with already defined redstone I/O")
continue = false
break
end
end
if continue then
for i = 1, #io_table do for i = 1, #io_table do
local valid = false local valid = false
local config = io_table[i] local conf = io_table[i]
-- verify configuration -- verify configuration
if rsio.is_valid_channel(config.channel) and rsio.is_valid_side(config.side) then if rsio.is_valid_channel(conf.channel) and rsio.is_valid_side(conf.side) then
if config.bundled_color then if conf.bundled_color then
valid = rsio.is_color(config.bundled_color) valid = rsio.is_color(conf.bundled_color)
else else
valid = true valid = true
end end
end end
if not valid then if not valid then
local message = "init> invalid redstone definition at index " .. i .. " in definition block #" .. reactor_idx .. local message = "init> invalid redstone definition at index " .. i .. " in definition block #" .. entry_idx ..
" (for reactor " .. rtu_redstone[reactor_idx].for_reactor .. ")" " (for reactor " .. io_reactor .. ")"
println_ts(message) println_ts(message)
log._warning(message) log.warning(message)
else else
-- link redstone in RTU -- link redstone in RTU
local mode = rsio.get_io_mode(config.channel) local mode = rsio.get_io_mode(conf.channel)
if mode == rsio.IO_MODE.DIGITAL_IN then if mode == rsio.IO_MODE.DIGITAL_IN then
rs_rtu.link_di(config.channel, config.side, config.bundled_color) -- can't have duplicate inputs
if util.table_contains(capabilities, conf.channel) then
log.warning("init> skipping duplicate input for channel " .. rsio.to_string(conf.channel) .. " on side " .. conf.side)
else
rs_rtu.link_di(conf.side, conf.bundled_color)
end
elseif mode == rsio.IO_MODE.DIGITAL_OUT then elseif mode == rsio.IO_MODE.DIGITAL_OUT then
rs_rtu.link_do(config.channel, config.side, config.bundled_color) rs_rtu.link_do(conf.channel, conf.side, conf.bundled_color)
elseif mode == rsio.IO_MODE.ANALOG_IN then elseif mode == rsio.IO_MODE.ANALOG_IN then
rs_rtu.link_ai(config.channel, config.side) -- can't have duplicate inputs
if util.table_contains(capabilities, conf.channel) then
log.warning("init> skipping duplicate input for channel " .. rsio.to_string(conf.channel) .. " on side " .. conf.side)
else
rs_rtu.link_ai(conf.side)
end
elseif mode == rsio.IO_MODE.ANALOG_OUT then elseif mode == rsio.IO_MODE.ANALOG_OUT then
rs_rtu.link_ao(config.channel, config.side) rs_rtu.link_ao(conf.side)
else else
-- should be unreachable code, we already validated channels -- should be unreachable code, we already validated channels
log._error("init> fell through if chain attempting to identify IO mode", true) log.error("init> fell through if chain attempting to identify IO mode", true)
break break
end end
table.insert(capabilities, config.channel) table.insert(capabilities, conf.channel)
log._debug("init> linked redstone " .. #capabilities .. ": " .. rsio.to_string(config.channel) .. " (" .. config.side .. log.debug("init> linked redstone " .. #capabilities .. ": " .. rsio.to_string(conf.channel) .. " (" .. conf.side ..
") for reactor " .. rtu_redstone[reactor_idx].for_reactor) ") for reactor " .. io_reactor)
end end
end end
table.insert(units, { ---@class rtu_unit_registry_entry
local unit = {
name = "redstone_io", name = "redstone_io",
type = "redstone", type = rtu_t.redstone,
index = 1, index = entry_idx,
reactor = rtu_redstone[reactor_idx].for_reactor, reactor = io_reactor,
device = capabilities, -- use device field for redstone channels device = capabilities, -- use device field for redstone channels
rtu = rs_rtu, rtu = rs_rtu,
modbus_io = modbus.new(rs_rtu) modbus_io = modbus.new(rs_rtu, false),
}) pkt_queue = nil,
thread = nil
}
log._debug("init> initialized RTU unit #" .. #units .. ": redstone_io (redstone) [1] for reactor " .. rtu_redstone[reactor_idx].for_reactor) table.insert(units, unit)
log.debug("init> initialized RTU unit #" .. #units .. ": redstone_io (redstone) [1] for reactor " .. io_reactor)
end
end end
-- mounted peripherals -- mounted peripherals
@ -127,124 +194,93 @@ for i = 1, #rtu_devices do
if device == nil then if device == nil then
local message = "init> '" .. rtu_devices[i].name .. "' not found" local message = "init> '" .. rtu_devices[i].name .. "' not found"
println_ts(message) println_ts(message)
log._warning(message) log.warning(message)
else else
local type = ppm.get_type(rtu_devices[i].name) local type = ppm.get_type(rtu_devices[i].name)
local rtu_iface = nil local rtu_iface = nil ---@type rtu_device
local rtu_type = "" local rtu_type = ""
if type == "boiler" then if type == "boiler" then
-- boiler multiblock -- boiler multiblock
rtu_type = "boiler" rtu_type = rtu_t.boiler
rtu_iface = boiler_rtu.new(device) rtu_iface = boiler_rtu.new(device)
elseif type == "boilerValve" then
-- boiler multiblock (10.1+)
rtu_type = rtu_t.boiler_valve
rtu_iface = boilerv_rtu.new(device)
elseif type == "turbine" then elseif type == "turbine" then
-- turbine multiblock -- turbine multiblock
rtu_type = "turbine" rtu_type = rtu_t.turbine
rtu_iface = turbine_rtu.new(device) rtu_iface = turbine_rtu.new(device)
elseif type == "turbineValve" then
-- turbine multiblock (10.1+)
rtu_type = rtu_t.turbine_valve
rtu_iface = turbinev_rtu.new(device)
elseif type == "mekanismMachine" then elseif type == "mekanismMachine" then
-- assumed to be an induction matrix multiblock -- assumed to be an induction matrix multiblock, pre Mekanism 10.1
rtu_type = "imatrix" -- also works with energy cubes
rtu_type = rtu_t.energy_machine
rtu_iface = energymachine_rtu.new(device)
elseif type == "inductionPort" then
-- induction matrix multiblock (10.1+)
rtu_type = rtu_t.induction_matrix
rtu_iface = imatrix_rtu.new(device) rtu_iface = imatrix_rtu.new(device)
else else
local message = "init> device '" .. rtu_devices[i].name .. "' is not a known type (" .. type .. ")" local message = "init> device '" .. rtu_devices[i].name .. "' is not a known type (" .. type .. ")"
println_ts(message) println_ts(message)
log._warning(message) log.warning(message)
end end
if rtu_iface ~= nil then if rtu_iface ~= nil then
table.insert(units, { ---@class rtu_unit_registry_entry
local rtu_unit = {
name = rtu_devices[i].name, name = rtu_devices[i].name,
type = rtu_type, type = rtu_type,
index = rtu_devices[i].index, index = rtu_devices[i].index,
reactor = rtu_devices[i].for_reactor, reactor = rtu_devices[i].for_reactor,
device = device, device = device,
rtu = rtu_iface, rtu = rtu_iface,
modbus_io = modbus.new(rtu_iface) modbus_io = modbus.new(rtu_iface, true),
}) pkt_queue = mqueue.new(),
thread = nil
}
log._debug("init> initialized RTU unit #" .. #units .. ": " .. rtu_devices[i].name .. " (" .. rtu_type .. ") [" .. rtu_unit.thread = threads.thread__unit_comms(__shared_memory, rtu_unit)
table.insert(units, rtu_unit)
log.debug("init> initialized RTU unit #" .. #units .. ": " .. rtu_devices[i].name .. " (" .. rtu_type .. ") [" ..
rtu_devices[i].index .. "] for reactor " .. rtu_devices[i].for_reactor) rtu_devices[i].index .. "] for reactor " .. rtu_devices[i].for_reactor)
end end
end end
end end
---------------------------------------- ----------------------------------------
-- main loop -- start system
---------------------------------------- ----------------------------------------
-- advertisement/heartbeat clock (every 2 seconds) -- start connection watchdog
local loop_clock = os.startTimer(2) smem_sys.conn_watchdog = util.new_watchdog(5)
log.debug("boot> conn watchdog started")
-- event loop -- setup comms
while true do smem_sys.rtu_comms = rtu.comms(RTU_VERSION, smem_dev.modem, config.LISTEN_PORT, config.SERVER_PORT, smem_sys.conn_watchdog)
local event, param1, param2, param3, param4, param5 = os.pullEventRaw() log.debug("boot> comms init")
if event == "peripheral_detach" then -- init threads
-- handle loss of a device local main_thread = threads.thread__main(__shared_memory)
local device = ppm.handle_unmount(param1) local comms_thread = threads.thread__comms(__shared_memory)
for i = 1, #units do -- assemble thread list
-- find disconnected device local _threads = { main_thread.p_exec, comms_thread.p_exec }
if units[i].device == device then for i = 1, #units do
-- we are going to let the PPM prevent crashes if units[i].thread ~= nil then
-- return fault flags/codes to MODBUS queries table.insert(_threads, units[i].thread.p_exec)
local unit = units[i]
println_ts("lost the " .. unit.type .. " on interface " .. unit.name)
end
end
elseif event == "peripheral" then
-- relink lost peripheral to correct unit entry
local type, device = ppm.mount(param1)
for i = 1, #units do
local unit = units[i]
-- find disconnected device to reconnect
if unit.name == param1 then
-- found, re-link
unit.device = device
if unit.type == "boiler" then
unit.rtu = boiler_rtu.new(device)
elseif unit.type == "turbine" then
unit.rtu = turbine_rtu.new(device)
elseif unit.type == "imatrix" then
unit.rtu = imatrix_rtu.new(device)
end
unit.modbus_io = modbus.new(unit.rtu)
println_ts("reconnected the " .. unit.type .. " on interface " .. unit.name)
end
end
elseif event == "timer" and param1 == loop_clock then
-- period tick, if we are linked send heartbeat, if not send advertisement
if linked then
rtu_comms.send_heartbeat()
else
-- advertise units
rtu_comms.send_advertisement(units)
end
-- start next clock timer
loop_clock = os.startTimer(2)
elseif event == "modem_message" then
-- got a packet
local link_ref = { linked = linked }
local packet = rtu_comms.parse_packet(p1, p2, p3, p4, p5)
rtu_comms.handle_packet(packet, units, link_ref)
-- if linked, stop sending advertisements
linked = link_ref.linked
end
-- check for termination request
if event == "terminate" or ppm.should_terminate() then
log._warning("terminate requested, exiting...")
break
end end
end end
-- run threads
parallel.waitForAll(table.unpack(_threads))
println_ts("exited") println_ts("exited")
log._info("exited") log.info("exited")

319
rtu/threads.lua Normal file
View File

@ -0,0 +1,319 @@
local log = require("scada-common.log")
local mqueue = require("scada-common.mqueue")
local ppm = require("scada-common.ppm")
local types = require("scada-common.types")
local util = require("scada-common.util")
local boiler_rtu = require("rtu.dev.boiler_rtu")
local boilerv_rtu = require("rtu.dev.boilerv_rtu")
local energymachine_rtu = require("rtu.dev.energymachine_rtu")
local imatrix_rtu = require("rtu.dev.imatrix_rtu")
local turbine_rtu = require("rtu.dev.turbine_rtu")
local turbinev_rtu = require("rtu.dev.turbinev_rtu")
local modbus = require("rtu.modbus")
local threads = {}
local rtu_t = types.rtu_t
local print = util.print
local println = util.println
local print_ts = util.print_ts
local println_ts = util.println_ts
local MAIN_CLOCK = 2 -- (2Hz, 40 ticks)
local COMMS_SLEEP = 100 -- (100ms, 2 ticks)
-- main thread
---@param smem rtu_shared_memory
threads.thread__main = function (smem)
local public = {} ---@class thread
-- execute thread
public.exec = function ()
log.debug("main thread start")
-- main loop clock
local loop_clock = util.new_clock(MAIN_CLOCK)
-- load in from shared memory
local rtu_state = smem.rtu_state
local rtu_dev = smem.rtu_dev
local rtu_comms = smem.rtu_sys.rtu_comms
local conn_watchdog = smem.rtu_sys.conn_watchdog
local units = smem.rtu_sys.units
-- start clock
loop_clock.start()
-- event loop
while true do
---@diagnostic disable-next-line: undefined-field
local event, param1, param2, param3, param4, param5 = os.pullEventRaw()
if event == "timer" and loop_clock.is_clock(param1) then
-- start next clock timer
loop_clock.start()
-- period tick, if we are not linked send advertisement
if not rtu_state.linked then
-- advertise units
rtu_comms.send_advertisement(units)
end
elseif event == "modem_message" then
-- got a packet
local packet = rtu_comms.parse_packet(param1, param2, param3, param4, param5)
if packet ~= nil then
-- pass the packet onto the comms message queue
smem.q.mq_comms.push_packet(packet)
end
elseif event == "timer" and conn_watchdog.is_timer(param1) then
-- haven't heard from server recently? unlink
rtu_comms.unlink(rtu_state)
elseif event == "peripheral_detach" then
-- handle loss of a device
local type, device = ppm.handle_unmount(param1)
if type ~= nil and device ~= nil then
if type == "modem" then
-- we only care if this is our wireless modem
if device == rtu_dev.modem then
println_ts("wireless modem disconnected!")
log.warning("comms modem disconnected!")
else
log.warning("non-comms modem disconnected")
end
else
for i = 1, #units do
-- find disconnected device
if units[i].device == device then
-- we are going to let the PPM prevent crashes
-- return fault flags/codes to MODBUS queries
local unit = units[i]
println_ts("lost the " .. unit.type .. " on interface " .. unit.name)
end
end
end
end
elseif event == "peripheral" then
-- peripheral connect
local type, device = ppm.mount(param1)
if type ~= nil and device ~= nil then
if type == "modem" then
if device.isWireless() then
-- reconnected modem
rtu_dev.modem = device
rtu_comms.reconnect_modem(rtu_dev.modem)
println_ts("wireless modem reconnected.")
log.info("comms modem reconnected.")
else
log.info("wired modem reconnected.")
end
else
-- relink lost peripheral to correct unit entry
for i = 1, #units do
local unit = units[i] ---@type rtu_unit_registry_entry
-- find disconnected device to reconnect
if unit.name == param1 then
-- found, re-link
unit.device = device
if unit.type == rtu_t.boiler then
unit.rtu = boiler_rtu.new(device)
elseif unit.type == rtu_t.boiler_valve then
unit.rtu = boilerv_rtu.new(device)
elseif unit.type == rtu_t.turbine then
unit.rtu = turbine_rtu.new(device)
elseif unit.type == rtu_t.turbine_valve then
unit.rtu = turbinev_rtu.new(device)
elseif unit.type == rtu_t.energy_machine then
unit.rtu = energymachine_rtu.new(device)
elseif unit.type == rtu_t.induction_matrix then
unit.rtu = imatrix_rtu.new(device)
end
unit.modbus_io = modbus.new(unit.rtu, true)
println_ts("reconnected the " .. unit.type .. " on interface " .. unit.name)
end
end
end
end
end
-- check for termination request
if event == "terminate" or ppm.should_terminate() then
rtu_state.shutdown = true
log.info("terminate requested, main thread exiting")
break
end
end
end
-- execute the thread in a protected mode, retrying it on return if not shutting down
public.p_exec = function ()
local rtu_state = smem.rtu_state
while not rtu_state.shutdown do
local status, result = pcall(public.exec)
if status == false then
log.fatal(result)
end
if not rtu_state.shutdown then
log.info("main thread restarting in 5 seconds...")
util.psleep(5)
end
end
end
return public
end
-- communications handler thread
---@param smem rtu_shared_memory
threads.thread__comms = function (smem)
local public = {} ---@class thread
-- execute thread
public.exec = function ()
log.debug("comms thread start")
-- load in from shared memory
local rtu_state = smem.rtu_state
local rtu_comms = smem.rtu_sys.rtu_comms
local units = smem.rtu_sys.units
local comms_queue = smem.q.mq_comms
local last_update = util.time()
-- thread loop
while true do
-- check for messages in the message queue
while comms_queue.ready() and not rtu_state.shutdown do
local msg = comms_queue.pop()
if msg ~= nil then
if msg.qtype == mqueue.TYPE.COMMAND then
-- received a command
elseif msg.qtype == mqueue.TYPE.DATA then
-- received data
elseif msg.qtype == mqueue.TYPE.PACKET then
-- received a packet
-- handle the packet (rtu_state passed to allow setting link flag)
rtu_comms.handle_packet(msg.message, units, rtu_state)
end
end
-- quick yield
util.nop()
end
-- check for termination request
if rtu_state.shutdown then
rtu_comms.close(rtu_state)
log.info("comms thread exiting")
break
end
-- delay before next check
last_update = util.adaptive_delay(COMMS_SLEEP, last_update)
end
end
-- execute the thread in a protected mode, retrying it on return if not shutting down
public.p_exec = function ()
local rtu_state = smem.rtu_state
while not rtu_state.shutdown do
local status, result = pcall(public.exec)
if status == false then
log.fatal(result)
end
if not rtu_state.shutdown then
log.info("comms thread restarting in 5 seconds...")
util.psleep(5)
end
end
end
return public
end
-- per-unit communications handler thread
---@param smem rtu_shared_memory
---@param unit rtu_unit_registry_entry
threads.thread__unit_comms = function (smem, unit)
local public = {} ---@class thread
-- execute thread
public.exec = function ()
log.debug("rtu unit thread start -> " .. unit.name .. "(" .. unit.type .. ")")
-- load in from shared memory
local rtu_state = smem.rtu_state
local rtu_comms = smem.rtu_sys.rtu_comms
local packet_queue = unit.pkt_queue
local last_update = util.time()
-- thread loop
while true do
-- check for messages in the message queue
while packet_queue.ready() and not rtu_state.shutdown do
local msg = packet_queue.pop()
if msg ~= nil then
if msg.qtype == mqueue.TYPE.COMMAND then
-- received a command
elseif msg.qtype == mqueue.TYPE.DATA then
-- received data
elseif msg.qtype == mqueue.TYPE.PACKET then
-- received a packet
local _, reply = unit.modbus_io.handle_packet(msg.message)
rtu_comms.send_modbus(reply)
end
end
-- quick yield
util.nop()
end
-- check for termination request
if rtu_state.shutdown then
log.info("rtu unit thread exiting -> " .. unit.name .. "(" .. unit.type .. ")")
break
end
-- delay before next check
last_update = util.adaptive_delay(COMMS_SLEEP, last_update)
end
end
-- execute the thread in a protected mode, retrying it on return if not shutting down
public.p_exec = function ()
local rtu_state = smem.rtu_state
while not rtu_state.shutdown do
local status, result = pcall(public.exec)
if status == false then
log.fatal(result)
end
if not rtu_state.shutdown then
log.info("rtu unit thread " .. unit.name .. "(" .. unit.type .. ") restarting in 5 seconds...")
util.psleep(5)
end
end
end
return public
end
return threads

View File

@ -1,3 +1,9 @@
local util = require("scada-common.util")
---@class alarm
local alarm = {}
---@alias SEVERITY integer
SEVERITY = { SEVERITY = {
INFO = 0, -- basic info message INFO = 0, -- basic info message
WARNING = 1, -- warning about some abnormal state WARNING = 1, -- warning about some abnormal state
@ -7,35 +13,11 @@ SEVERITY = {
EMERGENCY = 5 -- critical safety alarm EMERGENCY = 5 -- critical safety alarm
} }
function scada_alarm(severity, device, message) alarm.SEVERITY = SEVERITY
local self = {
time = os.time(),
ts_string = os.date("[%H:%M:%S]"),
severity = severity,
device = device,
message = message
}
local format = function () -- severity integer to string
return self.ts_string .. " [" .. severity_to_string(self.severity) .. "] (" .. self.device ") >> " .. self.message ---@param severity SEVERITY
end alarm.severity_to_string = function (severity)
local properties = function ()
return {
time = self.time,
severity = self.severity,
device = self.device,
message = self.message
}
end
return {
format = format,
properties = properties
}
end
function severity_to_string(severity)
if severity == SEVERITY.INFO then if severity == SEVERITY.INFO then
return "INFO" return "INFO"
elseif severity == SEVERITY.WARNING then elseif severity == SEVERITY.WARNING then
@ -52,3 +34,40 @@ function severity_to_string(severity)
return "UNKNOWN" return "UNKNOWN"
end end
end end
-- create a new scada alarm entry
---@param severity SEVERITY
---@param device string
---@param message string
alarm.scada_alarm = function (severity, device, message)
local self = {
time = util.time(),
ts_string = os.date("[%H:%M:%S]"),
severity = severity,
device = device,
message = message
}
---@class scada_alarm
local public = {}
-- format the alarm as a string
---@return string message
public.format = function ()
return self.ts_string .. " [" .. alarm.severity_to_string(self.severity) .. "] (" .. self.device ") >> " .. self.message
end
-- get alarm properties
public.properties = function ()
return {
time = self.time,
severity = self.severity,
device = self.device,
message = self.message
}
end
return public
end
return alarm

View File

@ -1,69 +1,105 @@
PROTOCOLS = { --
-- Communications
--
local log = require("scada-common.log")
local types = require("scada-common.types")
---@class comms
local comms = {}
local rtu_t = types.rtu_t
local insert = table.insert
---@alias PROTOCOLS integer
local PROTOCOLS = {
MODBUS_TCP = 0, -- our "MODBUS TCP"-esque protocol MODBUS_TCP = 0, -- our "MODBUS TCP"-esque protocol
RPLC = 1, -- reactor PLC protocol RPLC = 1, -- reactor PLC protocol
SCADA_MGMT = 2, -- SCADA supervisor intercommunication, device advertisements, etc SCADA_MGMT = 2, -- SCADA supervisor management, device advertisements, etc
COORD_DATA = 3 -- data packets for coordinators to/from supervisory controller COORD_DATA = 3, -- data/control packets for coordinators to/from supervisory controllers
COORD_API = 4 -- data/control packets for pocket computers to/from coordinators
} }
SCADA_SV_MODES = { ---@alias RPLC_TYPES integer
ACTIVE = 0, -- supervisor running as primary local RPLC_TYPES = {
BACKUP = 1 -- supervisor running as hot backup LINK_REQ = 0, -- linking requests
STATUS = 1, -- reactor/system status
MEK_STRUCT = 2, -- mekanism build structure
MEK_BURN_RATE = 3, -- set burn rate
RPS_ENABLE = 4, -- enable reactor
RPS_SCRAM = 5, -- SCRAM reactor
RPS_STATUS = 6, -- RPS status
RPS_ALARM = 7, -- RPS alarm broadcast
RPS_RESET = 8 -- clear RPS trip (if in bad state, will trip immediately)
} }
RPLC_TYPES = { ---@alias RPLC_LINKING integer
KEEP_ALIVE = 0, -- keep alive packets local RPLC_LINKING = {
LINK_REQ = 1, -- linking requests
STATUS = 2, -- reactor/system status
MEK_STRUCT = 3, -- mekanism build structure
MEK_SCRAM = 4, -- SCRAM reactor
MEK_ENABLE = 5, -- enable reactor
MEK_BURN_RATE = 6, -- set burn rate
ISS_ALARM = 7, -- ISS alarm broadcast
ISS_GET = 8, -- get ISS status
ISS_CLEAR = 9 -- clear ISS trip (if in bad state, will trip immediately)
}
RPLC_LINKING = {
ALLOW = 0, -- link approved ALLOW = 0, -- link approved
DENY = 1, -- link denied DENY = 1, -- link denied
COLLISION = 2 -- link denied due to existing active link COLLISION = 2 -- link denied due to existing active link
} }
SCADA_MGMT_TYPES = { ---@alias SCADA_MGMT_TYPES integer
PING = 0, -- generic ping local SCADA_MGMT_TYPES = {
SV_HEARTBEAT = 1, -- supervisor heartbeat KEEP_ALIVE = 0, -- keep alive packet w/ RTT
REMOTE_LINKED = 2, -- remote device linked CLOSE = 1, -- close a connection
RTU_ADVERT = 3, -- RTU capability advertisement RTU_ADVERT = 2, -- RTU capability advertisement
RTU_HEARTBEAT = 4, -- RTU heartbeat REMOTE_LINKED = 3 -- remote device linked
} }
RTU_ADVERT_TYPES = { ---@alias RTU_UNIT_TYPES integer
BOILER = 0, -- boiler local RTU_UNIT_TYPES = {
TURBINE = 1, -- turbine REDSTONE = 0, -- redstone I/O
IMATRIX = 2, -- induction matrix BOILER = 1, -- boiler
REDSTONE = 3 -- redstone I/O BOILER_VALVE = 2, -- boiler mekanism 10.1+
TURBINE = 3, -- turbine
TURBINE_VALVE = 4, -- turbine, mekanism 10.1+
EMACHINE = 5, -- energy machine
IMATRIX = 6 -- induction matrix
} }
comms.PROTOCOLS = PROTOCOLS
comms.RPLC_TYPES = RPLC_TYPES
comms.RPLC_LINKING = RPLC_LINKING
comms.SCADA_MGMT_TYPES = SCADA_MGMT_TYPES
comms.RTU_UNIT_TYPES = RTU_UNIT_TYPES
-- generic SCADA packet object -- generic SCADA packet object
function scada_packet() comms.scada_packet = function ()
local self = { local self = {
modem_msg_in = nil, modem_msg_in = nil,
valid = false, valid = false,
raw = nil,
seq_num = nil, seq_num = nil,
protocol = nil, protocol = nil,
length = nil, length = nil,
raw = nil payload = nil
} }
local make = function (seq_num, protocol, payload) ---@class scada_packet
local public = {}
-- make a SCADA packet
---@param seq_num integer
---@param protocol PROTOCOLS
---@param payload table
public.make = function (seq_num, protocol, payload)
self.valid = true self.valid = true
self.seq_num = seq_num self.seq_num = seq_num
self.protocol = protocol self.protocol = protocol
self.length = #payload self.length = #payload
self.raw = { self.seq_num, self.protocol, self.length, payload } self.payload = payload
self.raw = { self.seq_num, self.protocol, self.payload }
end end
local receive = function (side, sender, reply_to, message, distance) -- parse in a modem message as a SCADA packet
---@param side string
---@param sender integer
---@param reply_to integer
---@param message any
---@param distance integer
public.receive = function (side, sender, reply_to, message, distance)
self.modem_msg_in = { self.modem_msg_in = {
iface = side, iface = side,
s_port = sender, s_port = sender,
@ -74,107 +110,500 @@ function scada_packet()
self.raw = self.modem_msg_in.msg self.raw = self.modem_msg_in.msg
if #self.raw < 3 then if type(self.raw) == "table" then
-- malformed if #self.raw >= 3 then
return false
else
self.valid = true self.valid = true
self.seq_num = self.raw[1] self.seq_num = self.raw[1]
self.protocol = self.raw[2] self.protocol = self.raw[2]
self.length = self.raw[3] self.length = #self.raw[3]
self.payload = self.raw[3]
end end
end end
local modem_event = function () return self.modem_msg_in end return self.valid
local raw = function () return self.raw end
local is_valid = function () return self.valid end
local seq_num = function () return self.seq_num end
local protocol = function () return self.protocol end
local length = function () return self.length end
local data = function ()
local subset = nil
if self.valid then
subset = { table.unpack(self.raw, 4, 3 + self.length) }
end
return subset
end end
return { -- public accessors --
make = make,
receive = receive, public.modem_event = function () return self.modem_msg_in end
modem_event = modem_event, public.raw_sendable = function () return self.raw end
raw = raw,
is_valid = is_valid, public.local_port = function () return self.modem_msg_in.s_port end
seq_num = seq_num, public.remote_port = function () return self.modem_msg_in.r_port end
protocol = protocol,
length = length, public.is_valid = function () return self.valid end
data = data
} public.seq_num = function () return self.seq_num end
public.protocol = function () return self.protocol end
public.length = function () return self.length end
public.data = function () return self.payload end
return public
end end
function mgmt_packet() -- MODBUS packet
-- modeled after MODBUS TCP packet
comms.modbus_packet = function ()
local self = { local self = {
frame = nil, frame = nil,
raw = nil,
txn_id = nil,
length = nil,
unit_id = nil,
func_code = nil,
data = nil
}
---@class modbus_packet
local public = {}
-- make a MODBUS packet
---@param txn_id integer
---@param unit_id integer
---@param func_code MODBUS_FCODE
---@param data table
public.make = function (txn_id, unit_id, func_code, data)
self.txn_id = txn_id
self.length = #data
self.unit_id = unit_id
self.func_code = func_code
self.data = data
-- populate raw array
self.raw = { self.txn_id, self.unit_id, self.func_code }
for i = 1, self.length do
insert(self.raw, data[i])
end
end
-- decode a MODBUS packet from a SCADA frame
---@param frame scada_packet
---@return boolean success
public.decode = function (frame)
if frame then
self.frame = frame
if frame.protocol() == PROTOCOLS.MODBUS_TCP then
local size_ok = frame.length() >= 3
if size_ok then
local data = frame.data()
public.make(data[1], data[2], data[3], { table.unpack(data, 4, #data) })
end
return size_ok
else
log.debug("attempted MODBUS_TCP parse of incorrect protocol " .. frame.protocol(), true)
return false
end
else
log.debug("nil frame encountered", true)
return false
end
end
-- get raw to send
public.raw_sendable = function () return self.raw end
-- get this packet as a frame with an immutable relation to this object
public.get = function ()
---@class modbus_frame
local frame = {
scada_frame = self.frame,
txn_id = self.txn_id,
length = self.length,
unit_id = self.unit_id,
func_code = self.func_code,
data = self.data
}
return frame
end
return public
end
-- reactor PLC packet
comms.rplc_packet = function ()
local self = {
frame = nil,
raw = nil,
id = nil,
type = nil,
length = nil,
body = nil
}
---@class rplc_packet
local public = {}
-- check that type is known
local _rplc_type_valid = function ()
return self.type == RPLC_TYPES.LINK_REQ or
self.type == RPLC_TYPES.STATUS or
self.type == RPLC_TYPES.MEK_STRUCT or
self.type == RPLC_TYPES.MEK_BURN_RATE or
self.type == RPLC_TYPES.RPS_ENABLE or
self.type == RPLC_TYPES.RPS_SCRAM or
self.type == RPLC_TYPES.RPS_ALARM or
self.type == RPLC_TYPES.RPS_STATUS or
self.type == RPLC_TYPES.RPS_RESET
end
-- make an RPLC packet
---@param id integer
---@param packet_type RPLC_TYPES
---@param data table
public.make = function (id, packet_type, data)
-- packet accessor properties
self.id = id
self.type = packet_type
self.length = #data
self.data = data
-- populate raw array
self.raw = { self.id, self.type }
for i = 1, #data do
insert(self.raw, data[i])
end
end
-- decode an RPLC packet from a SCADA frame
---@param frame scada_packet
---@return boolean success
public.decode = function (frame)
if frame then
self.frame = frame
if frame.protocol() == PROTOCOLS.RPLC then
local ok = frame.length() >= 2
if ok then
local data = frame.data()
public.make(data[1], data[2], { table.unpack(data, 3, #data) })
ok = _rplc_type_valid()
end
return ok
else
log.debug("attempted RPLC parse of incorrect protocol " .. frame.protocol(), true)
return false
end
else
log.debug("nil frame encountered", true)
return false
end
end
-- get raw to send
public.raw_sendable = function () return self.raw end
-- get this packet as a frame with an immutable relation to this object
public.get = function ()
---@class rplc_frame
local frame = {
scada_frame = self.frame,
id = self.id,
type = self.type,
length = self.length,
data = self.data
}
return frame
end
return public
end
-- SCADA management packet
comms.mgmt_packet = function ()
local self = {
frame = nil,
raw = nil,
type = nil, type = nil,
length = nil, length = nil,
data = nil data = nil
} }
---@class mgmt_packet
local public = {}
-- check that type is known
local _scada_type_valid = function () local _scada_type_valid = function ()
return self.type == SCADA_MGMT_TYPES.PING or return self.type == SCADA_MGMT_TYPES.KEEP_ALIVE or
self.type == SCADA_MGMT_TYPES.SV_HEARTBEAT or self.type == SCADA_MGMT_TYPES.CLOSE or
self.type == SCADA_MGMT_TYPES.REMOTE_LINKED or self.type == SCADA_MGMT_TYPES.REMOTE_LINKED or
self.type == SCADA_MGMT_TYPES.RTU_ADVERT or self.type == SCADA_MGMT_TYPES.RTU_ADVERT
self.type == SCADA_MGMT_TYPES.RTU_HEARTBEAT
end end
-- make a SCADA management packet -- make a SCADA management packet
local make = function (packet_type, length, data) ---@param packet_type SCADA_MGMT_TYPES
---@param data table
public.make = function (packet_type, data)
-- packet accessor properties
self.type = packet_type self.type = packet_type
self.length = length self.length = #data
self.data = data self.data = data
-- populate raw array
self.raw = { self.type }
for i = 1, #data do
insert(self.raw, data[i])
end
end end
-- decode a SCADA management packet from a SCADA frame -- decode a SCADA management packet from a SCADA frame
local decode = function (frame) ---@param frame scada_packet
---@return boolean success
public.decode = function (frame)
if frame then if frame then
self.frame = frame self.frame = frame
if frame.protocol() == comms.PROTOCOLS.SCADA_MGMT then if frame.protocol() == PROTOCOLS.SCADA_MGMT then
local data = frame.data() local ok = frame.length() >= 1
local ok = #data > 1
if ok then if ok then
make(data[1], data[2], { table.unpack(data, 3, #data) }) local data = frame.data()
public.make(data[1], { table.unpack(data, 2, #data) })
ok = _scada_type_valid() ok = _scada_type_valid()
end end
return ok return ok
else else
log._debug("attempted SCADA_MGMT parse of incorrect protocol " .. frame.protocol(), true) log.debug("attempted SCADA_MGMT parse of incorrect protocol " .. frame.protocol(), true)
return false return false
end end
else else
log._debug("nil frame encountered", true) log.debug("nil frame encountered", true)
return false return false
end end
end end
local get = function () -- get raw to send
return { public.raw_sendable = function () return self.raw end
-- get this packet as a frame with an immutable relation to this object
public.get = function ()
---@class mgmt_frame
local frame = {
scada_frame = self.frame, scada_frame = self.frame,
type = self.type, type = self.type,
length = self.length, length = self.length,
data = self.data data = self.data
} }
return frame
end end
return { return public
make = make,
decode = decode,
get = get
}
end end
-- SCADA coordinator packet
-- @todo
comms.coord_packet = function ()
local self = {
frame = nil,
raw = nil,
type = nil,
length = nil,
data = nil
}
---@class coord_packet
local public = {}
local _coord_type_valid = function ()
-- @todo
return false
end
-- make a coordinator packet
---@param packet_type any
---@param data table
public.make = function (packet_type, data)
-- packet accessor properties
self.type = packet_type
self.length = #data
self.data = data
-- populate raw array
self.raw = { self.type }
for i = 1, #data do
insert(self.raw, data[i])
end
end
-- decode a coordinator packet from a SCADA frame
---@param frame scada_packet
---@return boolean success
public.decode = function (frame)
if frame then
self.frame = frame
if frame.protocol() == PROTOCOLS.COORD_DATA then
local ok = frame.length() >= 1
if ok then
local data = frame.data()
public.make(data[1], { table.unpack(data, 2, #data) })
ok = _coord_type_valid()
end
return ok
else
log.debug("attempted COORD_DATA parse of incorrect protocol " .. frame.protocol(), true)
return false
end
else
log.debug("nil frame encountered", true)
return false
end
end
-- get raw to send
public.raw_sendable = function () return self.raw end
-- get this packet as a frame with an immutable relation to this object
public.get = function ()
---@class coord_frame
local frame = {
scada_frame = self.frame,
type = self.type,
length = self.length,
data = self.data
}
return frame
end
return public
end
-- coordinator API (CAPI) packet
-- @todo
comms.capi_packet = function ()
local self = {
frame = nil,
raw = nil,
type = nil,
length = nil,
data = nil
}
---@class capi_packet
local public = {}
local _coord_type_valid = function ()
-- @todo
return false
end
-- make a coordinator API packet
---@param packet_type any
---@param data table
public.make = function (packet_type, data)
-- packet accessor properties
self.type = packet_type
self.length = #data
self.data = data
-- populate raw array
self.raw = { self.type }
for i = 1, #data do
insert(self.raw, data[i])
end
end
-- decode a coordinator API packet from a SCADA frame
---@param frame scada_packet
---@return boolean success
public.decode = function (frame)
if frame then
self.frame = frame
if frame.protocol() == PROTOCOLS.COORD_API then
local ok = frame.length() >= 1
if ok then
local data = frame.data()
public.make(data[1], { table.unpack(data, 2, #data) })
ok = _coord_type_valid()
end
return ok
else
log.debug("attempted COORD_API parse of incorrect protocol " .. frame.protocol(), true)
return false
end
else
log.debug("nil frame encountered", true)
return false
end
end
-- get raw to send
public.raw_sendable = function () return self.raw end
-- get this packet as a frame with an immutable relation to this object
public.get = function ()
---@class capi_frame
local frame = {
scada_frame = self.frame,
type = self.type,
length = self.length,
data = self.data
}
return frame
end
return public
end
-- convert rtu_t to RTU unit type
---@param type rtu_t
---@return RTU_UNIT_TYPES|nil
comms.rtu_t_to_unit_type = function (type)
if type == rtu_t.redstone then
return RTU_UNIT_TYPES.REDSTONE
elseif type == rtu_t.boiler then
return RTU_UNIT_TYPES.BOILER
elseif type == rtu_t.boiler_valve then
return RTU_UNIT_TYPES.BOILER_VALVE
elseif type == rtu_t.turbine then
return RTU_UNIT_TYPES.TURBINE
elseif type == rtu_t.turbine_valve then
return RTU_UNIT_TYPES.TURBINE_VALVE
elseif type == rtu_t.energy_machine then
return RTU_UNIT_TYPES.EMACHINE
elseif type == rtu_t.induction_matrix then
return RTU_UNIT_TYPES.IMATRIX
end
return nil
end
-- convert RTU unit type to rtu_t
---@param utype RTU_UNIT_TYPES
---@return rtu_t|nil
comms.advert_type_to_rtu_t = function (utype)
if utype == RTU_UNIT_TYPES.REDSTONE then
return rtu_t.redstone
elseif utype == RTU_UNIT_TYPES.BOILER then
return rtu_t.boiler
elseif utype == RTU_UNIT_TYPES.BOILER_VALVE then
return rtu_t.boiler_valve
elseif utype == RTU_UNIT_TYPES.TURBINE then
return rtu_t.turbine
elseif utype == RTU_UNIT_TYPES.TURBINE_VALVE then
return rtu_t.turbine_valve
elseif utype == RTU_UNIT_TYPES.EMACHINE then
return rtu_t.energy_machine
elseif utype == RTU_UNIT_TYPES.IMATRIX then
return rtu_t.induction_matrix
end
return nil
end
return comms

View File

@ -1,64 +1,205 @@
local util = require("scada-common.util")
-- --
-- File System Logger -- File System Logger
-- --
-- we use extra short abbreviations since computer craft screens are very small ---@class log
-- underscores are used since some of these names are used elsewhere (e.g. 'debug' is a lua table) local log = {}
---@alias MODE integer
local MODE = {
APPEND = 0,
NEW = 1
}
log.MODE = MODE
-- whether to log debug messages or not
local LOG_DEBUG = true local LOG_DEBUG = true
local file_handle = fs.open("/log.txt", "a") local _log_sys = {
path = "/log.txt",
mode = MODE.APPEND,
file = nil,
dmesg_out = nil
}
local _log = function (msg) ---@type function
local stamped = os.date("[%c] ") .. msg local free_space = fs.getFreeSpace
file_handle.writeLine(stamped)
file_handle.flush() -- initialize logger
---@param path string file path
---@param write_mode MODE
---@param dmesg_redirect? table terminal/window to direct dmesg to
log.init = function (path, write_mode, dmesg_redirect)
_log_sys.path = path
_log_sys.mode = write_mode
if _log_sys.mode == MODE.APPEND then
_log_sys.file = fs.open(path, "a")
else
_log_sys.file = fs.open(path, "w")
end
if dmesg_redirect then
_log_sys.dmesg_out = dmesg_redirect
else
_log_sys.dmesg_out = term.current()
end
end end
function _debug(msg, trace) -- private log write function
---@param msg string
local _log = function (msg)
local time_stamp = os.date("[%c] ")
local stamped = time_stamp .. util.strval(msg)
-- attempt to write log
local status, result = pcall(function ()
_log_sys.file.writeLine(stamped)
_log_sys.file.flush()
end)
-- if we don't have space, we need to create a new log file
if not status then
if result == "Out of space" then
-- will delete log file
elseif result ~= nil then
util.println("unknown error writing to logfile: " .. result)
end
end
if (result == "Out of space") or (free_space(_log_sys.path) < 100) then
-- delete the old log file and open a new one
_log_sys.file.close()
fs.delete(_log_sys.path)
log.init(_log_sys.path, _log_sys.mode)
-- leave a message
_log_sys.file.writeLine(time_stamp .. "recycled log file")
_log_sys.file.writeLine(stamped)
_log_sys.file.flush()
end
end
-- write a message to the dmesg output
---@param msg string message to write
local _write = function (msg)
local out = _log_sys.dmesg_out
local out_w, out_h = out.getSize()
local lines = { msg }
-- wrap if needed
if string.len(msg) > out_w then
local remaining = true
local s_start = 1
local s_end = out_w
local i = 1
lines = {}
while remaining do
local line = string.sub(msg, s_start, s_end)
if line == "" then
remaining = false
else
lines[i] = line
s_start = s_end + 1
s_end = s_end + out_w
i = i + 1
end
end
end
-- output message
for i = 1, #lines do
local cur_x, cur_y = out.getCursorPos()
if cur_x > 1 then
if cur_y == out_h then
out.scroll(1)
out.setCursorPos(1, cur_y)
else
out.setCursorPos(1, cur_y + 1)
end
end
out.write(lines[i])
end
end
-- dmesg style logging for boot because I like linux-y things
---@param msg string message
---@param show_term? boolean whether or not to show on terminal output
log.dmesg = function (msg, show_term)
local message = string.format("[%10.3f] ", os.clock()) .. util.strval(msg)
if show_term then _write(message) end
_log(message)
end
-- log debug messages
---@param msg string message
---@param trace? boolean include file trace
log.debug = function (msg, trace)
if LOG_DEBUG then if LOG_DEBUG then
local dbg_info = "" local dbg_info = ""
if trace then if trace then
local info = debug.getinfo(2)
local name = "" local name = ""
if debug.getinfo(2).name ~= nil then if info.name ~= nil then
name = ":" .. debug.getinfo(2).name .. "():" name = ":" .. info.name .. "():"
end end
dbg_info = debug.getinfo(2).short_src .. ":" .. name .. dbg_info = info.short_src .. ":" .. name .. info.currentline .. " > "
debug.getinfo(2).currentline .. " > "
end end
_log("[DBG] " .. dbg_info .. msg) _log("[DBG] " .. dbg_info .. util.strval(msg))
end end
end end
function _info(msg) -- log info messages
_log("[INF] " .. msg) ---@param msg string message
log.info = function (msg)
_log("[INF] " .. util.strval(msg))
end end
function _warning(msg) -- log warning messages
_log("[WRN] " .. msg) ---@param msg string message
log.warning = function (msg)
_log("[WRN] " .. util.strval(msg))
end end
function _error(msg, trace) -- log error messages
---@param msg string message
---@param trace? boolean include file trace
log.error = function (msg, trace)
local dbg_info = "" local dbg_info = ""
if trace then if trace then
local info = debug.getinfo(2)
local name = "" local name = ""
if debug.getinfo(2).name ~= nil then if info.name ~= nil then
name = ":" .. debug.getinfo(2).name .. "():" name = ":" .. info.name .. "():"
end end
dbg_info = debug.getinfo(2).short_src .. ":" .. name .. dbg_info = info.short_src .. ":" .. name .. info.currentline .. " > "
debug.getinfo(2).currentline .. " > "
end end
_log("[ERR] " .. dbg_info .. msg) _log("[ERR] " .. dbg_info .. util.strval(msg))
end end
function _fatal(msg) -- log fatal errors
_log("[FTL] " .. msg) ---@param msg string message
log.fatal = function (msg)
_log("[FTL] " .. util.strval(msg))
end end
return log

View File

@ -1,325 +0,0 @@
-- modbus function codes
local MODBUS_FCODE = {
READ_COILS = 0x01,
READ_DISCRETE_INPUTS = 0x02,
READ_MUL_HOLD_REGS = 0x03,
READ_INPUT_REGS = 0x04,
WRITE_SINGLE_COIL = 0x05,
WRITE_SINGLE_HOLD_REG = 0x06,
WRITE_MUL_COILS = 0x0F,
WRITE_MUL_HOLD_REGS = 0x10,
ERROR_FLAG = 0x80
}
-- modbus exception codes
local MODBUS_EXCODE = {
ILLEGAL_FUNCTION = 0x01,
ILLEGAL_DATA_ADDR = 0x02,
ILLEGAL_DATA_VALUE = 0x03,
SERVER_DEVICE_FAIL = 0x04,
ACKNOWLEDGE = 0x05,
SERVER_DEVICE_BUSY = 0x06,
NEG_ACKNOWLEDGE = 0x07,
MEMORY_PARITY_ERROR = 0x08,
GATEWAY_PATH_UNAVAILABLE = 0x0A,
GATEWAY_TARGET_TIMEOUT = 0x0B
}
-- new modbus comms handler object
function new(rtu_dev)
local self = {
rtu = rtu_dev
}
local _1_read_coils = function (c_addr_start, count)
local readings = {}
local access_fault = false
local _, coils, _, _ = self.rtu.io_count()
local return_ok = (c_addr_start + count) <= coils
if return_ok then
for i = 0, (count - 1) do
readings[i], access_fault = self.rtu.read_coil(c_addr_start + i)
if access_fault then
return_ok = false
readings = MODBUS_EXCODE.SERVER_DEVICE_FAIL
break
end
end
else
readings = MODBUS_EXCODE.ILLEGAL_DATA_ADDR
end
return return_ok, readings
end
local _2_read_discrete_inputs = function (di_addr_start, count)
local readings = {}
local access_fault = false
local discrete_inputs, _, _, _ = self.rtu.io_count()
local return_ok = (di_addr_start + count) <= discrete_inputs
if return_ok then
for i = 0, (count - 1) do
readings[i], access_fault = self.rtu.read_di(di_addr_start + i)
if access_fault then
return_ok = false
readings = MODBUS_EXCODE.SERVER_DEVICE_FAIL
break
end
end
else
readings = MODBUS_EXCODE.ILLEGAL_DATA_ADDR
end
return return_ok, readings
end
local _3_read_multiple_holding_registers = function (hr_addr_start, count)
local readings = {}
local access_fault = false
local _, _, _, hold_regs = self.rtu.io_count()
local return_ok = (hr_addr_start + count) <= hold_regs
if return_ok then
for i = 0, (count - 1) do
readings[i], access_fault = self.rtu.read_holding_reg(hr_addr_start + i)
if access_fault then
return_ok = false
readings = MODBUS_EXCODE.SERVER_DEVICE_FAIL
break
end
end
else
readings = MODBUS_EXCODE.ILLEGAL_DATA_ADDR
end
return return_ok, readings
end
local _4_read_input_registers = function (ir_addr_start, count)
local readings = {}
local access_fault = false
local _, _, input_regs, _ = self.rtu.io_count()
local return_ok = (ir_addr_start + count) <= input_regs
if return_ok then
for i = 0, (count - 1) do
readings[i], access_fault = self.rtu.read_input_reg(ir_addr_start + i)
if access_fault then
return_ok = false
readings = MODBUS_EXCODE.SERVER_DEVICE_FAIL
break
end
end
else
readings = MODBUS_EXCODE.ILLEGAL_DATA_ADDR
end
return return_ok, readings
end
local _5_write_single_coil = function (c_addr, value)
local response = nil
local _, coils, _, _ = self.rtu.io_count()
local return_ok = c_addr <= coils
if return_ok then
local access_fault = self.rtu.write_coil(c_addr, value)
if access_fault then
return_ok = false
readings = MODBUS_EXCODE.SERVER_DEVICE_FAIL
end
else
response = MODBUS_EXCODE.ILLEGAL_DATA_ADDR
end
return return_ok, response
end
local _6_write_single_holding_register = function (hr_addr, value)
local response = nil
local _, _, _, hold_regs = self.rtu.io_count()
local return_ok = hr_addr <= hold_regs
if return_ok then
local access_fault = self.rtu.write_holding_reg(hr_addr, value)
if access_fault then
return_ok = false
readings = MODBUS_EXCODE.SERVER_DEVICE_FAIL
end
end
return return_ok
end
local _15_write_multiple_coils = function (c_addr_start, values)
local response = nil
local _, coils, _, _ = self.rtu.io_count()
local count = #values
local return_ok = (c_addr_start + count) <= coils
if return_ok then
for i = 0, (count - 1) do
local access_fault = self.rtu.write_coil(c_addr_start + i, values[i + 1])
if access_fault then
return_ok = false
readings = MODBUS_EXCODE.SERVER_DEVICE_FAIL
break
end
end
end
return return_ok, response
end
local _16_write_multiple_holding_registers = function (hr_addr_start, values)
local response = nil
local _, _, _, hold_regs = self.rtu.io_count()
local count = #values
local return_ok = (hr_addr_start + count) <= hold_regs
if return_ok then
for i = 0, (count - 1) do
local access_fault = self.rtu.write_coil(hr_addr_start + i, values[i + 1])
if access_fault then
return_ok = false
readings = MODBUS_EXCODE.SERVER_DEVICE_FAIL
break
end
end
end
return return_ok, response
end
local handle_packet = function (packet)
local return_code = true
local response = nil
local reply = packet
if #packet.data == 2 then
-- handle by function code
if packet.func_code == MODBUS_FCODE.READ_COILS then
return_code, response = _1_read_coils(packet.data[1], packet.data[2])
elseif packet.func_code == MODBUS_FCODE.READ_DISCRETE_INPUTS then
return_code, response = _2_read_discrete_inputs(packet.data[1], packet.data[2])
elseif packet.func_code == MODBUS_FCODE.READ_MUL_HOLD_REGS then
return_code, response = _3_read_multiple_holding_registers(packet.data[1], packet.data[2])
elseif packet.func_code == MODBUS_FCODE.READ_INPUT_REGISTERS then
return_code, response = _4_read_input_registers(packet.data[1], packet.data[2])
elseif packet.func_code == MODBUS_FCODE.WRITE_SINGLE_COIL then
return_code, response = _5_write_single_coil(packet.data[1], packet.data[2])
elseif packet.func_code == MODBUS_FCODE.WRITE_SINGLE_HOLD_REG then
return_code, response = _6_write_single_holding_register(packet.data[1], packet.data[2])
elseif packet.func_code == MODBUS_FCODE.WRITE_MUL_COILS then
return_code, response = _15_write_multiple_coils(packet.data[1], packet.data[2])
elseif packet.func_code == MODBUS_FCODE.WRITE_MUL_HOLD_REGS then
return_code, response = _16_write_multiple_holding_registers(packet.data[1], packet.data[2])
else
-- unknown function
return_code = false
response = MODBUS_EXCODE.ILLEGAL_FUNCTION
end
else
-- invalid length
return_code = false
end
if return_code then
-- default is to echo back
if type(response) == "table" then
reply.length = #response
reply.data = response
end
else
-- echo back with error flag
reply.func_code = bit.bor(packet.func_code, MODBUS_FCODE.ERROR_FLAG)
if type(response) == "nil" then
reply.length = 0
reply.data = {}
elseif type(response) == "number" then
reply.length = 1
reply.data = { response }
elseif type(response) == "table" then
reply.length = #response
reply.data = response
end
end
return return_code, reply
end
return {
handle_packet = handle_packet
}
end
function packet()
local self = {
frame = nil,
txn_id = txn_id,
protocol = protocol,
length = length,
unit_id = unit_id,
func_code = func_code,
data = data
}
-- make a MODBUS packet
local make = function (txn_id, protocol, length, unit_id, func_code, data)
self.txn_id = txn_id
self.protocol = protocol
self.length = length
self.unit_id = unit_id
self.func_code = func_code
self.data = data
end
-- decode a MODBUS packet from a SCADA frame
local decode = function (frame)
if frame then
self.frame = frame
local data = frame.data()
local size_ok = #data ~= 6
if size_ok then
make(data[1], data[2], data[3], data[4], data[5], data[6])
end
return size_ok and self.protocol == comms.PROTOCOLS.MODBUS_TCP
else
log._debug("nil frame encountered", true)
return false
end
end
-- get this packet
local get = function ()
return {
scada_frame = self.frame,
txn_id = self.txn_id,
protocol = self.protocol,
length = self.length,
unit_id = self.unit_id,
func_code = self.func_code,
data = self.data
}
end
return {
make = make,
decode = decode,
get = get
}
end

83
scada-common/mqueue.lua Normal file
View File

@ -0,0 +1,83 @@
--
-- Message Queue
--
local mqueue = {}
---@alias TYPE integer
local TYPE = {
COMMAND = 0,
DATA = 1,
PACKET = 2
}
mqueue.TYPE = TYPE
-- create a new message queue
mqueue.new = function ()
local queue = {}
local insert = table.insert
local remove = table.remove
---@class queue_item
---@field qtype TYPE
---@field message any
---@class queue_data
---@field key any
---@field val any
---@class mqueue
local public = {}
-- get queue length
public.length = function () return #queue end
-- check if queue is empty
---@return boolean is_empty
public.empty = function () return #queue == 0 end
-- check if queue has contents
public.ready = function () return #queue ~= 0 end
-- push a new item onto the queue
---@param qtype TYPE
---@param message string
local _push = function (qtype, message)
insert(queue, { qtype = qtype, message = message })
end
-- push a command onto the queue
---@param message any
public.push_command = function (message)
_push(TYPE.COMMAND, message)
end
-- push data onto the queue
---@param key any
---@param value any
public.push_data = function (key, value)
_push(TYPE.DATA, { key = key, val = value })
end
-- push a packet onto the queue
---@param packet scada_packet|modbus_packet|rplc_packet|coord_packet|capi_packet
public.push_packet = function (packet)
_push(TYPE.PACKET, packet)
end
-- get an item off the queue
---@return queue_item|nil
public.pop = function ()
if #queue > 0 then
return remove(queue, 1)
else
return nil
end
end
return public
end
return mqueue

View File

@ -1,57 +1,113 @@
-- #REQUIRES log.lua local log = require("scada-common.log")
-- --
-- Protected Peripheral Manager -- Protected Peripheral Manager
-- --
ACCESS_OK = true ---@class ppm
ACCESS_FAULT = nil local ppm = {}
local ACCESS_FAULT = nil ---@type nil
ppm.ACCESS_FAULT = ACCESS_FAULT
---------------------------- ----------------------------
-- PRIVATE DATA/FUNCTIONS -- -- PRIVATE DATA/FUNCTIONS --
---------------------------- ----------------------------
local self = { local REPORT_FREQUENCY = 20 -- log every 20 faults per function
local _ppm_sys = {
mounts = {}, mounts = {},
auto_cf = false, auto_cf = false,
faulted = false, faulted = false,
last_fault = "",
terminate = false, terminate = false,
mute = false mute = false
} }
-- wrap peripheral calls with lua protected call -- wrap peripheral calls with lua protected call as we don't want a disconnect to crash a program
-- ex. reason: we don't want a disconnect to crash the program before a SCRAM ---
local peri_init = function (device) ---also provides peripheral-specific fault checks (auto-clear fault defaults to true)
for key, func in pairs(device) do ---
device[key] = function (...) ---assumes iface is a valid peripheral
---@param iface string CC peripheral interface
local peri_init = function (iface)
local self = {
faulted = false,
last_fault = "",
fault_counts = {},
auto_cf = true,
type = peripheral.getType(iface),
device = peripheral.wrap(iface)
}
-- initialization process (re-map)
for key, func in pairs(self.device) do
self.fault_counts[key] = 0
self.device[key] = function (...)
local status, result = pcall(func, ...) local status, result = pcall(func, ...)
if status then if status then
-- auto fault clear -- auto fault clear
if self.auto_cf then self.faulted = false end if self.auto_cf then self.faulted = false end
if _ppm_sys.auto_cf then _ppm_sys.faulted = false end
self.fault_counts[key] = 0
-- assume nil is only for functions with no return, so return status
if result == nil then
return ACCESS_OK
else
return result return result
end
else else
-- function failed -- function failed
self.faulted = true self.faulted = true
self.last_fault = result
if not mute then _ppm_sys.faulted = true
log._error("PPM: protected " .. key .. "() -> " .. result) _ppm_sys.last_fault = result
if not _ppm_sys.mute and (self.fault_counts[key] % REPORT_FREQUENCY == 0) then
local count_str = ""
if self.fault_counts[key] > 0 then
count_str = " [" .. self.fault_counts[key] .. " total faults]"
end end
log.error("PPM: protected " .. key .. "() -> " .. result .. count_str)
end
self.fault_counts[key] = self.fault_counts[key] + 1
if result == "Terminated" then if result == "Terminated" then
self.terminate = true _ppm_sys.terminate = true
end end
return ACCESS_FAULT return ACCESS_FAULT
end end
end end
end end
-- fault management functions
local clear_fault = function () self.faulted = false end
local get_last_fault = function () return self.last_fault end
local is_faulted = function () return self.faulted end
local is_ok = function () return not self.faulted end
local enable_afc = function () self.auto_cf = true end
local disable_afc = function () self.auto_cf = false end
-- append to device functions
self.device.__p_clear_fault = clear_fault
self.device.__p_last_fault = get_last_fault
self.device.__p_is_faulted = is_faulted
self.device.__p_is_ok = is_ok
self.device.__p_enable_afc = enable_afc
self.device.__p_disable_afc = disable_afc
return {
type = self.type,
dev = self.device
}
end end
---------------------- ----------------------
@ -61,132 +117,152 @@ end
-- REPORTING -- -- REPORTING --
-- silence error prints -- silence error prints
function disable_reporting() ppm.disable_reporting = function ()
self.mute = true _ppm_sys.mute = true
end end
-- allow error prints -- allow error prints
function enable_reporting() ppm.enable_reporting = function ()
self.mute = false _ppm_sys.mute = false
end end
-- FAULT MEMORY -- -- FAULT MEMORY --
-- enable automatically clearing fault flag -- enable automatically clearing fault flag
function enable_afc() ppm.enable_afc = function ()
self.auto_cf = true _ppm_sys.auto_cf = true
end end
-- disable automatically clearing fault flag -- disable automatically clearing fault flag
function disable_afc() ppm.disable_afc = function ()
self.auto_cf = false _ppm_sys.auto_cf = false
end
-- check fault flag
function is_faulted()
return self.faulted
end end
-- clear fault flag -- clear fault flag
function clear_fault() ppm.clear_fault = function ()
self.faulted = false _ppm_sys.faulted = false
end
-- check fault flag
ppm.is_faulted = function ()
return _ppm_sys.faulted
end
-- get the last fault message
ppm.get_last_fault = function ()
return _ppm_sys.last_fault
end end
-- TERMINATION -- -- TERMINATION --
-- if a caught error was a termination request -- if a caught error was a termination request
function should_terminate() ppm.should_terminate = function ()
return self.terminate return _ppm_sys.terminate
end end
-- MOUNTING -- -- MOUNTING --
-- mount all available peripherals (clears mounts first) -- mount all available peripherals (clears mounts first)
function mount_all() ppm.mount_all = function ()
local ifaces = peripheral.getNames() local ifaces = peripheral.getNames()
self.mounts = {} _ppm_sys.mounts = {}
for i = 1, #ifaces do for i = 1, #ifaces do
local pm_dev = peripheral.wrap(ifaces[i]) _ppm_sys.mounts[ifaces[i]] = peri_init(ifaces[i])
peri_init(pm_dev)
self.mounts[ifaces[i]] = { log.info("PPM: found a " .. _ppm_sys.mounts[ifaces[i]].type .. " (" .. ifaces[i] .. ")")
type = peripheral.getType(ifaces[i]),
dev = pm_dev
}
log._debug("PPM: found a " .. self.mounts[ifaces[i]].type .. " (" .. ifaces[i] .. ")")
end end
if #ifaces == 0 then if #ifaces == 0 then
log._warning("PPM: mount_all() -> no devices found") log.warning("PPM: mount_all() -> no devices found")
end end
end end
-- mount a particular device -- mount a particular device
function mount(iface) ---@param iface string CC peripheral interface
---@return string|nil type, table|nil device
ppm.mount = function (iface)
local ifaces = peripheral.getNames() local ifaces = peripheral.getNames()
local pm_dev = nil local pm_dev = nil
local type = nil local pm_type = nil
for i = 1, #ifaces do for i = 1, #ifaces do
if iface == ifaces[i] then if iface == ifaces[i] then
log._debug("PPM: mount(" .. iface .. ") -> found a " .. peripheral.getType(iface)) _ppm_sys.mounts[iface] = peri_init(iface)
type = peripheral.getType(iface) pm_type = _ppm_sys.mounts[iface].type
pm_dev = peripheral.wrap(iface) pm_dev = _ppm_sys.mounts[iface].dev
peri_init(pm_dev)
self.mounts[iface] = { log.info("PPM: mount(" .. iface .. ") -> found a " .. pm_type)
type = peripheral.getType(iface),
dev = pm_dev
}
break break
end end
end end
return type, pm_dev return pm_type, pm_dev
end end
-- handle peripheral_detach event -- handle peripheral_detach event
function handle_unmount(iface) ---@param iface string CC peripheral interface
---@return string|nil type, table|nil device
ppm.handle_unmount = function (iface)
local pm_dev = nil
local pm_type = nil
-- what got disconnected? -- what got disconnected?
local lost_dev = self.mounts[iface] local lost_dev = _ppm_sys.mounts[iface]
local type = lost_dev.type
log._warning("PPM: lost device " .. type .. " mounted to " .. iface) if lost_dev then
pm_type = lost_dev.type
pm_dev = lost_dev.dev
return lost_dev log.warning("PPM: lost device " .. pm_type .. " mounted to " .. iface)
else
log.error("PPM: lost device unknown to the PPM mounted to " .. iface)
end
return pm_type, pm_dev
end end
-- GENERAL ACCESSORS -- -- GENERAL ACCESSORS --
-- list all available peripherals -- list all available peripherals
function list_avail() ---@return table names
ppm.list_avail = function ()
return peripheral.getNames() return peripheral.getNames()
end end
-- list mounted peripherals -- list mounted peripherals
function list_mounts() ---@return table mounts
return self.mounts ppm.list_mounts = function ()
return _ppm_sys.mounts
end end
-- get a mounted peripheral by side/interface -- get a mounted peripheral by side/interface
function get_periph(iface) ---@param iface string CC peripheral interface
return self.mounts[iface].dev ---@return table|nil device function table
ppm.get_periph = function (iface)
if _ppm_sys.mounts[iface] then
return _ppm_sys.mounts[iface].dev
else return nil end
end end
-- get a mounted peripheral type by side/interface -- get a mounted peripheral type by side/interface
function get_type(iface) ---@param iface string CC peripheral interface
return self.mounts[iface].type ---@return string|nil type
ppm.get_type = function (iface)
if _ppm_sys.mounts[iface] then
return _ppm_sys.mounts[iface].type
else return nil end
end end
-- get all mounted peripherals by type -- get all mounted peripherals by type
function get_all_devices(name) ---@param name string type name
---@return table devices device function tables
ppm.get_all_devices = function (name)
local devices = {} local devices = {}
for side, data in pairs(self.mounts) do for _, data in pairs(_ppm_sys.mounts) do
if data.type == name then if data.type == name then
table.insert(devices, data.dev) table.insert(devices, data.dev)
end end
@ -196,10 +272,12 @@ function get_all_devices(name)
end end
-- get a mounted peripheral by type (if multiple, returns the first) -- get a mounted peripheral by type (if multiple, returns the first)
function get_device(name) ---@param name string type name
---@return table|nil device function table
ppm.get_device = function (name)
local device = nil local device = nil
for side, data in pairs(self.mounts) do for side, data in pairs(_ppm_sys.mounts) do
if data.type == name then if data.type == name then
device = data.dev device = data.dev
break break
@ -212,15 +290,17 @@ end
-- SPECIFIC DEVICE ACCESSORS -- -- SPECIFIC DEVICE ACCESSORS --
-- get the fission reactor (if multiple, returns the first) -- get the fission reactor (if multiple, returns the first)
function get_fission_reactor() ---@return table|nil reactor function table
return get_device("fissionReactor") ppm.get_fission_reactor = function ()
return ppm.get_device("fissionReactor")
end end
-- get the wireless modem (if multiple, returns the first) -- get the wireless modem (if multiple, returns the first)
function get_wireless_modem() ---@return table|nil modem function table
ppm.get_wireless_modem = function ()
local w_modem = nil local w_modem = nil
for side, device in pairs(self.mounts) do for _, device in pairs(_ppm_sys.mounts) do
if device.type == "modem" and device.dev.isWireless() then if device.type == "modem" and device.dev.isWireless() then
w_modem = device.dev w_modem = device.dev
break break
@ -231,6 +311,9 @@ function get_wireless_modem()
end end
-- list all connected monitors -- list all connected monitors
function list_monitors() ---@return table monitors
return get_all_devices("monitor") ppm.list_monitors = function ()
return ppm.get_all_devices("monitor")
end end
return ppm

View File

@ -1,67 +1,92 @@
IO_LVL = { --
-- Redstone I/O
--
local rsio = {}
----------------------
-- RS I/O CONSTANTS --
----------------------
---@alias IO_LVL integer
local IO_LVL = {
LOW = 0, LOW = 0,
HIGH = 1 HIGH = 1,
DISCONNECT = -1 -- use for RTU session to indicate this RTU is not connected to this channel
} }
IO_DIR = { ---@alias IO_DIR integer
local IO_DIR = {
IN = 0, IN = 0,
OUT = 1 OUT = 1
} }
IO_MODE = { ---@alias IO_MODE integer
DIGITAL_OUT = 0, local IO_MODE = {
DIGITAL_IN = 1, DIGITAL_IN = 0,
ANALOG_OUT = 2, DIGITAL_OUT = 1,
ANALOG_IN = 3 ANALOG_IN = 2,
ANALOG_OUT = 3
} }
RS_IO = { ---@alias RS_IO integer
local RS_IO = {
-- digital inputs -- -- digital inputs --
-- facility -- facility
F_SCRAM = 1, -- active low, facility-wide scram F_SCRAM = 1, -- active low, facility-wide scram
F_AE2_LIVE = 2, -- active high, indicates whether AE2 network is online (hint: use redstone P2P)
-- reactor -- reactor
R_SCRAM = 3, -- active low, reactor scram R_SCRAM = 2, -- active low, reactor scram
R_ENABLE = 4, -- active high, reactor enable R_ENABLE = 3, -- active high, reactor enable
-- digital outputs -- -- digital outputs --
-- facility
F_ALARM = 4, -- active high, facility safety alarm
-- waste -- waste
WASTE_PO = 5, -- active low, polonium routing WASTE_PO = 5, -- active low, polonium routing
WASTE_PU = 6, -- active low, plutonium routing WASTE_PU = 6, -- active low, plutonium routing
WASTE_AM = 7, -- active low, antimatter routing WASTE_AM = 7, -- active low, antimatter routing
-- reactor -- reactor
R_SCRAMMED = 8, -- active high, if the reactor is scrammed R_ALARM = 8, -- active high, reactor safety alarm
R_AUTO_SCRAM = 9, -- active high, if the reactor was automatically scrammed R_SCRAMMED = 9, -- active high, if the reactor is scrammed
R_ACTIVE = 10, -- active high, if the reactor is active R_AUTO_SCRAM = 10, -- active high, if the reactor was automatically scrammed
R_AUTO_CTRL = 11, -- active high, if the reactor burn rate is automatic R_ACTIVE = 11, -- active high, if the reactor is active
R_DMG_CRIT = 12, -- active high, if the reactor damage is critical R_AUTO_CTRL = 12, -- active high, if the reactor burn rate is automatic
R_HIGH_TEMP = 13, -- active high, if the reactor is at a high temperature R_DMG_CRIT = 13, -- active high, if the reactor damage is critical
R_NO_COOLANT = 14, -- active high, if the reactor has no coolant R_HIGH_TEMP = 14, -- active high, if the reactor is at a high temperature
R_EXCESS_HC = 15, -- active high, if the reactor has excess heated coolant R_NO_COOLANT = 15, -- active high, if the reactor has no coolant
R_EXCESS_WS = 16, -- active high, if the reactor has excess waste R_EXCESS_HC = 16, -- active high, if the reactor has excess heated coolant
R_INSUFF_FUEL = 17, -- active high, if the reactor has insufficent fuel R_EXCESS_WS = 17, -- active high, if the reactor has excess waste
R_PLC_TIMEOUT = 18, -- active high, if the reactor PLC has not been heard from R_INSUFF_FUEL = 18, -- active high, if the reactor has insufficent fuel
R_PLC_FAULT = 19, -- active high, if the reactor PLC reports a device access fault
-- analog outputs -- R_PLC_TIMEOUT = 20 -- active high, if the reactor PLC has not been heard from
A_R_BURN_RATE = 19, -- reactor burn rate percentage
A_B_BOIL_RATE = 20, -- boiler boil rate percentage
A_T_FLOW_RATE = 21 -- turbine flow rate percentage
} }
function to_string(channel) rsio.IO_LVL = IO_LVL
rsio.IO_DIR = IO_DIR
rsio.IO_MODE = IO_MODE
rsio.IO = RS_IO
-----------------------
-- UTILITY FUNCTIONS --
-----------------------
-- channel to string
---@param channel RS_IO
rsio.to_string = function (channel)
local names = { local names = {
"F_SCRAM", "F_SCRAM",
"F_AE2_LIVE",
"R_SCRAM", "R_SCRAM",
"R_ENABLE", "R_ENABLE",
"F_ALARM",
"WASTE_PO", "WASTE_PO",
"WASTE_PU", "WASTE_PU",
"WASTE_AM", "WASTE_AM",
"R_ALARM",
"R_SCRAMMED", "R_SCRAMMED",
"R_AUTO_SCRAM", "R_AUTO_SCRAM",
"R_ACTIVE", "R_ACTIVE",
@ -72,92 +97,79 @@ function to_string(channel)
"R_EXCESS_HC", "R_EXCESS_HC",
"R_EXCESS_WS", "R_EXCESS_WS",
"R_INSUFF_FUEL", "R_INSUFF_FUEL",
"R_PLC_TIMEOUT", "R_PLC_FAULT",
"A_R_BURN_RATE", "R_PLC_TIMEOUT"
"A_B_BOIL_RATE",
"A_T_FLOW_RATE"
} }
if channel > 0 and channel <= #names then if type(channel) == "number" and channel > 0 and channel <= #names then
return names[channel] return names[channel]
else else
return "" return ""
end end
end end
function is_valid_channel(channel) local _B_AND = bit.band
return channel ~= nil and channel > 0 and channel <= RS_IO.A_T_FLOW_RATE
end
function is_valid_side(side) local function _ACTIVE_HIGH(level) return level == IO_LVL.HIGH end
if side ~= nil then local function _ACTIVE_LOW(level) return level == IO_LVL.LOW end
for _, s in pairs(rs.getSides()) do
if s == side then return true end
end
end
return false
end
function is_color(color)
return (color > 0) and (bit.band(color, (color - 1)) == 0);
end
local _TRINARY = function (cond, t, f) if cond then return t else return f end end
local _DI_ACTIVE_HIGH = function (level) return level == IO_LVL.HIGH end
local _DI_ACTIVE_LOW = function (level) return level == IO_LVL.LOW end
local _DO_ACTIVE_HIGH = function (on) return _TRINARY(on, IO_LVL.HIGH, IO_LVL.LOW) end
local _DO_ACTIVE_LOW = function (on) return _TRINARY(on, IO_LVL.LOW, IO_LVL.HIGH) end
-- I/O mappings to I/O function and I/O mode -- I/O mappings to I/O function and I/O mode
local RS_DIO_MAP = { local RS_DIO_MAP = {
-- F_SCRAM -- F_SCRAM
{ _f = _DI_ACTIVE_LOW, mode = IO_DIR.IN }, { _f = _ACTIVE_LOW, mode = IO_DIR.IN },
-- F_AE2_LIVE
{ _f = _DI_ACTIVE_HIGH, mode = IO_DIR.IN },
-- R_SCRAM -- R_SCRAM
{ _f = _DI_ACTIVE_LOW, mode = IO_DIR.IN }, { _f = _ACTIVE_LOW, mode = IO_DIR.IN },
-- R_ENABLE -- R_ENABLE
{ _f = _DI_ACTIVE_HIGH, mode = IO_DIR.IN }, { _f = _ACTIVE_HIGH, mode = IO_DIR.IN },
-- F_ALARM
{ _f = _ACTIVE_HIGH, mode = IO_DIR.OUT },
-- WASTE_PO -- WASTE_PO
{ _f = _DO_ACTIVE_LOW, mode = IO_DIR.OUT }, { _f = _ACTIVE_LOW, mode = IO_DIR.OUT },
-- WASTE_PU -- WASTE_PU
{ _f = _DO_ACTIVE_LOW, mode = IO_DIR.OUT }, { _f = _ACTIVE_LOW, mode = IO_DIR.OUT },
-- WASTE_AM -- WASTE_AM
{ _f = _DO_ACTIVE_LOW, mode = IO_DIR.OUT }, { _f = _ACTIVE_LOW, mode = IO_DIR.OUT },
-- R_ALARM
{ _f = _ACTIVE_HIGH, mode = IO_DIR.OUT },
-- R_SCRAMMED -- R_SCRAMMED
{ _f = _DO_ACTIVE_HIGH, mode = IO_DIR.OUT }, { _f = _ACTIVE_HIGH, mode = IO_DIR.OUT },
-- R_AUTO_SCRAM -- R_AUTO_SCRAM
{ _f = _DO_ACTIVE_HIGH, mode = IO_DIR.OUT }, { _f = _ACTIVE_HIGH, mode = IO_DIR.OUT },
-- R_ACTIVE -- R_ACTIVE
{ _f = _DO_ACTIVE_HIGH, mode = IO_DIR.OUT }, { _f = _ACTIVE_HIGH, mode = IO_DIR.OUT },
-- R_AUTO_CTRL -- R_AUTO_CTRL
{ _f = _DO_ACTIVE_HIGH, mode = IO_DIR.OUT }, { _f = _ACTIVE_HIGH, mode = IO_DIR.OUT },
-- R_DMG_CRIT -- R_DMG_CRIT
{ _f = _DO_ACTIVE_HIGH, mode = IO_DIR.OUT }, { _f = _ACTIVE_HIGH, mode = IO_DIR.OUT },
-- R_HIGH_TEMP -- R_HIGH_TEMP
{ _f = _DO_ACTIVE_HIGH, mode = IO_DIR.OUT }, { _f = _ACTIVE_HIGH, mode = IO_DIR.OUT },
-- R_NO_COOLANT -- R_NO_COOLANT
{ _f = _DO_ACTIVE_HIGH, mode = IO_DIR.OUT }, { _f = _ACTIVE_HIGH, mode = IO_DIR.OUT },
-- R_EXCESS_HC -- R_EXCESS_HC
{ _f = _DO_ACTIVE_HIGH, mode = IO_DIR.OUT }, { _f = _ACTIVE_HIGH, mode = IO_DIR.OUT },
-- R_EXCESS_WS -- R_EXCESS_WS
{ _f = _DO_ACTIVE_HIGH, mode = IO_DIR.OUT }, { _f = _ACTIVE_HIGH, mode = IO_DIR.OUT },
-- R_INSUFF_FUEL -- R_INSUFF_FUEL
{ _f = _DO_ACTIVE_HIGH, mode = IO_DIR.OUT }, { _f = _ACTIVE_HIGH, mode = IO_DIR.OUT },
-- R_PLC_FAULT
{ _f = _ACTIVE_HIGH, mode = IO_DIR.OUT },
-- R_PLC_TIMEOUT -- R_PLC_TIMEOUT
{ _f = _DO_ACTIVE_HIGH, mode = IO_DIR.OUT } { _f = _ACTIVE_HIGH, mode = IO_DIR.OUT }
} }
function get_io_mode(channel) -- get the mode of a channel
---@param channel RS_IO
---@return IO_MODE
rsio.get_io_mode = function (channel)
local modes = { local modes = {
IO_MODE.DIGITAL_IN, -- F_SCRAM IO_MODE.DIGITAL_IN, -- F_SCRAM
IO_MODE.DIGITAL_IN, -- F_AE2_LIVE
IO_MODE.DIGITAL_IN, -- R_SCRAM IO_MODE.DIGITAL_IN, -- R_SCRAM
IO_MODE.DIGITAL_IN, -- R_ENABLE IO_MODE.DIGITAL_IN, -- R_ENABLE
IO_MODE.DIGITAL_OUT, -- F_ALARM
IO_MODE.DIGITAL_OUT, -- WASTE_PO IO_MODE.DIGITAL_OUT, -- WASTE_PO
IO_MODE.DIGITAL_OUT, -- WASTE_PU IO_MODE.DIGITAL_OUT, -- WASTE_PU
IO_MODE.DIGITAL_OUT, -- WASTE_AM IO_MODE.DIGITAL_OUT, -- WASTE_AM
IO_MODE.DIGITAL_OUT, -- R_ALARM
IO_MODE.DIGITAL_OUT, -- R_SCRAMMED IO_MODE.DIGITAL_OUT, -- R_SCRAMMED
IO_MODE.DIGITAL_OUT, -- R_AUTO_SCRAM IO_MODE.DIGITAL_OUT, -- R_AUTO_SCRAM
IO_MODE.DIGITAL_OUT, -- R_ACTIVE IO_MODE.DIGITAL_OUT, -- R_ACTIVE
@ -168,21 +180,57 @@ function get_io_mode(channel)
IO_MODE.DIGITAL_OUT, -- R_EXCESS_HC IO_MODE.DIGITAL_OUT, -- R_EXCESS_HC
IO_MODE.DIGITAL_OUT, -- R_EXCESS_WS IO_MODE.DIGITAL_OUT, -- R_EXCESS_WS
IO_MODE.DIGITAL_OUT, -- R_INSUFF_FUEL IO_MODE.DIGITAL_OUT, -- R_INSUFF_FUEL
IO_MODE.DIGITAL_OUT, -- R_PLC_TIMEOUT IO_MODE.DIGITAL_OUT, -- R_PLC_FAULT
IO_MODE.ANALOG_OUT, -- A_R_BURN_RATE IO_MODE.DIGITAL_OUT -- R_PLC_TIMEOUT
IO_MODE.ANALOG_OUT, -- A_B_BOIL_RATE
IO_MODE.ANALOG_OUT -- A_T_FLOW_RATE
} }
if channel > 0 and channel <= #modes then if type(channel) == "number" and channel > 0 and channel <= #modes then
return modes[channel] return modes[channel]
else else
return IO_MODE.ANALOG_IN return IO_MODE.ANALOG_IN
end end
end end
--------------------
-- GENERIC CHECKS --
--------------------
local RS_SIDES = rs.getSides()
-- check if a channel is valid
---@param channel RS_IO
---@return boolean valid
rsio.is_valid_channel = function (channel)
return (type(channel) == "number") and (channel > 0) and (channel <= RS_IO.R_PLC_TIMEOUT)
end
-- check if a side is valid
---@param side string
---@return boolean valid
rsio.is_valid_side = function (side)
if side ~= nil then
for i = 0, #RS_SIDES do
if RS_SIDES[i] == side then return true end
end
end
return false
end
-- check if a color is a valid single color
---@param color integer
---@return boolean valid
rsio.is_color = function (color)
return (type(color) == "number") and (color > 0) and (_B_AND(color, (color - 1)) == 0);
end
-----------------
-- DIGITAL I/O --
-----------------
-- get digital IO level reading -- get digital IO level reading
function digital_read(rs_value) ---@param rs_value boolean
---@return IO_LVL
rsio.digital_read = function (rs_value)
if rs_value then if rs_value then
return IO_LVL.HIGH return IO_LVL.HIGH
else else
@ -191,19 +239,51 @@ function digital_read(rs_value)
end end
-- returns the level corresponding to active -- returns the level corresponding to active
function digital_write(channel, active) ---@param channel RS_IO
if channel < RS_IO.WASTE_PO or channel > RS_IO.R_PLC_TIMEOUT then ---@param level IO_LVL
return IO_LVL.LOW ---@return boolean
rsio.digital_write = function (channel, level)
if type(channel) ~= "number" or channel < RS_IO.F_ALARM or channel > RS_IO.R_PLC_TIMEOUT then
return false
else else
return RS_DIO_MAP[channel]._f(level) return RS_DIO_MAP[channel]._f(level)
end end
end end
-- returns true if the level corresponds to active -- returns true if the level corresponds to active
function digital_is_active(channel, level) ---@param channel RS_IO
if channel > RS_IO.R_ENABLE or channel > RS_IO.R_PLC_TIMEOUT then ---@param level IO_LVL
---@return boolean
rsio.digital_is_active = function (channel, level)
if type(channel) ~= "number" or channel > RS_IO.R_ENABLE then
return false return false
else else
return RS_DIO_MAP[channel]._f(level) return RS_DIO_MAP[channel]._f(level)
end end
end end
----------------
-- ANALOG I/O --
----------------
-- read an analog value scaled from min to max
---@param rs_value number redstone reading (0 to 15)
---@param min number minimum of range
---@param max number maximum of range
---@return number value scaled reading (min to max)
rsio.analog_read = function (rs_value, min, max)
local value = rs_value / 15
return (value * (max - min)) + min
end
-- write an analog value from the provided scale range
---@param value number value to write (from min to max range)
---@param min number minimum of range
---@param max number maximum of range
---@return number rs_value scaled redstone reading (0 to 15)
rsio.analog_write = function (value, min, max)
local scaled_value = (value - min) / (max - min)
return scaled_value * 15
end
return rsio

100
scada-common/types.lua Normal file
View File

@ -0,0 +1,100 @@
--
-- Global Types
--
---@class types
local types = {}
-- CLASSES --
---@class tank_fluid
---@field name string
---@field amount integer
---@class coordinate
---@field x integer
---@field y integer
---@field z integer
---@class rtu_advertisement
---@field type integer
---@field index integer
---@field reactor integer
---@field rsio table|nil
-- ENUMERATION TYPES --
---@alias TRI_FAIL integer
types.TRI_FAIL = {
OK = 0,
PARTIAL = 1,
FULL = 2
}
-- STRING TYPES --
---@alias rtu_t string
types.rtu_t = {
redstone = "redstone",
boiler = "boiler",
boiler_valve = "boiler_valve",
turbine = "turbine",
turbine_valve = "turbine_valve",
energy_machine = "emachine",
induction_matrix = "induction_matrix"
}
---@alias rps_status_t string
types.rps_status_t = {
ok = "ok",
dmg_crit = "dmg_crit",
high_temp = "high_temp",
no_coolant = "no_coolant",
ex_waste = "full_waste",
ex_hcoolant = "heated_coolant_backup",
no_fuel = "no_fuel",
fault = "fault",
timeout = "timeout",
manual = "manual"
}
-- turbine steam dumping modes
---@alias DUMPING_MODE string
types.DUMPING_MODE = {
IDLE = "IDLE",
DUMPING = "DUMPING",
DUMPING_EXCESS = "DUMPING_EXCESS"
}
-- MODBUS
-- modbus function codes
---@alias MODBUS_FCODE integer
types.MODBUS_FCODE = {
READ_COILS = 0x01,
READ_DISCRETE_INPUTS = 0x02,
READ_MUL_HOLD_REGS = 0x03,
READ_INPUT_REGS = 0x04,
WRITE_SINGLE_COIL = 0x05,
WRITE_SINGLE_HOLD_REG = 0x06,
WRITE_MUL_COILS = 0x0F,
WRITE_MUL_HOLD_REGS = 0x10,
ERROR_FLAG = 0x80
}
-- modbus exception codes
---@alias MODBUS_EXCODE integer
types.MODBUS_EXCODE = {
ILLEGAL_FUNCTION = 0x01,
ILLEGAL_DATA_ADDR = 0x02,
ILLEGAL_DATA_VALUE = 0x03,
SERVER_DEVICE_FAIL = 0x04,
ACKNOWLEDGE = 0x05,
SERVER_DEVICE_BUSY = 0x06,
NEG_ACKNOWLEDGE = 0x07,
MEMORY_PARITY_ERROR = 0x08,
GATEWAY_PATH_UNAVAILABLE = 0x0A,
GATEWAY_TARGET_TIMEOUT = 0x0B
}
return types

View File

@ -1,48 +1,262 @@
-- we are overwriting 'print' so save it first --
local _print = print -- Utility Functions
--
---@class util
local util = {}
-- PRINT --
-- print -- print
function print(message) ---@param message any
term.write(message) util.print = function (message)
term.write(tostring(message))
end end
-- print line -- print line
function println(message) ---@param message any
_print(message) util.println = function (message)
print(tostring(message))
end end
-- timestamped print -- timestamped print
function print_ts(message) ---@param message any
term.write(os.date("[%H:%M:%S] ") .. message) util.print_ts = function (message)
term.write(os.date("[%H:%M:%S] ") .. tostring(message))
end end
-- timestamped print line -- timestamped print line
function println_ts(message) ---@param message any
_print(os.date("[%H:%M:%S] ") .. message) util.println_ts = function (message)
print(os.date("[%H:%M:%S] ") .. tostring(message))
end end
-- STRING TOOLS --
-- get a value as a string
---@param val any
---@return string
util.strval = function (val)
local t = type(val)
if t == "table" or t == "function" then
return "[" .. tostring(val) .. "]"
else
return tostring(val)
end
end
-- concatenation with built-in to string
---@vararg any
---@return string
util.concat = function (...)
local str = ""
for _, v in ipairs(arg) do
str = str .. util.strval(v)
end
return str
end
-- sprintf implementation
---@param format string
---@vararg any
util.sprintf = function (format, ...)
return string.format(format, table.unpack(arg))
end
-- TIME --
-- current time
---@return integer milliseconds
util.time_ms = function ()
---@diagnostic disable-next-line: undefined-field
return os.epoch('local')
end
-- current time
---@return number seconds
util.time_s = function ()
---@diagnostic disable-next-line: undefined-field
return os.epoch('local') / 1000.0
end
-- current time
---@return integer milliseconds
util.time = function ()
return util.time_ms()
end
-- PARALLELIZATION --
-- protected sleep call so we still are in charge of catching termination
---@param t integer seconds
--- EVENT_CONSUMER: this function consumes events
util.psleep = function (t)
---@diagnostic disable-next-line: undefined-field
pcall(os.sleep, t)
end
-- no-op to provide a brief pause (1 tick) to yield
---
--- EVENT_CONSUMER: this function consumes events
util.nop = function ()
util.psleep(0.05)
end
-- attempt to maintain a minimum loop timing (duration of execution)
---@param target_timing integer minimum amount of milliseconds to wait for
---@param last_update integer millisecond time of last update
---@return integer time_now
-- EVENT_CONSUMER: this function consumes events
util.adaptive_delay = function (target_timing, last_update)
local sleep_for = target_timing - (util.time() - last_update)
-- only if >50ms since worker loops already yield 0.05s
if sleep_for >= 50 then
util.psleep(sleep_for / 1000.0)
end
return util.time()
end
-- TABLE UTILITIES --
-- delete elements from a table if the passed function returns false when passed a table element
--
-- put briefly: deletes elements that return false, keeps elements that return true
---@param t table table to remove elements from
---@param f function should return false to delete an element when passed the element: f(elem) = true|false
---@param on_delete? function optional function to execute on deletion, passed the table element to be deleted as the parameter
util.filter_table = function (t, f, on_delete)
local move_to = 1
for i = 1, #t do
local element = t[i]
if element ~= nil then
if f(element) then
if t[move_to] == nil then
t[move_to] = element
t[i] = nil
end
move_to = move_to + 1
else
if on_delete then on_delete(element) end
t[i] = nil
end
end
end
end
-- check if a table contains the provided element
---@param t table table to check
---@param element any element to check for
util.table_contains = function (t, element)
for i = 1, #t do
if t[i] == element then return true end
end
return false
end
-- MEKANISM POWER --
-- function util.kFE(fe) return fe / 1000.0 end
-- function util.MFE(fe) return fe / 1000000.0 end
-- function util.GFE(fe) return fe / 1000000000.0 end
-- function util.TFE(fe) return fe / 1000000000000.0 end
-- -- FLOATING POINT PRINTS --
-- local function fractional_1s(number)
-- return number == math.round(number)
-- end
-- local function fractional_10ths(number)
-- number = number * 10
-- return number == math.round(number)
-- end
-- local function fractional_100ths(number)
-- number = number * 100
-- return number == math.round(number)
-- end
-- function util.power_format(fe)
-- if fe < 1000 then
-- return string.format("%.2f FE", fe)
-- elseif fe < 1000000 then
-- return string.format("%.3f kFE", kFE(fe))
-- end
-- end
-- WATCHDOG --
-- ComputerCraft OS Timer based Watchdog -- ComputerCraft OS Timer based Watchdog
-- triggers a timer event if not fed within 'timeout' seconds ---@param timeout number timeout duration
function new_watchdog(timeout) ---
--- triggers a timer event if not fed within 'timeout' seconds
util.new_watchdog = function (timeout)
---@diagnostic disable-next-line: undefined-field
local start_timer = os.startTimer
---@diagnostic disable-next-line: undefined-field
local cancel_timer = os.cancelTimer
local self = { local self = {
_timeout = timeout, timeout = timeout,
_wd_timer = os.startTimer(timeout) wd_timer = start_timer(timeout)
} }
local get_timer = function () ---@class watchdog
return self._wd_timer local public = {}
---@param timer number timer event timer ID
public.is_timer = function (timer)
return self.wd_timer == timer
end end
local feed = function () -- satiate the beast
if self._wd_timer ~= nil then public.feed = function ()
os.cancelTimer(self._wd_timer) if self.wd_timer ~= nil then
cancel_timer(self.wd_timer)
end end
self._wd_timer = os.startTimer(self._timeout) self.wd_timer = start_timer(self.timeout)
end end
return { -- cancel the watchdog
get_timer = get_timer, public.cancel = function ()
feed = feed if self.wd_timer ~= nil then
} cancel_timer(self.wd_timer)
end
end
return public
end end
-- LOOP CLOCK --
-- ComputerCraft OS Timer based Loop Clock
---@param period number clock period
---
--- fires a timer event at the specified period, does not start at construct time
util.new_clock = function (period)
---@diagnostic disable-next-line: undefined-field
local start_timer = os.startTimer
local self = {
period = period,
timer = nil
}
---@class clock
local public = {}
---@param timer number timer event timer ID
public.is_clock = function (timer)
return self.timer == timer
end
-- start the clock
public.start = function ()
self.timer = start_timer(self.period)
end
return public
end
return util

50
startup.lua Normal file
View File

@ -0,0 +1,50 @@
local util = require("scada-common.util")
local BOOTLOADER_VERSION = "0.2"
local println = util.println
local println_ts = util.println_ts
println("SCADA BOOTLOADER V" .. BOOTLOADER_VERSION)
local exit_code = false
println_ts("BOOT> SCANNING FOR APPLICATIONS...")
if fs.exists("reactor-plc/startup.lua") then
-- found reactor-plc application
println("BOOT> FOUND REACTOR PLC APPLICATION")
println("BOOT> EXEC STARTUP")
exit_code = shell.execute("reactor-plc/startup")
elseif fs.exists("rtu/startup.lua") then
-- found rtu application
println("BOOT> FOUND RTU APPLICATION")
println("BOOT> EXEC STARTUP")
exit_code = shell.execute("rtu/startup")
elseif fs.exists("supervisor/startup.lua") then
-- found supervisor application
println("BOOT> FOUND SUPERVISOR APPLICATION")
println("BOOT> EXEC STARTUP")
exit_code = shell.execute("supervisor/startup")
elseif fs.exists("coordinator/startup.lua") then
-- found coordinator application
println("BOOT> FOUND COORDINATOR APPLICATION")
println("BOOT> EXEC STARTUP")
exit_code = shell.execute("coordinator/startup")
elseif fs.exists("pocket/startup.lua") then
-- found pocket application
println("BOOT> FOUND POCKET APPLICATION")
println("BOOT> EXEC STARTUP")
exit_code = shell.execute("pocket/startup")
else
-- no known applications found
println("BOOT> NO SCADA STARTUP APPLICATION FOUND")
println("BOOT> EXIT")
return false
end
if not exit_code then
println_ts("BOOT> APPLICATION CRASHED")
end
return exit_code

View File

@ -1,16 +1,23 @@
-- type ('active','backup') local config = {}
-- 'active' system carries through instructions and control
-- 'backup' system serves as a hot backup, still recieving data
-- from all PLCs and coordinator(s) while in backup to allow
-- instant failover if active goes offline without re-sync
SYSTEM_TYPE = 'active'
-- scada network listen for PLC's and RTU's -- scada network listen for PLC's and RTU's
SCADA_DEV_LISTEN = 16000 config.SCADA_DEV_LISTEN = 16000
-- failover synchronization
SCADA_FO_CHANNEL = 16001
-- listen port for SCADA supervisor access by coordinators -- listen port for SCADA supervisor access by coordinators
SCADA_SV_CHANNEL = 16002 config.SCADA_SV_LISTEN = 16100
-- expected number of reactors -- expected number of reactors
NUM_REACTORS = 4 config.NUM_REACTORS = 4
-- expected number of boilers/turbines for each reactor
config.REACTOR_COOLING = {
{ BOILERS = 1, TURBINES = 1 }, -- reactor unit 1
{ BOILERS = 1, TURBINES = 1 }, -- reactor unit 2
{ BOILERS = 1, TURBINES = 1 }, -- reactor unit 3
{ BOILERS = 1, TURBINES = 1 } -- reactor unit 4
}
-- log path
config.LOG_PATH = "/log.txt"
-- log mode
-- 0 = APPEND (adds to existing file on start)
-- 1 = NEW (replaces existing file on start)
config.LOG_MODE = 0
return config

View File

@ -0,0 +1,3 @@
local coordinator = {}
return coordinator

621
supervisor/session/plc.lua Normal file
View File

@ -0,0 +1,621 @@
local comms = require("scada-common.comms")
local log = require("scada-common.log")
local mqueue = require("scada-common.mqueue")
local util = require("scada-common.util")
local plc = {}
local PROTOCOLS = comms.PROTOCOLS
local RPLC_TYPES = comms.RPLC_TYPES
local SCADA_MGMT_TYPES = comms.SCADA_MGMT_TYPES
local print = util.print
local println = util.println
local print_ts = util.print_ts
local println_ts = util.println_ts
-- retry time constants in ms
local INITIAL_WAIT = 1500
local RETRY_PERIOD = 1000
local PLC_S_CMDS = {
SCRAM = 0,
ENABLE = 1,
RPS_RESET = 2
}
local PLC_S_DATA = {
BURN_RATE = 1,
RAMP_BURN_RATE = 2
}
plc.PLC_S_CMDS = PLC_S_CMDS
plc.PLC_S_DATA = PLC_S_DATA
local PERIODICS = {
KEEP_ALIVE = 2.0
}
-- PLC supervisor session
---@param id integer
---@param for_reactor integer
---@param in_queue mqueue
---@param out_queue mqueue
plc.new_session = function (id, for_reactor, in_queue, out_queue)
local log_header = "plc_session(" .. id .. "): "
local self = {
id = id,
for_reactor = for_reactor,
in_q = in_queue,
out_q = out_queue,
commanded_state = false,
commanded_burn_rate = 0.0,
ramping_rate = false,
-- connection properties
seq_num = 0,
r_seq_num = nil,
connected = true,
received_struct = false,
received_status_cache = false,
plc_conn_watchdog = util.new_watchdog(3),
last_rtt = 0,
-- periodic messages
periodics = {
last_update = 0,
keep_alive = 0
},
-- when to next retry one of these requests
retry_times = {
struct_req = (util.time() + 500),
status_req = (util.time() + 500),
scram_req = 0,
enable_req = 0,
burn_rate_req = 0,
rps_reset_req = 0
},
-- command acknowledgements
acks = {
scram = true,
enable = true,
burn_rate = true,
rps_reset = true
},
-- session database
---@class reactor_db
sDB = {
last_status_update = 0,
control_state = false,
overridden = false,
degraded = false,
rps_tripped = false,
rps_trip_cause = "ok",
---@class rps_status
rps_status = {
dmg_crit = false,
ex_hcool = false,
ex_waste = false,
high_temp = false,
no_fuel = false,
no_cool = false,
timed_out = false
},
---@class mek_status
mek_status = {
heating_rate = 0.0,
status = false,
burn_rate = 0.0,
act_burn_rate = 0.0,
temp = 0.0,
damage = 0.0,
boil_eff = 0.0,
env_loss = 0.0,
fuel = 0,
fuel_need = 0,
fuel_fill = 0.0,
waste = 0,
waste_need = 0,
waste_fill = 0.0,
ccool_type = "?",
ccool_amnt = 0,
ccool_need = 0,
ccool_fill = 0.0,
hcool_type = "?",
hcool_amnt = 0,
hcool_need = 0,
hcool_fill = 0.0
},
---@class mek_struct
mek_struct = {
heat_cap = 0,
fuel_asm = 0,
fuel_sa = 0,
fuel_cap = 0,
waste_cap = 0,
ccool_cap = 0,
hcool_cap = 0,
max_burn = 0.0
}
}
}
---@class plc_session
local public = {}
-- copy in the RPS status
---@param rps_status table
local _copy_rps_status = function (rps_status)
self.sDB.rps_status.dmg_crit = rps_status[1]
self.sDB.rps_status.ex_hcool = rps_status[2]
self.sDB.rps_status.ex_waste = rps_status[3]
self.sDB.rps_status.high_temp = rps_status[4]
self.sDB.rps_status.no_fuel = rps_status[5]
self.sDB.rps_status.no_cool = rps_status[6]
self.sDB.rps_status.timed_out = rps_status[7]
end
-- copy in the reactor status
---@param mek_data table
local _copy_status = function (mek_data)
-- copy status information
self.sDB.mek_status.status = mek_data[1]
self.sDB.mek_status.burn_rate = mek_data[2]
self.sDB.mek_status.act_burn_rate = mek_data[3]
self.sDB.mek_status.temp = mek_data[4]
self.sDB.mek_status.damage = mek_data[5]
self.sDB.mek_status.boil_eff = mek_data[6]
self.sDB.mek_status.env_loss = mek_data[7]
-- copy container information
self.sDB.mek_status.fuel = mek_data[8]
self.sDB.mek_status.fuel_fill = mek_data[9]
self.sDB.mek_status.waste = mek_data[10]
self.sDB.mek_status.waste_fill = mek_data[11]
self.sDB.mek_status.ccool_type = mek_data[12]
self.sDB.mek_status.ccool_amnt = mek_data[13]
self.sDB.mek_status.ccool_fill = mek_data[14]
self.sDB.mek_status.hcool_type = mek_data[15]
self.sDB.mek_status.hcool_amnt = mek_data[16]
self.sDB.mek_status.hcool_fill = mek_data[17]
-- update computable fields if we have our structure
if self.received_struct then
self.sDB.mek_status.fuel_need = self.sDB.mek_struct.fuel_cap - self.sDB.mek_status.fuel_fill
self.sDB.mek_status.waste_need = self.sDB.mek_struct.waste_cap - self.sDB.mek_status.waste_fill
self.sDB.mek_status.cool_need = self.sDB.mek_struct.ccool_cap - self.sDB.mek_status.ccool_fill
self.sDB.mek_status.hcool_need = self.sDB.mek_struct.hcool_cap - self.sDB.mek_status.hcool_fill
end
end
-- copy in the reactor structure
---@param mek_data table
local _copy_struct = function (mek_data)
self.sDB.mek_struct.heat_cap = mek_data[1]
self.sDB.mek_struct.fuel_asm = mek_data[2]
self.sDB.mek_struct.fuel_sa = mek_data[3]
self.sDB.mek_struct.fuel_cap = mek_data[4]
self.sDB.mek_struct.waste_cap = mek_data[5]
self.sDB.mek_struct.ccool_cap = mek_data[6]
self.sDB.mek_struct.hcool_cap = mek_data[7]
self.sDB.mek_struct.max_burn = mek_data[8]
end
-- mark this PLC session as closed, stop watchdog
local _close = function ()
self.plc_conn_watchdog.cancel()
self.connected = false
end
-- send an RPLC packet
---@param msg_type RPLC_TYPES
---@param msg table
local _send = function (msg_type, msg)
local s_pkt = comms.scada_packet()
local r_pkt = comms.rplc_packet()
r_pkt.make(self.id, msg_type, msg)
s_pkt.make(self.seq_num, PROTOCOLS.RPLC, r_pkt.raw_sendable())
self.out_q.push_packet(s_pkt)
self.seq_num = self.seq_num + 1
end
-- send a SCADA management packet
---@param msg_type SCADA_MGMT_TYPES
---@param msg table
local _send_mgmt = function (msg_type, msg)
local s_pkt = comms.scada_packet()
local m_pkt = comms.mgmt_packet()
m_pkt.make(msg_type, msg)
s_pkt.make(self.seq_num, PROTOCOLS.SCADA_MGMT, m_pkt.raw_sendable())
self.out_q.push_packet(s_pkt)
self.seq_num = self.seq_num + 1
end
-- get an ACK status
---@param pkt rplc_frame
---@return boolean|nil ack
local _get_ack = function (pkt)
if pkt.length == 1 then
return pkt.data[1]
else
log.warning(log_header .. "RPLC ACK length mismatch")
return nil
end
end
-- handle a packet
---@param pkt rplc_frame
local _handle_packet = function (pkt)
-- check sequence number
if self.r_seq_num == nil then
self.r_seq_num = pkt.scada_frame.seq_num()
elseif self.r_seq_num >= pkt.scada_frame.seq_num() then
log.warning(log_header .. "sequence out-of-order: last = " .. self.r_seq_num .. ", new = " .. pkt.scada_frame.seq_num())
return
else
self.r_seq_num = pkt.scada_frame.seq_num()
end
-- process packet
if pkt.scada_frame.protocol() == PROTOCOLS.RPLC then
-- check reactor ID
if pkt.id ~= for_reactor then
log.warning(log_header .. "RPLC packet with ID not matching reactor ID: reactor " .. self.for_reactor .. " != " .. pkt.id)
return
end
-- feed watchdog
self.plc_conn_watchdog.feed()
-- handle packet by type
if pkt.type == RPLC_TYPES.STATUS then
-- status packet received, update data
if pkt.length >= 5 then
self.sDB.last_status_update = pkt.data[1]
self.sDB.control_state = pkt.data[2]
self.sDB.overridden = pkt.data[3]
self.sDB.degraded = pkt.data[4]
self.sDB.mek_status.heating_rate = pkt.data[5]
-- attempt to read mek_data table
if pkt.data[6] ~= nil then
local status = pcall(_copy_status, pkt.data[6])
if status then
-- copied in status data OK
self.received_status_cache = true
else
-- error copying status data
log.error(log_header .. "failed to parse status packet data")
end
end
else
log.debug(log_header .. "RPLC status packet length mismatch")
end
elseif pkt.type == RPLC_TYPES.MEK_STRUCT then
-- received reactor structure, record it
if pkt.length == 8 then
local status = pcall(_copy_struct, pkt.data)
if status then
-- copied in structure data OK
self.received_struct = true
else
-- error copying structure data
log.error(log_header .. "failed to parse struct packet data")
end
else
log.debug(log_header .. "RPLC struct packet length mismatch")
end
elseif pkt.type == RPLC_TYPES.MEK_BURN_RATE then
-- burn rate acknowledgement
local ack = _get_ack(pkt)
if ack then
self.acks.burn_rate = true
elseif ack == false then
log.debug(log_header .. "burn rate update failed!")
end
elseif pkt.type == RPLC_TYPES.RPS_ENABLE then
-- enable acknowledgement
local ack = _get_ack(pkt)
if ack then
self.acks.enable = true
self.sDB.control_state = true
elseif ack == false then
log.debug(log_header .. "enable failed!")
end
elseif pkt.type == RPLC_TYPES.RPS_SCRAM then
-- SCRAM acknowledgement
local ack = _get_ack(pkt)
if ack then
self.acks.scram = true
self.sDB.control_state = false
elseif ack == false then
log.debug(log_header .. "SCRAM failed!")
end
elseif pkt.type == RPLC_TYPES.RPS_STATUS then
-- RPS status packet received, copy data
if pkt.length == 7 then
local status = pcall(_copy_rps_status, pkt.data)
if status then
-- copied in RPS status data OK
else
-- error copying RPS status data
log.error(log_header .. "failed to parse RPS status packet data")
end
else
log.debug(log_header .. "RPLC RPS status packet length mismatch")
end
elseif pkt.type == RPLC_TYPES.RPS_ALARM then
-- RPS alarm
self.sDB.overridden = true
if pkt.length == 8 then
self.sDB.rps_tripped = true
self.sDB.rps_trip_cause = pkt.data[1]
local status = pcall(_copy_rps_status, { table.unpack(pkt.data, 2, #pkt.length) })
if status then
-- copied in RPS status data OK
else
-- error copying RPS status data
log.error(log_header .. "failed to parse RPS alarm status data")
end
else
log.debug(log_header .. "RPLC RPS alarm packet length mismatch")
end
elseif pkt.type == RPLC_TYPES.RPS_RESET then
-- RPS reset acknowledgement
local ack = _get_ack(pkt)
if ack then
self.acks.rps_tripped = true
self.sDB.rps_tripped = false
self.sDB.rps_trip_cause = "ok"
elseif ack == false then
log.debug(log_header .. "RPS reset failed")
end
else
log.debug(log_header .. "handler received unsupported RPLC packet type " .. pkt.type)
end
elseif pkt.scada_frame.protocol() == PROTOCOLS.SCADA_MGMT then
if pkt.type == SCADA_MGMT_TYPES.KEEP_ALIVE then
-- keep alive reply
if pkt.length == 2 then
local srv_start = pkt.data[1]
local plc_send = pkt.data[2]
local srv_now = util.time()
self.last_rtt = srv_now - srv_start
if self.last_rtt > 500 then
log.warning(log_header .. "PLC KEEP_ALIVE round trip time > 500ms (" .. self.last_rtt .. "ms)")
end
-- log.debug(log_header .. "PLC RTT = ".. self.last_rtt .. "ms")
-- log.debug(log_header .. "PLC TT = ".. (srv_now - plc_send) .. "ms")
else
log.debug(log_header .. "SCADA keep alive packet length mismatch")
end
elseif pkt.type == SCADA_MGMT_TYPES.CLOSE then
-- close the session
_close()
else
log.debug(log_header .. "handler received unsupported SCADA_MGMT packet type " .. pkt.type)
end
end
end
-- PUBLIC FUNCTIONS --
-- get the session ID
public.get_id = function () return self.id end
-- get the session database
public.get_db = function () return self.sDB end
-- get the reactor structure
public.get_struct = function ()
if self.received_struct then
return self.sDB.mek_struct
else
return nil
end
end
-- get the reactor status
public.get_status = function ()
if self.received_status_cache then
return self.sDB.mek_status
else
return nil
end
end
-- get the reactor RPS status
public.get_rps = function ()
return self.sDB.rps_status
end
-- get the general status information
public.get_general_status = function ()
return {
last_status_update = self.sDB.last_status_update,
control_state = self.sDB.control_state,
overridden = self.sDB.overridden,
degraded = self.sDB.degraded,
rps_tripped = self.sDB.rps_tripped,
rps_trip_cause = self.sDB.rps_trip_cause
}
end
-- check if a timer matches this session's watchdog
public.check_wd = function (timer)
return self.plc_conn_watchdog.is_timer(timer) and self.connected
end
-- close the connection
public.close = function ()
_close()
_send_mgmt(SCADA_MGMT_TYPES.CLOSE, {})
println("connection to reactor " .. self.for_reactor .. " PLC closed by server")
log.info(log_header .. "session closed by server")
end
-- iterate the session
---@return boolean connected
public.iterate = function ()
if self.connected then
------------------
-- handle queue --
------------------
local handle_start = util.time()
while self.in_q.ready() and self.connected do
-- get a new message to process
local message = self.in_q.pop()
if message ~= nil then
if message.qtype == mqueue.TYPE.PACKET then
-- handle a packet
_handle_packet(message.message)
elseif message.qtype == mqueue.TYPE.COMMAND then
-- handle instruction
local cmd = message.message
if cmd == PLC_S_CMDS.ENABLE then
-- enable reactor
self.acks.enable = false
self.retry_times.enable_req = util.time() + INITIAL_WAIT
_send(RPLC_TYPES.RPS_ENABLE, {})
elseif cmd == PLC_S_CMDS.SCRAM then
-- SCRAM reactor
self.acks.scram = false
self.retry_times.scram_req = util.time() + INITIAL_WAIT
_send(RPLC_TYPES.RPS_SCRAM, {})
elseif cmd == PLC_S_CMDS.RPS_RESET then
-- reset RPS
self.acks.rps_reset = false
self.retry_times.rps_reset_req = util.time() + INITIAL_WAIT
_send(RPLC_TYPES.RPS_RESET, {})
end
elseif message.qtype == mqueue.TYPE.DATA then
-- instruction with body
local cmd = message.message
if cmd.key == PLC_S_DATA.BURN_RATE then
-- update burn rate
self.commanded_burn_rate = cmd.val
self.ramping_rate = false
self.acks.burn_rate = false
self.retry_times.burn_rate_req = util.time() + INITIAL_WAIT
_send(RPLC_TYPES.MEK_BURN_RATE, { self.commanded_burn_rate, self.ramping_rate })
elseif cmd.key == PLC_S_DATA.RAMP_BURN_RATE then
-- ramp to burn rate
self.commanded_burn_rate = cmd.val
self.ramping_rate = true
self.acks.burn_rate = false
self.retry_times.burn_rate_req = util.time() + INITIAL_WAIT
_send(RPLC_TYPES.MEK_BURN_RATE, { self.commanded_burn_rate, self.ramping_rate })
end
end
end
-- max 100ms spent processing queue
if util.time() - handle_start > 100 then
log.warning(log_header .. "exceeded 100ms queue process limit")
break
end
end
-- exit if connection was closed
if not self.connected then
println("connection to reactor " .. self.for_reactor .. " PLC closed by remote host")
log.info(log_header .. "session closed by remote host")
return self.connected
end
----------------------
-- update periodics --
----------------------
local elapsed = util.time() - self.periodics.last_update
local periodics = self.periodics
-- keep alive
periodics.keep_alive = periodics.keep_alive + elapsed
if periodics.keep_alive >= PERIODICS.KEEP_ALIVE then
_send_mgmt(SCADA_MGMT_TYPES.KEEP_ALIVE, { util.time() })
periodics.keep_alive = 0
end
self.periodics.last_update = util.time()
---------------------
-- attempt retries --
---------------------
local rtimes = self.retry_times
-- struct request retry
if not self.received_struct then
if rtimes.struct_req - util.time() <= 0 then
_send(RPLC_TYPES.MEK_STRUCT, {})
rtimes.struct_req = util.time() + RETRY_PERIOD
end
end
-- status cache request retry
if not self.received_status_cache then
if rtimes.status_req - util.time() <= 0 then
_send(RPLC_TYPES.MEK_STATUS, {})
rtimes.status_req = util.time() + RETRY_PERIOD
end
end
-- SCRAM request retry
if not self.acks.scram then
if rtimes.scram_req - util.time() <= 0 then
_send(RPLC_TYPES.RPS_SCRAM, {})
rtimes.scram_req = util.time() + RETRY_PERIOD
end
end
-- enable request retry
if not self.acks.enable then
if rtimes.enable_req - util.time() <= 0 then
_send(RPLC_TYPES.RPS_ENABLE, {})
rtimes.enable_req = util.time() + RETRY_PERIOD
end
end
-- burn rate request retry
if not self.acks.burn_rate then
if rtimes.burn_rate_req - util.time() <= 0 then
_send(RPLC_TYPES.MEK_BURN_RATE, { self.commanded_burn_rate, self.ramping_rate })
rtimes.burn_rate_req = util.time() + RETRY_PERIOD
end
end
-- RPS reset request retry
if not self.acks.rps_reset then
if rtimes.rps_reset_req - util.time() <= 0 then
_send(RPLC_TYPES.RPS_RESET, {})
rtimes.rps_reset_req = util.time() + RETRY_PERIOD
end
end
end
return self.connected
end
return public
end
return plc

325
supervisor/session/rtu.lua Normal file
View File

@ -0,0 +1,325 @@
local comms = require("scada-common.comms")
local log = require("scada-common.log")
local mqueue = require("scada-common.mqueue")
local rsio = require("scada-common.rsio")
local util = require("scada-common.util")
-- supervisor rtu sessions (svrs)
local svrs_boiler = require("supervisor.session.rtu.boiler")
local svrs_emachine = require("supervisor.session.rtu.emachine")
local svrs_redstone = require("supervisor.session.rtu.redstone")
local svrs_turbine = require("supervisor.session.rtu.turbine")
local rtu = {}
local PROTOCOLS = comms.PROTOCOLS
local SCADA_MGMT_TYPES = comms.SCADA_MGMT_TYPES
local RTU_UNIT_TYPES = comms.RTU_UNIT_TYPES
local print = util.print
local println = util.println
local print_ts = util.print_ts
local println_ts = util.println_ts
local RTU_S_CMDS = {
}
local RTU_S_DATA = {
RS_COMMAND = 1,
UNIT_COMMAND = 2
}
rtu.RTU_S_CMDS = RTU_S_CMDS
rtu.RTU_S_DATA = RTU_S_DATA
local PERIODICS = {
KEEP_ALIVE = 2.0
}
---@class rs_session_command
---@field reactor integer
---@field channel RS_IO
---@field value integer|boolean
-- create a new RTU session
---@param id integer
---@param in_queue mqueue
---@param out_queue mqueue
---@param advertisement table
rtu.new_session = function (id, in_queue, out_queue, advertisement)
local log_header = "rtu_session(" .. id .. "): "
local self = {
id = id,
in_q = in_queue,
out_q = out_queue,
advert = advertisement,
-- connection properties
seq_num = 0,
r_seq_num = nil,
connected = true,
rtu_conn_watchdog = util.new_watchdog(3),
last_rtt = 0,
rs_io_q = {},
units = {}
}
---@class rtu_session
local public = {}
-- parse the recorded advertisement and create unit sub-sessions
local _handle_advertisement = function ()
self.units = {}
self.rs_io_q = {}
for i = 1, #self.advert do
local unit = nil ---@type unit_session|nil
local rs_in_q = nil ---@type mqueue|nil
---@type rtu_advertisement
local unit_advert = {
type = self.advert[i][1],
index = self.advert[i][2],
reactor = self.advert[i][3],
rsio = self.advert[i][4]
}
local u_type = unit_advert.type
-- create unit by type
if u_type == RTU_UNIT_TYPES.REDSTONE then
unit, rs_in_q = svrs_redstone.new(self.id, i, unit_advert, self.out_q)
elseif u_type == RTU_UNIT_TYPES.BOILER then
unit = svrs_boiler.new(self.id, i, unit_advert, self.out_q)
elseif u_type == RTU_UNIT_TYPES.BOILER_VALVE then
-- @todo Mekanism 10.1+
elseif u_type == RTU_UNIT_TYPES.TURBINE then
unit = svrs_turbine.new(self.id, i, unit_advert, self.out_q)
elseif u_type == RTU_UNIT_TYPES.TURBINE_VALVE then
-- @todo Mekanism 10.1+
elseif u_type == RTU_UNIT_TYPES.EMACHINE then
unit = svrs_emachine.new(self.id, i, unit_advert, self.out_q)
elseif u_type == RTU_UNIT_TYPES.IMATRIX then
-- @todo Mekanism 10.1+
else
log.error(log_header .. "bad advertisement: encountered unsupported RTU type")
end
if unit ~= nil then
table.insert(self.units, unit)
if self.rs_io_q[unit_advert.reactor] == nil then
self.rs_io_q[unit_advert.reactor] = rs_in_q
else
self.units = {}
self.rs_io_q = {}
log.error(log_header .. "bad advertisement: duplicate redstone RTU for reactor " .. unit_advert.reactor)
break
end
else
self.units = {}
self.rs_io_q = {}
local type_string = comms.advert_type_to_rtu_t(u_type)
if type_string == nil then type_string = "unknown" end
log.error(log_header .. "bad advertisement: error occured while creating a unit (type is " .. type_string .. ")")
break
end
end
end
-- mark this RTU session as closed, stop watchdog
local _close = function ()
self.rtu_conn_watchdog.cancel()
self.connected = false
-- mark all RTU unit sessions as closed so the reactor unit knows
for i = 1, #self.units do
self.units[i].close()
end
end
-- send a SCADA management packet
---@param msg_type SCADA_MGMT_TYPES
---@param msg table
local _send_mgmt = function (msg_type, msg)
local s_pkt = comms.scada_packet()
local m_pkt = comms.mgmt_packet()
m_pkt.make(msg_type, msg)
s_pkt.make(self.seq_num, PROTOCOLS.SCADA_MGMT, m_pkt.raw_sendable())
self.out_q.push_packet(s_pkt)
self.seq_num = self.seq_num + 1
end
-- handle a packet
---@param pkt modbus_frame|mgmt_frame
local _handle_packet = function (pkt)
-- check sequence number
if self.r_seq_num == nil then
self.r_seq_num = pkt.scada_frame.seq_num()
elseif self.r_seq_num >= pkt.scada_frame.seq_num() then
log.warning(log_header .. "sequence out-of-order: last = " .. self.r_seq_num .. ", new = " .. pkt.scada_frame.seq_num())
return
else
self.r_seq_num = pkt.scada_frame.seq_num()
end
-- feed watchdog
self.rtu_conn_watchdog.feed()
-- process packet
if pkt.scada_frame.protocol() == PROTOCOLS.MODBUS_TCP then
if self.units[pkt.unit_id] ~= nil then
local unit = self.units[pkt.unit_id] ---@type unit_session
unit.handle_packet(pkt)
end
elseif pkt.scada_frame.protocol() == PROTOCOLS.SCADA_MGMT then
-- handle management packet
if pkt.type == SCADA_MGMT_TYPES.KEEP_ALIVE then
-- keep alive reply
if pkt.length == 2 then
local srv_start = pkt.data[1]
local rtu_send = pkt.data[2]
local srv_now = util.time()
self.last_rtt = srv_now - srv_start
if self.last_rtt > 500 then
log.warning(log_header .. "RTU KEEP_ALIVE round trip time > 500ms (" .. self.last_rtt .. "ms)")
end
-- log.debug(log_header .. "RTU RTT = ".. self.last_rtt .. "ms")
-- log.debug(log_header .. "RTU TT = ".. (srv_now - rtu_send) .. "ms")
else
log.debug(log_header .. "SCADA keep alive packet length mismatch")
end
elseif pkt.type == SCADA_MGMT_TYPES.CLOSE then
-- close the session
_close()
elseif pkt.type == SCADA_MGMT_TYPES.RTU_ADVERT then
-- RTU unit advertisement
-- handle advertisement; this will re-create all unit sub-sessions
self.advert = pkt.data
_handle_advertisement()
else
log.debug(log_header .. "handler received unsupported SCADA_MGMT packet type " .. pkt.type)
end
end
end
-- PUBLIC FUNCTIONS --
-- get the session ID
public.get_id = function () return self.id end
-- check if a timer matches this session's watchdog
---@param timer number
public.check_wd = function (timer)
return self.rtu_conn_watchdog.is_timer(timer) and self.connected
end
-- close the connection
public.close = function ()
_close()
_send_mgmt(SCADA_MGMT_TYPES.CLOSE, {})
println(log_header .. "connection to RTU closed by server")
log.info(log_header .. "session closed by server")
end
-- iterate the session
---@return boolean connected
public.iterate = function ()
if self.connected then
------------------
-- handle queue --
------------------
local handle_start = util.time()
while self.in_q.ready() and self.connected do
-- get a new message to process
local msg = self.in_q.pop()
if msg ~= nil then
if msg.qtype == mqueue.TYPE.PACKET then
-- handle a packet
_handle_packet(msg.message)
elseif msg.qtype == mqueue.TYPE.COMMAND then
-- handle instruction
elseif msg.qtype == mqueue.TYPE.DATA then
-- instruction with body
local cmd = msg.message ---@type queue_data
if cmd.key == RTU_S_DATA.RS_COMMAND then
local rs_cmd = cmd.val ---@type rs_session_command
if rsio.is_valid_channel(rs_cmd.channel) then
cmd.key = svrs_redstone.RS_RTU_S_DATA.RS_COMMAND
if rs_cmd.reactor == nil then
-- for all reactors (facility)
for i = 1, #self.rs_io_q do
local q = self.rs_io.q[i] ---@type mqueue
q.push_data(msg)
end
elseif self.rs_io_q[rs_cmd.reactor] ~= nil then
-- for just one reactor
local q = self.rs_io.q[rs_cmd.reactor] ---@type mqueue
q.push_data(msg)
end
end
end
end
end
-- max 100ms spent processing queue
if util.time() - handle_start > 100 then
log.warning(log_header .. "exceeded 100ms queue process limit")
break
end
end
-- exit if connection was closed
if not self.connected then
println(log_header .. "connection to RTU closed by remote host")
log.info(log_header .. "session closed by remote host")
return self.connected
end
------------------
-- update units --
------------------
local time_now = util.time()
for i = 1, #self.units do
self.units[i].update(time_now)
end
----------------------
-- update periodics --
----------------------
local elapsed = util.time() - self.periodics.last_update
local periodics = self.periodics
-- keep alive
periodics.keep_alive = periodics.keep_alive + elapsed
if periodics.keep_alive >= PERIODICS.KEEP_ALIVE then
_send_mgmt(SCADA_MGMT_TYPES.KEEP_ALIVE, { util.time() })
periodics.keep_alive = 0
end
self.periodics.last_update = util.time()
end
return self.connected
end
return public
end
return rtu

View File

@ -0,0 +1,188 @@
local comms = require("scada-common.comms")
local log = require("scada-common.log")
local types = require("scada-common.types")
local unit_session = require("supervisor.session.rtu.unit_session")
local boiler = {}
local RTU_UNIT_TYPES = comms.RTU_UNIT_TYPES
local MODBUS_FCODE = types.MODBUS_FCODE
local TXN_TYPES = {
BUILD = 1,
STATE = 2,
TANKS = 3
}
local TXN_TAGS = {
"boiler.build",
"boiler.state",
"boiler.tanks",
}
local PERIODICS = {
BUILD = 1000,
STATE = 500,
TANKS = 1000
}
-- create a new boiler rtu session runner
---@param session_id integer
---@param unit_id integer
---@param advert rtu_advertisement
---@param out_queue mqueue
boiler.new = function (session_id, unit_id, advert, out_queue)
-- type check
if advert.type ~= RTU_UNIT_TYPES.BOILER then
log.error("attempt to instantiate boiler RTU for type '" .. advert.type .. "'. this is a bug.")
return nil
end
local log_tag = "session.rtu(" .. session_id .. ").boiler(" .. advert.index .. "): "
local self = {
session = unit_session.new(unit_id, advert, out_queue, log_tag, TXN_TAGS),
has_build = false,
periodics = {
next_build_req = 0,
next_state_req = 0,
next_tanks_req = 0,
},
---@class boiler_session_db
db = {
build = {
boil_cap = 0.0,
steam_cap = 0,
water_cap = 0,
hcoolant_cap = 0,
ccoolant_cap = 0,
superheaters = 0,
max_boil_rate = 0.0
},
state = {
temperature = 0.0,
boil_rate = 0.0
},
tanks = {
steam = 0,
steam_need = 0,
steam_fill = 0.0,
water = 0,
water_need = 0,
water_fill = 0.0,
hcool = {}, ---@type tank_fluid
hcool_need = 0,
hcool_fill = 0.0,
ccool = {}, ---@type tank_fluid
ccool_need = 0,
ccool_fill = 0.0
}
}
}
local public = self.session.get()
-- PRIVATE FUNCTIONS --
-- query the build of the device
local _request_build = function ()
-- read input registers 1 through 7 (start = 1, count = 7)
self.session.send_request(TXN_TYPES.BUILD, MODBUS_FCODE.READ_INPUT_REGS, { 1, 7 })
end
-- query the state of the device
local _request_state = function ()
-- read input registers 8 through 9 (start = 8, count = 2)
self.session.send_request(TXN_TYPES.STATE, MODBUS_FCODE.READ_INPUT_REGS, { 8, 2 })
end
-- query the tanks of the device
local _request_tanks = function ()
-- read input registers 10 through 21 (start = 10, count = 12)
self.session.send_request(TXN_TYPES.TANKS, MODBUS_FCODE.READ_INPUT_REGS, { 10, 12 })
end
-- PUBLIC FUNCTIONS --
-- handle a packet
---@param m_pkt modbus_frame
public.handle_packet = function (m_pkt)
local txn_type = self.session.try_resolve(m_pkt.txn_id)
if txn_type == false then
-- nothing to do
elseif txn_type == TXN_TYPES.BUILD then
-- build response
-- load in data if correct length
if m_pkt.length == 7 then
self.db.build.boil_cap = m_pkt.data[1]
self.db.build.steam_cap = m_pkt.data[2]
self.db.build.water_cap = m_pkt.data[3]
self.db.build.hcoolant_cap = m_pkt.data[4]
self.db.build.ccoolant_cap = m_pkt.data[5]
self.db.build.superheaters = m_pkt.data[6]
self.db.build.max_boil_rate = m_pkt.data[7]
else
log.debug(log_tag .. "MODBUS transaction reply length mismatch (boiler.build)")
end
elseif txn_type == TXN_TYPES.STATE then
-- state response
-- load in data if correct length
if m_pkt.length == 2 then
self.db.state.temperature = m_pkt.data[1]
self.db.state.boil_rate = m_pkt.data[2]
else
log.debug(log_tag .. "MODBUS transaction reply length mismatch (boiler.state)")
end
elseif txn_type == TXN_TYPES.TANKS then
-- tanks response
-- load in data if correct length
if m_pkt.length == 12 then
self.db.tanks.steam = m_pkt.data[1]
self.db.tanks.steam_need = m_pkt.data[2]
self.db.tanks.steam_fill = m_pkt.data[3]
self.db.tanks.water = m_pkt.data[4]
self.db.tanks.water_need = m_pkt.data[5]
self.db.tanks.water_fill = m_pkt.data[6]
self.db.tanks.hcool = m_pkt.data[7]
self.db.tanks.hcool_need = m_pkt.data[8]
self.db.tanks.hcool_fill = m_pkt.data[9]
self.db.tanks.ccool = m_pkt.data[10]
self.db.tanks.ccool_need = m_pkt.data[11]
self.db.tanks.ccool_fill = m_pkt.data[12]
else
log.debug(log_tag .. "MODBUS transaction reply length mismatch (boiler.tanks)")
end
elseif txn_type == nil then
log.error(log_tag .. "unknown transaction reply")
else
log.error(log_tag .. "unknown transaction type " .. txn_type)
end
end
-- update this runner
---@param time_now integer milliseconds
public.update = function (time_now)
if not self.periodics.has_build and self.periodics.next_build_req <= time_now then
_request_build()
self.periodics.next_build_req = time_now + PERIODICS.BUILD
end
if self.periodics.next_state_req <= time_now then
_request_state()
self.periodics.next_state_req = time_now + PERIODICS.STATE
end
if self.periodics.next_tanks_req <= time_now then
_request_tanks()
self.periodics.next_tanks_req = time_now + PERIODICS.TANKS
end
end
-- get the unit session database
public.get_db = function () return self.db end
return public
end
return boiler

View File

@ -0,0 +1,128 @@
local comms = require("scada-common.comms")
local log = require("scada-common.log")
local types = require("scada-common.types")
local unit_session = require("supervisor.session.rtu.unit_session")
local emachine = {}
local RTU_UNIT_TYPES = comms.RTU_UNIT_TYPES
local MODBUS_FCODE = types.MODBUS_FCODE
local TXN_TYPES = {
BUILD = 1,
STORAGE = 2
}
local TXN_TAGS = {
"emachine.build",
"emachine.storage"
}
local PERIODICS = {
BUILD = 1000,
STORAGE = 500
}
-- create a new energy machine rtu session runner
---@param session_id integer
---@param unit_id integer
---@param advert rtu_advertisement
---@param out_queue mqueue
emachine.new = function (session_id, unit_id, advert, out_queue)
-- type check
if advert.type ~= RTU_UNIT_TYPES.EMACHINE then
log.error("attempt to instantiate emachine RTU for type '" .. advert.type .. "'. this is a bug.")
return nil
end
local log_tag = "session.rtu(" .. session_id .. ").emachine(" .. advert.index .. "): "
local self = {
session = unit_session.new(unit_id, advert, out_queue, log_tag, TXN_TAGS),
has_build = false,
periodics = {
next_build_req = 0,
next_storage_req = 0,
},
---@class emachine_session_db
db = {
build = {
max_energy = 0
},
storage = {
energy = 0,
energy_need = 0,
energy_fill = 0.0
}
}
}
local public = self.session.get()
-- PRIVATE FUNCTIONS --
-- query the build of the device
local _request_build = function ()
-- read input register 1 (start = 1, count = 1)
self.session.send_request(TXN_TYPES.BUILD, MODBUS_FCODE.READ_INPUT_REGS, { 1, 1 })
end
-- query the state of the energy storage
local _request_storage = function ()
-- read input registers 2 through 4 (start = 2, count = 3)
self.session.send_request(TXN_TYPES.STORAGE, MODBUS_FCODE.READ_INPUT_REGS, { 2, 3 })
end
-- PUBLIC FUNCTIONS --
-- handle a packet
---@param m_pkt modbus_frame
public.handle_packet = function (m_pkt)
local txn_type = self.session.try_resolve(m_pkt.txn_id)
if txn_type == false then
-- nothing to do
elseif txn_type == TXN_TYPES.BUILD then
-- build response
if m_pkt.length == 1 then
self.db.build.max_energy = m_pkt.data[1]
else
log.debug(log_tag .. "MODBUS transaction reply length mismatch (emachine.build)")
end
elseif txn_type == TXN_TYPES.STORAGE then
-- storage response
if m_pkt.length == 3 then
self.db.storage.energy = m_pkt.data[1]
self.db.storage.energy_need = m_pkt.data[2]
self.db.storage.energy_fill = m_pkt.data[3]
else
log.debug(log_tag .. "MODBUS transaction reply length mismatch (emachine.storage)")
end
elseif txn_type == nil then
log.error(log_tag .. "unknown transaction reply")
else
log.error(log_tag .. "unknown transaction type " .. txn_type)
end
end
-- update this runner
---@param time_now integer milliseconds
public.update = function (time_now)
if not self.has_build and self.periodics.next_build_req <= time_now then
_request_build()
self.periodics.next_build_req = time_now + PERIODICS.BUILD
end
if self.periodics.next_storage_req <= time_now then
_request_storage()
self.periodics.next_storage_req = time_now + PERIODICS.STORAGE
end
end
-- get the unit session database
public.get_db = function () return self.db end
return public
end
return emachine

View File

@ -0,0 +1,254 @@
local comms = require("scada-common.comms")
local log = require("scada-common.log")
local mqueue= require("scada-common.mqueue")
local rsio = require("scada-common.rsio")
local types = require("scada-common.types")
local util = require("scada-common.util")
local unit_session = require("supervisor.session.rtu.unit_session")
local redstone = {}
local RTU_UNIT_TYPES = comms.RTU_UNIT_TYPES
local MODBUS_FCODE = types.MODBUS_FCODE
local RS_IO = rsio.IO
local IO_LVL = rsio.IO_LVL
local IO_DIR = rsio.IO_DIR
local IO_MODE = rsio.IO_MODE
local RS_RTU_S_CMDS = {
}
local RS_RTU_S_DATA = {
RS_COMMAND = 1
}
redstone.RS_RTU_S_CMDS = RS_RTU_S_CMDS
redstone.RS_RTU_S_DATA = RS_RTU_S_DATA
local TXN_TYPES = {
DI_READ = 1,
COIL_WRITE = 2,
INPUT_REG_READ = 3,
HOLD_REG_WRITE = 4
}
local TXN_TAGS = {
"redstone.di_read",
"redstone.coil_write",
"redstone.input_reg_write",
"redstone.hold_reg_write"
}
local PERIODICS = {
INPUT_READ = 200
}
-- create a new redstone rtu session runner
---@param session_id integer
---@param unit_id integer
---@param advert rtu_advertisement
---@param out_queue mqueue
redstone.new = function (session_id, unit_id, advert, out_queue)
-- type check
if advert.type ~= RTU_UNIT_TYPES.REDSTONE then
log.error("attempt to instantiate redstone RTU for type '" .. advert.type .. "'. this is a bug.")
return nil
end
-- for redstone, use unit ID not device index
local log_tag = "session.rtu(" .. session_id .. ").redstone(" .. unit_id .. "): "
local self = {
session = unit_session.new(unit_id, advert, out_queue, log_tag, TXN_TAGS),
has_di = false,
has_ai = false,
periodics = {
next_di_req = 0,
next_ir_req = 0,
},
io_list = {
digital_in = {}, -- discrete inputs
digital_out = {}, -- coils
analog_in = {}, -- input registers
analog_out = {} -- holding registers
},
db = {}
}
local public = self.session.get()
-- INITIALIZE --
-- create all channels as disconnected
for _ = 1, #RS_IO do
table.insert(self.db, IO_LVL.DISCONNECT)
end
-- setup I/O
for i = 1, #advert.rsio do
local channel = advert.rsio[i]
local mode = rsio.get_io_mode(channel)
if mode == IO_MODE.DIGITAL_IN then
self.has_di = true
table.insert(self.io_list.digital_in, channel)
elseif mode == IO_MODE.DIGITAL_OUT then
table.insert(self.io_list.digital_out, channel)
elseif mode == IO_MODE.ANALOG_IN then
self.has_ai = true
table.insert(self.io_list.analog_in, channel)
elseif mode == IO_MODE.ANALOG_OUT then
table.insert(self.io_list.analog_out, channel)
else
-- should be unreachable code, we already validated channels
log.error(log_tag .. "failed to identify advertisement channel IO mode (" .. channel .. ")", true)
return nil
end
self.db[channel] = IO_LVL.LOW
end
-- PRIVATE FUNCTIONS --
-- query discrete inputs
local _request_discrete_inputs = function ()
self.session.send_request(TXN_TYPES.DI_READ, MODBUS_FCODE.READ_DISCRETE_INPUTS, { 1, #self.io_list.digital_in })
end
-- query input registers
local _request_input_registers = function ()
self.session.send_request(TXN_TYPES.INPUT_REG_READ, MODBUS_FCODE.READ_INPUT_REGS, { 1, #self.io_list.analog_in })
end
-- write coil output
local _write_coil = function (coil, value)
self.session.send_request(TXN_TYPES.COIL_WRITE, MODBUS_FCODE.WRITE_MUL_COILS, { coil, value })
end
-- write holding register output
local _write_holding_register = function (reg, value)
self.session.send_request(TXN_TYPES.HOLD_REG_WRITE, MODBUS_FCODE.WRITE_MUL_HOLD_REGS, { reg, value })
end
-- PUBLIC FUNCTIONS --
-- handle a packet
---@param m_pkt modbus_frame
public.handle_packet = function (m_pkt)
local txn_type = self.session.try_resolve(m_pkt.txn_id)
if txn_type == false then
-- nothing to do
elseif txn_type == TXN_TYPES.DI_READ then
-- discrete input read response
if m_pkt.length == #self.io_list.digital_in then
for i = 1, m_pkt.length do
local channel = self.io_list.digital_in[i]
local value = m_pkt.data[i]
self.db[channel] = value
end
else
log.debug(log_tag .. "MODBUS transaction reply length mismatch (redstone.di_read)")
end
elseif txn_type == TXN_TYPES.INPUT_REG_READ then
-- input register read response
if m_pkt.length == #self.io_list.analog_in then
for i = 1, m_pkt.length do
local channel = self.io_list.analog_in[i]
local value = m_pkt.data[i]
self.db[channel] = value
end
else
log.debug(log_tag .. "MODBUS transaction reply length mismatch (redstone.input_reg_read)")
end
elseif txn_type == TXN_TYPES.COIL_WRITE or txn_type == TXN_TYPES.HOLD_REG_WRITE then
-- successful acknowledgement
elseif txn_type == nil then
log.error(log_tag .. "unknown transaction reply")
else
log.error(log_tag .. "unknown transaction type " .. txn_type)
end
end
-- update this runner
---@param time_now integer milliseconds
public.update = function (time_now)
-- check command queue
while self.in_q.ready() do
-- get a new message to process
local msg = self.in_q.pop()
if msg ~= nil then
if msg.qtype == mqueue.TYPE.DATA then
-- instruction with body
local cmd = msg.message ---@type queue_data
if cmd.key == RS_RTU_S_DATA.RS_COMMAND then
local rs_cmd = cmd.val ---@type rs_session_command
if self.db[rs_cmd.channel] ~= IO_LVL.DISCONNECT then
-- we have this as a connected channel
local mode = rsio.get_io_mode(rs_cmd.channel)
if mode == IO_MODE.DIGITAL_OUT then
-- record the value for retries
self.db[rs_cmd.channel] = rs_cmd.value
-- find the coil address then write to it
for i = 0, #self.digital_out do
if self.digital_out[i] == rs_cmd.channel then
_write_coil(i, rs_cmd.value)
break
end
end
elseif mode == IO_MODE.ANALOG_OUT then
-- record the value for retries
self.db[rs_cmd.channel] = rs_cmd.value
-- find the holding register address then write to it
for i = 0, #self.analog_out do
if self.analog_out[i] == rs_cmd.channel then
_write_holding_register(i, rs_cmd.value)
break
end
end
elseif mode ~= nil then
log.debug(log_tag .. "attemted write to non D/O or A/O mode " .. mode)
end
end
end
end
end
-- max 100ms spent processing queue
if util.time() - time_now > 100 then
log.warning(log_tag .. "exceeded 100ms queue process limit")
break
end
end
time_now = util.time()
-- poll digital inputs
if self.has_di then
if self.periodics.next_di_req <= time_now then
_request_discrete_inputs()
self.periodics.next_di_req = time_now + PERIODICS.INPUT_READ
end
end
-- poll analog inputs
if self.has_ai then
if self.periodics.next_ir_req <= time_now then
_request_input_registers()
self.periodics.next_ir_req = time_now + PERIODICS.INPUT_READ
end
end
end
-- get the unit session database
public.get_db = function () return self.db end
return public, self.in_q
end
return redstone

View File

@ -0,0 +1,176 @@
local comms = require("scada-common.comms")
local log = require("scada-common.log")
local types = require("scada-common.types")
local unit_session = require("supervisor.session.rtu.unit_session")
local turbine = {}
local RTU_UNIT_TYPES = comms.RTU_UNIT_TYPES
local DUMPING_MODE = types.DUMPING_MODE
local MODBUS_FCODE = types.MODBUS_FCODE
local TXN_TYPES = {
BUILD = 1,
STATE = 2,
TANKS = 3
}
local TXN_TAGS = {
"turbine.build",
"turbine.state",
"turbine.tanks",
}
local PERIODICS = {
BUILD = 1000,
STATE = 500,
TANKS = 1000
}
-- create a new turbine rtu session runner
---@param session_id integer
---@param unit_id integer
---@param advert rtu_advertisement
---@param out_queue mqueue
turbine.new = function (session_id, unit_id, advert, out_queue)
-- type check
if advert.type ~= RTU_UNIT_TYPES.TURBINE then
log.error("attempt to instantiate turbine RTU for type '" .. advert.type .. "'. this is a bug.")
return nil
end
local log_tag = "session.rtu(" .. session_id .. ").turbine(" .. advert.index .. "): "
local self = {
session = unit_session.new(unit_id, advert, out_queue, log_tag, TXN_TAGS),
has_build = false,
periodics = {
next_build_req = 0,
next_state_req = 0,
next_tanks_req = 0,
},
---@class turbine_session_db
db = {
build = {
blades = 0,
coils = 0,
vents = 0,
dispersers = 0,
condensers = 0,
steam_cap = 0,
max_flow_rate = 0,
max_production = 0,
max_water_output = 0
},
state = {
flow_rate = 0.0,
prod_rate = 0.0,
steam_input_rate = 0.0,
dumping_mode = DUMPING_MODE.IDLE ---@type DUMPING_MODE
},
tanks = {
steam = 0,
steam_need = 0,
steam_fill = 0.0
}
}
}
local public = self.session.get()
-- PRIVATE FUNCTIONS --
-- query the build of the device
local _request_build = function ()
-- read input registers 1 through 9 (start = 1, count = 9)
self.session.send_request(TXN_TYPES.BUILD, MODBUS_FCODE.READ_INPUT_REGS, { 1, 9 })
end
-- query the state of the device
local _request_state = function ()
-- read input registers 10 through 13 (start = 10, count = 4)
self.session.send_request(TXN_TYPES.STATE, MODBUS_FCODE.READ_INPUT_REGS, { 10, 4 })
end
-- query the tanks of the device
local _request_tanks = function ()
-- read input registers 14 through 16 (start = 14, count = 3)
self.session.send_request(TXN_TYPES.TANKS, MODBUS_FCODE.READ_INPUT_REGS, { 14, 3 })
end
-- PUBLIC FUNCTIONS --
-- handle a packet
---@param m_pkt modbus_frame
public.handle_packet = function (m_pkt)
local txn_type = self.session.try_resolve(m_pkt.txn_id)
if txn_type == false then
-- nothing to do
elseif txn_type == TXN_TYPES.BUILD then
-- build response
if m_pkt.length == 9 then
self.db.build.blades = m_pkt.data[1]
self.db.build.coils = m_pkt.data[2]
self.db.build.vents = m_pkt.data[3]
self.db.build.dispersers = m_pkt.data[4]
self.db.build.condensers = m_pkt.data[5]
self.db.build.steam_cap = m_pkt.data[6]
self.db.build.max_flow_rate = m_pkt.data[7]
self.db.build.max_production = m_pkt.data[8]
self.db.build.max_water_output = m_pkt.data[9]
else
log.debug(log_tag .. "MODBUS transaction reply length mismatch (turbine.build)")
end
elseif txn_type == TXN_TYPES.STATE then
-- state response
if m_pkt.length == 4 then
self.db.state.flow_rate = m_pkt.data[1]
self.db.state.prod_rate = m_pkt.data[2]
self.db.state.steam_input_rate = m_pkt.data[3]
self.db.state.dumping_mode = m_pkt.data[4]
else
log.debug(log_tag .. "MODBUS transaction reply length mismatch (turbine.state)")
end
elseif txn_type == TXN_TYPES.TANKS then
-- tanks response
if m_pkt.length == 3 then
self.db.tanks.steam = m_pkt.data[1]
self.db.tanks.steam_need = m_pkt.data[2]
self.db.tanks.steam_fill = m_pkt.data[3]
else
log.debug(log_tag .. "MODBUS transaction reply length mismatch (turbine.tanks)")
end
elseif txn_type == nil then
log.error(log_tag .. "unknown transaction reply")
else
log.error(log_tag .. "unknown transaction type " .. txn_type)
end
end
-- update this runner
---@param time_now integer milliseconds
public.update = function (time_now)
if not self.has_build and self.periodics.next_build_req <= time_now then
_request_build()
self.periodics.next_build_req = time_now + PERIODICS.BUILD
end
if self.periodics.next_state_req <= time_now then
_request_state()
self.periodics.next_state_req = time_now + PERIODICS.STATE
end
if self.periodics.next_tanks_req <= time_now then
_request_tanks()
self.periodics.next_tanks_req = time_now + PERIODICS.TANKS
end
end
-- get the unit session database
public.get_db = function () return self.db end
return public
end
return turbine

View File

@ -0,0 +1,91 @@
--
-- MODBUS Transaction Controller
--
local util = require("scada-common.util")
local txnctrl = {}
local TIMEOUT = 2000 -- 2000ms max wait
-- create a new transaction controller
txnctrl.new = function ()
local self = {
list = {},
next_id = 0
}
---@class transaction_controller
local public = {}
local insert = table.insert
-- get the length of the transaction list
public.length = function ()
return #self.list
end
-- check if there are no active transactions
public.empty = function ()
return #self.list == 0
end
-- create a new transaction of the given type
---@param txn_type integer
---@return integer txn_id
public.create = function (txn_type)
local txn_id = self.next_id
insert(self.list, {
txn_id = txn_id,
txn_type = txn_type,
expiry = util.time() + TIMEOUT
})
self.next_id = self.next_id + 1
return txn_id
end
-- mark a transaction as resolved to get its transaction type
---@param txn_id integer
---@return integer txn_type
public.resolve = function (txn_id)
local txn_type = nil
for i = 1, public.length() do
if self.list[i].txn_id == txn_id then
txn_type = self.list[i].txn_type
self.list[i] = nil
end
end
return txn_type
end
-- renew a transaction by re-inserting it with its ID and type
---@param txn_id integer
---@param txn_type integer
public.renew = function (txn_id, txn_type)
insert(self.list, {
txn_id = txn_id,
txn_type = txn_type,
expiry = util.time() + TIMEOUT
})
end
-- close timed-out transactions
public.cleanup = function ()
local now = util.time()
util.filter_table(self.list, function (txn) return txn.expiry > now end)
end
-- clear the transaction list
public.clear = function ()
self.list = {}
end
return public
end
return txnctrl

View File

@ -0,0 +1,155 @@
local comms = require("scada-common.comms")
local log = require("scada-common.log")
local types = require("scada-common.types")
local txnctrl = require("supervisor.session.rtu.txnctrl")
local unit_session = {}
local PROTOCOLS = comms.PROTOCOLS
local MODBUS_FCODE = types.MODBUS_FCODE
local MODBUS_EXCODE = types.MODBUS_EXCODE
-- create a new unit session runner
---@param unit_id integer MODBUS unit ID
---@param advert rtu_advertisement RTU advertisement for this unit
---@param out_queue mqueue send queue
---@param log_tag string logging tag
---@param txn_tags table transaction log tags
unit_session.new = function (unit_id, advert, out_queue, log_tag, txn_tags)
local self = {
log_tag = log_tag,
txn_tags = txn_tags,
unit_id = unit_id,
device_index = advert.index,
reactor = advert.reactor,
out_q = out_queue,
transaction_controller = txnctrl.new(),
connected = true,
device_fail = false
}
---@class _unit_session
local protected = {}
---@class unit_session
local public = {}
-- PROTECTED FUNCTIONS --
-- send a MODBUS message, creating a transaction in the process
---@param txn_type integer transaction type
---@param f_code MODBUS_FCODE function code
---@param register_param table register range or register and values
protected.send_request = function (txn_type, f_code, register_param)
local m_pkt = comms.modbus_packet()
local txn_id = self.transaction_controller.create(txn_type)
m_pkt.make(txn_id, self.unit_id, f_code, register_param)
self.out_q.push_packet(m_pkt)
end
-- try to resolve a MODBUS transaction
---@param m_pkt modbus_frame MODBUS packet
---@return integer|false txn_type transaction type or false on error/busy
protected.try_resolve = function (m_pkt)
if m_pkt.scada_frame.protocol() == PROTOCOLS.MODBUS_TCP then
if m_pkt.unit_id == self.unit_id then
local txn_type = self.transaction_controller.resolve(m_pkt.txn_id)
local txn_tag = " (" .. self.txn_tags[txn_type] .. ")"
if bit.band(m_pkt.func_code, MODBUS_FCODE.ERROR_FLAG) ~= 0 then
-- transaction incomplete or failed
local ex = m_pkt.data[1]
if ex == MODBUS_EXCODE.ILLEGAL_FUNCTION then
log.error(log_tag .. "MODBUS: illegal function" .. txn_tag)
elseif ex == MODBUS_EXCODE.ILLEGAL_DATA_ADDR then
log.error(log_tag .. "MODBUS: illegal data address" .. txn_tag)
elseif ex == MODBUS_EXCODE.SERVER_DEVICE_FAIL then
if self.device_fail then
log.debug(log_tag .. "MODBUS: repeated device failure" .. txn_tag)
else
self.device_fail = true
log.warning(log_tag .. "MODBUS: device failure" .. txn_tag)
end
elseif ex == MODBUS_EXCODE.ACKNOWLEDGE then
-- will have to wait on reply, renew the transaction
self.transaction_controller.renew(m_pkt.txn_id, txn_type)
elseif ex == MODBUS_EXCODE.SERVER_DEVICE_BUSY then
-- will have to wait on reply, renew the transaction
self.transaction_controller.renew(m_pkt.txn_id, txn_type)
log.debug(log_tag .. "MODBUS: device busy" .. txn_tag)
elseif ex == MODBUS_EXCODE.NEG_ACKNOWLEDGE then
-- general failure
log.error(log_tag .. "MODBUS: negative acknowledge (bad request)" .. txn_tag)
elseif ex == MODBUS_EXCODE.GATEWAY_PATH_UNAVAILABLE then
-- RTU gateway has no known unit with the given ID
log.error(log_tag .. "MODBUS: gateway path unavailable (unknown unit)" .. txn_tag)
elseif ex ~= nil then
-- unsupported exception code
log.debug(log_tag .. "MODBUS: unsupported error " .. ex .. txn_tag)
else
-- nil exception code
log.debug(log_tag .. "MODBUS: nil exception code" .. txn_tag)
end
else
-- clear device fail flag
self.device_fail = false
-- no error, return the transaction type
return txn_type
end
else
log.error(log_tag .. "wrong unit ID: " .. m_pkt.unit_id, true)
end
else
log.error(log_tag .. "illegal packet type " .. m_pkt.scada_frame.protocol(), true)
end
-- error or transaction in progress, return false
return false
end
-- get the public interface
protected.get = function () return public end
-- PUBLIC FUNCTIONS --
-- get the unit ID
public.get_unit_id = function () return self.unit_id end
-- get the device index
public.get_device_idx = function () return self.device_index end
-- get the reactor ID
public.get_reactor = function () return self.reactor end
-- close this unit
public.close = function () self.connected = false end
-- check if this unit is connected
public.is_connected = function () return self.connected end
-- check if this unit is faulted
public.is_faulted = function () return self.device_fail end
-- PUBLIC TEMPLATE FUNCTIONS --
-- handle a packet
---@param m_pkt modbus_frame
---@diagnostic disable-next-line: unused-local
public.handle_packet = function (m_pkt)
log.debug("template unit_session.handle_packet() called", true)
end
-- update this runner
---@param time_now integer milliseconds
---@diagnostic disable-next-line: unused-local
public.update = function (time_now)
log.debug("template unit_session.update() called", true)
end
-- get the unit session database
public.get_db = function () return {} end
return protected
end
return unit_session

View File

@ -0,0 +1,294 @@
local log = require("scada-common.log")
local mqueue = require("scada-common.mqueue")
local util = require("scada-common.util")
local coordinator = require("supervisor.session.coordinator")
local plc = require("supervisor.session.plc")
local rtu = require("supervisor.session.rtu")
-- Supervisor Sessions Handler
local svsessions = {}
local SESSION_TYPE = {
RTU_SESSION = 0,
PLC_SESSION = 1,
COORD_SESSION = 2
}
svsessions.SESSION_TYPE = SESSION_TYPE
local self = {
modem = nil,
num_reactors = 0,
rtu_sessions = {},
plc_sessions = {},
coord_sessions = {},
next_rtu_id = 0,
next_plc_id = 0,
next_coord_id = 0
}
-- PRIVATE FUNCTIONS --
-- iterate all the given sessions
---@param sessions table
local function _iterate(sessions)
for i = 1, #sessions do
local session = sessions[i] ---@type plc_session_struct|rtu_session_struct
if session.open then
local ok = session.instance.iterate()
if ok then
-- send packets in out queue
while session.out_queue.ready() do
local msg = session.out_queue.pop()
if msg ~= nil and msg.qtype == mqueue.TYPE.PACKET then
self.modem.transmit(session.r_port, session.l_port, msg.message.raw_sendable())
end
end
else
session.open = false
end
end
end
end
-- cleanly close a session
---@param session plc_session_struct|rtu_session_struct
local function _shutdown(session)
session.open = false
session.instance.close()
-- send packets in out queue (namely the close packet)
while session.out_queue.ready() do
local msg = session.out_queue.pop()
if msg ~= nil and msg.qtype == mqueue.TYPE.PACKET then
self.modem.transmit(session.r_port, session.l_port, msg.message.raw_sendable())
end
end
log.debug("closed session " .. session.instance.get_id() .. " on remote port " .. session.r_port)
end
-- close connections
---@param sessions table
local function _close(sessions)
for i = 1, #sessions do
local session = sessions[i] ---@type plc_session_struct
if session.open then
_shutdown(session)
end
end
end
-- check if a watchdog timer event matches that of one of the provided sessions
---@param sessions table
---@param timer_event number
local function _check_watchdogs(sessions, timer_event)
for i = 1, #sessions do
local session = sessions[i] ---@type plc_session_struct
if session.open then
local triggered = session.instance.check_wd(timer_event)
if triggered then
log.debug("watchdog closing session " .. session.instance.get_id() .. " on remote port " .. session.r_port .. "...")
_shutdown(session)
end
end
end
end
-- delete any closed sessions
---@param sessions table
local function _free_closed(sessions)
local f = function (session) return session.open end
local on_delete = function (session) log.debug("free'ing closed session " .. session.instance.get_id() .. " on remote port " .. session.r_port) end
util.filter_table(sessions, f, on_delete)
end
-- find a session by remote port
---@param list table
---@param port integer
---@return plc_session_struct|rtu_session_struct|nil
local function _find_session(list, port)
for i = 1, #list do
if list[i].r_port == port then return list[i] end
end
return nil
end
-- PUBLIC FUNCTIONS --
-- link the modem
---@param modem table
svsessions.link_modem = function (modem)
self.modem = modem
end
-- find an RTU session by the remote port
---@param remote_port integer
---@return rtu_session_struct|nil
svsessions.find_rtu_session = function (remote_port)
-- check RTU sessions
return _find_session(self.rtu_sessions, remote_port)
end
-- find a PLC session by the remote port
---@param remote_port integer
---@return plc_session_struct|nil
svsessions.find_plc_session = function (remote_port)
-- check PLC sessions
return _find_session(self.plc_sessions, remote_port)
end
-- find a PLC/RTU session by the remote port
---@param remote_port integer
---@return plc_session_struct|rtu_session_struct|nil
svsessions.find_device_session = function (remote_port)
-- check RTU sessions
local s = _find_session(self.rtu_sessions, remote_port)
-- check PLC sessions
if s == nil then s = _find_session(self.plc_sessions, remote_port) end
return s
end
-- find a coordinator session by the remote port
---@param remote_port integer
---@return nil
svsessions.find_coord_session = function (remote_port)
-- check coordinator sessions
return _find_session(self.coord_sessions, remote_port)
end
-- get a session by reactor ID
---@param reactor integer
---@return plc_session_struct|nil session
svsessions.get_reactor_session = function (reactor)
local session = nil
for i = 1, #self.plc_sessions do
if self.plc_sessions[i].reactor == reactor then
session = self.plc_sessions[i]
end
end
return session
end
-- establish a new PLC session
---@param local_port integer
---@param remote_port integer
---@param for_reactor integer
---@param version string
---@return integer|false session_id
svsessions.establish_plc_session = function (local_port, remote_port, for_reactor, version)
if svsessions.get_reactor_session(for_reactor) == nil then
---@class plc_session_struct
local plc_s = {
open = true,
reactor = for_reactor,
version = version,
l_port = local_port,
r_port = remote_port,
in_queue = mqueue.new(),
out_queue = mqueue.new(),
instance = nil
}
plc_s.instance = plc.new_session(self.next_plc_id, for_reactor, plc_s.in_queue, plc_s.out_queue)
table.insert(self.plc_sessions, plc_s)
log.debug("established new PLC session to " .. remote_port .. " with ID " .. self.next_plc_id)
self.next_plc_id = self.next_plc_id + 1
-- success
return plc_s.instance.get_id()
else
-- reactor already assigned to a PLC
return false
end
end
-- establish a new RTU session
---@param local_port integer
---@param remote_port integer
---@param advertisement table
---@return integer session_id
svsessions.establish_rtu_session = function (local_port, remote_port, advertisement)
-- pull version from advertisement
local version = table.remove(advertisement, 1)
---@class rtu_session_struct
local rtu_s = {
open = true,
version = version,
l_port = local_port,
r_port = remote_port,
in_queue = mqueue.new(),
out_queue = mqueue.new(),
instance = nil
}
rtu_s.instance = rtu.new_session(self.next_rtu_id, rtu_s.in_queue, rtu_s.out_queue, advertisement)
table.insert(self.rtu_sessions, rtu_s)
log.debug("established new RTU session to " .. remote_port .. " with ID " .. self.next_rtu_id)
self.next_rtu_id = self.next_rtu_id + 1
-- success
return rtu_s.instance.get_id()
end
-- attempt to identify which session's watchdog timer fired
---@param timer_event number
svsessions.check_all_watchdogs = function (timer_event)
-- check RTU session watchdogs
_check_watchdogs(self.rtu_sessions, timer_event)
-- check PLC session watchdogs
_check_watchdogs(self.plc_sessions, timer_event)
-- check coordinator session watchdogs
_check_watchdogs(self.coord_sessions, timer_event)
end
-- iterate all sessions
svsessions.iterate_all = function ()
-- iterate RTU sessions
_iterate(self.rtu_sessions)
-- iterate PLC sessions
_iterate(self.plc_sessions)
-- iterate coordinator sessions
_iterate(self.coord_sessions)
end
-- delete all closed sessions
svsessions.free_all_closed = function ()
-- free closed RTU sessions
_free_closed(self.rtu_sessions)
-- free closed PLC sessions
_free_closed(self.plc_sessions)
-- free closed coordinator sessions
_free_closed(self.coord_sessions)
end
-- close all open connections
svsessions.close_all = function ()
-- close sessions
_close(self.rtu_sessions)
_close(self.plc_sessions)
_close(self.coord_sessions)
-- free sessions
svsessions.free_all_closed()
end
return svsessions

View File

@ -2,65 +2,116 @@
-- Nuclear Generation Facility SCADA Supervisor -- Nuclear Generation Facility SCADA Supervisor
-- --
os.loadAPI("scada-common/log.lua") require("/initenv").init_env()
os.loadAPI("scada-common/util.lua")
os.loadAPI("scada-common/ppm.lua")
os.loadAPI("scada-common/comms.lua")
os.loadAPI("supervisor/config.lua") local log = require("scada-common.log")
os.loadAPI("supervisor/supervisor.lua") local ppm = require("scada-common.ppm")
local util = require("scada-common.util")
local SUPERVISOR_VERSION = "alpha-v0.1.0" local svsessions = require("supervisor.session.svsessions")
local config = require("supervisor.config")
local supervisor = require("supervisor.supervisor")
local SUPERVISOR_VERSION = "alpha-v0.4.0"
local print = util.print
local println = util.println
local print_ts = util.print_ts local print_ts = util.print_ts
local println_ts = util.println_ts
log.init(config.LOG_PATH, config.LOG_MODE)
log.info("========================================")
log.info("BOOTING supervisor.startup " .. SUPERVISOR_VERSION)
log.info("========================================")
println(">> SCADA Supervisor " .. SUPERVISOR_VERSION .. " <<")
-- mount connected devices
ppm.mount_all() ppm.mount_all()
local modem = ppm.get_device("modem") local modem = ppm.get_wireless_modem()
print("| SCADA Supervisor - " .. SUPERVISOR_VERSION .. " |")
-- we need a modem
if modem == nil then if modem == nil then
print("Please connect a modem.") println("boot> wireless modem not found")
log.warning("no wireless modem on startup")
return return
end end
-- determine active/backup mode
local mode = comms.SCADA_SV_MODES.BACKUP
if config.SYSTEM_TYPE == "active" then
mode = comms.SCADA_SV_MODES.ACTIVE
end
-- start comms, open all channels -- start comms, open all channels
if not modem.isOpen(config.SCADA_DEV_LISTEN) then local superv_comms = supervisor.comms(SUPERVISOR_VERSION, config.NUM_REACTORS, modem, config.SCADA_DEV_LISTEN, config.SCADA_SV_LISTEN)
modem.open(config.SCADA_DEV_LISTEN)
end
if not modem.isOpen(config.SCADA_FO_CHANNEL) then
modem.open(config.SCADA_FO_CHANNEL)
end
if not modem.isOpen(config.SCADA_SV_CHANNEL) then
modem.open(config.SCADA_SV_CHANNEL)
end
local comms = supervisor.superv_comms(config.NUM_REACTORS, modem, config.SCADA_DEV_LISTEN, config.SCADA_FO_CHANNEL, config.SCADA_SV_CHANNEL) -- base loop clock (6.67Hz, 3 ticks)
local MAIN_CLOCK = 0.15
local loop_clock = util.new_clock(MAIN_CLOCK)
-- base loop clock (4Hz, 5 ticks) -- start clock
local loop_tick = os.startTimer(0.25) loop_clock.start()
-- event loop -- event loop
while true do while true do
---@diagnostic disable-next-line: undefined-field
local event, param1, param2, param3, param4, param5 = os.pullEventRaw() local event, param1, param2, param3, param4, param5 = os.pullEventRaw()
-- handle event -- handle event
if event == "timer" and param1 == loop_tick then if event == "peripheral_detach" then
-- basic event tick, send keep-alives local type, device = ppm.handle_unmount(param1)
if type ~= nil and device ~= nil then
if type == "modem" then
-- we only care if this is our wireless modem
if device == modem then
println_ts("wireless modem disconnected!")
log.error("comms modem disconnected!")
else
log.warning("non-comms modem disconnected")
end
end
end
elseif event == "peripheral" then
local type, device = ppm.mount(param1)
if type ~= nil and device ~= nil then
if type == "modem" then
if device.isWireless() then
-- reconnected modem
modem = device
superv_comms.reconnect_modem(modem)
println_ts("wireless modem reconnected.")
log.info("comms modem reconnected.")
else
log.info("wired modem reconnected.")
end
end
end
elseif event == "timer" and loop_clock.is_clock(param1) then
-- main loop tick
-- iterate sessions
svsessions.iterate_all()
-- free any closed sessions
svsessions.free_all_closed()
loop_clock.start()
elseif event == "timer" then
-- a non-clock timer event, check watchdogs
svsessions.check_all_watchdogs(param1)
elseif event == "modem_message" then elseif event == "modem_message" then
-- got a packet -- got a packet
elseif event == "terminate" then local packet = superv_comms.parse_packet(param1, param2, param3, param4, param5)
-- safe exit superv_comms.handle_packet(packet)
print_ts("[alert] terminated\n") end
-- todo: attempt failover, alert hot backup
return -- check for termination request
if event == "terminate" or ppm.should_terminate() then
println_ts("closing sessions...")
log.info("terminate requested, closing sessions...")
svsessions.close_all()
log.info("sessions closed")
break
end end
end end
println_ts("exited")
log.info("exited")

View File

@ -1,15 +1,255 @@
-- #REQUIRES comms.lua local comms = require("scada-common.comms")
local log = require("scada-common.log")
local util = require("scada-common.util")
local svsessions = require("supervisor.session.svsessions")
local supervisor = {}
local PROTOCOLS = comms.PROTOCOLS
local RPLC_TYPES = comms.RPLC_TYPES
local RPLC_LINKING = comms.RPLC_LINKING
local SCADA_MGMT_TYPES = comms.SCADA_MGMT_TYPES
local RTU_UNIT_TYPES = comms.RTU_UNIT_TYPES
local SESSION_TYPE = svsessions.SESSION_TYPE
local print = util.print
local println = util.println
local print_ts = util.print_ts
local println_ts = util.println_ts
-- supervisory controller communications -- supervisory controller communications
function superv_comms(mode, num_reactors, modem, dev_listen, fo_channel, sv_channel) ---@param version string
---@param num_reactors integer
---@param modem table
---@param dev_listen integer
---@param coord_listen integer
supervisor.comms = function (version, num_reactors, modem, dev_listen, coord_listen)
local self = { local self = {
mode = mode, version = version,
seq_num = 0,
num_reactors = num_reactors, num_reactors = num_reactors,
modem = modem, modem = modem,
dev_listen = dev_listen, dev_listen = dev_listen,
fo_channel = fo_channel, coord_listen = coord_listen,
sv_channel = sv_channel,
reactor_struct_cache = nil reactor_struct_cache = nil
} }
---@class superv_comms
local public = {}
-- PRIVATE FUNCTIONS --
-- open all channels
local _open_channels = function ()
if not self.modem.isOpen(self.dev_listen) then
self.modem.open(self.dev_listen)
end
if not self.modem.isOpen(self.coord_listen) then
self.modem.open(self.coord_listen)
end
end
-- open at construct time
_open_channels()
-- link modem to svsessions
svsessions.link_modem(self.modem)
-- send PLC link request responses
---@param dest integer
---@param msg table
local _send_plc_linking = function (seq_id, dest, msg)
local s_pkt = comms.scada_packet()
local r_pkt = comms.rplc_packet()
r_pkt.make(0, RPLC_TYPES.LINK_REQ, msg)
s_pkt.make(seq_id, PROTOCOLS.RPLC, r_pkt.raw_sendable())
self.modem.transmit(dest, self.dev_listen, s_pkt.raw_sendable())
end
-- send RTU advertisement responses
---@param seq_id integer
---@param dest integer
local _send_remote_linked = function (seq_id, dest)
local s_pkt = comms.scada_packet()
local m_pkt = comms.mgmt_packet()
m_pkt.make(SCADA_MGMT_TYPES.REMOTE_LINKED, {})
s_pkt.make(seq_id, PROTOCOLS.SCADA_MGMT, m_pkt.raw_sendable())
self.modem.transmit(dest, self.dev_listen, s_pkt.raw_sendable())
end
-- PUBLIC FUNCTIONS --
-- reconnect a newly connected modem
---@param modem table
---@diagnostic disable-next-line: redefined-local
public.reconnect_modem = function (modem)
self.modem = modem
svsessions.link_modem(self.modem)
_open_channels()
end
-- parse a packet
---@param side string
---@param sender integer
---@param reply_to integer
---@param message any
---@param distance integer
---@return modbus_frame|rplc_frame|mgmt_frame|coord_frame|nil packet
public.parse_packet = function(side, sender, reply_to, message, distance)
local pkt = nil
local s_pkt = comms.scada_packet()
-- parse packet as generic SCADA packet
s_pkt.receive(side, sender, reply_to, message, distance)
if s_pkt.is_valid() then
-- get as MODBUS TCP packet
if s_pkt.protocol() == PROTOCOLS.MODBUS_TCP then
local m_pkt = comms.modbus_packet()
if m_pkt.decode(s_pkt) then
pkt = m_pkt.get()
end
-- get as RPLC packet
elseif s_pkt.protocol() == PROTOCOLS.RPLC then
local rplc_pkt = comms.rplc_packet()
if rplc_pkt.decode(s_pkt) then
pkt = rplc_pkt.get()
end
-- get as SCADA management packet
elseif s_pkt.protocol() == PROTOCOLS.SCADA_MGMT then
local mgmt_pkt = comms.mgmt_packet()
if mgmt_pkt.decode(s_pkt) then
pkt = mgmt_pkt.get()
end
-- get as coordinator packet
elseif s_pkt.protocol() == PROTOCOLS.COORD_DATA then
local coord_pkt = comms.coord_packet()
if coord_pkt.decode(s_pkt) then
pkt = coord_pkt.get()
end
else
log.debug("attempted parse of illegal packet type " .. s_pkt.protocol(), true)
end
end
return pkt
end
-- handle a packet
---@param packet modbus_frame|rplc_frame|mgmt_frame|coord_frame
public.handle_packet = function(packet)
if packet ~= nil then
local l_port = packet.scada_frame.local_port()
local r_port = packet.scada_frame.remote_port()
local protocol = packet.scada_frame.protocol()
-- device (RTU/PLC) listening channel
if l_port == self.dev_listen then
if protocol == PROTOCOLS.MODBUS_TCP then
-- look for an associated session
local session = svsessions.find_rtu_session(r_port)
-- MODBUS response
if session ~= nil then
-- pass the packet onto the session handler
session.in_queue.push_packet(packet)
else
-- any other packet should be session related, discard it
log.debug("discarding MODBUS_TCP packet without a known session")
end
elseif protocol == PROTOCOLS.RPLC then
-- look for an associated session
local session = svsessions.find_plc_session(r_port)
-- reactor PLC packet
if session ~= nil then
if packet.type == RPLC_TYPES.LINK_REQ then
-- new device on this port? that's a collision
log.debug("PLC_LNK: request from existing connection received on " .. r_port .. ", responding with collision")
_send_plc_linking(packet.scada_frame.seq_num() + 1, r_port, { RPLC_LINKING.COLLISION })
else
-- pass the packet onto the session handler
session.in_queue.push_packet(packet)
end
else
local next_seq_id = packet.scada_frame.seq_num() + 1
-- unknown session, is this a linking request?
if packet.type == RPLC_TYPES.LINK_REQ then
if packet.length == 2 then
-- this is a linking request
local plc_id = svsessions.establish_plc_session(l_port, r_port, packet.data[1], packet.data[2])
if plc_id == false then
-- reactor already has a PLC assigned
log.debug("PLC_LNK: assignment collision with reactor " .. packet.data[1])
_send_plc_linking(next_seq_id, r_port, { RPLC_LINKING.COLLISION })
else
-- got an ID; assigned to a reactor successfully
println("connected to reactor " .. packet.data[1] .. " PLC (" .. packet.data[2] .. ") [:" .. r_port .. "]")
log.debug("PLC_LNK: allowed for device at " .. r_port)
_send_plc_linking(next_seq_id, r_port, { RPLC_LINKING.ALLOW })
end
else
log.debug("PLC_LNK: new linking packet length mismatch")
end
else
-- force a re-link
log.debug("PLC_LNK: no session but not a link, force relink")
_send_plc_linking(next_seq_id, r_port, { RPLC_LINKING.DENY })
end
end
elseif protocol == PROTOCOLS.SCADA_MGMT then
-- look for an associated session
local session = svsessions.find_device_session(r_port)
-- SCADA management packet
if session ~= nil then
-- pass the packet onto the session handler
session.in_queue.push_packet(packet)
elseif packet.type == SCADA_MGMT_TYPES.RTU_ADVERT then
if packet.length >= 1 then
-- this is an RTU advertisement for a new session
println("connected to RTU (" .. packet.data[1] .. ") [:" .. r_port .. "]")
svsessions.establish_rtu_session(l_port, r_port, packet.data)
log.debug("RTU_ADVERT: linked " .. r_port)
_send_remote_linked(packet.scada_frame.seq_num() + 1, r_port)
else
log.debug("RTU_ADVERT: advertisement packet empty")
end
else
-- any other packet should be session related, discard it
log.debug("discarding SCADA_MGMT packet without a known session")
end
else
log.debug("illegal packet type " .. protocol .. " on device listening channel")
end
-- coordinator listening channel
elseif l_port == self.coord_listen then
-- look for an associated session
local session = svsessions.find_coord_session(r_port)
if protocol == PROTOCOLS.SCADA_MGMT then
-- SCADA management packet
elseif protocol == PROTOCOLS.COORD_DATA then
-- coordinator packet
else
log.debug("illegal packet type " .. protocol .. " on coordinator listening channel")
end
else
log.error("received packet on unused channel " .. l_port, true)
end
end
end
return public
end end
return supervisor

460
supervisor/unit.lua Normal file
View File

@ -0,0 +1,460 @@
local types = require "scada-common.types"
local util = require "scada-common.util"
local unit = {}
local TRI_FAIL = types.TRI_FAIL
local DUMPING_MODE = types.DUMPING_MODE
local DT_KEYS = {
ReactorTemp = "RTP",
ReactorFuel = "RFL",
ReactorWaste = "RWS",
ReactorCCool = "RCC",
ReactorHCool = "RHC",
BoilerWater = "BWR",
BoilerSteam = "BST",
BoilerCCool = "BCC",
BoilerHCool = "BHC",
TurbineSteam = "TST"
}
-- create a new reactor unit
---@param for_reactor integer reactor unit number
---@param num_boilers integer number of boilers expected
---@param num_turbines integer number of turbines expected
unit.new = function (for_reactor, num_boilers, num_turbines)
local self = {
r_id = for_reactor,
plc_s = nil, ---@class plc_session
counts = { boilers = num_boilers, turbines = num_turbines },
turbines = {},
boilers = {},
redstone = {},
deltas = {},
db = {
---@class annunciator
annunciator = {
-- reactor
PLCOnline = false,
ReactorTrip = false,
ManualReactorTrip = false,
RCPTrip = false,
RCSFlowLow = false,
ReactorTempHigh = false,
ReactorHighDeltaT = false,
FuelInputRateLow = false,
WasteLineOcclusion = false,
HighStartupRate = false,
-- boiler
BoilerOnline = TRI_FAIL.OK,
HeatingRateLow = {},
BoilRateMismatch = false,
CoolantFeedMismatch = false,
-- turbine
TurbineOnline = TRI_FAIL.OK,
SteamFeedMismatch = false,
MaxWaterReturnFeed = false,
SteamDumpOpen = {},
TurbineOverSpeed = {},
TurbineTrip = {}
}
}
}
-- init boiler table fields
for _ = 1, self.num_boilers do
table.insert(self.db.annunciator.HeatingRateLow, false)
end
-- init turbine table fields
for _ = 1, self.num_turbines do
table.insert(self.db.annunciator.SteamDumpOpen, TRI_FAIL.OK)
table.insert(self.db.annunciator.TurbineOverSpeed, false)
table.insert(self.db.annunciator.TurbineTrip, false)
end
---@class reactor_unit
local public = {}
-- PRIVATE FUNCTIONS --
-- compute a change with respect to time of the given value
---@param key string value key
---@param value number value
local _compute_dt = function (key, value)
if self.deltas[key] then
local data = self.deltas[key]
data.dt = (value - data.last_v) / (util.time_s() - data.last_t)
data.last_v = value
data.last_t = util.time_s()
else
self.deltas[key] = {
last_t = util.time_s(),
last_v = value,
dt = 0.0
}
end
end
-- clear a delta
---@param key string value key
local _reset_dt = function (key)
self.deltas[key] = nil
end
-- get the delta t of a value
---@param key string value key
---@return number
local _get_dt = function (key)
if self.deltas[key] then
return self.deltas[key].dt
else
return 0.0
end
end
-- update all delta computations
local _dt__compute_all = function ()
if self.plc_s ~= nil then
local plc_db = self.plc_s.get_db()
-- @todo Meknaism 10.1+ will change fuel/waste to need _amnt
_compute_dt(DT_KEYS.ReactorTemp, plc_db.mek_status.temp)
_compute_dt(DT_KEYS.ReactorFuel, plc_db.mek_status.fuel)
_compute_dt(DT_KEYS.ReactorWaste, plc_db.mek_status.waste)
_compute_dt(DT_KEYS.ReactorCCool, plc_db.mek_status.ccool_amnt)
_compute_dt(DT_KEYS.ReactorHCool, plc_db.mek_status.hcool_amnt)
end
for i = 1, #self.boilers do
local boiler = self.boilers[i] ---@type unit_session
local db = boiler.get_db() ---@type boiler_session_db
-- @todo Meknaism 10.1+ will change water/steam to need .amount
_compute_dt(DT_KEYS.BoilerWater .. boiler.get_device_idx(), db.tanks.water)
_compute_dt(DT_KEYS.BoilerSteam .. boiler.get_device_idx(), db.tanks.steam)
_compute_dt(DT_KEYS.BoilerCCool .. boiler.get_device_idx(), db.tanks.ccool.amount)
_compute_dt(DT_KEYS.BoilerHCool .. boiler.get_device_idx(), db.tanks.hcool.amount)
end
for i = 1, #self.turbines do
local turbine = self.turbines[i] ---@type unit_session
local db = turbine.get_db() ---@type turbine_session_db
_compute_dt(DT_KEYS.TurbineSteam .. turbine.get_device_idx(), db.tanks.steam)
-- @todo Mekanism 10.1+ needed
-- _compute_dt(DT_KEYS.TurbinePower .. turbine.get_device_idx(), db.?)
end
end
-- update the annunciator
local _update_annunciator = function ()
-- update deltas
_dt__compute_all()
-------------
-- REACTOR --
-------------
-- check PLC status
self.db.annunciator.PLCOnline = (self.plc_s ~= nil) and (self.plc_s.open)
if self.plc_s ~= nil then
local plc_db = self.plc_s.get_db()
-- update annunciator
self.db.annunciator.ReactorTrip = plc_db.rps_tripped
self.db.annunciator.ManualReactorTrip = plc_db.rps_trip_cause == types.rps_status_t.manual
self.db.annunciator.RCPTrip = plc_db.rps_tripped and (plc_db.rps_status.ex_hcool or plc_db.rps_status.no_cool)
self.db.annunciator.RCSFlowLow = plc_db.mek_status.ccool_fill < 0.75 or plc_db.mek_status.hcool_fill > 0.25
self.db.annunciator.ReactorTempHigh = plc_db.mek_status.temp > 1000
self.db.annunciator.ReactorHighDeltaT = _get_dt(DT_KEYS.ReactorTemp) > 100
self.db.annunciator.FuelInputRateLow = _get_dt(DT_KEYS.ReactorFuel) < 0.0 or plc_db.mek_status.fuel_fill <= 0.01
self.db.annunciator.WasteLineOcclusion = _get_dt(DT_KEYS.ReactorWaste) > 0.0 or plc_db.mek_status.waste_fill >= 0.99
-- @todo this is dependent on setup, i.e. how much coolant is buffered and the turbine setup
self.db.annunciator.HighStartupRate = not plc_db.control_state and plc_db.mek_status.burn_rate > 40
end
-------------
-- BOILERS --
-------------
-- check boiler online status
local connected_boilers = #self.boilers
if connected_boilers == 0 and self.num_boilers > 0 then
self.db.annunciator.BoilerOnline = TRI_FAIL.FULL
elseif connected_boilers > 0 and connected_boilers ~= self.num_boilers then
self.db.annunciator.BoilerOnline = TRI_FAIL.PARTIAL
else
self.db.annunciator.BoilerOnline = TRI_FAIL.OK
end
-- compute aggregated statistics
local total_boil_rate = 0.0
local boiler_steam_dt_sum = 0.0
local boiler_water_dt_sum = 0.0
for i = 1, #self.boilers do
local boiler = self.boilers[i].get_db() ---@type boiler_session_db
total_boil_rate = total_boil_rate + boiler.state.boil_rate
boiler_steam_dt_sum = _get_dt(DT_KEYS.BoilerSteam .. self.boilers[i].get_device_idx())
boiler_water_dt_sum = _get_dt(DT_KEYS.BoilerWater .. self.boilers[i].get_device_idx())
end
-- check heating rate low
if self.plc_s ~= nil then
-- check for inactive boilers while reactor is active
for i = 1, #self.boilers do
local boiler = self.boilers[i] ---@type unit_session
local idx = boiler.get_device_idx()
local db = boiler.get_db() ---@type boiler_session_db
if self.plc_s.get_db().mek_status.status then
self.db.annunciator.HeatingRateLow[idx] = db.state.boil_rate == 0
else
self.db.annunciator.HeatingRateLow[idx] = false
end
end
-- check for rate mismatch
local expected_boil_rate = self.plc_s.get_db().mek_status.heating_rate / 10.0
self.db.annunciator.BoilRateMismatch = math.abs(expected_boil_rate - total_boil_rate) > 25.0
end
-- check coolant feed mismatch
local cfmismatch = false
for i = 1, #self.boilers do
local boiler = self.boilers[i] ---@type unit_session
local idx = boiler.get_device_idx()
local db = boiler.get_db() ---@type boiler_session_db
-- gaining heated coolant
cfmismatch = cfmismatch or _get_dt(DT_KEYS.BoilerHCool .. idx) > 0 or db.tanks.hcool_fill == 1
-- losing cooled coolant
cfmismatch = cfmismatch or _get_dt(DT_KEYS.BoilerCCool .. idx) < 0 or db.tanks.ccool_fill == 0
end
self.db.annunciator.CoolantFeedMismatch = cfmismatch
--------------
-- TURBINES --
--------------
-- check turbine online status
local connected_turbines = #self.turbines
if connected_turbines == 0 and self.num_turbines > 0 then
self.db.annunciator.TurbineOnline = TRI_FAIL.FULL
elseif connected_turbines > 0 and connected_turbines ~= self.num_turbines then
self.db.annunciator.TurbineOnline = TRI_FAIL.PARTIAL
else
self.db.annunciator.TurbineOnline = TRI_FAIL.OK
end
-- compute aggregated statistics
local total_flow_rate = 0
local total_input_rate = 0
local max_water_return_rate = 0
for i = 1, #self.turbines do
local turbine = self.turbines[i].get_db() ---@type turbine_session_db
total_flow_rate = total_flow_rate + turbine.state.flow_rate
total_input_rate = total_input_rate + turbine.state.steam_input_rate
max_water_return_rate = max_water_return_rate + turbine.build.max_water_output
end
-- check for steam feed mismatch and max return rate
local sfmismatch = math.abs(total_flow_rate - total_input_rate) > 10
sfmismatch = sfmismatch or boiler_steam_dt_sum > 0 or boiler_water_dt_sum < 0
self.db.annunciator.SteamFeedMismatch = sfmismatch
self.db.annunciator.MaxWaterReturnFeed = max_water_return_rate == total_flow_rate
-- check if steam dumps are open
for i = 1, #self.turbines do
local turbine = self.turbines[i] ---@type unit_session
local db = turbine.get_db() ---@type turbine_session_db
local idx = turbine.get_device_idx()
if db.state.dumping_mode == DUMPING_MODE.IDLE then
self.db.annunciator.SteamDumpOpen[idx] = TRI_FAIL.OK
elseif db.state.dumping_mode == DUMPING_MODE.DUMPING_EXCESS then
self.db.annunciator.SteamDumpOpen[idx] = TRI_FAIL.PARTIAL
else
self.db.annunciator.SteamDumpOpen[idx] = TRI_FAIL.FULL
end
end
-- check if turbines are at max speed but not keeping up
for i = 1, #self.turbines do
local turbine = self.turbines[i] ---@type unit_session
local db = turbine.get_db() ---@type turbine_session_db
local idx = turbine.get_device_idx()
self.db.annunciator.TurbineOverSpeed[idx] = (db.state.flow_rate == db.build.max_flow_rate) and (_get_dt(DT_KEYS.TurbineSteam .. idx) > 0)
end
--[[
Turbine Trip
a turbine trip is when the turbine stops, which means we are no longer receiving water and lose the ability to cool.
this can be identified by these conditions:
- the current flow rate is 0 mB/t and it should not be
- can initially catch this by detecting a 0 flow rate with a non-zero input rate, but eventually the steam will fill up
- can later identified by presence of steam in tank with a 0 flow rate
]]--
for i = 1, #self.turbines do
local turbine = self.turbines[i] ---@type unit_session
local db = turbine.get_db() ---@type turbine_session_db
local has_steam = db.state.steam_input_rate > 0 or db.tanks.steam_fill > 0.01
self.db.annunciator.TurbineTrip[turbine.get_device_idx()] = has_steam and db.state.flow_rate == 0
end
end
-- unlink disconnected units
---@param sessions table
local _unlink_disconnected_units = function (sessions)
util.filter_table(sessions, function (u) return u.is_connected() end)
end
-- PUBLIC FUNCTIONS --
-- link the PLC
---@param plc_session plc_session_struct
public.link_plc_session = function (plc_session)
self.plc_s = plc_session
-- reset deltas
_reset_dt(DT_KEYS.ReactorTemp)
_reset_dt(DT_KEYS.ReactorFuel)
_reset_dt(DT_KEYS.ReactorWaste)
_reset_dt(DT_KEYS.ReactorCCool)
_reset_dt(DT_KEYS.ReactorHCool)
end
-- link a turbine RTU session
---@param turbine unit_session
public.add_turbine = function (turbine)
if #self.turbines < self.num_turbines and turbine.get_device_idx() <= self.num_turbines then
table.insert(self.turbines, turbine)
-- reset deltas
_reset_dt(DT_KEYS.TurbineSteam .. turbine.get_device_idx())
_reset_dt(DT_KEYS.TurbinePower .. turbine.get_device_idx())
return true
else
return false
end
end
-- link a boiler RTU session
---@param boiler unit_session
public.add_boiler = function (boiler)
if #self.boilers < self.num_boilers and boiler.get_device_idx() <= self.num_boilers then
table.insert(self.boilers, boiler)
-- reset deltas
_reset_dt(DT_KEYS.BoilerWater .. boiler.get_device_idx())
_reset_dt(DT_KEYS.BoilerSteam .. boiler.get_device_idx())
_reset_dt(DT_KEYS.BoilerCCool .. boiler.get_device_idx())
_reset_dt(DT_KEYS.BoilerHCool .. boiler.get_device_idx())
return true
else
return false
end
end
-- link a redstone RTU capability
public.add_redstone = function (field, accessor)
-- ensure field exists
if self.redstone[field] == nil then
self.redstone[field] = {}
end
-- insert into list
table.insert(self.redstone[field], accessor)
end
-- update (iterate) this unit
public.update = function ()
-- unlink PLC if session was closed
if not self.plc_s.open then
self.plc_s = nil
end
-- unlink RTU unit sessions if they are closed
_unlink_disconnected_units(self.boilers)
_unlink_disconnected_units(self.turbines)
-- update annunciator logic
_update_annunciator()
end
-- get build properties of all machines
public.get_build = function ()
local build = {}
if self.plc_s ~= nil then
build.reactor = self.plc_s.get_struct()
end
build.boilers = {}
for i = 1, #self.boilers do
table.insert(build.boilers, self.boilers[i].get_db().build)
end
build.turbines = {}
for i = 1, #self.turbines do
table.insert(build.turbines, self.turbines[i].get_db().build)
end
return build
end
-- get reactor status
public.get_reactor_status = function ()
local status = {}
if self.plc_s ~= nil then
local reactor = self.plc_s
status.mek = reactor.get_status()
status.rps = reactor.get_rps()
status.general = reactor.get_general_status()
end
return status
end
-- get RTU statuses
public.get_rtu_statuses = function ()
local status = {}
-- status of boilers (including tanks)
status.boilers = {}
for i = 1, #self.boilers do
table.insert(status.boilers, {
state = self.boilers[i].get_db().state,
tanks = self.boilers[i].get_db().tanks,
})
end
-- status of turbines (including tanks)
status.turbines = {}
for i = 1, #self.turbines do
table.insert(status.turbines, {
state = self.turbines[i].get_db().state,
tanks = self.turbines[i].get_db().tanks,
})
end
return status
end
-- get the annunciator status
public.get_annunciator = function () return self.db.annunciator end
return public
end
return unit

236
test/modbustest.lua Normal file
View File

@ -0,0 +1,236 @@
require("/initenv").init_env()
local types = require("scada-common.types")
local util = require("scada-common.util")
local testutils = require("test.testutils")
local modbus = require("rtu.modbus")
local redstone_rtu = require("rtu.dev.redstone_rtu")
local rsio = require("scada-common.rsio")
local print = util.print
local println = util.println
local MODBUS_FCODE = types.MODBUS_FCODE
local MODBUS_EXCODE = types.MODBUS_EXCODE
println("starting redstone RTU and MODBUS tester")
println("")
-- RTU init --
print(">>> init redstone RTU: ")
local rs_rtu = redstone_rtu.new()
local di, c, ir, hr = rs_rtu.io_count()
assert(di == 0 and c == 0 and ir == 0 and hr == 0, "IOCOUNT_0")
rs_rtu.link_di("back", colors.black)
rs_rtu.link_di("back", colors.blue)
rs_rtu.link_do(rsio.IO.F_ALARM, "back", colors.red)
rs_rtu.link_do(rsio.IO.WASTE_AM, "back", colors.purple)
rs_rtu.link_ai("right")
rs_rtu.link_ao("left")
di, c, ir, hr = rs_rtu.io_count()
assert(di == 2, "IOCOUNT_DI")
assert(c == 2, "IOCOUNT_C")
assert(ir == 1, "IOCOUNT_IR")
assert(hr == 1, "IOCOUNT_HR")
println("OK")
-- MODBUS testing --
local rs_modbus = modbus.new(rs_rtu, false)
local mbt = testutils.modbus_tester(rs_modbus, MODBUS_FCODE.ERROR_FLAG)
-------------------------
--- CHECKING REQUESTS ---
-------------------------
println(">>> checking MODBUS requests:")
print("read c {0}: ")
mbt.pkt_set(MODBUS_FCODE.READ_COILS, {0})
mbt.test_error__check_request(MODBUS_EXCODE.NEG_ACKNOWLEDGE)
println("PASS")
print("99 {1,2}: ")
mbt.pkt_set(99, {1, 2})
mbt.test_error__check_request(MODBUS_EXCODE.ILLEGAL_FUNCTION)
println("PASS")
print("read c {1,2}: ")
mbt.pkt_set(MODBUS_FCODE.READ_COILS, {1, 2})
mbt.test_success__check_request(MODBUS_EXCODE.ACKNOWLEDGE)
println("PASS")
testutils.pause()
--------------------
--- BAD REQUESTS ---
--------------------
println(">>> trying bad requests:")
print("read di {1,10}: ")
mbt.pkt_set(MODBUS_FCODE.READ_DISCRETE_INPUTS, {1, 10})
mbt.test_error__handle_packet(MODBUS_EXCODE.ILLEGAL_DATA_ADDR)
println("PASS")
print("read di {5,1}: ")
mbt.pkt_set(MODBUS_FCODE.READ_DISCRETE_INPUTS, {5, 1})
mbt.test_error__handle_packet(MODBUS_EXCODE.ILLEGAL_DATA_ADDR)
println("PASS")
print("read di {1,0}: ")
mbt.pkt_set(MODBUS_FCODE.READ_DISCRETE_INPUTS, {1, 0})
mbt.test_error__handle_packet(MODBUS_EXCODE.ILLEGAL_DATA_ADDR)
println("PASS")
print("read c {5,1}: ")
mbt.pkt_set(MODBUS_FCODE.READ_COILS, {5, 1})
mbt.test_error__handle_packet(MODBUS_EXCODE.ILLEGAL_DATA_ADDR)
println("PASS")
print("read c {1,0}: ")
mbt.pkt_set(MODBUS_FCODE.READ_COILS, {1, 0})
mbt.test_error__handle_packet(MODBUS_EXCODE.ILLEGAL_DATA_ADDR)
println("PASS")
print("read ir {5,1}: ")
mbt.pkt_set(MODBUS_FCODE.READ_INPUT_REGS, {5, 1})
mbt.test_error__handle_packet(MODBUS_EXCODE.ILLEGAL_DATA_ADDR)
println("PASS")
print("read ir {1,0}: ")
mbt.pkt_set(MODBUS_FCODE.READ_INPUT_REGS, {1, 0})
mbt.test_error__handle_packet(MODBUS_EXCODE.ILLEGAL_DATA_ADDR)
println("PASS")
print("read hr {5,1}: ")
mbt.pkt_set(MODBUS_FCODE.READ_MUL_HOLD_REGS, {5, 1})
mbt.test_error__handle_packet(MODBUS_EXCODE.ILLEGAL_DATA_ADDR)
println("PASS")
print("write c {5,1}: ")
mbt.pkt_set(MODBUS_FCODE.WRITE_SINGLE_COIL, {5, 1})
mbt.test_error__handle_packet(MODBUS_EXCODE.ILLEGAL_DATA_ADDR)
println("PASS")
print("write mul c {5,1}: ")
mbt.pkt_set(MODBUS_FCODE.WRITE_SINGLE_COIL, {5, 1})
mbt.test_error__handle_packet(MODBUS_EXCODE.ILLEGAL_DATA_ADDR)
println("PASS")
print("write mul c {5,{1}}: ")
mbt.pkt_set(MODBUS_FCODE.WRITE_SINGLE_COIL, {5, {1}})
mbt.test_error__handle_packet(MODBUS_EXCODE.ILLEGAL_DATA_ADDR)
println("PASS")
print("write hr {5,1}: ")
mbt.pkt_set(MODBUS_FCODE.WRITE_SINGLE_HOLD_REG, {5, 1})
mbt.test_error__handle_packet(MODBUS_EXCODE.ILLEGAL_DATA_ADDR)
println("PASS")
print("write mul hr {5,{1}}: ")
mbt.pkt_set(MODBUS_FCODE.WRITE_SINGLE_HOLD_REG, {5, {1}})
mbt.test_error__handle_packet(MODBUS_EXCODE.ILLEGAL_DATA_ADDR)
println("PASS")
testutils.pause()
----------------------
--- READING INPUTS ---
----------------------
println(">>> reading inputs:")
print("read di {1,1}: ")
mbt.pkt_set(MODBUS_FCODE.READ_DISCRETE_INPUTS, {1, 1})
mbt.test_success__handle_packet()
print("read di {2,1}: ")
mbt.pkt_set(MODBUS_FCODE.READ_DISCRETE_INPUTS, {2, 1})
mbt.test_success__handle_packet()
print("read di {1,2}: ")
mbt.pkt_set(MODBUS_FCODE.READ_DISCRETE_INPUTS, {1, 2})
mbt.test_success__handle_packet()
print("read ir {1,1}: ")
mbt.pkt_set(MODBUS_FCODE.READ_INPUT_REGS, {1, 1})
mbt.test_success__handle_packet()
testutils.pause()
-----------------------
--- WRITING OUTPUTS ---
-----------------------
println(">>> writing outputs:")
print("write mul c {1,{LOW,LOW}}: ")
mbt.pkt_set(MODBUS_FCODE.WRITE_MUL_COILS, {1, {rsio.IO_LVL.LOW, rsio.IO_LVL.LOW}})
mbt.test_success__handle_packet()
testutils.pause()
print("write c {1,HIGH}: ")
mbt.pkt_set(MODBUS_FCODE.WRITE_SINGLE_COIL, {1, rsio.IO_LVL.HIGH})
mbt.test_success__handle_packet()
testutils.pause()
print("write c {2,HIGH}: ")
mbt.pkt_set(MODBUS_FCODE.WRITE_SINGLE_COIL, {2, rsio.IO_LVL.HIGH})
mbt.test_success__handle_packet()
testutils.pause()
print("write hr {1,7}: ")
mbt.pkt_set(MODBUS_FCODE.WRITE_SINGLE_HOLD_REG, {1, 7})
mbt.test_success__handle_packet()
testutils.pause()
print("write mul hr {1,{4}}: ")
mbt.pkt_set(MODBUS_FCODE.WRITE_MUL_HOLD_REGS, {1, {4}})
mbt.test_success__handle_packet()
println("PASS")
testutils.pause()
-----------------------
--- READING OUTPUTS ---
-----------------------
println(">>> reading outputs:")
print("read c {1,1}: ")
mbt.pkt_set(MODBUS_FCODE.READ_COILS, {1, 1})
mbt.test_success__handle_packet()
print("read c {2,1}: ")
mbt.pkt_set(MODBUS_FCODE.READ_COILS, {2, 1})
mbt.test_success__handle_packet()
print("read c {1,2}: ")
mbt.pkt_set(MODBUS_FCODE.READ_COILS, {1, 2})
mbt.test_success__handle_packet()
print("read hr {1,1}: ")
mbt.pkt_set(MODBUS_FCODE.READ_MUL_HOLD_REGS, {1, 1})
mbt.test_success__handle_packet()
println("PASS")
println("TEST COMPLETE")

145
test/rstest.lua Normal file
View File

@ -0,0 +1,145 @@
require("/initenv").init_env()
local rsio = require("scada-common.rsio")
local util = require("scada-common.util")
local testutils = require("test.testutils")
local print = util.print
local println = util.println
local IO = rsio.IO
local IO_LVL = rsio.IO_LVL
local IO_DIR = rsio.IO_DIR
local IO_MODE = rsio.IO_MODE
println("starting RSIO tester")
println("")
println(">>> checking valid channels:")
-- channel function tests
local cid = 0
local max_value = 1
for key, value in pairs(IO) do
if value > max_value then max_value = value end
cid = cid + 1
local c_name = rsio.to_string(value)
local io_mode = rsio.get_io_mode(value)
local mode = ""
if io_mode == IO_MODE.DIGITAL_IN then
mode = " (DIGITAL_IN)"
elseif io_mode == IO_MODE.DIGITAL_OUT then
mode = " (DIGITAL_OUT)"
elseif io_mode == IO_MODE.ANALOG_IN then
mode = " (ANALOG_IN)"
elseif io_mode == IO_MODE.ANALOG_OUT then
mode = " (ANALOG_OUT)"
else
error("unknown mode for channel " .. key)
end
assert(key == c_name, c_name .. " != " .. key .. ": " .. value .. mode)
println(c_name .. ": " .. value .. mode)
end
assert(max_value == cid, "RS_IO last IDx out-of-sync with count: " .. max_value .. " (count " .. cid .. ")")
testutils.pause()
println(">>> checking invalid channels:")
testutils.test_func("rsio.to_string", rsio.to_string, { -1, 100, false }, "")
testutils.test_func_nil("rsio.to_string", rsio.to_string, "")
testutils.test_func("rsio.get_io_mode", rsio.get_io_mode, { -1, 100, false }, IO_MODE.ANALOG_IN)
testutils.test_func_nil("rsio.get_io_mode", rsio.get_io_mode, IO_MODE.ANALOG_IN)
testutils.pause()
println(">>> checking validity checks:")
local ivc_t_list = { 0, -1, 100 }
testutils.test_func("rsio.is_valid_channel", rsio.is_valid_channel, ivc_t_list, false)
testutils.test_func_nil("rsio.is_valid_channel", rsio.is_valid_channel, false)
local ivs_t_list = rs.getSides()
testutils.test_func("rsio.is_valid_side", rsio.is_valid_side, ivs_t_list, true)
testutils.test_func("rsio.is_valid_side", rsio.is_valid_side, { "" }, false)
testutils.test_func_nil("rsio.is_valid_side", rsio.is_valid_side, false)
local ic_t_list = { colors.white, colors.purple, colors.blue, colors.cyan, colors.black }
testutils.test_func("rsio.is_color", rsio.is_color, ic_t_list, true)
testutils.test_func("rsio.is_color", rsio.is_color, { 0, 999999, colors.combine(colors.red, colors.blue, colors.black) }, false)
testutils.test_func_nil("rsio.is_color", rsio.is_color, false)
testutils.pause()
println(">>> checking channel-independent I/O wrappers:")
testutils.test_func("rsio.digital_read", rsio.digital_read, { true, false }, { IO_LVL.HIGH, IO_LVL.LOW })
print("rsio.analog_read(): ")
assert(rsio.analog_read(0, 0, 100) == 0, "RS_READ_0_100")
assert(rsio.analog_read(7.5, 0, 100) == 50, "RS_READ_7_5_100")
assert(rsio.analog_read(15, 0, 100) == 100, "RS_READ_15_100")
assert(rsio.analog_read(4, 0, 15) == 4, "RS_READ_4_15")
assert(rsio.analog_read(12, 0, 15) == 12, "RS_READ_12_15")
println("PASS")
print("rsio.analog_write(): ")
assert(rsio.analog_write(0, 0, 100) == 0, "RS_WRITE_0_100")
assert(rsio.analog_write(100, 0, 100) == 15, "RS_WRITE_100_100")
assert(rsio.analog_write(4, 0, 15) == 4, "RS_WRITE_4_15")
assert(rsio.analog_write(12, 0, 15) == 12, "RS_WRITE_12_15")
println("PASS")
testutils.pause()
println(">>> checking channel I/O:")
print("rsio.digital_is_active(...): ")
-- check input channels
assert(rsio.digital_is_active(IO.F_SCRAM, IO_LVL.LOW) == true, "IO_F_SCRAM_HIGH")
assert(rsio.digital_is_active(IO.F_SCRAM, IO_LVL.HIGH) == false, "IO_F_SCRAM_LOW")
assert(rsio.digital_is_active(IO.R_SCRAM, IO_LVL.LOW) == true, "IO_R_SCRAM_HIGH")
assert(rsio.digital_is_active(IO.R_SCRAM, IO_LVL.HIGH) == false, "IO_R_SCRAM_LOW")
assert(rsio.digital_is_active(IO.R_ENABLE, IO_LVL.LOW) == false, "IO_R_ENABLE_HIGH")
assert(rsio.digital_is_active(IO.R_ENABLE, IO_LVL.HIGH) == true, "IO_R_ENABLE_LOW")
-- non-inputs should always return LOW
assert(rsio.digital_is_active(IO.F_ALARM, IO_LVL.LOW) == false, "IO_OUT_READ_LOW")
assert(rsio.digital_is_active(IO.F_ALARM, IO_LVL.HIGH) == false, "IO_OUT_READ_HIGH")
println("PASS")
-- check output channels
print("rsio.digital_write(...): ")
-- check output channels
assert(rsio.digital_write(IO.F_ALARM, IO_LVL.LOW) == false, "IO_F_ALARM_FALSE")
assert(rsio.digital_write(IO.F_ALARM, IO_LVL.HIGH) == true, "IO_F_ALARM_TRUE")
assert(rsio.digital_write(IO.WASTE_PO, IO_LVL.HIGH) == false, "IO_WASTE_PO_FALSE")
assert(rsio.digital_write(IO.WASTE_PO, IO_LVL.LOW) == true, "IO_WASTE_PO_TRUE")
assert(rsio.digital_write(IO.WASTE_PU, IO_LVL.HIGH) == false, "IO_WASTE_PU_FALSE")
assert(rsio.digital_write(IO.WASTE_PU, IO_LVL.LOW) == true, "IO_WASTE_PU_TRUE")
assert(rsio.digital_write(IO.WASTE_AM, IO_LVL.HIGH) == false, "IO_WASTE_AM_FALSE")
assert(rsio.digital_write(IO.WASTE_AM, IO_LVL.LOW) == true, "IO_WASTE_AM_TRUE")
-- check all reactor output channels (all are active high)
for i = IO.R_ALARM, (IO.R_PLC_TIMEOUT - IO.R_ALARM + 1) do
assert(rsio.to_string(i) ~= "", "REACTOR_IO_BAD_CHANNEL")
assert(rsio.digital_write(i, IO_LVL.LOW) == false, "IO_" .. rsio.to_string(i) .. "_FALSE")
assert(rsio.digital_write(i, IO_LVL.HIGH) == true, "IO_" .. rsio.to_string(i) .. "_TRUE")
end
-- non-outputs should always return false
assert(rsio.digital_write(IO.F_SCRAM, IO_LVL.LOW) == false, "IO_IN_WRITE_LOW")
assert(rsio.digital_write(IO.F_SCRAM, IO_LVL.LOW) == false, "IO_IN_WRITE_HIGH")
println("PASS")
println("TEST COMPLETE")

122
test/testutils.lua Normal file
View File

@ -0,0 +1,122 @@
local util = require("scada-common.util")
local print = util.print
local println = util.println
local testutils = {}
-- test a function
---@param name string function name
---@param f function function
---@param values table input values, one per function call
---@param results any table of values or a single value for all tests
function testutils.test_func(name, f, values, results)
-- if only one value was given, use that for all checks
if type(results) ~= "table" then
local _r = {}
for _ = 1, #values do
table.insert(_r, results)
end
results = _r
end
assert(#values == #results, "test_func(" .. name .. ") #values ~= #results")
for i = 1, #values do
local check = values[i]
local expect = results[i]
print(name .. "(" .. util.strval(check) .. ") => ")
assert(f(check) == expect, "FAIL")
println("PASS")
end
end
-- test a function with nil as a parameter
---@param name string function name
---@param f function function
---@param result any expected result
function testutils.test_func_nil(name, f, result)
print(name .. "(nil) => ")
assert(f(nil) == result, "FAIL")
println("PASS")
end
-- get something as a string
---@param result any
---@return string
function testutils.stringify(result)
return textutils.serialize(result, { allow_repetitions = true, compact = true })
end
-- pause for 1 second, or the provided seconds
---@param seconds? number
function testutils.pause(seconds)
seconds = seconds or 1.0
---@diagnostic disable-next-line: undefined-field
os.sleep(seconds)
end
-- create a new MODBUS tester
---@param modbus modbus modbus object
---@param error_flag MODBUS_FCODE MODBUS_FCODE.ERROR_FLAG
function testutils.modbus_tester(modbus, error_flag)
-- test packet
---@type modbus_frame
local packet = {
txn_id = 0,
length = 0,
unit_id = 0,
func_code = 0,
data = {},
scada_frame = nil
}
---@class modbus_tester
local public = {}
-- set the packet function and data for the next test
---@param func MODBUS_FCODE function code
---@param data table
function public.pkt_set(func, data)
packet.length = #data
packet.data = data
packet.func_code = func
end
-- check the current packet, expecting an error
---@param excode MODBUS_EXCODE exception code to expect
function public.test_error__check_request(excode)
local rcode, reply = modbus.check_request(packet)
assert(rcode == false, "CHECK_NOT_FAIL")
assert(reply.get().func_code == bit.bor(packet.func_code, error_flag), "WRONG_FCODE")
assert(reply.get().data[1] == excode, "EXCODE_MISMATCH")
end
-- test the current packet, expecting an error
---@param excode MODBUS_EXCODE exception code to expect
function public.test_error__handle_packet(excode)
local rcode, reply = modbus.handle_packet(packet)
assert(rcode == false, "CHECK_NOT_FAIL")
assert(reply.get().func_code == bit.bor(packet.func_code, error_flag), "WRONG_FCODE")
assert(reply.get().data[1] == excode, "EXCODE_MISMATCH")
end
-- check the current packet, expecting success
---@param excode MODBUS_EXCODE exception code to expect
function public.test_success__check_request(excode)
local rcode, reply = modbus.check_request(packet)
assert(rcode, "CHECK_NOT_OK")
assert(reply.get().data[1] == excode, "EXCODE_MISMATCH")
end
-- test the current packet, expecting success
function public.test_success__handle_packet()
local rcode, reply = modbus.handle_packet(packet)
assert(rcode, "CHECK_NOT_OK")
println(testutils.stringify(reply.get().data))
end
return public
end
return testutils

View File

@ -0,0 +1,68 @@
require("/initenv").init_env()
local log = require("scada-common.log")
local ppm = require("scada-common.ppm")
local types = require("scada-common.types")
local util = require("scada-common.util")
local testutils = require("test.testutils")
local modbus = require("rtu.modbus")
local turbine_rtu = require("rtu.dev.turbine_rtu")
local print = util.print
local println = util.println
local MODBUS_FCODE = types.MODBUS_FCODE
println("starting turbine RTU MODBUS tester")
println("note: use rs_modbustest to fully test RTU/MODBUS")
println(" this only tests a turbine/parallel read")
println("")
-- RTU init --
log.init("/log.txt", log.MODE.NEW)
print(">>> init turbine RTU: ")
ppm.mount_all()
local dev = ppm.get_device("turbine")
assert(dev ~= nil, "NO_TURBINE")
local t_rtu = turbine_rtu.new(dev)
local di, c, ir, hr = t_rtu.io_count()
assert(di == 0, "IOCOUNT_DI")
assert(c == 0, "IOCOUNT_C")
assert(ir == 16, "IOCOUNT_IR")
assert(hr == 0, "IOCOUNT_HR")
println("OK")
local t_modbus = modbus.new(t_rtu, true)
local mbt = testutils.modbus_tester(t_modbus, MODBUS_FCODE.ERROR_FLAG)
----------------------
--- READING INPUTS ---
----------------------
println(">>> reading inputs:")
print("read ir {1,1}: ")
mbt.pkt_set(MODBUS_FCODE.READ_INPUT_REGS, {1, 1})
mbt.test_success__handle_packet()
print("read ir {2,1}: ")
mbt.pkt_set(MODBUS_FCODE.READ_INPUT_REGS, {2, 1})
mbt.test_success__handle_packet()
print("read ir {1,16}: ")
mbt.pkt_set(MODBUS_FCODE.READ_INPUT_REGS, {1, 16})
mbt.test_success__handle_packet()
println("PASS")
println("TEST COMPLETE")