From 5b0670684066cfc55ce6a436737809ee610e8538 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=86=AF=E5=BB=BA=E5=9B=BD?= <395987374@qq.com> Date: Wed, 11 Jun 2025 03:37:31 +0000 Subject: [PATCH 1/3] update LICENSE. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: 冯建国 <395987374@qq.com> --- LICENSE | 862 +++++++++++++------------------------------------------- 1 file changed, 201 insertions(+), 661 deletions(-) diff --git a/LICENSE b/LICENSE index ada1a81..29f81d8 100644 --- a/LICENSE +++ b/LICENSE @@ -1,661 +1,201 @@ - GNU AFFERO GENERAL PUBLIC LICENSE - Version 3, 19 November 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 Affero General Public License is a free, copyleft license for -software and other kinds of works, specifically designed to ensure -cooperation with the community in the case of network server software. - - The licenses for most software and other practical works are designed -to take away your freedom to share and change the works. By contrast, -our General Public Licenses are 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. - - 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. - - Developers that use our General Public Licenses protect your rights -with two steps: (1) assert copyright on the software, and (2) offer -you this License which gives you legal permission to copy, distribute -and/or modify the software. - - A secondary benefit of defending all users' freedom is that -improvements made in alternate versions of the program, if they -receive widespread use, become available for other developers to -incorporate. Many developers of free software are heartened and -encouraged by the resulting cooperation. However, in the case of -software used on network servers, this result may fail to come about. -The GNU General Public License permits making a modified version and -letting the public access it on a server without ever releasing its -source code to the public. - - The GNU Affero General Public License is designed specifically to -ensure that, in such cases, the modified source code becomes available -to the community. It requires the operator of a network server to -provide the source code of the modified version running there to the -users of that server. Therefore, public use of a modified version, on -a publicly accessible server, gives the public access to the source -code of the modified version. - - An older license, called the Affero General Public License and -published by Affero, was designed to accomplish similar goals. This is -a different license, not a version of the Affero GPL, but Affero has -released a new version of the Affero GPL which permits relicensing under -this license. - - 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 Affero 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. Remote Network Interaction; Use with the GNU General Public License. - - Notwithstanding any other provision of this License, if you modify the -Program, your modified version must prominently offer all users -interacting with it remotely through a computer network (if your version -supports such interaction) an opportunity to receive the Corresponding -Source of your version by providing access to the Corresponding Source -from a network server at no charge, through some standard or customary -means of facilitating copying of software. This Corresponding Source -shall include the Corresponding Source for any work covered by version 3 -of the GNU General Public License that is incorporated pursuant to the -following paragraph. - - 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 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 work with which it is combined will remain governed by version -3 of the GNU General Public License. - - 14. Revised Versions of this License. - - The Free Software Foundation may publish revised and/or new versions of -the GNU Affero 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 Affero 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 Affero 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 Affero 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 Affero 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 Affero General Public License for more details. - - You should have received a copy of the GNU Affero General Public License - along with this program. If not, see . - -Also add information on how to contact you by electronic and paper mail. - - If your software can interact with users remotely through a computer -network, you should also make sure that it provides a way for users to -get its source. For example, if your program is a web application, its -interface could display a "Source" link that leads users to an archive -of the code. There are many ways you could offer source, and different -solutions will be better for different programs; see section 13 for the -specific requirements. - - 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 AGPL, see -. + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. -- Gitee From 231684fc6313087aa635d8476e6ba64832306306 Mon Sep 17 00:00:00 2001 From: fengjianguo <395987374@qq.com> Date: Mon, 28 Sep 2026 11:59:08 +0800 Subject: [PATCH 2/3] =?UTF-8?q?feat:=20=E5=AE=8C=E5=96=84=E6=AC=A7?= =?UTF-8?q?=E5=A7=86=E9=BE=99FINS=E5=AE=A2=E6=88=B7=E7=AB=AF=E2=80=94?= =?UTF-8?q?=E2=80=94=E8=AE=A2=E9=98=85=E7=9B=91=E6=8E=A7=E3=80=81=E7=8B=AC?= =?UTF-8?q?=E7=AB=8B=E6=97=A5=E5=BF=97=E3=80=81=E5=85=A8=E7=B1=BB=E5=9E=8B?= =?UTF-8?q?=E8=AF=BB=E5=86=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 订阅监控: - 可配置订阅点位,按点位独立间隔轮询,值变化回调监听器(日志/自定义) - 支持两种注册方式:application.yml 配置 + 连接时外部传入(FinsClientConfig#connect(List)),点位共存、重名跳过、断线重连不重复注册 - 首次读取触发可配置:点位级 first-read-trigger > 全局配置 > 默认 true - SID 并发让位:可用 SID <= 1 时订阅自动跳过本轮(不告警不记失败),手动/异步读写优先;SID 恢复后自动补读,订阅点位数量不受 256 并发上限限制 - 提供 REST 管理接口 /subscribe/list、/subscribe、/subscribe/{name}/start|stop、/subscribe/stats 独立日志: - 交互报文、订阅值变化通过 MDC(FinsCategoryFilter)分别路由到独立日志文件,名称可配置(带默认值) - logback 修复:双 root 挂载独立文件、charset、控制台仅打印订阅值变化,其余降为 debug 通信与读写: - PLC 节点号改为握手响应自动解析(FinsNodeContext),移除 plc-ip-node 手动配置 - 读写类型补全:WORD/HEX/SIGNED/LIST/DWORD/FLOAT/BOOL/BITS 同步+异步全家桶(含负值补码写入) - TestController 迁移至 controller 包并重写,增加全类型读写测试接口 修复与重构: - 修复 SID 泄漏:FINS 响应匹配后立即归还 SID,避免队列单调耗尽报请求过于频繁 - 线程池可配置:订阅调度/通知分发/异步读写三组(OmronThreadPoolConfig) - 全局异常 GlobalException 改名 OmronException 并完善(cause 透传、静态工厂 of()) - 删除废弃代码:根包 TestController、RequestMsg、FinsConstant、FinsResponseFlagEnum 文档与配置: - README 同步更新(订阅两种注册/并发让位/读写示例/日志说明) - application.yml 增加订阅点位示例、日志、线程池配置;.gitignore 补 logs/ --- .gitignore | 1 + README.md | 316 ++++++++++ .../com/fjg/omorntcpfins/TestController.java | 68 -- .../fjg/omorntcpfins/common/RequestMsg.java | 148 ----- .../common/constant/FinsConstant.java | 50 -- .../common/enums/AreaTypeEnum.java | 23 +- .../common/enums/FinsResponseFlagEnum.java | 25 - .../common/enums/MemoryAreaEnum.java | 106 ++++ .../omorntcpfins/common/enums/MsgEnum.java | 3 +- .../omorntcpfins/common/queue/HexQueue.java | 46 +- .../omorntcpfins/config/OmronConfigProps.java | 18 +- .../omorntcpfins/config/OmronLogProps.java | 47 ++ .../config/OmronSubscribeProps.java | 43 ++ .../config/OmronThreadPoolConfig.java | 90 +++ .../config/OmronThreadPoolProps.java | 102 +++ .../config/fins/FinsNodeContext.java | 74 +++ .../config/fins/client/FinsClientConfig.java | 252 ++++++-- .../config/fins/client/FinsClientHandler.java | 64 +- .../config/log/FinsCategoryFilter.java | 96 +++ .../config/log/FinsLogContext.java | 54 ++ .../controller/FinsSubscribeController.java | 95 +++ .../controller/TestController.java | 237 +++++++ .../exception/GlobalException.java | 29 - .../exception/OmronException.java | 76 +++ .../handler/fins/FinsMessageHandler.java | 150 +++-- .../strategy/area/AreaTypeBitStrategy.java | 43 +- .../strategy/area/AreaTypeWordStrategy.java | 27 +- .../strategy/area/BaseAreaTypeStrategy.java | 40 +- .../model/FinsSubscribePoint.java | 136 ++++ .../com/fjg/omorntcpfins/run/AppRunning.java | 33 +- .../subscribe/FinsSubscribeLogListener.java | 30 + .../subscribe/FinsSubscribeManager.java | 462 ++++++++++++++ .../subscribe/FinsValueChangeListener.java | 27 + .../util/FinsMsgAnalysisUtil.java | 31 + .../com/fjg/omorntcpfins/util/FinsUtil.java | 582 +++++++++++++++--- src/main/resources/application.yml | 113 +++- src/main/resources/logback-spring.xml | 101 ++- 37 files changed, 3217 insertions(+), 621 deletions(-) delete mode 100644 src/main/java/com/fjg/omorntcpfins/TestController.java delete mode 100644 src/main/java/com/fjg/omorntcpfins/common/RequestMsg.java delete mode 100644 src/main/java/com/fjg/omorntcpfins/common/constant/FinsConstant.java delete mode 100644 src/main/java/com/fjg/omorntcpfins/common/enums/FinsResponseFlagEnum.java create mode 100644 src/main/java/com/fjg/omorntcpfins/common/enums/MemoryAreaEnum.java create mode 100644 src/main/java/com/fjg/omorntcpfins/config/OmronLogProps.java create mode 100644 src/main/java/com/fjg/omorntcpfins/config/OmronSubscribeProps.java create mode 100644 src/main/java/com/fjg/omorntcpfins/config/OmronThreadPoolConfig.java create mode 100644 src/main/java/com/fjg/omorntcpfins/config/OmronThreadPoolProps.java create mode 100644 src/main/java/com/fjg/omorntcpfins/config/fins/FinsNodeContext.java create mode 100644 src/main/java/com/fjg/omorntcpfins/config/log/FinsCategoryFilter.java create mode 100644 src/main/java/com/fjg/omorntcpfins/config/log/FinsLogContext.java create mode 100644 src/main/java/com/fjg/omorntcpfins/controller/FinsSubscribeController.java create mode 100644 src/main/java/com/fjg/omorntcpfins/controller/TestController.java delete mode 100644 src/main/java/com/fjg/omorntcpfins/exception/GlobalException.java create mode 100644 src/main/java/com/fjg/omorntcpfins/exception/OmronException.java create mode 100644 src/main/java/com/fjg/omorntcpfins/model/FinsSubscribePoint.java create mode 100644 src/main/java/com/fjg/omorntcpfins/subscribe/FinsSubscribeLogListener.java create mode 100644 src/main/java/com/fjg/omorntcpfins/subscribe/FinsSubscribeManager.java create mode 100644 src/main/java/com/fjg/omorntcpfins/subscribe/FinsValueChangeListener.java diff --git a/.gitignore b/.gitignore index 549e00a..cf60db2 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ HELP.md target/ +logs/ !.mvn/wrapper/maven-wrapper.jar !**/src/main/**/target/ !**/src/test/**/target/ diff --git a/README.md b/README.md index 7f3a837..e559f9d 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,321 @@ # [欧姆龙Fins读写指令大全](https://www.cnblogs.com/resplendent/p/15219216.html) +## 项目功能 + +- **FINS TCP 通信**:基于 Netty 的欧姆龙 PLC 客户端,支持握手、字/位读写、断线自动重连、优雅关闭。 +- **节点自动解析**:PLC 节点号在 TCP 握手成功后自动从握手响应解析,无需手动配置。 +- **订阅监控**:可配置订阅点位,按点位独立轮询间隔周期读取,值变化时回调监听器(日志输出 / 自定义监听),并维护读取统计。 +- **专用日志**:交互报文、订阅值变化通过 MDC 标记分别输出到独立日志文件,名称可配置(带默认值)。 +- **线程池配置**:订阅调度、通知分发、异步读写三组线程池均可通过 `application.yml` 调整。 +- **SID 并发控制**:00-FF 共 256 个服务 ID 池化管理,支持同一连接上的并发读写与请求超时控制。 + +## 配置说明(application.yml) + +```yaml +omron: + fins-tcp-ip: 192.168.100.202 # PLC IP + fins-tcp-port: 9600 # FINS TCP 端口 + pc-id-node: 61 # PC 节点(PLC 节点由握手响应自动解析,无需配置) + read-write-timeout: 3 # 读写超时(秒) + connect-timeout-ms: 5000 # TCP 连接超时(毫秒) + reconnect-delay-ms: 3000 # 断线重连延迟(毫秒) + + # 专用日志(名称均可手动配置,未配置时使用默认值) + log: + mdc-key: finsLogCategory # 路由专用日志的 MDC 键名 + interact-value: interact # 交互报文 MDC 取值 + change-value: change # 值变化 MDC 取值 + interact-log-name: omron-tcp-fins_interact # 交互报文日志文件名 + change-log-name: omron-tcp-fins_change # 值变化日志文件名 + + # 订阅监控 + subscribe: + enabled: true + default-interval-ms: 1000 # 点位未单独配置间隔时的默认值 + first-read-trigger: true # 全局默认:首次读取是否触发值变化(优先级低于点位级配置,默认true) + points: + - name: "D6004-温度" # 点位名称(唯一) + address: "D6004" # 地址:D/C/W/H/A/E/T 字或位 + offset: 1 # WORD=读取个数 / BIT=位号(0~15) + data-type: WORD # WORD 或 BIT + interval-ms: 1000 # 轮询间隔(毫秒) + first-read-trigger: true # 点位级:首次读取是否触发值变化(优先级高于全局 first-read-trigger) + enabled: true + description: "示例点位" + + # 线程池 + thread-pool: + subscribe: # 订阅轮询调度线程池 + core-pool-size: 4 + keep-alive-seconds: 60 + notify: # 值变化通知分发线程池 + core-pool-size: 2 + max-pool-size: 4 + queue-capacity: 1024 + keep-alive-seconds: 60 + read: # 异步读写线程池 + core-pool-size: 4 + max-pool-size: 8 + queue-capacity: 512 + keep-alive-seconds: 60 +``` + +## 专用日志 + +| 日志文件 | 内容 | 说明 | +| ---- | ---- | ---- | +| `logs/omron-tcp-fins/interact/omron-tcp-fins_interact.log` | FINS 收发报文(十六进制) | 交互报文单独输出,不进入通用 info 日志 | +| `logs/omron-tcp-fins/change/omron-tcp-fins_change.log` | 订阅点位值变化(点位、地址、旧值→新值) | 值变化单独输出,不进入通用 info 日志 | + +实现方式:打印上述日志前向 MDC 写入标记键值(`omron.log.mdc-key`),logback 中 +`FinsCategoryFilter` 据此将日志路由到独立文件;通用 `info` 日志中排除这两类日志。 +如需调整文件名或 MDC 键值,修改 `omron.log.*` 即可,未配置时使用默认值。 + +## 订阅监控 + +订阅点位支持**两种注册方式**,可并存: + +| 方式 | 入口 | 时机 | 说明 | +| ---- | ---- | ---- | ---- | +| ① 配置文件 | `application.yml` → `omron.subscribe.points` | 应用启动 | 连接 PLC 后自动轮询;断线重连自动恢复 | +| ② 外部传入 | `FinsClientConfig#connect(List)` | 创建连接时 | 连接成功后自动注册并启动;与 yml 点位共存,点位名重复时跳过,断线重连不重复注册 | + +### ① 配置文件注册 + +在 `application.yml` 的 `omron.subscribe.points` 中配置点位,应用启动并连接 PLC 后自动开始轮询; +连接断开自动暂停全部点位,重连成功后自动恢复。 + +**首次读取是否触发(`first-read-trigger`)**:控制点位首轮读取到值时是否记为一次值变化。 +- `true`(默认):首读 `null → 值` 记为一次变化,写入变化日志并通知监听器; +- `false`:首读仅建立基线(记录当前值),不触发变化日志、变化计数与监听回调,之后值真正变化时才触发。 + +配置优先级:**点位级 `first-read-trigger` > 全局 `omron.subscribe.first-read-trigger` > 内置默认 `true`**。 +点位未配置该字段时使用全局值(全局未配置时默认 `true`);可通过 yml 或 REST 新增点位时配置。 + +### ② 连接时外部传入注册 + +创建连接时直接指定订阅点位,连接成功后自动注册并启动轮询: + +```java +// 构造外部订阅点位 +FinsSubscribePoint point = new FinsSubscribePoint(); +point.setName("产线A-温度"); +point.setAddress("D6004"); +point.setOffset(1); +point.setDataType(AreaTypeEnum.FLOAT); +point.setIntervalMs(1000L); +point.setEnabled(true); + +// 创建连接并指定订阅点位(与 yml 点位共存;同名点位跳过不重复注册) +finsClientConfig.connect(Arrays.asList(point)); + +// 也可先无参连接,之后随时用 REST / addPoint 动态注册 +finsClientConfig.connect(); +``` + +两种方式共享同一套点位表与统计(`/subscribe/list`、`/subscribe/stats` 均可查到); +`first-read-trigger`、`interval-ms` 等字段语义与 yml 配置一致。 + +### 并发能力与 SID 让位 + +- **256 是并发在途请求上限**(FINS 协议 SID 00-FF),**不是订阅点位数量上限**——订阅点位数量无硬性限制 +- 每个点位轮询为串行(`reading` 防重叠标志),同一时刻占用 SID 的点位数 = 订阅线程池核心线程数(`omron.thread-pool.subscribe.core-pool-size`,默认 4),点位再多也只是排队轮询,不会报错 +- **读写优先**:当可用 SID ≤ 1 时,订阅点位自动**跳过本轮**(debug 日志,不记失败、不告警),将 SID 让给手动/异步读写;SID 恢复后下一轮自动补读 +- 需要提高订阅吞吐时调大 `omron.thread-pool.subscribe.core-pool-size`;订阅与读写并发叠加逼近 256 时,订阅自动降级,读写请求始终优先 + +### 各区域类型订阅示例 + +订阅点位支持全部内存区(`D/C/W/H/A/E/T`),`data-type` 支持 `WORD`(字)、`BIT`(位)、`DWORD`(32位整数,占用连续2字)、`FLOAT`(IEEE754 浮点,占用连续2字)。 +以下为各区域示例(`application.yml` 中默认 `enabled: false`,按需启用并修改地址): + +```yaml +points: + - name: "D6004-温度" # D:数据存储器 DM(此处存 FLOAT,占用 D6004+D6005) + address: "D6004" + offset: 1 + data-type: FLOAT + interval-ms: 1000 + enabled: true + - name: "D6000-运行状态" # D 区位示例 + address: "D6000" + offset: 1 # BIT=位号(0~15),此处为 D6000.1 + data-type: BIT + interval-ms: 500 + enabled: true + - name: "C100-CIO区示例" # C:CIO 区 + address: "C100" + offset: 1 + data-type: WORD + interval-ms: 1000 + enabled: false + - name: "W100-工作继电器示例" # W:工作继电器 WR + address: "W100" + offset: 1 + data-type: WORD + interval-ms: 1000 + enabled: false + - name: "H100-保持继电器示例" # H:保持继电器 HR + address: "H100" + offset: 1 + data-type: WORD + interval-ms: 1000 + enabled: false + - name: "A100-辅助继电器示例" # A:辅助继电器 AR + address: "A100" + offset: 1 + data-type: WORD + interval-ms: 1000 + enabled: false + - name: "E100-扩展寄存器示例" # E:扩展数据寄存器 EM(当前Bank) + address: "E100" + offset: 1 + data-type: WORD + interval-ms: 1000 + enabled: false + - name: "T10-定时器示例" # T:定时器/计数器 TC(WORD=PV 值;BIT=完成标志) + address: "T10" + offset: 1 + data-type: WORD + interval-ms: 1000 + enabled: false +``` + +> 位订阅(BIT):`data-type: BIT` + `offset` 填位号(0~15),如 `D6000 offset=1` 表示 D6000.1。 +> 各区域是否可读取决于 PLC 实际配置(如 EM 区 Bank、定时器号),示例默认关闭以免干扰运行。 + +### 值变化监听 + +实现 `FinsValueChangeListener` 接口并注册为 Spring Bean 即可接收所有点位变化通知(项目内置 +`FinsSubscribeLogListener` 输出日志): + +```java +@Slf4j +@Component +public class MyListener implements FinsValueChangeListener { + @Override + public void onChange(FinsSubscribePoint point, String oldValue, String newValue) { + // point.getName() / getCurrentValue() / getReadCount() ... + } +} +``` + +### 管理接口 + +| 方法 | 路径 | 说明 | +| ---- | ---- | ---- | +| GET | /subscribe/list | 查询全部点位(含当前值与统计) | +| POST | /subscribe | 新增点位(JSON),自动启动轮询 | +| POST | /subscribe/{name}/start | 启动点位 | +| POST | /subscribe/{name}/stop | 停止点位 | +| DELETE | /subscribe/{name} | 删除点位 | +| GET | /subscribe/stats | 聚合统计(读取/成功/变化次数、成功率) | + +新增点位示例: + +```json +{ + "name": "W100-计数", + "address": "W100", + "offset": 1, + "dataType": "WORD", + "intervalMs": 2000, + "firstReadTrigger": false, + "enabled": true, + "description": "计数器(首读不触发变化)" +} +``` + +## 读写接口 + +### 支持的内存区类型 + +| 前缀 | 区域 | 字代码 | 位代码 | +| ---- | ---- | ---- | ---- | +| D | 数据存储器 DM | 82 | 02 | +| C | CIO 区 | B0 | 30 | +| W | 工作继电器 WR | B1 | 31 | +| H | 保持继电器 HR | B2 | 32 | +| A | 辅助继电器 AR | B3 | 33 | +| E | 扩展数据寄存器 EM(当前Bank) | A0 | 20 | +| T | 定时器/计数器 TC(PV/完成标志) | 89 | 09 | + +> 说明:地址格式为「前缀+编号」,如 `D6004`、`W100`、`T10`。WORD 模式 `offset` 为读取个数(一次最多 1000 个);BIT 模式 `offset` 为**位号**(0~15),如 `D6000 offset=1` 表示 D6000.1。 + +### REST 接口 + +读取(`GET /send/read`),`dataType` 支持 word/hex/signed/list/dword/float/bool/bits: + +| 参数 | 说明 | +| ---- | ---- | +| type | 内存区前缀 D/C/W/H/A/E/T,默认 D(db 自带前缀时可省略) | +| db | 地址,如 D6004、W100、T10 | +| offset | word/hex/list/signed 保留(默认1);bool/bits 为位号(0~15) | +| dataType | word(默认)/hex/signed/list/dword/float/bool/bits | +| count | 批量读取个数(signed/list/bits,默认1) | + +写入(`GET /send/write`),`dataType` 支持 word/hex/list/dword/float/bool/bits;不传时按值自动推断(兼容旧用法): + +| 参数 | 说明 | +| ---- | ---- | +| type / db / offset | 同读取(offset 在 bool/bits 为位号) | +| dataType | word/hex/list/dword/float/bool/bits(可省略,自动推断) | +| dbValue | word=整数(-32768~65535);hex=4位十六进制;dword=32位整数;float=浮点;bool=true/false | +| values | 批量值(逗号分隔):list 为十进制字;bits 为 true/false | +| count | list 批量写入个数(默认按 values 个数) | + +示例: + +``` +# 读取 +GET /send/read?type=D&db=D6004&dataType=word # 读字(十进制) +GET /send/read?type=D&db=D6004&dataType=hex # 读字(十六进制) +GET /send/read?type=D&db=D6004&count=3&dataType=list # 批量读3个字(无符号) +GET /send/read?type=D&db=D6004&count=3&dataType=signed# 批量读3个字(有符号) +GET /send/read?type=D&db=D6004&dataType=dword # 读32位整数(2字,低字在前) +GET /send/read?type=D&db=D6004&dataType=float # 读32位浮点(2字) +GET /send/read?db=D6000&offset=1&dataType=bool # 读位 D6000.1 +GET /send/read?db=D6000&offset=0&count=4&dataType=bits# 批量读位 D6000.0~D6000.3 + +# 写入 +GET /send/write?type=D&db=D6004&dataType=word&dbValue=100 # 写字(支持负值补码) +GET /send/write?type=D&db=D6004&dataType=hex&dbValue=ae14 # 写十六进制 +GET /send/write?type=D&db=D6004&dataType=list&values=1,2,3 # 批量写3个字 +GET /send/write?type=D&db=D6004&dataType=dword&dbValue=12345678 # 写32位整数 +GET /send/write?type=D&db=D6004&dataType=float&dbValue=3.14 # 写浮点 +GET /send/write?db=D6000&offset=1&dataType=bool&dbValue=true # 写位 D6000.1 +GET /send/write?db=D6000&offset=0&dataType=bits&values=true,false,true,false # 批量写位 +``` + +代码中亦可通过 `FinsUtil` 进行同步/异步读写: + +```java +// 同步-单值 +String v = finsUtil.readWord("D6004", 1); // 十进制(0~65535) +String hex = finsUtil.readWordHex("D6004", 1); // 原始十六进制 +Boolean b = finsUtil.readBool("D6000", 1); // D6000.1 +finsUtil.writeWord("D6004", 1, "100"); +finsUtil.writeWord("D6004", 1, "-1"); // 有符号写入(16位补码) +finsUtil.writeBool("D6000", 1, true); + +// 同步-批量/有符号/32位/浮点/位 +List words = finsUtil.readWords("D6004", 3); // 批量读3个字(无符号) +List signed = finsUtil.readWordsSigned("D6004", 3); // 批量读3个字(有符号) +finsUtil.writeWords("D6004", 3, Arrays.asList(1, 2, 3)); // 批量写3个字(支持负值) +Long dword = finsUtil.readDword("D6004", 1); // 32位整数(低字在前) +Float f = finsUtil.readFloat("D6004", 1); // 32位浮点 +finsUtil.writeFloat("D6004", 1, 3.14f); +List bits = finsUtil.readBits("D6000", 0, 4); // 批量读位 D6000.0~3 +finsUtil.writeBits("D6000", 0, Arrays.asList(true, false, true, false)); + +// 异步(同名 + Async 后缀:readWordAsync/readWordHexAsync/readWordsAsync/readWordsSignedAsync/ +// readDwordAsync/readFloatAsync/readBitsAsync/writeWordAsync/writeBoolAsync/ +// writeWordsAsync/writeWordsHexAsync/writeDwordAsync/writeFloatAsync/writeBitsAsync) +CompletableFuture f = finsUtil.readWordAsync("D6004", 1); +CompletableFuture> f2 = finsUtil.readWordsAsync("D6004", 3); +``` + +## 协议说明 + 以下指令均为16进制数,PC IP地址:192.168.250.24,PLC IP地址:192.168.250.23 diff --git a/src/main/java/com/fjg/omorntcpfins/TestController.java b/src/main/java/com/fjg/omorntcpfins/TestController.java deleted file mode 100644 index 1b30a99..0000000 --- a/src/main/java/com/fjg/omorntcpfins/TestController.java +++ /dev/null @@ -1,68 +0,0 @@ -package com.fjg.omorntcpfins; - -import HslCommunication.Profinet.Omron.OmronFinsNet; -import cn.hutool.extra.spring.SpringUtil; -import com.fjg.omorntcpfins.handler.fins.FinsMessageHandler; -import com.fjg.omorntcpfins.config.fins.client.FinsClientConfig; -import com.fjg.omorntcpfins.util.FinsUtil; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RestController; - -import java.util.concurrent.CompletableFuture; -import java.util.concurrent.ExecutionException; - -/** - * @author fengjianguo - * @date 2024/7/22 10:05 - */ -@RestController -@RequestMapping("/send") -public class TestController { - @RequestMapping("/test") - public String test(String msg) { - FinsUtil bean = SpringUtil.getBean(FinsUtil.class); - String s1 = bean.readWord("D6004", 1); - System.out.println("readWord:" + s1); - CompletableFuture future = new CompletableFuture<>(); - FinsMessageHandler.setRequestFutures("1", future); - FinsClientConfig.send(msg); - try { - String s = future.get(); - System.out.println("handler:" + s); - } catch (InterruptedException | ExecutionException e) { - throw new RuntimeException(e); - } - return "success"; - } - - /** - * 读取测试 - * - * @param db db块,D6000 - * @param offset 偏移量 1 - * @param bool 是否读取布尔值 - * @return {@link String } - */ - @RequestMapping("/send") - public String test(String db, Integer offset, Boolean bool) { - FinsUtil bean = SpringUtil.getBean(FinsUtil.class); - if (bool) { - System.out.println(bean.readBool(db, offset)); - } else { - System.out.println(bean.readWord(db, offset)); - } - return "success"; - } - - @RequestMapping("/write") - public String write(String db, Integer offset, String dbValue, Boolean bool) { - FinsUtil bean = SpringUtil.getBean(FinsUtil.class); - if (bool) { - System.out.println(bean.writeBool(db, offset, Boolean.valueOf(dbValue))); - } else { - System.out.println(bean.writeWord(db, offset, dbValue)); - } - return "success"; - } - -} diff --git a/src/main/java/com/fjg/omorntcpfins/common/RequestMsg.java b/src/main/java/com/fjg/omorntcpfins/common/RequestMsg.java deleted file mode 100644 index 46e2732..0000000 --- a/src/main/java/com/fjg/omorntcpfins/common/RequestMsg.java +++ /dev/null @@ -1,148 +0,0 @@ -package com.fjg.omorntcpfins.common; - - -/** - * @author fengjianguo - * @date 2024/7/29 9:47 - */ -public class RequestMsg { - - private static final String OK_CODE = "0"; - - private static final String ERROR_CODE = "-1"; - - /** - * 请求id - */ - private String requestId; - - /** - * 异常代码 - */ - private String errorCode; - - /** - * 内容 - */ - private String content; - - public RequestMsg(String requestId, String content) { - this.requestId = requestId; - this.content = content; - } - - public RequestMsg(String requestId, String content, String errorCode) { - this.requestId = requestId; - this.content = content; - this.errorCode = errorCode; - } - - public RequestMsg(String requestId) { - this.requestId = requestId; - } - - public RequestMsg() { - } - - /** - * 构建请求消息 - * - * @param requestId 请求id - * @param content 内容 - * @return {@link RequestMsg } - */ - public static RequestMsg build(String requestId, String content) { - return new RequestMsg(requestId, content); - } - - /** - * 构建请求消息 - * - * @param requestId 请求id - * @return {@link RequestMsg } - */ - public static RequestMsg build(String requestId) { - return new RequestMsg(requestId); - } - - /** - * 构建请求消息 - * - * @param requestId 请求id - * @param content 内容 - * @param errorCode 异常代码 - * @return {@link RequestMsg } - */ - public static RequestMsg build(String requestId, String content, String errorCode) { - return new RequestMsg(requestId, content, errorCode); - } - - /** - * 构建请求消息-成功 - * - * @param requestId 请求id - * @param content 内容 - * @return {@link RequestMsg } - */ - public static RequestMsg ok(String requestId, String content) { - return new RequestMsg(requestId, content, OK_CODE); - } - - /** - * 构建请求消息-失败 - * - * @param requestId 请求id - * @param content 内容 - * @return {@link RequestMsg } - */ - public static RequestMsg fail(String requestId, String content) { - return new RequestMsg(requestId, content, ERROR_CODE); - } - - /** - * 获取请求id - * - * @return {@link String } - */ - public String requestId() { - return requestId; - } - - /** - * 获取内容 - */ - public String content() { - return content; - } - - /** - * 获取异常代码 - */ - public String errorCode() { - return errorCode; - } - - /** - * 设置请求id - */ - public RequestMsg requestId(String requestId) { - this.requestId = requestId; - return this; - } - - /** - * 设置内容 - */ - public RequestMsg content(String content) { - this.content = content; - return this; - } - - /** - * 设置异常代码 - */ - public RequestMsg errorCode(String errorCode) { - this.errorCode = errorCode; - return this; - } -} diff --git a/src/main/java/com/fjg/omorntcpfins/common/constant/FinsConstant.java b/src/main/java/com/fjg/omorntcpfins/common/constant/FinsConstant.java deleted file mode 100644 index e4a0ce9..0000000 --- a/src/main/java/com/fjg/omorntcpfins/common/constant/FinsConstant.java +++ /dev/null @@ -1,50 +0,0 @@ -package com.fjg.omorntcpfins.common.constant; - -/** - * @author fengjianguo - * @date 2024/7/27 16:01 - */ -public class FinsConstant { - /** - * 46494E53:ASCII编码:FINS; - * - * 0000001A:指后面跟的字节长度; - * - * 00000002:固定命令; - * - * 00000000:错误代码; - * - * 80:ICF; - * - * 00:RSV; - * - * 02:GCT; - * - * 00:PLC网络地址; - * - * 17:PLC节点地址; - * - * 00:PLC单元地址; - * - * 00:PC网络地址; - * - * 18:PC节点地址; - * - * 00:PC单元地址; - * - * FF:SID; - * - * 0101:读指令; - * - * 82:读地址区(D位:02,D字:82,W位:31,C位:30,W字:B1,C字:B0); - * - * 006400:起始地址; - * - * 0002:读个数。 - */ - - /** - * FINS协议头 - */ - -} diff --git a/src/main/java/com/fjg/omorntcpfins/common/enums/AreaTypeEnum.java b/src/main/java/com/fjg/omorntcpfins/common/enums/AreaTypeEnum.java index 647d653..42a670a 100644 --- a/src/main/java/com/fjg/omorntcpfins/common/enums/AreaTypeEnum.java +++ b/src/main/java/com/fjg/omorntcpfins/common/enums/AreaTypeEnum.java @@ -4,19 +4,32 @@ import lombok.AllArgsConstructor; import lombok.Getter; /** - * @author fengjianguo - * @date 2024/7/29 11:14 + * 读写数据类型 + *

+ * 0:位;1:字;2:双字(32位整数,占用连续2字);3:浮点(IEEE754,占用连续2字)。 + * DWORD/FLOAT 底层报文与 WORD 相同(按字读取),仅解析方式不同,故复用字策略。 + *

*/ @Getter @AllArgsConstructor public enum AreaTypeEnum { /** - * 0:位 - * 1:字 + * 位(0=位号读取) */ BIT(0, "位", "areaTypeBitStrategy"), - WORD(1, "字", "areaTypeWordStrategy"); + /** + * 字 + */ + WORD(1, "字", "areaTypeWordStrategy"), + /** + * 双字(32位整数,占用连续2字,低字在前) + */ + DWORD(2, "双字", "areaTypeWordStrategy"), + /** + * 浮点(IEEE754,占用连续2字,低字在前) + */ + FLOAT(3, "浮点", "areaTypeWordStrategy"); private final int code; private final String desc; diff --git a/src/main/java/com/fjg/omorntcpfins/common/enums/FinsResponseFlagEnum.java b/src/main/java/com/fjg/omorntcpfins/common/enums/FinsResponseFlagEnum.java deleted file mode 100644 index 8935de4..0000000 --- a/src/main/java/com/fjg/omorntcpfins/common/enums/FinsResponseFlagEnum.java +++ /dev/null @@ -1,25 +0,0 @@ -package com.fjg.omorntcpfins.common.enums; - -import lombok.AllArgsConstructor; -import lombok.Getter; - -@Getter -@AllArgsConstructor -public enum FinsResponseFlagEnum { - /** - * 0000: 正常 - * 1103:命令错误 - */ - RESPONSE_FLAG_0000("0000", "正常"), - RESPONSE_FLAG_0103("0103", "命令错误") - ; - private final String code; - private final String msg; - - /** - * 校验响应是否为正常 - */ - public static Boolean isNormal(String responseFlag) { - return RESPONSE_FLAG_0000.getCode().equals(responseFlag); - } -} diff --git a/src/main/java/com/fjg/omorntcpfins/common/enums/MemoryAreaEnum.java b/src/main/java/com/fjg/omorntcpfins/common/enums/MemoryAreaEnum.java new file mode 100644 index 0000000..abe5868 --- /dev/null +++ b/src/main/java/com/fjg/omorntcpfins/common/enums/MemoryAreaEnum.java @@ -0,0 +1,106 @@ +package com.fjg.omorntcpfins.common.enums; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +/** + * 欧姆龙 FINS 内存区(Memory Area)枚举 + *

+ * 覆盖常见 PLC(CP/CJ/CS 系列)的读写区域,每个区域包含字(Word)与位(Bit) + * 两种 FINS 内存区代码。地址格式为「区域前缀 + 编号」,如 D6004、C100、W0、H50、A1、E100、T10。 + *

+ * + * @author fengjianguo + * @date 2026/09/28 + */ +@Getter +@AllArgsConstructor +public enum MemoryAreaEnum { + + /** + * D:数据存储器 DM(Data Memory),字 0x82 / 位 0x02 + */ + D("D", "数据存储器DM", "82", "02"), + + /** + * C:CIO 区(Channel I/O),字 0xB0 / 位 0x30 + */ + C("C", "CIO区", "B0", "30"), + + /** + * W:工作继电器 WR(Work Relay),字 0xB1 / 位 0x31 + */ + W("W", "工作继电器WR", "B1", "31"), + + /** + * H:保持继电器 HR(Holding Relay),字 0xB2 / 位 0x32 + */ + H("H", "保持继电器HR", "B2", "32"), + + /** + * A:辅助继电器 AR(Auxiliary Relay),字 0xB3 / 位 0x33 + */ + A("A", "辅助继电器AR", "B3", "33"), + + /** + * E:扩展数据寄存器 EM(Extended Data Memory,当前 Bank),字 0xA0 / 位 0x20 + */ + E("E", "扩展数据寄存器EM", "A0", "20"), + + /** + * T:定时器/计数器 TC(Timer/Counter),当前值 PV 0x89 / 完成标志位 0x09 + *

字读写访问 PV 当前值;位读写访问完成标志(置位表示定时/计数完成)。

+ */ + T("T", "定时器/计数器TC", "89", "09"); + + /** + * 地址前缀(不区分大小写) + */ + private final String prefix; + + /** + * 区域名称 + */ + private final String name; + + /** + * 字读写内存区代码 + */ + private final String wordCode; + + /** + * 位读写内存区代码 + */ + private final String bitCode; + + /** + * 按地址前缀解析内存区(不区分大小写) + * + * @param prefix 区域前缀,如 D、d、W、T + * @return 内存区枚举 + * @throws IllegalArgumentException 前缀不支持时抛出 + */ + public static MemoryAreaEnum of(String prefix) { + if (prefix == null || prefix.isEmpty()) { + throw new IllegalArgumentException("区域前缀不能为空"); + } + for (MemoryAreaEnum area : values()) { + if (area.prefix.equalsIgnoreCase(prefix)) { + return area; + } + } + throw new IllegalArgumentException("不支持的FINS内存区前缀: " + prefix + + "(支持: D/C/W/H/A/E/T)"); + } + + /** + * 获取指定读写类型对应的内存区代码 + * + * @param areaType BIT=位代码,WORD=字代码 + * @return FINS 内存区代码 + */ + public String codeOf(AreaTypeEnum areaType) { + return areaType == AreaTypeEnum.BIT ? bitCode : wordCode; + } + +} diff --git a/src/main/java/com/fjg/omorntcpfins/common/enums/MsgEnum.java b/src/main/java/com/fjg/omorntcpfins/common/enums/MsgEnum.java index ff9e021..fae7735 100644 --- a/src/main/java/com/fjg/omorntcpfins/common/enums/MsgEnum.java +++ b/src/main/java/com/fjg/omorntcpfins/common/enums/MsgEnum.java @@ -4,7 +4,8 @@ import lombok.AllArgsConstructor; import lombok.Getter; /** - * @author Zhang GuiHong + * 统一返回结果枚举 + * @author fengjianguo * @date 2021/12/27 10:07 **/ @AllArgsConstructor diff --git a/src/main/java/com/fjg/omorntcpfins/common/queue/HexQueue.java b/src/main/java/com/fjg/omorntcpfins/common/queue/HexQueue.java index c923126..9228d24 100644 --- a/src/main/java/com/fjg/omorntcpfins/common/queue/HexQueue.java +++ b/src/main/java/com/fjg/omorntcpfins/common/queue/HexQueue.java @@ -1,19 +1,34 @@ package com.fjg.omorntcpfins.common.queue; -/** - * @author fengjianguo - * @date 2024/7/30 16:12 - */ import lombok.extern.slf4j.Slf4j; import java.util.concurrent.ConcurrentLinkedQueue; import java.util.stream.IntStream; +/** + * FINS 服务ID(SID)队列 + *

+ * FINS 协议中 SID 取值范围 00-FF 共 256 个,用于唯一标识一次请求。 + * 每次发送请求时从队列中取出一个 SID,响应返回或超时后归还, + * 从而支持在同一连接上并发进行多笔读写。 + *

+ * + * @author fengjianguo + * @date 2024/7/30 16:12 + */ @Slf4j public class HexQueue { + private static final ConcurrentLinkedQueue QUEUE = new ConcurrentLinkedQueue<>(); - public synchronized static void init() { + private HexQueue() { + } + + /** + * 重置并初始化队列(先清空再填充,幂等,避免断线重连后重复入队) + */ + public synchronized static void reset() { + QUEUE.clear(); IntStream // 生成0-255的数字 .rangeClosed(0, 255) @@ -21,15 +36,16 @@ public class HexQueue { .mapToObj(i -> String.format("%02x", i)) // 添加到队列 .forEach(QUEUE::add); + log.info("SID队列已重置,当前容量:{}", QUEUE.size()); } /** - * 获取并删除数据的方法 + * 获取并删除队首SID * - * @return {@link String } + * @return SID,队列为空时返回 null */ public synchronized static String fetchHex() { - if (QUEUE.isEmpty()){ + if (QUEUE.isEmpty()) { return null; } // 获取并移除队首元素 @@ -37,16 +53,22 @@ public class HexQueue { } /** - * 归还数据到队列的方法 + * 归还SID到队列尾部(幂等,避免重复入队造成 SID 冲突) * - * @param hexValue 十六进制值 + * @param hexValue SID */ public synchronized static void returnHex(String hexValue) { - if (hexValue != null && !hexValue.isEmpty()) { + if (hexValue != null && !hexValue.isEmpty() && !QUEUE.contains(hexValue)) { // 将数据放回队列尾部 QUEUE.offer(hexValue); } } -} + /** + * 当前队列中可用SID数量 + */ + public static int available() { + return QUEUE.size(); + } +} diff --git a/src/main/java/com/fjg/omorntcpfins/config/OmronConfigProps.java b/src/main/java/com/fjg/omorntcpfins/config/OmronConfigProps.java index fc881d1..e163399 100644 --- a/src/main/java/com/fjg/omorntcpfins/config/OmronConfigProps.java +++ b/src/main/java/com/fjg/omorntcpfins/config/OmronConfigProps.java @@ -5,6 +5,8 @@ import org.springframework.boot.context.properties.ConfigurationProperties; import org.springframework.context.annotation.Configuration; /** + * 欧姆龙 PLC 连接配置 + * * @author fengjianguo * @date 2024/7/29 12:51 */ @@ -13,7 +15,7 @@ import org.springframework.context.annotation.Configuration; @ConfigurationProperties("omron") public class OmronConfigProps { /** - * FINS TCP 端口 + * PLC IP 地址 */ private String finsTcpIp; /** @@ -21,7 +23,7 @@ public class OmronConfigProps { */ private String finsTcpPort; /** - * PLC IP 节点 + * PLC IP 节点(可选,握手成功后自动从握手响应解析,无需手动配置;仅作为握手完成前的兜底) */ private Integer plcIpNode; /** @@ -29,7 +31,15 @@ public class OmronConfigProps { */ private Integer pcIdNode; /** - * 读写数据超时时间 + * 读写数据超时时间(秒) + */ + private Integer readWriteTimeout = 3; + /** + * TCP 连接超时时间(毫秒) + */ + private Integer connectTimeoutMs = 5000; + /** + * 断线重连延迟(毫秒) */ - private Integer readWriteTimeout; + private Long reconnectDelayMs = 3000L; } diff --git a/src/main/java/com/fjg/omorntcpfins/config/OmronLogProps.java b/src/main/java/com/fjg/omorntcpfins/config/OmronLogProps.java new file mode 100644 index 0000000..7d7a361 --- /dev/null +++ b/src/main/java/com/fjg/omorntcpfins/config/OmronLogProps.java @@ -0,0 +1,47 @@ +package com.fjg.omorntcpfins.config; + +import lombok.Data; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.context.annotation.Configuration; + +/** + * 专用日志配置 + *

+ * 交互报文、订阅值变化通过 MDC 标记后路由到各自独立的日志文件。 + * 以下名称均可在 application.yml 中手动配置,未配置时使用默认值。 + *

+ * + * @author fengjianguo + * @date 2026/09/28 + */ +@Data +@Configuration +@ConfigurationProperties("omron.log") +public class OmronLogProps { + + /** + * 用于路由专用日志的 MDC 键名(默认值 finsLogCategory) + */ + private String mdcKey = "finsLogCategory"; + + /** + * 交互报文对应的 MDC 取值(默认值 interact) + */ + private String interactValue = "interact"; + + /** + * 订阅值变化对应的 MDC 取值(默认值 change) + */ + private String changeValue = "change"; + + /** + * 交互报文日志文件名前缀(默认值 omron-tcp-fins_interact) + */ + private String interactLogName = "omron-tcp-fins_interact"; + + /** + * 订阅值变化日志文件名前缀(默认值 omron-tcp-fins_change) + */ + private String changeLogName = "omron-tcp-fins_change"; + +} diff --git a/src/main/java/com/fjg/omorntcpfins/config/OmronSubscribeProps.java b/src/main/java/com/fjg/omorntcpfins/config/OmronSubscribeProps.java new file mode 100644 index 0000000..4076836 --- /dev/null +++ b/src/main/java/com/fjg/omorntcpfins/config/OmronSubscribeProps.java @@ -0,0 +1,43 @@ +package com.fjg.omorntcpfins.config; + +import com.fjg.omorntcpfins.model.FinsSubscribePoint; +import lombok.Data; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.context.annotation.Configuration; + +import java.util.ArrayList; +import java.util.List; + +/** + * 订阅监控配置 + * + * @author fengjianguo + * @date 2026/09/28 + */ +@Data +@Configuration +@ConfigurationProperties("omron.subscribe") +public class OmronSubscribeProps { + + /** + * 订阅监控总开关 + */ + private boolean enabled = true; + + /** + * 默认轮询间隔(毫秒),点位未单独配置时使用 + */ + private long defaultIntervalMs = 1000; + + /** + * 全局默认:首次读取是否触发值变化(默认 true=首读记为一次变化;false=首读仅建立基线)。 + * 优先级低于点位级 firstReadTrigger:点位单独配置时以点位为准,未配置时使用本全局值 + */ + private Boolean firstReadTrigger = true; + + /** + * 配置的订阅点位 + */ + private List points = new ArrayList<>(); + +} diff --git a/src/main/java/com/fjg/omorntcpfins/config/OmronThreadPoolConfig.java b/src/main/java/com/fjg/omorntcpfins/config/OmronThreadPoolConfig.java new file mode 100644 index 0000000..6c136b2 --- /dev/null +++ b/src/main/java/com/fjg/omorntcpfins/config/OmronThreadPoolConfig.java @@ -0,0 +1,90 @@ +package com.fjg.omorntcpfins.config; + +import cn.hutool.core.thread.ThreadFactoryBuilder; +import lombok.extern.slf4j.Slf4j; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +import java.util.concurrent.ArrayBlockingQueue; +import java.util.concurrent.ScheduledThreadPoolExecutor; +import java.util.concurrent.ThreadPoolExecutor; +import java.util.concurrent.TimeUnit; + +/** + * 线程池配置 + *

+ * 提供三组可配置的线程池: + *

    + *
  • finsSubscribeScheduler:订阅点位轮询调度
  • + *
  • finsNotifyExecutor:订阅值变化通知分发
  • + *
  • finsReadExecutor:异步读写执行
  • + *
+ *

+ * + * @author fengjianguo + * @date 2026/09/28 + */ +@Slf4j +@Configuration +public class OmronThreadPoolConfig { + + /** + * 订阅点位轮询调度线程池 + *

任务积压时采用 AbortPolicy 拒绝并抛异常,便于及时暴露配置过小的问题。

+ */ + @Bean(value = "finsSubscribeScheduler", destroyMethod = "shutdown") + public ScheduledThreadPoolExecutor finsSubscribeScheduler(OmronThreadPoolProps props) { + OmronThreadPoolProps.Subscribe subscribe = props.getSubscribe(); + ScheduledThreadPoolExecutor executor = new ScheduledThreadPoolExecutor( + subscribe.getCorePoolSize(), + new ThreadFactoryBuilder().setNamePrefix(subscribe.getThreadNamePrefix()).build(), + new ThreadPoolExecutor.AbortPolicy()); + executor.setKeepAliveTime(subscribe.getKeepAliveSeconds(), TimeUnit.SECONDS); + executor.setRemoveOnCancelPolicy(true); + executor.setExecuteExistingDelayedTasksAfterShutdownPolicy(false); + executor.setContinueExistingPeriodicTasksAfterShutdownPolicy(false); + log.info("初始化订阅调度线程池:core={}", subscribe.getCorePoolSize()); + return executor; + } + + /** + * 订阅值变化通知分发线程池 + *

队列满时采用 CallerRunsPolicy,由调度线程代为执行,避免丢失通知。

+ */ + @Bean(value = "finsNotifyExecutor", destroyMethod = "shutdown") + public ThreadPoolExecutor finsNotifyExecutor(OmronThreadPoolProps props) { + OmronThreadPoolProps.Notify notify = props.getNotify(); + ThreadPoolExecutor executor = new ThreadPoolExecutor( + notify.getCorePoolSize(), + notify.getMaxPoolSize(), + notify.getKeepAliveSeconds(), + TimeUnit.SECONDS, + new ArrayBlockingQueue<>(notify.getQueueCapacity()), + new ThreadFactoryBuilder().setNamePrefix(notify.getThreadNamePrefix()).build(), + new ThreadPoolExecutor.CallerRunsPolicy()); + log.info("初始化通知分发线程池:core={}, max={}, queue={}", + notify.getCorePoolSize(), notify.getMaxPoolSize(), notify.getQueueCapacity()); + return executor; + } + + /** + * 异步读写执行线程池 + *

队列满时采用 CallerRunsPolicy,避免任务丢失。

+ */ + @Bean(value = "finsReadExecutor", destroyMethod = "shutdown") + public ThreadPoolExecutor finsReadExecutor(OmronThreadPoolProps props) { + OmronThreadPoolProps.Read read = props.getRead(); + ThreadPoolExecutor executor = new ThreadPoolExecutor( + read.getCorePoolSize(), + read.getMaxPoolSize(), + read.getKeepAliveSeconds(), + TimeUnit.SECONDS, + new ArrayBlockingQueue<>(read.getQueueCapacity()), + new ThreadFactoryBuilder().setNamePrefix(read.getThreadNamePrefix()).build(), + new ThreadPoolExecutor.CallerRunsPolicy()); + log.info("初始化异步读写线程池:core={}, max={}, queue={}", + read.getCorePoolSize(), read.getMaxPoolSize(), read.getQueueCapacity()); + return executor; + } + +} diff --git a/src/main/java/com/fjg/omorntcpfins/config/OmronThreadPoolProps.java b/src/main/java/com/fjg/omorntcpfins/config/OmronThreadPoolProps.java new file mode 100644 index 0000000..e437196 --- /dev/null +++ b/src/main/java/com/fjg/omorntcpfins/config/OmronThreadPoolProps.java @@ -0,0 +1,102 @@ +package com.fjg.omorntcpfins.config; + +import lombok.Data; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.context.annotation.Configuration; + +/** + * 线程池配置 + *
    + *
  • subscribe:订阅点位轮询调度线程池
  • + *
  • notify:订阅值变化通知分发线程池
  • + *
  • read:异步读写执行线程池
  • + *
+ * + * @author fengjianguo + * @date 2026/09/28 + */ +@Data +@Configuration +@ConfigurationProperties("omron.thread-pool") +public class OmronThreadPoolProps { + + /** + * 订阅轮询调度线程池 + */ + private Subscribe subscribe = new Subscribe(); + + /** + * 值变化通知分发线程池 + */ + private Notify notify = new Notify(); + + /** + * 异步读写执行线程池 + */ + private Read read = new Read(); + + @Data + public static class Subscribe { + /** + * 核心线程数 + */ + private int corePoolSize = 4; + /** + * 空闲线程存活时间(秒) + */ + private long keepAliveSeconds = 60; + /** + * 线程名前缀 + */ + private String threadNamePrefix = "fins-subscribe-"; + } + + @Data + public static class Notify { + /** + * 核心线程数 + */ + private int corePoolSize = 2; + /** + * 最大线程数 + */ + private int maxPoolSize = 4; + /** + * 任务队列容量 + */ + private int queueCapacity = 1024; + /** + * 空闲线程存活时间(秒) + */ + private long keepAliveSeconds = 60; + /** + * 线程名前缀 + */ + private String threadNamePrefix = "fins-notify-"; + } + + @Data + public static class Read { + /** + * 核心线程数 + */ + private int corePoolSize = 4; + /** + * 最大线程数 + */ + private int maxPoolSize = 8; + /** + * 任务队列容量 + */ + private int queueCapacity = 512; + /** + * 空闲线程存活时间(秒) + */ + private long keepAliveSeconds = 60; + /** + * 线程名前缀 + */ + private String threadNamePrefix = "fins-read-"; + } + +} diff --git a/src/main/java/com/fjg/omorntcpfins/config/fins/FinsNodeContext.java b/src/main/java/com/fjg/omorntcpfins/config/fins/FinsNodeContext.java new file mode 100644 index 0000000..772a127 --- /dev/null +++ b/src/main/java/com/fjg/omorntcpfins/config/fins/FinsNodeContext.java @@ -0,0 +1,74 @@ +package com.fjg.omorntcpfins.config.fins; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; + +/** + * FINS 节点信息上下文 + *

+ * PLC 节点号在 TCP 握手成功后,从握手响应报文解析获取,无需再手动配置。 + * 本机(PC)节点号同时保留配置值,用于组包。 + *

+ * + * @author fengjianguo + * @date 2026/09/28 + */ +@Slf4j +@Component +public class FinsNodeContext { + + /** + * PLC 节点号(握手响应解析所得,未完成握手时为 null) + */ + private volatile Integer plcNode; + + /** + * 本机节点号(握手响应回显,校验用) + */ + private volatile Integer pcNode; + + /** + * 是否已完成握手并成功解析 + */ + private volatile boolean handshakeDone = false; + + /** + * 根据握手响应更新节点信息 + * + * @param pcNodeHex 本机节点(十六进制字符串,如 "18") + * @param plcNodeHex PLC节点(十六进制字符串,如 "17") + */ + public void updateFromHandshake(String pcNodeHex, String plcNodeHex) { + try { + this.pcNode = Integer.parseInt(pcNodeHex, 16); + this.plcNode = Integer.parseInt(plcNodeHex, 16); + this.handshakeDone = true; + log.info("握手解析节点成功:本机节点=0x{}({}),PLC节点=0x{}({})", + pcNodeHex, pcNode, plcNodeHex, plcNode); + } catch (NumberFormatException e) { + log.warn("握手节点解析失败:本机节点hex={},PLC节点hex={}", pcNodeHex, plcNodeHex); + } + } + + /** + * PLC 节点号(握手解析所得,未握手时为 null) + */ + public Integer getPlcNode() { + return plcNode; + } + + /** + * 本机节点号(握手回显,未握手时为 null) + */ + public Integer getPcNode() { + return pcNode; + } + + /** + * 是否已完成握手并成功解析节点 + */ + public boolean isHandshakeDone() { + return handshakeDone; + } + +} diff --git a/src/main/java/com/fjg/omorntcpfins/config/fins/client/FinsClientConfig.java b/src/main/java/com/fjg/omorntcpfins/config/fins/client/FinsClientConfig.java index 992156d..ab841ca 100644 --- a/src/main/java/com/fjg/omorntcpfins/config/fins/client/FinsClientConfig.java +++ b/src/main/java/com/fjg/omorntcpfins/config/fins/client/FinsClientConfig.java @@ -1,94 +1,252 @@ package com.fjg.omorntcpfins.config.fins.client; +import com.fjg.omorntcpfins.config.OmronConfigProps; +import com.fjg.omorntcpfins.common.queue.HexQueue; +import com.fjg.omorntcpfins.config.log.FinsLogContext; +import com.fjg.omorntcpfins.handler.fins.FinsMessageHandler; +import com.fjg.omorntcpfins.model.FinsSubscribePoint; import com.fjg.omorntcpfins.util.HexStringUtil; import io.netty.bootstrap.Bootstrap; -import io.netty.buffer.ByteBuf; import io.netty.buffer.Unpooled; import io.netty.channel.*; import io.netty.channel.nio.NioEventLoopGroup; import io.netty.channel.socket.SocketChannel; import io.netty.channel.socket.nio.NioSocketChannel; +import io.netty.util.concurrent.DefaultThreadFactory; import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; +import javax.annotation.PreDestroy; +import java.util.List; +import java.util.concurrent.CopyOnWriteArrayList; import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.function.Consumer; /** - * fins客户端配置 + * FINS TCP 客户端 + *

+ * 基于 Netty 实现,提供: + *

    + *
  • 非阻塞连接与断线自动重连(可配置重连延迟)
  • + *
  • 连接状态监听(供订阅监控等感知连接/断开)
  • + *
  • 应用关闭时优雅释放资源
  • + *
+ *

* * @author fengjianguo * @date 2024/07/25 */ @Slf4j +@Component public class FinsClientConfig { - private final String host; - private final int port; + + private final OmronConfigProps props; + private final FinsMessageHandler finsMessageHandler; + private final FinsLogContext finsLogContext; + + private EventLoopGroup group; private Bootstrap bootstrap; - private static ChannelFuture channelFuture; + /** + * 当前连接通道 + */ + private volatile Channel channel; + /** + * 是否正在发起连接(防重入) + */ + private final AtomicBoolean connecting = new AtomicBoolean(false); + /** + * 是否已关闭 + */ + private final AtomicBoolean shutdown = new AtomicBoolean(false); + /** + * 连接状态监听器(true=已连接,false=已断开) + */ + private final List> connectionListeners = new CopyOnWriteArrayList<>(); + /** + * 连接成功后注册订阅点位的回调(由 FinsSubscribeManager 注入) + */ + private volatile Consumer> subscribePointsHandler; + /** + * 本次连接指定的订阅点位(连接成功时一次性交给回调注册) + */ + private volatile List pendingSubscribePoints; - public FinsClientConfig(String host, int port) { - this.host = host; - this.port = port; + public FinsClientConfig(OmronConfigProps props, + FinsMessageHandler finsMessageHandler, + FinsLogContext finsLogContext) { + this.props = props; + this.finsMessageHandler = finsMessageHandler; + this.finsLogContext = finsLogContext; init(); } private void init() { - //客户端需要一个事件循环组 - EventLoopGroup group = new NioEventLoopGroup(); - //创建客户端启动对象 - // bootstrap 可重用, 只需在NettyClient实例化的时候初始化即可. + // 客户端需要一个事件循环组 + group = new NioEventLoopGroup(1, new DefaultThreadFactory("fins-client", true)); + // 初始化SID队列,保证在任何请求发送前队列可用(channelActive 中会再次幂等重置) + HexQueue.reset(); + // 创建客户端启动对象,bootstrap 可重用,只需在实例化时初始化 bootstrap = new Bootstrap(); - bootstrap.option(ChannelOption.CONNECT_TIMEOUT_MILLIS, 5000); - bootstrap.group(group).channel(NioSocketChannel.class).handler(new ChannelInitializer() { - @Override - protected void initChannel(SocketChannel ch) { - //加入处理器 - ch.pipeline() - //设置指定时间没有响应数据抛出超时异常(秒) -// .addLast(new ReadTimeoutHandler(3)) - //设置处理器 - .addLast(new FinsClientHandler(FinsClientConfig.this)) - ; - } - }); + bootstrap.group(group) + .channel(NioSocketChannel.class) + .option(ChannelOption.CONNECT_TIMEOUT_MILLIS, props.getConnectTimeoutMs()) + .option(ChannelOption.TCP_NODELAY, true) + .option(ChannelOption.SO_KEEPALIVE, true) + .handler(new ChannelInitializer() { + @Override + protected void initChannel(SocketChannel ch) { + ch.pipeline().addLast(new FinsClientHandler(FinsClientConfig.this, finsMessageHandler, props, finsLogContext)); + } + }); } /** - * 连接服务端 - * - * @throws Exception + * 连接服务端(非阻塞) */ - public void connect() throws Exception { - //启动客户端去连接服务器端 - channelFuture = bootstrap.connect(host, port); - channelFuture.addListener((ChannelFutureListener) future -> { - if (!future.isSuccess()) { - //重连交给后端线程执行 - future.channel().eventLoop().schedule(() -> { - log.info("重连服务端"); + public synchronized void connect() { + if (shutdown.get() || connecting.get()) { + return; + } + connecting.set(true); + bootstrap.connect(props.getFinsTcpIp(), getPort()).addListener((ChannelFutureListener) future -> { + connecting.set(false); + if (future.isSuccess()) { + this.channel = future.channel(); + log.info("FINS连接成功:{}:{}", props.getFinsTcpIp(), getPort()); + notifyConnectionState(true); + // 连接成功:注册本次连接指定的订阅点位(仅首次成功触发一次,重连不重复注册) + List points = this.pendingSubscribePoints; + if (points != null && !points.isEmpty() && subscribePointsHandler != null) { + this.pendingSubscribePoints = null; try { - connect(); + subscribePointsHandler.accept(points); } catch (Exception e) { - e.printStackTrace(); + log.error("注册连接指定的订阅点位异常", e); } - }, 3000, TimeUnit.MILLISECONDS); + } + // 对通道关闭进行监听,断开后自动重连 + future.channel().closeFuture().addListener(closeFuture -> { + this.channel = null; + notifyConnectionState(false); + scheduleReconnect(); + }); } else { - log.info("连接成功"); + this.channel = null; + log.warn("FINS连接失败:{}", future.cause() == null ? "未知原因" : future.cause().getMessage()); + scheduleReconnect(); } }); - //对通道关闭进行监听 - channelFuture.channel().closeFuture().sync(); + } + + /** + * 带订阅点位的连接:连接成功后自动注册并启动指定订阅点位 + *

与配置文件(yml)注册的订阅点位共存,点位名重复时跳过;断线重连不会重复注册。

+ * + * @param subscribePoints 本次连接指定的订阅点位 + */ + public synchronized void connect(List subscribePoints) { + this.pendingSubscribePoints = subscribePoints; + connect(); + } + + /** + * 设置连接成功后注册订阅点位的回调(由订阅管理器注入) + * + * @param handler 订阅点位列表 -> void + */ + public void setSubscribePointsHandler(Consumer> handler) { + this.subscribePointsHandler = handler; + } + + /** + * 延迟重连(在事件循环线程上调度,避免额外创建线程) + */ + private void scheduleReconnect() { + if (shutdown.get() || group == null || group.isShutdown()) { + return; + } + long delay = props.getReconnectDelayMs() == null ? 3000L : props.getReconnectDelayMs(); + group.next().schedule(() -> connect(), delay, TimeUnit.MILLISECONDS); + log.info("{}ms后尝试重新连接...", delay); } /** * 发送数据 * - * @param msg 数据 + * @param msg 十六进制字符串 + * @return 是否发送成功(通道未连接时返回 false) */ - public static void send(String msg) { - if (channelFuture.channel().isActive()) { + public boolean send(String msg) { + Channel ch = this.channel; + if (ch == null || !ch.isActive()) { + log.warn("FINS通道未连接,消息发送失败:{}", msg); + return false; + } + // 交互报文日志打上 MDC 标记,路由到独立日志文件 + finsLogContext.putInteract(); + try { log.info("发送的消息:{}", msg); - ByteBuf byteBuf = Unpooled.wrappedBuffer(HexStringUtil.hexToByteArray(msg)); - channelFuture.channel().writeAndFlush(byteBuf); + } finally { + finsLogContext.clear(); } + ch.writeAndFlush(Unpooled.wrappedBuffer(HexStringUtil.hexToByteArray(msg))); + return true; } + + /** + * 当前是否已连接 + */ + public boolean isConnected() { + Channel ch = this.channel; + return ch != null && ch.isActive(); + } + + /** + * 注册连接状态监听器 + * + * @param listener true=已连接,false=已断开 + */ + public void addConnectionListener(Consumer listener) { + connectionListeners.add(listener); + } + + private void notifyConnectionState(boolean connected) { + if (!connected) { + // 断开时让所有在途请求快速失败 + finsMessageHandler.failAllPending("FINS连接断开"); + } + connectionListeners.forEach(listener -> { + try { + listener.accept(connected); + } catch (Exception e) { + log.error("连接状态监听器执行异常", e); + } + }); + } + + private int getPort() { + try { + return Integer.parseInt(props.getFinsTcpPort()); + } catch (NumberFormatException e) { + log.warn("FINS TCP端口配置非法:{},使用默认端口9600", props.getFinsTcpPort()); + return 9600; + } + } + + /** + * 应用关闭时优雅释放资源 + */ + @PreDestroy + public void shutdown() { + shutdown.set(true); + if (channel != null) { + channel.close(); + } + if (group != null) { + group.shutdownGracefully(0, 5, TimeUnit.SECONDS); + } + log.info("FINS客户端已关闭"); + } + } diff --git a/src/main/java/com/fjg/omorntcpfins/config/fins/client/FinsClientHandler.java b/src/main/java/com/fjg/omorntcpfins/config/fins/client/FinsClientHandler.java index d4b9a02..1995c24 100644 --- a/src/main/java/com/fjg/omorntcpfins/config/fins/client/FinsClientHandler.java +++ b/src/main/java/com/fjg/omorntcpfins/config/fins/client/FinsClientHandler.java @@ -2,9 +2,9 @@ package com.fjg.omorntcpfins.config.fins.client; import cn.hutool.core.util.HexUtil; import cn.hutool.core.util.StrUtil; -import cn.hutool.extra.spring.SpringUtil; import com.fjg.omorntcpfins.common.queue.HexQueue; import com.fjg.omorntcpfins.config.OmronConfigProps; +import com.fjg.omorntcpfins.config.log.FinsLogContext; import com.fjg.omorntcpfins.handler.fins.FinsMessageHandler; import com.fjg.omorntcpfins.util.HexStringUtil; import io.netty.buffer.ByteBuf; @@ -13,12 +13,9 @@ import io.netty.buffer.Unpooled; import io.netty.channel.ChannelHandlerContext; import io.netty.channel.ChannelInboundHandlerAdapter; import lombok.extern.slf4j.Slf4j; -import org.springframework.stereotype.Component; - -import java.util.Arrays; /** - * fins客户端处理器 + * FINS 客户端处理器 * * @author fengjianguo * @date 2024/07/25 @@ -26,28 +23,45 @@ import java.util.Arrays; @Slf4j public class FinsClientHandler extends ChannelInboundHandlerAdapter { + /** + * 握手指令前缀(不含PC节点号) + */ + private static final String HANDSHAKE_PREFIX = "46494E530000000C0000000000000000000000"; + private final FinsClientConfig finsClientConfig; - public static Object[] newsState = new Object[1]; + private final FinsMessageHandler finsMessageHandler; + private final OmronConfigProps props; + private final FinsLogContext finsLogContext; - public FinsClientHandler(FinsClientConfig finsClientConfig) { + public FinsClientHandler(FinsClientConfig finsClientConfig, + FinsMessageHandler finsMessageHandler, + OmronConfigProps props, + FinsLogContext finsLogContext) { this.finsClientConfig = finsClientConfig; + this.finsMessageHandler = finsMessageHandler; + this.props = props; + this.finsLogContext = finsLogContext; } /** - * 建立连接时 + * 建立连接时:发送握手指令并重置SID队列 */ @Override public void channelActive(ChannelHandlerContext ctx) throws Exception { - String order = "46494E530000000C0000000000000000000000"; - OmronConfigProps bean = SpringUtil.getBean(OmronConfigProps.class); - Integer pcIdNode = bean.getPcIdNode(); + // 重置SID队列(幂等,避免断线重连后重复入队) + HexQueue.reset(); + Integer pcIdNode = props.getPcIdNode() == null ? 0 : props.getPcIdNode(); String hexString = Integer.toHexString(pcIdNode); - String handshakeMsg = order + String.format("%2s", hexString).replace(' ', '0'); - log.info("握手的消息:{}", handshakeMsg); + String handshakeMsg = HANDSHAKE_PREFIX + String.format("%2s", hexString).replace(' ', '0'); + // 握手消息同样属于交互报文,打上 MDC 标记路由到交互日志 + finsLogContext.putInteract(); + try { + log.info("发送握手消息:{}", handshakeMsg); + } finally { + finsLogContext.clear(); + } ByteBuf byteBuf = Unpooled.wrappedBuffer(HexStringUtil.hexToByteArray(handshakeMsg)); ctx.writeAndFlush(byteBuf); - // 初始化绘话队列 - HexQueue.init(); } /** @@ -58,25 +72,27 @@ public class FinsClientHandler extends ChannelInboundHandlerAdapter { ByteBuf byteBuf = (ByteBuf) msg; byte[] bytes = ByteBufUtil.getBytes(byteBuf); String bf = HexUtil.encodeHexStr(bytes); - newsState[0] = bf; - log.info("接收的消息:{}\n", bf); - FinsMessageHandler.handle(bf); + // 交互报文日志打上 MDC 标记,路由到独立日志文件 + finsLogContext.putInteract(); + try { + log.info("接收的消息:{}", bf); + } finally { + finsLogContext.clear(); + } + finsMessageHandler.handle(bf); } /** - * 关闭连接时 + * 关闭连接时(重连逻辑由 FinsClientConfig 的 closeFuture 监听器统一处理) */ @Override public void channelInactive(ChannelHandlerContext ctx) throws Exception { - log.error("运行中断开重连, isRegistered{},isActive:{}", ctx.channel().isRegistered(), ctx.channel().isActive()); - Thread.sleep(1000); - ctx.close(); - finsClientConfig.connect(); + log.warn("FINS连接断开,等待自动重连..."); } @Override public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) throws Exception { - log.error("连接异常:{}", StrUtil.isBlankIfStr(cause.getMessage()) ? cause.getClass() : cause.getMessage()); + log.error("FINS连接异常:{}", StrUtil.isBlankIfStr(cause.getMessage()) ? cause.getClass() : cause.getMessage()); ctx.close(); } diff --git a/src/main/java/com/fjg/omorntcpfins/config/log/FinsCategoryFilter.java b/src/main/java/com/fjg/omorntcpfins/config/log/FinsCategoryFilter.java new file mode 100644 index 0000000..aca8e78 --- /dev/null +++ b/src/main/java/com/fjg/omorntcpfins/config/log/FinsCategoryFilter.java @@ -0,0 +1,96 @@ +package com.fjg.omorntcpfins.config.log; + +import ch.qos.logback.classic.spi.ILoggingEvent; +import ch.qos.logback.core.filter.Filter; +import ch.qos.logback.core.spi.FilterReply; + +/** + * 基于 MDC 的日志分类过滤器 + *

+ * 用于将带指定 MDC 键值的日志事件路由到独立日志文件: + *

    + *
  • 交互报文 appender:{@code mdcKey + mdcValue} 匹配 ACCEPT,不匹配 DENY
  • + *
  • 通用日志 appender:{@code mdcValues}(逗号分隔)匹配 DENY,不匹配 NEUTRAL,用于排除专用日志
  • + *
+ * 键名与取值均可通过 application.yml(omron.log.*)手动配置,未配置时使用默认值。 + *

+ * + * @author fengjianguo + * @date 2026/09/28 + */ +public class FinsCategoryFilter extends Filter { + + /** + * MDC 键名 + */ + private String mdcKey; + /** + * 匹配的 MDC 取值(单值) + */ + private String mdcValue; + /** + * 匹配的 MDC 取值(逗号分隔多值,优先级高于 mdcValue) + */ + private String mdcValues; + /** + * 匹配时的动作:ACCEPT / DENY / NEUTRAL + */ + private String onMatch = "ACCEPT"; + /** + * 不匹配时的动作:ACCEPT / DENY / NEUTRAL + */ + private String onMismatch = "DENY"; + + public void setMdcKey(String mdcKey) { + this.mdcKey = mdcKey; + } + + public void setMdcValue(String mdcValue) { + this.mdcValue = mdcValue; + } + + public void setMdcValues(String mdcValues) { + this.mdcValues = mdcValues; + } + + public void setOnMatch(String onMatch) { + this.onMatch = onMatch; + } + + public void setOnMismatch(String onMismatch) { + this.onMismatch = onMismatch; + } + + @Override + public FilterReply decide(ILoggingEvent event) { + if (mdcKey == null || mdcKey.isEmpty()) { + return FilterReply.NEUTRAL; + } + String value = event.getMDCPropertyMap().get(mdcKey); + boolean matched; + if (mdcValues != null && !mdcValues.trim().isEmpty()) { + matched = false; + for (String item : mdcValues.split(",")) { + if (item != null && item.trim().equals(value)) { + matched = true; + break; + } + } + } else if (mdcValue == null || mdcValue.isEmpty()) { + // 未配置取值时,按 MDC 键是否存在判断 + matched = value != null; + } else { + matched = mdcValue.equals(value); + } + return matched ? parse(onMatch) : parse(onMismatch); + } + + private FilterReply parse(String action) { + try { + return FilterReply.valueOf(action.trim().toUpperCase()); + } catch (IllegalArgumentException e) { + return FilterReply.NEUTRAL; + } + } + +} diff --git a/src/main/java/com/fjg/omorntcpfins/config/log/FinsLogContext.java b/src/main/java/com/fjg/omorntcpfins/config/log/FinsLogContext.java new file mode 100644 index 0000000..e4b9de4 --- /dev/null +++ b/src/main/java/com/fjg/omorntcpfins/config/log/FinsLogContext.java @@ -0,0 +1,54 @@ +package com.fjg.omorntcpfins.config.log; + +import com.fjg.omorntcpfins.config.OmronLogProps; +import org.slf4j.MDC; +import org.springframework.stereotype.Component; + +/** + * 专用日志 MDC 标记工具 + *

+ * 在打印交互报文、订阅值变化日志前,向 MDC 写入对应的键值, + * logback 中配置的 {@code FinsCategoryFilter} 据此将日志路由到独立文件。 + *

+ * + * @author fengjianguo + * @date 2026/09/28 + */ +@Component +public class FinsLogContext { + + private final OmronLogProps props; + + public FinsLogContext(OmronLogProps props) { + this.props = props; + } + + /** + * 标记当前线程日志为交互报文 + */ + public void putInteract() { + MDC.put(props.getMdcKey(), props.getInteractValue()); + } + + /** + * 标记当前线程日志为订阅值变化 + */ + public void putChange() { + MDC.put(props.getMdcKey(), props.getChangeValue()); + } + + /** + * 清除 MDC 标记(务必在 finally 中调用) + */ + public void clear() { + MDC.remove(props.getMdcKey()); + } + + /** + * 当前使用的 MDC 键名 + */ + public String getMdcKey() { + return props.getMdcKey(); + } + +} diff --git a/src/main/java/com/fjg/omorntcpfins/controller/FinsSubscribeController.java b/src/main/java/com/fjg/omorntcpfins/controller/FinsSubscribeController.java new file mode 100644 index 0000000..9437248 --- /dev/null +++ b/src/main/java/com/fjg/omorntcpfins/controller/FinsSubscribeController.java @@ -0,0 +1,95 @@ +package com.fjg.omorntcpfins.controller; + +import com.fjg.omorntcpfins.model.FinsSubscribePoint; +import com.fjg.omorntcpfins.subscribe.FinsSubscribeManager; +import lombok.extern.slf4j.Slf4j; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.util.List; +import java.util.Map; + +/** + * 订阅监控管理接口 + * + * @author fengjianguo + * @date 2026/09/28 + */ +@Slf4j +@RestController +@RequestMapping("/subscribe") +public class FinsSubscribeController { + + private final FinsSubscribeManager subscribeManager; + + public FinsSubscribeController(FinsSubscribeManager subscribeManager) { + this.subscribeManager = subscribeManager; + } + + /** + * 查询全部订阅点位(含当前值与运行统计) + */ + @GetMapping("/list") + public List list() { + return subscribeManager.listPoints(); + } + + /** + * 新增订阅点位(启用的点位会自动启动轮询) + *
+     * {
+     *   "name": "D6004-温度",
+     *   "address": "D6004",
+     *   "offset": 1,
+     *   "dataType": "WORD",
+     *   "intervalMs": 1000,
+     *   "enabled": true,
+     *   "description": "温度"
+     * }
+     * 
+ */ + @PostMapping + public FinsSubscribePoint add(@RequestBody FinsSubscribePoint point) { + log.info("新增订阅点位:{}", point); + return subscribeManager.addPoint(point); + } + + /** + * 启动指定点位轮询 + */ + @PostMapping("/{name}/start") + public FinsSubscribePoint start(@PathVariable String name) { + return subscribeManager.startPoint(name); + } + + /** + * 停止指定点位轮询 + */ + @PostMapping("/{name}/stop") + public FinsSubscribePoint stop(@PathVariable String name) { + return subscribeManager.stopPoint(name); + } + + /** + * 删除指定点位 + */ + @DeleteMapping("/{name}") + public String remove(@PathVariable String name) { + subscribeManager.removePoint(name); + return "success"; + } + + /** + * 订阅监控聚合统计 + */ + @GetMapping("/stats") + public Map stats() { + return subscribeManager.stats(); + } + +} diff --git a/src/main/java/com/fjg/omorntcpfins/controller/TestController.java b/src/main/java/com/fjg/omorntcpfins/controller/TestController.java new file mode 100644 index 0000000..18e558e --- /dev/null +++ b/src/main/java/com/fjg/omorntcpfins/controller/TestController.java @@ -0,0 +1,237 @@ +package com.fjg.omorntcpfins.controller; + +import com.fjg.omorntcpfins.util.FinsUtil; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/** + * FINS 读写测试接口 + *

支持全部内存区类型(type: D/C/W/H/A/E/T)与全部读写数据类型。

+ *
    + *
  • 读取 dataType:word(十进制字)/hex(原始十六进制)/signed(有符号字)/list(批量无符号字)/dword(32位整数)/float(浮点)/bool(单点)/bits(批量位)
  • + *
  • 写入 dataType:word/hex/list/dword/float/bool/bits(不传时按值自动推断,兼容旧用法)
  • + *
+ * + * @author fengjianguo + * @date 2024/7/22 10:05 + */ +@RestController +@RequestMapping("/send") +public class TestController { + + private final FinsUtil finsUtil; + + public TestController(FinsUtil finsUtil) { + this.finsUtil = finsUtil; + } + + /** + * 读取测试 + * + * @param type 内存区类型:D/C/W/H/A/E/T,默认 D + * @param db 地址,如 D6004、W100、T10 + * @param offset word/list 模式为读取个数;bool/bits 模式为位号(0~15) + * @param dataType 数据类型:word(默认)/hex/signed/list/dword/float/bool/bits + * @param count bits/list/signed 批量读取个数(默认1) + * @return 读取结果 + */ + @GetMapping({"/read", "/send"}) + public Map read(@RequestParam(defaultValue = "D") String type, + @RequestParam String db, + @RequestParam(defaultValue = "1") Integer offset, + @RequestParam(defaultValue = "word") String dataType, + @RequestParam(defaultValue = "1") Integer count) { + String address = normalize(type, db); + Map result = new HashMap<>(); + result.put("address", address); + try { + switch (dataType.toLowerCase()) { + case "hex": + result.put("dataType", "hex"); + result.put("data", finsUtil.readWordHex(address, offset)); + break; + case "signed": + result.put("dataType", "signed"); + result.put("count", count); + result.put("data", finsUtil.readWordsSigned(address, count)); + break; + case "list": + result.put("dataType", "list"); + result.put("count", count); + result.put("data", finsUtil.readWords(address, count)); + break; + case "dword": + result.put("dataType", "dword"); + result.put("data", finsUtil.readDword(address, offset)); + break; + case "float": + result.put("dataType", "float"); + result.put("data", finsUtil.readFloat(address, offset)); + break; + case "bool": + result.put("dataType", "bool"); + result.put("data", finsUtil.readBool(address, offset)); + break; + case "bits": + result.put("dataType", "bits"); + result.put("bit", offset); + result.put("count", count); + result.put("data", finsUtil.readBits(address, offset, count)); + break; + default: + result.put("dataType", "word"); + result.put("data", finsUtil.readWord(address, offset)); + } + result.put("success", true); + } catch (Exception e) { + result.put("success", false); + result.put("error", e.getMessage()); + } + return result; + } + + /** + * 写入测试 + * + * @param type 内存区类型:D/C/W/H/A/E/T,默认 D + * @param db 地址,如 D6004、W100、T10 + * @param offset bool/bits 模式为位号(0~15),其余模式保留参数 + * @param dataType 数据类型:word/hex/list/dword/float/bool/bits;不传时按值自动推断 + * @param dbValue 写入值:bool=true/false;float=浮点;dword=整数;hex=4位十六进制;word=整数(-32768~65535) + * @param values 批量写入值(逗号分隔):list 为十进制字,bits 为 true/false + * @param count list 批量写入个数(默认按 values 个数) + * @return 写入结果 + */ + @GetMapping("/write") + public Map write(@RequestParam(defaultValue = "D") String type, + @RequestParam String db, + @RequestParam(defaultValue = "1") Integer offset, + @RequestParam(required = false) String dataType, + @RequestParam(required = false) String dbValue, + @RequestParam(required = false) String values, + @RequestParam(required = false) Integer count) { + String address = normalize(type, db); + Map result = new HashMap<>(); + result.put("address", address); + try { + boolean ok; + String dt; + String dtParam = dataType == null ? "" : dataType.toLowerCase(); + if ("bool".equals(dtParam)) { + dt = "bool"; + ok = finsUtil.writeBool(address, offset, Boolean.valueOf(dbValue)); + } else if ("bits".equals(dtParam)) { + dt = "bits"; + List bits = parseBits(values); + ok = finsUtil.writeBits(address, offset, bits); + result.put("bit", offset); + result.put("count", bits.size()); + } else if ("hex".equals(dtParam)) { + dt = "hex"; + ok = finsUtil.writeWordsHex(address, 1, dbValue); + } else if ("list".equals(dtParam)) { + dt = "list"; + List list = parseValues(values); + ok = finsUtil.writeWords(address, count == null ? list.size() : count, list); + result.put("count", list.size()); + } else if ("dword".equals(dtParam)) { + dt = "dword"; + ok = finsUtil.writeDword(address, offset, Long.parseLong(dbValue)); + } else if ("float".equals(dtParam)) { + dt = "float"; + ok = finsUtil.writeFloat(address, offset, Float.parseFloat(dbValue)); + } else if ("word".equals(dtParam)) { + dt = "word"; + ok = finsUtil.writeWord(address, offset, dbValue); + } else { + // 自动推断(兼容旧用法) + if (values != null && !values.trim().isEmpty()) { + dt = "list"; + List list = parseValues(values); + ok = finsUtil.writeWords(address, list.size(), list); + result.put("count", list.size()); + } else if (dbValue != null && dbValue.toLowerCase().contains(".")) { + dt = "float"; + ok = finsUtil.writeFloat(address, offset, Float.parseFloat(dbValue)); + } else if (dbValue != null && dbValue.toLowerCase().startsWith("0x")) { + dt = "hex"; + ok = finsUtil.writeWordsHex(address, 1, dbValue.substring(2)); + } else if (dbValue != null && dbValue.length() > 6) { + dt = "dword"; + ok = finsUtil.writeDword(address, offset, Long.parseLong(dbValue)); + } else { + dt = "word"; + ok = finsUtil.writeWord(address, offset, dbValue); + } + } + result.put("dataType", dt); + result.put("success", ok); + } catch (Exception e) { + result.put("success", false); + result.put("error", e.getMessage()); + } + return result; + } + + /** + * 规范化地址:若 db 未带区域前缀,则补上 type + */ + private String normalize(String type, String db) { + if (db == null || db.isEmpty()) { + throw new IllegalArgumentException("地址不能为空"); + } + char first = Character.toUpperCase(db.charAt(0)); + if (first >= 'A' && first <= 'Z') { + return db.toUpperCase(); + } + return type.toUpperCase() + db; + } + + /** + * 解析逗号分隔的十进制值(支持 0x 前缀) + */ + private List parseValues(String values) { + List list = new ArrayList<>(); + if (values == null) { + return list; + } + for (String v : values.split(",")) { + String s = v.trim(); + if (s.isEmpty()) { + continue; + } + if (s.toLowerCase().startsWith("0x")) { + list.add(Integer.parseInt(s.substring(2), 16)); + } else { + list.add(Integer.parseInt(s)); + } + } + return list; + } + + /** + * 解析逗号分隔的布尔值(true/false/1/0) + */ + private List parseBits(String values) { + List list = new ArrayList<>(); + if (values == null) { + return list; + } + for (String v : values.split(",")) { + String s = v.trim().toLowerCase(); + if (s.isEmpty()) { + continue; + } + list.add("true".equals(s) || "1".equals(s)); + } + return list; + } + +} diff --git a/src/main/java/com/fjg/omorntcpfins/exception/GlobalException.java b/src/main/java/com/fjg/omorntcpfins/exception/GlobalException.java deleted file mode 100644 index 95d91a8..0000000 --- a/src/main/java/com/fjg/omorntcpfins/exception/GlobalException.java +++ /dev/null @@ -1,29 +0,0 @@ -package com.fjg.omorntcpfins.exception; - -import com.fjg.omorntcpfins.common.enums.MsgEnum; -import lombok.Getter; - -/** - * @author Zhang GuiHong - * @date 2021/12/27 10:25 - **/ -@Getter -public class GlobalException extends RuntimeException { - - private final Integer code; - - public GlobalException(Integer code, String msg) { - super(msg); - this.code = code; - } - - public GlobalException(MsgEnum msgEnum) { - super(msgEnum.msg()); - this.code = msgEnum.code(); - } - - public GlobalException(String msg) { - super(msg); - this.code = MsgEnum.ERROR.code(); - } -} diff --git a/src/main/java/com/fjg/omorntcpfins/exception/OmronException.java b/src/main/java/com/fjg/omorntcpfins/exception/OmronException.java new file mode 100644 index 0000000..01e2cfd --- /dev/null +++ b/src/main/java/com/fjg/omorntcpfins/exception/OmronException.java @@ -0,0 +1,76 @@ +package com.fjg.omorntcpfins.exception; + +import com.fjg.omorntcpfins.common.enums.MsgEnum; +import lombok.Getter; + +/** + * 项目统一业务异常 + *

+ * 所有业务校验、协议解析、读写失败等可控错误统一抛出本异常,携带错误码与提示信息; + * 由调用方(REST 层/上层服务)决定如何呈现。非受检异常,无需强制捕获。 + *

+ * + * @author fengjianguo + * @date 2023/09/05 + */ +@Getter +public class OmronException extends RuntimeException { + + private static final long serialVersionUID = 1L; + + /** + * 错误码:0=成功;-1=通用错误;业务可自定义 + */ + private final Integer code; + + public OmronException(String msg) { + super(msg); + this.code = MsgEnum.ERROR.code(); + } + + public OmronException(String msg, Throwable cause) { + super(msg, cause); + this.code = MsgEnum.ERROR.code(); + } + + public OmronException(Integer code, String msg) { + super(msg); + this.code = code; + } + + public OmronException(Integer code, String msg, Throwable cause) { + super(msg, cause); + this.code = code; + } + + public OmronException(MsgEnum msgEnum) { + super(msgEnum.msg()); + this.code = msgEnum.code(); + } + + public OmronException(MsgEnum msgEnum, Throwable cause) { + super(msgEnum.msg(), cause); + this.code = msgEnum.code(); + } + + /** + * 快速构造:指定错误码与信息 + */ + public static OmronException of(Integer code, String msg) { + return new OmronException(code, msg); + } + + /** + * 快速构造:通用错误码(-1) + */ + public static OmronException of(String msg) { + return new OmronException(msg); + } + + /** + * 快速构造:携带底层原因 + */ + public static OmronException of(Integer code, String msg, Throwable cause) { + return new OmronException(code, msg, cause); + } +} diff --git a/src/main/java/com/fjg/omorntcpfins/handler/fins/FinsMessageHandler.java b/src/main/java/com/fjg/omorntcpfins/handler/fins/FinsMessageHandler.java index 900c9fe..ef23ed0 100644 --- a/src/main/java/com/fjg/omorntcpfins/handler/fins/FinsMessageHandler.java +++ b/src/main/java/com/fjg/omorntcpfins/handler/fins/FinsMessageHandler.java @@ -4,95 +4,143 @@ import com.fjg.omorntcpfins.common.enums.FinsErrorCodeEnum; import com.fjg.omorntcpfins.common.enums.FinsFixedValueEnum; import com.fjg.omorntcpfins.common.enums.FinsReadWriteEnum; import com.fjg.omorntcpfins.common.queue.HexQueue; -import com.fjg.omorntcpfins.config.fins.client.FinsClientConfig; +import com.fjg.omorntcpfins.config.fins.FinsNodeContext; +import com.fjg.omorntcpfins.exception.OmronException; import com.fjg.omorntcpfins.util.FinsMsgAnalysisUtil; -import com.fjg.omorntcpfins.util.HexStringUtil; -import io.netty.buffer.ByteBuf; -import io.netty.buffer.Unpooled; -import io.netty.channel.ChannelHandlerContext; import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; -import java.util.Arrays; import java.util.Map; import java.util.concurrent.CompletableFuture; import java.util.concurrent.ConcurrentHashMap; /** + * FINS 响应消息处理器 + *

+ * 按 SID 匹配请求与响应:收到 PLC 响应后,从在途请求表中取出对应的 + * {@link CompletableFuture} 并写入结果(读返回数据、写返回响应标识)。 + * 握手响应到达时,解析其中的节点信息(PLC 节点不再手动配置)。 + *

+ * * @author fengjianguo * @date 2024/7/29 10:04 */ @Slf4j +@Component public class FinsMessageHandler { - private final static Map> REQUEST_FUTURES = new ConcurrentHashMap<>(); + /** + * 在途请求表:SID -> CompletableFuture + */ + private final Map> requestFutures = new ConcurrentHashMap<>(); - public static void handle(String msg) { - // 过滤握手信息 - if (FinsMsgAnalysisUtil.isHandshake(msg)){ - log.info("收到握手信息:" + msg); + private final FinsNodeContext finsNodeContext; + + public FinsMessageHandler(FinsNodeContext finsNodeContext) { + this.finsNodeContext = finsNodeContext; + } + + /** + * 处理 PLC 响应消息 + * + * @param msg 响应消息(十六进制字符串) + */ + public void handle(String msg) { + // 过滤握手响应 + if (FinsMsgAnalysisUtil.isHandshake(msg)) { try { - String s = FinsMsgAnalysisUtil.finsErrorCode(msg); - if (FinsErrorCodeEnum.isConnectedAlready(s)){ - log.error("连接被占用"); + String errorCode = FinsMsgAnalysisUtil.finsErrorCode(msg); + if (FinsErrorCodeEnum.isConnectedAlready(errorCode)) { + log.error("FINS 连接被占用(00000021)"); return; } + // 从握手响应解析本机节点与PLC节点(PLC节点不再手动配置) + finsNodeContext.updateFromHandshake( + FinsMsgAnalysisUtil.finsHandshakePcNode(msg), + FinsMsgAnalysisUtil.finsHandshakePlcNode(msg)); + log.info("FINS 握手成功"); } catch (IllegalArgumentException e) { - log.error("解析错误:{}" , msg); - log.error("解析错误:{}" , e.getMessage()); - log.error("解析错误:{}" , Arrays.toString(e.getStackTrace())); + log.error("握手消息解析失败:{}", msg, e); } return; } + try { - // 获取fins头 + // 校验FINS头 String finsHead = FinsMsgAnalysisUtil.finsHead(msg); - // 十六进制解析 - boolean finsHeadByHexValue = FinsFixedValueEnum.isFinsHeadByHexValue(finsHead); - if (!finsHeadByHexValue){ + if (!FinsFixedValueEnum.isFinsHeadByHexValue(finsHead)) { log.error("FINS头错误:" + finsHead); return; } - // 获取fins-SID + // 获取SID,按SID匹配在途请求 String finsSid = FinsMsgAnalysisUtil.finsSid(msg); - // 判断是读是写 + // 判断是读还是写 String finsReadWrite = FinsMsgAnalysisUtil.finsReadWrite(msg); - boolean writeByCode = FinsReadWriteEnum.isWriteByCode(finsReadWrite); - if (writeByCode){ - // 执行写操作 - if (REQUEST_FUTURES.containsKey(finsSid)){ - CompletableFuture future = REQUEST_FUTURES.get(finsSid); - // 解析,获取db信息,获取读取到的数据 - future.complete(FinsMsgAnalysisUtil.finsResponseFlag(msg)); - REQUEST_FUTURES.remove(finsSid); - }else{ - log.info("未找到对应的请求"); - } + boolean isWrite = FinsReadWriteEnum.isWriteByCode(finsReadWrite); + + CompletableFuture future = requestFutures.remove(finsSid); + if (future == null) { + log.info("未找到对应的请求[SID={}],消息:{}", finsSid, msg); return; } - log.info("收到消息:" + msg); - if (REQUEST_FUTURES.containsKey(finsSid)){ - CompletableFuture future = REQUEST_FUTURES.get(finsSid); - // 解析,获取db信息,获取读取到的数据 + if (isWrite) { + // 写响应:完成标识 + future.complete(FinsMsgAnalysisUtil.finsResponseFlag(msg)); + } else { + // 读响应:返回数据 future.complete(FinsMsgAnalysisUtil.finsData(msg)); - REQUEST_FUTURES.remove(finsSid); - }else{ - log.info("未找到对应的请求"); } + // 响应已匹配并移除在途请求,SID 立即归还队列 + // (FinsUtil.send 之后调 removeRequestFutures 时 remove 返回 null,不会重复归还) + HexQueue.returnHex(finsSid); } catch (IllegalArgumentException e) { - log.error("解析错误:{}" , msg); - log.error("解析错误:{}" , e.getMessage()); - log.error("解析错误:{}" , Arrays.toString(e.getStackTrace())); + log.error("解析错误:{},原因:{}", msg, e.getMessage()); + } + } + + /** + * 注册在途请求 + * + * @param key SID + * @param future 请求对应的Future + */ + public void setRequestFutures(String key, CompletableFuture future) { + requestFutures.put(key, future); + } + + /** + * 移除在途请求并归还SID(仅当确实存在时才归还,避免SID重复入队) + * + * @param key SID + */ + public void removeRequestFutures(String key) { + if (requestFutures.remove(key) != null) { + log.info("归还SID:{}", key); + HexQueue.returnHex(key); } } - public static void setRequestFutures(String key, CompletableFuture future) { - REQUEST_FUTURES.put(key, future); + /** + * 连接断开时,让所有在途请求快速失败,避免调用方无限等待 + *

注意:此处不归还SID,SID队列会在重连建立时整体重置。

+ * + * @param reason 失败原因 + */ + public void failAllPending(String reason) { + if (requestFutures.isEmpty()) { + return; + } + log.warn("FINS连接断开,清理在途请求 {} 个,原因:{}", requestFutures.size(), reason); + requestFutures.forEach((key, future) -> + future.completeExceptionally(new OmronException(reason))); + requestFutures.clear(); } - public static void removeRequestFutures(String key) { - REQUEST_FUTURES.remove(key); - log.info("删除请求:{}" , key); - log.info("归还sid:{}", key); - HexQueue.returnHex(key); + /** + * 当前在途请求数量 + */ + public int pendingCount() { + return requestFutures.size(); } + } diff --git a/src/main/java/com/fjg/omorntcpfins/handler/strategy/area/AreaTypeBitStrategy.java b/src/main/java/com/fjg/omorntcpfins/handler/strategy/area/AreaTypeBitStrategy.java index 5c108a3..9e96c92 100644 --- a/src/main/java/com/fjg/omorntcpfins/handler/strategy/area/AreaTypeBitStrategy.java +++ b/src/main/java/com/fjg/omorntcpfins/handler/strategy/area/AreaTypeBitStrategy.java @@ -1,34 +1,43 @@ package com.fjg.omorntcpfins.handler.strategy.area; +import com.fjg.omorntcpfins.common.enums.MemoryAreaEnum; +import com.fjg.omorntcpfins.exception.OmronException; import com.fjg.omorntcpfins.model.DbInfoPO; import org.springframework.stereotype.Component; /** + * 位读写地址解析策略 + *

+ * 支持 D/C/W/H/A/E/T 全部内存区。FINS 位读写地址为 3 字节:高 2 字节为字号、低字节为位号(0~15), + * 因此 offset 语义为位号(如 D6000 offset=1 表示 D6000.1),读取/写入个数固定为 1。 + *

+ * * @author fengjianguo * @date 2024/7/29 11:24 */ @Component("areaTypeBitStrategy") public class AreaTypeBitStrategy extends BaseAreaTypeStrategy { + + /** + * 位号上限(0~15) + */ + private static final int MAX_BIT_NUMBER = 15; + @Override public DbInfoPO parseDbInfo(String address, int offset) { - DbInfoPO dbInfo = buildDbInfo(address,offset); - String substring = address.substring(0, 1); - String dbArea; - // D位:02,D字:82,W位:31,C位:30,W字:B1,C字:B0 - switch (substring.toUpperCase()) { - case "D": - dbArea = "02"; - break; - case "W": - dbArea = "31"; - break; - case "C": - dbArea = "30"; - break; - default: - dbArea = "00"; + DbInfoPO dbInfo = buildDbInfo(address, offset); + String prefix = address.substring(0, 1); + // 按区域前缀取位内存区代码(D:02, C:30, W:31, H:32, A:33, E:20, T:09) + dbInfo.setDbArea(MemoryAreaEnum.of(prefix).getBitCode()); + if (offset < 0 || offset > MAX_BIT_NUMBER) { + throw new OmronException("位号超出范围(0~15): " + offset); } - dbInfo.setDbArea(dbArea); + // 位地址 = 字号×256 + 位号,并入低字节 + int word = Integer.parseInt(address.substring(1)); + String bitAddress = Integer.toHexString(word * 256 + offset); + dbInfo.setDbBlock(String.format("%6s", bitAddress).replace(" ", "0")); + // 位读写个数固定为 1 + dbInfo.setDbOffset("0001"); return dbInfo; } } diff --git a/src/main/java/com/fjg/omorntcpfins/handler/strategy/area/AreaTypeWordStrategy.java b/src/main/java/com/fjg/omorntcpfins/handler/strategy/area/AreaTypeWordStrategy.java index 0fbf198..6a87395 100644 --- a/src/main/java/com/fjg/omorntcpfins/handler/strategy/area/AreaTypeWordStrategy.java +++ b/src/main/java/com/fjg/omorntcpfins/handler/strategy/area/AreaTypeWordStrategy.java @@ -1,10 +1,13 @@ package com.fjg.omorntcpfins.handler.strategy.area; -import com.fjg.omorntcpfins.common.enums.AreaTypeEnum; +import com.fjg.omorntcpfins.common.enums.MemoryAreaEnum; import com.fjg.omorntcpfins.model.DbInfoPO; import org.springframework.stereotype.Component; /** + * 字读写地址解析策略 + *

支持 D/C/W/H/A/E/T 全部内存区,offset 为读取/写入的个数(一次最多 1000 个字)。

+ * * @author fengjianguo * @date 2024/7/29 11:24 */ @@ -12,24 +15,10 @@ import org.springframework.stereotype.Component; public class AreaTypeWordStrategy extends BaseAreaTypeStrategy { @Override public DbInfoPO parseDbInfo(String address, int offset) { - DbInfoPO dbInfo = buildDbInfo(address,offset); - String substring = address.substring(0, 1); - String dbArea; - // D位:02,D字:82,W位:31,C位:30,W字:B1,C字:B0 - switch (substring.toUpperCase()) { - case "D": - dbArea = "82"; - break; - case "W": - dbArea = "B1"; - break; - case "C": - dbArea = "B0"; - break; - default: - dbArea = "00"; - } - dbInfo.setDbArea(dbArea); + DbInfoPO dbInfo = buildDbInfo(address, offset); + String prefix = address.substring(0, 1); + // 按区域前缀取字内存区代码(D:82, C: B0, W:B1, H:B2, A:B3, E:A0, T:89) + dbInfo.setDbArea(MemoryAreaEnum.of(prefix).getWordCode()); return dbInfo; } } diff --git a/src/main/java/com/fjg/omorntcpfins/handler/strategy/area/BaseAreaTypeStrategy.java b/src/main/java/com/fjg/omorntcpfins/handler/strategy/area/BaseAreaTypeStrategy.java index f5280e2..29c2e77 100644 --- a/src/main/java/com/fjg/omorntcpfins/handler/strategy/area/BaseAreaTypeStrategy.java +++ b/src/main/java/com/fjg/omorntcpfins/handler/strategy/area/BaseAreaTypeStrategy.java @@ -1,7 +1,7 @@ package com.fjg.omorntcpfins.handler.strategy.area; +import com.fjg.omorntcpfins.exception.OmronException; import com.fjg.omorntcpfins.model.DbInfoPO; -import com.sun.org.apache.bcel.internal.generic.PUSH; import lombok.extern.slf4j.Slf4j; /** @@ -11,6 +11,16 @@ import lombok.extern.slf4j.Slf4j; @Slf4j public abstract class BaseAreaTypeStrategy implements AreaTypeStrategy { + /** + * 字地址在 FINS 指令中占 3 字节(6 位十六进制),换算规则:字号 × 256 + */ + private static final int WORD_ADDRESS_LENGTH = 6; + + /** + * 字号上限:3 字节地址最大 0xFFFFFF,对应字号最大 0xFFFF + */ + private static final int MAX_WORD_NUMBER = 0xFFFF; + @Override public DbInfoPO parseDbInfo(String address, int offset) { log.info("address:{}, offset:{}", address, offset); @@ -18,12 +28,34 @@ public abstract class BaseAreaTypeStrategy implements AreaTypeStrategy { return null; } + /** + * 构建 FINS 字地址信息 + *

起始地址 = 字号 × 256(低字节恒为 0,供位读写时填入位号), + * 统一补零为 6 位十六进制,避免小地址导致报文长度错误。

+ * + * @param address 地址,如 D6004、W100、T10 + * @param offset 字偏移/读取个数或位号 + * @return 地址信息 + */ public DbInfoPO buildDbInfo(String address, int offset) { + if (address == null || address.length() < 2) { + throw new OmronException("地址格式错误: " + address + "(应为 区域前缀+编号,如 D6004)"); + } + int word; + try { + word = Integer.parseInt(address.substring(1)); + } catch (NumberFormatException e) { + throw new OmronException("地址编号解析失败: " + address); + } + if (word < 0 || word > MAX_WORD_NUMBER) { + throw new OmronException("地址编号超出范围(0~65535): " + address); + } DbInfoPO dbInfo = new DbInfoPO(); - String dbBlock = Integer.toHexString(Integer.parseInt(address.substring(1)) * 256); + String dbBlock = Integer.toHexString(word * 256); + dbInfo.setDbBlock(String.format("%" + WORD_ADDRESS_LENGTH + "s", dbBlock).replace(" ", "0")); String dbOffset = Integer.toHexString(offset); - dbInfo.setDbBlock(dbBlock); - dbInfo.setDbOffset(String.format("%4s",dbOffset).replace(" ", "0")); + dbInfo.setDbOffset(String.format("%4s", dbOffset).replace(" ", "0")); return dbInfo; } + } diff --git a/src/main/java/com/fjg/omorntcpfins/model/FinsSubscribePoint.java b/src/main/java/com/fjg/omorntcpfins/model/FinsSubscribePoint.java new file mode 100644 index 0000000..8c2f0b5 --- /dev/null +++ b/src/main/java/com/fjg/omorntcpfins/model/FinsSubscribePoint.java @@ -0,0 +1,136 @@ +package com.fjg.omorntcpfins.model; + +import com.fjg.omorntcpfins.common.enums.AreaTypeEnum; +import lombok.Data; + +import java.util.concurrent.atomic.AtomicLong; + +/** + * 订阅点位 + *

+ * 配置字段(yml 或 REST API 传入):name / address / offset / dataType / intervalMs / enabled / description; + * 运行字段(由 {@code FinsSubscribeManager} 维护):当前值、累计读取/成功/变化次数、最近读取时间、错误信息、运行状态。 + *

+ * + * @author fengjianguo + * @date 2026/09/28 + */ +@Data +public class FinsSubscribePoint { + + /** + * 最小轮询间隔(毫秒),防止配置过小打爆PLC + */ + public static final long MIN_INTERVAL_MS = 100L; + + /** + * 点位运行状态 + */ + public enum SubscribeStatus { + /** + * 已注册未启动 + */ + INIT, + /** + * 轮询中 + */ + RUNNING, + /** + * 已停止 + */ + STOPPED + } + + // ==================== 配置字段 ==================== + + /** + * 点位名称(唯一标识) + */ + private String name; + + /** + * PLC 地址,如 D6004、W100、C50 + */ + private String address; + + /** + * 偏移量/一次读取个数,通常为 1 + */ + private Integer offset = 1; + + /** + * 数据类型:BIT / WORD + */ + private AreaTypeEnum dataType = AreaTypeEnum.WORD; + + /** + * 首次读取是否触发值变化:true=首次读到值记为一次变化 null→值;false=首次读取仅建立基线,不触发监听/变化日志/变化计数。 + * 为空时由全局配置 {@code omron.subscribe.first-read-trigger} 兜底(优先级:点位配置 > 全局配置) + */ + private Boolean firstReadTrigger; + + /** + * 轮询间隔(毫秒),为空时使用全局默认值 + */ + private Long intervalMs; + + /** + * 是否启用 + */ + private Boolean enabled = true; + + /** + * 描述 + */ + private String description; + + // ==================== 运行状态字段 ==================== + + /** + * 当前值 + */ + private volatile String currentValue; + + /** + * 最近错误信息 + */ + private volatile String lastError; + + /** + * 累计读取次数 + */ + private AtomicLong readCount = new AtomicLong(0); + + /** + * 累计成功次数 + */ + private AtomicLong successCount = new AtomicLong(0); + + /** + * 累计值变化次数 + */ + private AtomicLong changeCount = new AtomicLong(0); + + /** + * 最近读取时间戳 + */ + private volatile Long lastReadTime; + + /** + * 是否正在读取中(防止同一点位轮询重叠) + */ + private volatile Boolean reading = false; + + /** + * 运行状态 + */ + private volatile SubscribeStatus status = SubscribeStatus.INIT; + + /** + * 生效的轮询间隔(毫秒) + */ + public long effectiveIntervalMs() { + return intervalMs == null || intervalMs < MIN_INTERVAL_MS ? MIN_INTERVAL_MS : intervalMs; + } + +} diff --git a/src/main/java/com/fjg/omorntcpfins/run/AppRunning.java b/src/main/java/com/fjg/omorntcpfins/run/AppRunning.java index 9e5a56b..49a6ae5 100644 --- a/src/main/java/com/fjg/omorntcpfins/run/AppRunning.java +++ b/src/main/java/com/fjg/omorntcpfins/run/AppRunning.java @@ -1,15 +1,14 @@ package com.fjg.omorntcpfins.run; +import com.fjg.omorntcpfins.config.OmronConfigProps; import com.fjg.omorntcpfins.config.fins.client.FinsClientConfig; import lombok.extern.slf4j.Slf4j; -import org.springframework.beans.factory.annotation.Value; import org.springframework.boot.CommandLineRunner; import org.springframework.stereotype.Component; -import java.util.concurrent.ExecutorService; -import java.util.concurrent.Executors; - /** + * 启动后自动连接 PLC + * * @author fengjianguo * @date 2024/7/22 9:28 */ @@ -17,24 +16,18 @@ import java.util.concurrent.Executors; @Component public class AppRunning implements CommandLineRunner { - @Value("${omron.fins-tcp-ip:(127.0.0.1)}") - private String url; + private final FinsClientConfig finsClientConfig; + private final OmronConfigProps omronConfigProps; - @Value("${omron.fins-tcp-port:(9600)}") - private Integer port; + public AppRunning(FinsClientConfig finsClientConfig, OmronConfigProps omronConfigProps) { + this.finsClientConfig = finsClientConfig; + this.omronConfigProps = omronConfigProps; + } @Override - public void run(String... args) throws Exception { - log.info("PLC连接中······"); - FinsClientConfig finsClientConfig = new FinsClientConfig(url, port); - ExecutorService executorService = Executors.newFixedThreadPool(1); - //启动Fins通讯 - executorService.submit(() -> { - try { - finsClientConfig.connect(); - } catch (Exception e) { - throw new RuntimeException(e); - } - }); + public void run(String... args) { + log.info("启动FINS客户端,连接 PLC {}:{} ...", omronConfigProps.getFinsTcpIp(), omronConfigProps.getFinsTcpPort()); + // 非阻塞连接,断线自动重连由客户端内部处理 + finsClientConfig.connect(); } } diff --git a/src/main/java/com/fjg/omorntcpfins/subscribe/FinsSubscribeLogListener.java b/src/main/java/com/fjg/omorntcpfins/subscribe/FinsSubscribeLogListener.java new file mode 100644 index 0000000..9182949 --- /dev/null +++ b/src/main/java/com/fjg/omorntcpfins/subscribe/FinsSubscribeLogListener.java @@ -0,0 +1,30 @@ +package com.fjg.omorntcpfins.subscribe; + +import com.fjg.omorntcpfins.model.FinsSubscribePoint; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; + +/** + * 订阅值变化默认日志监听器 + *

值变化已由 {@link FinsSubscribeManager} 统一输出到专用 change 日志文件, + * 本监听器以 DEBUG 级别记录,便于排查时在调试日志中跟踪,避免与专用日志重复刷屏。

+ * + * @author fengjianguo + * @date 2026/09/28 + */ +@Slf4j +@Component +public class FinsSubscribeLogListener implements FinsValueChangeListener { + + @Override + public void onChange(FinsSubscribePoint point, String oldValue, String newValue) { + log.debug("【订阅变化】点位[{}] 地址[{}-{}] {} → {}(累计变化{}次)", + point.getName(), + point.getAddress(), + point.getOffset(), + oldValue, + newValue, + point.getChangeCount()); + } + +} diff --git a/src/main/java/com/fjg/omorntcpfins/subscribe/FinsSubscribeManager.java b/src/main/java/com/fjg/omorntcpfins/subscribe/FinsSubscribeManager.java new file mode 100644 index 0000000..36c1747 --- /dev/null +++ b/src/main/java/com/fjg/omorntcpfins/subscribe/FinsSubscribeManager.java @@ -0,0 +1,462 @@ +package com.fjg.omorntcpfins.subscribe; + +import com.fjg.omorntcpfins.common.enums.AreaTypeEnum; +import com.fjg.omorntcpfins.common.queue.HexQueue; +import com.fjg.omorntcpfins.config.OmronSubscribeProps; +import com.fjg.omorntcpfins.config.fins.client.FinsClientConfig; +import com.fjg.omorntcpfins.config.log.FinsLogContext; +import com.fjg.omorntcpfins.exception.OmronException; +import com.fjg.omorntcpfins.model.FinsSubscribePoint; +import com.fjg.omorntcpfins.util.FinsUtil; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.DisposableBean; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.stereotype.Component; + +import javax.annotation.PostConstruct; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.ScheduledFuture; +import java.util.concurrent.TimeUnit; + +/** + * 订阅监控管理器 + *

+ * 核心能力: + *

    + *
  • 从 yml 加载订阅点位并在连接建立后自动启动轮询
  • + *
  • 每个点位独立轮询间隔,值变化时通知所有 {@link FinsValueChangeListener}
  • + *
  • 连接断开自动暂停全部点位,重连成功后自动恢复
  • + *
  • 支持通过 REST API 动态增删改订阅点位
  • + *
  • 维护每个点位的读取/成功/变化统计,供监控查询
  • + *
+ *

+ * + * @author fengjianguo + * @date 2026/09/28 + */ +@Slf4j +@Component +public class FinsSubscribeManager implements DisposableBean { + + private final FinsUtil finsUtil; + private final FinsClientConfig finsClientConfig; + private final OmronSubscribeProps subscribeProps; + private final ScheduledExecutorService subscribeScheduler; + private final ExecutorService notifyExecutor; + private final FinsLogContext finsLogContext; + private final List listeners; + + /** + * 点位表:名称 -> 点位 + */ + private final Map points = new ConcurrentHashMap<>(); + /** + * 轮询任务表:名称 -> ScheduledFuture + */ + private final Map> tasks = new ConcurrentHashMap<>(); + /** + * 当前是否已连接 + */ + private volatile boolean connected = false; + + public FinsSubscribeManager(FinsUtil finsUtil, + FinsClientConfig finsClientConfig, + OmronSubscribeProps subscribeProps, + @Qualifier("finsSubscribeScheduler") ScheduledExecutorService subscribeScheduler, + @Qualifier("finsNotifyExecutor") ExecutorService notifyExecutor, + FinsLogContext finsLogContext, + List listeners) { + this.finsUtil = finsUtil; + this.finsClientConfig = finsClientConfig; + this.subscribeProps = subscribeProps; + this.subscribeScheduler = subscribeScheduler; + this.notifyExecutor = notifyExecutor; + this.finsLogContext = finsLogContext; + this.listeners = listeners; + } + + @PostConstruct + public void init() { + // 注册连接状态监听:连接建立后启动全部启用的点位,断开时暂停 + finsClientConfig.addConnectionListener(this::onConnectionStateChanged); + // 注册连接时指定订阅点位的回调:connect(订阅点位) 连接成功后自动注册 + finsClientConfig.setSubscribePointsHandler(this::registerPointsFromConnect); + // 加载 yml 配置的订阅点位 + if (subscribeProps != null && subscribeProps.isEnabled() && subscribeProps.getPoints() != null) { + for (FinsSubscribePoint point : subscribeProps.getPoints()) { + registerPoint(point, false); + } + log.info("已加载配置订阅点位 {} 个", subscribeProps.getPoints().size()); + } + // 若初始化时已连接(连接早于本组件初始化),直接启动 + if (finsClientConfig.isConnected()) { + connected = true; + startAllEnabled(); + } + } + + // ==================== 连接时指定订阅点位(外部传入注册方式) ==================== + + /** + * 注册通过 {@code FinsClientConfig#connect(List)} 指定的订阅点位 + *

连接成功后由客户端回调触发:逐点注册并启动;点位名与已有点位(yml/REST)重复时跳过并告警。

+ * + * @param points 连接时指定的订阅点位 + */ + private void registerPointsFromConnect(List points) { + if (points == null || points.isEmpty()) { + return; + } + int success = 0; + for (FinsSubscribePoint point : points) { + try { + addPoint(point); + success++; + } catch (OmronException e) { + log.warn("连接指定的订阅点位[{}]注册失败:{}", point.getName(), e.getMessage()); + } + } + log.info("连接指定的订阅点位注册完成:成功 {} 个,共 {} 个", success, points.size()); + } + + // ==================== 连接状态联动 ==================== + + private void onConnectionStateChanged(boolean connected) { + this.connected = connected; + if (connected) { + log.info("FINS已连接,启动全部启用的订阅点位"); + startAllEnabled(); + } else { + log.warn("FINS已断开,暂停全部订阅点位"); + stopAll(); + } + } + + // ==================== 点位管理 ==================== + + /** + * 新增订阅点位(启用的点位在连接可用时立即启动轮询) + * + * @param point 点位 + * @return 已注册的点位(含运行统计字段) + */ + public FinsSubscribePoint addPoint(FinsSubscribePoint point) { + registerPoint(point, true); + return point; + } + + /** + * 删除订阅点位并停止轮询 + * + * @param name 点位名称 + */ + public void removePoint(String name) { + if (name == null || name.trim().isEmpty()) { + throw new OmronException("点位名称不能为空"); + } + cancelTask(name); + if (points.remove(name) != null) { + log.info("订阅点位[{}]已删除", name); + } else { + throw new OmronException("订阅点位不存在:" + name); + } + } + + /** + * 启动指定点位轮询 + * + * @param name 点位名称 + * @return 点位 + */ + public FinsSubscribePoint startPoint(String name) { + FinsSubscribePoint point = getPoint(name); + if (!connected) { + log.warn("FINS未连接,点位[{}]暂不启动,连接建立后自动启动", name); + point.setEnabled(true); + return point; + } + point.setEnabled(true); + schedule(point); + return point; + } + + /** + * 停止指定点位轮询 + * + * @param name 点位名称 + * @return 点位 + */ + public FinsSubscribePoint stopPoint(String name) { + FinsSubscribePoint point = getPoint(name); + cancelTask(name); + point.setEnabled(false); + return point; + } + + /** + * 查询全部订阅点位(含运行统计) + */ + public List listPoints() { + return new ArrayList<>(points.values()); + } + + /** + * 查询单个订阅点位 + */ + public FinsSubscribePoint getPoint(String name) { + if (name == null || name.trim().isEmpty()) { + throw new OmronException("点位名称不能为空"); + } + FinsSubscribePoint point = points.get(name); + if (point == null) { + throw new OmronException("订阅点位不存在:" + name); + } + return point; + } + + /** + * 聚合统计 + */ + public Map stats() { + Map stats = new LinkedHashMap<>(); + long totalRead = 0; + long totalSuccess = 0; + long totalChange = 0; + int running = 0; + for (FinsSubscribePoint p : points.values()) { + totalRead += p.getReadCount().get(); + totalSuccess += p.getSuccessCount().get(); + totalChange += p.getChangeCount().get(); + if (p.getStatus() == FinsSubscribePoint.SubscribeStatus.RUNNING) { + running++; + } + } + stats.put("pointCount", points.size()); + stats.put("runningCount", running); + stats.put("connected", connected); + stats.put("totalReadCount", totalRead); + stats.put("totalSuccessCount", totalSuccess); + stats.put("totalChangeCount", totalChange); + stats.put("successRate", totalRead == 0 ? 0 : (double) Math.round(totalSuccess * 10000.0 / totalRead) / 100); + return stats; + } + + /** + * 动态注册监听器 + */ + public void addListener(FinsValueChangeListener listener) { + if (listener != null) { + listeners.add(listener); + } + } + + /** + * 注销监听器 + */ + public void removeListener(FinsValueChangeListener listener) { + listeners.remove(listener); + } + + // ==================== 内部方法 ==================== + + private void registerPoint(FinsSubscribePoint point, boolean autoStart) { + validate(point); + String name = point.getName(); + if (points.containsKey(name)) { + throw new OmronException("订阅点位已存在:" + name); + } + // 未配置间隔时使用全局默认值 + if (point.getIntervalMs() == null && subscribeProps != null) { + point.setIntervalMs(subscribeProps.getDefaultIntervalMs()); + } + // 未配置首次读取触发时使用全局默认值(优先级:点位配置 > 全局配置) + if (point.getFirstReadTrigger() == null && subscribeProps != null && subscribeProps.getFirstReadTrigger() != null) { + point.setFirstReadTrigger(subscribeProps.getFirstReadTrigger()); + } + point.setStatus(FinsSubscribePoint.SubscribeStatus.INIT); + points.put(name, point); + log.info("订阅点位[{}]已注册:地址[{}-{}] 类型[{}] 间隔[{}ms]", + name, point.getAddress(), point.getOffset(), point.getDataType(), point.effectiveIntervalMs()); + if (autoStart && Boolean.TRUE.equals(point.getEnabled()) && connected) { + schedule(point); + } + } + + private void validate(FinsSubscribePoint point) { + if (point == null) { + throw new OmronException("订阅点位不能为空"); + } + if (point.getName() == null || point.getName().trim().isEmpty()) { + throw new OmronException("点位名称不能为空"); + } + if (point.getAddress() == null || point.getAddress().trim().isEmpty()) { + throw new OmronException("点位地址不能为空"); + } + if (point.getOffset() == null || point.getOffset() <= 0) { + point.setOffset(1); + } + if (point.getDataType() == null) { + point.setDataType(AreaTypeEnum.WORD); + } + } + + private void startAllEnabled() { + for (FinsSubscribePoint point : points.values()) { + if (Boolean.TRUE.equals(point.getEnabled()) + && point.getStatus() != FinsSubscribePoint.SubscribeStatus.RUNNING) { + schedule(point); + } + } + } + + private void stopAll() { + for (String name : new ArrayList<>(tasks.keySet())) { + cancelTask(name); + } + } + + /** + * 启动点位轮询任务(先取消旧任务,保证幂等) + */ + private void schedule(FinsSubscribePoint point) { + String name = point.getName(); + cancelTask(name); + long interval = point.effectiveIntervalMs(); + // 首次执行延迟200ms,留出握手完成时间;之后按配置间隔轮询 + long initialDelay = Math.min(200L, interval); + ScheduledFuture future = subscribeScheduler.scheduleAtFixedRate( + new SubscribeTask(point), initialDelay, interval, TimeUnit.MILLISECONDS); + tasks.put(name, future); + point.setStatus(FinsSubscribePoint.SubscribeStatus.RUNNING); + log.info("订阅点位[{}]已启动轮询,间隔 {}ms", name, interval); + } + + /** + * 取消点位轮询任务(幂等) + */ + private void cancelTask(String name) { + ScheduledFuture future = tasks.remove(name); + if (future != null) { + future.cancel(false); + FinsSubscribePoint point = points.get(name); + if (point != null) { + point.setStatus(FinsSubscribePoint.SubscribeStatus.STOPPED); + } + log.info("订阅点位[{}]已停止轮询", name); + } + } + + /** + * 异步通知所有监听器(慢监听器不阻塞轮询线程) + */ + private void notifyListeners(FinsSubscribePoint point, String oldValue, String newValue) { + for (FinsValueChangeListener listener : listeners) { + notifyExecutor.submit(() -> { + try { + listener.onChange(point, oldValue, newValue); + } catch (Exception e) { + log.error("订阅监听器执行异常:{}", e.getMessage(), e); + } + }); + } + } + + /** + * 单点位轮询任务 + */ + private class SubscribeTask implements Runnable { + + private final FinsSubscribePoint point; + + SubscribeTask(FinsSubscribePoint point) { + this.point = point; + } + + @Override + public void run() { + // 未连接或上一次读取尚未完成时跳过本次轮询,避免请求堆积 + if (!connected || Boolean.TRUE.equals(point.getReading())) { + return; + } + // SID 不足时跳过本轮:订阅让位给手动/异步读写(读写优先), + // 保留至少 1 个可用 SID 供读写请求使用,避免订阅抢占导致读写失败 + if (HexQueue.available() <= 1) { + log.debug("订阅点位[{}] SID不足,跳过本轮", point.getName()); + return; + } + point.setReading(true); + try { + String value; + switch (point.getDataType()) { + case BIT: + value = String.valueOf(finsUtil.readBool(point.getAddress(), point.getOffset())); + break; + case DWORD: + value = String.valueOf(finsUtil.readDword(point.getAddress(), point.getOffset())); + break; + case FLOAT: + value = String.valueOf(finsUtil.readFloat(point.getAddress(), point.getOffset())); + break; + default: + value = finsUtil.readWord(point.getAddress(), point.getOffset()); + break; + } + point.getReadCount().incrementAndGet(); + point.getSuccessCount().incrementAndGet(); + point.setLastError(null); + point.setLastReadTime(System.currentTimeMillis()); + // 值变化检测 + String oldValue = point.getCurrentValue(); + if (!Objects.equals(oldValue, value)) { + boolean firstRead = oldValue == null; + point.setCurrentValue(value); + // 首次读取且配置为不触发时,仅建立基线,不触发监听/变化日志/变化计数 + if (firstRead && !Boolean.TRUE.equals(point.getFirstReadTrigger())) { + log.debug("订阅点位[{}] 首次读取建立基线:{}(不触发变化)", + point.getName(), value); + return; + } + point.getChangeCount().incrementAndGet(); + // 值变化日志打上 MDC 标记,路由到独立日志文件 + finsLogContext.putChange(); + try { + log.info("订阅点位[{}] 地址[{}-{}] 值变化:{} → {}", + point.getName(), point.getAddress(), point.getOffset(), oldValue, value); + } finally { + finsLogContext.clear(); + } + notifyListeners(point, oldValue, value); + } + } catch (Exception e) { + point.getReadCount().incrementAndGet(); + point.setLastError(e.getMessage()); + point.setLastReadTime(System.currentTimeMillis()); + // 竞态下 SID 恰好被读写抢占时静默跳过(下一轮再读),其余错误保持告警 + if (e.getMessage() != null && e.getMessage().contains("SID队列已空")) { + log.debug("订阅点位[{}] SID不足,跳过本轮:{}", point.getName(), e.getMessage()); + } else { + log.warn("订阅点位[{}] 读取失败:{}", point.getName(), e.getMessage()); + } + } finally { + point.setReading(false); + } + } + } + + @Override + public void destroy() { + for (ScheduledFuture future : tasks.values()) { + future.cancel(false); + } + tasks.clear(); + log.info("订阅监控已停止"); + } + +} diff --git a/src/main/java/com/fjg/omorntcpfins/subscribe/FinsValueChangeListener.java b/src/main/java/com/fjg/omorntcpfins/subscribe/FinsValueChangeListener.java new file mode 100644 index 0000000..ed2c0ec --- /dev/null +++ b/src/main/java/com/fjg/omorntcpfins/subscribe/FinsValueChangeListener.java @@ -0,0 +1,27 @@ +package com.fjg.omorntcpfins.subscribe; + +import com.fjg.omorntcpfins.model.FinsSubscribePoint; + +/** + * 订阅点位值变化监听器 + *

+ * 实现该接口并注册为 Spring Bean({@code @Component}),即可自动接收所有订阅点位的值变化通知; + * 也可通过 {@code FinsSubscribeManager#addListener} 动态注册。 + *

+ * + * @author fengjianguo + * @date 2026/09/28 + */ +@FunctionalInterface +public interface FinsValueChangeListener { + + /** + * 点位值变化回调 + * + * @param point 点位(含最新值及运行统计) + * @param oldValue 变化前值 + * @param newValue 变化后值 + */ + void onChange(FinsSubscribePoint point, String oldValue, String newValue); + +} diff --git a/src/main/java/com/fjg/omorntcpfins/util/FinsMsgAnalysisUtil.java b/src/main/java/com/fjg/omorntcpfins/util/FinsMsgAnalysisUtil.java index c7db9f6..ea8d592 100644 --- a/src/main/java/com/fjg/omorntcpfins/util/FinsMsgAnalysisUtil.java +++ b/src/main/java/com/fjg/omorntcpfins/util/FinsMsgAnalysisUtil.java @@ -81,6 +81,21 @@ public class FinsMsgAnalysisUtil { */ private static final int DATA_START = 60; + /** + * 握手响应中本机节点位置(如 00000018 中的 18) + */ + private static final int HANDSHAKE_PC_NODE_START = 38; + + /** + * 握手响应中PLC节点位置(如 00000017 中的 17) + */ + private static final int HANDSHAKE_PLC_NODE_START = 46; + + /** + * 握手响应报文最小长度 + */ + private static final int HANDSHAKE_TOTAL_LENGTH = 48; + /** * 步长2 */ @@ -283,4 +298,20 @@ public class FinsMsgAnalysisUtil { return "00000001".equals(finsCommand(msg)); } + /** + * 获取握手响应中的本机节点(十六进制字符串,如 "18") + */ + public static String finsHandshakePcNode(String msg) { + validateMsgLength(msg, HANDSHAKE_TOTAL_LENGTH); + return extractSubString(msg, HANDSHAKE_PC_NODE_START, STEP_TWO); + } + + /** + * 获取握手响应中的PLC节点(十六进制字符串,如 "17") + */ + public static String finsHandshakePlcNode(String msg) { + validateMsgLength(msg, HANDSHAKE_TOTAL_LENGTH); + return extractSubString(msg, HANDSHAKE_PLC_NODE_START, STEP_TWO); + } + } diff --git a/src/main/java/com/fjg/omorntcpfins/util/FinsUtil.java b/src/main/java/com/fjg/omorntcpfins/util/FinsUtil.java index af873e4..ac3ed65 100644 --- a/src/main/java/com/fjg/omorntcpfins/util/FinsUtil.java +++ b/src/main/java/com/fjg/omorntcpfins/util/FinsUtil.java @@ -1,24 +1,29 @@ package com.fjg.omorntcpfins.util; import com.fjg.omorntcpfins.common.enums.AreaTypeEnum; +import com.fjg.omorntcpfins.common.enums.MemoryAreaEnum; import com.fjg.omorntcpfins.common.queue.HexQueue; import com.fjg.omorntcpfins.config.OmronConfigProps; +import com.fjg.omorntcpfins.config.fins.FinsNodeContext; import com.fjg.omorntcpfins.config.fins.client.FinsClientConfig; -import com.fjg.omorntcpfins.exception.GlobalException; +import com.fjg.omorntcpfins.exception.OmronException; import com.fjg.omorntcpfins.handler.fins.FinsMessageHandler; import com.fjg.omorntcpfins.handler.strategy.area.AreaTypeStrategy; import com.fjg.omorntcpfins.model.DbInfoPO; import lombok.extern.slf4j.Slf4j; -import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Qualifier; import org.springframework.stereotype.Component; import java.util.Map; import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ExecutorService; import java.util.concurrent.ExecutionException; import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeoutException; /** + * FINS 读写工具 + * * @author fengjianguo * @date 2024/7/27 15:57 */ @@ -26,11 +31,12 @@ import java.util.concurrent.TimeoutException; @Component public class FinsUtil { - @Autowired - private OmronConfigProps omronConfigProps; - - @Autowired - private Map areaTypeStrategyMap; + private final OmronConfigProps omronConfigProps; + private final Map areaTypeStrategyMap; + private final FinsMessageHandler finsMessageHandler; + private final FinsClientConfig finsClientConfig; + private final FinsNodeContext finsNodeContext; + private final ExecutorService readExecutor; /** * FINS协议头 @@ -73,11 +79,6 @@ public class FinsUtil { * FINS协议PC单元地址 */ private static final String FINS_PC_UNIT_ADDRESS = "00"; - /** - * FINS协议SID - * 00 - FF - */ - private static final String FINS_SID = "FF"; /** * FINS协议读指令 */ @@ -92,13 +93,32 @@ public class FinsUtil { */ private static final int FINS_WRITE_VALUE_LENGTH = 4; + /** + * 单次读写最大个数(FINS 协议限制) + */ + private static final int MAX_READ_COUNT = 1000; + + public FinsUtil(OmronConfigProps omronConfigProps, + Map areaTypeStrategyMap, + FinsMessageHandler finsMessageHandler, + FinsClientConfig finsClientConfig, + FinsNodeContext finsNodeContext, + @Qualifier("finsReadExecutor") ExecutorService readExecutor) { + this.omronConfigProps = omronConfigProps; + this.areaTypeStrategyMap = areaTypeStrategyMap; + this.finsMessageHandler = finsMessageHandler; + this.finsClientConfig = finsClientConfig; + this.finsNodeContext = finsNodeContext; + this.readExecutor = readExecutor; + } + /** * 获取FINS读指令 * - * @param address - * @param offset - * @param areaTypeEnum - * @return + * @param address 地址 + * @param offset 偏移量/读取个数 + * @param areaTypeEnum 类型 + * @return 指令 */ private String getReadMessage(String address, int offset, AreaTypeEnum areaTypeEnum) { String strategy = areaTypeEnum.getStrategy(); @@ -112,11 +132,11 @@ public class FinsUtil { /** * 获取FINS写指令 * - * @param address - * @param offset - * @param areaTypeEnum - * @param dbValue - * @return + * @param address 地址 + * @param offset 偏移量 + * @param areaTypeEnum 类型 + * @param dbValue 写入值 + * @return 指令 */ private String getWriteMessage(String address, int offset, AreaTypeEnum areaTypeEnum, String dbValue) { String strategy = areaTypeEnum.getStrategy(); @@ -128,8 +148,12 @@ public class FinsUtil { } private String getSendMsg(DbInfoPO dbInfo, Boolean isWrite) { - // 获取plc地址 - String plcIpNode = String.format("%2s", Integer.toHexString(omronConfigProps.getPlcIpNode())).replace(" ", "0"); + // PLC节点:优先使用握手响应解析的节点,未握手前回退到配置值(可选,默认0) + Integer plcNode = finsNodeContext.getPlcNode(); + if (plcNode == null) { + plcNode = omronConfigProps.getPlcIpNode(); + } + String plcIpNode = String.format("%2s", Integer.toHexString(plcNode == null ? 0 : plcNode)).replace(" ", "0"); // 获取pc地址 String body = getFinsBody(plcIpNode, dbInfo, isWrite); String length = String.format("%8s", Integer.toHexString(body.length() / 2)).replace(' ', '0'); @@ -139,18 +163,18 @@ public class FinsUtil { } /** - * 获取FINS指令 + * 获取FINS指令体 * * @param plcIpNode plc ip节点 * @param dbInfo db信息 - * @param isWrite - * @return {@link String } + * @param isWrite 是否写指令 + * @return 指令体 */ private String getFinsBody(String plcIpNode, DbInfoPO dbInfo, Boolean isWrite) { String pcIdNode = String.format("%2s", Integer.toHexString(omronConfigProps.getPcIdNode())).replace(" ", "0"); String sid = HexQueue.fetchHex(); if (sid == null) { - throw new GlobalException("hex队列已空"); + throw new OmronException("SID队列已空,请求过于频繁"); } return FINS_COMMAND + FINS_ERROR_CODE @@ -172,53 +196,55 @@ public class FinsUtil { } /** - * 发送FINS指令 + * 发送FINS指令并同步等待响应 * - * @param msg - * @return + * @param msg 指令 + * @return 响应数据 */ private String send(String msg) { - log.info("发送的消息:{}", msg); - CompletableFuture future = new CompletableFuture<>(); String sid = FinsRequestAnalysisUtil.finsSid(msg); - FinsMessageHandler.setRequestFutures(sid, future); - FinsClientConfig.send(msg); + CompletableFuture future = new CompletableFuture<>(); + finsMessageHandler.setRequestFutures(sid, future); + boolean sent = finsClientConfig.send(msg); + if (!sent) { + finsMessageHandler.removeRequestFutures(sid); + throw new OmronException("FINS通道未连接,消息发送失败"); + } + int timeout = omronConfigProps.getReadWriteTimeout() == null ? 3 : omronConfigProps.getReadWriteTimeout(); try { - String s = future.get(omronConfigProps.getReadWriteTimeout(), TimeUnit.SECONDS); - FinsMessageHandler.removeRequestFutures(sid); + String s = future.get(timeout, TimeUnit.SECONDS); + finsMessageHandler.removeRequestFutures(sid); log.info("response: {}", s); return s; } catch (TimeoutException e) { - FinsMessageHandler.removeRequestFutures(sid); - throw new GlobalException("响应超时-[" + sid + "]"); + finsMessageHandler.removeRequestFutures(sid); + throw new OmronException("响应超时-[" + sid + "]"); } catch (InterruptedException | ExecutionException e) { - throw new GlobalException("数据响应异常"); + finsMessageHandler.removeRequestFutures(sid); + Throwable cause = e.getCause() == null ? e : e.getCause(); + throw new OmronException("数据响应异常:" + cause.getMessage()); } } /** - * str到bool - * - * @param str str - * @return boolean + * 十六进制字符串转布尔 */ private boolean strToBool(String str) { try { int i = Integer.parseInt(str, 16); return 0 != i; } catch (NumberFormatException e) { - log.error("数据读取异常: 数据转换失败-" + str); - e.printStackTrace(); + log.error("数据读取异常: 数据转换失败-{}", str); return false; } } /** - * 读取布尔值 + * 读取布尔值(同步) * - * @param address - * @param offset - * @return + * @param address 地址,如 D6000 + * @param offset 偏移量/读取个数,通常为1 + * @return 布尔值 */ public Boolean readBool(String address, int offset) { String readMessage = getReadMessage(address, offset, AreaTypeEnum.BIT); @@ -227,11 +253,11 @@ public class FinsUtil { } /** - * 读取字 + * 读取字(同步,返回十进制字符串) * - * @param address - * @param offset - * @return + * @param address 地址,如 D6004 + * @param offset 偏移量/读取个数,通常为1 + * @return 十进制值 */ public String readWord(String address, int offset) { String readMessage = getReadMessage(address, offset, AreaTypeEnum.WORD); @@ -241,61 +267,467 @@ public class FinsUtil { try { return Integer.parseInt(msg, 16) + ""; } catch (NumberFormatException e) { - e.printStackTrace(); - throw new GlobalException("数据读取异常: 数据转换失败-" + msg); + throw new OmronException("数据读取异常: 数据转换失败-" + msg); } } + /** + * 读取字(同步,返回原始十六进制) + * + * @param address 地址,如 D6004 + * @param offset 偏移量/读取个数,通常为1 + * @return 原始十六进制值 + */ + public String readWordHex(String address, int offset) { + String readMessage = getReadMessage(address, offset, AreaTypeEnum.WORD); + // 发送读取指令 + return send(readMessage); + } + + // ==================== 批量读 / 32位 / 浮点 / 位批量读 ==================== + + /** + * 批量读取多个连续字(同步,返回原始十六进制列表) + * + * @param address 起始地址,如 D6004 + * @param count 读取个数(1~1000) + * @return 每个字的原始十六进制值列表 + */ + public java.util.List readWordsHexList(String address, int count) { + int total = checkReadCount(count); + String msg = send(getReadMessage(address, total, AreaTypeEnum.WORD)); + return splitHexWords(msg, total); + } + + /** + * 批量读取多个连续字(同步,返回无符号十进制列表 0~65535) + * + * @param address 起始地址,如 D6004 + * @param count 读取个数(1~1000) + * @return 无符号十进制值列表 + */ + public java.util.List readWords(String address, int count) { + java.util.List hexList = readWordsHexList(address, count); + java.util.List result = new java.util.ArrayList<>(hexList.size()); + for (String hex : hexList) { + try { + result.add(Integer.parseInt(hex, 16)); + } catch (NumberFormatException e) { + throw new OmronException("数据读取异常: 数据转换失败-" + hex); + } + } + return result; + } + + /** + * 批量读取多个连续字(同步,返回有符号十进制列表 -32768~32767) + * + * @param address 起始地址,如 D6004 + * @param count 读取个数(1~1000) + * @return 有符号十进制值列表 + */ + public java.util.List readWordsSigned(String address, int count) { + java.util.List hexList = readWordsHexList(address, count); + java.util.List result = new java.util.ArrayList<>(hexList.size()); + for (String hex : hexList) { + try { + short s = (short) Integer.parseInt(hex, 16); + result.add((int) s); + } catch (NumberFormatException e) { + throw new OmronException("数据读取异常: 数据转换失败-" + hex); + } + } + return result; + } + + /** + * 读取 32 位整数(同步,占用连续 2 个字,低字在前) + *

如地址 D100 存储 DINT 时:D100=低16位、D101=高16位。

+ * + * @param address 起始地址,如 D100 + * @param offset 起始字号,通常为起始地址(保留参数,读取固定 2 个字) + * @return 32 位无符号整数(-2147483648~4294967295 用 long 表示) + */ + public Long readDword(String address, int offset) { + java.util.List hexList = readWordsHexList(address, 2); + long low = Long.parseLong(hexList.get(0), 16); + long high = Long.parseLong(hexList.get(1), 16); + return low | (high << 16); + } + + /** + * 读取 32 位浮点数(同步,IEEE754,占用连续 2 个字,低字在前) + * + * @param address 起始地址,如 D100 + * @param offset 起始字号(保留参数,读取固定 2 个字) + * @return 浮点值 + */ + public Float readFloat(String address, int offset) { + java.util.List hexList = readWordsHexList(address, 2); + long low = Long.parseLong(hexList.get(0), 16); + long high = Long.parseLong(hexList.get(1), 16); + return Float.intBitsToFloat((int) (low | (high << 16))); + } + + /** + * 批量读取位(同步,从指定位号开始连续读取) + *

FINS 位读响应中每个位占 1 字节(2 位十六进制),数据长度 = 个数 × 2。

+ * + * @param address 地址,如 D6000 + * @param bit 起始位号(0~15) + * @param count 读取个数(1~1000) + * @return 位值列表 + */ + public java.util.List readBits(String address, int bit, int count) { + int total = checkReadCount(count); + if (bit < 0 || bit > 15) { + throw new OmronException("位号超出范围(0~15): " + bit); + } + DbInfoPO dbInfo = buildBitDbInfo(address, bit, total); + String msg = send(getSendMsg(dbInfo, false)); + if (msg == null || msg.length() != total * 2) { + throw new OmronException("位读取响应数据长度异常: 期望" + (total * 2) + "位,实际" + + (msg == null ? 0 : msg.length()) + "位-" + msg); + } + java.util.List result = new java.util.ArrayList<>(total); + for (int i = 0; i < msg.length(); i += 2) { + result.add(strToBool(msg.substring(i, i + 2))); + } + return result; + } + + /** + * 校验读取个数(1~1000),超出抛异常 + */ + private int checkReadCount(int count) { + if (count <= 0 || count > MAX_READ_COUNT) { + throw new OmronException("读取个数超出范围(1~" + MAX_READ_COUNT + "): " + count); + } + return count; + } + + /** + * 将响应数据按 4 位切分为多个字 + * + * @param msg 响应数据(十六进制) + * @param count 期望的字个数 + * @return 每个字的十六进制 + */ + private java.util.List splitHexWords(String msg, int count) { + if (msg == null || msg.length() != count * 4) { + throw new OmronException("响应数据长度异常: 期望" + (count * 4) + "位,实际" + + (msg == null ? 0 : msg.length()) + "位-" + msg); + } + java.util.List result = new java.util.ArrayList<>(count); + for (int i = 0; i < msg.length(); i += 4) { + result.add(msg.substring(i, i + 4)); + } + return result; + } + + /** + * 构建位读写地址信息(起始位 + 个数) + * + * @param address 地址,如 D6000 + * @param bit 起始位号(0~15) + * @param count 个数 + * @return 地址信息 + */ + private DbInfoPO buildBitDbInfo(String address, int bit, int count) { + String prefix = address.substring(0, 1); + int word = Integer.parseInt(address.substring(1)); + DbInfoPO dbInfo = new DbInfoPO(); + dbInfo.setDbArea(MemoryAreaEnum.of(prefix).getBitCode()); + String bitAddress = Integer.toHexString(word * 256 + bit); + dbInfo.setDbBlock(String.format("%6s", bitAddress).replace(" ", "0")); + String dbOffset = Integer.toHexString(count); + dbInfo.setDbOffset(String.format("%4s", dbOffset).replace(" ", "0")); + return dbInfo; + } + /** * 写入布尔值 + * + * @param address 地址,如 D6000 + * @param offset 偏移量 + * @param bool 值 + * @return 是否写入成功 */ public boolean writeBool(String address, int offset, Boolean bool) { - HexStringUtil.hexToByteArray(bool.toString()); String dbValue = bool ? "01" : "00"; String writeMessage = getWriteMessage(address, offset, AreaTypeEnum.BIT, dbValue); - // 发送读取指令 + // 发送写指令 String msg = send(writeMessage); try { - // 十六进制转换为int - int i = Integer.parseInt(msg, 16); - return i == 0; + // 响应标识为 0000 表示成功 + return Integer.parseInt(msg, 16) == 0; } catch (NumberFormatException e) { - e.printStackTrace(); + log.error("写入布尔值响应解析失败:{}", msg); return false; } } /** - * 写入字 + * 写入字(支持有符号:-32768~32767 负值按 16 位补码写入;0~65535 按无符号写入) + * + * @param address 地址,如 D6004 + * @param offset 偏移量 + * @param word 写入值(十进制,范围 -32768~65535) + * @return 是否写入成功 */ public boolean writeWord(String address, int offset, String word) { - HexStringUtil.hexToByteArray(word); - int intValue = 0; + int intValue; try { intValue = Integer.parseInt(word); } catch (NumberFormatException e) { - log.error("数据写入异常: 数据转换失败-" + word); - e.printStackTrace(); - throw new GlobalException("数据写入异常(目前只支持写入int类型): 数据转换失败-" + word); + log.error("数据写入异常: 数据转换失败-{}", word); + throw new OmronException("数据写入异常(目前只支持写入int类型): 数据转换失败-" + word); + } + if (intValue < -32768 || intValue > 0xFFFF) { + throw new OmronException("数据写入异常: 值超出范围(-32768~65535)-" + word); } - String dbValue = Integer.toHexString(intValue); + // 负值转 16 位补码 + int unsigned = intValue < 0 ? (intValue & 0xFFFF) : intValue; + String dbValue = Integer.toHexString(unsigned); int length = dbValue.length(); if (length > FINS_WRITE_VALUE_LENGTH) { - throw new GlobalException("数据写入异常: 数据长度大于4位-" + word); - }else if (length < FINS_WRITE_VALUE_LENGTH) { + throw new OmronException("数据写入异常: 数据长度大于4位-" + word); + } else if (length < FINS_WRITE_VALUE_LENGTH) { dbValue = String.format("%4s", dbValue).replace(' ', '0'); } String writeMessage = getWriteMessage(address, offset, AreaTypeEnum.WORD, dbValue); - // 发送读取指令 + // 发送写指令 String msg = send(writeMessage); try { - // 十六进制转换为int - int i = Integer.parseInt(msg, 16); - return i == 0; + // 响应标识为 0000 表示成功 + return Integer.parseInt(msg, 16) == 0; } catch (NumberFormatException e) { - e.printStackTrace(); + log.error("写入字响应解析失败:{}", msg); return false; } } + // ==================== 批量写 / 32位 / 浮点 / 位批量写 ==================== + + /** + * 批量写入多个连续字(同步,十进制值,支持有符号:-32768~-1 按补码写入) + * + * @param address 起始地址,如 D6004 + * @param count 写入个数(1~1000),必须与 values 大小一致 + * @param values 十进制值列表(每个值 -32768~65535) + * @return 是否写入成功 + */ + public boolean writeWords(String address, int count, java.util.List values) { + if (values == null || values.size() != count) { + throw new OmronException("写入值与个数不匹配: count=" + count + ", values=" + values); + } + checkReadCount(count); + StringBuilder dbValue = new StringBuilder(); + for (Integer value : values) { + if (value < -32768 || value > 0xFFFF) { + throw new OmronException("写入值超出范围(-32768~65535): " + value); + } + int unsigned = value < 0 ? (value & 0xFFFF) : value; + dbValue.append(String.format("%4s", Integer.toHexString(unsigned)).replace(' ', '0')); + } + return writeWordsHex(address, count, dbValue.toString()); + } + + /** + * 批量写入多个连续字(同步,原始十六进制拼接) + * + * @param address 起始地址,如 D6004 + * @param count 写入个数(1~1000),必须与数据长度一致 + * @param hexValue 每个字 4 位十六进制拼接串,如 "000100020003" + * @return 是否写入成功 + */ + public boolean writeWordsHex(String address, int count, String hexValue) { + checkReadCount(count); + if (hexValue == null || hexValue.length() != count * 4) { + throw new OmronException("写入十六进制长度与个数不匹配: count=" + count + + ", hex长度=" + (hexValue == null ? 0 : hexValue.length())); + } + String writeMessage = getWriteMessage(address, count, AreaTypeEnum.WORD, hexValue); + String msg = send(writeMessage); + try { + return Integer.parseInt(msg, 16) == 0; + } catch (NumberFormatException e) { + log.error("批量写入字响应解析失败:{}", msg); + return false; + } + } + + /** + * 写入 32 位整数(同步,占用连续 2 个字,低字在前) + * + * @param address 起始地址,如 D100(D100=低16位、D101=高16位) + * @param offset 起始字号(保留参数,写入固定 2 个字) + * @param value 32 位整数 + * @return 是否写入成功 + */ + public boolean writeDword(String address, int offset, long value) { + long low = value & 0xFFFFL; + long high = (value >>> 16) & 0xFFFFL; + String hexValue = String.format("%4s", Long.toHexString(low)).replace(' ', '0') + + String.format("%4s", Long.toHexString(high)).replace(' ', '0'); + return writeWordsHex(address, 2, hexValue); + } + + /** + * 写入 32 位浮点数(同步,IEEE754,占用连续 2 个字,低字在前) + * + * @param address 起始地址,如 D100 + * @param offset 起始字号(保留参数,写入固定 2 个字) + * @param value 浮点值 + * @return 是否写入成功 + */ + public boolean writeFloat(String address, int offset, float value) { + return writeDword(address, offset, Float.floatToIntBits(value)); + } + + /** + * 批量写入位(同步,从指定位号开始连续写入) + * + * @param address 地址,如 D6000 + * @param bit 起始位号(0~15) + * @param values 位值列表 + * @return 是否写入成功 + */ + public boolean writeBits(String address, int bit, java.util.List values) { + if (values == null || values.isEmpty()) { + throw new OmronException("写入位值不能为空"); + } + checkReadCount(values.size()); + if (bit < 0 || bit > 15) { + throw new OmronException("位号超出范围(0~15): " + bit); + } + StringBuilder hexValue = new StringBuilder(); + for (Boolean v : values) { + hexValue.append(Boolean.TRUE.equals(v) ? "01" : "00"); + } + DbInfoPO dbInfo = buildBitDbInfo(address, bit, values.size()); + dbInfo.setDbValue(hexValue.toString()); + String msg = send(getSendMsg(dbInfo, true)); + try { + return Integer.parseInt(msg, 16) == 0; + } catch (NumberFormatException e) { + log.error("批量写入位响应解析失败:{}", msg); + return false; + } + } + + // ==================== 异步读写 ==================== + + /** + * 异步读取布尔值 + */ + public CompletableFuture readBoolAsync(String address, int offset) { + return CompletableFuture.supplyAsync(() -> readBool(address, offset), readExecutor); + } + + /** + * 异步读取字(十进制字符串) + */ + public CompletableFuture readWordAsync(String address, int offset) { + return CompletableFuture.supplyAsync(() -> readWord(address, offset), readExecutor); + } + + /** + * 异步读取字(原始十六进制) + */ + public CompletableFuture readWordHexAsync(String address, int offset) { + return CompletableFuture.supplyAsync(() -> readWordHex(address, offset), readExecutor); + } + + /** + * 异步写入布尔值 + */ + public CompletableFuture writeBoolAsync(String address, int offset, Boolean bool) { + return CompletableFuture.supplyAsync(() -> writeBool(address, offset, bool), readExecutor); + } + + /** + * 异步写入字 + */ + public CompletableFuture writeWordAsync(String address, int offset, String word) { + return CompletableFuture.supplyAsync(() -> writeWord(address, offset, word), readExecutor); + } + + /** + * 异步批量读取字(无符号十进制列表) + */ + public CompletableFuture> readWordsAsync(String address, int count) { + return CompletableFuture.supplyAsync(() -> readWords(address, count), readExecutor); + } + + /** + * 异步批量读取字(原始十六进制列表) + */ + public CompletableFuture> readWordsHexListAsync(String address, int count) { + return CompletableFuture.supplyAsync(() -> readWordsHexList(address, count), readExecutor); + } + + /** + * 异步读取 32 位整数 + */ + public CompletableFuture readDwordAsync(String address, int offset) { + return CompletableFuture.supplyAsync(() -> readDword(address, offset), readExecutor); + } + + /** + * 异步读取 32 位浮点数 + */ + public CompletableFuture readFloatAsync(String address, int offset) { + return CompletableFuture.supplyAsync(() -> readFloat(address, offset), readExecutor); + } + + /** + * 异步批量读取位 + */ + public CompletableFuture> readBitsAsync(String address, int bit, int count) { + return CompletableFuture.supplyAsync(() -> readBits(address, bit, count), readExecutor); + } + + /** + * 异步批量写入字(十进制值列表) + */ + public CompletableFuture writeWordsAsync(String address, int count, java.util.List values) { + return CompletableFuture.supplyAsync(() -> writeWords(address, count, values), readExecutor); + } + + /** + * 异步批量读取字(有符号十进制列表) + */ + public CompletableFuture> readWordsSignedAsync(String address, int count) { + return CompletableFuture.supplyAsync(() -> readWordsSigned(address, count), readExecutor); + } + + /** + * 异步批量写入字(原始十六进制拼接) + */ + public CompletableFuture writeWordsHexAsync(String address, int count, String hexValue) { + return CompletableFuture.supplyAsync(() -> writeWordsHex(address, count, hexValue), readExecutor); + } + + /** + * 异步写入 32 位整数 + */ + public CompletableFuture writeDwordAsync(String address, int offset, long value) { + return CompletableFuture.supplyAsync(() -> writeDword(address, offset, value), readExecutor); + } + + /** + * 异步写入 32 位浮点数 + */ + public CompletableFuture writeFloatAsync(String address, int offset, float value) { + return CompletableFuture.supplyAsync(() -> writeFloat(address, offset, value), readExecutor); + } + + /** + * 异步批量写入位 + */ + public CompletableFuture writeBitsAsync(String address, int bit, java.util.List values) { + return CompletableFuture.supplyAsync(() -> writeBits(address, bit, values), readExecutor); + } + } diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index ed8611e..ce791f5 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -2,13 +2,118 @@ server: port: 8080 omron: - fins-tcp-ip: 192.168.100.202 + fins-tcp-ip: 192.168.1.5 # fins-tcp-ip: 192.168.201.61 fins-tcp-port: 9600 # FINS TCP 端口 默认为9600 - plc-ip-node: 202 - pc-id-node: 61 + # PLC 节点号在TCP握手成功后自动从握手响应解析,无需手动配置 + pc-id-node: 5 # PC 节点号 # PLC 读取数据超时时间,单位秒 read-write-timeout: 3 + # TCP 连接超时时间(毫秒) + connect-timeout-ms: 5000 + # 断线重连延迟(毫秒) + reconnect-delay-ms: 3000 + # ========== 专用日志配置(名称均可手动配置,未配置时使用默认值) ========== + log: + # 用于路由专用日志的 MDC 键名 + mdc-key: finsLogCategory + # 交互报文对应的 MDC 取值 + interact-value: interact + # 订阅值变化对应的 MDC 取值 + change-value: change + # 交互报文日志文件名 + interact-log-name: omron-tcp-fins_interact + # 订阅值变化日志文件名 + change-log-name: omron-tcp-fins_change + # ========== 订阅监控配置 ========== + subscribe: + # 订阅监控总开关 + enabled: true + # 点位未单独配置时的默认轮询间隔(毫秒) + default-interval-ms: 1000 + # 全局默认:首次读取是否触发值变化(默认true=首读记为一次变化;false=首读仅建立基线不触发)。 + # 优先级低于点位级 first-read-trigger:点位单独配置时以点位为准,未配置时使用本全局值 + first-read-trigger: true + # 订阅点位列表(data-type 支持 WORD/BIT/DWORD/FLOAT;DWORD/FLOAT 占用连续2字) + points: + - name: "D6004-温度" + address: "D6004" + offset: 1 + data-type: FLOAT + interval-ms: 1000 + # 首次读取是否触发值变化:true(默认)=首读记一次变化 null→值;false=首读仅建立基线不触发 + first-read-trigger: true + enabled: true + description: "示例:D6004 浮点读取(IEEE754,占用D6004+D6005)" + - name: "D6000-运行状态" + address: "D6000" + offset: 1 + data-type: BIT + interval-ms: 500 + first-read-trigger: false + enabled: true + description: "示例:D6000 位读取,首读不触发变化" + # ========== 各区域类型示例(默认关闭,按需将 enabled 改为 true 启用;地址按实际 PLC 修改) ========== + # BIT 类型:将 data-type 改为 BIT、offset 填位号(0~15),参考上方 D6000-运行状态 + - name: "C100-CIO区示例" + address: "C100" + offset: 1 + data-type: WORD + interval-ms: 1000 + enabled: false + description: "示例:CIO 区字读取" + - name: "W100-工作继电器示例" + address: "W100" + offset: 1 + data-type: WORD + interval-ms: 1000 + enabled: false + description: "示例:工作继电器区字读取" + - name: "H100-保持继电器示例" + address: "H100" + offset: 1 + data-type: WORD + interval-ms: 1000 + enabled: false + description: "示例:保持继电器区字读取" + - name: "A100-辅助继电器示例" + address: "A100" + offset: 1 + data-type: WORD + interval-ms: 1000 + enabled: false + description: "示例:辅助继电器区字读取" + - name: "E100-扩展寄存器示例" + address: "E100" + offset: 1 + data-type: WORD + interval-ms: 1000 + enabled: false + description: "示例:扩展数据寄存器(当前Bank)字读取" + - name: "T10-定时器示例" + address: "T10" + offset: 1 + data-type: WORD + interval-ms: 1000 + enabled: false + description: "示例:定时器 PV 值读取(BIT 可用于完成标志)" + # ========== 线程池配置 ========== + thread-pool: + # 订阅点位轮询调度线程池 + subscribe: + core-pool-size: 4 + keep-alive-seconds: 60 + # 订阅值变化通知分发线程池 + notify: + core-pool-size: 2 + max-pool-size: 4 + queue-capacity: 1024 + keep-alive-seconds: 60 + # 异步读写执行线程池 + read: + core-pool-size: 4 + max-pool-size: 8 + queue-capacity: 512 + keep-alive-seconds: 60 logging: config: classpath:logback-spring.xml - diff --git a/src/main/resources/logback-spring.xml b/src/main/resources/logback-spring.xml index 50edeef..a78054f 100644 --- a/src/main/resources/logback-spring.xml +++ b/src/main/resources/logback-spring.xml @@ -17,16 +17,72 @@ + value="%date{ISO8601} %-5level [%thread] %logger{56}.%method:%L -%msg%n"/> - + + + + + + + + ${log.pattern} ${log.charset} + + + ${mdcKey} + ${changeValue} + ACCEPT + DENY + + + + + + ${log.base}/interact/${interactLogName}.log + + %date{yyyy-MM-dd HH:mm:ss.SSS} %-5level [%thread] -%msg%n + ${log.charset} + + + ${log.base}/interact/archive/${interactLogName}_%d{yyyy-MM-dd}.%i.log.zip + 60 + ${log.max.size} + + + + ${mdcKey} + ${interactValue} + ACCEPT + DENY + + + + + + ${log.base}/change/${changeLogName}.log + + %date{yyyy-MM-dd HH:mm:ss.SSS} %-5level [%thread] -%msg%n + ${log.charset} + + + ${log.base}/change/archive/${changeLogName}_%d{yyyy-MM-dd}.%i.log.zip + 60 + ${log.max.size} + + + + ${mdcKey} + ${changeValue} + ACCEPT + DENY + @@ -47,11 +103,16 @@ ${log.max.size} - - - INFO - ACCEPT - DENY + + + ${mdcKey} + ${interactValue},${changeValue} + DENY + NEUTRAL + + + + DEBUG @@ -62,6 +123,7 @@ %date{yyyy-MM-dd HH:mm:ss.SSS} %-5level [%thread]%logger{56}.%method:%L -%msg%n + ${log.charset} @@ -86,6 +148,7 @@ %date{yyyy-MM-dd HH:mm:ss.SSS} %-5level [%thread]%logger{56}.%method:%L -%msg%n + ${log.charset} @@ -106,22 +169,21 @@ - - - - - - - + + + + + - + + @@ -131,16 +193,19 @@ - + - + - + + + + -- Gitee From 8a017188d35bbe5127bd1ec237455c7433affa2c Mon Sep 17 00:00:00 2001 From: fengjianguo <395987374@qq.com> Date: Mon, 28 Sep 2026 12:08:09 +0800 Subject: [PATCH 3/3] =?UTF-8?q?fix:=20=E8=AE=A2=E9=98=85=E5=8F=98=E5=8C=96?= =?UTF-8?q?=E7=9B=91=E5=90=AC=E5=99=A8=E6=97=A5=E5=BF=97=E8=B7=AF=E7=94=B1?= =?UTF-8?q?=E5=88=B0=E6=8E=A7=E5=88=B6=E5=8F=B0=E4=B8=8Echange=E6=97=A5?= =?UTF-8?q?=E5=BF=97=E6=96=87=E4=BB=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit FinsSubscribeLogListener 打印【订阅变化】前未设置 MDC change 标记,被控制台与 change 日志过滤器 DENY,仅落入通用 info 日志; 补上 MDC 标记(FinsLogContext.putChange + finally clear),与 FinsSubscribeManager 内建值变化日志保持一致,控制台与 change 日志文件均正常输出 --- .../subscribe/FinsSubscribeLogListener.java | 31 +++++++++++++------ 1 file changed, 22 insertions(+), 9 deletions(-) diff --git a/src/main/java/com/fjg/omorntcpfins/subscribe/FinsSubscribeLogListener.java b/src/main/java/com/fjg/omorntcpfins/subscribe/FinsSubscribeLogListener.java index 9182949..d0fdacc 100644 --- a/src/main/java/com/fjg/omorntcpfins/subscribe/FinsSubscribeLogListener.java +++ b/src/main/java/com/fjg/omorntcpfins/subscribe/FinsSubscribeLogListener.java @@ -1,13 +1,14 @@ package com.fjg.omorntcpfins.subscribe; +import com.fjg.omorntcpfins.config.log.FinsLogContext; import com.fjg.omorntcpfins.model.FinsSubscribePoint; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Component; /** * 订阅值变化默认日志监听器 - *

值变化已由 {@link FinsSubscribeManager} 统一输出到专用 change 日志文件, - * 本监听器以 DEBUG 级别记录,便于排查时在调试日志中跟踪,避免与专用日志重复刷屏。

+ *

打印前打上 MDC 值变化标记,日志路由到控制台与专用 change 日志文件; + * 与 {@link FinsSubscribeManager} 内建的值变化日志保持一致。

* * @author fengjianguo * @date 2026/09/28 @@ -16,15 +17,27 @@ import org.springframework.stereotype.Component; @Component public class FinsSubscribeLogListener implements FinsValueChangeListener { + private final FinsLogContext finsLogContext; + + public FinsSubscribeLogListener(FinsLogContext finsLogContext) { + this.finsLogContext = finsLogContext; + } + @Override public void onChange(FinsSubscribePoint point, String oldValue, String newValue) { - log.debug("【订阅变化】点位[{}] 地址[{}-{}] {} → {}(累计变化{}次)", - point.getName(), - point.getAddress(), - point.getOffset(), - oldValue, - newValue, - point.getChangeCount()); + // 值变化日志打上 MDC 标记,路由到控制台与独立 change 日志文件 + finsLogContext.putChange(); + try { + log.info("【订阅变化】点位[{}] 地址[{}-{}] {} → {}(累计变化{}次)", + point.getName(), + point.getAddress(), + point.getOffset(), + oldValue, + newValue, + point.getChangeCount()); + } finally { + finsLogContext.clear(); + } } } -- Gitee