handover-check 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Suleman Ali
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,150 @@
1
+ Metadata-Version: 2.4
2
+ Name: handover-check
3
+ Version: 0.1.0
4
+ Summary: Test a conveyor station's AMR/AGV load-handover handshake over OPC UA before the robot arrives: plays the vehicle side, also the ways a vehicle fails, and reports rule by rule.
5
+ Author: Suleman Ali
6
+ License: MIT
7
+ Project-URL: Repository, https://github.com/sulemanali5760/amr-station-handshake
8
+ Keywords: agv,amr,plc,opc-ua,handshake,commissioning,conveyor,vda5050,intralogistics
9
+ Classifier: Development Status :: 3 - Alpha
10
+ Classifier: Environment :: Console
11
+ Classifier: Intended Audience :: Manufacturing
12
+ Classifier: License :: OSI Approved :: MIT License
13
+ Classifier: Programming Language :: Python :: 3
14
+ Classifier: Topic :: Software Development :: Testing
15
+ Requires-Python: >=3.11
16
+ Description-Content-Type: text/markdown
17
+ License-File: LICENSE
18
+ Requires-Dist: asyncua<3,>=1.1
19
+ Requires-Dist: pyyaml>=6.0
20
+ Dynamic: license-file
21
+
22
+ # handover-check
23
+
24
+ Test a conveyor station's **AMR/AGV load-handover handshake** before the robot arrives.
25
+
26
+ The tool connects to the station PLC over OPC UA and plays the vehicle side: it requests a handover,
27
+ docks, releases, and also does what vehicles do when things go wrong (goes silent, leaves mid-transfer,
28
+ changes its mind). It checks the station's answers and response times against ten rules and reports
29
+ rule by rule.
30
+
31
+ ```
32
+ $ handover-check run opc.tcp://192.168.0.10:4840 --map station.yaml --station-is-safe
33
+
34
+ [PASS] R1 Idle at rest: no output is on without a request
35
+ all outputs off for 1 s without a request
36
+ [PASS] R2 StationReady follows AmrDockRequest in time, and nothing moves yet
37
+ StationReady 0.53 s after the request (limit 5 s)
38
+ [PASS] R3 No transfer before AmrInPosition
39
+ no transfer for 1 s while the vehicle was not in position
40
+ [PASS] R4 Transfer starts after AmrInPosition and completes; TransferActive is off when TransferDone is on
41
+ transfer started after 0.02 s and finished after 4.02 s
42
+ [PASS] R5 Station returns to idle after the vehicle releases
43
+ station idle 0.03 s after the release (limit 3 s)
44
+ [PASS] R6 A second handover works straight after the first
45
+ two handovers in a row completed
46
+ [PASS] R7 A withdrawn request returns the station to idle without a transfer
47
+ station back to idle 0.02 s after the request was withdrawn
48
+ [FAIL] R8 Heartbeat watchdog: a silent vehicle stops the transfer with a fault code
49
+ the vehicle went silent for 2 s during the transfer and the station carried on: Ready, TransferActive
50
+ [SKIP] R9 Dropping the request acknowledges and clears a fault
51
+ could not bring the station into a fault to acknowledge (see R8): Ready, TransferActive
52
+ [PASS] R10 The conveyor stops when the vehicle leaves its position
53
+ conveyor stopped 0.02 s after the vehicle left its position (limit 1 s; station: Fault(code 3))
54
+
55
+ Station does NOT conform. 8 passed, 1 failed, 1 not testable.
56
+ ```
57
+
58
+ *Real output, against the bundled reference station with its watchdog switched off; address changed.*
59
+
60
+ ## Why
61
+
62
+ VDA 5050 covers fleet controller to vehicle and expressly leaves out "interfaces to peripheral
63
+ equipment". The handshake between vehicle and conveyor is therefore written again in every project,
64
+ by two teams, and its mistakes show up at commissioning with the robot standing in front of the
65
+ station. The rules are written down in [docs/SPEC.md](../../docs/SPEC.md); this tool lets the PLC
66
+ programmer test the station side alone, at the desk or on site, days earlier.
67
+
68
+ ## Install
69
+
70
+ ```bash
71
+ pip install handover-check
72
+ ```
73
+
74
+ Python 3.11 or newer; Windows or Linux. No ROS and no robot needed.
75
+ Offline bundles for machines without internet are attached to each
76
+ [release](https://github.com/sulemanali5760/amr-station-handshake/releases).
77
+
78
+ ## Use
79
+
80
+ Try it without a PLC, in two terminals:
81
+
82
+ ```bash
83
+ handover-check station # reference station on opc.tcp://127.0.0.1:4860/station/
84
+ handover-check run opc.tcp://127.0.0.1:4860/station/
85
+ ```
86
+
87
+ ```bash
88
+ handover-check station --defect no_watchdog # a station with one rule switched off
89
+ ```
90
+
91
+ Against your PLC: describe where the eight signals live in a signal map (examples for the reference
92
+ station, Siemens S7-1500 and Beckhoff TwinCAT 3 are in [`maps/`](maps)), then:
93
+
94
+ ```bash
95
+ handover-check run opc.tcp://192.168.0.10:4840 --map my_station.yaml --station-is-safe
96
+ ```
97
+
98
+ | Option | Purpose |
99
+ |---|---|
100
+ | `--t-ready 5` `--t-start 3` `--t-transfer 15` `--t-release 3` `--t-watchdog 2` `--t-stop 1` | the time limits of your plant specification, in seconds |
101
+ | `--only R2 R8` | run selected rules |
102
+ | `--report record.md` | Markdown report for the commissioning record |
103
+ | `--json` | machine-readable output |
104
+ | `--strict` | exit code 2 if a rule could not be tested |
105
+
106
+ Exit code 0 = no rule failed, 1 = at least one rule failed.
107
+
108
+ ## It moves your conveyor
109
+
110
+ The tool writes the request signals. On a real station that starts the conveyor.
111
+
112
+ - For any endpoint that is not this computer it refuses to run without `--station-is-safe`.
113
+ - Run it with the station in a test state, clear of people and loads.
114
+ - It always writes both request bits back to FALSE when it ends, also after an error.
115
+ - It is a commissioning aid. It is **not** a safety function and no substitute for the safety
116
+ validation of the station.
117
+
118
+ ## Tested
119
+
120
+ The test suite starts the reference station over real OPC UA, once conforming and once for each of
121
+ eight built-in defects, and expects the tester to report exactly the broken rule:
122
+
123
+ | Defect in the station | Reported under |
124
+ |---|---|
125
+ | Ready on without a request | R1 |
126
+ | Ready far too late | R2 |
127
+ | conveyor starts before the vehicle is in position | R3 |
128
+ | TransferActive still on with TransferDone | R4 |
129
+ | outputs not cleared after release | R5 |
130
+ | no heartbeat watchdog | R8 |
131
+ | fault not cleared by the acknowledge | R9 |
132
+ | conveyor keeps running when the vehicle leaves | R10 |
133
+
134
+ A transfer that is too short to judge R8 or R10 is reported as "not testable", not as a pass.
135
+
136
+ ## Limits
137
+
138
+ - **Not tested against a real PLC.** Only against the bundled reference station. The S7-1500 and
139
+ TwinCAT maps are examples; node ids depend on the project.
140
+ - **No OPC UA security.** The tool connects anonymously without encryption. A PLC that demands
141
+ certificates or a login cannot be tested yet.
142
+ - **Polling.** Signals are read every 25 ms by default, so measured times are accurate to a few
143
+ tens of milliseconds plus the network. It cannot see events shorter than that.
144
+ - **The rules are this project's own** ([SPEC](../../docs/SPEC.md)), shaped after SEMI E84. They
145
+ are not a standard of any body, and a station built to another handshake will fail them.
146
+ - **One vehicle, one station.** No carrier identification, no multi-lane stations.
147
+
148
+ ## Licence
149
+
150
+ MIT
@@ -0,0 +1,129 @@
1
+ # handover-check
2
+
3
+ Test a conveyor station's **AMR/AGV load-handover handshake** before the robot arrives.
4
+
5
+ The tool connects to the station PLC over OPC UA and plays the vehicle side: it requests a handover,
6
+ docks, releases, and also does what vehicles do when things go wrong (goes silent, leaves mid-transfer,
7
+ changes its mind). It checks the station's answers and response times against ten rules and reports
8
+ rule by rule.
9
+
10
+ ```
11
+ $ handover-check run opc.tcp://192.168.0.10:4840 --map station.yaml --station-is-safe
12
+
13
+ [PASS] R1 Idle at rest: no output is on without a request
14
+ all outputs off for 1 s without a request
15
+ [PASS] R2 StationReady follows AmrDockRequest in time, and nothing moves yet
16
+ StationReady 0.53 s after the request (limit 5 s)
17
+ [PASS] R3 No transfer before AmrInPosition
18
+ no transfer for 1 s while the vehicle was not in position
19
+ [PASS] R4 Transfer starts after AmrInPosition and completes; TransferActive is off when TransferDone is on
20
+ transfer started after 0.02 s and finished after 4.02 s
21
+ [PASS] R5 Station returns to idle after the vehicle releases
22
+ station idle 0.03 s after the release (limit 3 s)
23
+ [PASS] R6 A second handover works straight after the first
24
+ two handovers in a row completed
25
+ [PASS] R7 A withdrawn request returns the station to idle without a transfer
26
+ station back to idle 0.02 s after the request was withdrawn
27
+ [FAIL] R8 Heartbeat watchdog: a silent vehicle stops the transfer with a fault code
28
+ the vehicle went silent for 2 s during the transfer and the station carried on: Ready, TransferActive
29
+ [SKIP] R9 Dropping the request acknowledges and clears a fault
30
+ could not bring the station into a fault to acknowledge (see R8): Ready, TransferActive
31
+ [PASS] R10 The conveyor stops when the vehicle leaves its position
32
+ conveyor stopped 0.02 s after the vehicle left its position (limit 1 s; station: Fault(code 3))
33
+
34
+ Station does NOT conform. 8 passed, 1 failed, 1 not testable.
35
+ ```
36
+
37
+ *Real output, against the bundled reference station with its watchdog switched off; address changed.*
38
+
39
+ ## Why
40
+
41
+ VDA 5050 covers fleet controller to vehicle and expressly leaves out "interfaces to peripheral
42
+ equipment". The handshake between vehicle and conveyor is therefore written again in every project,
43
+ by two teams, and its mistakes show up at commissioning with the robot standing in front of the
44
+ station. The rules are written down in [docs/SPEC.md](../../docs/SPEC.md); this tool lets the PLC
45
+ programmer test the station side alone, at the desk or on site, days earlier.
46
+
47
+ ## Install
48
+
49
+ ```bash
50
+ pip install handover-check
51
+ ```
52
+
53
+ Python 3.11 or newer; Windows or Linux. No ROS and no robot needed.
54
+ Offline bundles for machines without internet are attached to each
55
+ [release](https://github.com/sulemanali5760/amr-station-handshake/releases).
56
+
57
+ ## Use
58
+
59
+ Try it without a PLC, in two terminals:
60
+
61
+ ```bash
62
+ handover-check station # reference station on opc.tcp://127.0.0.1:4860/station/
63
+ handover-check run opc.tcp://127.0.0.1:4860/station/
64
+ ```
65
+
66
+ ```bash
67
+ handover-check station --defect no_watchdog # a station with one rule switched off
68
+ ```
69
+
70
+ Against your PLC: describe where the eight signals live in a signal map (examples for the reference
71
+ station, Siemens S7-1500 and Beckhoff TwinCAT 3 are in [`maps/`](maps)), then:
72
+
73
+ ```bash
74
+ handover-check run opc.tcp://192.168.0.10:4840 --map my_station.yaml --station-is-safe
75
+ ```
76
+
77
+ | Option | Purpose |
78
+ |---|---|
79
+ | `--t-ready 5` `--t-start 3` `--t-transfer 15` `--t-release 3` `--t-watchdog 2` `--t-stop 1` | the time limits of your plant specification, in seconds |
80
+ | `--only R2 R8` | run selected rules |
81
+ | `--report record.md` | Markdown report for the commissioning record |
82
+ | `--json` | machine-readable output |
83
+ | `--strict` | exit code 2 if a rule could not be tested |
84
+
85
+ Exit code 0 = no rule failed, 1 = at least one rule failed.
86
+
87
+ ## It moves your conveyor
88
+
89
+ The tool writes the request signals. On a real station that starts the conveyor.
90
+
91
+ - For any endpoint that is not this computer it refuses to run without `--station-is-safe`.
92
+ - Run it with the station in a test state, clear of people and loads.
93
+ - It always writes both request bits back to FALSE when it ends, also after an error.
94
+ - It is a commissioning aid. It is **not** a safety function and no substitute for the safety
95
+ validation of the station.
96
+
97
+ ## Tested
98
+
99
+ The test suite starts the reference station over real OPC UA, once conforming and once for each of
100
+ eight built-in defects, and expects the tester to report exactly the broken rule:
101
+
102
+ | Defect in the station | Reported under |
103
+ |---|---|
104
+ | Ready on without a request | R1 |
105
+ | Ready far too late | R2 |
106
+ | conveyor starts before the vehicle is in position | R3 |
107
+ | TransferActive still on with TransferDone | R4 |
108
+ | outputs not cleared after release | R5 |
109
+ | no heartbeat watchdog | R8 |
110
+ | fault not cleared by the acknowledge | R9 |
111
+ | conveyor keeps running when the vehicle leaves | R10 |
112
+
113
+ A transfer that is too short to judge R8 or R10 is reported as "not testable", not as a pass.
114
+
115
+ ## Limits
116
+
117
+ - **Not tested against a real PLC.** Only against the bundled reference station. The S7-1500 and
118
+ TwinCAT maps are examples; node ids depend on the project.
119
+ - **No OPC UA security.** The tool connects anonymously without encryption. A PLC that demands
120
+ certificates or a login cannot be tested yet.
121
+ - **Polling.** Signals are read every 25 ms by default, so measured times are accurate to a few
122
+ tens of milliseconds plus the network. It cannot see events shorter than that.
123
+ - **The rules are this project's own** ([SPEC](../../docs/SPEC.md)), shaped after SEMI E84. They
124
+ are not a standard of any body, and a station built to another handshake will fail them.
125
+ - **One vehicle, one station.** No carrier identification, no multi-lane stations.
126
+
127
+ ## Licence
128
+
129
+ MIT
@@ -0,0 +1,34 @@
1
+ [build-system]
2
+ requires = ["setuptools>=68"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "handover-check"
7
+ version = "0.1.0"
8
+ description = "Test a conveyor station's AMR/AGV load-handover handshake over OPC UA before the robot arrives: plays the vehicle side, also the ways a vehicle fails, and reports rule by rule."
9
+ readme = "README.md"
10
+ requires-python = ">=3.11"
11
+ license = { text = "MIT" }
12
+ authors = [{ name = "Suleman Ali" }]
13
+ keywords = ["agv", "amr", "plc", "opc-ua", "handshake", "commissioning", "conveyor", "vda5050", "intralogistics"]
14
+ classifiers = [
15
+ "Development Status :: 3 - Alpha",
16
+ "Environment :: Console",
17
+ "Intended Audience :: Manufacturing",
18
+ "License :: OSI Approved :: MIT License",
19
+ "Programming Language :: Python :: 3",
20
+ "Topic :: Software Development :: Testing",
21
+ ]
22
+ dependencies = ["asyncua>=1.1,<3", "pyyaml>=6.0"]
23
+
24
+ [project.urls]
25
+ Repository = "https://github.com/sulemanali5760/amr-station-handshake"
26
+
27
+ [project.scripts]
28
+ handover-check = "handover_check.__main__:main"
29
+
30
+ [tool.setuptools.packages.find]
31
+ where = ["src"]
32
+
33
+ [tool.pytest.ini_options]
34
+ testpaths = ["tests"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,7 @@
1
+ """handover-check: test a conveyor station's AMR handover handshake against rules R1-R10."""
2
+ __version__ = "0.1.0"
3
+
4
+ from handover_check.checks import RULES, Limits, Result, run_checks # noqa: E402
5
+ from handover_check.station import DEFECTS, StationLogic, serve # noqa: E402
6
+
7
+ __all__ = ["DEFECTS", "Limits", "RULES", "Result", "StationLogic", "run_checks", "serve"]
@@ -0,0 +1,100 @@
1
+ """handover-check run opc.tcp://plc:4840 --map station.yaml | handover-check station"""
2
+ import argparse
3
+ import asyncio
4
+ import json
5
+ import sys
6
+ import urllib.parse
7
+
8
+ from handover_check import __version__
9
+ from handover_check.checks import FAIL, PASS, SIM_MAP, SKIP, Limits, as_dicts, load_signal_map, run_checks
10
+ from handover_check.station import DEFECTS, StationLogic, serve
11
+
12
+ MARK = {PASS: "[PASS]", FAIL: "[FAIL]", SKIP: "[SKIP]"}
13
+
14
+
15
+ def render(results, endpoint: str) -> str:
16
+ lines = [f"Station handover check, {endpoint}", ""]
17
+ for r in results:
18
+ lines += [f"{MARK[r.status]} {r.rule:<3} {r.title}", f" {r.detail}"]
19
+ n = {s: sum(r.status == s for r in results) for s in (PASS, FAIL, SKIP)}
20
+ verdict = "Station conforms." if not n[FAIL] and not n[SKIP] else \
21
+ "Station does NOT conform." if n[FAIL] else "No rule failed, but not every rule could be tested."
22
+ lines += ["", f"{verdict} {n[PASS]} passed, {n[FAIL]} failed, {n[SKIP]} not testable."]
23
+ return "\n".join(lines)
24
+
25
+
26
+ def markdown(results, endpoint: str) -> str:
27
+ rows = "\n".join(f"| {r.rule} | {r.title} | **{r.status}** | {r.detail} |" for r in results)
28
+ return (f"# Station handover check\n\nStation: `{endpoint}` \nTool: handover-check {__version__}\n\n"
29
+ f"| Rule | Requirement | Result | Observed |\n|---|---|---|---|\n{rows}\n")
30
+
31
+
32
+ def _run(a) -> int:
33
+ host = urllib.parse.urlparse(a.endpoint).hostname or ""
34
+ if host not in ("127.0.0.1", "localhost", "::1") and not a.station_is_safe:
35
+ print("handover-check writes request signals to the station. On a real station this can start a conveyor.\n"
36
+ "Make sure the station is in a test state and clear of people and loads, then repeat the command "
37
+ "with --station-is-safe.", file=sys.stderr)
38
+ return 64
39
+ signal_map = load_signal_map(a.map) if a.map else SIM_MAP
40
+ limits = Limits(a.t_ready, a.t_start, a.t_transfer, a.t_release, a.t_watchdog, a.t_stop, a.t_hold)
41
+ results = asyncio.run(run_checks(a.endpoint, signal_map, limits, a.only))
42
+ print(json.dumps(as_dicts(results), indent=2) if a.json else render(results, a.endpoint))
43
+ if a.report:
44
+ with open(a.report, "w", encoding="utf-8") as f:
45
+ f.write(markdown(results, a.endpoint))
46
+ failed = any(r.status == FAIL for r in results)
47
+ skipped = any(r.status == SKIP for r in results)
48
+ return 1 if failed else 2 if skipped and a.strict else 0
49
+
50
+
51
+ def _station(a) -> int:
52
+ logic = StationLogic(a.ready_delay, a.transfer_time, a.watchdog, frozenset(a.defect or ()))
53
+ endpoint = f"opc.tcp://{a.host}:{a.port}/station/"
54
+ print(f"reference station on {endpoint}" + (f", defects: {', '.join(a.defect)}" if a.defect else ""), flush=True)
55
+ try:
56
+ asyncio.run(serve(endpoint, logic))
57
+ except KeyboardInterrupt:
58
+ pass
59
+ return 0
60
+
61
+
62
+ def main(argv=None) -> int:
63
+ ap = argparse.ArgumentParser(prog="handover-check", description="Check a conveyor station's AMR handover "
64
+ "handshake against rules R1-R10, before the robot arrives.")
65
+ ap.add_argument("--version", action="version", version=__version__)
66
+ sub = ap.add_subparsers(dest="command", required=True)
67
+ r = sub.add_parser("run", help="play the vehicle side against a station and report rule by rule")
68
+ r.add_argument("endpoint", help="OPC UA endpoint of the station PLC, e.g. opc.tcp://192.168.0.10:4840")
69
+ r.add_argument("--map", help="signal map YAML (namespace_uri, node_ids); default: the reference station")
70
+ r.add_argument("--station-is-safe", action="store_true",
71
+ help="confirm the station is in a test state, clear of people and loads (needed for any "
72
+ "endpoint that is not this computer)")
73
+ r.add_argument("--only", nargs="+", metavar="RULE", help="run only these rules, e.g. --only R2 R8")
74
+ for name, default, text in (("ready", 5.0, "request -> StationReady"), ("start", 3.0, "in position -> TransferActive"),
75
+ ("transfer", 15.0, "TransferActive -> TransferDone"), ("release", 3.0, "release -> idle"),
76
+ ("watchdog", 2.0, "heartbeat stopped -> fault"), ("stop", 1.0, "vehicle left -> conveyor stopped"),
77
+ ("hold", 1.0, "how long 'must stay off' is observed")):
78
+ r.add_argument(f"--t-{name}", type=float, default=default, metavar="S", help=f"limit in seconds: {text} (default {default:g})")
79
+ r.add_argument("--json", action="store_true", help="machine-readable output")
80
+ r.add_argument("--report", metavar="FILE.md", help="also write a Markdown report (for the commissioning record)")
81
+ r.add_argument("--strict", action="store_true", help="exit code 2 when a rule could not be tested")
82
+ r.set_defaults(func=_run)
83
+ s = sub.add_parser("station", help="run the reference station (no PLC needed), optionally with a defect")
84
+ s.add_argument("--host", default="127.0.0.1")
85
+ s.add_argument("--port", type=int, default=4860)
86
+ s.add_argument("--ready-delay", type=float, default=0.5)
87
+ s.add_argument("--transfer-time", type=float, default=4.0)
88
+ s.add_argument("--watchdog", type=float, default=1.0)
89
+ s.add_argument("--defect", action="append", choices=DEFECTS, help="switch one rule off (repeatable)")
90
+ s.set_defaults(func=_station)
91
+ a = ap.parse_args(argv)
92
+ try:
93
+ return a.func(a)
94
+ except (ValueError, OSError, asyncio.TimeoutError) as e:
95
+ print(f"handover-check: {e or type(e).__name__}", file=sys.stderr)
96
+ return 64
97
+
98
+
99
+ if __name__ == "__main__":
100
+ sys.exit(main())
@@ -0,0 +1,342 @@
1
+ """Play the vehicle side of the handover against a station PLC and check the station against rules R1-R10.
2
+
3
+ The tester only needs the eight handshake signals over OPC UA (docs/SPEC.md). It does not need a robot,
4
+ ROS or the load: it sets the two request bits and the heartbeat like a vehicle would, also in the ways a
5
+ vehicle fails (goes silent, leaves, changes its mind), and watches what the station answers and how fast.
6
+
7
+ It WRITES to the station. On a real station that can start a conveyor. Run it with the station in a
8
+ test state, clear of people and loads.
9
+ """
10
+ import asyncio
11
+ from dataclasses import asdict, dataclass
12
+
13
+ import yaml
14
+ from asyncua import Client, ua
15
+
16
+ SIGNALS = ("AmrDockRequest", "AmrInPosition", "AmrHeartbeat",
17
+ "StationReady", "TransferActive", "TransferDone", "Fault", "FaultCode")
18
+ SIM_MAP = {"namespace_uri": "urn:amr-station-handshake:station",
19
+ "node_ids": {name: f"Station.{name}" for name in SIGNALS}}
20
+ PASS, FAIL, SKIP = "PASS", "FAIL", "SKIP"
21
+
22
+
23
+ @dataclass(frozen=True)
24
+ class Limits:
25
+ """Longest time the station may take, in seconds. Set them to what the plant specification says."""
26
+ ready: float = 5.0 # request -> StationReady
27
+ start: float = 3.0 # in position -> TransferActive
28
+ transfer: float = 15.0 # TransferActive -> TransferDone
29
+ release: float = 3.0 # request dropped -> station idle
30
+ watchdog: float = 2.0 # heartbeat stopped -> Fault, conveyor stopped
31
+ stop: float = 1.0 # vehicle left position -> conveyor stopped
32
+ hold: float = 1.0 # how long "must stay off" conditions are observed
33
+
34
+
35
+ @dataclass
36
+ class Result:
37
+ rule: str
38
+ title: str
39
+ status: str
40
+ detail: str = ""
41
+ seconds: float | None = None # the measured response time, where the rule has one
42
+
43
+
44
+ @dataclass(frozen=True)
45
+ class State:
46
+ ready: bool
47
+ active: bool
48
+ done: bool
49
+ fault: bool
50
+ code: int
51
+
52
+ @property
53
+ def idle(self) -> bool:
54
+ return not (self.ready or self.active or self.done or self.fault)
55
+
56
+ def __str__(self) -> str:
57
+ on = [n for n, v in (("Ready", self.ready), ("TransferActive", self.active),
58
+ ("TransferDone", self.done), (f"Fault(code {self.code})", self.fault)) if v]
59
+ return ", ".join(on) or "all outputs off"
60
+
61
+
62
+ def load_signal_map(path) -> dict:
63
+ with open(path, encoding="utf-8") as f:
64
+ signal_map = yaml.safe_load(f)
65
+ missing = [n for n in SIGNALS if n not in (signal_map or {}).get("node_ids", {})]
66
+ if not signal_map or "namespace_uri" not in signal_map or missing:
67
+ raise ValueError(f"{path}: needs namespace_uri and node_ids for {', '.join(missing or SIGNALS)}")
68
+ return signal_map
69
+
70
+
71
+ class Rig:
72
+ """The vehicle side: two request bits, a heartbeat, and eyes on the station's five outputs."""
73
+
74
+ def __init__(self, endpoint: str, signal_map: dict, period: float = 0.05, timeout: float = 4.0):
75
+ self.endpoint, self.signal_map, self.period = endpoint, signal_map, period
76
+ self._client = Client(endpoint, timeout=timeout)
77
+ self._beating = True
78
+ self._beat = 0
79
+ self._task = None
80
+ self._n = {}
81
+
82
+ async def __aenter__(self) -> "Rig":
83
+ await self._client.connect()
84
+ try:
85
+ ns = await self._client.get_namespace_index(self.signal_map["namespace_uri"])
86
+ except ValueError:
87
+ await self._client.disconnect()
88
+ raise ValueError(f"namespace {self.signal_map['namespace_uri']} not found on {self.endpoint}") from None
89
+ self._n = {name: self._client.get_node(ua.NodeId(self.signal_map["node_ids"][name], ns)) for name in SIGNALS}
90
+ self._outputs = [self._n[k] for k in SIGNALS[3:]]
91
+ await self.read() # fails here if a node id is wrong
92
+ self._task = asyncio.create_task(self._heartbeat())
93
+ await asyncio.sleep(self.period * 4) # heartbeat is running before the first request
94
+ return self
95
+
96
+ async def __aexit__(self, *exc) -> None:
97
+ if self._task:
98
+ self._task.cancel()
99
+ await asyncio.gather(self._task, return_exceptions=True)
100
+ try: # never leave a request latched on the PLC
101
+ await self.set(False, False)
102
+ finally:
103
+ await self._client.disconnect()
104
+
105
+ async def _heartbeat(self) -> None:
106
+ while True:
107
+ if self._beating:
108
+ self._beat = (self._beat + 1) % 32768 # PLC INT
109
+ await self._n["AmrHeartbeat"].write_value(self._beat, ua.VariantType.Int16)
110
+ await asyncio.sleep(self.period)
111
+
112
+ def heartbeat(self, on: bool) -> None:
113
+ self._beating = on
114
+
115
+ async def set(self, request: bool, in_position: bool) -> None:
116
+ await self._n["AmrDockRequest"].write_value(request, ua.VariantType.Boolean)
117
+ await self._n["AmrInPosition"].write_value(in_position, ua.VariantType.Boolean)
118
+
119
+ async def read(self) -> State:
120
+ ready, active, done, fault, code = await self._client.read_values(self._outputs) # one consistent snapshot
121
+ return State(bool(ready), bool(active), bool(done), bool(fault), int(code or 0))
122
+
123
+ async def wait(self, wanted, limit: float, forbidden=None):
124
+ """Poll until wanted(state). Returns (reached, seconds, state, violation): violation is the first
125
+ state in which forbidden(state) was true while waiting."""
126
+ loop = asyncio.get_running_loop()
127
+ start = loop.time()
128
+ violation = None
129
+ while True:
130
+ s = await self.read()
131
+ t = loop.time() - start
132
+ if forbidden and violation is None and forbidden(s):
133
+ violation = s
134
+ if wanted(s):
135
+ return True, t, s, violation
136
+ if t > limit:
137
+ return False, t, s, violation
138
+ await asyncio.sleep(self.period / 2)
139
+
140
+ async def observe(self, seconds: float, forbidden):
141
+ """Watch for `seconds`; returns the first state that broke the rule, or None."""
142
+ _, _, _, violation = await self.wait(lambda s: False, seconds, forbidden)
143
+ return violation
144
+
145
+ async def to_idle(self, limit: float) -> bool:
146
+ self.heartbeat(True)
147
+ await self.set(False, False)
148
+ reached, *_ = await self.wait(lambda s: s.idle, limit)
149
+ # A vehicle is alive before it asks. Give the station a few fresh heartbeats first, otherwise the
150
+ # next request can meet a stale counter and a correct watchdog trips at once.
151
+ await asyncio.sleep(self.period * 4)
152
+ return reached
153
+
154
+
155
+ # ---------------------------------------------------------------- building blocks
156
+ async def _to_ready(rig: Rig, lim: Limits):
157
+ await rig.set(True, False)
158
+ return await rig.wait(lambda s: s.ready, lim.ready, forbidden=lambda s: s.active or s.done)
159
+
160
+
161
+ async def _to_transfer(rig: Rig, lim: Limits):
162
+ reached, t, s, _ = await _to_ready(rig, lim)
163
+ if not reached:
164
+ return False, t, s
165
+ await rig.set(True, True)
166
+ reached, t, s, _ = await rig.wait(lambda s: s.active or s.done or s.fault, lim.start)
167
+ return reached and s.active, t, s
168
+
169
+
170
+ # ---------------------------------------------------------------- the rules
171
+ async def r1_idle_at_rest(rig, lim):
172
+ bad = await rig.observe(lim.hold, lambda s: not s.idle)
173
+ if bad:
174
+ return Result("R1", "", FAIL, f"with no request the station shows: {bad}")
175
+ return Result("R1", "", PASS, f"all outputs off for {lim.hold:g} s without a request")
176
+
177
+
178
+ async def r2_ready_follows_request(rig, lim):
179
+ reached, t, s, early = await _to_ready(rig, lim)
180
+ if early:
181
+ return Result("R2", "", FAIL, f"before the vehicle was in position the station showed: {early}", t)
182
+ if not reached:
183
+ return Result("R2", "", FAIL, f"no StationReady within {lim.ready:g} s of the request (station: {s})", t)
184
+ return Result("R2", "", PASS, f"StationReady {t:.2f} s after the request (limit {lim.ready:g} s)", t)
185
+
186
+
187
+ async def r3_no_transfer_before_in_position(rig, lim):
188
+ reached, _, s, _ = await _to_ready(rig, lim)
189
+ if not reached:
190
+ return Result("R3", "", SKIP, f"station never became ready (see R2), so this could not be tested: {s}")
191
+ bad = await rig.observe(lim.hold, lambda s: s.active or s.done)
192
+ if bad:
193
+ return Result("R3", "", FAIL, f"the vehicle was NOT in position, but the station showed: {bad}")
194
+ return Result("R3", "", PASS, f"no transfer for {lim.hold:g} s while the vehicle was not in position")
195
+
196
+
197
+ async def r4_transfer_completes(rig, lim):
198
+ ok, t_start, s = await _to_transfer(rig, lim)
199
+ if not ok:
200
+ return Result("R4", "", FAIL, f"no TransferActive within {lim.start:g} s of AmrInPosition (station: {s})", t_start)
201
+ reached, t, s, _ = await rig.wait(lambda s: s.done or s.fault, lim.transfer)
202
+ if not reached or s.fault:
203
+ return Result("R4", "", FAIL, f"no TransferDone within {lim.transfer:g} s (station: {s})", t)
204
+ s = await rig.read()
205
+ if s.active:
206
+ return Result("R4", "", FAIL, "TransferDone is set but TransferActive is still on: the vehicle cannot tell "
207
+ "whether the conveyor has stopped", t)
208
+ return Result("R4", "", PASS, f"transfer started after {t_start:.2f} s and finished after {t:.2f} s", t)
209
+
210
+
211
+ async def r5_release(rig, lim):
212
+ ok, _, s = await _to_transfer(rig, lim)
213
+ reached, _, s, _ = await rig.wait(lambda s: s.done or s.fault, lim.transfer) if ok else (False, 0, s, None)
214
+ if not reached or s.fault:
215
+ return Result("R5", "", SKIP, f"no completed transfer to release from (see R4): {s}")
216
+ await rig.set(False, False)
217
+ reached, t, s, _ = await rig.wait(lambda s: s.idle, lim.release)
218
+ if not reached:
219
+ return Result("R5", "", FAIL, f"{lim.release:g} s after the vehicle released, the station still shows: {s}", t)
220
+ return Result("R5", "", PASS, f"station idle {t:.2f} s after the release (limit {lim.release:g} s)", t)
221
+
222
+
223
+ async def r6_second_handover(rig, lim):
224
+ for n in (1, 2):
225
+ ok, _, s = await _to_transfer(rig, lim)
226
+ reached, _, s, _ = await rig.wait(lambda s: s.done or s.fault, lim.transfer) if ok else (False, 0, s, None)
227
+ if not reached or s.fault:
228
+ return Result("R6", "", FAIL if n == 2 else SKIP, f"handover {n} did not complete: {s}")
229
+ if not await rig.to_idle(lim.release):
230
+ return Result("R6", "", FAIL if n == 2 else SKIP, f"station not idle after handover {n}: {await rig.read()}")
231
+ return Result("R6", "", PASS, "two handovers in a row completed")
232
+
233
+
234
+ async def r7_withdrawn_request(rig, lim):
235
+ reached, _, s, _ = await _to_ready(rig, lim)
236
+ if not reached:
237
+ return Result("R7", "", SKIP, f"station never became ready (see R2): {s}")
238
+ await rig.set(False, False)
239
+ reached, t, s, moved = await rig.wait(lambda s: s.idle, lim.release, forbidden=lambda s: s.active or s.done)
240
+ if moved:
241
+ return Result("R7", "", FAIL, f"the vehicle withdrew its request, yet the station showed: {moved}", t)
242
+ if not reached:
243
+ return Result("R7", "", FAIL, f"{lim.release:g} s after the request was withdrawn the station still shows: {s}", t)
244
+ return Result("R7", "", PASS, f"station back to idle {t:.2f} s after the request was withdrawn", t)
245
+
246
+
247
+ async def r8_heartbeat_loss(rig, lim):
248
+ ok, _, s = await _to_transfer(rig, lim)
249
+ if not ok:
250
+ return Result("R8", "", SKIP, f"no running transfer to interrupt (see R4): {s}")
251
+ rig.heartbeat(False)
252
+ reached, t, s, _ = await rig.wait(lambda s: s.fault or s.done, lim.watchdog)
253
+ if s.done and not s.fault:
254
+ return Result("R8", "", SKIP, f"the transfer finished {t:.2f} s after the heartbeat stopped, before a watchdog "
255
+ "could show. Test with a longer transfer.", t)
256
+ if not reached:
257
+ return Result("R8", "", FAIL, f"the vehicle went silent for {lim.watchdog:g} s during the transfer and the "
258
+ f"station carried on: {s}", t)
259
+ if s.active:
260
+ return Result("R8", "", FAIL, f"Fault is set but TransferActive is still on: {s}", t)
261
+ if s.code == 0:
262
+ return Result("R8", "", FAIL, "Fault is set but FaultCode is 0: nobody can tell what happened", t)
263
+ return Result("R8", "", PASS, f"fault code {s.code} and conveyor stopped {t:.2f} s after the heartbeat stopped "
264
+ f"(limit {lim.watchdog:g} s)", t)
265
+
266
+
267
+ async def r9_fault_acknowledge(rig, lim):
268
+ ok, _, s = await _to_transfer(rig, lim)
269
+ if ok:
270
+ rig.heartbeat(False)
271
+ ok, _, s, _ = await rig.wait(lambda s: s.fault or s.done, lim.watchdog)
272
+ if not ok or not s.fault:
273
+ return Result("R9", "", SKIP, f"could not bring the station into a fault to acknowledge (see R8): {s}")
274
+ await rig.set(False, False) # the acknowledge; the heartbeat may still be dead
275
+ reached, t, s, _ = await rig.wait(lambda s: s.idle, lim.release)
276
+ if not reached:
277
+ return Result("R9", "", FAIL, f"{lim.release:g} s after the request was dropped (the acknowledge) the station "
278
+ f"still shows: {s}", t)
279
+ return Result("R9", "", PASS, f"fault cleared {t:.2f} s after the acknowledge", t)
280
+
281
+
282
+ async def r10_vehicle_leaves(rig, lim):
283
+ ok, _, s = await _to_transfer(rig, lim)
284
+ if not ok:
285
+ return Result("R10", "", SKIP, f"no running transfer to interrupt (see R4): {s}")
286
+ await rig.set(True, False) # still requesting, but no longer in position
287
+ reached, t, s, _ = await rig.wait(lambda s: not s.active, lim.stop)
288
+ if not reached:
289
+ return Result("R10", "", FAIL, f"the vehicle left its position and {lim.stop:g} s later the conveyor is "
290
+ f"still running: {s}", t)
291
+ if s.done and not s.fault:
292
+ return Result("R10", "", SKIP, f"the transfer finished {t:.2f} s after the vehicle left, too fast to judge. "
293
+ "Test with a longer transfer.", t)
294
+ return Result("R10", "", PASS, f"conveyor stopped {t:.2f} s after the vehicle left its position "
295
+ f"(limit {lim.stop:g} s; station: {s})", t)
296
+
297
+
298
+ RULES = {
299
+ "R1": ("Idle at rest: no output is on without a request", r1_idle_at_rest),
300
+ "R2": ("StationReady follows AmrDockRequest in time, and nothing moves yet", r2_ready_follows_request),
301
+ "R3": ("No transfer before AmrInPosition", r3_no_transfer_before_in_position),
302
+ "R4": ("Transfer starts after AmrInPosition and completes; TransferActive is off when TransferDone is on", r4_transfer_completes),
303
+ "R5": ("Station returns to idle after the vehicle releases", r5_release),
304
+ "R6": ("A second handover works straight after the first", r6_second_handover),
305
+ "R7": ("A withdrawn request returns the station to idle without a transfer", r7_withdrawn_request),
306
+ "R8": ("Heartbeat watchdog: a silent vehicle stops the transfer with a fault code", r8_heartbeat_loss),
307
+ "R9": ("Dropping the request acknowledges and clears a fault", r9_fault_acknowledge),
308
+ "R10": ("The conveyor stops when the vehicle leaves its position", r10_vehicle_leaves),
309
+ }
310
+
311
+
312
+ async def run_checks(endpoint: str, signal_map: dict = SIM_MAP, limits: Limits = Limits(),
313
+ only: list[str] | None = None, period: float = 0.05) -> list[Result]:
314
+ """Run the rules in order against the station. Before each rule the station is brought back to idle;
315
+ a rule that cannot start from idle is reported as SKIP with the reason."""
316
+ results = []
317
+ stuck = False
318
+ async with Rig(endpoint, signal_map, period) as rig:
319
+ for rule, (title, check) in RULES.items():
320
+ if only and rule not in only:
321
+ continue
322
+ if rule != "R1" and not await rig.to_idle(limits.release * 2):
323
+ # A station that keeps outputs on after the vehicle dropped both request bits has broken
324
+ # the release rule. Say so once as a failure; the rules after it cannot be judged.
325
+ detail = (f"the vehicle dropped its request and {limits.release * 2:g} s later the station still "
326
+ f"shows: {await rig.read()}")
327
+ r = Result(rule, title, SKIP if stuck else FAIL,
328
+ ("not testable, " if stuck else "station does not return to idle (rule R5): ") + detail)
329
+ stuck = True
330
+ else:
331
+ try:
332
+ r = await check(rig, limits)
333
+ finally:
334
+ rig.heartbeat(True)
335
+ r.title = title
336
+ results.append(r)
337
+ await rig.to_idle(limits.release)
338
+ return results
339
+
340
+
341
+ def as_dicts(results: list[Result]) -> list[dict]:
342
+ return [asdict(r) for r in results]
@@ -0,0 +1,120 @@
1
+ """Reference station: the station side of the handover as a pure, PLC-style step function, plus an
2
+ OPC UA server around it.
3
+
4
+ Two uses:
5
+ - a station to develop against when no PLC is at hand (`handover-check station`);
6
+ - the yardstick for the tester's own tests: each DEFECT switches one rule off, and the tester must
7
+ report exactly that rule as broken.
8
+
9
+ The logic is written the way a PLC task works: read inputs, decide, write outputs, once per cycle.
10
+ """
11
+ import asyncio
12
+ from dataclasses import dataclass, field
13
+
14
+ from asyncua import Server, ua
15
+
16
+ NAMESPACE = "urn:amr-station-handshake:station"
17
+ FAULT_CONVEYOR_BLOCKED, FAULT_HEARTBEAT_LOST, FAULT_AMR_LEFT = 1, 2, 3
18
+ DEFECTS = (
19
+ "ready_without_request", # R1: Ready is on although nobody asked
20
+ "slow_ready", # R2: Ready comes far too late
21
+ "transfer_before_docked", # R3: conveyor starts before the vehicle is in position
22
+ "active_stays_on", # R4: TransferActive still on when TransferDone is set
23
+ "ready_stuck", # R5: Ready/Done not cleared after the vehicle released
24
+ "no_watchdog", # R8: a dead vehicle is not noticed
25
+ "fault_not_cleared", # R9: fault stays latched after the acknowledge
26
+ "ignores_undock", # R10: conveyor keeps running when the vehicle leaves
27
+ )
28
+
29
+
30
+ @dataclass
31
+ class Outputs:
32
+ ready: bool = False
33
+ transfer_active: bool = False
34
+ transfer_done: bool = False
35
+ fault: bool = False
36
+ fault_code: int = 0
37
+
38
+
39
+ @dataclass
40
+ class StationLogic:
41
+ ready_delay: float = 0.5 # s from request to Ready
42
+ transfer_time: float = 2.0 # s the conveyor runs
43
+ watchdog: float = 1.0 # s without a new heartbeat value before the station faults
44
+ defects: frozenset = field(default_factory=frozenset)
45
+ phase: str = "idle"
46
+ code: int = 0
47
+ _due: float = 0.0
48
+ _last_beat: int | None = None
49
+ _beat_seen: float = 0.0
50
+
51
+ def step(self, request: bool, docked: bool, beat: int, now: float) -> Outputs:
52
+ d = self.defects
53
+ if beat != self._last_beat:
54
+ self._last_beat, self._beat_seen = beat, now
55
+
56
+ if not request:
57
+ if self.phase == "fault" and "fault_not_cleared" in d:
58
+ pass
59
+ elif self.phase in ("ready", "done") and "ready_stuck" in d:
60
+ pass
61
+ else:
62
+ self.phase, self.code = "idle", 0 # dropping the request also acknowledges a fault
63
+ elif self.phase == "fault":
64
+ pass # latched until the request drops
65
+ elif now - self._beat_seen > self.watchdog and "no_watchdog" not in d:
66
+ self.phase, self.code = "fault", FAULT_HEARTBEAT_LOST
67
+ elif self.phase == "idle":
68
+ self.phase = "preparing"
69
+ self._due = now + (self.ready_delay * 20 if "slow_ready" in d else self.ready_delay)
70
+ elif self.phase == "preparing" and now >= self._due:
71
+ self.phase = "ready"
72
+ elif self.phase == "ready" and (docked or "transfer_before_docked" in d):
73
+ self.phase, self._due = "transferring", now + self.transfer_time
74
+ elif self.phase == "transferring":
75
+ if not docked and not d & {"ignores_undock", "transfer_before_docked"}:
76
+ self.phase, self.code = "fault", FAULT_AMR_LEFT
77
+ elif now >= self._due:
78
+ self.phase = "done"
79
+
80
+ return Outputs(
81
+ ready=self.phase in ("ready", "transferring", "done") or "ready_without_request" in d,
82
+ transfer_active=self.phase == "transferring" or (self.phase == "done" and "active_stays_on" in d),
83
+ transfer_done=self.phase == "done",
84
+ fault=self.phase == "fault",
85
+ fault_code=self.code,
86
+ )
87
+
88
+
89
+ async def serve(endpoint: str, logic: StationLogic, ready: asyncio.Event | None = None,
90
+ stop: asyncio.Event | None = None, cycle: float = 0.02) -> None:
91
+ """Run the station as an OPC UA server (no security: a development aid, bind it to localhost)."""
92
+ server = Server()
93
+ await server.init()
94
+ server.set_endpoint(endpoint)
95
+ server.set_server_name("Reference conveyor station (handover-check)")
96
+ server.set_security_policy([ua.SecurityPolicyType.NoSecurity])
97
+ idx = await server.register_namespace(NAMESPACE)
98
+ station = await server.nodes.objects.add_object(ua.NodeId("Station", idx), "Station")
99
+
100
+ async def signal(name, value, vt):
101
+ node = await station.add_variable(ua.NodeId(f"Station.{name}", idx), name, value, vt)
102
+ await node.set_writable()
103
+ return node
104
+ boolean, int16 = ua.VariantType.Boolean, ua.VariantType.Int16
105
+ inputs = [await signal(n, v, t) for n, v, t in (("AmrDockRequest", False, boolean),
106
+ ("AmrInPosition", False, boolean), ("AmrHeartbeat", 0, int16))]
107
+ outs = [await signal(n, v, t) for n, v, t in (("StationReady", False, boolean), ("TransferActive", False, boolean),
108
+ ("TransferDone", False, boolean), ("Fault", False, boolean),
109
+ ("FaultCode", 0, int16))]
110
+ loop = asyncio.get_running_loop()
111
+ async with server:
112
+ if ready:
113
+ ready.set()
114
+ while not (stop and stop.is_set()):
115
+ request, docked, beat = [await n.read_value() for n in inputs]
116
+ o = logic.step(bool(request), bool(docked), int(beat), loop.time())
117
+ for node, value, vt in zip(outs, (o.ready, o.transfer_active, o.transfer_done, o.fault, o.fault_code),
118
+ (boolean, boolean, boolean, boolean, int16)):
119
+ await node.write_value(value, vt)
120
+ await asyncio.sleep(cycle)
@@ -0,0 +1,150 @@
1
+ Metadata-Version: 2.4
2
+ Name: handover-check
3
+ Version: 0.1.0
4
+ Summary: Test a conveyor station's AMR/AGV load-handover handshake over OPC UA before the robot arrives: plays the vehicle side, also the ways a vehicle fails, and reports rule by rule.
5
+ Author: Suleman Ali
6
+ License: MIT
7
+ Project-URL: Repository, https://github.com/sulemanali5760/amr-station-handshake
8
+ Keywords: agv,amr,plc,opc-ua,handshake,commissioning,conveyor,vda5050,intralogistics
9
+ Classifier: Development Status :: 3 - Alpha
10
+ Classifier: Environment :: Console
11
+ Classifier: Intended Audience :: Manufacturing
12
+ Classifier: License :: OSI Approved :: MIT License
13
+ Classifier: Programming Language :: Python :: 3
14
+ Classifier: Topic :: Software Development :: Testing
15
+ Requires-Python: >=3.11
16
+ Description-Content-Type: text/markdown
17
+ License-File: LICENSE
18
+ Requires-Dist: asyncua<3,>=1.1
19
+ Requires-Dist: pyyaml>=6.0
20
+ Dynamic: license-file
21
+
22
+ # handover-check
23
+
24
+ Test a conveyor station's **AMR/AGV load-handover handshake** before the robot arrives.
25
+
26
+ The tool connects to the station PLC over OPC UA and plays the vehicle side: it requests a handover,
27
+ docks, releases, and also does what vehicles do when things go wrong (goes silent, leaves mid-transfer,
28
+ changes its mind). It checks the station's answers and response times against ten rules and reports
29
+ rule by rule.
30
+
31
+ ```
32
+ $ handover-check run opc.tcp://192.168.0.10:4840 --map station.yaml --station-is-safe
33
+
34
+ [PASS] R1 Idle at rest: no output is on without a request
35
+ all outputs off for 1 s without a request
36
+ [PASS] R2 StationReady follows AmrDockRequest in time, and nothing moves yet
37
+ StationReady 0.53 s after the request (limit 5 s)
38
+ [PASS] R3 No transfer before AmrInPosition
39
+ no transfer for 1 s while the vehicle was not in position
40
+ [PASS] R4 Transfer starts after AmrInPosition and completes; TransferActive is off when TransferDone is on
41
+ transfer started after 0.02 s and finished after 4.02 s
42
+ [PASS] R5 Station returns to idle after the vehicle releases
43
+ station idle 0.03 s after the release (limit 3 s)
44
+ [PASS] R6 A second handover works straight after the first
45
+ two handovers in a row completed
46
+ [PASS] R7 A withdrawn request returns the station to idle without a transfer
47
+ station back to idle 0.02 s after the request was withdrawn
48
+ [FAIL] R8 Heartbeat watchdog: a silent vehicle stops the transfer with a fault code
49
+ the vehicle went silent for 2 s during the transfer and the station carried on: Ready, TransferActive
50
+ [SKIP] R9 Dropping the request acknowledges and clears a fault
51
+ could not bring the station into a fault to acknowledge (see R8): Ready, TransferActive
52
+ [PASS] R10 The conveyor stops when the vehicle leaves its position
53
+ conveyor stopped 0.02 s after the vehicle left its position (limit 1 s; station: Fault(code 3))
54
+
55
+ Station does NOT conform. 8 passed, 1 failed, 1 not testable.
56
+ ```
57
+
58
+ *Real output, against the bundled reference station with its watchdog switched off; address changed.*
59
+
60
+ ## Why
61
+
62
+ VDA 5050 covers fleet controller to vehicle and expressly leaves out "interfaces to peripheral
63
+ equipment". The handshake between vehicle and conveyor is therefore written again in every project,
64
+ by two teams, and its mistakes show up at commissioning with the robot standing in front of the
65
+ station. The rules are written down in [docs/SPEC.md](../../docs/SPEC.md); this tool lets the PLC
66
+ programmer test the station side alone, at the desk or on site, days earlier.
67
+
68
+ ## Install
69
+
70
+ ```bash
71
+ pip install handover-check
72
+ ```
73
+
74
+ Python 3.11 or newer; Windows or Linux. No ROS and no robot needed.
75
+ Offline bundles for machines without internet are attached to each
76
+ [release](https://github.com/sulemanali5760/amr-station-handshake/releases).
77
+
78
+ ## Use
79
+
80
+ Try it without a PLC, in two terminals:
81
+
82
+ ```bash
83
+ handover-check station # reference station on opc.tcp://127.0.0.1:4860/station/
84
+ handover-check run opc.tcp://127.0.0.1:4860/station/
85
+ ```
86
+
87
+ ```bash
88
+ handover-check station --defect no_watchdog # a station with one rule switched off
89
+ ```
90
+
91
+ Against your PLC: describe where the eight signals live in a signal map (examples for the reference
92
+ station, Siemens S7-1500 and Beckhoff TwinCAT 3 are in [`maps/`](maps)), then:
93
+
94
+ ```bash
95
+ handover-check run opc.tcp://192.168.0.10:4840 --map my_station.yaml --station-is-safe
96
+ ```
97
+
98
+ | Option | Purpose |
99
+ |---|---|
100
+ | `--t-ready 5` `--t-start 3` `--t-transfer 15` `--t-release 3` `--t-watchdog 2` `--t-stop 1` | the time limits of your plant specification, in seconds |
101
+ | `--only R2 R8` | run selected rules |
102
+ | `--report record.md` | Markdown report for the commissioning record |
103
+ | `--json` | machine-readable output |
104
+ | `--strict` | exit code 2 if a rule could not be tested |
105
+
106
+ Exit code 0 = no rule failed, 1 = at least one rule failed.
107
+
108
+ ## It moves your conveyor
109
+
110
+ The tool writes the request signals. On a real station that starts the conveyor.
111
+
112
+ - For any endpoint that is not this computer it refuses to run without `--station-is-safe`.
113
+ - Run it with the station in a test state, clear of people and loads.
114
+ - It always writes both request bits back to FALSE when it ends, also after an error.
115
+ - It is a commissioning aid. It is **not** a safety function and no substitute for the safety
116
+ validation of the station.
117
+
118
+ ## Tested
119
+
120
+ The test suite starts the reference station over real OPC UA, once conforming and once for each of
121
+ eight built-in defects, and expects the tester to report exactly the broken rule:
122
+
123
+ | Defect in the station | Reported under |
124
+ |---|---|
125
+ | Ready on without a request | R1 |
126
+ | Ready far too late | R2 |
127
+ | conveyor starts before the vehicle is in position | R3 |
128
+ | TransferActive still on with TransferDone | R4 |
129
+ | outputs not cleared after release | R5 |
130
+ | no heartbeat watchdog | R8 |
131
+ | fault not cleared by the acknowledge | R9 |
132
+ | conveyor keeps running when the vehicle leaves | R10 |
133
+
134
+ A transfer that is too short to judge R8 or R10 is reported as "not testable", not as a pass.
135
+
136
+ ## Limits
137
+
138
+ - **Not tested against a real PLC.** Only against the bundled reference station. The S7-1500 and
139
+ TwinCAT maps are examples; node ids depend on the project.
140
+ - **No OPC UA security.** The tool connects anonymously without encryption. A PLC that demands
141
+ certificates or a login cannot be tested yet.
142
+ - **Polling.** Signals are read every 25 ms by default, so measured times are accurate to a few
143
+ tens of milliseconds plus the network. It cannot see events shorter than that.
144
+ - **The rules are this project's own** ([SPEC](../../docs/SPEC.md)), shaped after SEMI E84. They
145
+ are not a standard of any body, and a station built to another handshake will fail them.
146
+ - **One vehicle, one station.** No carrier identification, no multi-lane stations.
147
+
148
+ ## Licence
149
+
150
+ MIT
@@ -0,0 +1,14 @@
1
+ LICENSE
2
+ README.md
3
+ pyproject.toml
4
+ src/handover_check/__init__.py
5
+ src/handover_check/__main__.py
6
+ src/handover_check/checks.py
7
+ src/handover_check/station.py
8
+ src/handover_check.egg-info/PKG-INFO
9
+ src/handover_check.egg-info/SOURCES.txt
10
+ src/handover_check.egg-info/dependency_links.txt
11
+ src/handover_check.egg-info/entry_points.txt
12
+ src/handover_check.egg-info/requires.txt
13
+ src/handover_check.egg-info/top_level.txt
14
+ tests/test_checks.py
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ handover-check = handover_check.__main__:main
@@ -0,0 +1,2 @@
1
+ asyncua<3,>=1.1
2
+ pyyaml>=6.0
@@ -0,0 +1 @@
1
+ handover_check
@@ -0,0 +1,135 @@
1
+ """The tester is only worth something if it catches a station that breaks a rule, and stays quiet on one
2
+ that does not. Every test here runs it against the reference station over real OPC UA."""
3
+ import asyncio
4
+ import json
5
+
6
+ import pytest
7
+
8
+ from handover_check.__main__ import main, markdown, render
9
+ from handover_check.checks import FAIL, PASS, RULES, SKIP, Limits, load_signal_map, run_checks
10
+ from handover_check.station import DEFECTS, FAULT_AMR_LEFT, FAULT_HEARTBEAT_LOST, StationLogic, serve
11
+
12
+ # Short times so the suite stays fast; the ratios are those of a real station.
13
+ LIMITS = Limits(ready=1.0, start=0.6, transfer=3.0, release=0.8, watchdog=0.9, stop=0.5, hold=0.4)
14
+ PORT = [48480]
15
+
16
+
17
+ def check(defects=(), only=None, limits=LIMITS, **station):
18
+ PORT[0] += 1
19
+ endpoint = f"opc.tcp://127.0.0.1:{PORT[0]}/station/"
20
+ logic = StationLogic(ready_delay=0.15, transfer_time=station.pop("transfer_time", 1.6), watchdog=0.4,
21
+ defects=frozenset(defects), **station)
22
+
23
+ async def go():
24
+ ready, stop = asyncio.Event(), asyncio.Event()
25
+ task = asyncio.create_task(serve(endpoint, logic, ready, stop))
26
+ await asyncio.wait_for(ready.wait(), 20)
27
+ try:
28
+ return await run_checks(endpoint, limits=limits, only=only, period=0.03)
29
+ finally:
30
+ stop.set()
31
+ await task
32
+ return {r.rule: r for r in asyncio.run(go())}
33
+
34
+
35
+ def test_conforming_station_passes_every_rule():
36
+ results = check()
37
+ assert list(results) == list(RULES)
38
+ assert {r.status for r in results.values()} == {PASS}, render(list(results.values()), "x")
39
+ assert 0.1 < results["R2"].seconds < 0.6 # the ready delay of 0.15 s is measured, not assumed
40
+ assert f"fault code {FAULT_HEARTBEAT_LOST}" in results["R8"].detail
41
+ assert f"code {FAULT_AMR_LEFT}" in results["R10"].detail
42
+
43
+
44
+ @pytest.mark.parametrize("defect, rule, words", [
45
+ ("ready_without_request", "R1", "with no request"),
46
+ ("slow_ready", "R2", "no StationReady within"),
47
+ ("transfer_before_docked", "R3", "NOT in position"),
48
+ ("active_stays_on", "R4", "TransferActive is still on"),
49
+ ("ready_stuck", "R5", "still shows"),
50
+ ("no_watchdog", "R8", "went silent"),
51
+ ("fault_not_cleared", "R9", "still shows"),
52
+ ("ignores_undock", "R10", "still running"),
53
+ ])
54
+ def test_each_defect_is_reported_under_its_rule(defect, rule, words):
55
+ only = [rule] if rule != "R2" else ["R2"]
56
+ r = check([defect], only=only)[rule]
57
+ assert r.status == FAIL, r
58
+ assert words in r.detail, r.detail
59
+
60
+
61
+ def test_every_defect_has_a_test():
62
+ assert len(DEFECTS) == 8
63
+
64
+
65
+ def test_a_broken_station_never_passes_everything():
66
+ """Whatever single defect the station has, the full run reports at least one FAIL."""
67
+ for defect in ("transfer_before_docked", "no_watchdog", "ready_stuck"):
68
+ statuses = [r.status for r in check([defect]).values()]
69
+ assert FAIL in statuses, (defect, statuses)
70
+
71
+
72
+ def test_too_fast_transfer_is_reported_as_not_testable_not_as_pass():
73
+ """With a 0.2 s transfer the watchdog cannot show before the transfer ends: say so."""
74
+ r = check(only=["R8"], transfer_time=0.2)["R8"]
75
+ assert r.status == SKIP and "longer transfer" in r.detail
76
+
77
+
78
+ def test_second_handover_fails_when_the_station_stays_latched():
79
+ r = check(["ready_stuck"], only=["R6"])["R6"]
80
+ assert r.status in (FAIL, SKIP) and "idle" in r.detail
81
+
82
+
83
+ # ---------------------------------------------------------------- pure station logic, no network
84
+ def test_station_logic_walks_the_normal_sequence():
85
+ s = StationLogic(ready_delay=1.0, transfer_time=2.0, watchdog=1.0)
86
+ assert not s.step(False, False, 0, 0.0).ready
87
+ s.step(True, False, 1, 0.1)
88
+ assert not s.step(True, False, 2, 0.5).ready
89
+ assert s.step(True, False, 3, 1.2).ready
90
+ assert s.step(True, True, 4, 1.3).transfer_active
91
+ out = s.step(True, True, 5, 3.4)
92
+ assert out.transfer_done and not out.transfer_active and out.ready
93
+ assert s.step(False, False, 6, 3.5) == type(out)() # all off again
94
+
95
+
96
+ def test_station_logic_faults_on_a_frozen_heartbeat():
97
+ s = StationLogic(ready_delay=0.0, transfer_time=10.0, watchdog=1.0)
98
+ for t in (0.0, 0.1, 0.2):
99
+ s.step(True, True, int(t * 10) + 1, t)
100
+ assert s.step(True, True, 3, 0.9).transfer_active # same counter value, still inside the watchdog time
101
+ out = s.step(True, True, 3, 1.3)
102
+ assert out.fault and out.fault_code == FAULT_HEARTBEAT_LOST and not out.transfer_active
103
+
104
+
105
+ # ---------------------------------------------------------------- command line and files
106
+ def test_refuses_a_remote_station_without_the_safety_confirmation(capsys):
107
+ assert main(["run", "opc.tcp://192.168.0.10:4840"]) == 64
108
+ assert "--station-is-safe" in capsys.readouterr().err
109
+
110
+
111
+ def test_unreachable_station_is_an_error_not_a_traceback(capsys):
112
+ assert main(["run", "opc.tcp://127.0.0.1:48479"]) == 64
113
+ assert "handover-check:" in capsys.readouterr().err
114
+
115
+
116
+ def test_signal_map_must_name_all_eight_signals(tmp_path):
117
+ p = tmp_path / "map.yaml"
118
+ p.write_text("namespace_uri: urn:x\nnode_ids:\n AmrDockRequest: a\n", encoding="utf-8")
119
+ with pytest.raises(ValueError, match="AmrInPosition"):
120
+ load_signal_map(p)
121
+
122
+
123
+ def test_shipped_maps_load():
124
+ import pathlib
125
+ maps = sorted((pathlib.Path(__file__).parents[1] / "maps").glob("*.yaml"))
126
+ assert len(maps) == 3
127
+ for m in maps:
128
+ assert len(load_signal_map(m)["node_ids"]) == 8
129
+
130
+
131
+ def test_reports():
132
+ results = list(check(only=["R1", "R2"]).values())
133
+ assert "[PASS] R1" in render(results, "opc.tcp://x") and "Station conforms." in render(results, "opc.tcp://x")
134
+ assert "| R2 |" in markdown(results, "opc.tcp://x")
135
+ assert json.dumps([r.__dict__ for r in results])