Skip to content

Commit

Permalink
Add support for testserver notifications (#239)
Browse files Browse the repository at this point in the history
  • Loading branch information
RobertoRoos authored May 13, 2021
1 parent a0c54f9 commit 7a5efb4
Show file tree
Hide file tree
Showing 14 changed files with 437 additions and 61 deletions.
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
### Added
* [#223](https://github.com/stlehmann/pyads/pull/223) Add structure support for symbols
* [#238](https://github.com/stlehmann/pyads/pull/238) Add LINT type to DATATYPE_MAP
* [#239](https://github.com/stlehmann/pyads/pull/239) Add device notification handling for AdvancedHandler in testserver

### Changed
* [#221](https://github.com/stlehmann/pyads/pull/221) CI now uses Github Actions instead of TravisCI. Also Upload to PyPi is now on automatic.
Expand Down
157 changes: 151 additions & 6 deletions doc/documentation/testserver.rst
Original file line number Diff line number Diff line change
@@ -1,13 +1,18 @@
Testserver
----------
==========

For first tests you can use the simple testserver that is provided with
the *pyads* package. To start it up simply run the following command
from a separate console window.
The testserver was created initially for internal testing. However,
you can also use the testserver to test your application. Use it when no
real ADS server is available, for example during continuous integration or
when TwinCAT is not installed.

You can run a basic testserver with the command:

.. code:: bash
$ python -m pyads.testserver
$ python -m pyads.testserver --handler basic
The handler type defaults to 'advanced'.

This will create a new device on 127.0.0.1 port 48898. In the next step
the route to the testserver needs to be added from another python
Expand All @@ -16,4 +21,144 @@ console.
.. code:: python
>>> import pyads
>>> pyads.add_route("127.0.0.1.1.1", '127.0.0.1')
>>> pyads.add_route("127.0.0.1.1.1", "127.0.0.1")
.. warning::

The testserver functionality was originally intended only for internal
testing. The documentation and any support are not guaranteed.

Handlers
--------

The server is a `socket.socket` listener, that listens to ADS-like connections and
sends responses to requests. :class:`~pyads.testserver.testserver.AdsTestServer`
itself does not manage the requests and responses. Those are managed by handler
classes. Currently there are two handlers available:

* :class:`~pyads.testserver.basic_handler.BasicHandler` always returns the same static responses. No data can be saved, any returned values are always 0.
* :class:`~pyads.testserver.advanced_handler.AdvancedHandler` keeps a list of variables and allows for reading/writing variables. Variables need to be created upfront via :meth:`~pyads.testserver.advanced_handler.AdvancedHandler.add_variable`.

Your requirements determine which handler is most suitable. You can also create your own handler by extending the
:class:`~pyads.testserver.handler.AbstractHandler` class. Typically, the basic handler will require the least amount
of work.

A complete overview of the capabilities of the handlers is below. If a feature is
mocked, it will do nothing but no error will be thrown when it is executed. If a
feature is not implemented, an error will be thrown when an attempt is made to use
the feature.

.. list-table:: Handler implementations
:widths: 50 25 25
:header-rows: 1

* - | Feature
| (Methods from :class:`~pyads.ads.Connection`)
- :class:`~pyads.testserver.basic_handler.BasicHandler`
- :class:`~pyads.testserver.advanced_handler.AdvancedHandler`
* - `read_state`
- Mocked
- Mocked
* - `write_control`
- Mocked
- Mocked
* - `read_device_info`
- Mocked
- Mocked
* - `read`
- Mocked
- Implemented
* - `write`
- Mocked
- Implemented
* - `read_by_name`
- Mocked
- Implemented
* - | `read_by_name`
| (with handle)
- Mocked
- Implemented
* - `write_by_name`
- Mocked
- Implemented
* - | `write_by_name`
| (with handle)
- Mocked
- Implemented
* - `get_symbol`
- | Mocked (no info will
| be found automatically)
- Implemented
* - `get_all_symbols`
- | Mocked (list will
| always be empty)
- Implemented
* - `get_handle`
- Mocked
- Implemented
* - `release_handle`
- Mocked
- Mocked
* - `read_list_by_name`
- Mocked
- Implemented
* - `write_list_by_name`
- Mocked
- Implemented
* - `read_structure_by_name`
- Mocked
- Not implemented
* - `write_structure_by_name`
- Mocked
- Not implemented
* - `add_device_notification`
- Mocked
- Implemented
* - `del_device_notification`
- Mocked
- Implemented
* - Device notifications
- | Not implemented (callbacks
| will never fire)
- Implemented

Basic Handler
*************

The :class:`~pyads.testserver.basic_handler.BasicHandler` just responds with `0x00` wherever possible. Trying to
read any byte or integer will always always net 0. Trying to read an LREAL
for example will give 2.09e-308, as that is the interpretation of all bits
at 0.

Actions like writing to a variable or adding a notification will always be
successful, but they won't have any effect.

Advanced Handler
****************

The :class:`~pyads.testserver.advanced_handler.AdvancedHandler` keeps track of variables in an internal list. You can
read from and write to those variables like you would with a real server, using
either the indices, name or variable handle. Any notifications will be issued
as expected too. The handler keeps a list of variables with the type :class:`~pyads.testserver.advanced_handler.PLCVariable`.
In order to address a variable you need to explicitly create it first:

.. code:: python
# Server code
handler = AdvancedHandler()
test_var = PLCVariable(
"Main.my_var", bytes(8), ads_type=constants.ADST_REAL64, symbol_type="LREAL"
)
handler.add_variable(test_var)
.. code:: python
# Client code
with plc:
sym = plc.get_symbol("Main.my_var") # Already exists remotely
print(sym)
print(sym.read())
1 change: 1 addition & 0 deletions doc/index.rst
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ The documentation for the ADS API is available on `infosys.beckhoff.com`_.
:maxdepth: 2
:caption: Contents:

Home <self>
installation
quickstart
documentation/index
Expand Down
36 changes: 34 additions & 2 deletions doc/pyads.rst
Original file line number Diff line number Diff line change
@@ -1,8 +1,7 @@
pyads package
=============

Submodules
----------
The submodules of the Pyads package are listed below.

pyads\.ads module
-----------------
Expand Down Expand Up @@ -60,6 +59,39 @@ pyads\.testserver module
:undoc-members:
:show-inheritance:

pyads\.testserver\.testserver
*****************************

.. automodule:: pyads.testserver.testserver
:members:
:undoc-members:
:show-inheritance:

pyads\.testserver\.handler
**************************

.. automodule:: pyads.testserver.handler
:members:
:undoc-members:
:show-inheritance:

pyads\.testserver\.basic_handler
********************************

.. automodule:: pyads.testserver.basic_handler
:members:
:undoc-members:
:show-inheritance:

pyads\.testserver\.advanced_handler
***********************************

.. automodule:: pyads.testserver.advanced_handler
:members:
:undoc-members:
:show-inheritance:


pyads\.utils module
-------------------

Expand Down
5 changes: 4 additions & 1 deletion pyads/ads.py
Original file line number Diff line number Diff line change
Expand Up @@ -455,6 +455,7 @@ def __init__(
) -> None:
self._port = None # type: Optional[int]
self._adr = AmsAddr(ams_net_id, ams_net_port)
self._open = False
if ip_address is None:
if ams_net_id is None:
raise TypeError("Must provide an IP or net ID")
Expand All @@ -463,7 +464,6 @@ def __init__(
self.ip_address = ip_address
self.ams_net_id = ams_net_id
self.ams_net_port = ams_net_port
self._open = False
self._notifications = {} # type: Dict[int, str]
self._symbol_info_cache: Dict[str, SAdsSymbolEntry] = {}

Expand Down Expand Up @@ -1175,6 +1175,9 @@ def add_device_notification(
>>> # Remove notification
>>> plc.del_device_notification(handles)
Note: the `user_handle` (passed or returned) is the same as the handle returned from
:meth:`Connection.get_handle()`.
"""
if self._port is not None:
notification_handle, user_handle = adsSyncAddDeviceNotificationReqEx(
Expand Down
4 changes: 2 additions & 2 deletions pyads/pyads_ex.py
Original file line number Diff line number Diff line change
Expand Up @@ -96,7 +96,7 @@
else: # pragma: no cover, can not test unsupported platform
raise RuntimeError("Unsupported platform {0}.".format(sys.platform))

callback_store = dict()
callback_store: Dict[Tuple[AmsAddr, int], Callable[[SAmsAddr, SAdsNotificationHeader, int], None]] = dict()


class ADSError(Exception):
Expand Down Expand Up @@ -1164,7 +1164,7 @@ def adsSyncAddDeviceNotificationReqEx(
adsSyncAddDeviceNotificationReqFct.restype = ctypes.c_long

# noinspection PyUnusedLocal
def wrapper(addr: AmsAddr, notification: SAdsNotificationHeader, user: int) -> Callable[
def wrapper(addr: SAmsAddr, notification: SAdsNotificationHeader, user: int) -> Callable[
[SAdsNotificationHeader, str], None]:
return callback(notification, data)

Expand Down
7 changes: 6 additions & 1 deletion pyads/testserver/__init__.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
"""The testserver module of pyads."""
"""The testserver package of pyads.
:author: Roberto Roos
:license: MIT, see license file or https://opensource.org/licenses/MIT
:created on: 2021-04-09
"""
from .testserver import AdsTestServer
from .basic_handler import BasicHandler
from .advanced_handler import AdvancedHandler, PLCVariable
Expand Down
49 changes: 49 additions & 0 deletions pyads/testserver/__main__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
"""The testserver run script.
:author: Roberto Roos
:license: MIT, see license file or https://opensource.org/licenses/MIT
:created on: 2021-04-09
"""

import argparse

from .testserver import AdsTestServer, AdvancedHandler, BasicHandler, AbstractHandler


def main() -> None:
"""Main function (keep variable out of global scope)"""

parser = argparse.ArgumentParser(description='Run an ADS Testserver')
parser.add_argument("--host", default="127.0.0.1", help="host IP, default: 127.0.0.1")
parser.add_argument("-p", "--port", default=48898, help="binding port, default: 48898", type=int)
parser.add_argument('--handler', choices=['basic', 'advanced'], default='advanced',
help="testserver handler, default: advanced")
args = parser.parse_args()

handler: AbstractHandler
if args.handler == 'basic':
handler = BasicHandler()
else:
handler = AdvancedHandler()

server = AdsTestServer(
ip_address=args.host,
port=args.port,
handler=handler
)

# noinspection PyBroadException
try:
print('Starting testserver...')
server.start()
print('Running testserver at {}:{}'.format(server.ip_address, server.port))
server.join()
except:
server.close()

print('Testserver closed')


if __name__ == "__main__":
main()
Loading

0 comments on commit 7a5efb4

Please sign in to comment.