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