diff --git a/.vscode/settings.json b/.vscode/settings.json
new file mode 100644
index 0000000..d2812b6
--- /dev/null
+++ b/.vscode/settings.json
@@ -0,0 +1,13 @@
+{
+ "Lua.diagnostics.globals": [
+ "term",
+ "fs",
+ "peripheral",
+ "rs",
+ "bit",
+ "parallel",
+ "colors",
+ "textutils",
+ "shell"
+ ]
+}
diff --git a/LICENSE b/LICENSE
index f288702..7cd46cf 100644
--- a/LICENSE
+++ b/LICENSE
@@ -1,674 +1,21 @@
- GNU GENERAL PUBLIC LICENSE
- Version 3, 29 June 2007
-
- Copyright (C) 2007 Free Software Foundation, Inc.
- Everyone is permitted to copy and distribute verbatim copies
- of this license document, but changing it is not allowed.
-
- Preamble
-
- The GNU General Public License is a free, copyleft license for
-software and other kinds of works.
-
- The licenses for most software and other practical works are designed
-to take away your freedom to share and change the works. By contrast,
-the GNU General Public License is intended to guarantee your freedom to
-share and change all versions of a program--to make sure it remains free
-software for all its users. We, the Free Software Foundation, use the
-GNU General Public License for most of our software; it applies also to
-any other work released this way by its authors. You can apply it to
-your programs, too.
-
- When we speak of free software, we are referring to freedom, not
-price. Our General Public Licenses are designed to make sure that you
-have the freedom to distribute copies of free software (and charge for
-them if you wish), that you receive source code or can get it if you
-want it, that you can change the software or use pieces of it in new
-free programs, and that you know you can do these things.
-
- To protect your rights, we need to prevent others from denying you
-these rights or asking you to surrender the rights. Therefore, you have
-certain responsibilities if you distribute copies of the software, or if
-you modify it: responsibilities to respect the freedom of others.
-
- For example, if you distribute copies of such a program, whether
-gratis or for a fee, you must pass on to the recipients the same
-freedoms that you received. You must make sure that they, too, receive
-or can get the source code. And you must show them these terms so they
-know their rights.
-
- Developers that use the GNU GPL protect your rights with two steps:
-(1) assert copyright on the software, and (2) offer you this License
-giving you legal permission to copy, distribute and/or modify it.
-
- For the developers' and authors' protection, the GPL clearly explains
-that there is no warranty for this free software. For both users' and
-authors' sake, the GPL requires that modified versions be marked as
-changed, so that their problems will not be attributed erroneously to
-authors of previous versions.
-
- Some devices are designed to deny users access to install or run
-modified versions of the software inside them, although the manufacturer
-can do so. This is fundamentally incompatible with the aim of
-protecting users' freedom to change the software. The systematic
-pattern of such abuse occurs in the area of products for individuals to
-use, which is precisely where it is most unacceptable. Therefore, we
-have designed this version of the GPL to prohibit the practice for those
-products. If such problems arise substantially in other domains, we
-stand ready to extend this provision to those domains in future versions
-of the GPL, as needed to protect the freedom of users.
-
- Finally, every program is threatened constantly by software patents.
-States should not allow patents to restrict development and use of
-software on general-purpose computers, but in those that do, we wish to
-avoid the special danger that patents applied to a free program could
-make it effectively proprietary. To prevent this, the GPL assures that
-patents cannot be used to render the program non-free.
-
- The precise terms and conditions for copying, distribution and
-modification follow.
-
- TERMS AND CONDITIONS
-
- 0. Definitions.
-
- "This License" refers to version 3 of the GNU General Public License.
-
- "Copyright" also means copyright-like laws that apply to other kinds of
-works, such as semiconductor masks.
-
- "The Program" refers to any copyrightable work licensed under this
-License. Each licensee is addressed as "you". "Licensees" and
-"recipients" may be individuals or organizations.
-
- To "modify" a work means to copy from or adapt all or part of the work
-in a fashion requiring copyright permission, other than the making of an
-exact copy. The resulting work is called a "modified version" of the
-earlier work or a work "based on" the earlier work.
-
- A "covered work" means either the unmodified Program or a work based
-on the Program.
-
- To "propagate" a work means to do anything with it that, without
-permission, would make you directly or secondarily liable for
-infringement under applicable copyright law, except executing it on a
-computer or modifying a private copy. Propagation includes copying,
-distribution (with or without modification), making available to the
-public, and in some countries other activities as well.
-
- To "convey" a work means any kind of propagation that enables other
-parties to make or receive copies. Mere interaction with a user through
-a computer network, with no transfer of a copy, is not conveying.
-
- An interactive user interface displays "Appropriate Legal Notices"
-to the extent that it includes a convenient and prominently visible
-feature that (1) displays an appropriate copyright notice, and (2)
-tells the user that there is no warranty for the work (except to the
-extent that warranties are provided), that licensees may convey the
-work under this License, and how to view a copy of this License. If
-the interface presents a list of user commands or options, such as a
-menu, a prominent item in the list meets this criterion.
-
- 1. Source Code.
-
- The "source code" for a work means the preferred form of the work
-for making modifications to it. "Object code" means any non-source
-form of a work.
-
- A "Standard Interface" means an interface that either is an official
-standard defined by a recognized standards body, or, in the case of
-interfaces specified for a particular programming language, one that
-is widely used among developers working in that language.
-
- The "System Libraries" of an executable work include anything, other
-than the work as a whole, that (a) is included in the normal form of
-packaging a Major Component, but which is not part of that Major
-Component, and (b) serves only to enable use of the work with that
-Major Component, or to implement a Standard Interface for which an
-implementation is available to the public in source code form. A
-"Major Component", in this context, means a major essential component
-(kernel, window system, and so on) of the specific operating system
-(if any) on which the executable work runs, or a compiler used to
-produce the work, or an object code interpreter used to run it.
-
- The "Corresponding Source" for a work in object code form means all
-the source code needed to generate, install, and (for an executable
-work) run the object code and to modify the work, including scripts to
-control those activities. However, it does not include the work's
-System Libraries, or general-purpose tools or generally available free
-programs which are used unmodified in performing those activities but
-which are not part of the work. For example, Corresponding Source
-includes interface definition files associated with source files for
-the work, and the source code for shared libraries and dynamically
-linked subprograms that the work is specifically designed to require,
-such as by intimate data communication or control flow between those
-subprograms and other parts of the work.
-
- The Corresponding Source need not include anything that users
-can regenerate automatically from other parts of the Corresponding
-Source.
-
- The Corresponding Source for a work in source code form is that
-same work.
-
- 2. Basic Permissions.
-
- All rights granted under this License are granted for the term of
-copyright on the Program, and are irrevocable provided the stated
-conditions are met. This License explicitly affirms your unlimited
-permission to run the unmodified Program. The output from running a
-covered work is covered by this License only if the output, given its
-content, constitutes a covered work. This License acknowledges your
-rights of fair use or other equivalent, as provided by copyright law.
-
- You may make, run and propagate covered works that you do not
-convey, without conditions so long as your license otherwise remains
-in force. You may convey covered works to others for the sole purpose
-of having them make modifications exclusively for you, or provide you
-with facilities for running those works, provided that you comply with
-the terms of this License in conveying all material for which you do
-not control copyright. Those thus making or running the covered works
-for you must do so exclusively on your behalf, under your direction
-and control, on terms that prohibit them from making any copies of
-your copyrighted material outside their relationship with you.
-
- Conveying under any other circumstances is permitted solely under
-the conditions stated below. Sublicensing is not allowed; section 10
-makes it unnecessary.
-
- 3. Protecting Users' Legal Rights From Anti-Circumvention Law.
-
- No covered work shall be deemed part of an effective technological
-measure under any applicable law fulfilling obligations under article
-11 of the WIPO copyright treaty adopted on 20 December 1996, or
-similar laws prohibiting or restricting circumvention of such
-measures.
-
- When you convey a covered work, you waive any legal power to forbid
-circumvention of technological measures to the extent such circumvention
-is effected by exercising rights under this License with respect to
-the covered work, and you disclaim any intention to limit operation or
-modification of the work as a means of enforcing, against the work's
-users, your or third parties' legal rights to forbid circumvention of
-technological measures.
-
- 4. Conveying Verbatim Copies.
-
- You may convey verbatim copies of the Program's source code as you
-receive it, in any medium, provided that you conspicuously and
-appropriately publish on each copy an appropriate copyright notice;
-keep intact all notices stating that this License and any
-non-permissive terms added in accord with section 7 apply to the code;
-keep intact all notices of the absence of any warranty; and give all
-recipients a copy of this License along with the Program.
-
- You may charge any price or no price for each copy that you convey,
-and you may offer support or warranty protection for a fee.
-
- 5. Conveying Modified Source Versions.
-
- You may convey a work based on the Program, or the modifications to
-produce it from the Program, in the form of source code under the
-terms of section 4, provided that you also meet all of these conditions:
-
- a) The work must carry prominent notices stating that you modified
- it, and giving a relevant date.
-
- b) The work must carry prominent notices stating that it is
- released under this License and any conditions added under section
- 7. This requirement modifies the requirement in section 4 to
- "keep intact all notices".
-
- c) You must license the entire work, as a whole, under this
- License to anyone who comes into possession of a copy. This
- License will therefore apply, along with any applicable section 7
- additional terms, to the whole of the work, and all its parts,
- regardless of how they are packaged. This License gives no
- permission to license the work in any other way, but it does not
- invalidate such permission if you have separately received it.
-
- d) If the work has interactive user interfaces, each must display
- Appropriate Legal Notices; however, if the Program has interactive
- interfaces that do not display Appropriate Legal Notices, your
- work need not make them do so.
-
- A compilation of a covered work with other separate and independent
-works, which are not by their nature extensions of the covered work,
-and which are not combined with it such as to form a larger program,
-in or on a volume of a storage or distribution medium, is called an
-"aggregate" if the compilation and its resulting copyright are not
-used to limit the access or legal rights of the compilation's users
-beyond what the individual works permit. Inclusion of a covered work
-in an aggregate does not cause this License to apply to the other
-parts of the aggregate.
-
- 6. Conveying Non-Source Forms.
-
- You may convey a covered work in object code form under the terms
-of sections 4 and 5, provided that you also convey the
-machine-readable Corresponding Source under the terms of this License,
-in one of these ways:
-
- a) Convey the object code in, or embodied in, a physical product
- (including a physical distribution medium), accompanied by the
- Corresponding Source fixed on a durable physical medium
- customarily used for software interchange.
-
- b) Convey the object code in, or embodied in, a physical product
- (including a physical distribution medium), accompanied by a
- written offer, valid for at least three years and valid for as
- long as you offer spare parts or customer support for that product
- model, to give anyone who possesses the object code either (1) a
- copy of the Corresponding Source for all the software in the
- product that is covered by this License, on a durable physical
- medium customarily used for software interchange, for a price no
- more than your reasonable cost of physically performing this
- conveying of source, or (2) access to copy the
- Corresponding Source from a network server at no charge.
-
- c) Convey individual copies of the object code with a copy of the
- written offer to provide the Corresponding Source. This
- alternative is allowed only occasionally and noncommercially, and
- only if you received the object code with such an offer, in accord
- with subsection 6b.
-
- d) Convey the object code by offering access from a designated
- place (gratis or for a charge), and offer equivalent access to the
- Corresponding Source in the same way through the same place at no
- further charge. You need not require recipients to copy the
- Corresponding Source along with the object code. If the place to
- copy the object code is a network server, the Corresponding Source
- may be on a different server (operated by you or a third party)
- that supports equivalent copying facilities, provided you maintain
- clear directions next to the object code saying where to find the
- Corresponding Source. Regardless of what server hosts the
- Corresponding Source, you remain obligated to ensure that it is
- available for as long as needed to satisfy these requirements.
-
- e) Convey the object code using peer-to-peer transmission, provided
- you inform other peers where the object code and Corresponding
- Source of the work are being offered to the general public at no
- charge under subsection 6d.
-
- A separable portion of the object code, whose source code is excluded
-from the Corresponding Source as a System Library, need not be
-included in conveying the object code work.
-
- A "User Product" is either (1) a "consumer product", which means any
-tangible personal property which is normally used for personal, family,
-or household purposes, or (2) anything designed or sold for incorporation
-into a dwelling. In determining whether a product is a consumer product,
-doubtful cases shall be resolved in favor of coverage. For a particular
-product received by a particular user, "normally used" refers to a
-typical or common use of that class of product, regardless of the status
-of the particular user or of the way in which the particular user
-actually uses, or expects or is expected to use, the product. A product
-is a consumer product regardless of whether the product has substantial
-commercial, industrial or non-consumer uses, unless such uses represent
-the only significant mode of use of the product.
-
- "Installation Information" for a User Product means any methods,
-procedures, authorization keys, or other information required to install
-and execute modified versions of a covered work in that User Product from
-a modified version of its Corresponding Source. The information must
-suffice to ensure that the continued functioning of the modified object
-code is in no case prevented or interfered with solely because
-modification has been made.
-
- If you convey an object code work under this section in, or with, or
-specifically for use in, a User Product, and the conveying occurs as
-part of a transaction in which the right of possession and use of the
-User Product is transferred to the recipient in perpetuity or for a
-fixed term (regardless of how the transaction is characterized), the
-Corresponding Source conveyed under this section must be accompanied
-by the Installation Information. But this requirement does not apply
-if neither you nor any third party retains the ability to install
-modified object code on the User Product (for example, the work has
-been installed in ROM).
-
- The requirement to provide Installation Information does not include a
-requirement to continue to provide support service, warranty, or updates
-for a work that has been modified or installed by the recipient, or for
-the User Product in which it has been modified or installed. Access to a
-network may be denied when the modification itself materially and
-adversely affects the operation of the network or violates the rules and
-protocols for communication across the network.
-
- Corresponding Source conveyed, and Installation Information provided,
-in accord with this section must be in a format that is publicly
-documented (and with an implementation available to the public in
-source code form), and must require no special password or key for
-unpacking, reading or copying.
-
- 7. Additional Terms.
-
- "Additional permissions" are terms that supplement the terms of this
-License by making exceptions from one or more of its conditions.
-Additional permissions that are applicable to the entire Program shall
-be treated as though they were included in this License, to the extent
-that they are valid under applicable law. If additional permissions
-apply only to part of the Program, that part may be used separately
-under those permissions, but the entire Program remains governed by
-this License without regard to the additional permissions.
-
- When you convey a copy of a covered work, you may at your option
-remove any additional permissions from that copy, or from any part of
-it. (Additional permissions may be written to require their own
-removal in certain cases when you modify the work.) You may place
-additional permissions on material, added by you to a covered work,
-for which you have or can give appropriate copyright permission.
-
- Notwithstanding any other provision of this License, for material you
-add to a covered work, you may (if authorized by the copyright holders of
-that material) supplement the terms of this License with terms:
-
- a) Disclaiming warranty or limiting liability differently from the
- terms of sections 15 and 16 of this License; or
-
- b) Requiring preservation of specified reasonable legal notices or
- author attributions in that material or in the Appropriate Legal
- Notices displayed by works containing it; or
-
- c) Prohibiting misrepresentation of the origin of that material, or
- requiring that modified versions of such material be marked in
- reasonable ways as different from the original version; or
-
- d) Limiting the use for publicity purposes of names of licensors or
- authors of the material; or
-
- e) Declining to grant rights under trademark law for use of some
- trade names, trademarks, or service marks; or
-
- f) Requiring indemnification of licensors and authors of that
- material by anyone who conveys the material (or modified versions of
- it) with contractual assumptions of liability to the recipient, for
- any liability that these contractual assumptions directly impose on
- those licensors and authors.
-
- All other non-permissive additional terms are considered "further
-restrictions" within the meaning of section 10. If the Program as you
-received it, or any part of it, contains a notice stating that it is
-governed by this License along with a term that is a further
-restriction, you may remove that term. If a license document contains
-a further restriction but permits relicensing or conveying under this
-License, you may add to a covered work material governed by the terms
-of that license document, provided that the further restriction does
-not survive such relicensing or conveying.
-
- If you add terms to a covered work in accord with this section, you
-must place, in the relevant source files, a statement of the
-additional terms that apply to those files, or a notice indicating
-where to find the applicable terms.
-
- Additional terms, permissive or non-permissive, may be stated in the
-form of a separately written license, or stated as exceptions;
-the above requirements apply either way.
-
- 8. Termination.
-
- You may not propagate or modify a covered work except as expressly
-provided under this License. Any attempt otherwise to propagate or
-modify it is void, and will automatically terminate your rights under
-this License (including any patent licenses granted under the third
-paragraph of section 11).
-
- However, if you cease all violation of this License, then your
-license from a particular copyright holder is reinstated (a)
-provisionally, unless and until the copyright holder explicitly and
-finally terminates your license, and (b) permanently, if the copyright
-holder fails to notify you of the violation by some reasonable means
-prior to 60 days after the cessation.
-
- Moreover, your license from a particular copyright holder is
-reinstated permanently if the copyright holder notifies you of the
-violation by some reasonable means, this is the first time you have
-received notice of violation of this License (for any work) from that
-copyright holder, and you cure the violation prior to 30 days after
-your receipt of the notice.
-
- Termination of your rights under this section does not terminate the
-licenses of parties who have received copies or rights from you under
-this License. If your rights have been terminated and not permanently
-reinstated, you do not qualify to receive new licenses for the same
-material under section 10.
-
- 9. Acceptance Not Required for Having Copies.
-
- You are not required to accept this License in order to receive or
-run a copy of the Program. Ancillary propagation of a covered work
-occurring solely as a consequence of using peer-to-peer transmission
-to receive a copy likewise does not require acceptance. However,
-nothing other than this License grants you permission to propagate or
-modify any covered work. These actions infringe copyright if you do
-not accept this License. Therefore, by modifying or propagating a
-covered work, you indicate your acceptance of this License to do so.
-
- 10. Automatic Licensing of Downstream Recipients.
-
- Each time you convey a covered work, the recipient automatically
-receives a license from the original licensors, to run, modify and
-propagate that work, subject to this License. You are not responsible
-for enforcing compliance by third parties with this License.
-
- An "entity transaction" is a transaction transferring control of an
-organization, or substantially all assets of one, or subdividing an
-organization, or merging organizations. If propagation of a covered
-work results from an entity transaction, each party to that
-transaction who receives a copy of the work also receives whatever
-licenses to the work the party's predecessor in interest had or could
-give under the previous paragraph, plus a right to possession of the
-Corresponding Source of the work from the predecessor in interest, if
-the predecessor has it or can get it with reasonable efforts.
-
- You may not impose any further restrictions on the exercise of the
-rights granted or affirmed under this License. For example, you may
-not impose a license fee, royalty, or other charge for exercise of
-rights granted under this License, and you may not initiate litigation
-(including a cross-claim or counterclaim in a lawsuit) alleging that
-any patent claim is infringed by making, using, selling, offering for
-sale, or importing the Program or any portion of it.
-
- 11. Patents.
-
- A "contributor" is a copyright holder who authorizes use under this
-License of the Program or a work on which the Program is based. The
-work thus licensed is called the contributor's "contributor version".
-
- A contributor's "essential patent claims" are all patent claims
-owned or controlled by the contributor, whether already acquired or
-hereafter acquired, that would be infringed by some manner, permitted
-by this License, of making, using, or selling its contributor version,
-but do not include claims that would be infringed only as a
-consequence of further modification of the contributor version. For
-purposes of this definition, "control" includes the right to grant
-patent sublicenses in a manner consistent with the requirements of
-this License.
-
- Each contributor grants you a non-exclusive, worldwide, royalty-free
-patent license under the contributor's essential patent claims, to
-make, use, sell, offer for sale, import and otherwise run, modify and
-propagate the contents of its contributor version.
-
- In the following three paragraphs, a "patent license" is any express
-agreement or commitment, however denominated, not to enforce a patent
-(such as an express permission to practice a patent or covenant not to
-sue for patent infringement). To "grant" such a patent license to a
-party means to make such an agreement or commitment not to enforce a
-patent against the party.
-
- If you convey a covered work, knowingly relying on a patent license,
-and the Corresponding Source of the work is not available for anyone
-to copy, free of charge and under the terms of this License, through a
-publicly available network server or other readily accessible means,
-then you must either (1) cause the Corresponding Source to be so
-available, or (2) arrange to deprive yourself of the benefit of the
-patent license for this particular work, or (3) arrange, in a manner
-consistent with the requirements of this License, to extend the patent
-license to downstream recipients. "Knowingly relying" means you have
-actual knowledge that, but for the patent license, your conveying the
-covered work in a country, or your recipient's use of the covered work
-in a country, would infringe one or more identifiable patents in that
-country that you have reason to believe are valid.
-
- If, pursuant to or in connection with a single transaction or
-arrangement, you convey, or propagate by procuring conveyance of, a
-covered work, and grant a patent license to some of the parties
-receiving the covered work authorizing them to use, propagate, modify
-or convey a specific copy of the covered work, then the patent license
-you grant is automatically extended to all recipients of the covered
-work and works based on it.
-
- A patent license is "discriminatory" if it does not include within
-the scope of its coverage, prohibits the exercise of, or is
-conditioned on the non-exercise of one or more of the rights that are
-specifically granted under this License. You may not convey a covered
-work if you are a party to an arrangement with a third party that is
-in the business of distributing software, under which you make payment
-to the third party based on the extent of your activity of conveying
-the work, and under which the third party grants, to any of the
-parties who would receive the covered work from you, a discriminatory
-patent license (a) in connection with copies of the covered work
-conveyed by you (or copies made from those copies), or (b) primarily
-for and in connection with specific products or compilations that
-contain the covered work, unless you entered into that arrangement,
-or that patent license was granted, prior to 28 March 2007.
-
- Nothing in this License shall be construed as excluding or limiting
-any implied license or other defenses to infringement that may
-otherwise be available to you under applicable patent law.
-
- 12. No Surrender of Others' Freedom.
-
- If conditions are imposed on you (whether by court order, agreement or
-otherwise) that contradict the conditions of this License, they do not
-excuse you from the conditions of this License. If you cannot convey a
-covered work so as to satisfy simultaneously your obligations under this
-License and any other pertinent obligations, then as a consequence you may
-not convey it at all. For example, if you agree to terms that obligate you
-to collect a royalty for further conveying from those to whom you convey
-the Program, the only way you could satisfy both those terms and this
-License would be to refrain entirely from conveying the Program.
-
- 13. Use with the GNU Affero General Public License.
-
- Notwithstanding any other provision of this License, you have
-permission to link or combine any covered work with a work licensed
-under version 3 of the GNU Affero General Public License into a single
-combined work, and to convey the resulting work. The terms of this
-License will continue to apply to the part which is the covered work,
-but the special requirements of the GNU Affero General Public License,
-section 13, concerning interaction through a network will apply to the
-combination as such.
-
- 14. Revised Versions of this License.
-
- The Free Software Foundation may publish revised and/or new versions of
-the GNU General Public License from time to time. Such new versions will
-be similar in spirit to the present version, but may differ in detail to
-address new problems or concerns.
-
- Each version is given a distinguishing version number. If the
-Program specifies that a certain numbered version of the GNU General
-Public License "or any later version" applies to it, you have the
-option of following the terms and conditions either of that numbered
-version or of any later version published by the Free Software
-Foundation. If the Program does not specify a version number of the
-GNU General Public License, you may choose any version ever published
-by the Free Software Foundation.
-
- If the Program specifies that a proxy can decide which future
-versions of the GNU General Public License can be used, that proxy's
-public statement of acceptance of a version permanently authorizes you
-to choose that version for the Program.
-
- Later license versions may give you additional or different
-permissions. However, no additional obligations are imposed on any
-author or copyright holder as a result of your choosing to follow a
-later version.
-
- 15. Disclaimer of Warranty.
-
- THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
-APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
-HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
-OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
-THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
-PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
-IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
-ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
-
- 16. Limitation of Liability.
-
- IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
-WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
-THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
-GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
-USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
-DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
-PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
-EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
-SUCH DAMAGES.
-
- 17. Interpretation of Sections 15 and 16.
-
- If the disclaimer of warranty and limitation of liability provided
-above cannot be given local legal effect according to their terms,
-reviewing courts shall apply local law that most closely approximates
-an absolute waiver of all civil liability in connection with the
-Program, unless a warranty or assumption of liability accompanies a
-copy of the Program in return for a fee.
-
- END OF TERMS AND CONDITIONS
-
- How to Apply These Terms to Your New Programs
-
- If you develop a new program, and you want it to be of the greatest
-possible use to the public, the best way to achieve this is to make it
-free software which everyone can redistribute and change under these terms.
-
- To do so, attach the following notices to the program. It is safest
-to attach them to the start of each source file to most effectively
-state the exclusion of warranty; and each file should have at least
-the "copyright" line and a pointer to where the full notice is found.
-
-
- Copyright (C)
-
- 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 .
-
-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:
-
- Copyright (C)
- 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
-.
-
- 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
-.
+MIT License
+
+Copyright (c) 2022 Mikayla Fischler
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/README.md b/README.md
index 37b9124..d0a34c1 100644
--- a/README.md
+++ b/README.md
@@ -1,6 +1,8 @@
# 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!
+This requires CC: Tweaked and Mekanism v10.0+ (10.1 recommended for full feature set).
+
## [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.
@@ -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)
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
- RTU: Remote Terminal Unit
- PLC: Programmable Logic Controller
## 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
-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
@@ -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.
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.
diff --git a/coordinator/coordinator.lua b/coordinator/coordinator.lua
index 96d766e..a6bf236 100644
--- a/coordinator/coordinator.lua
+++ b/coordinator/coordinator.lua
@@ -1,8 +1,12 @@
--- #REQUIRES comms.lua
+local comms = require("scada-common.comms")
+
+local coordinator = {}
-- coordinator communications
-function coord_comms()
+coordinator.coord_comms = function ()
local self = {
reactor_struct_cache = nil
}
end
+
+return coordinator
diff --git a/coordinator/old-controller/controller.lua b/coordinator/old-controller/controller.lua
deleted file mode 100644
index b0e18b7..0000000
--- a/coordinator/old-controller/controller.lua
+++ /dev/null
@@ -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
diff --git a/coordinator/old-controller/defs.lua b/coordinator/old-controller/defs.lua
deleted file mode 100644
index 13f6803..0000000
--- a/coordinator/old-controller/defs.lua
+++ /dev/null
@@ -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
diff --git a/coordinator/old-controller/log.lua b/coordinator/old-controller/log.lua
deleted file mode 100644
index c4e1cbb..0000000
--- a/coordinator/old-controller/log.lua
+++ /dev/null
@@ -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
diff --git a/coordinator/old-controller/reactor.lua b/coordinator/old-controller/reactor.lua
deleted file mode 100644
index 137b46c..0000000
--- a/coordinator/old-controller/reactor.lua
+++ /dev/null
@@ -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
diff --git a/coordinator/old-controller/regulator.lua b/coordinator/old-controller/regulator.lua
deleted file mode 100644
index e8acf55..0000000
--- a/coordinator/old-controller/regulator.lua
+++ /dev/null
@@ -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
diff --git a/coordinator/old-controller/render.lua b/coordinator/old-controller/render.lua
deleted file mode 100644
index e10614d..0000000
--- a/coordinator/old-controller/render.lua
+++ /dev/null
@@ -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
diff --git a/coordinator/old-controller/server.lua b/coordinator/old-controller/server.lua
deleted file mode 100644
index 61ad386..0000000
--- a/coordinator/old-controller/server.lua
+++ /dev/null
@@ -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
diff --git a/coordinator/startup.lua b/coordinator/startup.lua
index 20be7a3..aa65be4 100644
--- a/coordinator/startup.lua
+++ b/coordinator/startup.lua
@@ -2,26 +2,36 @@
-- Nuclear Generation Facility SCADA Coordinator
--
-os.loadAPI("scada-common/log.lua")
-os.loadAPI("scada-common/util.lua")
-os.loadAPI("scada-common/ppm.lua")
-os.loadAPI("scada-common/comms.lua")
+require("/initenv").init_env()
-os.loadAPI("coordinator/config.lua")
-os.loadAPI("coordinator/coordinator.lua")
+local log = require("scada-common.log")
+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 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()
-local modem = ppm.get_device("modem")
-
-print("| SCADA Coordinator - " .. COORDINATOR_VERSION .. " |")
+local modem = ppm.get_wireless_modem()
-- we need a modem
if modem == nil then
- print("Please connect a modem.")
+ println("please connect a wireless modem")
return
end
diff --git a/initenv.lua b/initenv.lua
new file mode 100644
index 0000000..c66e4c3
--- /dev/null
+++ b/initenv.lua
@@ -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 }
diff --git a/pocket/startup.lua b/pocket/startup.lua
index aeeaef4..40a0777 100644
--- a/pocket/startup.lua
+++ b/pocket/startup.lua
@@ -1,3 +1,5 @@
--
-- SCADA Coordinator Access on a Pocket Computer
---
\ No newline at end of file
+--
+
+require("/initenv").init_env()
diff --git a/reactor-plc/config.lua b/reactor-plc/config.lua
index 25f750c..99edc92 100644
--- a/reactor-plc/config.lua
+++ b/reactor-plc/config.lua
@@ -1,8 +1,18 @@
--- set to false to run in standalone mode (safety regulation only)
-NETWORKED = true
+local config = {}
+
+-- set to false to run in offline mode (safety regulation only)
+config.NETWORKED = true
-- unique reactor ID
-REACTOR_ID = 1
+config.REACTOR_ID = 1
-- port to send packets TO server
-SERVER_PORT = 16000
+config.SERVER_PORT = 16000
-- 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
diff --git a/reactor-plc/plc.lua b/reactor-plc/plc.lua
index 6843303..9620636 100644
--- a/reactor-plc/plc.lua
+++ b/reactor-plc/plc.lua
@@ -1,285 +1,311 @@
--- #REQUIRES comms.lua
--- #REQUIRES ppm.lua
+local comms = require("scada-common.comms")
+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 plc = {}
+
+local rps_status_t = types.rps_status_t
+
+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 print = util.print
+local println = util.println
+local print_ts = util.print_ts
+local println_ts = util.println_ts
+
+--- RPS: Reactor Protection System
+---
+--- identifies dangerous states and SCRAMs reactor if warranted
+---
+--- autonomous from main SCADA supervisor/coordinator control
+plc.rps_init = function (reactor)
+ local state_keys = {
+ dmg_crit = 1,
+ high_temp = 2,
+ no_coolant = 3,
+ ex_waste = 4,
+ ex_hcoolant = 5,
+ no_fuel = 6,
+ fault = 7,
+ timeout = 8,
+ manual = 9
+ }
--- Internal Safety System
--- identifies dangerous states and SCRAMs reactor if warranted
--- autonomous from main SCADA supervisor/coordinator control
-function iss_init(reactor)
local self = {
reactor = reactor,
- timed_out = false,
+ state = { false, false, false, false, false, false, false, false, false },
+ reactor_enabled = false,
tripped = false,
trip_cause = ""
}
- -- re-link a reactor after a peripheral re-connect
- local reconnect_reactor = function (reactor)
- self.reactor = reactor
+ ---@class rps
+ local public = {}
+
+ -- PRIVATE FUNCTIONS --
+
+ -- set reactor access fault flag
+ local _set_fault = function ()
+ if self.reactor.__p_last_fault() ~= "Terminated" then
+ self.state[state_keys.fault] = true
+ end
+ end
+
+ -- clear reactor access fault flag
+ local _clear_fault = function ()
+ self.state[state_keys.fault] = false
end
-- check for critical damage
- local damage_critical = function ()
+ local _damage_critical = function ()
local damage_percent = self.reactor.getDamagePercent()
if damage_percent == ppm.ACCESS_FAULT then
-- lost the peripheral or terminated, handled later
- log._error("ISS: failed to check reactor damage")
- return false
+ log.error("RPS: failed to check reactor damage")
+ _set_fault()
+ self.state[state_keys.dmg_crit] = false
else
- return damage_percent >= 100
- end
- end
-
- -- check for heated coolant backup
- local excess_heated_coolant = function ()
- local hc_needed = self.reactor.getHeatedCoolantNeeded()
- if hc_needed == ppm.ACCESS_FAULT then
- -- lost the peripheral or terminated, handled later
- log._error("ISS: failed to check reactor heated coolant level")
- return false
- else
- return hc_needed == 0
- end
- end
-
- -- check for excess waste
- local excess_waste = function ()
- local w_needed = self.reactor.getWasteNeeded()
- if w_needed == ppm.ACCESS_FAULT then
- -- lost the peripheral or terminated, handled later
- log._error("ISS: failed to check reactor waste level")
- return false
- else
- return w_needed == 0
+ self.state[state_keys.dmg_crit] = damage_percent >= 100
end
end
-- check if the reactor is at a critically high temperature
- local high_temp = function ()
+ local _high_temp = function ()
-- mekanism: MAX_DAMAGE_TEMPERATURE = 1_200
local temp = self.reactor.getTemperature()
if temp == ppm.ACCESS_FAULT then
-- lost the peripheral or terminated, handled later
- log._error("ISS: failed to check reactor temperature")
- return false
+ log.error("RPS: failed to check reactor temperature")
+ _set_fault()
+ self.state[state_keys.high_temp] = false
else
- return temp >= 1200
+ self.state[state_keys.high_temp] = temp >= 1200
+ end
+ end
+
+ -- check if there is no coolant (<2% filled)
+ local _no_coolant = function ()
+ local coolant_filled = self.reactor.getCoolantFilledPercentage()
+ if coolant_filled == ppm.ACCESS_FAULT then
+ -- lost the peripheral or terminated, handled later
+ log.error("RPS: failed to check reactor coolant level")
+ _set_fault()
+ self.state[state_keys.no_coolant] = false
+ else
+ self.state[state_keys.no_coolant] = coolant_filled < 0.02
+ end
+ end
+
+ -- check for excess waste (>80% filled)
+ local _excess_waste = function ()
+ local w_filled = self.reactor.getWasteFilledPercentage()
+ if w_filled == ppm.ACCESS_FAULT then
+ -- lost the peripheral or terminated, handled later
+ log.error("RPS: failed to check reactor waste level")
+ _set_fault()
+ self.state[state_keys.ex_waste] = false
+ else
+ self.state[state_keys.ex_waste] = w_filled > 0.8
+ end
+ end
+
+ -- check for heated coolant backup (>95% filled)
+ local _excess_heated_coolant = function ()
+ local hc_filled = self.reactor.getHeatedCoolantFilledPercentage()
+ if hc_filled == ppm.ACCESS_FAULT then
+ -- lost the peripheral or terminated, handled later
+ log.error("RPS: failed to check reactor heated coolant level")
+ _set_fault()
+ self.state[state_keys.ex_hcoolant] = false
+ else
+ self.state[state_keys.ex_hcoolant] = hc_filled > 0.95
end
end
-- check if there is no fuel
- local insufficient_fuel = function ()
+ local _insufficient_fuel = function ()
local fuel = self.reactor.getFuel()
if fuel == ppm.ACCESS_FAULT then
-- lost the peripheral or terminated, handled later
- log._error("ISS: failed to check reactor fuel level")
- return false
+ log.error("RPS: failed to check reactor fuel")
+ _set_fault()
+ self.state[state_keys.no_fuel] = false
else
- return fuel == 0
+ self.state[state_keys.no_fuel] = fuel == 0
end
end
- -- check if there is no coolant
- local no_coolant = function ()
- local coolant_filled = self.reactor.getCoolantFilledPercentage()
- if coolant_filled == ppm.ACCESS_FAULT then
- -- lost the peripheral or terminated, handled later
- log._error("ISS: failed to check reactor coolant level")
+ -- PUBLIC FUNCTIONS --
+
+ -- re-link a reactor after a peripheral re-connect
+---@diagnostic disable-next-line: redefined-local
+ public.reconnect_reactor = function (reactor)
+ self.reactor = reactor
+ end
+
+ -- trip for lost peripheral
+ public.trip_fault = function ()
+ _set_fault()
+ end
+
+ -- trip for a PLC comms timeout
+ public.trip_timeout = function ()
+ self.state[state_keys.timeout] = true
+ end
+
+ -- manually SCRAM the reactor
+ public.trip_manual = function ()
+ self.state[state_keys.manual] = true
+ end
+
+ -- SCRAM the reactor now
+ ---@return boolean success
+ public.scram = function ()
+ log.info("RPS: reactor SCRAM")
+
+ self.reactor.scram()
+ if self.reactor.__p_is_faulted() then
+ log.error("RPS: failed reactor SCRAM")
return false
else
- return coolant_filled < 2
+ self.reactor_enabled = false
+ return true
end
end
- -- if PLC timed out
- local timed_out = function ()
- return self.timed_out
+ -- start the reactor
+ ---@return boolean success
+ public.activate = function ()
+ if not self.tripped then
+ log.info("RPS: reactor start")
+
+ self.reactor.activate()
+ if self.reactor.__p_is_faulted() then
+ log.error("RPS: failed reactor start")
+ else
+ self.reactor_enabled = true
+ return true
+ end
+ end
+
+ return false
end
-- check all safety conditions
- local check = function ()
- local status = "ok"
+ ---@return boolean tripped, rps_status_t trip_status, boolean first_trip
+ public.check = function ()
+ local status = rps_status_t.ok
local was_tripped = self.tripped
-
+ local first_trip = false
+
+ -- update state
+ parallel.waitForAll(
+ _damage_critical,
+ _high_temp,
+ _no_coolant,
+ _excess_waste,
+ _excess_heated_coolant,
+ _insufficient_fuel
+ )
+
-- check system states in order of severity
- if damage_critical() then
- log._warning("ISS: damage critical!")
- status = "dmg_crit"
- elseif high_temp() then
- log._warning("ISS: high temperature!")
- status = "high_temp"
- elseif excess_heated_coolant() then
- log._warning("ISS: heated coolant backup!")
- status = "heated_coolant_backup"
- elseif excess_waste() then
- log._warning("ISS: full waste!")
- status = "full_waste"
- elseif insufficient_fuel() then
- log._warning("ISS: no fuel!")
- status = "no_fuel"
- elseif self.tripped then
+ if self.tripped then
status = self.trip_cause
+ elseif self.state[state_keys.dmg_crit] then
+ log.warning("RPS: damage critical")
+ status = rps_status_t.dmg_crit
+ elseif self.state[state_keys.high_temp] then
+ log.warning("RPS: high temperature")
+ status = rps_status_t.high_temp
+ elseif self.state[state_keys.no_coolant] then
+ log.warning("RPS: no coolant")
+ status = rps_status_t.no_coolant
+ elseif self.state[state_keys.ex_waste] then
+ log.warning("RPS: full waste")
+ status = rps_status_t.ex_waste
+ elseif self.state[state_keys.ex_hcoolant] then
+ log.warning("RPS: heated coolant backup")
+ status = rps_status_t.ex_hcoolant
+ elseif self.state[state_keys.no_fuel] then
+ log.warning("RPS: no fuel")
+ status = rps_status_t.no_fuel
+ elseif self.state[state_keys.fault] then
+ log.warning("RPS: reactor access fault")
+ status = rps_status_t.fault
+ elseif self.state[state_keys.timeout] then
+ log.warning("RPS: supervisor connection timeout")
+ status = rps_status_t.timeout
+ elseif self.state[state_keys.manual] then
+ log.warning("RPS: manual SCRAM requested")
+ status = rps_status_t.manual
else
self.tripped = false
end
-
+
-- if a new trip occured...
- if status ~= "ok" then
- log._warning("ISS: reactor SCRAM")
+ if (not was_tripped) and (status ~= rps_status_t.ok) then
+ first_trip = true
self.tripped = true
self.trip_cause = status
- if self.reactor.scram() == ppm.ACCESS_FAULT then
- log._error("ISS: failed reactor SCRAM")
- end
+
+ public.scram()
end
- local first_trip = not was_tripped and self.tripped
-
return self.tripped, status, first_trip
end
- -- report a PLC comms timeout
- local trip_timeout = function ()
- self.tripped = false
- self.trip_cause = "timeout"
- self.timed_out = true
- self.reactor.scram()
- end
+ public.status = function () return self.state end
+ public.is_tripped = function () return self.tripped end
+ public.is_active = function () return self.reactor_enabled end
- -- reset the ISS
- local reset = function ()
- self.timed_out = false
+ -- reset the RPS
+ public.reset = function ()
self.tripped = false
- self.trip_cause = ""
- end
+ self.trip_cause = rps_status_t.ok
- -- get the ISS status
- local status = function (named)
- if named then
- return {
- damage_critical = damage_critical(),
- excess_heated_coolant = excess_heated_coolant(),
- excess_waste = excess_waste(),
- high_temp = high_temp(),
- insufficient_fuel = insufficient_fuel(),
- no_coolant = no_coolant(),
- timed_out = timed_out()
- }
- else
- return {
- damage_critical(),
- excess_heated_coolant(),
- excess_waste(),
- high_temp(),
- insufficient_fuel(),
- no_coolant(),
- timed_out()
- }
+ for i = 1, #self.state do
+ self.state[i] = false
end
end
- return {
- reconnect_reactor = reconnect_reactor,
- check = check,
- trip_timeout = trip_timeout,
- reset = reset,
- status = status,
- damage_critical = damage_critical,
- excess_heated_coolant = excess_heated_coolant,
- excess_waste = excess_waste,
- high_temp = high_temp,
- insufficient_fuel = insufficient_fuel,
- no_coolant = no_coolant,
- timed_out = timed_out
- }
+ return public
end
-function rplc_packet()
- local self = {
- frame = nil,
- id = nil,
- type = nil,
- length = nil,
- body = nil
- }
-
- local _rplc_type_valid = function ()
- return self.type == RPLC_TYPES.KEEP_ALIVE or
- 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_SCRAM or
- self.type == RPLC_TYPES.MEK_ENABLE or
- self.type == RPLC_TYPES.MEK_BURN_RATE or
- self.type == RPLC_TYPES.ISS_ALARM or
- self.type == RPLC_TYPES.ISS_GET or
- self.type == RPLC_TYPES.ISS_CLEAR
- end
-
- -- make an RPLC packet
- local make = function (id, packet_type, length, data)
- self.id = id
- self.type = packet_type
- self.length = length
- self.data = data
- end
-
- -- decode an RPLC packet from a SCADA frame
- local decode = function (frame)
- if frame then
- self.frame = frame
-
- if frame.protocol() == comms.PROTOCOLS.RPLC then
- local data = frame.data()
- local ok = #data > 2
-
- if ok then
- make(data[1], data[2], data[3], { table.unpack(data, 4, #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
-
- local get = function ()
- return {
- scada_frame = self.frame,
- id = self.id,
- type = self.type,
- length = self.length,
- data = self.data
- }
- end
-
- return {
- make = make,
- decode = decode,
- get = get
- }
-end
-
--- reactor PLC communications
-function comms_init(id, modem, local_port, server_port, reactor, iss)
+-- Reactor PLC Communications
+---@param id integer
+---@param version string
+---@param modem table
+---@param local_port integer
+---@param server_port integer
+---@param reactor table
+---@param rps rps
+---@param conn_watchdog watchdog
+plc.comms = function (id, version, modem, local_port, server_port, reactor, rps, conn_watchdog)
local self = {
id = id,
+ version = version,
seq_num = 0,
+ r_seq_num = nil,
modem = modem,
s_port = server_port,
l_port = local_port,
reactor = reactor,
- iss = iss,
- status_cache = nil,
+ rps = rps,
+ conn_watchdog = conn_watchdog,
scrammed = false,
- linked = false
+ linked = false,
+ status_cache = nil,
+ max_burn_rate = nil
}
+ ---@class plc_comms
+ local public = {}
+
-- open modem
if not self.modem.isOpen(self.l_port) then
self.modem.open(self.l_port)
@@ -287,56 +313,113 @@ function comms_init(id, modem, local_port, server_port, reactor, iss)
-- PRIVATE FUNCTIONS --
- local _send = function (msg)
- local packet = scada_packet()
- packet.make(self.seq_num, PROTOCOLS.RPLC, msg)
- self.modem.transmit(self.s_port, self.l_port, packet.raw())
+ -- send an RPLC packet
+ ---@param msg_type RPLC_TYPES
+ ---@param msg string
+ 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.modem.transmit(self.s_port, self.l_port, s_pkt.raw_sendable())
+ self.seq_num = self.seq_num + 1
+ end
+
+ -- send a SCADA management packet
+ ---@param msg_type SCADA_MGMT_TYPES
+ ---@param msg string
+ 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.modem.transmit(self.s_port, self.l_port, s_pkt.raw_sendable())
self.seq_num = self.seq_num + 1
end
-- variable reactor status information, excluding heating rate
+ ---@return table data_table, boolean faulted
local _reactor_status = function ()
- ppm.clear_fault()
- return {
- status = self.reactor.getStatus(),
- burn_rate = self.reactor.getBurnRate(),
- act_burn_r = self.reactor.getActualBurnRate(),
- temp = self.reactor.getTemperature(),
- damage = self.reactor.getDamagePercent(),
- boil_eff = self.reactor.getBoilEfficiency(),
- env_loss = self.reactor.getEnvironmentalLoss(),
+ local coolant = nil
+ local hcoolant = nil
- fuel = self.reactor.getFuel(),
- fuel_need = self.reactor.getFuelNeeded(),
- fuel_fill = self.reactor.getFuelFilledPercentage(),
- waste = self.reactor.getWaste(),
- waste_need = self.reactor.getWasteNeeded(),
- waste_fill = self.reactor.getWasteFilledPercentage(),
- cool_type = self.reactor.getCoolant()['name'],
- cool_amnt = self.reactor.getCoolant()['amount'],
- cool_need = self.reactor.getCoolantNeeded(),
- cool_fill = self.reactor.getCoolantFilledPercentage(),
- hcool_type = self.reactor.getHeatedCoolant()['name'],
- hcool_amnt = self.reactor.getHeatedCoolant()['amount'],
- hcool_need = self.reactor.getHeatedCoolantNeeded(),
- hcool_fill = self.reactor.getHeatedCoolantFilledPercentage()
- }, ppm.faulted()
+ local data_table = {
+ false, -- getStatus
+ 0, -- getBurnRate
+ 0, -- getActualBurnRate
+ 0, -- getTemperature
+ 0, -- getDamagePercent
+ 0, -- getBoilEfficiency
+ 0, -- getEnvironmentalLoss
+ 0, -- getFuel
+ 0, -- getFuelFilledPercentage
+ 0, -- getWaste
+ 0, -- getWasteFilledPercentage
+ "", -- coolant_name
+ 0, -- coolant_amnt
+ 0, -- getCoolantFilledPercentage
+ "", -- hcoolant_name
+ 0, -- hcoolant_amnt
+ 0 -- getHeatedCoolantFilledPercentage
+ }
+
+ local tasks = {
+ function () data_table[1] = self.reactor.getStatus() end,
+ function () data_table[2] = self.reactor.getBurnRate() end,
+ function () data_table[3] = self.reactor.getActualBurnRate() end,
+ function () data_table[4] = self.reactor.getTemperature() end,
+ function () data_table[5] = self.reactor.getDamagePercent() end,
+ function () data_table[6] = self.reactor.getBoilEfficiency() end,
+ function () data_table[7] = self.reactor.getEnvironmentalLoss() end,
+ function () data_table[8] = self.reactor.getFuel() end,
+ function () data_table[9] = self.reactor.getFuelFilledPercentage() end,
+ function () data_table[10] = self.reactor.getWaste() end,
+ function () data_table[11] = self.reactor.getWasteFilledPercentage() end,
+ function () coolant = self.reactor.getCoolant() end,
+ function () data_table[14] = self.reactor.getCoolantFilledPercentage() end,
+ function () hcoolant = self.reactor.getHeatedCoolant() end,
+ function () data_table[17] = self.reactor.getHeatedCoolantFilledPercentage() end
+ }
+
+ parallel.waitForAll(table.unpack(tasks))
+
+ if coolant ~= nil then
+ data_table[12] = coolant.name
+ data_table[13] = coolant.amount
+ end
+
+ if hcoolant ~= nil then
+ data_table[15] = hcoolant.name
+ data_table[16] = hcoolant.amount
+ end
+
+ return data_table, self.reactor.__p_is_faulted()
end
+ -- update the status cache if changed
+ ---@return boolean changed
local _update_status_cache = function ()
local status, faulted = _reactor_status()
local changed = false
- if not faulted then
- for key, value in pairs(status) do
- if value ~= self.status_cache[key] then
- changed = true
- break
+ if self.status_cache ~= nil then
+ if not faulted then
+ for i = 1, #status do
+ if status[i] ~= self.status_cache[i] then
+ changed = true
+ break
+ end
end
end
+ else
+ changed = true
end
- if changed then
+ if changed and not faulted then
self.status_cache = status
end
@@ -344,69 +427,48 @@ function comms_init(id, modem, local_port, server_port, reactor, iss)
end
-- keep alive ack
- local _send_keep_alive_ack = function ()
- local keep_alive_data = {
- id = self.id,
- timestamp = os.time(),
- type = RPLC_TYPES.KEEP_ALIVE
- }
-
- _send(keep_alive_data)
+ ---@param srv_time integer
+ local _send_keep_alive_ack = function (srv_time)
+ _send_mgmt(SCADA_MGMT_TYPES.KEEP_ALIVE, { srv_time, util.time() })
end
-- general ack
- local _send_ack = function (type, succeeded)
- local ack_data = {
- id = self.id,
- type = type,
- ack = succeeded
- }
-
- _send(ack_data)
+ ---@param msg_type RPLC_TYPES
+ ---@param succeeded boolean
+ local _send_ack = function (msg_type, succeeded)
+ _send(msg_type, { succeeded })
end
- -- send structure properties (these should not change)
- -- (server will cache these)
+ -- send structure properties (these should not change, server will cache these)
local _send_struct = function ()
- ppm.clear_fault()
- local mek_data = {
- heat_cap = self.reactor.getHeatCapacity(),
- fuel_asm = self.reactor.getFuelAssemblies(),
- fuel_sa = self.reactor.getFuelSurfaceArea(),
- fuel_cap = self.reactor.getFuelCapacity(),
- waste_cap = self.reactor.getWasteCapacity(),
- cool_cap = self.reactor.getCoolantCapacity(),
- hcool_cap = self.reactor.getHeatedCoolantCapacity(),
- max_burn = self.reactor.getMaxBurnRate()
+ local mek_data = { 0, 0, 0, 0, 0, 0, 0, 0 }
+
+ local tasks = {
+ function () mek_data[1] = self.reactor.getHeatCapacity() end,
+ function () mek_data[2] = self.reactor.getFuelAssemblies() end,
+ function () mek_data[3] = self.reactor.getFuelSurfaceArea() end,
+ function () mek_data[4] = self.reactor.getFuelCapacity() end,
+ function () mek_data[5] = self.reactor.getWasteCapacity() end,
+ function () mek_data[6] = self.reactor.getCoolantCapacity() end,
+ function () mek_data[7] = self.reactor.getHeatedCoolantCapacity() end,
+ function () mek_data[8] = self.reactor.getMaxBurnRate() end
}
- if not faulted then
- local struct_packet = {
- id = self.id,
- type = RPLC_TYPES.MEK_STRUCT,
- mek_data = mek_data
- }
+ parallel.waitForAll(table.unpack(tasks))
- _send(struct_packet)
+ if not self.reactor.__p_is_faulted() then
+ _send(RPLC_TYPES.MEK_STRUCT, mek_data)
else
- log._error("failed to send structure: PPM fault")
+ log.error("failed to send structure: PPM fault")
end
end
- local _send_iss_status = function ()
- local iss_status = {
- id = self.id,
- type = RPLC_TYPES.ISS_GET,
- status = iss.status()
- }
-
- _send(iss_status)
- end
-
-- PUBLIC FUNCTIONS --
-- reconnect a newly connected modem
- local reconnect_modem = function (modem)
+ ---@param modem table
+---@diagnostic disable-next-line: redefined-local
+ public.reconnect_modem = function (modem)
self.modem = modem
-- open modem
@@ -416,34 +478,108 @@ function comms_init(id, modem, local_port, server_port, reactor, iss)
end
-- reconnect a newly connected reactor
- local reconnect_reactor = function (reactor)
+ ---@param reactor table
+---@diagnostic disable-next-line: redefined-local
+ public.reconnect_reactor = function (reactor)
self.reactor = reactor
- _update_status_cache()
+ self.status_cache = nil
+ end
+
+ -- unlink from the server
+ public.unlink = function ()
+ self.linked = false
+ self.r_seq_num = nil
+ self.status_cache = nil
+ end
+
+ -- close the connection to the server
+ public.close = function ()
+ self.conn_watchdog.cancel()
+ public.unlink()
+ _send_mgmt(SCADA_MGMT_TYPES.CLOSE, {})
+ end
+
+ -- attempt to establish link with supervisor
+ public.send_link_req = function ()
+ _send(RPLC_TYPES.LINK_REQ, { self.id, self.version })
+ end
+
+ -- send live status information
+ ---@param degraded boolean
+ public.send_status = function (degraded)
+ if self.linked then
+ local mek_data = nil
+
+ if _update_status_cache() then
+ mek_data = self.status_cache
+ end
+
+ local sys_status = {
+ util.time(), -- timestamp
+ (not self.scrammed), -- requested control state
+ rps.is_tripped(), -- overridden
+ degraded, -- degraded
+ self.reactor.getHeatingRate(), -- heating rate
+ mek_data -- mekanism status data
+ }
+
+ if not self.reactor.__p_is_faulted() then
+ _send(RPLC_TYPES.STATUS, sys_status)
+ else
+ log.error("failed to send status: PPM fault")
+ end
+ end
+ end
+
+ -- send reactor protection system status
+ public.send_rps_status = function ()
+ if self.linked then
+ _send(RPLC_TYPES.RPS_STATUS, rps.status())
+ end
+ end
+
+ -- send reactor protection system alarm
+ ---@param cause rps_status_t
+ public.send_rps_alarm = function (cause)
+ if self.linked then
+ local rps_alarm = {
+ cause,
+ table.unpack(rps.status())
+ }
+
+ _send(RPLC_TYPES.RPS_ALARM, rps_alarm)
+ end
end
-- parse an RPLC 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 rplc_frame|mgmt_frame|nil packet
+ public.parse_packet = function(side, sender, reply_to, message, distance)
local pkt = nil
- local s_pkt = scada_packet()
+ local s_pkt = comms.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
-- get as RPLC packet
if s_pkt.protocol() == PROTOCOLS.RPLC then
- local rplc_pkt = rplc_packet()
+ 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 = mgmt_packet()
+ local mgmt_pkt = comms.mgmt_packet()
if mgmt_pkt.decode(s_pkt) then
- pkt = mgmt_packet.get()
+ pkt = mgmt_pkt.get()
end
else
- log._error("illegal packet type " .. s_pkt.protocol(), true)
+ log.error("illegal packet type " .. s_pkt.protocol(), true)
end
end
@@ -451,174 +587,183 @@ function comms_init(id, modem, local_port, server_port, reactor, iss)
end
-- handle an RPLC packet
- local handle_packet = function (packet, plc_state)
+ ---@param packet rplc_frame|mgmt_frame
+ ---@param plc_state plc_state
+ ---@param setpoints setpoints
+ public.handle_packet = function (packet, plc_state, setpoints)
if packet ~= nil then
- if packet.scada_frame.protocol() == PROTOCOLS.RPLC then
+ -- check sequence number
+ if self.r_seq_num == nil then
+ self.r_seq_num = packet.scada_frame.seq_num()
+ elseif self.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 the watchdog first so it doesn't uhh...eat our packets :)
+ self.conn_watchdog.feed()
+
+ local protocol = packet.scada_frame.protocol()
+
+ -- handle packet
+ if protocol == PROTOCOLS.RPLC then
if self.linked then
- if packet.type == RPLC_TYPES.KEEP_ALIVE then
- -- keep alive request received, echo back
- local timestamp = packet.data[1]
- local trip_time = os.time() - ts
-
- if trip_time < 0 then
- log._warning("PLC KEEP_ALIVE trip time less than 0 (" .. trip_time .. ")")
- elseif trip_time > 1 then
- log._warning("PLC KEEP_ALIVE trip time > 1s (" .. trip_time .. ")")
- end
-
- _send_keep_alive_ack()
- elseif packet.type == RPLC_TYPES.LINK_REQ then
+ if packet.type == RPLC_TYPES.LINK_REQ then
-- link request confirmation
- log._debug("received unsolicited link request response")
+ if packet.length == 1 then
+ log.debug("received unsolicited link request response")
- local link_ack = packet.data[1]
-
- if link_ack == RPLC_LINKING.ALLOW then
- _send_struct()
- send_status()
- log._debug("re-sent initial status data")
- elseif link_ack == RPLC_LINKING.DENY then
- -- @todo: make sure this doesn't become an MITM security risk
- print_ts("received unsolicited link denial, unlinking\n")
- log._debug("unsolicited rplc link request denied")
- elseif link_ack == RPLC_LINKING.COLLISION then
- -- @todo: make sure this doesn't become an MITM security risk
- print_ts("received unsolicited link collision, unlinking\n")
- log._warning("unsolicited rplc link request collision")
+ local link_ack = packet.data[1]
+
+ if link_ack == RPLC_LINKING.ALLOW then
+ self.status_cache = nil
+ _send_struct()
+ public.send_status(plc_state.degraded)
+ log.debug("re-sent initial status data")
+ elseif link_ack == RPLC_LINKING.DENY then
+ println_ts("received unsolicited link denial, unlinking")
+ log.debug("unsolicited RPLC link request denied")
+ elseif link_ack == RPLC_LINKING.COLLISION then
+ println_ts("received unsolicited link collision, unlinking")
+ log.warning("unsolicited RPLC link request collision")
+ else
+ println_ts("invalid unsolicited link response")
+ log.error("unsolicited unknown RPLC link request response")
+ end
+
+ self.linked = link_ack == RPLC_LINKING.ALLOW
else
- print_ts("invalid unsolicited link response\n")
- log._error("unsolicited unknown rplc link request response")
+ log.debug("RPLC link req packet length mismatch")
end
-
- self.linked = link_ack == RPLC_LINKING.ALLOW
+ elseif packet.type == RPLC_TYPES.STATUS then
+ -- request of full status, clear cache first
+ self.status_cache = nil
+ public.send_status(plc_state.degraded)
+ log.debug("sent out status cache again, did supervisor miss it?")
elseif packet.type == RPLC_TYPES.MEK_STRUCT then
-- request for physical structure
_send_struct()
- elseif packet.type == RPLC_TYPES.MEK_SCRAM then
- -- disable the reactor
- self.scrammed = true
- plc_state.scram = true
- _send_ack(packet.type, self.reactor.scram() == ppm.ACCESS_OK)
- elseif packet.type == RPLC_TYPES.MEK_ENABLE then
- -- enable the reactor
- self.scrammed = false
- plc_state.scram = false
- _send_ack(packet.type, self.reactor.activate() == ppm.ACCESS_OK)
+ log.debug("sent out structure again, did supervisor miss it?")
elseif packet.type == RPLC_TYPES.MEK_BURN_RATE then
-- set the burn rate
- local burn_rate = packet.data[1]
- local max_burn_rate = self.reactor.getMaxBurnRate()
- local success = false
+ if packet.length == 2 then
+ local success = false
+ local burn_rate = packet.data[1]
+ local ramp = packet.data[2]
- if max_burn_rate ~= ppm.ACCESS_FAULT then
- if burn_rate > 0 and burn_rate <= max_burn_rate then
- success = self.reactor.setBurnRate(burn_rate)
+ -- if no known max burn rate, check again
+ if self.max_burn_rate == nil then
+ self.max_burn_rate = self.reactor.getMaxBurnRate()
end
- end
- _send_ack(packet.type, success == ppm.ACCESS_OK)
- elseif packet.type == RPLC_TYPES.ISS_GET then
- -- get the ISS status
- _send_iss_status(iss.status())
- elseif packet.type == RPLC_TYPES.ISS_CLEAR then
- -- clear the ISS status
- iss.reset()
+ -- if we know our max burn rate, update current burn rate setpoint if in range
+ if self.max_burn_rate ~= ppm.ACCESS_FAULT then
+ if burn_rate > 0 and burn_rate <= self.max_burn_rate then
+ if ramp then
+ setpoints.burn_rate_en = true
+ setpoints.burn_rate = burn_rate
+ success = true
+ else
+ self.reactor.setBurnRate(burn_rate)
+ success = not self.reactor.__p_is_faulted()
+ end
+ end
+ end
+
+ _send_ack(packet.type, success)
+ else
+ log.debug("RPLC set burn rate packet length mismatch")
+ end
+ elseif packet.type == RPLC_TYPES.RPS_ENABLE then
+ -- enable the reactor
+ self.scrammed = false
+ _send_ack(packet.type, self.rps.activate())
+ elseif packet.type == RPLC_TYPES.RPS_SCRAM then
+ -- disable the reactor
+ self.scrammed = true
+ self.rps.trip_manual()
+ _send_ack(packet.type, true)
+ elseif packet.type == RPLC_TYPES.RPS_RESET then
+ -- reset the RPS status
+ rps.reset()
_send_ack(packet.type, true)
else
- log._warning("received unknown RPLC packet type " .. packet.type)
+ log.warning("received unknown RPLC packet type " .. packet.type)
end
elseif packet.type == RPLC_TYPES.LINK_REQ then
-- link request confirmation
- local link_ack = packet.data[1]
-
- if link_ack == RPLC_LINKING.ALLOW then
- print_ts("...linked!\n")
- log._debug("rplc link request approved")
+ if packet.length == 1 then
+ local link_ack = packet.data[1]
- _send_struct()
- send_status()
+ if link_ack == RPLC_LINKING.ALLOW then
+ println_ts("linked!")
+ log.debug("RPLC link request approved")
- log._debug("sent initial status data")
- elseif link_ack == RPLC_LINKING.DENY then
- print_ts("...denied, retrying...\n")
- log._debug("rplc link request denied")
- elseif link_ack == RPLC_LINKING.COLLISION then
- print_ts("reactor PLC ID collision (check config), retrying...\n")
- log._warning("rplc link request collision")
+ -- reset remote sequence number and cache
+ self.r_seq_num = nil
+ self.status_cache = nil
+
+ _send_struct()
+ public.send_status(plc_state.degraded)
+
+ log.debug("sent initial status data")
+ elseif link_ack == RPLC_LINKING.DENY then
+ println_ts("link request denied, retrying...")
+ log.debug("RPLC link request denied")
+ elseif link_ack == RPLC_LINKING.COLLISION then
+ println_ts("reactor PLC ID collision (check config), retrying...")
+ log.warning("RPLC link request collision")
+ else
+ println_ts("invalid link response, bad channel? retrying...")
+ log.error("unknown RPLC link request response")
+ end
+
+ self.linked = link_ack == RPLC_LINKING.ALLOW
else
- print_ts("invalid link response, bad channel? retrying...\n")
- log._error("unknown rplc link request response")
+ log.debug("RPLC link req packet length mismatch")
end
-
- self.linked = link_ack == RPLC_LINKING.ALLOW
else
- log._debug("discarding non-link packet before linked")
+ log.debug("discarding non-link packet before linked")
end
- elseif packet.scada_frame.protocol() == PROTOCOLS.SCADA_MGMT then
- -- todo
+ elseif protocol == PROTOCOLS.SCADA_MGMT 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("PLC KEEP_ALIVE trip time > 500ms (" .. trip_time .. "ms)")
+ end
+
+ -- log.debug("RPLC 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
+ -- handle session close
+ self.conn_watchdog.cancel()
+ public.unlink()
+ println_ts("server connection closed by remote host")
+ log.warning("server connection closed by remote host")
+ else
+ log.warning("received unknown SCADA_MGMT packet type " .. packet.type)
+ end
+ else
+ -- should be unreachable assuming packet is from parse_packet()
+ log.error("illegal packet type " .. protocol, true)
end
end
end
- -- attempt to establish link with supervisor
- local send_link_req = function ()
- local linking_data = {
- id = self.id,
- type = RPLC_TYPES.LINK_REQ
- }
+ public.is_scrammed = function () return self.scrammed end
+ public.is_linked = function () return self.linked end
- _send(linking_data)
- end
-
- -- send live status information
- -- overridden : if ISS force disabled reactor
- -- degraded : if PLC status is degraded
- local send_status = function (overridden, degraded)
- local mek_data = nil
-
- if _update_status_cache() then
- mek_data = self.status_cache
- end
-
- local sys_status = {
- id = self.id,
- type = RPLC_TYPES.STATUS,
- timestamp = os.time(),
- control_state = not self.scrammed,
- overridden = overridden,
- degraded = degraded,
- heating_rate = self.reactor.getHeatingRate(),
- mek_data = mek_data
- }
-
- _send(sys_status)
- end
-
- local send_iss_alarm = function (cause)
- local iss_alarm = {
- id = self.id,
- type = RPLC_TYPES.ISS_ALARM,
- cause = cause,
- status = iss.status()
- }
-
- _send(iss_alarm)
- end
-
- local is_scrammed = function () return self.scrammed end
- local is_linked = function () return self.linked end
- local unlink = function () self.linked = false end
-
- return {
- reconnect_modem = reconnect_modem,
- reconnect_reactor = reconnect_reactor,
- parse_packet = parse_packet,
- handle_packet = handle_packet,
- send_link_req = send_link_req,
- send_status = send_status,
- send_iss_alarm = send_iss_alarm,
- is_scrammed = is_scrammed,
- is_linked = is_linked,
- unlink = unlink
- }
+ return public
end
+
+return plc
diff --git a/reactor-plc/startup.lua b/reactor-plc/startup.lua
index 4d13e74..062db60 100644
--- a/reactor-plc/startup.lua
+++ b/reactor-plc/startup.lua
@@ -2,57 +2,98 @@
-- Reactor Programmable Logic Controller
--
-os.loadAPI("scada-common/log.lua")
-os.loadAPI("scada-common/util.lua")
-os.loadAPI("scada-common/ppm.lua")
-os.loadAPI("scada-common/comms.lua")
+require("/initenv").init_env()
-os.loadAPI("config.lua")
-os.loadAPI("plc.lua")
+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 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 println = util.println
local print_ts = util.print_ts
local println_ts = util.println_ts
-log._info("========================================")
-log._info("BOOTING reactor-plc.startup " .. R_PLC_VERSION)
-log._info("========================================")
+log.init(config.LOG_PATH, config.LOG_MODE)
+
+log.info("========================================")
+log.info("BOOTING reactor-plc.startup " .. R_PLC_VERSION)
+log.info("========================================")
println(">> Reactor PLC " .. R_PLC_VERSION .. " <<")
-- mount connected devices
ppm.mount_all()
-local reactor = ppm.get_fission_reactor()
-local modem = ppm.get_wireless_modem()
+-- shared memory across threads
+---@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
+ plc_state = {
+ init_ok = true,
+ shutdown = false,
+ degraded = false,
+ no_reactor = false,
+ no_modem = false
+ },
-local plc_state = {
- init_ok = true,
- scram = true,
- degraded = false,
- no_reactor = 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
-if reactor == nil then
+if smem_dev.reactor == nil then
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.degraded = true
plc_state.no_reactor = true
end
-if networked and modem == nil then
+if __shared_memory.networked and smem_dev.modem == nil then
println("boot> wireless modem not found")
- log._warning("no wireless modem on startup")
+ log.warning("no wireless modem on startup")
- if reactor ~= nil then
- reactor.scram()
+ if smem_dev.reactor ~= nil then
+ smem_dev.reactor.scram()
end
plc_state.init_ok = false
@@ -60,238 +101,75 @@ if networked and modem == nil then
plc_state.no_modem = true
end
-local iss = nil
-local plc_comms = nil
-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()
+-- PLC init
+local init = function ()
if plc_state.init_ok then
-- just booting up, no fission allowed (neutrons stay put thanks)
- reactor.scram()
+ smem_dev.reactor.scram()
- -- init internal safety system
- iss = plc.iss_init(reactor)
- log._debug("iss 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")
+ -- init reactor protection system
+ smem_sys.rps = plc.rps_init(smem_dev.reactor)
+ log.debug("init> rps init")
+ if __shared_memory.networked then
-- comms watchdog, 3 second timeout
- conn_watchdog = util.new_watchdog(3)
- log._debug("conn watchdog started")
+ smem_sys.conn_watchdog = util.new_watchdog(3)
+ 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
- log._debug("running without networking")
+ println("boot> starting in offline mode");
+ log.debug("init> running without networking")
end
- -- loop clock (10Hz, 2 ticks)
- loop_clock = os.startTimer(0.05)
- log._debug("loop clock started")
+---@diagnostic disable-next-line: undefined-field
+ os.queueEvent("clock_start")
println("boot> completed");
+ log.debug("init> boot completed")
else
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
+----------------------------------------
+-- start system
+----------------------------------------
+
-- initialize PLC
init()
--- event loop
-while true do
- local event, param1, param2, param3, param4, param5 = os.pullEventRaw()
+-- init threads
+local main_thread = threads.thread__main(__shared_memory, init)
+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 we tried to SCRAM but failed, keep trying
- -- if it disconnected, isPowered will return nil (and error logs will get spammed at 10Hz, so disable reporting)
- -- in that case, SCRAM won't be called until it reconnects (this is the expected use of this check)
- 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
- if event == "peripheral_detach" then
- 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
+ -- send status one last time after RPS shutdown
+ smem_sys.plc_comms.send_status(plc_state.degraded)
+ smem_sys.plc_comms.send_rps_status()
+
+ -- close connection
+ smem_sys.plc_comms.close()
end
+else
+ -- run threads, excluding comms
+ parallel.waitForAll(main_thread.p_exec, rps_thread.p_exec)
end
--- send an alarm: plc_comms.send_alarm(ALARMS.PLC_SHUTDOWN) ?
println_ts("exited")
-log._info("exited")
+log.info("exited")
diff --git a/reactor-plc/threads.lua b/reactor-plc/threads.lua
new file mode 100644
index 0000000..f28c775
--- /dev/null
+++ b/reactor-plc/threads.lua
@@ -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
diff --git a/rtu/config.lua b/rtu/config.lua
index 71804a4..3d2e889 100644
--- a/rtu/config.lua
+++ b/rtu/config.lua
@@ -1,45 +1,52 @@
--- #REQUIRES rsio.lua
+local rsio = require("scada-common.rsio")
+
+local config = {}
-- port to send packets TO server
-SERVER_PORT = 16000
+config.SERVER_PORT = 16000
-- 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_DEVICES = {
+config.RTU_DEVICES = {
{
- name = "boiler_0",
+ name = "boiler_1",
index = 1,
for_reactor = 1
},
{
- name = "turbine_0",
+ name = "turbine_1",
index = 1,
for_reactor = 1
}
}
-- RTU redstone interface definitions
-RTU_REDSTONE = {
+config.RTU_REDSTONE = {
{
for_reactor = 1,
io = {
{
- channel = rsio.RS_IO.WASTE_PO,
+ channel = rsio.IO.WASTE_PO,
side = "top",
- bundled_color = colors.blue,
- for_reactor = 1
+ bundled_color = colors.blue
},
{
- channel = rsio.RS_IO.WASTE_PU,
+ channel = rsio.IO.WASTE_PU,
side = "top",
- bundled_color = colors.cyan,
- for_reactor = 1
+ bundled_color = colors.cyan
},
{
- channel = rsio.RS_IO.WASTE_AM,
+ channel = rsio.IO.WASTE_AM,
side = "top",
- bundled_color = colors.purple,
- for_reactor = 1
+ bundled_color = colors.purple
}
}
}
}
+
+return config
diff --git a/rtu/dev/boiler_rtu.lua b/rtu/dev/boiler_rtu.lua
index 861a34f..26b5ebc 100644
--- a/rtu/dev/boiler_rtu.lua
+++ b/rtu/dev/boiler_rtu.lua
@@ -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 = {
- rtu = rtu.rtu_init(),
+ rtu = rtu.init_unit(),
boiler = boiler
}
- local rtu_interface = function ()
- return self.rtu
- end
-
-- discrete inputs --
-- none
@@ -45,7 +45,7 @@ function new(boiler)
-- holding registers --
-- none
- return {
- rtu_interface = rtu_interface
- }
+ return self.rtu.interface()
end
+
+return boiler_rtu
diff --git a/rtu/dev/boilerv_rtu.lua b/rtu/dev/boilerv_rtu.lua
new file mode 100644
index 0000000..fca1f09
--- /dev/null
+++ b/rtu/dev/boilerv_rtu.lua
@@ -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
diff --git a/rtu/dev/energymachine_rtu.lua b/rtu/dev/energymachine_rtu.lua
new file mode 100644
index 0000000..e0e05af
--- /dev/null
+++ b/rtu/dev/energymachine_rtu.lua
@@ -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
diff --git a/rtu/dev/imatrix_rtu.lua b/rtu/dev/imatrix_rtu.lua
index 529a1f8..56498e5 100644
--- a/rtu/dev/imatrix_rtu.lua
+++ b/rtu/dev/imatrix_rtu.lua
@@ -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 = {
- rtu = rtu.rtu_init(),
+ rtu = rtu.init_unit(),
imatrix = imatrix
}
- local rtu_interface = function ()
- return self.rtu
- end
-
-- discrete inputs --
- -- none
+ 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.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
- self.rtu.connect_input_reg(self.imatrix.getTotalEnergy)
- self.rtu.connect_input_reg(self.imatrix.getTotalEnergyNeeded)
- self.rtu.connect_input_reg(self.imatrix.getTotalEnergyFilledPercentage)
+ self.rtu.connect_input_reg(self.imatrix.getEnergy)
+ self.rtu.connect_input_reg(self.imatrix.getEnergyNeeded)
+ 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 --
-- none
- return {
- rtu_interface = rtu_interface
- }
+ return self.rtu.interface()
end
+
+return imatrix_rtu
diff --git a/rtu/dev/redstone_rtu.lua b/rtu/dev/redstone_rtu.lua
index d81cebb..563886e 100644
--- a/rtu/dev/redstone_rtu.lua
+++ b/rtu/dev/redstone_rtu.lua
@@ -1,20 +1,37 @@
--- #REQUIRES rtu.lua
--- #REQUIRES rsio.lua
--- note: this RTU makes extensive use of the programming concept of closures
+local rtu = require("rtu.rtu")
+local rsio = require("scada-common.rsio")
+
+local redstone_rtu = {}
local digital_read = rsio.digital_read
+local digital_write = rsio.digital_write
local digital_is_active = rsio.digital_is_active
-function new()
+-- create new redstone device
+redstone_rtu.new = function ()
local self = {
- rtu = rtu.rtu_init()
+ rtu = rtu.init_unit()
}
- local rtu_interface = function ()
- return self.rtu
- end
+ -- get RTU interface
+ local interface = self.rtu.interface()
- 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
if color then
@@ -26,11 +43,15 @@ function new()
return digital_read(rs.getInput(side))
end
end
-
+
self.rtu.connect_di(f_read)
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_write = nil
@@ -41,12 +62,11 @@ function new()
f_write = function (level)
local output = rs.getBundledOutput(side)
- local active = digital_is_active(channel, level)
- if active then
- colors.combine(output, color)
+ if digital_write(channel, level) then
+ output = colors.combine(output, color)
else
- colors.subtract(output, color)
+ output = colors.subtract(output, color)
end
rs.setBundledOutput(side, output)
@@ -60,11 +80,13 @@ function new()
rs.setOutput(side, digital_is_active(channel, level))
end
end
-
+
self.rtu.connect_coil(f_read, f_write)
end
- local link_ai = function (channel, side)
+ -- link analog input
+ ---@param side string
+ public.link_ai = function (side)
self.rtu.connect_input_reg(
function ()
return rs.getAnalogInput(side)
@@ -72,7 +94,9 @@ function new()
)
end
- local link_ao = function (channel, side)
+ -- link analog output
+ ---@param side string
+ public.link_ao = function (side)
self.rtu.connect_holding_reg(
function ()
return rs.getAnalogOutput(side)
@@ -83,11 +107,7 @@ function new()
)
end
- return {
- rtu_interface = rtu_interface,
- link_di = link_di,
- link_do = link_do,
- link_ai = link_ai,
- link_ao = link_ao
- }
+ return public
end
+
+return redstone_rtu
diff --git a/rtu/dev/turbine_rtu.lua b/rtu/dev/turbine_rtu.lua
index 7584270..476a50c 100644
--- a/rtu/dev/turbine_rtu.lua
+++ b/rtu/dev/turbine_rtu.lua
@@ -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 = {
- rtu = rtu.rtu_init(),
+ rtu = rtu.init_unit(),
turbine = turbine
}
- local rtu_interface = function ()
- return self.rtu
- end
-
-- discrete inputs --
-- none
@@ -23,15 +23,15 @@ function new(turbine)
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.getMaxFlowRate)
- self.rtu.connect_input_reg(self.turbine.getMaxWaterOutput)
self.rtu.connect_input_reg(self.turbine.getMaxProduction)
+ self.rtu.connect_input_reg(self.turbine.getMaxWaterOutput)
-- 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)
+ self.rtu.connect_input_reg(self.turbine.getDumpingMode)
-- tanks
self.rtu.connect_input_reg(self.turbine.getSteam)
self.rtu.connect_input_reg(self.turbine.getSteamNeeded)
@@ -40,7 +40,7 @@ function new(turbine)
-- holding registers --
-- none
- return {
- rtu_interface = rtu_interface
- }
+ return self.rtu.interface()
end
+
+return turbine_rtu
diff --git a/rtu/dev/turbinev_rtu.lua b/rtu/dev/turbinev_rtu.lua
new file mode 100644
index 0000000..aa7a108
--- /dev/null
+++ b/rtu/dev/turbinev_rtu.lua
@@ -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
diff --git a/rtu/modbus.lua b/rtu/modbus.lua
new file mode 100644
index 0000000..2654405
--- /dev/null
+++ b/rtu/modbus.lua
@@ -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
diff --git a/rtu/rtu.lua b/rtu/rtu.lua
index efa31f2..2309be5 100644
--- a/rtu/rtu.lua
+++ b/rtu/rtu.lua
@@ -1,12 +1,26 @@
--- #REQUIRES comms.lua
--- #REQUIRES modbus.lua
--- #REQUIRES ppm.lua
+local comms = require("scada-common.comms")
+local ppm = require("scada-common.ppm")
+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 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 = {
discrete_inputs = {},
coils = {},
@@ -15,49 +29,71 @@ function rtu_init()
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 ()
self.io_count_cache = { #self.discrete_inputs, #self.coils, #self.input_regs, #self.holding_regs }
end
- -- return : IO count table
- local io_count = function ()
- return self.io_count_cache[0], self.io_count_cache[1], self.io_count_cache[2], self.io_count_cache[3]
+ -- return IO count
+ ---@return integer discrete_inputs, integer coils, integer input_regs, integer holding_regs
+ 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
-- discrete inputs: single bit read-only
- -- return : count of discrete inputs
- local connect_di = function (f)
- table.insert(self.discrete_inputs, f)
+ -- connect discrete input
+ ---@param f function
+ ---@return integer count count of discrete inputs
+ protected.connect_di = function (f)
+ insert(self.discrete_inputs, { read = f })
_count_io()
return #self.discrete_inputs
end
- -- return : value, access fault
- local read_di = function (di_addr)
+ -- read discrete input
+ ---@param di_addr integer
+ ---@return any value, boolean access_fault
+ public.read_di = function (di_addr)
ppm.clear_fault()
- local value = self.discrete_inputs[di_addr]()
+ local value = self.discrete_inputs[di_addr].read()
return value, ppm.is_faulted()
end
-- coils: single bit read-write
- -- return : count of coils
- local connect_coil = function (f_read, f_write)
- table.insert(self.coils, { read = f_read, write = f_write })
+ -- connect coil
+ ---@param f_read function
+ ---@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()
return #self.coils
end
- -- return : value, access fault
- local read_coil = function (coil_addr)
+ -- read coil
+ ---@param coil_addr integer
+ ---@return any value, boolean access_fault
+ public.read_coil = function (coil_addr)
ppm.clear_fault()
local value = self.coils[coil_addr].read()
return value, ppm.is_faulted()
end
- -- return : access fault
- local write_coil = function (coil_addr, value)
+ -- write coil
+ ---@param coil_addr integer
+ ---@param value any
+ ---@return boolean access_fault
+ public.write_coil = function (coil_addr, value)
ppm.clear_fault()
self.coils[coil_addr].write(value)
return ppm.is_faulted()
@@ -65,67 +101,88 @@ function rtu_init()
-- input registers: multi-bit read-only
- -- return : count of input registers
- local connect_input_reg = function (f)
- table.insert(self.input_regs, f)
+ -- connect input register
+ ---@param f function
+ ---@return integer count count of input registers
+ protected.connect_input_reg = function (f)
+ insert(self.input_regs, { read = f })
_count_io()
return #self.input_regs
end
- -- return : value, access fault
- local read_input_reg = function (reg_addr)
+ -- read input register
+ ---@param reg_addr integer
+ ---@return any value, boolean access_fault
+ public.read_input_reg = function (reg_addr)
ppm.clear_fault()
- local value = self.coils[reg_addr]()
+ local value = self.input_regs[reg_addr].read()
return value, ppm.is_faulted()
end
-- holding registers: multi-bit read-write
- -- return : count of holding registers
- local connect_holding_reg = function (f_read, f_write)
- table.insert(self.holding_regs, { read = f_read, write = f_write })
+ -- connect holding register
+ ---@param f_read function
+ ---@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()
return #self.holding_regs
end
- -- return : value, access fault
- local read_holding_reg = function (reg_addr)
+ -- read holding register
+ ---@param reg_addr integer
+ ---@return any value, boolean access_fault
+ public.read_holding_reg = function (reg_addr)
ppm.clear_fault()
- local value = self.coils[reg_addr].read()
+ local value = self.holding_regs[reg_addr].read()
return value, ppm.is_faulted()
end
- -- return : access fault
- local write_holding_reg = function (reg_addr, value)
+ -- write holding register
+ ---@param reg_addr integer
+ ---@param value any
+ ---@return boolean access_fault
+ public.write_holding_reg = function (reg_addr, value)
ppm.clear_fault()
- self.coils[reg_addr].write(value)
+ self.holding_regs[reg_addr].write(value)
return ppm.is_faulted()
end
- return {
- io_count = io_count,
- connect_di = connect_di,
- read_di = read_di,
- connect_coil = connect_coil,
- read_coil = read_coil,
- write_coil = write_coil,
- connect_input_reg = connect_input_reg,
- read_input_reg = read_input_reg,
- connect_holding_reg = connect_holding_reg,
- read_holding_reg = read_holding_reg,
- write_holding_reg = write_holding_reg
- }
+ -- public RTU device access
+
+ -- get the public interface to this RTU
+ protected.interface = function ()
+ return public
+ end
+
+ return protected
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 = {
+ version = version,
seq_num = 0,
+ r_seq_num = nil,
txn_id = 0,
modem = modem,
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
if not self.modem.isOpen(self.l_port) then
self.modem.open(self.l_port)
@@ -133,27 +190,109 @@ function rtu_comms(modem, local_port, server_port)
-- PRIVATE FUNCTIONS --
- local _send = function (protocol, msg)
- local packet = comms.scada_packet()
- packet.make(self.seq_num, protocol, msg)
- self.modem.transmit(self.s_port, self.l_port, packet.raw())
+ -- send a scada management packet
+ ---@param msg_type SCADA_MGMT_TYPES
+ ---@param msg table
+ 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
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 --
+ -- 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
- 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 s_pkt = comms.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
-- get as MODBUS TCP packet
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
pkt = m_pkt.get()
end
@@ -161,109 +300,120 @@ function rtu_comms(modem, local_port, server_port)
elseif s_pkt.protocol() == PROTOCOLS.SCADA_MGMT then
local mgmt_pkt = comms.mgmt_packet()
if mgmt_pkt.decode(s_pkt) then
- pkt = mgmt_packet.get()
+ pkt = mgmt_pkt.get()
end
else
- log._error("illegal packet type " .. s_pkt.protocol(), true)
+ log.error("illegal packet type " .. s_pkt.protocol(), true)
end
end
return pkt
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
+ -- 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()
if protocol == PROTOCOLS.MODBUS_TCP then
- -- MODBUS instruction
- if packet.unit_id <= #units then
- local unit = units[packet.unit_id]
- local return_code, response = unit.modbus_io.handle_packet(packet)
- _send(PROTOCOLS.MODBUS_TCP, response)
+ local return_code = false
+ local reply = modbus.reply__neg_ack(packet)
- if not return_code then
- log._warning("MODBUS operation failed")
+ -- 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
+ 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
else
-- 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
+
+ public.send_modbus(reply)
elseif protocol == PROTOCOLS.SCADA_MGMT then
-- 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
- ref.linked = true
+ rtu_state.linked = true
+ self.r_seq_num = nil
elseif packet.type == SCADA_MGMT_TYPES.RTU_ADVERT then
-- request for capabilities again
- send_advertisement(units)
+ public.send_advertisement(units)
else
-- not supported
- log._warning("RTU got unexpected SCADA message type " .. packet.type, true)
+ log.warning("RTU got unexpected SCADA message type " .. packet.type)
end
else
-- 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
- -- send capability advertisement
- 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
- }
+ return public
end
+
+return rtu
diff --git a/rtu/startup.lua b/rtu/startup.lua
index 576dd47..6f5730e 100644
--- a/rtu/startup.lua
+++ b/rtu/startup.lua
@@ -2,122 +2,189 @@
-- RTU: Remote Terminal Unit
--
-os.loadAPI("scada-common/log.lua")
-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")
+require("/initenv").init_env()
-os.loadAPI("config.lua")
-os.loadAPI("rtu.lua")
+local log = require("scada-common.log")
+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")
-os.loadAPI("dev/boiler_rtu.lua")
-os.loadAPI("dev/imatrix_rtu.lua")
-os.loadAPI("dev/turbine_rtu.lua")
+local config = require("rtu.config")
+local modbus = require("rtu.modbus")
+local rtu = require("rtu.rtu")
+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 println = util.println
local print_ts = util.print_ts
local println_ts = util.println_ts
-log._info("========================================")
-log._info("BOOTING rtu.startup " .. RTU_VERSION)
-log._info("========================================")
+log.init(config.LOG_PATH, config.LOG_MODE)
+
+log.info("========================================")
+log.info("BOOTING rtu.startup " .. RTU_VERSION)
+log.info("========================================")
println(">> RTU " .. RTU_VERSION .. " <<")
----------------------------------------
-- startup
----------------------------------------
-local units = {}
-local linked = false
-
-- mount connected devices
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
-local modem = ppm.get_wireless_modem()
-if modem == nil then
+if smem_dev.modem == nil then
println("boot> wireless modem not found")
- log._warning("no wireless modem on startup")
+ log.warning("no wireless modem on startup")
return
end
-local rtu_comms = rtu.rtu_comms(modem, config.LISTEN_PORT, config.SERVER_PORT)
+----------------------------------------
+-- interpret config and init units
+----------------------------------------
-----------------------------------------
--- determine configuration
-----------------------------------------
+local units = __shared_memory.rtu_sys.units
local rtu_redstone = config.RTU_REDSTONE
local rtu_devices = config.RTU_DEVICES
-- redstone interfaces
-for reactor_idx = 1, #rtu_redstone do
+for entry_idx = 1, #rtu_redstone do
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 = {}
- 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 .. "...")
- for i = 1, #io_table do
- local valid = false
- local config = io_table[i]
+ local continue = true
- -- verify configuration
- if rsio.is_valid_channel(config.channel) and rsio.is_valid_side(config.side) then
- if config.bundled_color then
- valid = rsio.is_color(config.bundled_color)
- else
- valid = true
- end
- end
-
- if not valid then
- local message = "init> invalid redstone definition at index " .. i .. " in definition block #" .. reactor_idx ..
- " (for reactor " .. rtu_redstone[reactor_idx].for_reactor .. ")"
- println_ts(message)
- log._warning(message)
- else
- -- link redstone in RTU
- local mode = rsio.get_io_mode(config.channel)
- if mode == rsio.IO_MODE.DIGITAL_IN then
- rs_rtu.link_di(config.channel, config.side, config.bundled_color)
- elseif mode == rsio.IO_MODE.DIGITAL_OUT then
- rs_rtu.link_do(config.channel, config.side, config.bundled_color)
- elseif mode == rsio.IO_MODE.ANALOG_IN then
- rs_rtu.link_ai(config.channel, config.side)
- elseif mode == rsio.IO_MODE.ANALOG_OUT then
- rs_rtu.link_ao(config.channel, config.side)
- else
- -- should be unreachable code, we already validated channels
- log._error("init> fell through if chain attempting to identify IO mode", true)
- break
- end
-
- table.insert(capabilities, config.channel)
-
- log._debug("init> linked redstone " .. #capabilities .. ": " .. rsio.to_string(config.channel) .. " (" .. config.side ..
- ") for reactor " .. rtu_redstone[reactor_idx].for_reactor)
+ 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
- table.insert(units, {
- name = "redstone_io",
- type = "redstone",
- index = 1,
- reactor = rtu_redstone[reactor_idx].for_reactor,
- device = capabilities, -- use device field for redstone channels
- rtu = rs_rtu,
- modbus_io = modbus.new(rs_rtu)
- })
+ if continue then
+ for i = 1, #io_table do
+ local valid = false
+ local conf = io_table[i]
- log._debug("init> initialized RTU unit #" .. #units .. ": redstone_io (redstone) [1] for reactor " .. rtu_redstone[reactor_idx].for_reactor)
+ -- verify configuration
+ if rsio.is_valid_channel(conf.channel) and rsio.is_valid_side(conf.side) then
+ if conf.bundled_color then
+ valid = rsio.is_color(conf.bundled_color)
+ else
+ valid = true
+ end
+ end
+
+ if not valid then
+ local message = "init> invalid redstone definition at index " .. i .. " in definition block #" .. entry_idx ..
+ " (for reactor " .. io_reactor .. ")"
+ println_ts(message)
+ log.warning(message)
+ else
+ -- link redstone in RTU
+ local mode = rsio.get_io_mode(conf.channel)
+ if mode == rsio.IO_MODE.DIGITAL_IN then
+ -- 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
+ rs_rtu.link_do(conf.channel, conf.side, conf.bundled_color)
+ elseif mode == rsio.IO_MODE.ANALOG_IN then
+ -- 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
+ rs_rtu.link_ao(conf.side)
+ else
+ -- should be unreachable code, we already validated channels
+ log.error("init> fell through if chain attempting to identify IO mode", true)
+ break
+ end
+
+ table.insert(capabilities, conf.channel)
+
+ log.debug("init> linked redstone " .. #capabilities .. ": " .. rsio.to_string(conf.channel) .. " (" .. conf.side ..
+ ") for reactor " .. io_reactor)
+ end
+ end
+
+ ---@class rtu_unit_registry_entry
+ local unit = {
+ name = "redstone_io",
+ type = rtu_t.redstone,
+ index = entry_idx,
+ reactor = io_reactor,
+ device = capabilities, -- use device field for redstone channels
+ rtu = rs_rtu,
+ modbus_io = modbus.new(rs_rtu, false),
+ pkt_queue = nil,
+ thread = nil
+ }
+
+ table.insert(units, unit)
+
+ log.debug("init> initialized RTU unit #" .. #units .. ": redstone_io (redstone) [1] for reactor " .. io_reactor)
+ end
end
-- mounted peripherals
@@ -127,124 +194,93 @@ for i = 1, #rtu_devices do
if device == nil then
local message = "init> '" .. rtu_devices[i].name .. "' not found"
println_ts(message)
- log._warning(message)
+ log.warning(message)
else
local type = ppm.get_type(rtu_devices[i].name)
- local rtu_iface = nil
+ local rtu_iface = nil ---@type rtu_device
local rtu_type = ""
if type == "boiler" then
-- boiler multiblock
- rtu_type = "boiler"
+ rtu_type = rtu_t.boiler
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
-- turbine multiblock
- rtu_type = "turbine"
+ rtu_type = rtu_t.turbine
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
- -- assumed to be an induction matrix multiblock
- rtu_type = "imatrix"
+ -- assumed to be an induction matrix multiblock, pre Mekanism 10.1
+ -- 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)
else
local message = "init> device '" .. rtu_devices[i].name .. "' is not a known type (" .. type .. ")"
println_ts(message)
- log._warning(message)
+ log.warning(message)
end
if rtu_iface ~= nil then
- table.insert(units, {
+ ---@class rtu_unit_registry_entry
+ local rtu_unit = {
name = rtu_devices[i].name,
type = rtu_type,
index = rtu_devices[i].index,
reactor = rtu_devices[i].for_reactor,
device = device,
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)
end
end
end
----------------------------------------
--- main loop
+-- start system
----------------------------------------
--- advertisement/heartbeat clock (every 2 seconds)
-local loop_clock = os.startTimer(2)
+-- start connection watchdog
+smem_sys.conn_watchdog = util.new_watchdog(5)
+log.debug("boot> conn watchdog started")
--- event loop
-while true do
- local event, param1, param2, param3, param4, param5 = os.pullEventRaw()
+-- setup comms
+smem_sys.rtu_comms = rtu.comms(RTU_VERSION, smem_dev.modem, config.LISTEN_PORT, config.SERVER_PORT, smem_sys.conn_watchdog)
+log.debug("boot> comms init")
- if event == "peripheral_detach" then
- -- handle loss of a device
- local device = ppm.handle_unmount(param1)
+-- init threads
+local main_thread = threads.thread__main(__shared_memory)
+local comms_thread = threads.thread__comms(__shared_memory)
- 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
- 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
+-- assemble thread list
+local _threads = { main_thread.p_exec, comms_thread.p_exec }
+for i = 1, #units do
+ if units[i].thread ~= nil then
+ table.insert(_threads, units[i].thread.p_exec)
end
end
+-- run threads
+parallel.waitForAll(table.unpack(_threads))
+
println_ts("exited")
-log._info("exited")
+log.info("exited")
diff --git a/rtu/threads.lua b/rtu/threads.lua
new file mode 100644
index 0000000..3d83acf
--- /dev/null
+++ b/rtu/threads.lua
@@ -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
diff --git a/scada-common/alarm.lua b/scada-common/alarm.lua
index b93df73..2fcaa19 100644
--- a/scada-common/alarm.lua
+++ b/scada-common/alarm.lua
@@ -1,3 +1,9 @@
+local util = require("scada-common.util")
+
+---@class alarm
+local alarm = {}
+
+---@alias SEVERITY integer
SEVERITY = {
INFO = 0, -- basic info message
WARNING = 1, -- warning about some abnormal state
@@ -7,35 +13,11 @@ SEVERITY = {
EMERGENCY = 5 -- critical safety alarm
}
-function scada_alarm(severity, device, message)
- local self = {
- time = os.time(),
- ts_string = os.date("[%H:%M:%S]"),
- severity = severity,
- device = device,
- message = message
- }
+alarm.SEVERITY = SEVERITY
- local format = function ()
- return self.ts_string .. " [" .. severity_to_string(self.severity) .. "] (" .. self.device ") >> " .. self.message
- end
-
- 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)
+-- severity integer to string
+---@param severity SEVERITY
+alarm.severity_to_string = function (severity)
if severity == SEVERITY.INFO then
return "INFO"
elseif severity == SEVERITY.WARNING then
@@ -52,3 +34,40 @@ function severity_to_string(severity)
return "UNKNOWN"
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
diff --git a/scada-common/comms.lua b/scada-common/comms.lua
index ac0c3c5..775127f 100644
--- a/scada-common/comms.lua
+++ b/scada-common/comms.lua
@@ -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
RPLC = 1, -- reactor PLC protocol
- SCADA_MGMT = 2, -- SCADA supervisor intercommunication, device advertisements, etc
- COORD_DATA = 3 -- data packets for coordinators to/from supervisory controller
+ SCADA_MGMT = 2, -- SCADA supervisor management, device advertisements, etc
+ 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 = {
- ACTIVE = 0, -- supervisor running as primary
- BACKUP = 1 -- supervisor running as hot backup
+---@alias RPLC_TYPES integer
+local RPLC_TYPES = {
+ 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 = {
- KEEP_ALIVE = 0, -- keep alive packets
- 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 = {
+---@alias RPLC_LINKING integer
+local RPLC_LINKING = {
ALLOW = 0, -- link approved
DENY = 1, -- link denied
COLLISION = 2 -- link denied due to existing active link
}
-SCADA_MGMT_TYPES = {
- PING = 0, -- generic ping
- SV_HEARTBEAT = 1, -- supervisor heartbeat
- REMOTE_LINKED = 2, -- remote device linked
- RTU_ADVERT = 3, -- RTU capability advertisement
- RTU_HEARTBEAT = 4, -- RTU heartbeat
+---@alias SCADA_MGMT_TYPES integer
+local SCADA_MGMT_TYPES = {
+ KEEP_ALIVE = 0, -- keep alive packet w/ RTT
+ CLOSE = 1, -- close a connection
+ RTU_ADVERT = 2, -- RTU capability advertisement
+ REMOTE_LINKED = 3 -- remote device linked
}
-RTU_ADVERT_TYPES = {
- BOILER = 0, -- boiler
- TURBINE = 1, -- turbine
- IMATRIX = 2, -- induction matrix
- REDSTONE = 3 -- redstone I/O
+---@alias RTU_UNIT_TYPES integer
+local RTU_UNIT_TYPES = {
+ REDSTONE = 0, -- redstone I/O
+ BOILER = 1, -- boiler
+ 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
-function scada_packet()
+comms.scada_packet = function ()
local self = {
modem_msg_in = nil,
valid = false,
+ raw = nil,
seq_num = nil,
protocol = 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.seq_num = seq_num
self.protocol = protocol
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
- 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 = {
iface = side,
s_port = sender,
@@ -74,107 +110,500 @@ function scada_packet()
self.raw = self.modem_msg_in.msg
- if #self.raw < 3 then
- -- malformed
- return false
- else
- self.valid = true
- self.seq_num = self.raw[1]
- self.protocol = self.raw[2]
- self.length = self.raw[3]
+ if type(self.raw) == "table" then
+ if #self.raw >= 3 then
+ self.valid = true
+ self.seq_num = self.raw[1]
+ self.protocol = self.raw[2]
+ self.length = #self.raw[3]
+ self.payload = self.raw[3]
+ end
end
+
+ return self.valid
end
- local modem_event = function () return self.modem_msg_in end
- local raw = function () return self.raw end
+ -- public accessors --
- local is_valid = function () return self.valid end
+ public.modem_event = function () return self.modem_msg_in end
+ public.raw_sendable = function () return self.raw end
- local seq_num = function () return self.seq_num end
- local protocol = function () return self.protocol end
- local length = function () return self.length end
+ public.local_port = function () return self.modem_msg_in.s_port end
+ public.remote_port = function () return self.modem_msg_in.r_port end
- local data = function ()
- local subset = nil
- if self.valid then
- subset = { table.unpack(self.raw, 4, 3 + self.length) }
- end
- return subset
- end
+ public.is_valid = function () return self.valid end
- return {
- make = make,
- receive = receive,
- modem_event = modem_event,
- raw = raw,
- is_valid = is_valid,
- seq_num = seq_num,
- protocol = protocol,
- length = length,
- 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
-function mgmt_packet()
+-- MODBUS packet
+-- modeled after MODBUS TCP packet
+comms.modbus_packet = function ()
local self = {
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,
length = nil,
data = nil
}
+ ---@class mgmt_packet
+ local public = {}
+
+ -- check that type is known
local _scada_type_valid = function ()
- return self.type == SCADA_MGMT_TYPES.PING or
- self.type == SCADA_MGMT_TYPES.SV_HEARTBEAT or
+ return self.type == SCADA_MGMT_TYPES.KEEP_ALIVE or
+ self.type == SCADA_MGMT_TYPES.CLOSE or
self.type == SCADA_MGMT_TYPES.REMOTE_LINKED or
- self.type == SCADA_MGMT_TYPES.RTU_ADVERT or
- self.type == SCADA_MGMT_TYPES.RTU_HEARTBEAT
+ self.type == SCADA_MGMT_TYPES.RTU_ADVERT
end
-- 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.length = length
+ 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 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
self.frame = frame
- if frame.protocol() == comms.PROTOCOLS.SCADA_MGMT then
- local data = frame.data()
- local ok = #data > 1
-
+ if frame.protocol() == PROTOCOLS.SCADA_MGMT then
+ local ok = frame.length() >= 1
+
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()
end
-
+
return ok
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
end
else
- log._debug("nil frame encountered", true)
+ log.debug("nil frame encountered", true)
return false
end
end
- local get = function ()
- return {
+ -- 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 mgmt_frame
+ local frame = {
scada_frame = self.frame,
type = self.type,
length = self.length,
data = self.data
}
+
+ return frame
end
- return {
- make = make,
- decode = decode,
- get = get
- }
+ return public
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
diff --git a/scada-common/log.lua b/scada-common/log.lua
index fbe2d66..2fa0b32 100644
--- a/scada-common/log.lua
+++ b/scada-common/log.lua
@@ -1,64 +1,205 @@
+local util = require("scada-common.util")
+
--
-- File System Logger
--
--- we use extra short abbreviations since computer craft screens are very small
--- underscores are used since some of these names are used elsewhere (e.g. 'debug' is a lua table)
+---@class log
+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 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)
- local stamped = os.date("[%c] ") .. msg
- file_handle.writeLine(stamped)
- file_handle.flush()
+---@type function
+local free_space = fs.getFreeSpace
+
+-- 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
-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
local dbg_info = ""
if trace then
+ local info = debug.getinfo(2)
local name = ""
- if debug.getinfo(2).name ~= nil then
- name = ":" .. debug.getinfo(2).name .. "():"
+ if info.name ~= nil then
+ name = ":" .. info.name .. "():"
end
- dbg_info = debug.getinfo(2).short_src .. ":" .. name ..
- debug.getinfo(2).currentline .. " > "
+ dbg_info = info.short_src .. ":" .. name .. info.currentline .. " > "
end
- _log("[DBG] " .. dbg_info .. msg)
+ _log("[DBG] " .. dbg_info .. util.strval(msg))
end
end
-function _info(msg)
- _log("[INF] " .. msg)
+-- log info messages
+---@param msg string message
+log.info = function (msg)
+ _log("[INF] " .. util.strval(msg))
end
-function _warning(msg)
- _log("[WRN] " .. msg)
+-- log warning messages
+---@param msg string message
+log.warning = function (msg)
+ _log("[WRN] " .. util.strval(msg))
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 = ""
-
+
if trace then
+ local info = debug.getinfo(2)
local name = ""
- if debug.getinfo(2).name ~= nil then
- name = ":" .. debug.getinfo(2).name .. "():"
+ if info.name ~= nil then
+ name = ":" .. info.name .. "():"
end
-
- dbg_info = debug.getinfo(2).short_src .. ":" .. name ..
- debug.getinfo(2).currentline .. " > "
+
+ dbg_info = info.short_src .. ":" .. name .. info.currentline .. " > "
end
- _log("[ERR] " .. dbg_info .. msg)
+ _log("[ERR] " .. dbg_info .. util.strval(msg))
end
-function _fatal(msg)
- _log("[FTL] " .. msg)
+-- log fatal errors
+---@param msg string message
+log.fatal = function (msg)
+ _log("[FTL] " .. util.strval(msg))
end
+
+return log
diff --git a/scada-common/modbus.lua b/scada-common/modbus.lua
deleted file mode 100644
index 8a4137f..0000000
--- a/scada-common/modbus.lua
+++ /dev/null
@@ -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
diff --git a/scada-common/mqueue.lua b/scada-common/mqueue.lua
new file mode 100644
index 0000000..8069ecb
--- /dev/null
+++ b/scada-common/mqueue.lua
@@ -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
diff --git a/scada-common/ppm.lua b/scada-common/ppm.lua
index fd92b23..d07e703 100644
--- a/scada-common/ppm.lua
+++ b/scada-common/ppm.lua
@@ -1,57 +1,113 @@
--- #REQUIRES log.lua
+local log = require("scada-common.log")
--
-- Protected Peripheral Manager
--
-ACCESS_OK = true
-ACCESS_FAULT = nil
+---@class ppm
+local ppm = {}
+
+local ACCESS_FAULT = nil ---@type nil
+
+ppm.ACCESS_FAULT = ACCESS_FAULT
----------------------------
-- PRIVATE DATA/FUNCTIONS --
----------------------------
-local self = {
+local REPORT_FREQUENCY = 20 -- log every 20 faults per function
+
+local _ppm_sys = {
mounts = {},
auto_cf = false,
faulted = false,
+ last_fault = "",
terminate = false,
mute = false
}
--- wrap peripheral calls with lua protected call
--- ex. reason: we don't want a disconnect to crash the program before a SCRAM
-local peri_init = function (device)
- for key, func in pairs(device) do
- device[key] = function (...)
+-- wrap peripheral calls with lua protected call as we don't want a disconnect to crash a program
+---
+---also provides peripheral-specific fault checks (auto-clear fault defaults to true)
+---
+---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, ...)
if status then
-- auto fault clear
if self.auto_cf then self.faulted = false end
+ if _ppm_sys.auto_cf then _ppm_sys.faulted = false end
- -- assume nil is only for functions with no return, so return status
- if result == nil then
- return ACCESS_OK
- else
- return result
- end
+ self.fault_counts[key] = 0
+
+ return result
else
-- function failed
self.faulted = true
+ self.last_fault = result
- if not mute then
- log._error("PPM: protected " .. key .. "() -> " .. result)
+ _ppm_sys.faulted = true
+ _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
+
+ log.error("PPM: protected " .. key .. "() -> " .. result .. count_str)
end
+ self.fault_counts[key] = self.fault_counts[key] + 1
+
if result == "Terminated" then
- self.terminate = true
+ _ppm_sys.terminate = true
end
return ACCESS_FAULT
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
----------------------
@@ -61,132 +117,152 @@ end
-- REPORTING --
-- silence error prints
-function disable_reporting()
- self.mute = true
+ppm.disable_reporting = function ()
+ _ppm_sys.mute = true
end
-- allow error prints
-function enable_reporting()
- self.mute = false
+ppm.enable_reporting = function ()
+ _ppm_sys.mute = false
end
-- FAULT MEMORY --
-- enable automatically clearing fault flag
-function enable_afc()
- self.auto_cf = true
+ppm.enable_afc = function ()
+ _ppm_sys.auto_cf = true
end
-- disable automatically clearing fault flag
-function disable_afc()
- self.auto_cf = false
-end
-
--- check fault flag
-function is_faulted()
- return self.faulted
+ppm.disable_afc = function ()
+ _ppm_sys.auto_cf = false
end
-- clear fault flag
-function clear_fault()
- self.faulted = false
+ppm.clear_fault = function ()
+ _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
-- TERMINATION --
-- if a caught error was a termination request
-function should_terminate()
- return self.terminate
+ppm.should_terminate = function ()
+ return _ppm_sys.terminate
end
-- MOUNTING --
-- mount all available peripherals (clears mounts first)
-function mount_all()
+ppm.mount_all = function ()
local ifaces = peripheral.getNames()
- self.mounts = {}
+ _ppm_sys.mounts = {}
for i = 1, #ifaces do
- local pm_dev = peripheral.wrap(ifaces[i])
- peri_init(pm_dev)
+ _ppm_sys.mounts[ifaces[i]] = peri_init(ifaces[i])
- self.mounts[ifaces[i]] = {
- type = peripheral.getType(ifaces[i]),
- dev = pm_dev
- }
-
- log._debug("PPM: found a " .. self.mounts[ifaces[i]].type .. " (" .. ifaces[i] .. ")")
+ log.info("PPM: found a " .. _ppm_sys.mounts[ifaces[i]].type .. " (" .. ifaces[i] .. ")")
end
if #ifaces == 0 then
- log._warning("PPM: mount_all() -> no devices found")
+ log.warning("PPM: mount_all() -> no devices found")
end
end
-- 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 pm_dev = nil
- local type = nil
+ local pm_type = nil
for i = 1, #ifaces do
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_dev = peripheral.wrap(iface)
- peri_init(pm_dev)
+ pm_type = _ppm_sys.mounts[iface].type
+ pm_dev = _ppm_sys.mounts[iface].dev
- self.mounts[iface] = {
- type = peripheral.getType(iface),
- dev = pm_dev
- }
+ log.info("PPM: mount(" .. iface .. ") -> found a " .. pm_type)
break
end
end
- return type, pm_dev
+ return pm_type, pm_dev
end
-- handle peripheral_detach event
-function handle_unmount(iface)
- -- what got disconnected?
- local lost_dev = self.mounts[iface]
- local type = lost_dev.type
-
- log._warning("PPM: lost device " .. type .. " mounted to " .. 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
- return lost_dev
+ -- what got disconnected?
+ local lost_dev = _ppm_sys.mounts[iface]
+
+ if lost_dev then
+ pm_type = lost_dev.type
+ pm_dev = lost_dev.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
-- GENERAL ACCESSORS --
-- list all available peripherals
-function list_avail()
+---@return table names
+ppm.list_avail = function ()
return peripheral.getNames()
end
-- list mounted peripherals
-function list_mounts()
- return self.mounts
+---@return table mounts
+ppm.list_mounts = function ()
+ return _ppm_sys.mounts
end
-- get a mounted peripheral by side/interface
-function get_periph(iface)
- return self.mounts[iface].dev
+---@param iface string CC peripheral interface
+---@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
-- get a mounted peripheral type by side/interface
-function get_type(iface)
- return self.mounts[iface].type
+---@param iface string CC peripheral interface
+---@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
-- 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 = {}
- for side, data in pairs(self.mounts) do
+ for _, data in pairs(_ppm_sys.mounts) do
if data.type == name then
table.insert(devices, data.dev)
end
@@ -196,31 +272,35 @@ function get_all_devices(name)
end
-- 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
- for side, data in pairs(self.mounts) do
+ for side, data in pairs(_ppm_sys.mounts) do
if data.type == name then
device = data.dev
break
end
end
-
+
return device
end
-- SPECIFIC DEVICE ACCESSORS --
-- get the fission reactor (if multiple, returns the first)
-function get_fission_reactor()
- return get_device("fissionReactor")
+---@return table|nil reactor function table
+ppm.get_fission_reactor = function ()
+ return ppm.get_device("fissionReactor")
end
-- 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
- 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
w_modem = device.dev
break
@@ -231,6 +311,9 @@ function get_wireless_modem()
end
-- list all connected monitors
-function list_monitors()
- return get_all_devices("monitor")
+---@return table monitors
+ppm.list_monitors = function ()
+ return ppm.get_all_devices("monitor")
end
+
+return ppm
diff --git a/scada-common/rsio.lua b/scada-common/rsio.lua
index f56a8a4..9ba6878 100644
--- a/scada-common/rsio.lua
+++ b/scada-common/rsio.lua
@@ -1,67 +1,92 @@
-IO_LVL = {
+--
+-- Redstone I/O
+--
+
+local rsio = {}
+
+----------------------
+-- RS I/O CONSTANTS --
+----------------------
+
+---@alias IO_LVL integer
+local IO_LVL = {
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,
OUT = 1
}
-IO_MODE = {
- DIGITAL_OUT = 0,
- DIGITAL_IN = 1,
- ANALOG_OUT = 2,
- ANALOG_IN = 3
+---@alias IO_MODE integer
+local IO_MODE = {
+ DIGITAL_IN = 0,
+ DIGITAL_OUT = 1,
+ ANALOG_IN = 2,
+ ANALOG_OUT = 3
}
-RS_IO = {
+---@alias RS_IO integer
+local RS_IO = {
-- digital inputs --
-- facility
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
- R_SCRAM = 3, -- active low, reactor scram
- R_ENABLE = 4, -- active high, reactor enable
+ R_SCRAM = 2, -- active low, reactor scram
+ R_ENABLE = 3, -- active high, reactor enable
-- digital outputs --
+ -- facility
+ F_ALARM = 4, -- active high, facility safety alarm
+
-- waste
WASTE_PO = 5, -- active low, polonium routing
WASTE_PU = 6, -- active low, plutonium routing
WASTE_AM = 7, -- active low, antimatter routing
-- reactor
- R_SCRAMMED = 8, -- active high, if the reactor is scrammed
- R_AUTO_SCRAM = 9, -- active high, if the reactor was automatically scrammed
- R_ACTIVE = 10, -- active high, if the reactor is active
- R_AUTO_CTRL = 11, -- active high, if the reactor burn rate is automatic
- R_DMG_CRIT = 12, -- active high, if the reactor damage is critical
- R_HIGH_TEMP = 13, -- active high, if the reactor is at a high temperature
- R_NO_COOLANT = 14, -- active high, if the reactor has no coolant
- R_EXCESS_HC = 15, -- active high, if the reactor has excess heated coolant
- R_EXCESS_WS = 16, -- active high, if the reactor has excess waste
- R_INSUFF_FUEL = 17, -- active high, if the reactor has insufficent fuel
- R_PLC_TIMEOUT = 18, -- active high, if the reactor PLC has not been heard from
-
- -- analog outputs --
-
- 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
+ R_ALARM = 8, -- active high, reactor safety alarm
+ R_SCRAMMED = 9, -- active high, if the reactor is scrammed
+ R_AUTO_SCRAM = 10, -- active high, if the reactor was automatically scrammed
+ R_ACTIVE = 11, -- active high, if the reactor is active
+ R_AUTO_CTRL = 12, -- active high, if the reactor burn rate is automatic
+ R_DMG_CRIT = 13, -- active high, if the reactor damage is critical
+ R_HIGH_TEMP = 14, -- active high, if the reactor is at a high temperature
+ R_NO_COOLANT = 15, -- active high, if the reactor has no coolant
+ R_EXCESS_HC = 16, -- active high, if the reactor has excess heated coolant
+ R_EXCESS_WS = 17, -- active high, if the reactor has excess waste
+ 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
+ R_PLC_TIMEOUT = 20 -- active high, if the reactor PLC has not been heard from
}
-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 = {
"F_SCRAM",
- "F_AE2_LIVE",
"R_SCRAM",
"R_ENABLE",
+ "F_ALARM",
"WASTE_PO",
"WASTE_PU",
"WASTE_AM",
+ "R_ALARM",
"R_SCRAMMED",
"R_AUTO_SCRAM",
"R_ACTIVE",
@@ -72,92 +97,79 @@ function to_string(channel)
"R_EXCESS_HC",
"R_EXCESS_WS",
"R_INSUFF_FUEL",
- "R_PLC_TIMEOUT",
- "A_R_BURN_RATE",
- "A_B_BOIL_RATE",
- "A_T_FLOW_RATE"
+ "R_PLC_FAULT",
+ "R_PLC_TIMEOUT"
}
- if channel > 0 and channel <= #names then
+ if type(channel) == "number" and channel > 0 and channel <= #names then
return names[channel]
else
return ""
end
end
-function is_valid_channel(channel)
- return channel ~= nil and channel > 0 and channel <= RS_IO.A_T_FLOW_RATE
-end
+local _B_AND = bit.band
-function is_valid_side(side)
- if side ~= nil then
- 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
+local function _ACTIVE_HIGH(level) return level == IO_LVL.HIGH end
+local function _ACTIVE_LOW(level) return level == IO_LVL.LOW end
-- I/O mappings to I/O function and I/O mode
local RS_DIO_MAP = {
-- F_SCRAM
- { _f = _DI_ACTIVE_LOW, mode = IO_DIR.IN },
- -- F_AE2_LIVE
- { _f = _DI_ACTIVE_HIGH, mode = IO_DIR.IN },
+ { _f = _ACTIVE_LOW, mode = IO_DIR.IN },
-- R_SCRAM
- { _f = _DI_ACTIVE_LOW, mode = IO_DIR.IN },
+ { _f = _ACTIVE_LOW, mode = IO_DIR.IN },
-- 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
- { _f = _DO_ACTIVE_LOW, mode = IO_DIR.OUT },
+ { _f = _ACTIVE_LOW, mode = IO_DIR.OUT },
-- WASTE_PU
- { _f = _DO_ACTIVE_LOW, mode = IO_DIR.OUT },
+ { _f = _ACTIVE_LOW, mode = IO_DIR.OUT },
-- 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
- { _f = _DO_ACTIVE_HIGH, mode = IO_DIR.OUT },
+ { _f = _ACTIVE_HIGH, mode = IO_DIR.OUT },
-- R_AUTO_SCRAM
- { _f = _DO_ACTIVE_HIGH, mode = IO_DIR.OUT },
+ { _f = _ACTIVE_HIGH, mode = IO_DIR.OUT },
-- R_ACTIVE
- { _f = _DO_ACTIVE_HIGH, mode = IO_DIR.OUT },
+ { _f = _ACTIVE_HIGH, mode = IO_DIR.OUT },
-- R_AUTO_CTRL
- { _f = _DO_ACTIVE_HIGH, mode = IO_DIR.OUT },
+ { _f = _ACTIVE_HIGH, mode = IO_DIR.OUT },
-- R_DMG_CRIT
- { _f = _DO_ACTIVE_HIGH, mode = IO_DIR.OUT },
+ { _f = _ACTIVE_HIGH, mode = IO_DIR.OUT },
-- R_HIGH_TEMP
- { _f = _DO_ACTIVE_HIGH, mode = IO_DIR.OUT },
+ { _f = _ACTIVE_HIGH, mode = IO_DIR.OUT },
-- R_NO_COOLANT
- { _f = _DO_ACTIVE_HIGH, mode = IO_DIR.OUT },
+ { _f = _ACTIVE_HIGH, mode = IO_DIR.OUT },
-- R_EXCESS_HC
- { _f = _DO_ACTIVE_HIGH, mode = IO_DIR.OUT },
+ { _f = _ACTIVE_HIGH, mode = IO_DIR.OUT },
-- R_EXCESS_WS
- { _f = _DO_ACTIVE_HIGH, mode = IO_DIR.OUT },
+ { _f = _ACTIVE_HIGH, mode = IO_DIR.OUT },
-- 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
- { _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 = {
IO_MODE.DIGITAL_IN, -- F_SCRAM
- IO_MODE.DIGITAL_IN, -- F_AE2_LIVE
IO_MODE.DIGITAL_IN, -- R_SCRAM
IO_MODE.DIGITAL_IN, -- R_ENABLE
+ IO_MODE.DIGITAL_OUT, -- F_ALARM
IO_MODE.DIGITAL_OUT, -- WASTE_PO
IO_MODE.DIGITAL_OUT, -- WASTE_PU
IO_MODE.DIGITAL_OUT, -- WASTE_AM
+ IO_MODE.DIGITAL_OUT, -- R_ALARM
IO_MODE.DIGITAL_OUT, -- R_SCRAMMED
IO_MODE.DIGITAL_OUT, -- R_AUTO_SCRAM
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_WS
IO_MODE.DIGITAL_OUT, -- R_INSUFF_FUEL
- IO_MODE.DIGITAL_OUT, -- R_PLC_TIMEOUT
- IO_MODE.ANALOG_OUT, -- A_R_BURN_RATE
- IO_MODE.ANALOG_OUT, -- A_B_BOIL_RATE
- IO_MODE.ANALOG_OUT -- A_T_FLOW_RATE
+ IO_MODE.DIGITAL_OUT, -- R_PLC_FAULT
+ IO_MODE.DIGITAL_OUT -- R_PLC_TIMEOUT
}
- if channel > 0 and channel <= #modes then
+ if type(channel) == "number" and channel > 0 and channel <= #modes then
return modes[channel]
else
return IO_MODE.ANALOG_IN
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
-function digital_read(rs_value)
+---@param rs_value boolean
+---@return IO_LVL
+rsio.digital_read = function (rs_value)
if rs_value then
return IO_LVL.HIGH
else
@@ -191,19 +239,51 @@ function digital_read(rs_value)
end
-- returns the level corresponding to active
-function digital_write(channel, active)
- if channel < RS_IO.WASTE_PO or channel > RS_IO.R_PLC_TIMEOUT then
- return IO_LVL.LOW
+---@param channel RS_IO
+---@param level IO_LVL
+---@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
return RS_DIO_MAP[channel]._f(level)
end
end
-- returns true if the level corresponds to active
-function digital_is_active(channel, level)
- if channel > RS_IO.R_ENABLE or channel > RS_IO.R_PLC_TIMEOUT then
+---@param channel RS_IO
+---@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
else
return RS_DIO_MAP[channel]._f(level)
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
diff --git a/scada-common/types.lua b/scada-common/types.lua
new file mode 100644
index 0000000..b1b5e8c
--- /dev/null
+++ b/scada-common/types.lua
@@ -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
diff --git a/scada-common/util.lua b/scada-common/util.lua
index 1d4e11c..7073d38 100644
--- a/scada-common/util.lua
+++ b/scada-common/util.lua
@@ -1,48 +1,262 @@
--- we are overwriting 'print' so save it first
-local _print = print
+--
+-- Utility Functions
+--
+
+---@class util
+local util = {}
+
+-- PRINT --
-- print
-function print(message)
- term.write(message)
+---@param message any
+util.print = function (message)
+ term.write(tostring(message))
end
-- print line
-function println(message)
- _print(message)
+---@param message any
+util.println = function (message)
+ print(tostring(message))
end
-- timestamped print
-function print_ts(message)
- term.write(os.date("[%H:%M:%S] ") .. message)
+---@param message any
+util.print_ts = function (message)
+ term.write(os.date("[%H:%M:%S] ") .. tostring(message))
end
-- timestamped print line
-function println_ts(message)
- _print(os.date("[%H:%M:%S] ") .. message)
+---@param message any
+util.println_ts = function (message)
+ print(os.date("[%H:%M:%S] ") .. tostring(message))
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
--- triggers a timer event if not fed within 'timeout' seconds
-function new_watchdog(timeout)
- local self = {
- _timeout = timeout,
- _wd_timer = os.startTimer(timeout)
+---@param timeout number timeout duration
+---
+--- 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 = {
+ timeout = timeout,
+ wd_timer = start_timer(timeout)
}
- local get_timer = function ()
- return self._wd_timer
+ ---@class watchdog
+ local public = {}
+
+ ---@param timer number timer event timer ID
+ public.is_timer = function (timer)
+ return self.wd_timer == timer
end
-
- local feed = function ()
- if self._wd_timer ~= nil then
- os.cancelTimer(self._wd_timer)
+
+ -- satiate the beast
+ public.feed = function ()
+ if self.wd_timer ~= nil then
+ cancel_timer(self.wd_timer)
end
- self._wd_timer = os.startTimer(self._timeout)
+ self.wd_timer = start_timer(self.timeout)
end
- return {
- get_timer = get_timer,
- feed = feed
- }
+ -- cancel the watchdog
+ public.cancel = function ()
+ if self.wd_timer ~= nil then
+ cancel_timer(self.wd_timer)
+ end
+ end
+
+ return public
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
diff --git a/startup.lua b/startup.lua
new file mode 100644
index 0000000..482c919
--- /dev/null
+++ b/startup.lua
@@ -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
diff --git a/supervisor/config.lua b/supervisor/config.lua
index bc02177..b98ceb2 100644
--- a/supervisor/config.lua
+++ b/supervisor/config.lua
@@ -1,16 +1,23 @@
--- type ('active','backup')
--- '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'
+local config = {}
-- scada network listen for PLC's and RTU's
-SCADA_DEV_LISTEN = 16000
--- failover synchronization
-SCADA_FO_CHANNEL = 16001
+config.SCADA_DEV_LISTEN = 16000
-- listen port for SCADA supervisor access by coordinators
-SCADA_SV_CHANNEL = 16002
-
+config.SCADA_SV_LISTEN = 16100
-- 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
diff --git a/supervisor/session/coordinator.lua b/supervisor/session/coordinator.lua
new file mode 100644
index 0000000..afa28c0
--- /dev/null
+++ b/supervisor/session/coordinator.lua
@@ -0,0 +1,3 @@
+local coordinator = {}
+
+return coordinator
diff --git a/supervisor/session/plc.lua b/supervisor/session/plc.lua
new file mode 100644
index 0000000..5279208
--- /dev/null
+++ b/supervisor/session/plc.lua
@@ -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
diff --git a/supervisor/session/rtu.lua b/supervisor/session/rtu.lua
new file mode 100644
index 0000000..ff95087
--- /dev/null
+++ b/supervisor/session/rtu.lua
@@ -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
diff --git a/supervisor/session/rtu/boiler.lua b/supervisor/session/rtu/boiler.lua
new file mode 100644
index 0000000..c3fa28e
--- /dev/null
+++ b/supervisor/session/rtu/boiler.lua
@@ -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
diff --git a/supervisor/session/rtu/emachine.lua b/supervisor/session/rtu/emachine.lua
new file mode 100644
index 0000000..e47293a
--- /dev/null
+++ b/supervisor/session/rtu/emachine.lua
@@ -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
diff --git a/supervisor/session/rtu/redstone.lua b/supervisor/session/rtu/redstone.lua
new file mode 100644
index 0000000..b41e223
--- /dev/null
+++ b/supervisor/session/rtu/redstone.lua
@@ -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
diff --git a/supervisor/session/rtu/turbine.lua b/supervisor/session/rtu/turbine.lua
new file mode 100644
index 0000000..d62626e
--- /dev/null
+++ b/supervisor/session/rtu/turbine.lua
@@ -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
diff --git a/supervisor/session/rtu/txnctrl.lua b/supervisor/session/rtu/txnctrl.lua
new file mode 100644
index 0000000..a19dee6
--- /dev/null
+++ b/supervisor/session/rtu/txnctrl.lua
@@ -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
diff --git a/supervisor/session/rtu/unit_session.lua b/supervisor/session/rtu/unit_session.lua
new file mode 100644
index 0000000..83a0766
--- /dev/null
+++ b/supervisor/session/rtu/unit_session.lua
@@ -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
diff --git a/supervisor/session/svsessions.lua b/supervisor/session/svsessions.lua
new file mode 100644
index 0000000..02bf70b
--- /dev/null
+++ b/supervisor/session/svsessions.lua
@@ -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
diff --git a/supervisor/startup.lua b/supervisor/startup.lua
index 4ff56a9..307b8e6 100644
--- a/supervisor/startup.lua
+++ b/supervisor/startup.lua
@@ -2,65 +2,116 @@
-- Nuclear Generation Facility SCADA Supervisor
--
-os.loadAPI("scada-common/log.lua")
-os.loadAPI("scada-common/util.lua")
-os.loadAPI("scada-common/ppm.lua")
-os.loadAPI("scada-common/comms.lua")
+require("/initenv").init_env()
-os.loadAPI("supervisor/config.lua")
-os.loadAPI("supervisor/supervisor.lua")
+local log = require("scada-common.log")
+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 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()
-local modem = ppm.get_device("modem")
-
-print("| SCADA Supervisor - " .. SUPERVISOR_VERSION .. " |")
-
--- we need a modem
+local modem = ppm.get_wireless_modem()
if modem == nil then
- print("Please connect a modem.")
+ println("boot> wireless modem not found")
+ log.warning("no wireless modem on startup")
return
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
-if not modem.isOpen(config.SCADA_DEV_LISTEN) then
- 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 superv_comms = supervisor.comms(SUPERVISOR_VERSION, config.NUM_REACTORS, modem, config.SCADA_DEV_LISTEN, config.SCADA_SV_LISTEN)
-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)
-local loop_tick = os.startTimer(0.25)
+-- 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()
-- handle event
- if event == "timer" and param1 == loop_tick then
- -- basic event tick, send keep-alives
+ if event == "peripheral_detach" then
+ 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
-- got a packet
- elseif event == "terminate" then
- -- safe exit
- print_ts("[alert] terminated\n")
- -- todo: attempt failover, alert hot backup
- return
+ local packet = superv_comms.parse_packet(param1, param2, param3, param4, param5)
+ superv_comms.handle_packet(packet)
+ end
+
+ -- 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
+
+println_ts("exited")
+log.info("exited")
diff --git a/supervisor/supervisor.lua b/supervisor/supervisor.lua
index 5f988a8..ba07d31 100644
--- a/supervisor/supervisor.lua
+++ b/supervisor/supervisor.lua
@@ -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
-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 = {
- mode = mode,
- seq_num = 0,
+ version = version,
num_reactors = num_reactors,
modem = modem,
dev_listen = dev_listen,
- fo_channel = fo_channel,
- sv_channel = sv_channel,
+ coord_listen = coord_listen,
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
+
+return supervisor
diff --git a/supervisor/unit.lua b/supervisor/unit.lua
new file mode 100644
index 0000000..628de67
--- /dev/null
+++ b/supervisor/unit.lua
@@ -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
diff --git a/test/modbustest.lua b/test/modbustest.lua
new file mode 100644
index 0000000..1d0b9ea
--- /dev/null
+++ b/test/modbustest.lua
@@ -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")
diff --git a/test/rstest.lua b/test/rstest.lua
new file mode 100644
index 0000000..1ed6827
--- /dev/null
+++ b/test/rstest.lua
@@ -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")
diff --git a/test/testutils.lua b/test/testutils.lua
new file mode 100644
index 0000000..aa9f45f
--- /dev/null
+++ b/test/testutils.lua
@@ -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
diff --git a/test/turbine_modbustest.lua b/test/turbine_modbustest.lua
new file mode 100644
index 0000000..fe167d7
--- /dev/null
+++ b/test/turbine_modbustest.lua
@@ -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")