Compare commits
20 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b2e22c3af0 | |||
| c76ac1023e | |||
| 8a55137e57 | |||
| 2218b854ce | |||
| d22bbc4ab2 | |||
| a2ebbf7c65 | |||
| 9876dd9b5a | |||
| 54626ceb6a | |||
| b1772cfefd | |||
| b30443089f | |||
| 4f3031aeb9 | |||
| af1a159c59 | |||
| 8686654b15 | |||
| 912304966f | |||
| cf230d4119 | |||
| e6ee927900 | |||
| 1da56f973f | |||
| 83593bb508 | |||
| 1daf120431 | |||
| b0a4c331cf |
@@ -79,6 +79,7 @@ jobs:
|
|||||||
apt-get install -y --no-install-recommends \
|
apt-get install -y --no-install-recommends \
|
||||||
build-essential cmake ninja-build \
|
build-essential cmake ninja-build \
|
||||||
libasio-dev libyaml-cpp-dev \
|
libasio-dev libyaml-cpp-dev \
|
||||||
|
libprotobuf-dev protobuf-compiler protobuf-compiler-grpc libgrpc++-dev \
|
||||||
python3 python3-yaml
|
python3 python3-yaml
|
||||||
|
|
||||||
# Debug + -fsanitize=thread. Catches data races in the
|
# Debug + -fsanitize=thread. Catches data races in the
|
||||||
@@ -98,6 +99,18 @@ jobs:
|
|||||||
TSAN_OPTIONS: halt_on_error=1
|
TSAN_OPTIONS: halt_on_error=1
|
||||||
run: build-tsan/secsgem_tests
|
run: build-tsan/secsgem_tests
|
||||||
|
|
||||||
|
# The daemon under TSan: run_async + concurrent gRPC handler threads is
|
||||||
|
# the production threading shape — this is where a strand-contract
|
||||||
|
# violation would actually surface. tools/tsan.supp silences false
|
||||||
|
# positives inside the UNinstrumented system libgrpc only; our frames
|
||||||
|
# stay fully checked.
|
||||||
|
- name: Daemon tests (TSan)
|
||||||
|
env:
|
||||||
|
TSAN_OPTIONS: halt_on_error=1 suppressions=tools/tsan.supp
|
||||||
|
run: |
|
||||||
|
test -x build-tsan/secs_gemd_tests || { echo "secs_gemd_tests not built under TSan"; exit 1; }
|
||||||
|
build-tsan/secs_gemd_tests
|
||||||
|
|
||||||
tshark-dissector:
|
tshark-dissector:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
container:
|
container:
|
||||||
@@ -188,6 +201,12 @@ jobs:
|
|||||||
- name: secs4j cross-validation
|
- name: secs4j cross-validation
|
||||||
run: bash interop/secs4j_validate.sh
|
run: bash interop/secs4j_validate.sh
|
||||||
|
|
||||||
|
# Same 55 Java checks against the DAEMON's HSMS face: secs_gemd and
|
||||||
|
# secs_server both sit on register_default_handlers, so they must be
|
||||||
|
# byte-identical GEM. Image layers are shared with the step above.
|
||||||
|
- name: secs4j cross-validation (secs_gemd)
|
||||||
|
run: TARGET=gemd bash interop/secs4j_validate.sh
|
||||||
|
|
||||||
python-interop:
|
python-interop:
|
||||||
# secsgem-py (the Python reference implementation) judging our wire
|
# secsgem-py (the Python reference implementation) judging our wire
|
||||||
# behaviour: its GemHostHandler drives our secs_server (31 checks), the
|
# behaviour: its GemHostHandler drives our secs_server (31 checks), the
|
||||||
@@ -260,6 +279,44 @@ jobs:
|
|||||||
kill $GEMD 2>/dev/null || true
|
kill $GEMD 2>/dev/null || true
|
||||||
exit $rc
|
exit $rc
|
||||||
|
|
||||||
|
- name: Python client package vs secs_gemd
|
||||||
|
env:
|
||||||
|
PYTHONPATH: clients/python
|
||||||
|
run: |
|
||||||
|
/tmp/venv/bin/python clients/python/tests/test_values.py
|
||||||
|
build/secs_gemd --port 5005 --grpc 127.0.0.1:50052 &
|
||||||
|
GEMD=$!
|
||||||
|
sleep 1
|
||||||
|
PYTHONPATH=clients/python /tmp/venv/bin/python interop/pyclient_interop.py \
|
||||||
|
--grpc 127.0.0.1:50052 --hsms-host 127.0.0.1 --hsms-port 5005
|
||||||
|
rc=$?
|
||||||
|
kill $GEMD 2>/dev/null || true
|
||||||
|
exit $rc
|
||||||
|
|
||||||
|
- name: spool persistence across a server restart
|
||||||
|
run: |
|
||||||
|
set -e
|
||||||
|
SPOOL=$(mktemp -d)
|
||||||
|
build/secs_server --port 5006 --spool-dir "$SPOOL" &
|
||||||
|
SRV=$!
|
||||||
|
sleep 1
|
||||||
|
/tmp/venv/bin/python interop/spool_persistence_test.py \
|
||||||
|
--phase enqueue --host 127.0.0.1 --port 5006
|
||||||
|
kill $SRV; wait $SRV 2>/dev/null || true
|
||||||
|
build/secs_server --port 5006 --spool-dir "$SPOOL" &
|
||||||
|
SRV=$!
|
||||||
|
sleep 1
|
||||||
|
/tmp/venv/bin/python interop/spool_persistence_test.py \
|
||||||
|
--phase drain --host 127.0.0.1 --port 5006
|
||||||
|
rc=$?
|
||||||
|
kill $SRV 2>/dev/null || true
|
||||||
|
exit $rc
|
||||||
|
|
||||||
|
# Phase E operational contract: unix-socket gRPC, Prometheus gauges,
|
||||||
|
# graceful SIGTERM shutdown (exit 0, journal-safe).
|
||||||
|
- name: daemon ops (unix socket + metrics + graceful shutdown)
|
||||||
|
run: bash tools/check_daemon_ops.sh
|
||||||
|
|
||||||
libfuzzer:
|
libfuzzer:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
container:
|
container:
|
||||||
|
|||||||
@@ -9,5 +9,12 @@ build-tsan/
|
|||||||
.DS_Store
|
.DS_Store
|
||||||
compile_commands.json
|
compile_commands.json
|
||||||
|
|
||||||
|
# Python build/cache artifacts
|
||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
*.egg-info/
|
||||||
|
.pytest_cache/
|
||||||
|
|
||||||
# Local Claude Code agent state (memory, skills, etc.)
|
# Local Claude Code agent state (memory, skills, etc.)
|
||||||
.claude/
|
.claude/
|
||||||
|
build-tsan-d/
|
||||||
|
|||||||
+12
-1
@@ -170,6 +170,15 @@ if(SECSGEM_DAEMON)
|
|||||||
${PROTO_OUT}/secsgem/v1/equipment.grpc.pb.cc)
|
${PROTO_OUT}/secsgem/v1/equipment.grpc.pb.cc)
|
||||||
target_include_directories(secs_gemd PRIVATE ${PROTO_OUT} ${Protobuf_INCLUDE_DIRS})
|
target_include_directories(secs_gemd PRIVATE ${PROTO_OUT} ${Protobuf_INCLUDE_DIRS})
|
||||||
target_link_libraries(secs_gemd PRIVATE secsgem PkgConfig::GRPCPP ${Protobuf_LIBRARIES})
|
target_link_libraries(secs_gemd PRIVATE secsgem PkgConfig::GRPCPP ${Protobuf_LIBRARIES})
|
||||||
|
# The header-only C++ client's worked example (clients/cpp).
|
||||||
|
add_executable(cpp_mini_tool
|
||||||
|
clients/cpp/examples/mini_tool.cpp
|
||||||
|
${PROTO_OUT}/secsgem/v1/equipment.pb.cc
|
||||||
|
${PROTO_OUT}/secsgem/v1/equipment.grpc.pb.cc)
|
||||||
|
target_include_directories(cpp_mini_tool PRIVATE
|
||||||
|
${PROTO_OUT} ${Protobuf_INCLUDE_DIRS} ${CMAKE_SOURCE_DIR}/clients/cpp/include)
|
||||||
|
target_link_libraries(cpp_mini_tool PRIVATE PkgConfig::GRPCPP ${Protobuf_LIBRARIES} Threads::Threads)
|
||||||
|
|
||||||
set(SECSGEM_DAEMON_BUILT TRUE)
|
set(SECSGEM_DAEMON_BUILT TRUE)
|
||||||
message(STATUS "secs_gemd daemon enabled (grpc++ ${GRPCPP_VERSION})")
|
message(STATUS "secs_gemd daemon enabled (grpc++ ${GRPCPP_VERSION})")
|
||||||
else()
|
else()
|
||||||
@@ -218,6 +227,7 @@ add_executable(secsgem_tests
|
|||||||
tests/test_data_model.cpp
|
tests/test_data_model.cpp
|
||||||
tests/test_runtime.cpp
|
tests/test_runtime.cpp
|
||||||
tests/test_default_handlers.cpp
|
tests/test_default_handlers.cpp
|
||||||
|
tests/test_handler_conformance.cpp
|
||||||
tests/test_name_index.cpp
|
tests/test_name_index.cpp
|
||||||
tests/test_messages.cpp
|
tests/test_messages.cpp
|
||||||
tests/test_loader.cpp
|
tests/test_loader.cpp
|
||||||
@@ -265,10 +275,11 @@ add_test(NAME secsgem_tests COMMAND secsgem_tests)
|
|||||||
if(SECSGEM_DAEMON_BUILT)
|
if(SECSGEM_DAEMON_BUILT)
|
||||||
add_executable(secs_gemd_tests
|
add_executable(secs_gemd_tests
|
||||||
tests/test_daemon_service.cpp
|
tests/test_daemon_service.cpp
|
||||||
|
tests/test_cpp_client.cpp
|
||||||
${PROTO_OUT}/secsgem/v1/equipment.pb.cc
|
${PROTO_OUT}/secsgem/v1/equipment.pb.cc
|
||||||
${PROTO_OUT}/secsgem/v1/equipment.grpc.pb.cc)
|
${PROTO_OUT}/secsgem/v1/equipment.grpc.pb.cc)
|
||||||
target_include_directories(secs_gemd_tests PRIVATE
|
target_include_directories(secs_gemd_tests PRIVATE
|
||||||
${PROTO_OUT} ${Protobuf_INCLUDE_DIRS} ${CMAKE_SOURCE_DIR}/apps)
|
${PROTO_OUT} ${Protobuf_INCLUDE_DIRS} ${CMAKE_SOURCE_DIR}/clients/cpp/include)
|
||||||
target_link_libraries(secs_gemd_tests PRIVATE
|
target_link_libraries(secs_gemd_tests PRIVATE
|
||||||
secsgem PkgConfig::GRPCPP ${Protobuf_LIBRARIES} doctest::doctest)
|
secsgem PkgConfig::GRPCPP ${Protobuf_LIBRARIES} doctest::doctest)
|
||||||
target_compile_definitions(secs_gemd_tests PRIVATE
|
target_compile_definitions(secs_gemd_tests PRIVATE
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ Everything runs in Docker — no compiler or build tools on the host.
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker compose run --rm builder # configure + compile
|
docker compose run --rm builder # configure + compile
|
||||||
docker compose run --rm tests # 445 cases / 2 753 assertions
|
docker compose run --rm tests # 473 cases / 3 087 assertions
|
||||||
docker compose up --no-deps server client # live two-container demo
|
docker compose up --no-deps server client # live two-container demo
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -28,6 +28,49 @@ through the data model. Watch the logs interleave.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Integrating your tool (pick a tier)
|
||||||
|
|
||||||
|
Three ways in, same engine underneath:
|
||||||
|
|
||||||
|
1. **Python, no SEMI knowledge** — run the `secs_gemd` daemon and
|
||||||
|
`pip install` the pure-Python client in [clients/python](clients/python):
|
||||||
|
|
||||||
|
```python
|
||||||
|
from secsgem_client import Equipment
|
||||||
|
|
||||||
|
eq = Equipment("localhost:50051")
|
||||||
|
eq.set(ChamberPressure=2.5) # variables: kwargs, not strings
|
||||||
|
|
||||||
|
@eq.command # the function name IS the command,
|
||||||
|
def START(cmd): # validated against the real equipment
|
||||||
|
run_recipe(cmd.params.get("PPID")) # — so a typo fails at startup
|
||||||
|
eq.fire(eq.names.event.ProcessStarted) # autocomplete + typo-safe
|
||||||
|
|
||||||
|
eq.listen()
|
||||||
|
```
|
||||||
|
|
||||||
|
Names come from *your* `equipment.yaml`. `@eq.command` binds a handler by
|
||||||
|
its function name; `eq.names.event.*` / `.alarm.*` / `.command.*` are
|
||||||
|
autocomplete-able, typo-checked views fetched from the live daemon — so
|
||||||
|
you rarely type a bare string. (The plain forms — `@eq.on("START")`,
|
||||||
|
`eq.fire("ProcessStarted")` — also work.)
|
||||||
|
|
||||||
|
A complete tool is ~25 lines: [clients/python/examples/mini_tool.py](clients/python/examples/mini_tool.py).
|
||||||
|
|
||||||
|
2. **Any language over gRPC** — `secs_gemd` exposes the name-based API in
|
||||||
|
[proto/secsgem/v1/equipment.proto](proto/secsgem/v1/equipment.proto)
|
||||||
|
(variables, events, alarms, control state, health stream, and the
|
||||||
|
host-command stream with the SEMI-conformant HCACK-4 contract). The
|
||||||
|
daemon owns the durable HSMS link: your tool software can restart
|
||||||
|
without the fab host ever noticing.
|
||||||
|
|
||||||
|
3. **Embedded C++** — construct a `gem::EquipmentRuntime`, call the
|
||||||
|
per-capability `register_*` functions (or `register_default_handlers`
|
||||||
|
for all of GEM), and wire behaviour with `commands.set_handler`.
|
||||||
|
`apps/secs_server.cpp` is the ~110-line canonical example.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Documentation map
|
## Documentation map
|
||||||
|
|
||||||
| File | What it covers |
|
| File | What it covers |
|
||||||
@@ -50,8 +93,8 @@ through the data model. Watch the logs interleave.
|
|||||||
|
|
||||||
## Testing
|
## Testing
|
||||||
|
|
||||||
- **Unit + integration** — `docker compose run --rm tests` runs 445
|
- **Unit + integration** — `docker compose run --rm tests` runs 473
|
||||||
cases / 2 753 assertions across every store, FSM, codec, parser, and
|
cases / 3 087 assertions across every store, FSM, codec, parser, and
|
||||||
persistence path.
|
persistence path.
|
||||||
- **Live conformance harness** — 47 wire-level checks against the
|
- **Live conformance harness** — 47 wire-level checks against the
|
||||||
passive server.
|
passive server.
|
||||||
@@ -59,11 +102,20 @@ through the data model. Watch the logs interleave.
|
|||||||
(55 checks), and Wireshark's HSMS dissector (69 frames, 0 malformed).
|
(55 checks), and Wireshark's HSMS dissector (69 frames, 0 malformed).
|
||||||
- **Soak + fuzz** — 100 000-op property test; libFuzzer with ASan +
|
- **Soak + fuzz** — 100 000-op property test; libFuzzer with ASan +
|
||||||
UBSan over `secs2::decode` and the SML parser, 0 crashes.
|
UBSan over `secs2::decode` and the SML parser, 0 crashes.
|
||||||
|
- **Daemon** — `secs_gemd_tests` exercises the gRPC service over real
|
||||||
|
in-process channels (125 assertions), in Release and under
|
||||||
|
ThreadSanitizer; `interop/daemon_interop.py` and
|
||||||
|
`interop/pyclient_interop.py` prove the gRPC↔HSMS bridge and the
|
||||||
|
published Python client against a live daemon with secsgem-py as host.
|
||||||
|
- **One command for all of it** — `tools/run_interop.sh` runs every
|
||||||
|
validation step (build, both unit suites, secsgem-py host, C++
|
||||||
|
conformance, Python client, daemon bridge, spool restart, tshark,
|
||||||
|
secs4java8) with a PASS/FAIL summary.
|
||||||
- **Config validation** — `secs_server --validate-config` rejects
|
- **Config validation** — `secs_server --validate-config` rejects
|
||||||
malformed YAML before startup.
|
malformed YAML before startup.
|
||||||
- **CI** — [Gitea Actions](.gitea/workflows/ci.yml) runs the full
|
- **CI** — [Gitea Actions](.gitea/workflows/ci.yml) runs the full
|
||||||
suite plus a `-fsanitize=thread` lane on every push to `main`; all
|
suite plus a `-fsanitize=thread` lane on every push to `main`; all
|
||||||
445 cases pass clean under TSan.
|
473 cases pass clean under TSan.
|
||||||
|
|
||||||
Exact commands, exit codes, and per-standard test counts are in
|
Exact commands, exit codes, and per-standard test counts are in
|
||||||
[docs/PROOFS.md](docs/PROOFS.md); the rationale behind the external
|
[docs/PROOFS.md](docs/PROOFS.md); the rationale behind the external
|
||||||
|
|||||||
@@ -1,171 +0,0 @@
|
|||||||
#pragma once
|
|
||||||
|
|
||||||
// The gRPC Equipment service: translates proto/secsgem/v1 RPCs onto an
|
|
||||||
// EquipmentRuntime. Header-only so both secs_gemd and the daemon tests share
|
|
||||||
// one definition.
|
|
||||||
//
|
|
||||||
// Threading: gRPC handlers run on gRPC's own threads while the engine's
|
|
||||||
// io thread owns the data model, so handlers must not read the live stores.
|
|
||||||
// The constructor therefore snapshots the name->id/format maps (immutable
|
|
||||||
// after config load) — construct the service BEFORE run_async(). All writes
|
|
||||||
// go through the runtime's posting API.
|
|
||||||
|
|
||||||
#include <grpcpp/grpcpp.h>
|
|
||||||
|
|
||||||
#include <cstdint>
|
|
||||||
#include <map>
|
|
||||||
#include <string>
|
|
||||||
|
|
||||||
#include "secsgem/gem/runtime.hpp"
|
|
||||||
#include "secsgem/secs2/item.hpp"
|
|
||||||
#include "secsgem/v1/equipment.grpc.pb.h"
|
|
||||||
#include "secsgem/v1/equipment.pb.h"
|
|
||||||
|
|
||||||
namespace secsgem::daemon {
|
|
||||||
|
|
||||||
namespace gem = secsgem::gem;
|
|
||||||
namespace s2 = secsgem::secs2;
|
|
||||||
namespace pb = secsgem::v1;
|
|
||||||
|
|
||||||
// proto Value -> SECS-II Item, honouring the variable's declared wire format
|
|
||||||
// (from equipment.yaml) so the host sees the same format in S1F11 namelists
|
|
||||||
// and in reported values. E.g. real 2.5 -> F4 if the variable is F4.
|
|
||||||
inline s2::Item to_item(const pb::Value& v, s2::Format want) {
|
|
||||||
using F = s2::Format;
|
|
||||||
switch (v.kind_case()) {
|
|
||||||
case pb::Value::kText:
|
|
||||||
return s2::Item::ascii(v.text());
|
|
||||||
case pb::Value::kBoolean:
|
|
||||||
return s2::Item::boolean(v.boolean());
|
|
||||||
case pb::Value::kBinary:
|
|
||||||
return s2::Item::binary({v.binary().begin(), v.binary().end()});
|
|
||||||
case pb::Value::kReal:
|
|
||||||
return want == F::F4 ? s2::Item::f4(static_cast<float>(v.real()))
|
|
||||||
: s2::Item::f8(v.real());
|
|
||||||
case pb::Value::kInteger: {
|
|
||||||
const int64_t n = v.integer();
|
|
||||||
switch (want) {
|
|
||||||
case F::U1: return s2::Item::u1(static_cast<uint8_t>(n));
|
|
||||||
case F::U2: return s2::Item::u2(static_cast<uint16_t>(n));
|
|
||||||
case F::U4: return s2::Item::u4(static_cast<uint32_t>(n));
|
|
||||||
case F::U8: return s2::Item::u8(static_cast<uint64_t>(n));
|
|
||||||
case F::I1: return s2::Item::i1(static_cast<int8_t>(n));
|
|
||||||
case F::I2: return s2::Item::i2(static_cast<int16_t>(n));
|
|
||||||
case F::I4: return s2::Item::i4(static_cast<int32_t>(n));
|
|
||||||
case F::F4: return s2::Item::f4(static_cast<float>(n));
|
|
||||||
case F::F8: return s2::Item::f8(static_cast<double>(n));
|
|
||||||
case F::Boolean: return s2::Item::boolean(n != 0);
|
|
||||||
default: return s2::Item::i8(n);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
case pb::Value::kList: {
|
|
||||||
// TODO(daemon): list elements inherit the variable's scalar format;
|
|
||||||
// honouring per-element formats needs the declared Item's nested shape.
|
|
||||||
s2::Item::List items;
|
|
||||||
for (const auto& e : v.list().items()) items.push_back(to_item(e, want));
|
|
||||||
return s2::Item::list(std::move(items));
|
|
||||||
}
|
|
||||||
default:
|
|
||||||
// Unreachable: callers reject KIND_NOT_SET before converting (see
|
|
||||||
// value_is_set). Kept as a safe fallback for future oneof additions.
|
|
||||||
return s2::Item::ascii("");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validate a client-supplied Value before conversion. An unset oneof would
|
|
||||||
// otherwise silently become ASCII "" — reject it at the API edge instead.
|
|
||||||
inline bool value_is_set(const pb::Value& v) {
|
|
||||||
return v.kind_case() != pb::Value::KIND_NOT_SET;
|
|
||||||
}
|
|
||||||
|
|
||||||
inline pb::ControlState::State to_proto_state(gem::ControlState s) {
|
|
||||||
switch (s) {
|
|
||||||
case gem::ControlState::EquipmentOffline: return pb::ControlState::EQUIPMENT_OFFLINE;
|
|
||||||
case gem::ControlState::AttemptOnline: return pb::ControlState::ATTEMPT_ONLINE;
|
|
||||||
case gem::ControlState::HostOffline: return pb::ControlState::HOST_OFFLINE;
|
|
||||||
case gem::ControlState::OnlineLocal: return pb::ControlState::ONLINE_LOCAL;
|
|
||||||
case gem::ControlState::OnlineRemote: return pb::ControlState::ONLINE_REMOTE;
|
|
||||||
}
|
|
||||||
return pb::ControlState::EQUIPMENT_OFFLINE;
|
|
||||||
}
|
|
||||||
|
|
||||||
class EquipmentService final : public pb::Equipment::Service {
|
|
||||||
public:
|
|
||||||
// Snapshots the (immutable) name->id/format dictionaries. Construct before
|
|
||||||
// run_async() so the model is read while the io thread isn't running yet.
|
|
||||||
explicit EquipmentService(gem::EquipmentRuntime& rt) : rt_(rt) {
|
|
||||||
for (const auto& sv : rt.model().svids.all())
|
|
||||||
vars_.insert({sv.name, {sv.id, sv.value.format()}});
|
|
||||||
for (const auto& dv : rt.model().dvids.all())
|
|
||||||
vars_.insert({dv.name, {dv.id, dv.value.format()}});
|
|
||||||
for (const auto& ev : rt.model().events.all_events())
|
|
||||||
events_.insert({ev.name, ev.id});
|
|
||||||
}
|
|
||||||
|
|
||||||
grpc::Status SetVariables(grpc::ServerContext*, const pb::VariableUpdate* req,
|
|
||||||
pb::Ack* resp) override {
|
|
||||||
for (const auto& kv : req->values()) {
|
|
||||||
auto it = vars_.find(kv.first);
|
|
||||||
if (it == vars_.end()) {
|
|
||||||
resp->set_code(pb::Ack::PARAMETER_INVALID);
|
|
||||||
resp->set_message("no variable named '" + kv.first + "'");
|
|
||||||
return grpc::Status::OK;
|
|
||||||
}
|
|
||||||
if (!value_is_set(kv.second)) {
|
|
||||||
resp->set_code(pb::Ack::PARAMETER_INVALID);
|
|
||||||
resp->set_message("value for '" + kv.first + "' has no kind set");
|
|
||||||
return grpc::Status::OK;
|
|
||||||
}
|
|
||||||
rt_.set_variable(it->second.vid, to_item(kv.second, it->second.format));
|
|
||||||
}
|
|
||||||
resp->set_code(pb::Ack::ACCEPT);
|
|
||||||
return grpc::Status::OK;
|
|
||||||
}
|
|
||||||
|
|
||||||
grpc::Status FireEvent(grpc::ServerContext*, const pb::Event* req,
|
|
||||||
pb::Ack* resp) override {
|
|
||||||
// Optional per-fire variable values, then trigger the collection event.
|
|
||||||
for (const auto& kv : req->data()) {
|
|
||||||
auto it = vars_.find(kv.first);
|
|
||||||
if (it == vars_.end()) {
|
|
||||||
resp->set_code(pb::Ack::PARAMETER_INVALID);
|
|
||||||
resp->set_message("no variable named '" + kv.first + "'");
|
|
||||||
return grpc::Status::OK;
|
|
||||||
}
|
|
||||||
if (!value_is_set(kv.second)) {
|
|
||||||
resp->set_code(pb::Ack::PARAMETER_INVALID);
|
|
||||||
resp->set_message("value for '" + kv.first + "' has no kind set");
|
|
||||||
return grpc::Status::OK;
|
|
||||||
}
|
|
||||||
rt_.set_variable(it->second.vid, to_item(kv.second, it->second.format));
|
|
||||||
}
|
|
||||||
auto ev = events_.find(req->name());
|
|
||||||
if (ev == events_.end()) {
|
|
||||||
resp->set_code(pb::Ack::PARAMETER_INVALID);
|
|
||||||
resp->set_message("no event named '" + req->name() + "'");
|
|
||||||
return grpc::Status::OK;
|
|
||||||
}
|
|
||||||
rt_.emit_event(ev->second);
|
|
||||||
resp->set_code(pb::Ack::ACCEPT);
|
|
||||||
return grpc::Status::OK;
|
|
||||||
}
|
|
||||||
|
|
||||||
grpc::Status GetControlState(grpc::ServerContext*, const pb::Empty*,
|
|
||||||
pb::ControlState* resp) override {
|
|
||||||
// Thread-safe: control_state() reads the runtime's atomic mirror.
|
|
||||||
resp->set_state(to_proto_state(rt_.control_state()));
|
|
||||||
return grpc::Status::OK;
|
|
||||||
}
|
|
||||||
|
|
||||||
private:
|
|
||||||
struct VarRef {
|
|
||||||
uint32_t vid;
|
|
||||||
s2::Format format; // declared wire format from equipment.yaml
|
|
||||||
};
|
|
||||||
|
|
||||||
gem::EquipmentRuntime& rt_;
|
|
||||||
std::map<std::string, VarRef> vars_; // SVIDs + DVIDs (SVIDs win on clash)
|
|
||||||
std::map<std::string, uint32_t> events_; // CEID by name
|
|
||||||
};
|
|
||||||
|
|
||||||
} // namespace secsgem::daemon
|
|
||||||
+89
-14
@@ -1,26 +1,42 @@
|
|||||||
// secs_gemd — the SECS/GEM daemon.
|
// secs_gemd — the SECS/GEM daemon.
|
||||||
//
|
//
|
||||||
// Runs the GEM engine (EquipmentRuntime + the default handlers) on a background
|
// Runs the GEM engine (EquipmentRuntime + the default handlers) on a background
|
||||||
// thread, owning the HSMS link to the host, and exposes a small name-based gRPC
|
// thread, owning the HSMS link to the host, and exposes the name-based gRPC
|
||||||
// API (proto/secsgem/v1/equipment.proto) so a tool's software — in any language
|
// API (proto/secsgem/v1/equipment.proto) so a tool's software — in any language
|
||||||
// — can drive the equipment without linking C++ or knowing SEMI.
|
// — can drive the equipment without linking C++ or knowing SEMI.
|
||||||
//
|
//
|
||||||
// Increment 1: the universal "report state to the host" RPCs (SetVariables,
|
// Operations (Phase E):
|
||||||
// FireEvent) plus control-state visibility. Alarms, GetVariables, and the
|
// --grpc defaults to 127.0.0.1:50051 — NEVER expose the unauthenticated
|
||||||
// host->tool Subscribe command stream follow (see docs/DAEMON_ROADMAP.md).
|
// API on the equipment LAN. For same-host tool software prefer a
|
||||||
|
// Unix domain socket: --grpc unix:///run/secs_gemd.sock
|
||||||
|
// --metrics Prometheus endpoint port (0 = disabled). Gauges: HSMS link
|
||||||
|
// state, control state, spool depth.
|
||||||
|
// SIGTERM/SIGINT: graceful shutdown — gRPC server drains (open streams are
|
||||||
|
// cancelled), then the engine stops cleanly, so a supervised
|
||||||
|
// restart (systemd / docker stop) never kills the spool journal
|
||||||
|
// mid-write. Exit code 0.
|
||||||
|
//
|
||||||
|
// Deployment recipe: deploy/secs_gemd.service (systemd) and docs/42.
|
||||||
|
|
||||||
#include <grpcpp/grpcpp.h>
|
#include <grpcpp/grpcpp.h>
|
||||||
|
|
||||||
|
#include <asio.hpp>
|
||||||
|
#include <chrono>
|
||||||
|
#include <csignal>
|
||||||
#include <cstdint>
|
#include <cstdint>
|
||||||
|
#include <functional>
|
||||||
#include <iostream>
|
#include <iostream>
|
||||||
#include <memory>
|
#include <memory>
|
||||||
#include <string>
|
#include <string>
|
||||||
|
|
||||||
#include "equipment_service.hpp"
|
#include "secsgem/daemon/equipment_service.hpp"
|
||||||
#include "secsgem/gem/default_handlers.hpp"
|
#include "secsgem/gem/default_handlers.hpp"
|
||||||
#include "secsgem/gem/runtime.hpp"
|
#include "secsgem/gem/runtime.hpp"
|
||||||
|
#include "secsgem/metrics/prometheus.hpp"
|
||||||
|
|
||||||
namespace gem = secsgem::gem;
|
namespace gem = secsgem::gem;
|
||||||
|
namespace metrics = secsgem::metrics;
|
||||||
|
using namespace std::chrono_literals;
|
||||||
|
|
||||||
namespace {
|
namespace {
|
||||||
std::string arg(int argc, char** argv, const std::string& key, const std::string& def) {
|
std::string arg(int argc, char** argv, const std::string& key, const std::string& def) {
|
||||||
@@ -31,9 +47,12 @@ std::string arg(int argc, char** argv, const std::string& key, const std::string
|
|||||||
} // namespace
|
} // namespace
|
||||||
|
|
||||||
int main(int argc, char** argv) {
|
int main(int argc, char** argv) {
|
||||||
const std::string hsms_port = arg(argc, argv, "--port", "5000");
|
const std::string hsms_port = arg(argc, argv, "--port", "5000");
|
||||||
const std::string grpc_addr = arg(argc, argv, "--grpc", "0.0.0.0:50051");
|
const std::string grpc_addr = arg(argc, argv, "--grpc", "127.0.0.1:50051");
|
||||||
const std::string cfgdir = arg(argc, argv, "--config-dir", "data");
|
const std::string cfgdir = arg(argc, argv, "--config-dir", "data");
|
||||||
|
const std::string spool_dir = arg(argc, argv, "--spool-dir", "");
|
||||||
|
const uint16_t metrics_port =
|
||||||
|
static_cast<uint16_t>(std::stoi(arg(argc, argv, "--metrics", "0")));
|
||||||
|
|
||||||
gem::EquipmentRuntime::Config cfg;
|
gem::EquipmentRuntime::Config cfg;
|
||||||
cfg.equipment_yaml = cfgdir + "/equipment.yaml";
|
cfg.equipment_yaml = cfgdir + "/equipment.yaml";
|
||||||
@@ -41,6 +60,7 @@ int main(int argc, char** argv) {
|
|||||||
cfg.process_job_yaml = cfgdir + "/process_job_state.yaml";
|
cfg.process_job_yaml = cfgdir + "/process_job_state.yaml";
|
||||||
cfg.control_job_yaml = cfgdir + "/control_job_state.yaml";
|
cfg.control_job_yaml = cfgdir + "/control_job_state.yaml";
|
||||||
cfg.port = static_cast<uint16_t>(std::stoi(hsms_port));
|
cfg.port = static_cast<uint16_t>(std::stoi(hsms_port));
|
||||||
|
cfg.spool_dir = spool_dir;
|
||||||
cfg.log = [](const std::string& m) { std::cout << "[gemd] " << m << std::endl; };
|
cfg.log = [](const std::string& m) { std::cout << "[gemd] " << m << std::endl; };
|
||||||
|
|
||||||
std::unique_ptr<gem::EquipmentRuntime> rt;
|
std::unique_ptr<gem::EquipmentRuntime> rt;
|
||||||
@@ -52,9 +72,50 @@ int main(int argc, char** argv) {
|
|||||||
}
|
}
|
||||||
gem::register_default_handlers(*rt);
|
gem::register_default_handlers(*rt);
|
||||||
|
|
||||||
|
// ---- observability (before run_async: observers must land first) -------
|
||||||
|
std::shared_ptr<metrics::Registry> registry;
|
||||||
|
std::shared_ptr<metrics::PrometheusServer> exporter;
|
||||||
|
std::shared_ptr<asio::steady_timer> gauge_timer;
|
||||||
|
if (metrics_port != 0) {
|
||||||
|
registry = std::make_shared<metrics::Registry>();
|
||||||
|
registry->describe("secsgem_link_selected",
|
||||||
|
"1 when an HSMS session is SELECTED, else 0",
|
||||||
|
metrics::MetricType::Gauge);
|
||||||
|
registry->describe("secsgem_control_state",
|
||||||
|
"E30 control state (0=EquipOffline 1=AttemptOnline "
|
||||||
|
"2=HostOffline 3=OnlineLocal 4=OnlineRemote)",
|
||||||
|
metrics::MetricType::Gauge);
|
||||||
|
registry->describe("secsgem_spool_depth", "Queued spool messages",
|
||||||
|
metrics::MetricType::Gauge);
|
||||||
|
rt->add_link_observer([registry](bool selected) {
|
||||||
|
registry->set_gauge("secsgem_link_selected", selected ? 1.0 : 0.0);
|
||||||
|
});
|
||||||
|
rt->add_control_state_observer(
|
||||||
|
[registry](gem::ControlState, gem::ControlState to, gem::ControlEvent) {
|
||||||
|
registry->set_gauge("secsgem_control_state", static_cast<double>(to));
|
||||||
|
});
|
||||||
|
registry->set_gauge("secsgem_link_selected", 0.0);
|
||||||
|
registry->set_gauge("secsgem_control_state",
|
||||||
|
static_cast<double>(rt->control_state()));
|
||||||
|
// Spool depth: sampled on the io thread (the model's owner).
|
||||||
|
gauge_timer = std::make_shared<asio::steady_timer>(rt->io());
|
||||||
|
auto tick = std::make_shared<std::function<void(std::error_code)>>();
|
||||||
|
*tick = [registry, gauge_timer, tick, raw = rt.get()](std::error_code ec) {
|
||||||
|
if (ec) return;
|
||||||
|
registry->set_gauge("secsgem_spool_depth",
|
||||||
|
static_cast<double>(raw->model().spool.size()));
|
||||||
|
gauge_timer->expires_after(5s);
|
||||||
|
gauge_timer->async_wait(*tick);
|
||||||
|
};
|
||||||
|
gauge_timer->expires_after(1s);
|
||||||
|
gauge_timer->async_wait(*tick);
|
||||||
|
exporter = std::make_shared<metrics::PrometheusServer>(
|
||||||
|
rt->io(), metrics_port, registry);
|
||||||
|
exporter->start();
|
||||||
|
}
|
||||||
|
|
||||||
// Construct the service before starting the io thread: its constructor
|
// Construct the service before starting the io thread: its constructor
|
||||||
// snapshots the name->id/format maps from the model (see the service's
|
// snapshots the name->id/format maps and registers its observers.
|
||||||
// threading note).
|
|
||||||
secsgem::daemon::EquipmentService service(*rt);
|
secsgem::daemon::EquipmentService service(*rt);
|
||||||
rt->run_async(); // engine + HSMS link on a background thread
|
rt->run_async(); // engine + HSMS link on a background thread
|
||||||
|
|
||||||
@@ -66,12 +127,26 @@ int main(int argc, char** argv) {
|
|||||||
std::cerr << "[gemd] failed to start gRPC server on " << grpc_addr << std::endl;
|
std::cerr << "[gemd] failed to start gRPC server on " << grpc_addr << std::endl;
|
||||||
return 1;
|
return 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Graceful shutdown: the signal lands on the io thread, which only nudges
|
||||||
|
// the gRPC server; Wait() then returns on the MAIN thread, which tears the
|
||||||
|
// engine down (rt->stop() joins the io thread — it must not run on it).
|
||||||
|
// The deadline cancels open Subscribe/WatchHealth streams instead of
|
||||||
|
// waiting for clients to hang up.
|
||||||
|
asio::signal_set signals(rt->io(), SIGINT, SIGTERM);
|
||||||
|
signals.async_wait([&server, &rt](std::error_code ec, int signo) {
|
||||||
|
if (ec) return;
|
||||||
|
rt->log("signal " + std::to_string(signo) + ": shutting down");
|
||||||
|
server->Shutdown(std::chrono::system_clock::now() + 2s);
|
||||||
|
});
|
||||||
|
|
||||||
std::cout << "[gemd] gRPC on " << grpc_addr
|
std::cout << "[gemd] gRPC on " << grpc_addr
|
||||||
|
<< (metrics_port ? "; metrics on :" + std::to_string(metrics_port) +
|
||||||
|
"/metrics"
|
||||||
|
: "")
|
||||||
<< "; HSMS equipment on :" << hsms_port << std::endl;
|
<< "; HSMS equipment on :" << hsms_port << std::endl;
|
||||||
// TODO(daemon): graceful shutdown — handle SIGTERM/SIGINT by calling
|
|
||||||
// server->Shutdown() and rt->stop() so in-flight RPCs drain and the spool
|
|
||||||
// journal flushes, instead of dying mid-write when supervised (systemd/
|
|
||||||
// docker stop). See DAEMON_ROADMAP Phase E.
|
|
||||||
server->Wait();
|
server->Wait();
|
||||||
|
rt->stop();
|
||||||
|
std::cout << "[gemd] stopped cleanly" << std::endl;
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
# secsgem-client (C++)
|
||||||
|
|
||||||
|
The C++ twin of [clients/python](../python): a header-only client for the
|
||||||
|
`secs_gemd` daemon. Name-based, plain-typed, no SEMI knowledge required.
|
||||||
|
|
||||||
|
```cpp
|
||||||
|
#include "secsgem_client/equipment.hpp"
|
||||||
|
|
||||||
|
secsgem_client::Equipment eq("localhost:50051");
|
||||||
|
eq.set("ChamberPressure", 2.5); // host sees it on its next poll
|
||||||
|
eq.fire("ProcessStarted"); // S6F11, report auto-assembled
|
||||||
|
eq.alarm("chiller_temp_high"); // S5F1 set / eq.clear(...) clears
|
||||||
|
|
||||||
|
eq.on("START", [&](const secsgem_client::Command& cmd) {
|
||||||
|
run_recipe(cmd.params.at("PPID"));
|
||||||
|
eq.fire("ProcessStarted"); // the host's completion signal
|
||||||
|
});
|
||||||
|
eq.listen(); // or listen_async() + stop()
|
||||||
|
```
|
||||||
|
|
||||||
|
Errors throw `secsgem_client::SecsGemError` carrying the daemon's
|
||||||
|
explanation. Build: include this header, compile the generated
|
||||||
|
`equipment.pb.cc` / `equipment.grpc.pb.cc` from
|
||||||
|
[proto/secsgem/v1](../../proto/secsgem/v1/equipment.proto), link `grpc++`.
|
||||||
|
In this repo the `cpp_mini_tool` CMake target (built when grpc is present)
|
||||||
|
is the worked example; out of tree, copy its four-line target definition.
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
// A complete GEM tool in ~30 lines of C++ — the twin of
|
||||||
|
// clients/python/examples/mini_tool.py. Run secs_gemd, then this.
|
||||||
|
#include <chrono>
|
||||||
|
#include <iostream>
|
||||||
|
#include <random>
|
||||||
|
#include <thread>
|
||||||
|
|
||||||
|
#include "secsgem_client/equipment.hpp"
|
||||||
|
|
||||||
|
int main() {
|
||||||
|
secsgem_client::Equipment eq("localhost:50051");
|
||||||
|
|
||||||
|
eq.on("START", [&](const secsgem_client::Command& cmd) {
|
||||||
|
std::cout << "host says START (id " << cmd.id << ")\n";
|
||||||
|
eq.fire("ProcessStarted");
|
||||||
|
});
|
||||||
|
eq.listen_async();
|
||||||
|
|
||||||
|
std::mt19937 rng{std::random_device{}()};
|
||||||
|
std::uniform_real_distribution<double> pressure(1.0, 3.0);
|
||||||
|
while (true) {
|
||||||
|
const double p = pressure(rng);
|
||||||
|
eq.set("ChamberPressure", p);
|
||||||
|
if (p > 2.9) eq.alarm("chiller_temp_high");
|
||||||
|
else eq.clear("chiller_temp_high");
|
||||||
|
std::this_thread::sleep_for(std::chrono::seconds(1));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,300 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
// secsgem_client::Equipment — drive a secs_gemd SECS/GEM daemon from C++.
|
||||||
|
//
|
||||||
|
// The C++ twin of the Python client (clients/python): name-based, plain-
|
||||||
|
// typed, no SEMI knowledge required. Header-only over the generated gRPC
|
||||||
|
// stubs; link grpc++ + the generated proto objects (see clients/cpp/README).
|
||||||
|
//
|
||||||
|
// secsgem_client::Equipment eq("localhost:50051");
|
||||||
|
// eq.set("ChamberPressure", 2.5);
|
||||||
|
// eq.fire("ProcessStarted");
|
||||||
|
// eq.on("START", [&](const secsgem_client::Command& cmd) {
|
||||||
|
// run_recipe(cmd.params.at("PPID"));
|
||||||
|
// eq.fire("ProcessStarted"); // the host's completion signal
|
||||||
|
// });
|
||||||
|
// eq.listen(); // or listen_async() + stop()
|
||||||
|
|
||||||
|
#include <grpcpp/grpcpp.h>
|
||||||
|
|
||||||
|
#include <atomic>
|
||||||
|
#include <chrono>
|
||||||
|
#include <cstdint>
|
||||||
|
#include <functional>
|
||||||
|
#include <map>
|
||||||
|
#include <memory>
|
||||||
|
#include <mutex>
|
||||||
|
#include <optional>
|
||||||
|
#include <stdexcept>
|
||||||
|
#include <string>
|
||||||
|
#include <thread>
|
||||||
|
#include <type_traits>
|
||||||
|
#include <variant>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
#include "secsgem/v1/equipment.grpc.pb.h"
|
||||||
|
#include "secsgem/v1/equipment.pb.h"
|
||||||
|
|
||||||
|
namespace secsgem_client {
|
||||||
|
|
||||||
|
namespace pb = secsgem::v1;
|
||||||
|
|
||||||
|
// Plain value: what you put in and what you get back. (Lists arrive as
|
||||||
|
// their text-free underlying values only via the daemon's scalar rules;
|
||||||
|
// nested lists are rare in tool code — extend when needed.)
|
||||||
|
using Value = std::variant<bool, int64_t, double, std::string,
|
||||||
|
std::vector<uint8_t>>;
|
||||||
|
|
||||||
|
class SecsGemError : public std::runtime_error {
|
||||||
|
public:
|
||||||
|
SecsGemError(int code, const std::string& msg)
|
||||||
|
: std::runtime_error(msg), code_(code) {}
|
||||||
|
int code() const { return code_; }
|
||||||
|
|
||||||
|
private:
|
||||||
|
int code_;
|
||||||
|
};
|
||||||
|
|
||||||
|
struct Command {
|
||||||
|
std::string id;
|
||||||
|
std::string name;
|
||||||
|
std::map<std::string, Value> params;
|
||||||
|
};
|
||||||
|
|
||||||
|
struct Health {
|
||||||
|
std::string link; // "DISCONNECTED" | "CONNECTED" | "SELECTED"
|
||||||
|
std::string control_state; // "HOST_OFFLINE" | "ONLINE_REMOTE" | ...
|
||||||
|
uint32_t spool_depth = 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
namespace detail {
|
||||||
|
|
||||||
|
inline pb::Value to_value(const Value& v) {
|
||||||
|
pb::Value out;
|
||||||
|
if (std::holds_alternative<bool>(v)) out.set_boolean(std::get<bool>(v));
|
||||||
|
else if (std::holds_alternative<int64_t>(v)) out.set_integer(std::get<int64_t>(v));
|
||||||
|
else if (std::holds_alternative<double>(v)) out.set_real(std::get<double>(v));
|
||||||
|
else if (std::holds_alternative<std::string>(v)) out.set_text(std::get<std::string>(v));
|
||||||
|
else {
|
||||||
|
const auto& b = std::get<std::vector<uint8_t>>(v);
|
||||||
|
out.set_binary(std::string(b.begin(), b.end()));
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
inline Value from_value(const pb::Value& v) {
|
||||||
|
switch (v.kind_case()) {
|
||||||
|
case pb::Value::kBoolean: return v.boolean();
|
||||||
|
case pb::Value::kInteger: return static_cast<int64_t>(v.integer());
|
||||||
|
case pb::Value::kReal: return v.real();
|
||||||
|
case pb::Value::kText: return v.text();
|
||||||
|
case pb::Value::kBinary:
|
||||||
|
return std::vector<uint8_t>(v.binary().begin(), v.binary().end());
|
||||||
|
default: return std::string{}; // unset / list: see header note
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Accept any sane C++ literal without variant-conversion ambiguity
|
||||||
|
// (e.g. a bare `7` would be ambiguous between int64_t and double).
|
||||||
|
template <typename T>
|
||||||
|
Value make_value(T&& v) {
|
||||||
|
using D = std::decay_t<T>;
|
||||||
|
if constexpr (std::is_same_v<D, bool>) return Value(v);
|
||||||
|
else if constexpr (std::is_integral_v<D>) return Value(static_cast<int64_t>(v));
|
||||||
|
else if constexpr (std::is_floating_point_v<D>) return Value(static_cast<double>(v));
|
||||||
|
else if constexpr (std::is_same_v<D, std::vector<uint8_t>>) return Value(std::forward<T>(v));
|
||||||
|
else return Value(std::string(std::forward<T>(v)));
|
||||||
|
}
|
||||||
|
|
||||||
|
inline void check(const pb::Ack& ack) {
|
||||||
|
if (ack.code() != pb::Ack::ACCEPT)
|
||||||
|
throw SecsGemError(ack.code(), ack.message().empty()
|
||||||
|
? pb::Ack::Code_Name(ack.code())
|
||||||
|
: ack.message());
|
||||||
|
}
|
||||||
|
|
||||||
|
inline void check(const grpc::Status& st) {
|
||||||
|
if (!st.ok()) throw SecsGemError(pb::Ack::PARAMETER_INVALID, st.error_message());
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace detail
|
||||||
|
|
||||||
|
class Equipment {
|
||||||
|
public:
|
||||||
|
explicit Equipment(const std::string& address = "localhost:50051",
|
||||||
|
std::chrono::seconds connect_timeout = std::chrono::seconds(10)) {
|
||||||
|
channel_ = grpc::CreateChannel(address, grpc::InsecureChannelCredentials());
|
||||||
|
if (!channel_->WaitForConnected(
|
||||||
|
std::chrono::system_clock::now() + connect_timeout))
|
||||||
|
throw SecsGemError(pb::Ack::CANNOT_DO_NOW, "no daemon at " + address);
|
||||||
|
stub_ = pb::Equipment::NewStub(channel_);
|
||||||
|
}
|
||||||
|
|
||||||
|
~Equipment() { stop(); }
|
||||||
|
|
||||||
|
// ---- report state to the host -------------------------------------------
|
||||||
|
|
||||||
|
template <typename T>
|
||||||
|
void set(const std::string& name, T&& value) {
|
||||||
|
set({{name, detail::make_value(std::forward<T>(value))}});
|
||||||
|
}
|
||||||
|
|
||||||
|
void set(const std::map<std::string, Value>& values) {
|
||||||
|
pb::VariableUpdate req;
|
||||||
|
for (const auto& [name, v] : values)
|
||||||
|
(*req.mutable_values())[name] = detail::to_value(v);
|
||||||
|
pb::Ack ack;
|
||||||
|
grpc::ClientContext ctx;
|
||||||
|
detail::check(stub_->SetVariables(&ctx, req, &ack));
|
||||||
|
detail::check(ack);
|
||||||
|
}
|
||||||
|
|
||||||
|
// No names = every configured variable.
|
||||||
|
std::map<std::string, Value> get(const std::vector<std::string>& names = {}) {
|
||||||
|
pb::VariableQuery req;
|
||||||
|
for (const auto& n : names) req.add_names(n);
|
||||||
|
pb::VariableSnapshot snap;
|
||||||
|
grpc::ClientContext ctx;
|
||||||
|
detail::check(stub_->GetVariables(&ctx, req, &snap));
|
||||||
|
std::map<std::string, Value> out;
|
||||||
|
for (const auto& [k, v] : snap.values()) out.emplace(k, detail::from_value(v));
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
void fire(const std::string& event,
|
||||||
|
const std::map<std::string, Value>& data = {}) {
|
||||||
|
pb::Event req;
|
||||||
|
req.set_name(event);
|
||||||
|
for (const auto& [name, v] : data)
|
||||||
|
(*req.mutable_data())[name] = detail::to_value(v);
|
||||||
|
pb::Ack ack;
|
||||||
|
grpc::ClientContext ctx;
|
||||||
|
detail::check(stub_->FireEvent(&ctx, req, &ack));
|
||||||
|
detail::check(ack);
|
||||||
|
}
|
||||||
|
|
||||||
|
void alarm(const std::string& name) { alarm_action(name, /*set=*/true); }
|
||||||
|
void clear(const std::string& name) { alarm_action(name, /*set=*/false); }
|
||||||
|
|
||||||
|
// ---- control state & health ----------------------------------------------
|
||||||
|
|
||||||
|
std::string control_state() {
|
||||||
|
pb::Empty req;
|
||||||
|
pb::ControlState resp;
|
||||||
|
grpc::ClientContext ctx;
|
||||||
|
detail::check(stub_->GetControlState(&ctx, req, &resp));
|
||||||
|
return pb::ControlState::State_Name(resp.state());
|
||||||
|
}
|
||||||
|
|
||||||
|
void request_control_state(const std::string& desired) {
|
||||||
|
pb::ControlState::State s;
|
||||||
|
if (!pb::ControlState::State_Parse(desired, &s))
|
||||||
|
throw SecsGemError(pb::Ack::PARAMETER_INVALID,
|
||||||
|
"unknown control state '" + desired + "'");
|
||||||
|
pb::ControlStateRequest req;
|
||||||
|
req.set_desired(s);
|
||||||
|
pb::Ack ack;
|
||||||
|
grpc::ClientContext ctx;
|
||||||
|
detail::check(stub_->RequestControlState(&ctx, req, &ack));
|
||||||
|
detail::check(ack);
|
||||||
|
}
|
||||||
|
|
||||||
|
Health health() {
|
||||||
|
pb::Empty req;
|
||||||
|
grpc::ClientContext ctx;
|
||||||
|
auto reader = stub_->WatchHealth(&ctx, req);
|
||||||
|
pb::Health h;
|
||||||
|
if (!reader->Read(&h))
|
||||||
|
throw SecsGemError(pb::Ack::CANNOT_DO_NOW, "health stream ended");
|
||||||
|
ctx.TryCancel();
|
||||||
|
pb::Health drain;
|
||||||
|
while (reader->Read(&drain)) {}
|
||||||
|
(void)reader->Finish(); // CANCELLED — expected
|
||||||
|
return {pb::Health::LinkState_Name(h.link()),
|
||||||
|
pb::ControlState::State_Name(h.control_state()), h.spool_depth()};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- react to the host -----------------------------------------------------
|
||||||
|
|
||||||
|
void on(const std::string& command, std::function<void(const Command&)> fn) {
|
||||||
|
handlers_[command] = std::move(fn);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Consume host requests and dispatch to on() handlers. Blocks until stop().
|
||||||
|
void listen() {
|
||||||
|
grpc::ClientContext ctx;
|
||||||
|
{
|
||||||
|
std::lock_guard<std::mutex> lk(listen_mu_);
|
||||||
|
listen_ctx_ = &ctx;
|
||||||
|
}
|
||||||
|
pb::SubscribeRequest req;
|
||||||
|
req.set_client("secsgem_client_cpp");
|
||||||
|
auto reader = stub_->Subscribe(&ctx, req);
|
||||||
|
pb::HostRequest hr;
|
||||||
|
while (!stopping_.load() && reader->Read(&hr)) {
|
||||||
|
if (!hr.has_command()) continue; // future HostRequest variants
|
||||||
|
const auto& c = hr.command();
|
||||||
|
Command cmd{c.id(), c.name(), {}};
|
||||||
|
for (const auto& [k, v] : c.params()) cmd.params.emplace(k, detail::from_value(v));
|
||||||
|
auto it = handlers_.find(cmd.name);
|
||||||
|
if (it == handlers_.end()) it = handlers_.find("*");
|
||||||
|
bool ok = true;
|
||||||
|
if (it != handlers_.end()) {
|
||||||
|
try {
|
||||||
|
it->second(cmd);
|
||||||
|
} catch (...) {
|
||||||
|
ok = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
complete(cmd.id, ok);
|
||||||
|
}
|
||||||
|
(void)reader->Finish();
|
||||||
|
{
|
||||||
|
std::lock_guard<std::mutex> lk(listen_mu_);
|
||||||
|
listen_ctx_ = nullptr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void listen_async() {
|
||||||
|
listen_thread_ = std::thread([this] { listen(); });
|
||||||
|
}
|
||||||
|
|
||||||
|
void stop() {
|
||||||
|
stopping_.store(true);
|
||||||
|
{
|
||||||
|
std::lock_guard<std::mutex> lk(listen_mu_);
|
||||||
|
if (listen_ctx_) listen_ctx_->TryCancel();
|
||||||
|
}
|
||||||
|
if (listen_thread_.joinable()) listen_thread_.join();
|
||||||
|
}
|
||||||
|
|
||||||
|
private:
|
||||||
|
void alarm_action(const std::string& name, bool set) {
|
||||||
|
pb::Alarm req;
|
||||||
|
req.set_name(name);
|
||||||
|
pb::Ack ack;
|
||||||
|
grpc::ClientContext ctx;
|
||||||
|
detail::check(set ? stub_->SetAlarm(&ctx, req, &ack)
|
||||||
|
: stub_->ClearAlarm(&ctx, req, &ack));
|
||||||
|
detail::check(ack);
|
||||||
|
}
|
||||||
|
|
||||||
|
void complete(const std::string& id, bool ok) {
|
||||||
|
pb::CommandResult res;
|
||||||
|
res.set_id(id);
|
||||||
|
res.mutable_ack()->set_code(ok ? pb::Ack::ACCEPT : pb::Ack::REJECTED);
|
||||||
|
pb::Ack ack;
|
||||||
|
grpc::ClientContext ctx;
|
||||||
|
(void)stub_->CompleteCommand(&ctx, res, &ack); // audit-only
|
||||||
|
}
|
||||||
|
|
||||||
|
std::shared_ptr<grpc::Channel> channel_;
|
||||||
|
std::unique_ptr<pb::Equipment::Stub> stub_;
|
||||||
|
std::map<std::string, std::function<void(const Command&)>> handlers_;
|
||||||
|
std::thread listen_thread_;
|
||||||
|
std::atomic<bool> stopping_{false};
|
||||||
|
std::mutex listen_mu_;
|
||||||
|
grpc::ClientContext* listen_ctx_ = nullptr;
|
||||||
|
};
|
||||||
|
|
||||||
|
} // namespace secsgem_client
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
# secsgem-client
|
||||||
|
|
||||||
|
A complete GEM tool integration in plain Python. The
|
||||||
|
[`secs_gemd`](../../docs/DAEMON_ROADMAP.md) daemon owns everything SEMI —
|
||||||
|
the HSMS link to the host, the GEM state machines, formats, timers,
|
||||||
|
spooling; this client tells it about your tool and reacts to the host.
|
||||||
|
|
||||||
|
```python
|
||||||
|
from secsgem_client import Equipment
|
||||||
|
|
||||||
|
eq = Equipment("localhost:50051")
|
||||||
|
|
||||||
|
eq.set(ChamberPressure=2.5) # host sees it on its next poll
|
||||||
|
eq.fire("ProcessStarted") # S6F11 to the host, report auto-assembled
|
||||||
|
eq.alarm("chiller_temp_high") # S5F1 (set), eq.clear(...) for clear
|
||||||
|
|
||||||
|
@eq.on("START") # host remote commands -> your function
|
||||||
|
def start(cmd):
|
||||||
|
run_recipe(cmd.params.get("PPID"))
|
||||||
|
eq.fire("ProcessStarted") # the host's real completion signal
|
||||||
|
|
||||||
|
eq.listen() # block and dispatch (background=True for a thread)
|
||||||
|
```
|
||||||
|
|
||||||
|
Names are the ones from your `equipment.yaml`; values are plain Python
|
||||||
|
(`float`, `int`, `bool`, `str`, `bytes`, lists). Errors raise
|
||||||
|
`SecsGemError` with the daemon's explanation ("no variable named ...").
|
||||||
|
|
||||||
|
No compiled extension, no SEMI knowledge, no C++ toolchain — `pip install`
|
||||||
|
and a running daemon is the whole setup.
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""A complete GEM tool in ~25 lines.
|
||||||
|
|
||||||
|
Run secs_gemd, then this. The fab host can poll ChamberPressure, receive
|
||||||
|
ProcessStarted events, get alarms above the threshold, and START the tool —
|
||||||
|
all SEMI plumbing handled by the daemon.
|
||||||
|
"""
|
||||||
|
import random
|
||||||
|
import time
|
||||||
|
|
||||||
|
from secsgem_client import Equipment
|
||||||
|
|
||||||
|
eq = Equipment("localhost:50051")
|
||||||
|
|
||||||
|
|
||||||
|
@eq.command
|
||||||
|
def START(cmd):
|
||||||
|
print("host says START", cmd.params)
|
||||||
|
eq.fire(eq.names.event.ProcessStarted)
|
||||||
|
|
||||||
|
|
||||||
|
eq.listen(background=True)
|
||||||
|
|
||||||
|
while True:
|
||||||
|
pressure = round(random.uniform(1.0, 3.0), 3)
|
||||||
|
eq.set(ChamberPressure=pressure)
|
||||||
|
if pressure > 2.9:
|
||||||
|
eq.alarm(eq.names.alarm.chiller_temp_high)
|
||||||
|
else:
|
||||||
|
eq.clear(eq.names.alarm.chiller_temp_high)
|
||||||
|
time.sleep(1)
|
||||||
|
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""A cluster tool that tracks material — E90 wafers + E157 modules.
|
||||||
|
|
||||||
|
Where mini_tool.py is the bare quickstart, this shows the *material-tracking*
|
||||||
|
side: when the host issues START, we run one wafer through one process module
|
||||||
|
and report every milestone. The daemon turns each report into the standard
|
||||||
|
GEM 300 collection events the host has subscribed to — we never touch a CEID.
|
||||||
|
|
||||||
|
build/secs_gemd --port 5000 --config-dir data # then run this
|
||||||
|
|
||||||
|
The host sees, per wafer: SubstrateArrived, module GeneralExecuting, the wafer
|
||||||
|
Acquired, the step running, processing start/stop, step complete, the wafer
|
||||||
|
delivered, and the module back to idle — the complete in-flight trace.
|
||||||
|
"""
|
||||||
|
import time
|
||||||
|
|
||||||
|
from secsgem_client import Equipment, Milestone, ModuleState
|
||||||
|
|
||||||
|
MODULE = "CHAMBER-A"
|
||||||
|
|
||||||
|
|
||||||
|
def run_wafer(eq: Equipment, wafer: str, carrier: str, slot: int) -> None:
|
||||||
|
"""One wafer's journey through one module. Milestone / ModuleState are
|
||||||
|
importable enums — autocomplete-friendly and typo-checked — but plain
|
||||||
|
strings ("ARRIVED", "STEP_EXECUTING") work identically if you prefer."""
|
||||||
|
eq.report_substrate(wafer, Milestone.ARRIVED, carrier_id=carrier, slot=slot)
|
||||||
|
eq.report_module(MODULE, ModuleState.GENERAL_EXECUTING) # module spins up
|
||||||
|
|
||||||
|
eq.report_substrate(wafer, Milestone.AT_WORK) # robot loads wafer
|
||||||
|
eq.report_module(MODULE, ModuleState.STEP_EXECUTING) # recipe step begins
|
||||||
|
eq.report_substrate(wafer, Milestone.PROCESSING)
|
||||||
|
eq.fire(eq.names.event.ProcessStarted)
|
||||||
|
|
||||||
|
time.sleep(1) # ...the actual process...
|
||||||
|
|
||||||
|
eq.report_substrate(wafer, Milestone.PROCESSED)
|
||||||
|
eq.report_module(MODULE, ModuleState.STEP_COMPLETED)
|
||||||
|
eq.report_substrate(wafer, Milestone.AT_DESTINATION) # robot unloads wafer
|
||||||
|
eq.report_module(MODULE, ModuleState.NOT_EXECUTING) # module idle again
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
# Context manager: the gRPC channel is closed for you on exit.
|
||||||
|
with Equipment("localhost:50051") as eq:
|
||||||
|
|
||||||
|
@eq.command # bound by name, validated against the daemon
|
||||||
|
def START(cmd): # the host's S2F41 START lands here
|
||||||
|
carrier = cmd.params.get("CARRIER", "FOUP-1")
|
||||||
|
for slot in range(1, 4): # three wafers out of the FOUP
|
||||||
|
run_wafer(eq, f"{carrier}-W{slot}", carrier, slot)
|
||||||
|
|
||||||
|
eq.listen() # blocks, dispatching host commands
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
[build-system]
|
||||||
|
requires = ["setuptools>=61"]
|
||||||
|
build-backend = "setuptools.build_meta"
|
||||||
|
|
||||||
|
[project]
|
||||||
|
name = "secsgem-client"
|
||||||
|
version = "0.1.0"
|
||||||
|
description = "Drive a secs_gemd SECS/GEM equipment daemon from plain Python"
|
||||||
|
readme = "README.md"
|
||||||
|
requires-python = ">=3.9"
|
||||||
|
dependencies = ["grpcio>=1.50", "protobuf>=4.21"]
|
||||||
|
|
||||||
|
[tool.setuptools.packages.find]
|
||||||
|
include = ["secsgem_client*"]
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
"""secsgem_client — drive a SECS/GEM equipment daemon from plain Python.
|
||||||
|
|
||||||
|
The secs_gemd daemon speaks SEMI (HSMS, GEM state machines, SECS-II) to the
|
||||||
|
fab host; this client speaks plain Python to the daemon. You never touch a
|
||||||
|
stream/function, a format code, or a numeric id — just the names from your
|
||||||
|
equipment.yaml.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from ._client import Command, Equipment, Health, ProcessJob, SecsGemError
|
||||||
|
from ._enums import JobState, Milestone, ModuleState
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"Equipment", "Command", "Health", "ProcessJob", "SecsGemError",
|
||||||
|
"Milestone", "ModuleState", "JobState",
|
||||||
|
]
|
||||||
|
__version__ = "0.1.0"
|
||||||
@@ -0,0 +1,376 @@
|
|||||||
|
"""The Equipment client: a complete GEM tool integration in plain Python.
|
||||||
|
|
||||||
|
The secs_gemd daemon owns everything SEMI — the HSMS link to the host, the
|
||||||
|
GEM state machines, message formats, timers, spooling. This client only ever
|
||||||
|
does two things: tell the equipment about itself (variables, events, alarms)
|
||||||
|
and react to what the host asks (commands). Names are the ones from your
|
||||||
|
equipment.yaml; values are plain Python.
|
||||||
|
|
||||||
|
from secsgem_client import Equipment
|
||||||
|
|
||||||
|
eq = Equipment("localhost:50051")
|
||||||
|
eq.set(ChamberPressure=2.5)
|
||||||
|
eq.fire(eq.names.event.ProcessStarted) # typo-safe; plain strings also work
|
||||||
|
|
||||||
|
@eq.command # function name IS the RCMD name,
|
||||||
|
def START(cmd): # validated against equipment at import
|
||||||
|
run_recipe(cmd.params.get("PPID"))
|
||||||
|
eq.fire(eq.names.event.ProcessStarted)
|
||||||
|
|
||||||
|
eq.listen() # blocks, dispatching host commands to your handlers
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import difflib
|
||||||
|
import enum
|
||||||
|
import threading
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from types import SimpleNamespace
|
||||||
|
from typing import Callable, Dict, Iterator, Optional, Union
|
||||||
|
|
||||||
|
import grpc
|
||||||
|
|
||||||
|
from ._enums import JobState, Milestone, ModuleState
|
||||||
|
from ._proto import equipment_pb2 as pb
|
||||||
|
from ._proto import equipment_pb2_grpc as rpc
|
||||||
|
from ._values import from_value, to_value
|
||||||
|
|
||||||
|
|
||||||
|
class SecsGemError(RuntimeError):
|
||||||
|
"""The daemon declined a request (unknown name, bad value, wrong state)."""
|
||||||
|
|
||||||
|
def __init__(self, code: int, message: str):
|
||||||
|
super().__init__(message or pb.Ack.Code.Name(code))
|
||||||
|
self.code = code
|
||||||
|
|
||||||
|
|
||||||
|
def _check(ack: pb.Ack) -> None:
|
||||||
|
if ack.code != pb.Ack.ACCEPT:
|
||||||
|
raise SecsGemError(ack.code, ack.message)
|
||||||
|
|
||||||
|
|
||||||
|
def _enum_value(proto_enum, value, kind: str) -> int:
|
||||||
|
"""Resolve a protocol-enum argument (an enum member like Milestone.ARRIVED
|
||||||
|
or a plain string like "ARRIVED") to its protobuf int, typo-safely — the
|
||||||
|
same close-match courtesy as eq.names, instead of a raw protobuf error."""
|
||||||
|
name = value.value if isinstance(value, enum.Enum) else str(value)
|
||||||
|
try:
|
||||||
|
return proto_enum.Value(name)
|
||||||
|
except ValueError:
|
||||||
|
valid = list(proto_enum.keys())
|
||||||
|
close = difflib.get_close_matches(name, valid, n=3)
|
||||||
|
hint = (f" Did you mean {', '.join(close)}?" if close
|
||||||
|
else f" Valid: {', '.join(valid)}.")
|
||||||
|
raise ValueError(f"unknown {kind} '{name}'.{hint}") from None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Command:
|
||||||
|
"""A remote command from the host (e.g. START). The host has already been
|
||||||
|
told "accepted, will finish later" — report the real outcome by firing an
|
||||||
|
event (success) or raising an alarm (failure) from your handler."""
|
||||||
|
|
||||||
|
id: str
|
||||||
|
name: str
|
||||||
|
params: Dict[str, object]
|
||||||
|
_eq: "Equipment"
|
||||||
|
|
||||||
|
def done(self, ok: bool = True) -> None:
|
||||||
|
"""Mark the command complete (for the daemon's audit trail). Called
|
||||||
|
automatically after your handler returns; call early if you want."""
|
||||||
|
self._eq._complete(self.id, ok)
|
||||||
|
self._done = True
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ProcessJob:
|
||||||
|
"""An E40 process job the host wants run (or stopped/aborted). Do the
|
||||||
|
physical work, then report progress with Equipment.report_job()."""
|
||||||
|
|
||||||
|
id: str
|
||||||
|
action: str # "START" | "STOP" | "PAUSE" | "RESUME" | "ABORT"
|
||||||
|
recipe: str
|
||||||
|
carriers: list
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Health:
|
||||||
|
link: str # "DISCONNECTED" | "CONNECTED" | "SELECTED"
|
||||||
|
control_state: str # "HOST_OFFLINE" | "ONLINE_REMOTE" | ...
|
||||||
|
spool_depth: int
|
||||||
|
|
||||||
|
|
||||||
|
class _Names:
|
||||||
|
"""An autocomplete-able, typo-safe view of the equipment's real names
|
||||||
|
(from Describe). `eq.names.event.ProcessStarted` returns "ProcessStarted";
|
||||||
|
a wrong name raises AttributeError with close-match suggestions, and the
|
||||||
|
set shows up in REPL/IDE completion via __dir__."""
|
||||||
|
|
||||||
|
def __init__(self, kind: str, names):
|
||||||
|
self._kind = kind
|
||||||
|
self._names = set(names)
|
||||||
|
|
||||||
|
def __getattr__(self, attr: str) -> str:
|
||||||
|
if attr in self.__dict__.get("_names", ()):
|
||||||
|
return attr
|
||||||
|
close = difflib.get_close_matches(attr, self._names, n=3)
|
||||||
|
hint = f" Did you mean {', '.join(close)}?" if close else ""
|
||||||
|
raise AttributeError(
|
||||||
|
f"no {self._kind} named '{attr}'.{hint}")
|
||||||
|
|
||||||
|
def __dir__(self):
|
||||||
|
return sorted(self._names)
|
||||||
|
|
||||||
|
def __contains__(self, name: str) -> bool:
|
||||||
|
return name in self._names
|
||||||
|
|
||||||
|
|
||||||
|
class Equipment:
|
||||||
|
"""A connection to the secs_gemd daemon — your equipment, by name."""
|
||||||
|
|
||||||
|
def __init__(self, address: str = "localhost:50051",
|
||||||
|
connect_timeout: float = 10.0):
|
||||||
|
self._channel = grpc.insecure_channel(address)
|
||||||
|
grpc.channel_ready_future(self._channel).result(timeout=connect_timeout)
|
||||||
|
self._stub = rpc.EquipmentStub(self._channel)
|
||||||
|
self._handlers: Dict[str, Callable[[Command], object]] = {}
|
||||||
|
self._job_handler: Optional[Callable[[ProcessJob], object]] = None
|
||||||
|
self._recipe_handler: Optional[Callable[[str, bytes], object]] = None
|
||||||
|
self._constant_handler: Optional[Callable[[str, object], object]] = None
|
||||||
|
self._listen_thread: Optional[threading.Thread] = None
|
||||||
|
self._stop = threading.Event()
|
||||||
|
self._names_cache: Optional[SimpleNamespace] = None
|
||||||
|
|
||||||
|
# ---- report state to the host -------------------------------------------
|
||||||
|
|
||||||
|
def set(self, values: Optional[Dict[str, object]] = None, **kwargs) -> None:
|
||||||
|
"""Update status/data variables by name: eq.set(ChamberPressure=2.5).
|
||||||
|
The host sees the new values immediately when it polls."""
|
||||||
|
merged = dict(values or {})
|
||||||
|
merged.update(kwargs)
|
||||||
|
req = pb.VariableUpdate()
|
||||||
|
for name, v in merged.items():
|
||||||
|
req.values[name].CopyFrom(to_value(v))
|
||||||
|
_check(self._stub.SetVariables(req))
|
||||||
|
|
||||||
|
def get(self, *names: str) -> Dict[str, object]:
|
||||||
|
"""Read variables back from the equipment. No names = all of them."""
|
||||||
|
try:
|
||||||
|
snap = self._stub.GetVariables(pb.VariableQuery(names=list(names)))
|
||||||
|
except grpc.RpcError as e:
|
||||||
|
raise SecsGemError(pb.Ack.PARAMETER_INVALID, e.details()) from None
|
||||||
|
return {k: from_value(v) for k, v in snap.values.items()}
|
||||||
|
|
||||||
|
@property
|
||||||
|
def names(self) -> SimpleNamespace:
|
||||||
|
"""The equipment's real names, by category, fetched once from the
|
||||||
|
daemon (Describe): eq.names.event.* / .alarm.* / .command.* / .var.* /
|
||||||
|
.constant.*. Autocomplete-friendly and typo-safe — handy instead of
|
||||||
|
bare string literals."""
|
||||||
|
if self._names_cache is None:
|
||||||
|
d = self._stub.Describe(pb.Empty())
|
||||||
|
self._names_cache = SimpleNamespace(
|
||||||
|
var=_Names("variable", d.variables),
|
||||||
|
event=_Names("event", d.events),
|
||||||
|
alarm=_Names("alarm", d.alarms),
|
||||||
|
command=_Names("command", d.commands),
|
||||||
|
constant=_Names("constant", d.constants))
|
||||||
|
return self._names_cache
|
||||||
|
|
||||||
|
def __setitem__(self, name: str, value) -> None:
|
||||||
|
self.set({name: value})
|
||||||
|
|
||||||
|
def __getitem__(self, name: str):
|
||||||
|
return self.get(name)[name]
|
||||||
|
|
||||||
|
def fire(self, event: str, **data) -> None:
|
||||||
|
"""Fire a collection event by name; the daemon assembles the configured
|
||||||
|
report and sends it to the host. kwargs set variable values for this
|
||||||
|
event first: eq.fire("WaferComplete", Thickness=1.2)."""
|
||||||
|
req = pb.Event(name=event)
|
||||||
|
for name, v in data.items():
|
||||||
|
req.data[name].CopyFrom(to_value(v))
|
||||||
|
_check(self._stub.FireEvent(req))
|
||||||
|
|
||||||
|
def alarm(self, name: str) -> None:
|
||||||
|
"""Raise an alarm by its config name (or stringified ALID)."""
|
||||||
|
_check(self._stub.SetAlarm(pb.Alarm(name=name)))
|
||||||
|
|
||||||
|
def clear(self, name: str) -> None:
|
||||||
|
"""Clear a previously raised alarm."""
|
||||||
|
_check(self._stub.ClearAlarm(pb.Alarm(name=name)))
|
||||||
|
|
||||||
|
# ---- control state & health ---------------------------------------------
|
||||||
|
|
||||||
|
@property
|
||||||
|
def control_state(self) -> str:
|
||||||
|
"""The GEM control state, e.g. "ONLINE_REMOTE"."""
|
||||||
|
cs = self._stub.GetControlState(pb.Empty())
|
||||||
|
return pb.ControlState.State.Name(cs.state)
|
||||||
|
|
||||||
|
def request_control_state(self, desired: str) -> None:
|
||||||
|
"""Operator-panel transition, e.g. eq.request_control_state("HOST_OFFLINE")
|
||||||
|
before maintenance. Raises if the E30 table says no from here."""
|
||||||
|
req = pb.ControlStateRequest(
|
||||||
|
desired=_enum_value(pb.ControlState.State, desired, "control state"))
|
||||||
|
_check(self._stub.RequestControlState(req))
|
||||||
|
|
||||||
|
def health(self) -> Health:
|
||||||
|
"""One health snapshot (link / control state / spool depth)."""
|
||||||
|
for h in self._stub.WatchHealth(pb.Empty()):
|
||||||
|
return Health(pb.Health.LinkState.Name(h.link),
|
||||||
|
pb.ControlState.State.Name(h.control_state),
|
||||||
|
h.spool_depth)
|
||||||
|
raise SecsGemError(pb.Ack.CANNOT_DO_NOW, "health stream ended")
|
||||||
|
|
||||||
|
def watch_health(self) -> Iterator[Health]:
|
||||||
|
"""Yields a Health snapshot on every link/state change."""
|
||||||
|
for h in self._stub.WatchHealth(pb.Empty()):
|
||||||
|
yield Health(pb.Health.LinkState.Name(h.link),
|
||||||
|
pb.ControlState.State.Name(h.control_state),
|
||||||
|
h.spool_depth)
|
||||||
|
|
||||||
|
# ---- react to the host ----------------------------------------------------
|
||||||
|
|
||||||
|
def on(self, command: str):
|
||||||
|
"""Decorator: run this function when the host sends `command`.
|
||||||
|
Use "*" to catch commands with no specific handler."""
|
||||||
|
|
||||||
|
def register(fn: Callable[[Command], object]):
|
||||||
|
self._handlers[command] = fn
|
||||||
|
return fn
|
||||||
|
|
||||||
|
return register
|
||||||
|
|
||||||
|
def command(self, fn: Callable[[Command], object]):
|
||||||
|
"""Bind a handler to the host command of the SAME NAME as the
|
||||||
|
function — no string literal:
|
||||||
|
|
||||||
|
@eq.command
|
||||||
|
def START(cmd): ... # binds the "START" RCMD
|
||||||
|
|
||||||
|
The function name is validated against the equipment's real command
|
||||||
|
set (via Describe), so a typo fails loudly at decoration time."""
|
||||||
|
name = fn.__name__
|
||||||
|
if name not in self.names.command:
|
||||||
|
close = difflib.get_close_matches(name, dir(self.names.command), n=3)
|
||||||
|
hint = f" Did you mean {', '.join(close)}?" if close else ""
|
||||||
|
raise NameError(f"no host command '{name}' to bind @eq.command to.{hint}")
|
||||||
|
self._handlers[name] = fn
|
||||||
|
return fn
|
||||||
|
|
||||||
|
def on_process_job(self, fn: Callable[[ProcessJob], object]):
|
||||||
|
"""Decorator: the host wants a process job run/stopped (E40).
|
||||||
|
Report progress with report_job()."""
|
||||||
|
self._job_handler = fn
|
||||||
|
return fn
|
||||||
|
|
||||||
|
def on_recipe(self, fn: Callable[[str, bytes], object]):
|
||||||
|
"""Decorator: the host downloaded a recipe (ppid, body)."""
|
||||||
|
self._recipe_handler = fn
|
||||||
|
return fn
|
||||||
|
|
||||||
|
def on_constant_change(self, fn: Callable[[str, object], object]):
|
||||||
|
"""Decorator: the host changed an equipment constant (name, value)."""
|
||||||
|
self._constant_handler = fn
|
||||||
|
return fn
|
||||||
|
|
||||||
|
def report_job(self, job_id: str, state: Union[JobState, str]) -> None:
|
||||||
|
"""Report physical job progress (E40). state: a JobState or its name —
|
||||||
|
SETTING_UP | PROCESSING | COMPLETE | ABORTED. The daemon drives the E40
|
||||||
|
state machine and notifies the host (S16F9)."""
|
||||||
|
req = pb.ProcessJobState(
|
||||||
|
job_id=job_id,
|
||||||
|
state=_enum_value(pb.ProcessJobState.State, state, "job state"))
|
||||||
|
_check(self._stub.ReportProcessJob(req))
|
||||||
|
|
||||||
|
def report_substrate(self, substrate_id: str,
|
||||||
|
milestone: Union[Milestone, str],
|
||||||
|
carrier_id: str = "", slot: int = 0) -> None:
|
||||||
|
"""E90 wafer tracking. milestone: a Milestone or its name — ARRIVED |
|
||||||
|
AT_WORK | PROCESSING | PROCESSED | AT_DESTINATION. ARRIVED records the
|
||||||
|
origin (carrier_id, slot) and creates the wafer; the rest drive its
|
||||||
|
E90 FSMs, and the daemon emits the standard CEIDs to the host."""
|
||||||
|
req = pb.SubstrateReport(
|
||||||
|
substrate_id=substrate_id,
|
||||||
|
milestone=_enum_value(pb.SubstrateReport.Milestone, milestone, "milestone"),
|
||||||
|
carrier_id=carrier_id, slot=slot)
|
||||||
|
_check(self._stub.ReportSubstrate(req))
|
||||||
|
|
||||||
|
def report_module(self, module_id: str,
|
||||||
|
state: Union[ModuleState, str]) -> None:
|
||||||
|
"""E157 module tracking. state: a ModuleState or its name —
|
||||||
|
NOT_EXECUTING | GENERAL_EXECUTING | STEP_EXECUTING | STEP_COMPLETED.
|
||||||
|
Auto-creates the module on first report; NOT_EXECUTING resets it."""
|
||||||
|
req = pb.ModuleReport(
|
||||||
|
module_id=module_id,
|
||||||
|
state=_enum_value(pb.ModuleReport.State, state, "module state"))
|
||||||
|
_check(self._stub.ReportModule(req))
|
||||||
|
|
||||||
|
def listen(self, background: bool = False) -> None:
|
||||||
|
"""Consume host requests and dispatch them to @eq.on handlers.
|
||||||
|
Blocks; pass background=True to run on a daemon thread instead."""
|
||||||
|
if background:
|
||||||
|
self._listen_thread = threading.Thread(target=self._listen_loop,
|
||||||
|
daemon=True)
|
||||||
|
self._listen_thread.start()
|
||||||
|
return
|
||||||
|
self._listen_loop()
|
||||||
|
|
||||||
|
def close(self) -> None:
|
||||||
|
self._stop.set()
|
||||||
|
self._channel.close()
|
||||||
|
|
||||||
|
def __enter__(self) -> "Equipment":
|
||||||
|
return self
|
||||||
|
|
||||||
|
def __exit__(self, *exc) -> bool:
|
||||||
|
self.close()
|
||||||
|
return False
|
||||||
|
|
||||||
|
# ---- internals -------------------------------------------------------------
|
||||||
|
|
||||||
|
def _listen_loop(self) -> None:
|
||||||
|
try:
|
||||||
|
for hr in self._stub.Subscribe(pb.SubscribeRequest(client="secsgem_client")):
|
||||||
|
if self._stop.is_set():
|
||||||
|
return
|
||||||
|
if hr.HasField("process_job") and self._job_handler:
|
||||||
|
j = hr.process_job
|
||||||
|
self._job_handler(ProcessJob(
|
||||||
|
j.job_id, pb.ProcessJob.Action.Name(j.action),
|
||||||
|
j.recipe, list(j.carriers)))
|
||||||
|
continue
|
||||||
|
if hr.HasField("process_program") and self._recipe_handler:
|
||||||
|
self._recipe_handler(hr.process_program.ppid,
|
||||||
|
hr.process_program.body)
|
||||||
|
continue
|
||||||
|
if hr.HasField("constant") and self._constant_handler:
|
||||||
|
self._constant_handler(hr.constant.name,
|
||||||
|
from_value(hr.constant.value))
|
||||||
|
continue
|
||||||
|
if not hr.HasField("command"):
|
||||||
|
continue # carriers etc.: future variants
|
||||||
|
cmd = hr.command
|
||||||
|
handler = self._handlers.get(cmd.name) or self._handlers.get("*")
|
||||||
|
command = Command(cmd.id, cmd.name,
|
||||||
|
{k: from_value(v) for k, v in cmd.params.items()},
|
||||||
|
self)
|
||||||
|
ok = True
|
||||||
|
try:
|
||||||
|
if handler is not None:
|
||||||
|
handler(command)
|
||||||
|
except Exception:
|
||||||
|
ok = False
|
||||||
|
raise
|
||||||
|
finally:
|
||||||
|
if not getattr(command, "_done", False):
|
||||||
|
self._complete(cmd.id, ok)
|
||||||
|
except grpc.RpcError:
|
||||||
|
if not self._stop.is_set():
|
||||||
|
raise
|
||||||
|
|
||||||
|
def _complete(self, command_id: str, ok: bool) -> None:
|
||||||
|
ack = pb.Ack(code=pb.Ack.ACCEPT if ok else pb.Ack.REJECTED)
|
||||||
|
self._stub.CompleteCommand(pb.CommandResult(id=command_id, ack=ack))
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
"""The fixed protocol enums you pass to the report_* / control methods.
|
||||||
|
|
||||||
|
Unlike the equipment's *names* (events, alarms, commands — those vary per
|
||||||
|
tool and live on ``eq.names``), these value sets are fixed by the SEMI
|
||||||
|
standards, so they ship as importable enums:
|
||||||
|
|
||||||
|
from secsgem_client import Equipment, Milestone, ModuleState
|
||||||
|
|
||||||
|
eq.report_substrate("WFR-1", Milestone.ARRIVED) # autocomplete + typo-safe
|
||||||
|
eq.report_substrate("WFR-1", "ARRIVED") # plain string also works
|
||||||
|
|
||||||
|
Each member *is* its wire name (``Milestone.ARRIVED == "ARRIVED"``), so the
|
||||||
|
two forms are interchangeable; the enums simply give you IDE autocomplete and
|
||||||
|
a typo-checked happy path. A wrong string raises ``ValueError`` with a
|
||||||
|
close-match suggestion (see ``_client._enum_value``).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import enum
|
||||||
|
|
||||||
|
|
||||||
|
class Milestone(str, enum.Enum):
|
||||||
|
"""E90 substrate (wafer) lifecycle milestones — for ``eq.report_substrate``."""
|
||||||
|
|
||||||
|
ARRIVED = "ARRIVED" # created at its source carrier slot
|
||||||
|
AT_WORK = "AT_WORK" # picked up for processing
|
||||||
|
PROCESSING = "PROCESSING" # recipe step started
|
||||||
|
PROCESSED = "PROCESSED" # recipe step finished
|
||||||
|
AT_DESTINATION = "AT_DESTINATION" # returned / deposited
|
||||||
|
|
||||||
|
|
||||||
|
class ModuleState(str, enum.Enum):
|
||||||
|
"""E157 module execution states — for ``eq.report_module``."""
|
||||||
|
|
||||||
|
NOT_EXECUTING = "NOT_EXECUTING" # idle (also resets the module)
|
||||||
|
GENERAL_EXECUTING = "GENERAL_EXECUTING" # setup / pre- / post-process
|
||||||
|
STEP_EXECUTING = "STEP_EXECUTING" # actively running a recipe step
|
||||||
|
STEP_COMPLETED = "STEP_COMPLETED"
|
||||||
|
|
||||||
|
|
||||||
|
class JobState(str, enum.Enum):
|
||||||
|
"""E40 process-job progress — for ``eq.report_job``."""
|
||||||
|
|
||||||
|
SETTING_UP = "SETTING_UP"
|
||||||
|
PROCESSING = "PROCESSING"
|
||||||
|
COMPLETE = "COMPLETE"
|
||||||
|
ABORTED = "ABORTED"
|
||||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,728 @@
|
|||||||
|
# Generated by the gRPC Python protocol compiler plugin. DO NOT EDIT!
|
||||||
|
"""Client and server classes corresponding to protobuf-defined services."""
|
||||||
|
import grpc
|
||||||
|
|
||||||
|
from . import equipment_pb2 as equipment__pb2
|
||||||
|
|
||||||
|
|
||||||
|
class EquipmentStub(object):
|
||||||
|
"""=============================================================================
|
||||||
|
SECS/GEM Equipment API
|
||||||
|
|
||||||
|
The daemon *is* one piece of SEMI equipment: it owns the HSMS link to the
|
||||||
|
host (MES) and speaks GEM on your behalf. Your tool software connects as a
|
||||||
|
client and only ever does two things:
|
||||||
|
|
||||||
|
• tell the equipment about itself — set variables, fire events, raise alarms
|
||||||
|
• react to what the host asks for — receive commands/jobs, answer them
|
||||||
|
|
||||||
|
Everything SECS lives inside the daemon: message framing, report definitions,
|
||||||
|
the GEM state machines, timers, spooling. You need no SEMI knowledge to use
|
||||||
|
this API. Items are addressed by the human names from your equipment config
|
||||||
|
(e.g. "chamber_pressure"), never by numeric SVID / CEID / ALID.
|
||||||
|
|
||||||
|
CAPABILITY TIERS — wire up only what your equipment is:
|
||||||
|
• Universal — variables, events, alarms, control state, commands. Every tool.
|
||||||
|
• Carriers — E87 carrier/load-port flows. Only carrier-based equipment.
|
||||||
|
• Recipes — S7 process-program transfer. Only recipe-driven equipment.
|
||||||
|
• Jobs — E40 process jobs. Only job-based process/front-end equipment.
|
||||||
|
If a tier doesn't apply, you simply never receive its HostRequest variants and
|
||||||
|
never call its report RPCs.
|
||||||
|
=============================================================================
|
||||||
|
|
||||||
|
---- Universal: report state to the host --------------------------------
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, channel):
|
||||||
|
"""Constructor.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
channel: A grpc.Channel.
|
||||||
|
"""
|
||||||
|
self.SetVariables = channel.unary_unary(
|
||||||
|
'/secsgem.v1.Equipment/SetVariables',
|
||||||
|
request_serializer=equipment__pb2.VariableUpdate.SerializeToString,
|
||||||
|
response_deserializer=equipment__pb2.Ack.FromString,
|
||||||
|
)
|
||||||
|
self.GetVariables = channel.unary_unary(
|
||||||
|
'/secsgem.v1.Equipment/GetVariables',
|
||||||
|
request_serializer=equipment__pb2.VariableQuery.SerializeToString,
|
||||||
|
response_deserializer=equipment__pb2.VariableSnapshot.FromString,
|
||||||
|
)
|
||||||
|
self.FireEvent = channel.unary_unary(
|
||||||
|
'/secsgem.v1.Equipment/FireEvent',
|
||||||
|
request_serializer=equipment__pb2.Event.SerializeToString,
|
||||||
|
response_deserializer=equipment__pb2.Ack.FromString,
|
||||||
|
)
|
||||||
|
self.SetAlarm = channel.unary_unary(
|
||||||
|
'/secsgem.v1.Equipment/SetAlarm',
|
||||||
|
request_serializer=equipment__pb2.Alarm.SerializeToString,
|
||||||
|
response_deserializer=equipment__pb2.Ack.FromString,
|
||||||
|
)
|
||||||
|
self.ClearAlarm = channel.unary_unary(
|
||||||
|
'/secsgem.v1.Equipment/ClearAlarm',
|
||||||
|
request_serializer=equipment__pb2.Alarm.SerializeToString,
|
||||||
|
response_deserializer=equipment__pb2.Ack.FromString,
|
||||||
|
)
|
||||||
|
self.GetControlState = channel.unary_unary(
|
||||||
|
'/secsgem.v1.Equipment/GetControlState',
|
||||||
|
request_serializer=equipment__pb2.Empty.SerializeToString,
|
||||||
|
response_deserializer=equipment__pb2.ControlState.FromString,
|
||||||
|
)
|
||||||
|
self.RequestControlState = channel.unary_unary(
|
||||||
|
'/secsgem.v1.Equipment/RequestControlState',
|
||||||
|
request_serializer=equipment__pb2.ControlStateRequest.SerializeToString,
|
||||||
|
response_deserializer=equipment__pb2.Ack.FromString,
|
||||||
|
)
|
||||||
|
self.Subscribe = channel.unary_stream(
|
||||||
|
'/secsgem.v1.Equipment/Subscribe',
|
||||||
|
request_serializer=equipment__pb2.SubscribeRequest.SerializeToString,
|
||||||
|
response_deserializer=equipment__pb2.HostRequest.FromString,
|
||||||
|
)
|
||||||
|
self.CompleteCommand = channel.unary_unary(
|
||||||
|
'/secsgem.v1.Equipment/CompleteCommand',
|
||||||
|
request_serializer=equipment__pb2.CommandResult.SerializeToString,
|
||||||
|
response_deserializer=equipment__pb2.Ack.FromString,
|
||||||
|
)
|
||||||
|
self.ReportProcessJob = channel.unary_unary(
|
||||||
|
'/secsgem.v1.Equipment/ReportProcessJob',
|
||||||
|
request_serializer=equipment__pb2.ProcessJobState.SerializeToString,
|
||||||
|
response_deserializer=equipment__pb2.Ack.FromString,
|
||||||
|
)
|
||||||
|
self.ReportCarrier = channel.unary_unary(
|
||||||
|
'/secsgem.v1.Equipment/ReportCarrier',
|
||||||
|
request_serializer=equipment__pb2.CarrierState.SerializeToString,
|
||||||
|
response_deserializer=equipment__pb2.Ack.FromString,
|
||||||
|
)
|
||||||
|
self.ReportSubstrate = channel.unary_unary(
|
||||||
|
'/secsgem.v1.Equipment/ReportSubstrate',
|
||||||
|
request_serializer=equipment__pb2.SubstrateReport.SerializeToString,
|
||||||
|
response_deserializer=equipment__pb2.Ack.FromString,
|
||||||
|
)
|
||||||
|
self.ReportModule = channel.unary_unary(
|
||||||
|
'/secsgem.v1.Equipment/ReportModule',
|
||||||
|
request_serializer=equipment__pb2.ModuleReport.SerializeToString,
|
||||||
|
response_deserializer=equipment__pb2.Ack.FromString,
|
||||||
|
)
|
||||||
|
self.WatchHealth = channel.unary_stream(
|
||||||
|
'/secsgem.v1.Equipment/WatchHealth',
|
||||||
|
request_serializer=equipment__pb2.Empty.SerializeToString,
|
||||||
|
response_deserializer=equipment__pb2.Health.FromString,
|
||||||
|
)
|
||||||
|
self.Describe = channel.unary_unary(
|
||||||
|
'/secsgem.v1.Equipment/Describe',
|
||||||
|
request_serializer=equipment__pb2.Empty.SerializeToString,
|
||||||
|
response_deserializer=equipment__pb2.EquipmentDescription.FromString,
|
||||||
|
)
|
||||||
|
self.FlushSpool = channel.unary_unary(
|
||||||
|
'/secsgem.v1.Equipment/FlushSpool',
|
||||||
|
request_serializer=equipment__pb2.SpoolFlushRequest.SerializeToString,
|
||||||
|
response_deserializer=equipment__pb2.Ack.FromString,
|
||||||
|
)
|
||||||
|
self.SendTerminalMessage = channel.unary_unary(
|
||||||
|
'/secsgem.v1.Equipment/SendTerminalMessage',
|
||||||
|
request_serializer=equipment__pb2.TerminalMessage.SerializeToString,
|
||||||
|
response_deserializer=equipment__pb2.Ack.FromString,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class EquipmentServicer(object):
|
||||||
|
"""=============================================================================
|
||||||
|
SECS/GEM Equipment API
|
||||||
|
|
||||||
|
The daemon *is* one piece of SEMI equipment: it owns the HSMS link to the
|
||||||
|
host (MES) and speaks GEM on your behalf. Your tool software connects as a
|
||||||
|
client and only ever does two things:
|
||||||
|
|
||||||
|
• tell the equipment about itself — set variables, fire events, raise alarms
|
||||||
|
• react to what the host asks for — receive commands/jobs, answer them
|
||||||
|
|
||||||
|
Everything SECS lives inside the daemon: message framing, report definitions,
|
||||||
|
the GEM state machines, timers, spooling. You need no SEMI knowledge to use
|
||||||
|
this API. Items are addressed by the human names from your equipment config
|
||||||
|
(e.g. "chamber_pressure"), never by numeric SVID / CEID / ALID.
|
||||||
|
|
||||||
|
CAPABILITY TIERS — wire up only what your equipment is:
|
||||||
|
• Universal — variables, events, alarms, control state, commands. Every tool.
|
||||||
|
• Carriers — E87 carrier/load-port flows. Only carrier-based equipment.
|
||||||
|
• Recipes — S7 process-program transfer. Only recipe-driven equipment.
|
||||||
|
• Jobs — E40 process jobs. Only job-based process/front-end equipment.
|
||||||
|
If a tier doesn't apply, you simply never receive its HostRequest variants and
|
||||||
|
never call its report RPCs.
|
||||||
|
=============================================================================
|
||||||
|
|
||||||
|
---- Universal: report state to the host --------------------------------
|
||||||
|
"""
|
||||||
|
|
||||||
|
def SetVariables(self, request, context):
|
||||||
|
"""Update one or more status/data variables by name. The daemon remembers the
|
||||||
|
values, so the host always sees the latest when it polls (S1F3).
|
||||||
|
"""
|
||||||
|
context.set_code(grpc.StatusCode.UNIMPLEMENTED)
|
||||||
|
context.set_details('Method not implemented!')
|
||||||
|
raise NotImplementedError('Method not implemented!')
|
||||||
|
|
||||||
|
def GetVariables(self, request, context):
|
||||||
|
"""Read back what the daemon currently holds (useful on tool restart/reconnect).
|
||||||
|
"""
|
||||||
|
context.set_code(grpc.StatusCode.UNIMPLEMENTED)
|
||||||
|
context.set_details('Method not implemented!')
|
||||||
|
raise NotImplementedError('Method not implemented!')
|
||||||
|
|
||||||
|
def FireEvent(self, request, context):
|
||||||
|
"""Fire a collection event by name. The daemon assembles the configured report
|
||||||
|
and sends S6F11. Values in `data` override current values for this one event.
|
||||||
|
"""
|
||||||
|
context.set_code(grpc.StatusCode.UNIMPLEMENTED)
|
||||||
|
context.set_details('Method not implemented!')
|
||||||
|
raise NotImplementedError('Method not implemented!')
|
||||||
|
|
||||||
|
def SetAlarm(self, request, context):
|
||||||
|
"""Raise (S5F1 set) or clear an alarm by name.
|
||||||
|
"""
|
||||||
|
context.set_code(grpc.StatusCode.UNIMPLEMENTED)
|
||||||
|
context.set_details('Method not implemented!')
|
||||||
|
raise NotImplementedError('Method not implemented!')
|
||||||
|
|
||||||
|
def ClearAlarm(self, request, context):
|
||||||
|
"""Missing associated documentation comment in .proto file."""
|
||||||
|
context.set_code(grpc.StatusCode.UNIMPLEMENTED)
|
||||||
|
context.set_details('Method not implemented!')
|
||||||
|
raise NotImplementedError('Method not implemented!')
|
||||||
|
|
||||||
|
def GetControlState(self, request, context):
|
||||||
|
"""---- Universal: control state -------------------------------------------
|
||||||
|
|
||||||
|
Current GEM control state (ONLINE/LOCAL/REMOTE/OFFLINE).
|
||||||
|
"""
|
||||||
|
context.set_code(grpc.StatusCode.UNIMPLEMENTED)
|
||||||
|
context.set_details('Method not implemented!')
|
||||||
|
raise NotImplementedError('Method not implemented!')
|
||||||
|
|
||||||
|
def RequestControlState(self, request, context):
|
||||||
|
"""Request a transition — e.g. an operator panel taking the tool OFFLINE for
|
||||||
|
maintenance, or back ONLINE. The daemon applies E30 rules and may decline.
|
||||||
|
"""
|
||||||
|
context.set_code(grpc.StatusCode.UNIMPLEMENTED)
|
||||||
|
context.set_details('Method not implemented!')
|
||||||
|
raise NotImplementedError('Method not implemented!')
|
||||||
|
|
||||||
|
def Subscribe(self, request, context):
|
||||||
|
"""---- Universal: react to the host ---------------------------------------
|
||||||
|
|
||||||
|
Subscribe to everything the host asks of this equipment. The daemon streams
|
||||||
|
HostRequest messages for as long as the call stays open.
|
||||||
|
|
||||||
|
Delivery contract (v1):
|
||||||
|
- firehose: every subscriber receives every host request;
|
||||||
|
- NO buffering: a command arriving while no client is subscribed is
|
||||||
|
answered with its declarative ack from the equipment config (the
|
||||||
|
pre-daemon behaviour) and is NOT replayed on reconnect — the daemon
|
||||||
|
never tells the host "will finish later" for work no tool will do;
|
||||||
|
- when a Command does arrive here, the host has ALREADY been answered
|
||||||
|
with S2F42 HCACK=4; report the real outcome via FireEvent/SetAlarm.
|
||||||
|
"""
|
||||||
|
context.set_code(grpc.StatusCode.UNIMPLEMENTED)
|
||||||
|
context.set_details('Method not implemented!')
|
||||||
|
raise NotImplementedError('Method not implemented!')
|
||||||
|
|
||||||
|
def CompleteCommand(self, request, context):
|
||||||
|
"""Report the outcome of a Command delivered on the stream, quoting its `id`.
|
||||||
|
NOTE the contract (SEMI-conformant, non-blocking): the daemon has ALREADY
|
||||||
|
answered the host with S2F42 HCACK=4 ("accepted, will finish later") when
|
||||||
|
it pushed the command onto the stream — the host's transaction is closed.
|
||||||
|
CompleteCommand therefore correlates/audits the command lifecycle; the
|
||||||
|
host learns the real outcome via the events/alarms you fire (FireEvent /
|
||||||
|
SetAlarm), exactly as E30 intends. A synchronous gating mode (tool decides
|
||||||
|
the HCACK before S2F42 goes out) is a possible v2 extension.
|
||||||
|
"""
|
||||||
|
context.set_code(grpc.StatusCode.UNIMPLEMENTED)
|
||||||
|
context.set_details('Method not implemented!')
|
||||||
|
raise NotImplementedError('Method not implemented!')
|
||||||
|
|
||||||
|
def ReportProcessJob(self, request, context):
|
||||||
|
"""---- Jobs / Carriers: report progress of work the host asked for --------
|
||||||
|
Keyed by the durable id (job_id / carrier_id), not a per-message id — these
|
||||||
|
are long-lived objects you report against as the physical work proceeds.
|
||||||
|
|
||||||
|
E40 — job-based tools
|
||||||
|
"""
|
||||||
|
context.set_code(grpc.StatusCode.UNIMPLEMENTED)
|
||||||
|
context.set_details('Method not implemented!')
|
||||||
|
raise NotImplementedError('Method not implemented!')
|
||||||
|
|
||||||
|
def ReportCarrier(self, request, context):
|
||||||
|
"""E87 — carrier-based tools. WAITING announces a physically-arrived
|
||||||
|
carrier (creates it; idempotent — re-announce updates the slot map);
|
||||||
|
IN_ACCESS / COMPLETE drive the access FSM. The host's S3F17 decisions
|
||||||
|
(ProceedWithCarrier / CancelCarrier) come back on the Subscribe stream
|
||||||
|
as CarrierAction.
|
||||||
|
"""
|
||||||
|
context.set_code(grpc.StatusCode.UNIMPLEMENTED)
|
||||||
|
context.set_details('Method not implemented!')
|
||||||
|
raise NotImplementedError('Method not implemented!')
|
||||||
|
|
||||||
|
def ReportSubstrate(self, request, context):
|
||||||
|
"""E90 — substrate (wafer) tracking. Report each milestone of a wafer's
|
||||||
|
journey; the daemon drives the E90 FSMs and emits the standard CEIDs to
|
||||||
|
the host. ARRIVED creates the substrate. Only for tools that track
|
||||||
|
individual substrates.
|
||||||
|
"""
|
||||||
|
context.set_code(grpc.StatusCode.UNIMPLEMENTED)
|
||||||
|
context.set_details('Method not implemented!')
|
||||||
|
raise NotImplementedError('Method not implemented!')
|
||||||
|
|
||||||
|
def ReportModule(self, request, context):
|
||||||
|
"""E157 — module process tracking. Report a module's execution state; the
|
||||||
|
daemon drives the E157 FSM and emits the standard CEIDs. The module is
|
||||||
|
auto-created on first report. Only for tools with tracked modules.
|
||||||
|
"""
|
||||||
|
context.set_code(grpc.StatusCode.UNIMPLEMENTED)
|
||||||
|
context.set_details('Method not implemented!')
|
||||||
|
raise NotImplementedError('Method not implemented!')
|
||||||
|
|
||||||
|
def WatchHealth(self, request, context):
|
||||||
|
"""---- Diagnostics --------------------------------------------------------
|
||||||
|
|
||||||
|
Live daemon/link status: distinguishes "host went offline" from "cable
|
||||||
|
unplugged" from "spool filling up". Streams a snapshot on every change.
|
||||||
|
"""
|
||||||
|
context.set_code(grpc.StatusCode.UNIMPLEMENTED)
|
||||||
|
context.set_details('Method not implemented!')
|
||||||
|
raise NotImplementedError('Method not implemented!')
|
||||||
|
|
||||||
|
def Describe(self, request, context):
|
||||||
|
"""Everything this equipment is configured with, by name — for tooling,
|
||||||
|
diagnostics, and client-side validation/autocomplete.
|
||||||
|
"""
|
||||||
|
context.set_code(grpc.StatusCode.UNIMPLEMENTED)
|
||||||
|
context.set_details('Method not implemented!')
|
||||||
|
raise NotImplementedError('Method not implemented!')
|
||||||
|
|
||||||
|
def FlushSpool(self, request, context):
|
||||||
|
"""Flush the spool: purge=true discards queued messages, purge=false drains
|
||||||
|
them toward the host (requires a SELECTED session).
|
||||||
|
"""
|
||||||
|
context.set_code(grpc.StatusCode.UNIMPLEMENTED)
|
||||||
|
context.set_details('Method not implemented!')
|
||||||
|
raise NotImplementedError('Method not implemented!')
|
||||||
|
|
||||||
|
def SendTerminalMessage(self, request, context):
|
||||||
|
"""Equipment-initiated operator message to the host (S10F1). Fails with
|
||||||
|
CANNOT_DO_NOW when no host is connected and stream 10 isn't spoolable.
|
||||||
|
"""
|
||||||
|
context.set_code(grpc.StatusCode.UNIMPLEMENTED)
|
||||||
|
context.set_details('Method not implemented!')
|
||||||
|
raise NotImplementedError('Method not implemented!')
|
||||||
|
|
||||||
|
|
||||||
|
def add_EquipmentServicer_to_server(servicer, server):
|
||||||
|
rpc_method_handlers = {
|
||||||
|
'SetVariables': grpc.unary_unary_rpc_method_handler(
|
||||||
|
servicer.SetVariables,
|
||||||
|
request_deserializer=equipment__pb2.VariableUpdate.FromString,
|
||||||
|
response_serializer=equipment__pb2.Ack.SerializeToString,
|
||||||
|
),
|
||||||
|
'GetVariables': grpc.unary_unary_rpc_method_handler(
|
||||||
|
servicer.GetVariables,
|
||||||
|
request_deserializer=equipment__pb2.VariableQuery.FromString,
|
||||||
|
response_serializer=equipment__pb2.VariableSnapshot.SerializeToString,
|
||||||
|
),
|
||||||
|
'FireEvent': grpc.unary_unary_rpc_method_handler(
|
||||||
|
servicer.FireEvent,
|
||||||
|
request_deserializer=equipment__pb2.Event.FromString,
|
||||||
|
response_serializer=equipment__pb2.Ack.SerializeToString,
|
||||||
|
),
|
||||||
|
'SetAlarm': grpc.unary_unary_rpc_method_handler(
|
||||||
|
servicer.SetAlarm,
|
||||||
|
request_deserializer=equipment__pb2.Alarm.FromString,
|
||||||
|
response_serializer=equipment__pb2.Ack.SerializeToString,
|
||||||
|
),
|
||||||
|
'ClearAlarm': grpc.unary_unary_rpc_method_handler(
|
||||||
|
servicer.ClearAlarm,
|
||||||
|
request_deserializer=equipment__pb2.Alarm.FromString,
|
||||||
|
response_serializer=equipment__pb2.Ack.SerializeToString,
|
||||||
|
),
|
||||||
|
'GetControlState': grpc.unary_unary_rpc_method_handler(
|
||||||
|
servicer.GetControlState,
|
||||||
|
request_deserializer=equipment__pb2.Empty.FromString,
|
||||||
|
response_serializer=equipment__pb2.ControlState.SerializeToString,
|
||||||
|
),
|
||||||
|
'RequestControlState': grpc.unary_unary_rpc_method_handler(
|
||||||
|
servicer.RequestControlState,
|
||||||
|
request_deserializer=equipment__pb2.ControlStateRequest.FromString,
|
||||||
|
response_serializer=equipment__pb2.Ack.SerializeToString,
|
||||||
|
),
|
||||||
|
'Subscribe': grpc.unary_stream_rpc_method_handler(
|
||||||
|
servicer.Subscribe,
|
||||||
|
request_deserializer=equipment__pb2.SubscribeRequest.FromString,
|
||||||
|
response_serializer=equipment__pb2.HostRequest.SerializeToString,
|
||||||
|
),
|
||||||
|
'CompleteCommand': grpc.unary_unary_rpc_method_handler(
|
||||||
|
servicer.CompleteCommand,
|
||||||
|
request_deserializer=equipment__pb2.CommandResult.FromString,
|
||||||
|
response_serializer=equipment__pb2.Ack.SerializeToString,
|
||||||
|
),
|
||||||
|
'ReportProcessJob': grpc.unary_unary_rpc_method_handler(
|
||||||
|
servicer.ReportProcessJob,
|
||||||
|
request_deserializer=equipment__pb2.ProcessJobState.FromString,
|
||||||
|
response_serializer=equipment__pb2.Ack.SerializeToString,
|
||||||
|
),
|
||||||
|
'ReportCarrier': grpc.unary_unary_rpc_method_handler(
|
||||||
|
servicer.ReportCarrier,
|
||||||
|
request_deserializer=equipment__pb2.CarrierState.FromString,
|
||||||
|
response_serializer=equipment__pb2.Ack.SerializeToString,
|
||||||
|
),
|
||||||
|
'ReportSubstrate': grpc.unary_unary_rpc_method_handler(
|
||||||
|
servicer.ReportSubstrate,
|
||||||
|
request_deserializer=equipment__pb2.SubstrateReport.FromString,
|
||||||
|
response_serializer=equipment__pb2.Ack.SerializeToString,
|
||||||
|
),
|
||||||
|
'ReportModule': grpc.unary_unary_rpc_method_handler(
|
||||||
|
servicer.ReportModule,
|
||||||
|
request_deserializer=equipment__pb2.ModuleReport.FromString,
|
||||||
|
response_serializer=equipment__pb2.Ack.SerializeToString,
|
||||||
|
),
|
||||||
|
'WatchHealth': grpc.unary_stream_rpc_method_handler(
|
||||||
|
servicer.WatchHealth,
|
||||||
|
request_deserializer=equipment__pb2.Empty.FromString,
|
||||||
|
response_serializer=equipment__pb2.Health.SerializeToString,
|
||||||
|
),
|
||||||
|
'Describe': grpc.unary_unary_rpc_method_handler(
|
||||||
|
servicer.Describe,
|
||||||
|
request_deserializer=equipment__pb2.Empty.FromString,
|
||||||
|
response_serializer=equipment__pb2.EquipmentDescription.SerializeToString,
|
||||||
|
),
|
||||||
|
'FlushSpool': grpc.unary_unary_rpc_method_handler(
|
||||||
|
servicer.FlushSpool,
|
||||||
|
request_deserializer=equipment__pb2.SpoolFlushRequest.FromString,
|
||||||
|
response_serializer=equipment__pb2.Ack.SerializeToString,
|
||||||
|
),
|
||||||
|
'SendTerminalMessage': grpc.unary_unary_rpc_method_handler(
|
||||||
|
servicer.SendTerminalMessage,
|
||||||
|
request_deserializer=equipment__pb2.TerminalMessage.FromString,
|
||||||
|
response_serializer=equipment__pb2.Ack.SerializeToString,
|
||||||
|
),
|
||||||
|
}
|
||||||
|
generic_handler = grpc.method_handlers_generic_handler(
|
||||||
|
'secsgem.v1.Equipment', rpc_method_handlers)
|
||||||
|
server.add_generic_rpc_handlers((generic_handler,))
|
||||||
|
|
||||||
|
|
||||||
|
# This class is part of an EXPERIMENTAL API.
|
||||||
|
class Equipment(object):
|
||||||
|
"""=============================================================================
|
||||||
|
SECS/GEM Equipment API
|
||||||
|
|
||||||
|
The daemon *is* one piece of SEMI equipment: it owns the HSMS link to the
|
||||||
|
host (MES) and speaks GEM on your behalf. Your tool software connects as a
|
||||||
|
client and only ever does two things:
|
||||||
|
|
||||||
|
• tell the equipment about itself — set variables, fire events, raise alarms
|
||||||
|
• react to what the host asks for — receive commands/jobs, answer them
|
||||||
|
|
||||||
|
Everything SECS lives inside the daemon: message framing, report definitions,
|
||||||
|
the GEM state machines, timers, spooling. You need no SEMI knowledge to use
|
||||||
|
this API. Items are addressed by the human names from your equipment config
|
||||||
|
(e.g. "chamber_pressure"), never by numeric SVID / CEID / ALID.
|
||||||
|
|
||||||
|
CAPABILITY TIERS — wire up only what your equipment is:
|
||||||
|
• Universal — variables, events, alarms, control state, commands. Every tool.
|
||||||
|
• Carriers — E87 carrier/load-port flows. Only carrier-based equipment.
|
||||||
|
• Recipes — S7 process-program transfer. Only recipe-driven equipment.
|
||||||
|
• Jobs — E40 process jobs. Only job-based process/front-end equipment.
|
||||||
|
If a tier doesn't apply, you simply never receive its HostRequest variants and
|
||||||
|
never call its report RPCs.
|
||||||
|
=============================================================================
|
||||||
|
|
||||||
|
---- Universal: report state to the host --------------------------------
|
||||||
|
"""
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def SetVariables(request,
|
||||||
|
target,
|
||||||
|
options=(),
|
||||||
|
channel_credentials=None,
|
||||||
|
call_credentials=None,
|
||||||
|
insecure=False,
|
||||||
|
compression=None,
|
||||||
|
wait_for_ready=None,
|
||||||
|
timeout=None,
|
||||||
|
metadata=None):
|
||||||
|
return grpc.experimental.unary_unary(request, target, '/secsgem.v1.Equipment/SetVariables',
|
||||||
|
equipment__pb2.VariableUpdate.SerializeToString,
|
||||||
|
equipment__pb2.Ack.FromString,
|
||||||
|
options, channel_credentials,
|
||||||
|
insecure, call_credentials, compression, wait_for_ready, timeout, metadata)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def GetVariables(request,
|
||||||
|
target,
|
||||||
|
options=(),
|
||||||
|
channel_credentials=None,
|
||||||
|
call_credentials=None,
|
||||||
|
insecure=False,
|
||||||
|
compression=None,
|
||||||
|
wait_for_ready=None,
|
||||||
|
timeout=None,
|
||||||
|
metadata=None):
|
||||||
|
return grpc.experimental.unary_unary(request, target, '/secsgem.v1.Equipment/GetVariables',
|
||||||
|
equipment__pb2.VariableQuery.SerializeToString,
|
||||||
|
equipment__pb2.VariableSnapshot.FromString,
|
||||||
|
options, channel_credentials,
|
||||||
|
insecure, call_credentials, compression, wait_for_ready, timeout, metadata)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def FireEvent(request,
|
||||||
|
target,
|
||||||
|
options=(),
|
||||||
|
channel_credentials=None,
|
||||||
|
call_credentials=None,
|
||||||
|
insecure=False,
|
||||||
|
compression=None,
|
||||||
|
wait_for_ready=None,
|
||||||
|
timeout=None,
|
||||||
|
metadata=None):
|
||||||
|
return grpc.experimental.unary_unary(request, target, '/secsgem.v1.Equipment/FireEvent',
|
||||||
|
equipment__pb2.Event.SerializeToString,
|
||||||
|
equipment__pb2.Ack.FromString,
|
||||||
|
options, channel_credentials,
|
||||||
|
insecure, call_credentials, compression, wait_for_ready, timeout, metadata)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def SetAlarm(request,
|
||||||
|
target,
|
||||||
|
options=(),
|
||||||
|
channel_credentials=None,
|
||||||
|
call_credentials=None,
|
||||||
|
insecure=False,
|
||||||
|
compression=None,
|
||||||
|
wait_for_ready=None,
|
||||||
|
timeout=None,
|
||||||
|
metadata=None):
|
||||||
|
return grpc.experimental.unary_unary(request, target, '/secsgem.v1.Equipment/SetAlarm',
|
||||||
|
equipment__pb2.Alarm.SerializeToString,
|
||||||
|
equipment__pb2.Ack.FromString,
|
||||||
|
options, channel_credentials,
|
||||||
|
insecure, call_credentials, compression, wait_for_ready, timeout, metadata)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def ClearAlarm(request,
|
||||||
|
target,
|
||||||
|
options=(),
|
||||||
|
channel_credentials=None,
|
||||||
|
call_credentials=None,
|
||||||
|
insecure=False,
|
||||||
|
compression=None,
|
||||||
|
wait_for_ready=None,
|
||||||
|
timeout=None,
|
||||||
|
metadata=None):
|
||||||
|
return grpc.experimental.unary_unary(request, target, '/secsgem.v1.Equipment/ClearAlarm',
|
||||||
|
equipment__pb2.Alarm.SerializeToString,
|
||||||
|
equipment__pb2.Ack.FromString,
|
||||||
|
options, channel_credentials,
|
||||||
|
insecure, call_credentials, compression, wait_for_ready, timeout, metadata)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def GetControlState(request,
|
||||||
|
target,
|
||||||
|
options=(),
|
||||||
|
channel_credentials=None,
|
||||||
|
call_credentials=None,
|
||||||
|
insecure=False,
|
||||||
|
compression=None,
|
||||||
|
wait_for_ready=None,
|
||||||
|
timeout=None,
|
||||||
|
metadata=None):
|
||||||
|
return grpc.experimental.unary_unary(request, target, '/secsgem.v1.Equipment/GetControlState',
|
||||||
|
equipment__pb2.Empty.SerializeToString,
|
||||||
|
equipment__pb2.ControlState.FromString,
|
||||||
|
options, channel_credentials,
|
||||||
|
insecure, call_credentials, compression, wait_for_ready, timeout, metadata)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def RequestControlState(request,
|
||||||
|
target,
|
||||||
|
options=(),
|
||||||
|
channel_credentials=None,
|
||||||
|
call_credentials=None,
|
||||||
|
insecure=False,
|
||||||
|
compression=None,
|
||||||
|
wait_for_ready=None,
|
||||||
|
timeout=None,
|
||||||
|
metadata=None):
|
||||||
|
return grpc.experimental.unary_unary(request, target, '/secsgem.v1.Equipment/RequestControlState',
|
||||||
|
equipment__pb2.ControlStateRequest.SerializeToString,
|
||||||
|
equipment__pb2.Ack.FromString,
|
||||||
|
options, channel_credentials,
|
||||||
|
insecure, call_credentials, compression, wait_for_ready, timeout, metadata)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def Subscribe(request,
|
||||||
|
target,
|
||||||
|
options=(),
|
||||||
|
channel_credentials=None,
|
||||||
|
call_credentials=None,
|
||||||
|
insecure=False,
|
||||||
|
compression=None,
|
||||||
|
wait_for_ready=None,
|
||||||
|
timeout=None,
|
||||||
|
metadata=None):
|
||||||
|
return grpc.experimental.unary_stream(request, target, '/secsgem.v1.Equipment/Subscribe',
|
||||||
|
equipment__pb2.SubscribeRequest.SerializeToString,
|
||||||
|
equipment__pb2.HostRequest.FromString,
|
||||||
|
options, channel_credentials,
|
||||||
|
insecure, call_credentials, compression, wait_for_ready, timeout, metadata)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def CompleteCommand(request,
|
||||||
|
target,
|
||||||
|
options=(),
|
||||||
|
channel_credentials=None,
|
||||||
|
call_credentials=None,
|
||||||
|
insecure=False,
|
||||||
|
compression=None,
|
||||||
|
wait_for_ready=None,
|
||||||
|
timeout=None,
|
||||||
|
metadata=None):
|
||||||
|
return grpc.experimental.unary_unary(request, target, '/secsgem.v1.Equipment/CompleteCommand',
|
||||||
|
equipment__pb2.CommandResult.SerializeToString,
|
||||||
|
equipment__pb2.Ack.FromString,
|
||||||
|
options, channel_credentials,
|
||||||
|
insecure, call_credentials, compression, wait_for_ready, timeout, metadata)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def ReportProcessJob(request,
|
||||||
|
target,
|
||||||
|
options=(),
|
||||||
|
channel_credentials=None,
|
||||||
|
call_credentials=None,
|
||||||
|
insecure=False,
|
||||||
|
compression=None,
|
||||||
|
wait_for_ready=None,
|
||||||
|
timeout=None,
|
||||||
|
metadata=None):
|
||||||
|
return grpc.experimental.unary_unary(request, target, '/secsgem.v1.Equipment/ReportProcessJob',
|
||||||
|
equipment__pb2.ProcessJobState.SerializeToString,
|
||||||
|
equipment__pb2.Ack.FromString,
|
||||||
|
options, channel_credentials,
|
||||||
|
insecure, call_credentials, compression, wait_for_ready, timeout, metadata)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def ReportCarrier(request,
|
||||||
|
target,
|
||||||
|
options=(),
|
||||||
|
channel_credentials=None,
|
||||||
|
call_credentials=None,
|
||||||
|
insecure=False,
|
||||||
|
compression=None,
|
||||||
|
wait_for_ready=None,
|
||||||
|
timeout=None,
|
||||||
|
metadata=None):
|
||||||
|
return grpc.experimental.unary_unary(request, target, '/secsgem.v1.Equipment/ReportCarrier',
|
||||||
|
equipment__pb2.CarrierState.SerializeToString,
|
||||||
|
equipment__pb2.Ack.FromString,
|
||||||
|
options, channel_credentials,
|
||||||
|
insecure, call_credentials, compression, wait_for_ready, timeout, metadata)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def ReportSubstrate(request,
|
||||||
|
target,
|
||||||
|
options=(),
|
||||||
|
channel_credentials=None,
|
||||||
|
call_credentials=None,
|
||||||
|
insecure=False,
|
||||||
|
compression=None,
|
||||||
|
wait_for_ready=None,
|
||||||
|
timeout=None,
|
||||||
|
metadata=None):
|
||||||
|
return grpc.experimental.unary_unary(request, target, '/secsgem.v1.Equipment/ReportSubstrate',
|
||||||
|
equipment__pb2.SubstrateReport.SerializeToString,
|
||||||
|
equipment__pb2.Ack.FromString,
|
||||||
|
options, channel_credentials,
|
||||||
|
insecure, call_credentials, compression, wait_for_ready, timeout, metadata)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def ReportModule(request,
|
||||||
|
target,
|
||||||
|
options=(),
|
||||||
|
channel_credentials=None,
|
||||||
|
call_credentials=None,
|
||||||
|
insecure=False,
|
||||||
|
compression=None,
|
||||||
|
wait_for_ready=None,
|
||||||
|
timeout=None,
|
||||||
|
metadata=None):
|
||||||
|
return grpc.experimental.unary_unary(request, target, '/secsgem.v1.Equipment/ReportModule',
|
||||||
|
equipment__pb2.ModuleReport.SerializeToString,
|
||||||
|
equipment__pb2.Ack.FromString,
|
||||||
|
options, channel_credentials,
|
||||||
|
insecure, call_credentials, compression, wait_for_ready, timeout, metadata)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def WatchHealth(request,
|
||||||
|
target,
|
||||||
|
options=(),
|
||||||
|
channel_credentials=None,
|
||||||
|
call_credentials=None,
|
||||||
|
insecure=False,
|
||||||
|
compression=None,
|
||||||
|
wait_for_ready=None,
|
||||||
|
timeout=None,
|
||||||
|
metadata=None):
|
||||||
|
return grpc.experimental.unary_stream(request, target, '/secsgem.v1.Equipment/WatchHealth',
|
||||||
|
equipment__pb2.Empty.SerializeToString,
|
||||||
|
equipment__pb2.Health.FromString,
|
||||||
|
options, channel_credentials,
|
||||||
|
insecure, call_credentials, compression, wait_for_ready, timeout, metadata)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def Describe(request,
|
||||||
|
target,
|
||||||
|
options=(),
|
||||||
|
channel_credentials=None,
|
||||||
|
call_credentials=None,
|
||||||
|
insecure=False,
|
||||||
|
compression=None,
|
||||||
|
wait_for_ready=None,
|
||||||
|
timeout=None,
|
||||||
|
metadata=None):
|
||||||
|
return grpc.experimental.unary_unary(request, target, '/secsgem.v1.Equipment/Describe',
|
||||||
|
equipment__pb2.Empty.SerializeToString,
|
||||||
|
equipment__pb2.EquipmentDescription.FromString,
|
||||||
|
options, channel_credentials,
|
||||||
|
insecure, call_credentials, compression, wait_for_ready, timeout, metadata)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def FlushSpool(request,
|
||||||
|
target,
|
||||||
|
options=(),
|
||||||
|
channel_credentials=None,
|
||||||
|
call_credentials=None,
|
||||||
|
insecure=False,
|
||||||
|
compression=None,
|
||||||
|
wait_for_ready=None,
|
||||||
|
timeout=None,
|
||||||
|
metadata=None):
|
||||||
|
return grpc.experimental.unary_unary(request, target, '/secsgem.v1.Equipment/FlushSpool',
|
||||||
|
equipment__pb2.SpoolFlushRequest.SerializeToString,
|
||||||
|
equipment__pb2.Ack.FromString,
|
||||||
|
options, channel_credentials,
|
||||||
|
insecure, call_credentials, compression, wait_for_ready, timeout, metadata)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def SendTerminalMessage(request,
|
||||||
|
target,
|
||||||
|
options=(),
|
||||||
|
channel_credentials=None,
|
||||||
|
call_credentials=None,
|
||||||
|
insecure=False,
|
||||||
|
compression=None,
|
||||||
|
wait_for_ready=None,
|
||||||
|
timeout=None,
|
||||||
|
metadata=None):
|
||||||
|
return grpc.experimental.unary_unary(request, target, '/secsgem.v1.Equipment/SendTerminalMessage',
|
||||||
|
equipment__pb2.TerminalMessage.SerializeToString,
|
||||||
|
equipment__pb2.Ack.FromString,
|
||||||
|
options, channel_credentials,
|
||||||
|
insecure, call_credentials, compression, wait_for_ready, timeout, metadata)
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
"""Plain Python values <-> the wire's Value message.
|
||||||
|
|
||||||
|
The daemon owns all SECS-II knowledge (it converts to each variable's
|
||||||
|
declared wire format); this layer only maps Python types onto the Value
|
||||||
|
oneof. Order matters in to_value: bool is checked before int because
|
||||||
|
isinstance(True, int) is True in Python.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from ._proto import equipment_pb2 as pb
|
||||||
|
|
||||||
|
|
||||||
|
def to_value(v) -> pb.Value:
|
||||||
|
if isinstance(v, pb.Value):
|
||||||
|
return v
|
||||||
|
if isinstance(v, bool):
|
||||||
|
return pb.Value(boolean=v)
|
||||||
|
if isinstance(v, int):
|
||||||
|
return pb.Value(integer=v)
|
||||||
|
if isinstance(v, float):
|
||||||
|
return pb.Value(real=v)
|
||||||
|
if isinstance(v, str):
|
||||||
|
return pb.Value(text=v)
|
||||||
|
if isinstance(v, (bytes, bytearray)):
|
||||||
|
return pb.Value(binary=bytes(v))
|
||||||
|
if isinstance(v, (list, tuple)):
|
||||||
|
return pb.Value(list=pb.List(items=[to_value(e) for e in v]))
|
||||||
|
raise TypeError(f"cannot convert {type(v).__name__} to a SECS value")
|
||||||
|
|
||||||
|
|
||||||
|
def from_value(v: pb.Value):
|
||||||
|
kind = v.WhichOneof("kind")
|
||||||
|
if kind == "text":
|
||||||
|
return v.text
|
||||||
|
if kind == "integer":
|
||||||
|
return v.integer
|
||||||
|
if kind == "real":
|
||||||
|
return v.real
|
||||||
|
if kind == "boolean":
|
||||||
|
return v.boolean
|
||||||
|
if kind == "binary":
|
||||||
|
return v.binary
|
||||||
|
if kind == "list":
|
||||||
|
return [from_value(e) for e in v.list.items]
|
||||||
|
return None # unset
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""The exported protocol enums must stay in lockstep with the proto, and the
|
||||||
|
typo-safe resolver must reject bad values with a helpful message. Plain
|
||||||
|
asserts — run directly."""
|
||||||
|
import sys
|
||||||
|
|
||||||
|
from secsgem_client import JobState, Milestone, ModuleState
|
||||||
|
from secsgem_client._client import _enum_value
|
||||||
|
from secsgem_client._proto import equipment_pb2 as pb
|
||||||
|
|
||||||
|
|
||||||
|
def members(e):
|
||||||
|
return {m.value for m in e}
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
# Each enum mirrors its proto source exactly — no drift, no missing member.
|
||||||
|
assert members(Milestone) == set(pb.SubstrateReport.Milestone.keys())
|
||||||
|
assert members(ModuleState) == set(pb.ModuleReport.State.keys())
|
||||||
|
assert members(JobState) == set(pb.ProcessJobState.State.keys())
|
||||||
|
|
||||||
|
# A member IS its wire name, so enum and plain string are interchangeable.
|
||||||
|
assert Milestone.ARRIVED == "ARRIVED"
|
||||||
|
assert _enum_value(pb.SubstrateReport.Milestone, Milestone.ARRIVED, "milestone") \
|
||||||
|
== _enum_value(pb.SubstrateReport.Milestone, "ARRIVED", "milestone")
|
||||||
|
|
||||||
|
# A typo raises ValueError (client-side, not a daemon round-trip) and the
|
||||||
|
# message offers the close match instead of leaking a raw protobuf error.
|
||||||
|
try:
|
||||||
|
_enum_value(pb.SubstrateReport.Milestone, "AT_WROK", "milestone")
|
||||||
|
raise SystemExit("expected ValueError for a misspelled milestone")
|
||||||
|
except ValueError as e:
|
||||||
|
assert "AT_WORK" in str(e), str(e)
|
||||||
|
|
||||||
|
print("enums: proto-sync + typo-safety checks passed")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Conversion round-trips for the Value layer. Plain asserts — run directly."""
|
||||||
|
import sys
|
||||||
|
|
||||||
|
from secsgem_client._proto import equipment_pb2 as pb
|
||||||
|
from secsgem_client._values import from_value, to_value
|
||||||
|
|
||||||
|
|
||||||
|
def roundtrip(v):
|
||||||
|
return from_value(to_value(v))
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
assert roundtrip(2.5) == 2.5
|
||||||
|
assert roundtrip(7) == 7
|
||||||
|
assert roundtrip(-3) == -3
|
||||||
|
assert roundtrip(True) is True # bool BEFORE int: must stay boolean
|
||||||
|
assert roundtrip(False) is False
|
||||||
|
assert to_value(True).WhichOneof("kind") == "boolean"
|
||||||
|
assert to_value(1).WhichOneof("kind") == "integer"
|
||||||
|
assert roundtrip("wafer-17") == "wafer-17"
|
||||||
|
assert roundtrip(b"\x01\x02") == b"\x01\x02"
|
||||||
|
assert roundtrip([1, 2.5, "x", [True]]) == [1, 2.5, "x", [True]]
|
||||||
|
assert from_value(pb.Value()) is None # unset oneof
|
||||||
|
try:
|
||||||
|
to_value(object())
|
||||||
|
raise SystemExit("expected TypeError for unconvertible type")
|
||||||
|
except TypeError:
|
||||||
|
pass
|
||||||
|
print("values: all conversion checks passed")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
+14
-3
@@ -56,10 +56,12 @@ ceids:
|
|||||||
- {id: 400, name: ControlJobExecuting} # E94 CJ entered Executing
|
- {id: 400, name: ControlJobExecuting} # E94 CJ entered Executing
|
||||||
- {id: 401, name: ControlJobCompleted} # E94 CJ entered Completed
|
- {id: 401, name: ControlJobCompleted} # E94 CJ entered Completed
|
||||||
|
|
||||||
# Reported on S5F5 / S5F1. `category` is the lower-7 of ALCD.
|
# Reported on S5F5 / S5F1. `category` is the lower-7 of ALCD. `name` is an
|
||||||
|
# optional local key for the daemon's name-based API (gRPC SetAlarm/ClearAlarm);
|
||||||
|
# it never goes on the wire — SEMI only defines numeric ALID + freetext text.
|
||||||
alarms:
|
alarms:
|
||||||
- {id: 1, text: "Chiller Temp High", category: 4}
|
- {id: 1, name: chiller_temp_high, text: "Chiller Temp High", category: 4}
|
||||||
- {id: 2, text: "Door Open", category: 1}
|
- {id: 2, name: door_open, text: "Door Open", category: 1}
|
||||||
|
|
||||||
# Reported on S7F19. Body served by S7F5/F6.
|
# Reported on S7F19. Body served by S7F5/F6.
|
||||||
recipes:
|
recipes:
|
||||||
@@ -91,3 +93,12 @@ spool:
|
|||||||
# CEID emitted automatically whenever the control state machine transitions
|
# CEID emitted automatically whenever the control state machine transitions
|
||||||
# (i.e. on every change-handler call). Set to null to disable.
|
# (i.e. on every change-handler call). Set to null to disable.
|
||||||
emit_on_control_change: 100
|
emit_on_control_change: 100
|
||||||
|
|
||||||
|
# Role bindings: which of the ids declared above the engine's built-in
|
||||||
|
# behaviours target. Explicit here so the coupling is visible in ONE file
|
||||||
|
# instead of constants in C++ that silently had to match.
|
||||||
|
roles:
|
||||||
|
control_state_svid: 1 # refreshed with the control-state name on S1F3
|
||||||
|
clock_svid: 2 # refreshed with the clock string on S1F3
|
||||||
|
cj_executing_ceid: 400 # fired when a control job enters Executing
|
||||||
|
cj_completed_ceid: 401 # fired when a control job enters Completed
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
# systemd unit for the secs_gemd SECS/GEM daemon.
|
||||||
|
#
|
||||||
|
# install -m644 deploy/secs_gemd.service /etc/systemd/system/
|
||||||
|
# install -m755 build/secs_gemd /usr/local/bin/
|
||||||
|
# mkdir -p /etc/secsgem && cp data/*.yaml /etc/secsgem/
|
||||||
|
# systemctl enable --now secs_gemd
|
||||||
|
#
|
||||||
|
# The tool API rides a Unix domain socket (no network exposure at all);
|
||||||
|
# point the Python/C++ client at unix:///run/secs_gemd/api.sock. The HSMS
|
||||||
|
# port (5000) faces the fab host — firewall it to the host's address (see
|
||||||
|
# docs/SECURITY.md for the nftables recipe).
|
||||||
|
|
||||||
|
[Unit]
|
||||||
|
Description=SECS/GEM equipment daemon (secs_gemd)
|
||||||
|
After=network-online.target
|
||||||
|
Wants=network-online.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=exec
|
||||||
|
ExecStart=/usr/local/bin/secs_gemd \
|
||||||
|
--port 5000 \
|
||||||
|
--grpc unix:///run/secs_gemd/api.sock \
|
||||||
|
--metrics 9091 \
|
||||||
|
--config-dir /etc/secsgem \
|
||||||
|
--spool-dir /var/lib/secs_gemd/spool
|
||||||
|
RuntimeDirectory=secs_gemd
|
||||||
|
StateDirectory=secs_gemd
|
||||||
|
# SIGTERM triggers the daemon's graceful shutdown (drains gRPC, stops the
|
||||||
|
# engine, exit 0); give it a moment before SIGKILL.
|
||||||
|
TimeoutStopSec=10
|
||||||
|
Restart=always
|
||||||
|
RestartSec=2
|
||||||
|
# Hardening
|
||||||
|
NoNewPrivileges=true
|
||||||
|
ProtectSystem=strict
|
||||||
|
ProtectHome=true
|
||||||
|
ReadWritePaths=/var/lib/secs_gemd
|
||||||
|
DynamicUser=true
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
@@ -73,6 +73,19 @@ services:
|
|||||||
- /app/data
|
- /app/data
|
||||||
networks: [secs]
|
networks: [secs]
|
||||||
|
|
||||||
|
# A virtual fab: FAB_N secs_gemd equipment in one container (HSMS 5100+i,
|
||||||
|
# gRPC 51000+i). interop/virtual_fab.py attaches a secsgem-py host AND a
|
||||||
|
# secsgem_client tool to every one and drives seeded random traffic.
|
||||||
|
fab:
|
||||||
|
<<: *base
|
||||||
|
depends_on:
|
||||||
|
builder:
|
||||||
|
condition: service_completed_successfully
|
||||||
|
environment:
|
||||||
|
FAB_N: "${FAB_N:-3}"
|
||||||
|
command: ["bash", "tools/spawn_fab.sh"]
|
||||||
|
networks: [secs]
|
||||||
|
|
||||||
# Python container preloaded with secsgem-py 0.3.0 for cross-validation
|
# Python container preloaded with secsgem-py 0.3.0 for cross-validation
|
||||||
# of our C++ HSMS/SECS-II/GEM implementation against the reference library.
|
# of our C++ HSMS/SECS-II/GEM implementation against the reference library.
|
||||||
interop:
|
interop:
|
||||||
|
|||||||
@@ -138,6 +138,7 @@ how to run it.
|
|||||||
|---|-------|--------|
|
|---|-------|--------|
|
||||||
| [40](40_building_running_demo.md) | Building, running, the demo | Docker setup, the two-container demo, every transaction it walks through. |
|
| [40](40_building_running_demo.md) | Building, running, the demo | Docker setup, the two-container demo, every transaction it walks through. |
|
||||||
| [41](41_integration_hardware_mes_production.md) | Integration | Wiring sensors and recipes, talking to a real MES, HSMS-GS for multi-MES, persistence layout, monitoring, security hardening, performance envelope. |
|
| [41](41_integration_hardware_mes_production.md) | Integration | Wiring sensors and recipes, talking to a real MES, HSMS-GS for multi-MES, persistence layout, monitoring, security hardening, performance envelope. |
|
||||||
|
| [42](42_vendor_daemon_and_clients.md) | The vendor daemon + language clients | secs_gemd, the gRPC API and the HCACK-4 command contract, the Python client, EquipmentRuntime + per-capability registration, the threading contract, which tier to pick. |
|
||||||
|
|
||||||
### Part 5 — Reference
|
### Part 5 — Reference
|
||||||
|
|
||||||
|
|||||||
@@ -201,6 +201,39 @@ wafer was at every moment.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Daemon path (Python client)
|
||||||
|
|
||||||
|
If your tool uses the daemon (`secs_gemd`) and the Python client, the
|
||||||
|
E90 and E157 RPCs are wrapped as two methods:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from secsgem_client import Equipment, Milestone, ModuleState
|
||||||
|
eq = Equipment("localhost:50051")
|
||||||
|
|
||||||
|
# E90 — substrate journey (daemon drives FSMs, fires CEIDs automatically)
|
||||||
|
eq.report_substrate("WFR-001", Milestone.ARRIVED, carrier_id="FOUP-7", slot=3)
|
||||||
|
eq.report_substrate("WFR-001", Milestone.AT_WORK)
|
||||||
|
eq.report_substrate("WFR-001", Milestone.PROCESSING)
|
||||||
|
eq.report_substrate("WFR-001", Milestone.PROCESSED)
|
||||||
|
eq.report_substrate("WFR-001", Milestone.AT_DESTINATION)
|
||||||
|
|
||||||
|
# E157 — module state (module is auto-created on first report)
|
||||||
|
eq.report_module("CHAMBER-A", ModuleState.GENERAL_EXECUTING)
|
||||||
|
eq.report_module("CHAMBER-A", ModuleState.STEP_EXECUTING)
|
||||||
|
eq.report_module("CHAMBER-A", ModuleState.STEP_COMPLETED)
|
||||||
|
eq.report_module("CHAMBER-A", ModuleState.NOT_EXECUTING)
|
||||||
|
```
|
||||||
|
|
||||||
|
`Milestone` / `ModuleState` are importable enums (each member equals its
|
||||||
|
plain-string name, so `"ARRIVED"` works just as well). The daemon's
|
||||||
|
`ReportSubstrate` handler validates FSM transitions: a substrate that never
|
||||||
|
`ARRIVED` is rejected with `INVALID_OBJECT`, and a **duplicate** `ARRIVED`
|
||||||
|
with `CANNOT_DO_NOW` (`substrate '...' already exists`) — it never silently
|
||||||
|
re-creates over a wafer's live state. A complete worked example is
|
||||||
|
[clients/python/examples/wafer_tool.py](../clients/python/examples/wafer_tool.py).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Where to go next
|
## Where to go next
|
||||||
|
|
||||||
You now know how every component of in-flight material is
|
You now know how every component of in-flight material is
|
||||||
|
|||||||
@@ -57,7 +57,7 @@ secs-gem/
|
|||||||
│ └── fuzz_sml_parse.cpp libFuzzer harness for try_parse_sml.
|
│ └── fuzz_sml_parse.cpp libFuzzer harness for try_parse_sml.
|
||||||
│
|
│
|
||||||
├── tests/ doctest unit + integration tests.
|
├── tests/ doctest unit + integration tests.
|
||||||
│ └── test_*.cpp 50 files, 445 cases, 2753 assertions.
|
│ └── test_*.cpp 55 files, 473 cases, 3087 assertions.
|
||||||
│
|
│
|
||||||
├── data/ YAML configs (the spec-as-data).
|
├── data/ YAML configs (the spec-as-data).
|
||||||
│ ├── messages.yaml SECS-II message catalog (164 msgs).
|
│ ├── messages.yaml SECS-II message catalog (164 msgs).
|
||||||
@@ -154,7 +154,9 @@ lines). Each binary lives in `build/` after `cmake --build`.
|
|||||||
| `secs_conformance` | [`apps/secs_conformance.cpp`](../apps/secs_conformance.cpp) | 47 wire-level conformance checks against a live server. |
|
| `secs_conformance` | [`apps/secs_conformance.cpp`](../apps/secs_conformance.cpp) | 47 wire-level conformance checks against a live server. |
|
||||||
| `secs_interop_probe` | [`apps/secs_interop_probe.cpp`](../apps/secs_interop_probe.cpp) | Active host probing a secsgem-py passive equipment. |
|
| `secs_interop_probe` | [`apps/secs_interop_probe.cpp`](../apps/secs_interop_probe.cpp) | Active host probing a secsgem-py passive equipment. |
|
||||||
| `secs_bench` | [`apps/secs_bench.cpp`](../apps/secs_bench.cpp) | Throughput / latency / memory harness. |
|
| `secs_bench` | [`apps/secs_bench.cpp`](../apps/secs_bench.cpp) | Throughput / latency / memory harness. |
|
||||||
| `secsgem_tests` | All `tests/*.cpp` | The 445-case doctest binary. |
|
| `secsgem_tests` | All `tests/*.cpp` | The 473-case doctest binary. |
|
||||||
|
| `secs_gemd` | `apps/secs_gemd.cpp` + `proto/secsgem/v1` | The gRPC daemon: HSMS equipment + name-based tool API. |
|
||||||
|
| `secs_gemd_tests` | `tests/test_daemon_service.cpp` | In-process gRPC service tests (built when grpc++ is). |
|
||||||
| `fuzz_secs2_decode` | [`apps/fuzz_secs2_decode.cpp`](../apps/fuzz_secs2_decode.cpp) | libFuzzer (clang only, opt-in `-DSECSGEM_FUZZ=ON`). |
|
| `fuzz_secs2_decode` | [`apps/fuzz_secs2_decode.cpp`](../apps/fuzz_secs2_decode.cpp) | libFuzzer (clang only, opt-in `-DSECSGEM_FUZZ=ON`). |
|
||||||
| `fuzz_sml_parse` | [`apps/fuzz_sml_parse.cpp`](../apps/fuzz_sml_parse.cpp) | libFuzzer for the SML parser. |
|
| `fuzz_sml_parse` | [`apps/fuzz_sml_parse.cpp`](../apps/fuzz_sml_parse.cpp) | libFuzzer for the SML parser. |
|
||||||
|
|
||||||
@@ -196,7 +198,7 @@ standard CMake.
|
|||||||
|
|
||||||
## Test layout
|
## Test layout
|
||||||
|
|
||||||
50 test files; 445 test cases; 2 753 assertions. One file per
|
50 test files; 473 test cases; 3 087 assertions. One file per
|
||||||
concern. Naming is `test_<thing>.cpp` consistently:
|
concern. Naming is `test_<thing>.cpp` consistently:
|
||||||
|
|
||||||
- `test_secs2.cpp`, `test_e5_kat.cpp`, `test_sml.cpp`,
|
- `test_secs2.cpp`, `test_e5_kat.cpp`, `test_sml.cpp`,
|
||||||
|
|||||||
@@ -134,11 +134,21 @@ ceids:
|
|||||||
- {id: 300, name: ProcessStarted}
|
- {id: 300, name: ProcessStarted}
|
||||||
|
|
||||||
alarms:
|
alarms:
|
||||||
- {id: 1, alcd: 0x84, text: "Chamber pressure above threshold"}
|
# `category` is the lower-7 ALCD severity bitmap; bit 7 (set/clear) is
|
||||||
|
# applied at emit time and must NOT be in YAML. `name` is an optional
|
||||||
|
# local key for name-based APIs (the gRPC daemon / Python client).
|
||||||
|
- {id: 1, name: pressure_high, category: 4,
|
||||||
|
text: "Chamber pressure above threshold"}
|
||||||
|
|
||||||
host_commands:
|
host_commands:
|
||||||
- {name: START, ack: Accept, emit_ceid: 300}
|
- {name: START, ack: Accept, emit_ceid: 300}
|
||||||
- {name: FAULT, ack: Accept, set_alarm: 1}
|
- {name: FAULT, ack: Accept, set_alarm: 1}
|
||||||
|
|
||||||
|
# Role bindings: which of the ids above the engine's built-in behaviours
|
||||||
|
# target (control-state/clock SVID refresh, CJ state CEIDs).
|
||||||
|
roles:
|
||||||
|
control_state_svid: 1
|
||||||
|
clock_svid: 2
|
||||||
```
|
```
|
||||||
|
|
||||||
Loaded at startup by `config::load_equipment`. Every key under
|
Loaded at startup by `config::load_equipment`. Every key under
|
||||||
|
|||||||
@@ -59,12 +59,16 @@ router->on(2, 41, [model](const auto& m) {
|
|||||||
return messages::s2f42(ack);
|
return messages::s2f42(ack);
|
||||||
});
|
});
|
||||||
|
|
||||||
// ...one per S/F pair. apps/secs_server.cpp registers ~50.
|
// ...one per S/F pair. The default GEM set (56) lives in
|
||||||
|
// src/gem/default_handlers.cpp, decomposed into 15 per-capability
|
||||||
|
// register_* functions; register_default_handlers(runtime) wires them all.
|
||||||
```
|
```
|
||||||
|
|
||||||
The `examples/pvd_tool/main.cpp` §6 register 51 handlers in ~460
|
The `examples/pvd_tool/main.cpp` §6 gets all 56 with ONE call —
|
||||||
lines. Each handler is a few lines: parse the body, mutate or read
|
`register_default_handlers(runtime)` — then adds tool behaviour via
|
||||||
a store, build the reply.
|
`commands.set_handler`. Inside the capability functions each handler is
|
||||||
|
still a few lines: parse the body, mutate or read a store, build the
|
||||||
|
reply.
|
||||||
|
|
||||||
### What happens for unhandled primaries
|
### What happens for unhandled primaries
|
||||||
|
|
||||||
|
|||||||
@@ -53,7 +53,9 @@ build/
|
|||||||
├── secs_conformance 47-check conformance harness
|
├── secs_conformance 47-check conformance harness
|
||||||
├── secs_interop_probe active host probing secsgem-py equipment
|
├── secs_interop_probe active host probing secsgem-py equipment
|
||||||
├── secs_bench throughput/latency bench
|
├── secs_bench throughput/latency bench
|
||||||
├── secsgem_tests the 445-case doctest binary
|
├── secsgem_tests the 473-case doctest binary
|
||||||
|
├── secs_gemd gRPC daemon: HSMS equipment + name-based tool API
|
||||||
|
├── secs_gemd_tests in-process gRPC service tests (when grpc++ present)
|
||||||
└── pvd_tool worked PVD-tool example
|
└── pvd_tool worked PVD-tool example
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -74,8 +76,8 @@ Runs `secsgem_tests` end-to-end. Expected output:
|
|||||||
[doctest] doctest version is "2.4.11"
|
[doctest] doctest version is "2.4.11"
|
||||||
[doctest] run with "--help" for options
|
[doctest] run with "--help" for options
|
||||||
===============================================================================
|
===============================================================================
|
||||||
[doctest] test cases: 445 | 445 passed | 0 failed | 0 skipped
|
[doctest] test cases: 473 | 473 passed | 0 failed | 0 skipped
|
||||||
[doctest] assertions: 2753 | 2753 passed | 0 failed |
|
[doctest] assertions: 3087 | 3087 passed | 0 failed |
|
||||||
[doctest] Status: SUCCESS!
|
[doctest] Status: SUCCESS!
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ the worked reference. Section by section:
|
|||||||
| §3 Recipe runner | PJ → SettingUp → Processing → ProcessComplete walk; per-step CEID emit |
|
| §3 Recipe runner | PJ → SettingUp → Processing → ProcessComplete walk; per-step CEID emit |
|
||||||
| §4 Alarm threshold monitor | Continuous threshold evaluation against ECID setpoints |
|
| §4 Alarm threshold monitor | Continuous threshold evaluation against ECID setpoints |
|
||||||
| §5 EPT cycling | E116 transitions driven by PJ state + safety alarms |
|
| §5 EPT cycling | E116 transitions driven by PJ state + safety alarms |
|
||||||
| §6 Router handlers | 51 handlers in ~460 lines — every S/F a host might send to a PVD tool |
|
| §6 main() | `EquipmentRuntime` + `register_default_handlers` (all 56) + `set_handler` for START — was 51 hand-written handlers before the runtime |
|
||||||
| §7 main() | YAML load → validate → compose → run |
|
| §7 main() | YAML load → validate → compose → run |
|
||||||
|
|
||||||
A real tool fork:
|
A real tool fork:
|
||||||
|
|||||||
@@ -0,0 +1,297 @@
|
|||||||
|
# 42 — The vendor daemon and language clients
|
||||||
|
|
||||||
|
Chapters 30–41 teach the embedded C++ path: your `main()` owns the engine.
|
||||||
|
This chapter teaches the **daemon path** — the engine as its own process,
|
||||||
|
your tool software in any language on the other side of a socket — and the
|
||||||
|
runtime/capability API both paths share. If you are integrating a tool and
|
||||||
|
your controller is not C++, start here.
|
||||||
|
|
||||||
|
Everything in this chapter is exercised by `tools/run_interop.sh` (the
|
||||||
|
`daemon`, `pyclient`, and `daemon-unit` steps) against the secsgem-py
|
||||||
|
reference host. Status and remaining work: [DAEMON_ROADMAP.md](DAEMON_ROADMAP.md).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Why a daemon at all
|
||||||
|
|
||||||
|
A fab host enforces timers: T3 reply timeouts, T6 control transactions,
|
||||||
|
linktest heartbeats. If the GEM stack lives inside your tool application,
|
||||||
|
every crash, upgrade, GC pause, or hung hardware call of that application
|
||||||
|
is a **communication failure the fab can see** — and in production that
|
||||||
|
can mean the tool gets taken offline and lots get held.
|
||||||
|
|
||||||
|
`secs_gemd` inverts the deployment: the daemon owns the durable HSMS
|
||||||
|
relationship and answers the host from its own process, around the clock.
|
||||||
|
Your tool software connects over gRPC, pushes values and events in, and
|
||||||
|
receives host commands out. It can restart any time; the host never
|
||||||
|
notices. Spooling (chapter 13 §spool) covers the gap if the *host* link
|
||||||
|
drops; the daemon model covers the gap if *your software* drops.
|
||||||
|
|
||||||
|
```
|
||||||
|
tool software (any language) secs_gemd fab host / MES
|
||||||
|
┌──────────────────────────┐ gRPC ┌──────────────────────────┐ HSMS ┌────────┐
|
||||||
|
│ set / fire / alarm │◄─────►│ EquipmentRuntime │◄────►│ MES │
|
||||||
|
│ @command / @on handlers │ :50051│ + register_default_* │SECS-II└────────┘
|
||||||
|
│ (restartable, crashable) │ │ + spool, timers, FSMs │
|
||||||
|
└──────────────────────────┘ └──────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
Run it:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
build/secs_gemd --port 5000 --config-dir data # gRPC on 127.0.0.1:50051
|
||||||
|
build/secs_gemd --grpc unix:///run/secs_gemd/api.sock … # production: no TCP at all
|
||||||
|
```
|
||||||
|
|
||||||
|
One process, two faces: passive HSMS equipment on `--port`, the gRPC tool
|
||||||
|
API on `--grpc` (localhost by default — see §5 before exposing anything).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. The API contract (`proto/secsgem/v1/equipment.proto`)
|
||||||
|
|
||||||
|
The proto is the source of truth; read it — it is heavily commented. The
|
||||||
|
shape in one breath: everything is **name-based** (the names from your
|
||||||
|
`equipment.yaml`; never numeric SVIDs/CEIDs/ALIDs) and **plain-typed**
|
||||||
|
(a `Value` oneof of text/integer/real/boolean/binary/list; the daemon
|
||||||
|
converts to each variable's declared SECS-II format, so an F4 variable
|
||||||
|
stays F4 on the wire no matter what you send).
|
||||||
|
|
||||||
|
| RPC | What it does |
|
||||||
|
|---|---|
|
||||||
|
| `SetVariables` / `GetVariables` | write/read variables by name |
|
||||||
|
| `FireEvent` | trigger a collection event; daemon assembles the configured report → S6F11 |
|
||||||
|
| `SetAlarm` / `ClearAlarm` | S5F1 set/clear, by alarm `name:` (or stringified ALID) |
|
||||||
|
| `GetControlState` / `RequestControlState` | read the E30 control state / operator transitions |
|
||||||
|
| `ReportCarrier` | E87 carrier state transitions (WAITING / IN_ACCESS / COMPLETE) |
|
||||||
|
| `ReportSubstrate` | E90 wafer tracking (ARRIVED / AT_WORK / PROCESSING / PROCESSED / AT_DESTINATION) |
|
||||||
|
| `ReportModule` | E157 module tracking (NOT_EXECUTING / GENERAL_EXECUTING / STEP_EXECUTING / STEP_COMPLETED) |
|
||||||
|
| `WatchHealth` | server stream: link state, control state, spool depth |
|
||||||
|
| `Subscribe` | server stream: everything the host asks of the tool |
|
||||||
|
| `CompleteCommand` | close a streamed command's audit entry |
|
||||||
|
| `Describe` | all names this equipment exposes (variables, events, alarms, commands, constants) |
|
||||||
|
| `FlushSpool` | drain or discard spooled messages |
|
||||||
|
| `SendTerminalMessage` | S10F1 operator message to the host |
|
||||||
|
|
||||||
|
### The HCACK-4 command contract
|
||||||
|
|
||||||
|
The one piece of SEMI behaviour you must understand: when the host sends a
|
||||||
|
remote command (S2F41 START), the daemon does **not** wait for your tool.
|
||||||
|
It answers the host immediately:
|
||||||
|
|
||||||
|
- **No tool subscribed** → the command's declarative ack from
|
||||||
|
`equipment.yaml` (exactly the pre-daemon behaviour; nothing buffered,
|
||||||
|
nothing replayed later).
|
||||||
|
- **Tool subscribed** → `HCACK=4` ("accepted, will finish later"), and the
|
||||||
|
command appears on your `Subscribe` stream with its parameters.
|
||||||
|
|
||||||
|
The S2F42 transaction is already closed by the time you see the command.
|
||||||
|
The host learns the *real* outcome the way E30 intends: from the
|
||||||
|
**collection event you fire on success** (or the alarm you raise on
|
||||||
|
failure). `CompleteCommand` only feeds the daemon's audit log. This is the
|
||||||
|
same pattern secsgem-py applications and commercial GEM gateways use — the
|
||||||
|
protocol was designed for it.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. The Python client (`clients/python`)
|
||||||
|
|
||||||
|
`pip install` the package (pure Python — pre-generated stubs, no compiled
|
||||||
|
extension) and the entire integration is:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from secsgem_client import Equipment, Milestone, ModuleState
|
||||||
|
|
||||||
|
with Equipment("localhost:50051") as eq: # context manager closes the channel
|
||||||
|
|
||||||
|
eq.set(ChamberPressure=2.5) # host sees it on its next S1F3
|
||||||
|
eq["WaferCounter"] = 7 # item syntax, same thing
|
||||||
|
print(eq.get("ChamberPressure")) # read back through the daemon
|
||||||
|
|
||||||
|
# eq.names — autocomplete-able, typo-safe name lookup (fetched from Describe)
|
||||||
|
eq.fire(eq.names.event.ProcessStarted) # typo → AttributeError at the line it happened
|
||||||
|
eq.alarm(eq.names.alarm.chiller_temp_high)
|
||||||
|
eq.clear(eq.names.alarm.chiller_temp_high)
|
||||||
|
# Plain strings still work; names are a convenience, not a requirement.
|
||||||
|
eq.fire("ProcessStarted", ChamberPressure=2.75)
|
||||||
|
|
||||||
|
@eq.command # function name IS the command name;
|
||||||
|
def START(cmd): # validated against Describe at decoration time
|
||||||
|
run_recipe(cmd.params.get("PPID"))
|
||||||
|
eq.fire(eq.names.event.ProcessStarted)
|
||||||
|
|
||||||
|
# @eq.on("NAME") still works — use it when the name can't be a Python identifier
|
||||||
|
# or when you prefer explicit strings.
|
||||||
|
|
||||||
|
eq.listen(background=True) # consume the Subscribe stream
|
||||||
|
|
||||||
|
eq.control_state # "ONLINE_REMOTE"
|
||||||
|
eq.request_control_state("HOST_OFFLINE") # operator panel -> maintenance
|
||||||
|
eq.health() # link / control state / spool depth
|
||||||
|
|
||||||
|
# E90 / E157 material tracking. Milestone / ModuleState are importable
|
||||||
|
# enums (autocomplete + typo-checked); the equivalent plain strings work too.
|
||||||
|
eq.report_substrate("WFR-001", Milestone.ARRIVED, carrier_id="FOUP-7", slot=3)
|
||||||
|
eq.report_substrate("WFR-001", Milestone.AT_WORK)
|
||||||
|
eq.report_substrate("WFR-001", Milestone.PROCESSING)
|
||||||
|
eq.report_substrate("WFR-001", Milestone.PROCESSED)
|
||||||
|
eq.report_substrate("WFR-001", Milestone.AT_DESTINATION)
|
||||||
|
|
||||||
|
eq.report_module("CHAMBER-A", ModuleState.GENERAL_EXECUTING)
|
||||||
|
eq.report_module("CHAMBER-A", ModuleState.STEP_EXECUTING)
|
||||||
|
eq.report_module("CHAMBER-A", ModuleState.STEP_COMPLETED)
|
||||||
|
eq.report_module("CHAMBER-A", ModuleState.NOT_EXECUTING)
|
||||||
|
```
|
||||||
|
|
||||||
|
Two error channels, by design: a **bad value you control** (a misspelled
|
||||||
|
milestone, an unknown control state) raises a plain `ValueError`/`NameError`
|
||||||
|
*before* any round-trip, with a close-match hint; anything the **daemon**
|
||||||
|
declines (unknown variable name, illegal FSM transition) raises `SecsGemError`
|
||||||
|
with its explanation (`no variable named 'ChamberPresure'`). Runnable tools:
|
||||||
|
[mini_tool.py](../clients/python/examples/mini_tool.py) (~25-line quickstart)
|
||||||
|
and [wafer_tool.py](../clients/python/examples/wafer_tool.py) (E90/E157
|
||||||
|
material tracking). The package is validated end-to-end by
|
||||||
|
`interop/pyclient_interop.py` driving the published API while secsgem-py
|
||||||
|
judges the wire.
|
||||||
|
|
||||||
|
### `eq.names` — name autocomplete and typo safety
|
||||||
|
|
||||||
|
`eq.names` fetches `Describe` from the daemon once (lazy, cached), then
|
||||||
|
exposes five sub-namespaces:
|
||||||
|
|
||||||
|
```python
|
||||||
|
eq.names.event.ProcessStarted # → "ProcessStarted"
|
||||||
|
eq.names.alarm.chiller_temp_high # → "chiller_temp_high"
|
||||||
|
eq.names.command.START # → "START"
|
||||||
|
eq.names.var.ChamberPressure # → "ChamberPressure"
|
||||||
|
eq.names.constant.MaxPressure # → "MaxPressure"
|
||||||
|
|
||||||
|
# Typo → AttributeError with suggestions:
|
||||||
|
# AttributeError: no event named 'ProcessStated'. Did you mean ProcessStarted?
|
||||||
|
|
||||||
|
# Membership test — useful in @eq.on guards:
|
||||||
|
"START" in eq.names.command # → True
|
||||||
|
```
|
||||||
|
|
||||||
|
`dir(eq.names.event)` lists all event names — REPL and IDE autocomplete
|
||||||
|
work out of the box.
|
||||||
|
|
||||||
|
### Names vs. enums — one rule
|
||||||
|
|
||||||
|
There are two kinds of identifier in the API, split on whether *your tool* or
|
||||||
|
*the SEMI standard* owns the value:
|
||||||
|
|
||||||
|
- **Equipment-specific names** — events, alarms, commands, variables,
|
||||||
|
constants — come from *your* `equipment.yaml`, so they live on the instance
|
||||||
|
as `eq.names.*` (fetched from the live daemon).
|
||||||
|
- **Fixed protocol value-sets** — `Milestone`, `ModuleState`, `JobState` —
|
||||||
|
are defined by the standards, so they're importable enums
|
||||||
|
(`from secsgem_client import Milestone`). `Milestone.ARRIVED == "ARRIVED"`,
|
||||||
|
so an enum member and its plain string are interchangeable; the enum just
|
||||||
|
buys you autocomplete and a typo-checked happy path.
|
||||||
|
|
||||||
|
Either way a wrong value fails fast and helpfully — a `ValueError`/`NameError`
|
||||||
|
with a close-match suggestion, raised client-side before the wire.
|
||||||
|
|
||||||
|
Other languages: generate stubs from the proto (`protoc` supports 11+
|
||||||
|
languages) and wrap them the same way — the Python client is ~200 lines
|
||||||
|
and is the reference for what a thin wrapper should feel like.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. The shared core: `EquipmentRuntime` + capability registration
|
||||||
|
|
||||||
|
Both `secs_server` and `secs_gemd` (and any future surface) are thin
|
||||||
|
fronts over the same two calls:
|
||||||
|
|
||||||
|
```cpp
|
||||||
|
#include "secsgem/gem/runtime.hpp"
|
||||||
|
#include "secsgem/gem/default_handlers.hpp"
|
||||||
|
|
||||||
|
gem::EquipmentRuntime::Config cfg;
|
||||||
|
cfg.equipment_yaml = "data/equipment.yaml";
|
||||||
|
cfg.control_state_yaml = "data/control_state.yaml";
|
||||||
|
cfg.process_job_yaml = "data/process_job_state.yaml";
|
||||||
|
cfg.control_job_yaml = "data/control_job_state.yaml";
|
||||||
|
cfg.port = 5000;
|
||||||
|
cfg.log = [](const std::string& m) { std::cout << m << std::endl; };
|
||||||
|
|
||||||
|
gem::EquipmentRuntime R(cfg);
|
||||||
|
gem::register_default_handlers(R); // all 56 GEM handlers + emitters
|
||||||
|
R.run(); // accept + io_context (blocks)
|
||||||
|
```
|
||||||
|
|
||||||
|
`register_default_handlers` is the composition of **15 per-capability
|
||||||
|
functions** (`register_identification`, `register_alarms`,
|
||||||
|
`register_carriers`, `register_jobs`, …) mirroring how E30 itself lists
|
||||||
|
capabilities (S1F19). A sensor-class tool with no carriers or jobs
|
||||||
|
registers only what it is — the unregistered messages get the Router's
|
||||||
|
SxF0/S9 default treatment, which is exactly what "I don't implement that
|
||||||
|
capability" should look like on the wire.
|
||||||
|
|
||||||
|
The ids the built-ins touch (the control-state/clock SVIDs the engine
|
||||||
|
refreshes, the CEIDs fired on CJ state changes) come from the `roles:`
|
||||||
|
block in `equipment.yaml` — visible coupling, no magic constants.
|
||||||
|
|
||||||
|
### The threading contract (the one rule)
|
||||||
|
|
||||||
|
One io_context thread owns the model. From any other thread:
|
||||||
|
|
||||||
|
- **writes** go through the runtime's posting API
|
||||||
|
(`set_variable`, `emit_event`, `set_alarm`, `clear_alarm`);
|
||||||
|
- **reads of mutable state** go through `read_sync` (post + future with a
|
||||||
|
deadline) — `control_state()` alone is lock-free (atomic mirror);
|
||||||
|
- **behaviour hooks** (`commands.set_handler`, state-change observers) run
|
||||||
|
*on* the io thread: return promptly, post long work elsewhere.
|
||||||
|
|
||||||
|
This is TSan-enforced in CI, daemon included. The first violation ever
|
||||||
|
caught was in our own test — the lane works.
|
||||||
|
|
||||||
|
### Observers vs. the primary slot
|
||||||
|
|
||||||
|
State machines expose `set_state_change_handler` (the primary slot —
|
||||||
|
yours, replaceable) **and** `add_state_change_handler` (append-only
|
||||||
|
observers that survive the primary being set). The runtime and daemon use
|
||||||
|
observers for the control-state mirror, `WatchHealth`, and the command
|
||||||
|
stream, so they never fight your application over the slot.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Running it in production
|
||||||
|
|
||||||
|
- **Exposure.** `--grpc` defaults to `127.0.0.1:50051`; the API is
|
||||||
|
unauthenticated by design (auth belongs to the transport), so it must
|
||||||
|
never face the equipment LAN. For same-host tool software use a Unix
|
||||||
|
domain socket — `--grpc unix:///run/secs_gemd/api.sock` — and there is
|
||||||
|
no network surface at all. The HSMS port faces the fab host; firewall
|
||||||
|
it to the host's address ([SECURITY.md](SECURITY.md) has the nftables
|
||||||
|
recipe).
|
||||||
|
- **Shutdown.** SIGTERM/SIGINT drain gracefully: open Subscribe/WatchHealth
|
||||||
|
streams are cancelled (2s deadline), the engine stops cleanly, the spool
|
||||||
|
journal is never cut mid-write, exit code 0. Safe under systemd and
|
||||||
|
`docker stop`.
|
||||||
|
- **Supervision.** [deploy/secs_gemd.service](../deploy/secs_gemd.service)
|
||||||
|
is a hardened systemd unit (DynamicUser, ProtectSystem, StateDirectory
|
||||||
|
for the spool, Restart=always). Pair with `--spool-dir` so host-bound
|
||||||
|
events survive daemon restarts too.
|
||||||
|
- **Metrics.** `--metrics 9091` serves Prometheus gauges:
|
||||||
|
`secsgem_link_selected`, `secsgem_control_state`, `secsgem_spool_depth`.
|
||||||
|
- **Sessions.** v1 runs one equipment identity per daemon (HSMS-SS). The
|
||||||
|
engine supports HSMS-GS multi-session, but the daemon doesn't surface it
|
||||||
|
yet — run one daemon per equipment identity.
|
||||||
|
- All of the above is enforced by `tools/check_daemon_ops.sh` (the
|
||||||
|
`daemon-ops` step of `tools/run_interop.sh`, also in CI).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Which tier do I pick?
|
||||||
|
|
||||||
|
| Your situation | Tier |
|
||||||
|
|---|---|
|
||||||
|
| New tool, Python anywhere in the controller, fastest start | Python client |
|
||||||
|
| Existing controller in C#/Java/Go/…; multi-process architecture; tool app must be restartable without the host noticing | gRPC daemon + thin client |
|
||||||
|
| In-process integration, custom transports, hard real-time adjacency | Embedded C++ (`EquipmentRuntime`, chapter 41) |
|
||||||
|
|
||||||
|
They compose: a C++ tool can still run the daemon for the HSMS face and
|
||||||
|
talk gRPC locally — that is precisely the "tool software + separate
|
||||||
|
SECS server" deployment many fabs already run.
|
||||||
@@ -98,6 +98,32 @@ often the right answer — this chapter helps you find which header.
|
|||||||
`include/secsgem/gem/store/` — 18 per-domain stores. See
|
`include/secsgem/gem/store/` — 18 per-domain stores. See
|
||||||
chapter [32](32_stores_and_the_data_model.md) for the full table.
|
chapter [32](32_stores_and_the_data_model.md) for the full table.
|
||||||
|
|
||||||
|
Engine-owner and default-behaviour entry points (added with the daemon
|
||||||
|
track; see [DAEMON_ROADMAP.md](DAEMON_ROADMAP.md)):
|
||||||
|
|
||||||
|
```cpp
|
||||||
|
#include "secsgem/gem/runtime.hpp" // EquipmentRuntime: owns
|
||||||
|
// io_context + Server + model + control FSM + Router; thread-safe
|
||||||
|
// set_variable/emit_event/set_alarm/clear_alarm, on_command hook,
|
||||||
|
// read_sync (the standard cross-thread read), control-state mirror,
|
||||||
|
// add_control_state_observer / add_link_observer.
|
||||||
|
#include "secsgem/gem/default_handlers.hpp" // the 56 GEM handlers as 15
|
||||||
|
// per-capability register_* functions + register_default_handlers.
|
||||||
|
#include "secsgem/gem/handler_slot.hpp" // primary + observer handler slots
|
||||||
|
#include "secsgem/gem/name_index.hpp" // name -> VID/CEID resolution
|
||||||
|
```
|
||||||
|
|
||||||
|
### `secsgem::daemon` — the gRPC vendor surface
|
||||||
|
|
||||||
|
```cpp
|
||||||
|
#include "secsgem/daemon/equipment_service.hpp" // EquipmentService: the
|
||||||
|
// proto/secsgem/v1 Equipment service over an EquipmentRuntime
|
||||||
|
// (SetVariables/GetVariables/FireEvent/SetAlarm/ClearAlarm/
|
||||||
|
// GetControlState/RequestControlState/WatchHealth/Subscribe/
|
||||||
|
// CompleteCommand). Built into apps/secs_gemd.cpp; the Python
|
||||||
|
// client in clients/python wraps the same proto.
|
||||||
|
```
|
||||||
|
|
||||||
### `secsgem::config` — YAML loader + validator (chapter 31, 36)
|
### `secsgem::config` — YAML loader + validator (chapter 31, 36)
|
||||||
|
|
||||||
```cpp
|
```cpp
|
||||||
|
|||||||
+16
-4
@@ -38,13 +38,25 @@ doc covers.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 2. The five layers
|
## 2. The layers
|
||||||
|
|
||||||
```
|
```
|
||||||
┌─────────────────────────────────────────────────────────────────┐
|
┌─────────────────────────────────────────────────────────────────┐
|
||||||
|
│ vendor surfaces (out-of-process) │
|
||||||
|
│ secs_gemd gRPC daemon (proto/secsgem/v1) ← clients/python │
|
||||||
|
│ secsgem-client and any-language gRPC stubs │
|
||||||
|
├─────────────────────────────────────────────────────────────────┤
|
||||||
│ apps/ (your main.cpp lives here) │
|
│ apps/ (your main.cpp lives here) │
|
||||||
│ secs_server, secs_client, secs_conformance, secs_bench, │
|
│ secs_server, secs_client, secs_gemd, secs_conformance, │
|
||||||
│ fuzz_*, secs_interop_probe │
|
│ secs_bench, fuzz_*, secs_interop_probe │
|
||||||
|
├─────────────────────────────────────────────────────────────────┤
|
||||||
|
│ gem::EquipmentRuntime + register_* capability functions │
|
||||||
|
│ ───────────────────────────────────────────────────── │
|
||||||
|
│ Runtime: owns io_context + Server + model + control FSM + │
|
||||||
|
│ Router; thread-safe set/emit/alarm API, on_command hook, │
|
||||||
|
│ read_sync, control-state mirror, link/state observers │
|
||||||
|
│ default_handlers: the 56 GEM handlers as 15 per-capability │
|
||||||
|
│ register_* fns (ids bound via the config's roles: block) │
|
||||||
├─────────────────────────────────────────────────────────────────┤
|
├─────────────────────────────────────────────────────────────────┤
|
||||||
│ gem::Router + gem::EquipmentDataModel │
|
│ gem::Router + gem::EquipmentDataModel │
|
||||||
│ ───────────────────────────────────────── │
|
│ ───────────────────────────────────────── │
|
||||||
@@ -469,7 +481,7 @@ contract has no locks; adding any would diverge from it.
|
|||||||
| How the Router dispatches | `gem/router.hpp` |
|
| How the Router dispatches | `gem/router.hpp` |
|
||||||
| How a store implements persistence | `gem/store/spool.hpp` (smallest), `gem/store/process_jobs.hpp` (richest) |
|
| How a store implements persistence | `gem/store/spool.hpp` (smallest), `gem/store/process_jobs.hpp` (richest) |
|
||||||
| How an FSM is structured | `gem/process_job_state.hpp`, `src/gem/process_job_state.cpp` |
|
| How an FSM is structured | `gem/process_job_state.hpp`, `src/gem/process_job_state.cpp` |
|
||||||
| How the application wires it all | `apps/secs_server.cpp` (the canonical example, ~1200 lines) |
|
| How the application wires it all | `gem::EquipmentRuntime` + `register_default_handlers` (apps/secs_server.cpp is now a ~110-line thin main over them) |
|
||||||
| How a customer would write main() | `examples/pvd_tool/main.cpp` (the worked vendor example) |
|
| How a customer would write main() | `examples/pvd_tool/main.cpp` (the worked vendor example) |
|
||||||
| How thread-safety works | `tests/test_thread_safety.cpp`, INTEGRATION.md §3 |
|
| How thread-safety works | `tests/test_thread_safety.cpp`, INTEGRATION.md §3 |
|
||||||
| How E84 timers integrate with asio | `gem/e84_asio_timers.hpp` (the canonical I/O-adapter pattern) |
|
| How E84 timers integrate with asio | `gem/e84_asio_timers.hpp` (the canonical I/O-adapter pattern) |
|
||||||
|
|||||||
+1
-1
@@ -346,7 +346,7 @@ walks ~20 SECS transactions end-to-end:
|
|||||||
23. `S1F15`/`S1F16` Request Offline.
|
23. `S1F15`/`S1F16` Request Offline.
|
||||||
24. `Separate.req` → clean close on both sides.
|
24. `Separate.req` → clean close on both sides.
|
||||||
|
|
||||||
Unit tests: **445 cases / 2753 assertions pass** (`docker compose run --rm tests`).
|
Unit tests: **473 cases / 3087 assertions pass** (`docker compose run --rm tests`).
|
||||||
The suite includes integration tests that drive a real `hsms::Connection`
|
The suite includes integration tests that drive a real `hsms::Connection`
|
||||||
over a loopback socket pair to verify the E37 §7.2 / §7.4 / §7.7
|
over a loopback socket pair to verify the E37 §7.2 / §7.4 / §7.7
|
||||||
edge cases — not just the happy path.
|
edge cases — not just the happy path.
|
||||||
|
|||||||
@@ -1,175 +0,0 @@
|
|||||||
# Vendor Daemon & gRPC API — Status, Known Issues, and Plan to Fab-Readiness
|
|
||||||
|
|
||||||
> **This is a forward-looking roadmap, not a description of shipped behaviour.**
|
|
||||||
> Every item carries a status marker. Do not read an item as "done" unless it
|
|
||||||
> says ✅. (Last full audit: 2026-06-10.)
|
|
||||||
>
|
|
||||||
> Status legend: ✅ done · 🚧 in progress · ⬜ planned · ⚠️ risk/unknown
|
|
||||||
|
|
||||||
## What this is
|
|
||||||
|
|
||||||
A vendor-facing **daemon** (`secs_gemd`) that runs the SECS/GEM engine as its
|
|
||||||
own process and exposes a small, name-based, language-agnostic API over gRPC,
|
|
||||||
so a tool's control software (in any language) can drive the equipment without
|
|
||||||
linking C++ or knowing SEMI. See `proto/secsgem/v1/equipment.proto`.
|
|
||||||
|
|
||||||
The point of the daemon model: it owns the durable HSMS relationship with the
|
|
||||||
host and stays conformant while the tool software restarts/upgrades/crashes.
|
|
||||||
|
|
||||||
## Current status (2026-06-10, end of day)
|
|
||||||
|
|
||||||
| Piece | Status | Notes |
|
|
||||||
|---|---|---|
|
|
||||||
| `proto/secsgem/v1/equipment.proto` | ✅ | v1 surface designed: universal + carrier/recipe/job tiers, `Subscribe` stream, health |
|
|
||||||
| `HostCommandRegistry::set_handler` behaviour hook | ✅ | the engine seam for command behaviour; tested |
|
|
||||||
| `EquipmentRuntime` (engine owner) | ✅ | tested (`test_runtime.cpp`); `secs_server` runs entirely on it (live GEM300 demo passes) |
|
|
||||||
| `register_default_handlers` (the 56 GEM handlers as a library fn) | ✅ | `src/gem/default_handlers.cpp`; tested (`test_default_handlers.cpp`) |
|
|
||||||
| gRPC/protobuf toolchain (Dockerfile + CMake codegen) | ✅ | grpc++ 1.51 / protoc 3.21; opt-in `SECSGEM_DAEMON`, graceful skip without grpc |
|
|
||||||
| `secs_gemd`: `SetVariables` / `FireEvent` / `GetControlState` | ✅ | **format-aware** (converts to each variable's declared SECS-II format) and thread-safe (name/format maps snapshotted at construction; all writes post to the io thread). In-process gRPC tests (`test_daemon_service.cpp`, 16 assertions) |
|
|
||||||
| Daemon interop vs **secsgem-py** reference host | ✅ | `interop/daemon_interop.py` (via `gemd` compose service): gRPC `SetVariables(ChamberPressure=2.5)` + `FireEvent` → host receives `S6F11 CEID 300` carrying `<F4 2.5>` — value *and declared format* flow gRPC→engine→HSMS→host |
|
|
||||||
| Daemon interop vs **secs4j** (Java) | ⬜ | mirror the secsgem-py harness against `interop/secs4j` |
|
|
||||||
| `Subscribe` host→tool command stream | ⬜ | design settled (HCACK-4, see below); not implemented |
|
|
||||||
| Remaining universal RPCs (`GetVariables`, alarms, `RequestControlState`, `WatchHealth`) | ⬜ | see plan |
|
|
||||||
| Python client package (the "beautiful API") | ⬜ | thin wrapper over generated stubs |
|
|
||||||
|
|
||||||
## Known issues (found in the 2026-06-10 audit; honest list)
|
|
||||||
|
|
||||||
- ✅ ~~**`GetControlState` cross-thread read.**~~ Fixed 2026-06-10: the runtime
|
|
||||||
keeps an atomic control-state mirror updated via an `add_state_change_handler`
|
|
||||||
observer (`HandlerSlot` primary+observers pattern), so the mirror survives
|
|
||||||
`register_default_handlers` claiming the primary slot. `control_state()` is
|
|
||||||
now safe from any thread.
|
|
||||||
- ⬜ **Alarms have no name key.** `equipment.yaml` alarms carry only numeric
|
|
||||||
`id` + freetext `text` (matches SEMI: ALID/ALTX; there is no standard short
|
|
||||||
name). The name-based `SetAlarm`/`ClearAlarm` RPCs need an optional local
|
|
||||||
`name:` field in the alarm config (fallback: stringified id).
|
|
||||||
- ⬜ **`pvd_tool` predates the behaviour hook.** It still hard-codes
|
|
||||||
`if (rcmd=="START") recipe->start(...)` in a router handler. Migrate it to
|
|
||||||
`commands.set_handler` so the flagship example showcases the intended seam.
|
|
||||||
- ⬜ **Interop harnesses are manual.** `daemon_interop.py` (and the older
|
|
||||||
host/server harnesses) run via ad-hoc compose invocations; there is no
|
|
||||||
`tools/run_interop.sh` or CI lane that runs them. Add one script + CI job.
|
|
||||||
- ⬜ **TSan lane doesn't cover the daemon.** `secs_gemd_tests` should also be
|
|
||||||
built/run under `-DSECSGEM_TSAN=ON` once the control-state mirror lands.
|
|
||||||
- ⚠️ **macOS bind-mount staleness can break Docker builds mid-edit** (a build
|
|
||||||
reading a half-synced source file). Not a product bug; re-run the build.
|
|
||||||
|
|
||||||
## The `Subscribe` design (settled — implement to this)
|
|
||||||
|
|
||||||
`S2F42` is an *acknowledgement*, not a completion: SEMI separates "I accept
|
|
||||||
your command" from "the work finished". The conformant, non-blocking flow:
|
|
||||||
|
|
||||||
1. Host sends `S2F41 START`. The engine's `on_command` handler (registered by
|
|
||||||
the daemon) runs on the io thread.
|
|
||||||
2. If no tool client is subscribed → fall back to the YAML declarative ack.
|
|
||||||
If a tool is subscribed → push the command onto its `Subscribe` stream and
|
|
||||||
**return `HCACK=4` (AcceptedWillFinishLater) immediately** — never block
|
|
||||||
the io thread or the T3 window on the tool.
|
|
||||||
3. The tool does the work and reports the outcome via `FireEvent` (success
|
|
||||||
event) / `SetAlarm` (failure) — exactly how secsgem-py applications and
|
|
||||||
commercial gateways do it.
|
|
||||||
4. `CompleteCommand` therefore only correlates/audits the command lifecycle in
|
|
||||||
v1. A *synchronous gating* mode (tool decides HCACK 0/2 before the S2F42
|
|
||||||
goes out) requires a deferred-reply mechanism in the engine — explicitly a
|
|
||||||
v2 refinement, not needed for conformance.
|
|
||||||
|
|
||||||
Open sub-decisions to settle while implementing:
|
|
||||||
- Per-command routing (subscribe to specific RCMDs?) or one firehose? (v1: firehose.)
|
|
||||||
- Reconnect semantics: buffer commands while no subscriber (bounded queue +
|
|
||||||
declarative fallback after timeout) or reject with HCACK 2? Must be decided
|
|
||||||
and TESTED before calling the stream production-ready.
|
|
||||||
|
|
||||||
## Plan — ordered next steps
|
|
||||||
|
|
||||||
### Phase 0 — structural debts (from the 2026-06-10 design review; pay before sprinting)
|
|
||||||
The review's verdict: architecture and API bets are sound, but two structural
|
|
||||||
debts tax every later phase, and the most valuable tests aren't automated.
|
|
||||||
|
|
||||||
1. 🚧 **Multi-observer callbacks** (THE structural blocker — hit twice already).
|
|
||||||
`HandlerSlot` (primary slot keeps legacy set_ semantics; append-only add_
|
|
||||||
observers survive it) — done for `ControlStateMachine` + PJ/CJ stores, plus
|
|
||||||
runtime atomic control-state mirror (race retired) and `add_link_observer`
|
|
||||||
(WatchHealth foundation). ⬜ Remaining: roll the same 3-line pattern onto the
|
|
||||||
other single-slot classes (comm-state, EPT, exceptions, substrates, modules,
|
|
||||||
carriers, E84) as each phase needs them — mechanical now that the type exists.
|
|
||||||
2. 🚧 **CI the interop + conformance harnesses.** `tools/run_interop.sh` ✅ —
|
|
||||||
one command runs ALL nine validation steps (build, unit, daemon-unit,
|
|
||||||
py-host 31 checks, conformance 47, daemon bridge, spool restart, tshark,
|
|
||||||
secs4j 55) with a PASS/FAIL summary; verified green end-to-end 2026-06-10.
|
|
||||||
CI ✅ added but UNVERIFIED until pushed: grpc deps + `secs_gemd_tests` in
|
|
||||||
the build job (fails loudly if the daemon silently drops out), and a new
|
|
||||||
`python-interop` lane (py-host + conformance + daemon harness against
|
|
||||||
localhost, no docker-in-docker). ⬜ Verify the lanes on the first push.
|
|
||||||
3. ✅ **Fix `CompleteCommand` proto comment** — it described the rejected
|
|
||||||
blocking model; now states the HCACK-4 contract.
|
|
||||||
4. ⬜ **Table-driven handler conformance test** ((request, expected-reply-shape)
|
|
||||||
pairs through `router.dispatch` for broad coverage of the 56 handlers) +
|
|
||||||
message-level golden wire frames (codec KATs exist; message-level don't).
|
|
||||||
5. ⬜ **Decompose `register_default_handlers` per GEM capability** (it is a
|
|
||||||
relocated main(), not a designed component) and replace magic constants
|
|
||||||
(SVIDs 1/2 `refresh()`, CEIDs 400/401) with YAML role bindings
|
|
||||||
(`control_state_svid:`, `cj_executing_ceid:` …). Gradual; aligns with the
|
|
||||||
capability structure GEM itself defines (S1F19) and enables vendor subsetting.
|
|
||||||
6. ⬜ **Standardize the mutable-read pattern** for daemon RPCs: post-to-io +
|
|
||||||
future with deadline (always truthful; latency irrelevant at SECS rates).
|
|
||||||
First consumer: `GetVariables` (Phase A1) — set the precedent there.
|
|
||||||
7. ⬜ Move `apps/equipment_service.hpp` into the library tree
|
|
||||||
(`include/secsgem/daemon/`) once Phase B grows it; add a TSan-built
|
|
||||||
`run_async` + concurrent-RPC daemon test (today's daemon tests only poll()).
|
|
||||||
8. 🚧 Validate names are identifier-safe in `ConfigValidator` (the Python
|
|
||||||
client's kwargs API depends on it) — ⬜. Generalized format-compliance
|
|
||||||
property test (iterates ALL configured variables via gRPC, asserts each
|
|
||||||
keeps its declared wire format) — ✅, plus an unset-`Value` guard at the
|
|
||||||
RPC edge (was silently writing ASCII "").
|
|
||||||
|
|
||||||
### Phase A — finish the universal daemon surface (small, unblock vendors)
|
|
||||||
1. ⬜ `GetVariables` — needs the reverse `Item → proto Value` conversion
|
|
||||||
(read via post-to-io + future, or serve from a daemon-side cache of last
|
|
||||||
set values; decide and document).
|
|
||||||
2. ⬜ Alarm `name:` config field + `SetAlarm`/`ClearAlarm` RPCs + tests.
|
|
||||||
3. ⬜ `RequestControlState` (operator online/offline) + control-state atomic
|
|
||||||
mirror (fixes the known race) + `WatchHealth` stream (link state from the
|
|
||||||
selected/closed handlers, spool depth, control state).
|
|
||||||
4. ⬜ Extend `test_daemon_service.cpp` + `daemon_interop.py` for all of the above.
|
|
||||||
|
|
||||||
### Phase B — the command stream (the big one)
|
|
||||||
5. ⬜ Implement `Subscribe`/`CompleteCommand` per the design above, including
|
|
||||||
the no-subscriber fallback and bounded buffering. In-process gRPC tests:
|
|
||||||
command arrives on stream; HCACK 4 on the wire; declarative fallback when
|
|
||||||
unsubscribed.
|
|
||||||
6. ⬜ Extend `daemon_interop.py`: secsgem-py host sends `S2F41 START` → gRPC
|
|
||||||
tool receives it on the stream → tool fires completion event → host sees
|
|
||||||
`S6F11`. (The full conformant loop against the reference implementation.)
|
|
||||||
7. ⬜ Java interop: `secs4j` host variant of the same scenario.
|
|
||||||
|
|
||||||
### Phase C — the beautiful Python client
|
|
||||||
8. ⬜ `clients/python/` package (`pip install secsgem-client`): wraps generated
|
|
||||||
stubs in the agreed API — `eq.set(chamber_pressure=2.5)`, `eq.fire("wafer_complete", thickness=1.2)`,
|
|
||||||
`eq.alarm("pressure_high")`, `@eq.on("START")` consuming the stream,
|
|
||||||
`eq.health()`. Pure Python (no compiled ext). Ship stubs pre-generated.
|
|
||||||
9. ⬜ Example: rewrite a minimal `pvd_tool`-equivalent in ~40 lines of Python
|
|
||||||
against the daemon; also migrate the C++ `pvd_tool` to `set_handler`.
|
|
||||||
|
|
||||||
### Phase D — GEM300 in-the-loop (process/carrier tools)
|
|
||||||
10. ⬜ Settle job/carrier semantics (who acks S16F5/S3F17, gate vs observe —
|
|
||||||
see proto comments), then wire `ProcessJob`/`CarrierAction` onto the
|
|
||||||
stream + `ReportProcessJob`/`ReportCarrier` into the PJ/CJ/carrier stores.
|
|
||||||
11. ⬜ Recipe download (`ProcessProgram` on the stream when S7F3 lands) and
|
|
||||||
EC-change notification (`ConstantChange` when S2F15 lands).
|
|
||||||
12. ⬜ Interop scenarios for jobs/carriers vs secsgem-py + secs4j.
|
|
||||||
|
|
||||||
### Phase E — hardening & operations
|
|
||||||
13. ⬜ gRPC exposure: default to localhost + document UDS; optional TLS creds.
|
|
||||||
14. ⬜ `tools/run_interop.sh` + CI lanes: all interop harnesses + TSan daemon lane.
|
|
||||||
15. ⬜ Daemon Prometheus metrics + supervised deployment recipe (systemd unit).
|
|
||||||
16. ⬜ Remaining Layer-1 API: traces, limits, substrates/modules, terminal
|
|
||||||
services, spool depth/flush, `Describe` RPC.
|
|
||||||
|
|
||||||
### Phase F — fab acceptance (parallel track; the hard gate)
|
|
||||||
- ⚠️ **Standards correctness remains unverified against SEMI texts** (behaviour
|
|
||||||
reconstructed without the standards; interop with secsgem-py/secs4j/Wireshark
|
|
||||||
mitigates but does not prove). The #1 fab-readiness risk; needs real
|
|
||||||
standards access and/or a fab's MES qualification run (`docs/MES_INTEROP.md`).
|
|
||||||
- ⬜ GEM compliance statement + manual matching the tool's data dictionary.
|
|
||||||
- ⬜ SECS-I serial driver (asio `serial_port` adapter; FSM done) — only if a
|
|
||||||
target tool uses RS-232.
|
|
||||||
@@ -17,6 +17,23 @@ how those two halves meet.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## 0. Three ways to integrate — pick your tier
|
||||||
|
|
||||||
|
This guide covers the **embedded C++** path. Since the daemon track
|
||||||
|
landed there are two higher-level options that need no C++ at all:
|
||||||
|
|
||||||
|
| Tier | You write | Best for |
|
||||||
|
|---|---|---|
|
||||||
|
| **Python client** ([clients/python](../clients/python)) | ~25 lines of Python against `secs_gemd` | new tools, lab/R&D, fastest start |
|
||||||
|
| **gRPC, any language** ([proto/secsgem/v1](../proto/secsgem/v1/equipment.proto)) | a thin client in Go/C#/Java/… | existing controllers, multi-process tools |
|
||||||
|
| **Embedded C++** (this guide) | a main() over `gem::EquipmentRuntime` | in-process integration, custom transports |
|
||||||
|
|
||||||
|
In the daemon tiers `secs_gemd` owns the durable HSMS link — your tool
|
||||||
|
software can crash/upgrade/restart without the fab host noticing. See
|
||||||
|
[DAEMON_ROADMAP.md](DAEMON_ROADMAP.md) for status.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## 1. What you get vs. what you build
|
## 1. What you get vs. what you build
|
||||||
|
|
||||||
```
|
```
|
||||||
|
|||||||
+7
-4
@@ -5,7 +5,7 @@ implements what [COMPLIANCE.md](COMPLIANCE.md) claims.
|
|||||||
|
|
||||||
| # | Command | What it proves |
|
| # | Command | What it proves |
|
||||||
|---|--------------------------------------------------------------------------------------------------|---------------------------------------------------------------------------------------------|
|
|---|--------------------------------------------------------------------------------------------------|---------------------------------------------------------------------------------------------|
|
||||||
| 1 | `docker compose run --rm tests` | **445 test cases / 2 753 assertions** pass: every store, FSM, codec, parser, persistence path |
|
| 1 | `docker compose run --rm tests` | **473 test cases / 3 087 assertions** pass: every store, FSM, codec, parser, persistence path |
|
||||||
| 2 | `docker compose run --rm builder /app/build/secs_conformance --host server --port 5000` | **47 wire-level conformance checks** PASS against a live passive equipment |
|
| 2 | `docker compose run --rm builder /app/build/secs_conformance --host server --port 5000` | **47 wire-level conformance checks** PASS against a live passive equipment |
|
||||||
| 3 | `docker compose run --rm interop python3 /app/interop/host_vs_cpp_server.py --host server` | **31 interop checks** PASS against secsgem-py 0.3.0 (the Python reference impl) |
|
| 3 | `docker compose run --rm interop python3 /app/interop/host_vs_cpp_server.py --host server` | **31 interop checks** PASS against secsgem-py 0.3.0 (the Python reference impl) |
|
||||||
| 4 | `SECSGEM_ROBUSTNESS_SOAK=1 docker compose run --rm builder /app/build/secsgem_tests -tc='*soak*'` | **100 000 random tool operations** execute with all invariants and persistence round-trips holding |
|
| 4 | `SECSGEM_ROBUSTNESS_SOAK=1 docker compose run --rm builder /app/build/secsgem_tests -tc='*soak*'` | **100 000 random tool operations** execute with all invariants and persistence round-trips holding |
|
||||||
@@ -16,7 +16,7 @@ implements what [COMPLIANCE.md](COMPLIANCE.md) claims.
|
|||||||
|
|
||||||
CI ([Gitea Actions](.gitea/workflows/ci.yml)) runs a Release build +
|
CI ([Gitea Actions](.gitea/workflows/ci.yml)) runs a Release build +
|
||||||
full suite and a separate `-fsanitize=thread` lane on every push to
|
full suite and a separate `-fsanitize=thread` lane on every push to
|
||||||
`main`. All 445 cases / 2 753 assertions pass under TSan clean.
|
`main`. All 473 cases / 3 087 assertions pass under TSan clean.
|
||||||
|
|
||||||
## Per-standard test coverage
|
## Per-standard test coverage
|
||||||
|
|
||||||
@@ -41,10 +41,13 @@ Every claimed standard has dedicated tests. Counts are
|
|||||||
| **E157** — module process tracking | `test_modules` | 5 |
|
| **E157** — module process tracking | `test_modules` | 5 |
|
||||||
| **E84** — parallel I/O + timers | `test_e84`, `test_e84_ports`, `test_e84_timers`, `test_e84_asio_timers` | 27 |
|
| **E84** — parallel I/O + timers | `test_e84`, `test_e84_ports`, `test_e84_timers`, `test_e84_asio_timers` | 27 |
|
||||||
| Persistence + cross-cutting | `test_job_persistence`, `test_persistence_upgrade`, `test_wire_ceid_emission`, `test_gem300_scenario`, `test_live_gem300`, `test_thread_safety`, `test_metrics_prometheus`, `test_robustness_fuzz` | 32 |
|
| Persistence + cross-cutting | `test_job_persistence`, `test_persistence_upgrade`, `test_wire_ceid_emission`, `test_gem300_scenario`, `test_live_gem300`, `test_thread_safety`, `test_metrics_prometheus`, `test_robustness_fuzz` | 32 |
|
||||||
| **Total** | | **445** |
|
| Runtime / handlers / daemon glue | `test_runtime`, `test_default_handlers`, `test_handler_conformance` (53-handler sweep + golden frames), `test_name_index`, loader/validator additions | 28 |
|
||||||
|
| **Total** | | **473** |
|
||||||
|
|
||||||
`docker compose run --rm builder /app/build/secsgem_tests --list-test-cases | wc -l`
|
`docker compose run --rm builder /app/build/secsgem_tests --list-test-cases | wc -l`
|
||||||
currently reports 445.
|
currently reports 473. The gRPC daemon has its own binary on top:
|
||||||
|
`secs_gemd_tests` (in-process gRPC service tests, 125 assertions, also run
|
||||||
|
under ThreadSanitizer in CI).
|
||||||
|
|
||||||
## Categories of evidence
|
## Categories of evidence
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,16 @@
|
|||||||
# Security operations guide
|
# Security operations guide
|
||||||
|
|
||||||
|
## The daemon's gRPC API
|
||||||
|
|
||||||
|
`secs_gemd`'s tool API is **unauthenticated by design** — authentication
|
||||||
|
belongs to the transport. The defaults are safe (`127.0.0.1`), and the
|
||||||
|
production recommendation is a Unix domain socket
|
||||||
|
(`--grpc unix:///run/secs_gemd/api.sock`, file-permission protected, zero
|
||||||
|
network surface). Never bind it to an interface reachable from the
|
||||||
|
equipment LAN; if cross-host access is unavoidable, front it with the same
|
||||||
|
stunnel pattern described below for HSMS.
|
||||||
|
|
||||||
|
|
||||||
HSMS is plain TCP — no auth, no encryption. That's what every fab
|
HSMS is plain TCP — no auth, no encryption. That's what every fab
|
||||||
tool ships and what every MES expects. Security comes from the
|
tool ships and what every MES expects. Security comes from the
|
||||||
network layer around the HSMS socket; this doc has the concrete
|
network layer around the HSMS socket; this doc has the concrete
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ push to `main`.
|
|||||||
|
|
||||||
| Channel | Source of independence |
|
| Channel | Source of independence |
|
||||||
|----------------------------------|-------------------------------------------------------|
|
|----------------------------------|-------------------------------------------------------|
|
||||||
| 445 unit/integration tests | Internal |
|
| 473 unit/integration tests | Internal |
|
||||||
| 47 conformance harness checks | Internal |
|
| 47 conformance harness checks | Internal |
|
||||||
| **SEMI E5 KAT** | **External — standards body's encoding rules** |
|
| **SEMI E5 KAT** | **External — standards body's encoding rules** |
|
||||||
| **Wireshark HSMS dissector** | **External — independent network-protocol authors** |
|
| **Wireshark HSMS dissector** | **External — independent network-protocol authors** |
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ customize. They're written to be a template, not an abstract demo.
|
|||||||
| §3 Recipe runner | Driving a PJ through SettingUp → Processing → ProcessComplete by walking the recipe body, with per-step CEID emission |
|
| §3 Recipe runner | Driving a PJ through SettingUp → Processing → ProcessComplete by walking the recipe body, with per-step CEID emission |
|
||||||
| §4 Alarm threshold monitor | Continuous threshold-based alarm logic (chamber pressure, cleaning interval) with set/clear emission |
|
| §4 Alarm threshold monitor | Continuous threshold-based alarm logic (chamber pressure, cleaning interval) with set/clear emission |
|
||||||
| §5 EPT cycling | E116 state transitions driven by PJ state + safety alarms |
|
| §5 EPT cycling | E116 state transitions driven by PJ state + safety alarms |
|
||||||
| §6 Router handlers | Every SECS/GEM message a host might send to a PVD tool, 51 handlers in ~460 lines |
|
| §6 main() | `EquipmentRuntime` + `register_default_handlers` (all 56 GEM handlers in one call) + `commands.set_handler` for the START behaviour |
|
||||||
| §7 main() | Loading YAML → validating → composing → running, including the Prometheus exporter on `:9090` (§7.3) |
|
| §7 main() | Loading YAML → validating → composing → running, including the Prometheus exporter on `:9090` (§7.3) |
|
||||||
|
|
||||||
## Running it
|
## Running it
|
||||||
|
|||||||
+76
-601
@@ -37,16 +37,12 @@
|
|||||||
#include <string>
|
#include <string>
|
||||||
#include <vector>
|
#include <vector>
|
||||||
|
|
||||||
#include "secsgem/config/loader.hpp"
|
|
||||||
#include "secsgem/config/validate.hpp"
|
#include "secsgem/config/validate.hpp"
|
||||||
#include "secsgem/endpoint.hpp"
|
|
||||||
#include "secsgem/gem/control_state.hpp"
|
|
||||||
#include "secsgem/gem/data_model.hpp"
|
#include "secsgem/gem/data_model.hpp"
|
||||||
#include "secsgem/gem/e116_constants.hpp"
|
#include "secsgem/gem/default_handlers.hpp"
|
||||||
#include "secsgem/gem/messages.hpp"
|
#include "secsgem/gem/runtime.hpp"
|
||||||
#include "secsgem/gem/router.hpp"
|
|
||||||
#include "secsgem/metrics/prometheus.hpp"
|
#include "secsgem/metrics/prometheus.hpp"
|
||||||
#include "secsgem/secs2/message.hpp"
|
#include "secsgem/secs2/item.hpp"
|
||||||
|
|
||||||
using namespace secsgem;
|
using namespace secsgem;
|
||||||
using namespace std::chrono_literals;
|
using namespace std::chrono_literals;
|
||||||
@@ -437,473 +433,15 @@ struct EptCycler {
|
|||||||
} // namespace pvd
|
} // namespace pvd
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
// §6. Router handler registration
|
// §6. main() — the integration, on the modern stack
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
//
|
//
|
||||||
// This is the smallest set of handlers a host needs to talk to the
|
// Everything protocol-shaped is three calls now: construct an
|
||||||
// tool and run a recipe. apps/secs_server.cpp has the full
|
// EquipmentRuntime from the YAML, register_default_handlers (all 56 GEM
|
||||||
// catalogue (~30 more handlers) for terminal services, slot maps,
|
// handlers + state-change emitters; ids bound via the config's roles:
|
||||||
// E40/E94 jobs, etc.; in production you'd copy that here too.
|
// defaults), and hook tool behaviour onto the host commands with
|
||||||
|
// commands.set_handler. Compare with git history: this main() replaced
|
||||||
void register_handlers(gem::Router& router,
|
// ~650 lines of hand-wired Server/Router/handler plumbing.
|
||||||
std::shared_ptr<gem::EquipmentDataModel> model,
|
|
||||||
std::shared_ptr<gem::ControlStateMachine> sm,
|
|
||||||
const config::EquipmentDescriptor& desc,
|
|
||||||
std::function<void(uint32_t)> emit_event,
|
|
||||||
std::function<void(uint32_t)> emit_alarm_set,
|
|
||||||
std::shared_ptr<pvd::RecipeRunner> recipe) {
|
|
||||||
|
|
||||||
// S1F1 → S1F2 Are You There
|
|
||||||
router.on(1, 1, [desc](const s2::Message&) {
|
|
||||||
return gem::s1f2_on_line_data(desc.model_name, desc.software_rev);
|
|
||||||
});
|
|
||||||
|
|
||||||
// S1F3 → S1F4 Selected Status Request
|
|
||||||
router.on(1, 3, [model](const s2::Message& m) {
|
|
||||||
auto svids = gem::parse_s1f3(m);
|
|
||||||
if (!svids) return s2::Message(1, 0, false);
|
|
||||||
std::vector<std::optional<s2::Item>> values;
|
|
||||||
if (svids->empty()) {
|
|
||||||
for (const auto& sv : model->svids.all()) values.push_back(sv.value);
|
|
||||||
} else {
|
|
||||||
for (auto id : *svids) {
|
|
||||||
auto sv = model->svids.get(id);
|
|
||||||
values.push_back(sv ? std::optional<s2::Item>(sv->value) : std::nullopt);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return gem::s1f4_selected_status_data(values);
|
|
||||||
});
|
|
||||||
|
|
||||||
// S1F11 → S1F12 Status Variable Namelist Request
|
|
||||||
router.on(1, 11, [model](const s2::Message&) {
|
|
||||||
std::vector<gem::StatusName> rows;
|
|
||||||
for (const auto& sv : model->svids.all())
|
|
||||||
rows.push_back({sv.id, sv.name, sv.units});
|
|
||||||
return gem::s1f12_status_namelist_data(rows);
|
|
||||||
});
|
|
||||||
|
|
||||||
// S1F13 → S1F14 Establish Communications
|
|
||||||
router.on(1, 13, [desc](const s2::Message&) {
|
|
||||||
return gem::s1f14_establish_comms_ack(gem::CommAck::Accept,
|
|
||||||
{desc.model_name, desc.software_rev});
|
|
||||||
});
|
|
||||||
|
|
||||||
// S1F17 → S1F18 Request Online
|
|
||||||
router.on(1, 17, [sm](const s2::Message&) {
|
|
||||||
auto ack = sm->on_host_request_online();
|
|
||||||
return gem::s1f18_online_ack(ack);
|
|
||||||
});
|
|
||||||
|
|
||||||
// S2F13 → S2F14 EC Values
|
|
||||||
router.on(2, 13, [model](const s2::Message& m) {
|
|
||||||
auto ids = gem::parse_u4_list_body(m);
|
|
||||||
if (!ids) return s2::Message(2, 0, false);
|
|
||||||
std::vector<s2::Item> values;
|
|
||||||
for (auto id : *ids) {
|
|
||||||
auto ec = model->ecids.get(id);
|
|
||||||
values.push_back(ec ? ec->value : s2::Item::list({}));
|
|
||||||
}
|
|
||||||
return gem::s2f14_ec_data(values);
|
|
||||||
});
|
|
||||||
|
|
||||||
// S2F17 → S2F18 Clock
|
|
||||||
router.on(2, 17, [model](const s2::Message&) {
|
|
||||||
return gem::s2f18_date_time_data(model->clock.current_time_string());
|
|
||||||
});
|
|
||||||
|
|
||||||
// S2F41 → S2F42 Host Command
|
|
||||||
router.on(2, 41, [model, emit_event, emit_alarm_set, recipe]
|
|
||||||
(const s2::Message& m) {
|
|
||||||
auto cmd = gem::parse_s2f41(m);
|
|
||||||
if (!cmd) return gem::s2f42_host_command_ack(gem::HostCmdAck::ParameterInvalid, {});
|
|
||||||
auto result = model->commands.dispatch(cmd->rcmd, cmd->params);
|
|
||||||
if (result.ack == gem::HostCmdAck::Accept) {
|
|
||||||
if (result.emit_ceid) emit_event(*result.emit_ceid);
|
|
||||||
if (result.set_alarm) emit_alarm_set(*result.set_alarm);
|
|
||||||
// Demo: RCMD=START with PJ in WaitingForStart triggers the
|
|
||||||
// recipe runner. Real tools would gate on richer state.
|
|
||||||
if (cmd->rcmd == "START") {
|
|
||||||
for (const auto& pjid : model->process_jobs.ids()) {
|
|
||||||
auto* pj = model->process_jobs.get(pjid);
|
|
||||||
if (pj && pj->fsm->state() == gem::ProcessJobState::WaitingForStart) {
|
|
||||||
model->process_jobs.fire_internal(pjid, gem::ProcessJobEvent::Start);
|
|
||||||
recipe->start(pjid);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return gem::s2f42_host_command_ack(result.ack, {});
|
|
||||||
});
|
|
||||||
|
|
||||||
// S5F5 → S5F6 List Alarms
|
|
||||||
router.on(5, 5, [model](const s2::Message& m) {
|
|
||||||
auto ids = gem::parse_u4_list_body(m);
|
|
||||||
std::vector<gem::Alarm> alarms;
|
|
||||||
if (ids && ids->empty()) alarms = model->alarms.all();
|
|
||||||
else if (ids)
|
|
||||||
for (auto id : *ids) {
|
|
||||||
auto a = model->alarms.get(id);
|
|
||||||
if (a) alarms.push_back(*a);
|
|
||||||
}
|
|
||||||
return gem::s5f6_list_alarms_data(
|
|
||||||
alarms, [model](uint32_t id) { return model->alarms.active(id); });
|
|
||||||
});
|
|
||||||
|
|
||||||
// S7F5 → S7F6 Process Program Request
|
|
||||||
router.on(7, 5, [model](const s2::Message& m) {
|
|
||||||
auto ppid = gem::parse_s7f5(m);
|
|
||||||
if (!ppid) return gem::s7f6_process_program_data("", "");
|
|
||||||
auto body = model->recipes.get(*ppid);
|
|
||||||
return gem::s7f6_process_program_data(*ppid, body ? *body : "");
|
|
||||||
});
|
|
||||||
|
|
||||||
// S7F19 → S7F20 Current PP List
|
|
||||||
router.on(7, 19, [model](const s2::Message&) {
|
|
||||||
return gem::s7f20_current_eppd_data(model->recipes.list());
|
|
||||||
});
|
|
||||||
|
|
||||||
// -------- Extended handlers (mirrors apps/secs_server.cpp) ----------
|
|
||||||
// These follow the demo server's patterns one-for-one. A real
|
|
||||||
// vendor's main.cpp would either include them inline (as we do here)
|
|
||||||
// or extract them to a shared helper.
|
|
||||||
|
|
||||||
// S1F15 → S1F16 Request Offline
|
|
||||||
router.on(1, 15, [sm](const s2::Message&) {
|
|
||||||
return gem::s1f16_offline_ack(sm->on_host_request_offline());
|
|
||||||
});
|
|
||||||
// S1F19 → S1F20 GEM Compliance
|
|
||||||
router.on(1, 19, [desc](const s2::Message&) {
|
|
||||||
std::vector<gem::CapabilityEntry> caps;
|
|
||||||
for (const auto& c : desc.capabilities) caps.push_back({c.first, c.second});
|
|
||||||
return gem::s1f20_get_gem_compliance_data(
|
|
||||||
desc.software_rev, desc.equipment_type, caps);
|
|
||||||
});
|
|
||||||
// S1F21 → S1F22 DVID Namelist
|
|
||||||
router.on(1, 21, [model](const s2::Message&) {
|
|
||||||
std::vector<gem::StatusName> rows;
|
|
||||||
for (const auto& dv : model->dvids.all())
|
|
||||||
rows.push_back({dv.id, dv.name, dv.units});
|
|
||||||
return gem::s1f22_data_variable_namelist_data(rows);
|
|
||||||
});
|
|
||||||
// S1F23 → S1F24 CEID Namelist
|
|
||||||
router.on(1, 23, [model](const s2::Message& m) {
|
|
||||||
auto req = gem::parse_s1f23(m);
|
|
||||||
std::vector<gem::CollectionEventName> rows;
|
|
||||||
if (req && req->empty()) {
|
|
||||||
for (const auto& e : model->events.all_events())
|
|
||||||
rows.push_back({e.id, e.name, model->events.vids_for(e.id)});
|
|
||||||
} else if (req) {
|
|
||||||
for (auto id : *req) {
|
|
||||||
auto info = model->events.event_info(id);
|
|
||||||
rows.push_back({id, info ? info->name : "", model->events.vids_for(id)});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return gem::s1f24_collection_event_namelist_data(rows);
|
|
||||||
});
|
|
||||||
// S2F15 → S2F16 EC Set
|
|
||||||
router.on(2, 15, [model](const s2::Message& m) {
|
|
||||||
auto sets = gem::parse_s2f15(m);
|
|
||||||
auto eac = gem::EquipmentAck::Accept;
|
|
||||||
if (!sets) eac = gem::EquipmentAck::Denied_OutOfRange;
|
|
||||||
else for (const auto& s : *sets) {
|
|
||||||
auto r = model->ecids.set_value(s.ecid, s.value);
|
|
||||||
if (r != gem::EquipmentAck::Accept) eac = r;
|
|
||||||
}
|
|
||||||
return gem::s2f16_ec_ack(eac);
|
|
||||||
});
|
|
||||||
// S2F29 → S2F30 EC Namelist
|
|
||||||
router.on(2, 29, [model](const s2::Message& m) {
|
|
||||||
auto ids = gem::parse_u4_list_body(m);
|
|
||||||
std::vector<gem::EquipmentConstant> ecs;
|
|
||||||
if (ids && ids->empty()) ecs = model->ecids.all();
|
|
||||||
else if (ids) for (auto id : *ids) {
|
|
||||||
auto ec = model->ecids.get(id);
|
|
||||||
if (ec) ecs.push_back(*ec);
|
|
||||||
}
|
|
||||||
std::vector<gem::EcNameRow> rows;
|
|
||||||
for (const auto& ec : ecs)
|
|
||||||
rows.push_back({ec.id, ec.name, ec.min_str, ec.max_str, "", ec.units});
|
|
||||||
return gem::s2f30_ec_namelist_data(rows);
|
|
||||||
});
|
|
||||||
// S2F31 → S2F32 Set Clock
|
|
||||||
router.on(2, 31, [model](const s2::Message& m) {
|
|
||||||
auto t = gem::parse_s2f31(m);
|
|
||||||
return gem::s2f32_date_time_ack(
|
|
||||||
t ? model->clock.set_time_string(*t) : gem::TimeAck::Error);
|
|
||||||
});
|
|
||||||
// S2F33/F35/F37 Dynamic event report config
|
|
||||||
router.on(2, 33, [model](const s2::Message& m) {
|
|
||||||
auto req = gem::parse_s2f33(m);
|
|
||||||
auto ack = gem::DefineReportAck::InvalidFormat;
|
|
||||||
if (req) {
|
|
||||||
std::vector<std::pair<uint32_t, std::vector<uint32_t>>> rows;
|
|
||||||
for (const auto& r : req->reports) rows.emplace_back(r.rptid, r.vids);
|
|
||||||
ack = model->define_reports(rows);
|
|
||||||
}
|
|
||||||
return gem::s2f34_define_report_ack(ack);
|
|
||||||
});
|
|
||||||
router.on(2, 35, [model](const s2::Message& m) {
|
|
||||||
auto req = gem::parse_s2f35(m);
|
|
||||||
auto ack = gem::LinkEventAck::InvalidFormat;
|
|
||||||
if (req) {
|
|
||||||
std::vector<std::pair<uint32_t, std::vector<uint32_t>>> rows;
|
|
||||||
for (const auto& l : req->links) rows.emplace_back(l.ceid, l.rptids);
|
|
||||||
ack = model->link_event_reports(rows);
|
|
||||||
}
|
|
||||||
return gem::s2f36_link_event_report_ack(ack);
|
|
||||||
});
|
|
||||||
router.on(2, 37, [model](const s2::Message& m) {
|
|
||||||
auto req = gem::parse_s2f37(m);
|
|
||||||
auto ack = req ? model->enable_events(req->enable, req->ceids)
|
|
||||||
: gem::EnableEventAck::UnknownCeid;
|
|
||||||
return gem::s2f38_enable_event_ack(ack);
|
|
||||||
});
|
|
||||||
// S2F21 Legacy remote command
|
|
||||||
router.on(2, 21, [model, emit_event, emit_alarm_set](const s2::Message& m) {
|
|
||||||
auto rcmd = gem::parse_s2f21(m);
|
|
||||||
if (!rcmd) return gem::s2f22_remote_command_ack(gem::HostCmdAck::ParameterInvalid);
|
|
||||||
auto result = model->commands.dispatch(*rcmd, {});
|
|
||||||
if (result.ack == gem::HostCmdAck::Accept) {
|
|
||||||
if (result.emit_ceid) emit_event(*result.emit_ceid);
|
|
||||||
if (result.set_alarm) emit_alarm_set(*result.set_alarm);
|
|
||||||
}
|
|
||||||
return gem::s2f22_remote_command_ack(result.ack);
|
|
||||||
});
|
|
||||||
// S2F23 trace, S2F43 spool reset, S2F45 limits, S2F47 limit attrs
|
|
||||||
router.on(2, 23, [model](const s2::Message& m) {
|
|
||||||
auto req = gem::parse_s2f23(m);
|
|
||||||
auto ack = gem::TraceAck::Accept;
|
|
||||||
if (!req) ack = gem::TraceAck::InvalidPeriod;
|
|
||||||
else for (auto v : req->svids)
|
|
||||||
if (!model->vid_exists(v)) { ack = gem::TraceAck::UnknownVid; break; }
|
|
||||||
return gem::s2f24_trace_initialize_ack(ack);
|
|
||||||
});
|
|
||||||
router.on(2, 43, [model](const s2::Message& m) {
|
|
||||||
auto streams = gem::parse_s2f43(m);
|
|
||||||
if (streams) model->spool.set_spoolable_streams(*streams);
|
|
||||||
return gem::s2f44_reset_spooling_ack(
|
|
||||||
streams ? gem::ResetSpoolAck::Accept : gem::ResetSpoolAck::Denied_NotAllowed, {});
|
|
||||||
});
|
|
||||||
router.on(2, 47, [model](const s2::Message& m) {
|
|
||||||
auto vids = gem::parse_s2f47(m);
|
|
||||||
std::vector<gem::VidLimitsEntry> rows;
|
|
||||||
if (vids) {
|
|
||||||
const auto target = vids->empty() ? model->limits.all_vids() : *vids;
|
|
||||||
for (auto v : target) rows.push_back({v, model->limits.get_for_vid(v)});
|
|
||||||
}
|
|
||||||
return gem::s2f48_variable_limit_attribute_data(rows);
|
|
||||||
});
|
|
||||||
// S2F49 Enhanced remote command
|
|
||||||
router.on(2, 49, [model, emit_event](const s2::Message& m) {
|
|
||||||
auto cmd = gem::parse_s2f49(m);
|
|
||||||
if (!cmd) return gem::s2f50_enhanced_host_command_ack(
|
|
||||||
gem::HostCmdAck::ParameterInvalid, {});
|
|
||||||
auto result = model->commands.dispatch(cmd->rcmd, cmd->params);
|
|
||||||
if (result.ack == gem::HostCmdAck::Accept && result.emit_ceid)
|
|
||||||
emit_event(*result.emit_ceid);
|
|
||||||
return gem::s2f50_enhanced_host_command_ack(result.ack, {});
|
|
||||||
});
|
|
||||||
// S5F3 enable alarm, S5F7 list enabled
|
|
||||||
router.on(5, 3, [model](const s2::Message& m) {
|
|
||||||
auto req = gem::parse_s5f3(m);
|
|
||||||
return gem::s5f4_enable_alarm_ack(
|
|
||||||
req ? model->alarms.set_enabled(req->alid, (req->aled & 0x80) != 0)
|
|
||||||
: gem::AlarmAck::Error);
|
|
||||||
});
|
|
||||||
router.on(5, 7, [model](const s2::Message&) {
|
|
||||||
std::vector<gem::AlarmListing> rows;
|
|
||||||
for (const auto& a : model->alarms.all()) {
|
|
||||||
if (!model->alarms.enabled(a.id)) continue;
|
|
||||||
const uint8_t alcd = (a.severity_category & 0x7F) |
|
|
||||||
(model->alarms.active(a.id) ? 0x80 : 0x00);
|
|
||||||
rows.push_back({alcd, a.id, a.text});
|
|
||||||
}
|
|
||||||
return gem::s5f8_list_enabled_alarms_data(rows);
|
|
||||||
});
|
|
||||||
// S5F13/F17 exception recover
|
|
||||||
router.on(5, 13, [model](const s2::Message& m) {
|
|
||||||
auto req = gem::parse_s5f13(m);
|
|
||||||
return gem::s5f14_exception_recover_ack(
|
|
||||||
req ? model->exceptions.on_recover(req->exid, req->exrecvra)
|
|
||||||
: gem::AlarmAck::Error);
|
|
||||||
});
|
|
||||||
router.on(5, 17, [model](const s2::Message& m) {
|
|
||||||
auto exid = gem::parse_s5f17(m);
|
|
||||||
return gem::s5f18_exception_recover_abort_ack(
|
|
||||||
exid ? model->exceptions.on_recover_abort(*exid) : gem::AlarmAck::Error);
|
|
||||||
});
|
|
||||||
// S6F15/F19/F21 host-initiated event/report queries
|
|
||||||
router.on(6, 15, [model](const s2::Message& m) {
|
|
||||||
auto ceid = gem::parse_s6f15(m);
|
|
||||||
if (!ceid) return gem::s6f16_event_report_data({0, 0, {}});
|
|
||||||
return gem::s6f16_event_report_data({0, *ceid, model->compose_reports_for(*ceid)});
|
|
||||||
});
|
|
||||||
router.on(6, 19, [model](const s2::Message& m) {
|
|
||||||
auto rptid = gem::parse_s6f19(m);
|
|
||||||
std::vector<s2::Item> values;
|
|
||||||
if (rptid) for (const auto& r : model->events.all_reports()) {
|
|
||||||
if (r.id != *rptid) continue;
|
|
||||||
for (auto vid : r.vids) {
|
|
||||||
auto v = model->vid_value(vid);
|
|
||||||
values.push_back(v ? *v : s2::Item::list({}));
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
return gem::s6f20_individual_report_data(values);
|
|
||||||
});
|
|
||||||
router.on(6, 21, [model](const s2::Message& m) {
|
|
||||||
auto rptid = gem::parse_s6f21(m);
|
|
||||||
std::vector<gem::AnnotatedValue> rows;
|
|
||||||
if (rptid) for (const auto& r : model->events.all_reports()) {
|
|
||||||
if (r.id != *rptid) continue;
|
|
||||||
for (auto vid : r.vids) {
|
|
||||||
auto v = model->vid_value(vid);
|
|
||||||
rows.push_back({vid, v ? *v : s2::Item::list({})});
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
return gem::s6f22_annotated_report_data(rows);
|
|
||||||
});
|
|
||||||
// S6F23 spool data request
|
|
||||||
router.on(6, 23, [model](const s2::Message& m) {
|
|
||||||
auto rsdc = gem::parse_s6f23(m);
|
|
||||||
if (!rsdc) return gem::s6f24_request_spool_data_ack(gem::SpoolRequestAck::Denied);
|
|
||||||
if (*rsdc == gem::SpoolRequestCode::Purge) model->spool.clear();
|
|
||||||
else model->spool.drain(); // demo: drop the drained messages
|
|
||||||
return gem::s6f24_request_spool_data_ack(gem::SpoolRequestAck::Accept);
|
|
||||||
});
|
|
||||||
// S7F1 PP load inquire, S7F3 PP send, S7F17 PP delete
|
|
||||||
router.on(7, 1, [](const s2::Message& m) {
|
|
||||||
auto req = gem::parse_s7f1(m);
|
|
||||||
auto ack = gem::ProcessProgramAck::Accept;
|
|
||||||
if (!req || req->ppid.empty()) ack = gem::ProcessProgramAck::PpidNotFound;
|
|
||||||
return gem::s7f2_pp_load_grant(ack);
|
|
||||||
});
|
|
||||||
router.on(7, 3, [model](const s2::Message& m) {
|
|
||||||
auto pp = gem::parse_s7f3(m);
|
|
||||||
if (!pp) return gem::s7f4_process_program_ack(gem::ProcessProgramAck::LengthError);
|
|
||||||
model->recipes.add(pp->ppid, pp->ppbody);
|
|
||||||
return gem::s7f4_process_program_ack(gem::ProcessProgramAck::Accept);
|
|
||||||
});
|
|
||||||
router.on(7, 17, [model](const s2::Message& m) {
|
|
||||||
auto req = gem::parse_s7f17(m);
|
|
||||||
if (!req) return gem::s7f18_delete_pp_ack(gem::ProcessProgramAck::LengthError);
|
|
||||||
if (req->empty()) for (const auto& id : model->recipes.list()) model->recipes.remove(id);
|
|
||||||
else for (const auto& id : *req) model->recipes.remove(id);
|
|
||||||
return gem::s7f18_delete_pp_ack(gem::ProcessProgramAck::Accept);
|
|
||||||
});
|
|
||||||
// S10F3 host→equipment terminal display, S10F5 multi-line
|
|
||||||
router.on(10, 3, [](const s2::Message& m) {
|
|
||||||
auto td = gem::parse_s10f3(m);
|
|
||||||
if (td) std::cout << "[TERMINAL " << static_cast<int>(td->tid)
|
|
||||||
<< "] " << td->text << "\n";
|
|
||||||
return gem::s10f4_terminal_display_ack(gem::TerminalAck::Accepted);
|
|
||||||
});
|
|
||||||
router.on(10, 5, [](const s2::Message& m) {
|
|
||||||
auto td = gem::parse_s10f5(m);
|
|
||||||
if (td) for (const auto& line : td->lines)
|
|
||||||
std::cout << "[TERMINAL " << static_cast<int>(td->tid) << "] " << line << "\n";
|
|
||||||
return gem::s10f6_terminal_display_multi_ack(gem::TerminalAck::Accepted);
|
|
||||||
});
|
|
||||||
// S3 — E87 carriers (basic acceptance)
|
|
||||||
router.on(3, 17, [model](const s2::Message& m) {
|
|
||||||
auto req = gem::parse_s3f17(m);
|
|
||||||
if (!req) return gem::s3f18_carrier_action_ack(gem::CarrierActionAck::ParameterInvalid);
|
|
||||||
if (!model->carriers.has(req->carrierid))
|
|
||||||
return gem::s3f18_carrier_action_ack(gem::CarrierActionAck::CarrierIDUnknown);
|
|
||||||
return gem::s3f18_carrier_action_ack(gem::CarrierActionAck::Accept);
|
|
||||||
});
|
|
||||||
router.on(3, 19, [model](const s2::Message& m) {
|
|
||||||
auto req = gem::parse_s3f19(m);
|
|
||||||
if (!req) return gem::s3f20_slot_map_verify_ack(gem::SlotMapVerifyAck::Error);
|
|
||||||
return gem::s3f20_slot_map_verify_ack(
|
|
||||||
model->carriers.has(req->carrierid) ? gem::SlotMapVerifyAck::Accept
|
|
||||||
: gem::SlotMapVerifyAck::CarrierUnknown);
|
|
||||||
});
|
|
||||||
router.on(3, 25, [](const s2::Message&) {
|
|
||||||
return gem::s3f26_carrier_transfer_ack(gem::CarrierActionAck::Accept);
|
|
||||||
});
|
|
||||||
router.on(3, 27, [model](const s2::Message& m) {
|
|
||||||
auto cid = gem::parse_s3f27(m);
|
|
||||||
return gem::s3f28_cancel_carrier_ack(
|
|
||||||
cid && model->carriers.has(*cid) ? gem::CarrierActionAck::Accept
|
|
||||||
: gem::CarrierActionAck::CarrierIDUnknown);
|
|
||||||
});
|
|
||||||
// S14 — E39 GetAttr + E94 CJ create/delete
|
|
||||||
router.on(14, 1, [model](const s2::Message& m) {
|
|
||||||
auto req = gem::parse_s14f1(m);
|
|
||||||
if (!req) return gem::s14f2_get_attr_data({}, gem::ObjectAck::Error);
|
|
||||||
auto* obj = model->cem.get(req->objspec);
|
|
||||||
if (!obj) return gem::s14f2_get_attr_data({}, gem::ObjectAck::Denied_UnknownObject);
|
|
||||||
std::vector<gem::AttrValue> attrs;
|
|
||||||
for (const auto& id : req->attrids) {
|
|
||||||
auto v = model->cem.get_attr(req->objspec, id);
|
|
||||||
attrs.push_back({id, v.value_or(s2::Item::ascii(""))});
|
|
||||||
}
|
|
||||||
return gem::s14f2_get_attr_data(attrs, gem::ObjectAck::Success);
|
|
||||||
});
|
|
||||||
router.on(14, 9, [model](const s2::Message& m) {
|
|
||||||
auto req = gem::parse_s14f9(m);
|
|
||||||
if (!req) return gem::s14f10_create_control_job_ack("", gem::ObjectAck::Error);
|
|
||||||
auto r = model->control_jobs.create(req->ctljobid, req->prjobids,
|
|
||||||
[model](const std::string& id) { return model->process_jobs.has(id); });
|
|
||||||
auto ack = (r == gem::ControlJobStore::CreateResult::Created)
|
|
||||||
? gem::ObjectAck::Success
|
|
||||||
: gem::ObjectAck::Denied_UnknownObject;
|
|
||||||
return gem::s14f10_create_control_job_ack(req->ctljobid, ack);
|
|
||||||
});
|
|
||||||
router.on(14, 11, [model](const s2::Message& m) {
|
|
||||||
auto id = gem::parse_s14f11(m);
|
|
||||||
return gem::s14f12_delete_control_job_ack(
|
|
||||||
id && model->control_jobs.remove(*id) ? gem::ObjectAck::Success
|
|
||||||
: gem::ObjectAck::Denied_UnknownObject);
|
|
||||||
});
|
|
||||||
// S16 — E40 PJ create/command/dequeue/monitor, E94 CJ command
|
|
||||||
router.on(16, 11, [model](const s2::Message& m) {
|
|
||||||
auto req = gem::parse_s16f11(m);
|
|
||||||
if (!req) return gem::s16f12_pr_job_create_ack(gem::HostCmdAck::ParameterInvalid);
|
|
||||||
auto r = model->process_jobs.create(req->prjobid, req->rcpspec.ppid, req->mtrloutspec,
|
|
||||||
[model](const std::string& ppid) { return model->recipes.get(ppid).has_value(); });
|
|
||||||
auto ack = gem::HostCmdAck::Accept;
|
|
||||||
if (r == gem::ProcessJobStore::CreateResult::Denied_AlreadyExists)
|
|
||||||
ack = gem::HostCmdAck::Rejected;
|
|
||||||
else if (r == gem::ProcessJobStore::CreateResult::Denied_InvalidPpid)
|
|
||||||
ack = gem::HostCmdAck::ParameterInvalid;
|
|
||||||
return gem::s16f12_pr_job_create_ack(ack);
|
|
||||||
});
|
|
||||||
router.on(16, 5, [model](const s2::Message& m) {
|
|
||||||
auto req = gem::parse_s16f5(m);
|
|
||||||
if (!req) return gem::s16f6_pr_job_command_ack(gem::HostCmdAck::ParameterInvalid);
|
|
||||||
auto ev = gem::pr_cmd_to_event(req->prcmd);
|
|
||||||
if (!ev) return gem::s16f6_pr_job_command_ack(gem::HostCmdAck::InvalidCommand);
|
|
||||||
return gem::s16f6_pr_job_command_ack(model->process_jobs.on_host_command(req->prjobid, *ev));
|
|
||||||
});
|
|
||||||
router.on(16, 7, [model](const s2::Message& m) {
|
|
||||||
auto req = gem::parse_s16f7(m);
|
|
||||||
if (!req) return gem::s16f8_pr_job_monitor_ack(gem::HostCmdAck::ParameterInvalid);
|
|
||||||
for (const auto& e : req->entries)
|
|
||||||
model->process_jobs.set_alert(e.prjobid, (e.pralert & 0x80) != 0);
|
|
||||||
return gem::s16f8_pr_job_monitor_ack(gem::HostCmdAck::Accept);
|
|
||||||
});
|
|
||||||
router.on(16, 13, [model](const s2::Message& m) {
|
|
||||||
auto id = gem::parse_s16f13(m);
|
|
||||||
return gem::s16f14_pr_job_dequeue_ack(
|
|
||||||
id ? model->process_jobs.dequeue(*id) : gem::HostCmdAck::ParameterInvalid);
|
|
||||||
});
|
|
||||||
router.on(16, 27, [model](const s2::Message& m) {
|
|
||||||
auto req = gem::parse_s16f27(m);
|
|
||||||
if (!req) return gem::s16f28_cj_command_ack(gem::HostCmdAck::ParameterInvalid);
|
|
||||||
auto ev = gem::ctl_cmd_to_event(req->ctljobcmd);
|
|
||||||
if (!ev) return gem::s16f28_cj_command_ack(gem::HostCmdAck::InvalidCommand);
|
|
||||||
return gem::s16f28_cj_command_ack(
|
|
||||||
model->control_jobs.on_host_command(req->ctljobid, *ev));
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// =============================================================================
|
|
||||||
// §7. main()
|
|
||||||
// =============================================================================
|
|
||||||
|
|
||||||
int main(int argc, char** argv) {
|
int main(int argc, char** argv) {
|
||||||
const std::string config_path = (argc > 1) ? argv[1] :
|
const std::string config_path = (argc > 1) ? argv[1] :
|
||||||
@@ -915,7 +453,7 @@ int main(int argc, char** argv) {
|
|||||||
const uint16_t metrics_port = (argc > 4) ?
|
const uint16_t metrics_port = (argc > 4) ?
|
||||||
static_cast<uint16_t>(std::stoi(argv[4])) : 9090;
|
static_cast<uint16_t>(std::stoi(argv[4])) : 9090;
|
||||||
|
|
||||||
// ---- §7.1 Validate the YAML configs before binding the port ----------
|
// ---- Validate the YAML configs before binding the port -----------------
|
||||||
{
|
{
|
||||||
config::ConfigValidator v;
|
config::ConfigValidator v;
|
||||||
v.validate_equipment(config_path);
|
v.validate_equipment(config_path);
|
||||||
@@ -927,167 +465,104 @@ int main(int argc, char** argv) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
auto logfn = [](const std::string& m) {
|
// std::endl (not "\n"): flush per line so logs are visible immediately
|
||||||
std::cout << "[pvd] " << m << "\n";
|
// when stdout is piped (docker logs, CI captures).
|
||||||
};
|
auto logfn = [](const std::string& m) { std::cout << "[pvd] " << m << std::endl; };
|
||||||
|
|
||||||
// ---- §7.2 Build the data model ---------------------------------------
|
// ---- The engine: one runtime + the default GEM behaviour ---------------
|
||||||
auto model = std::make_shared<gem::EquipmentDataModel>();
|
gem::EquipmentRuntime::Config cfg;
|
||||||
config::EquipmentDescriptor desc;
|
cfg.equipment_yaml = config_path;
|
||||||
config::ControlStateConfig sm_cfg;
|
cfg.control_state_yaml = state_path;
|
||||||
|
cfg.process_job_yaml = "/app/data/process_job_state.yaml";
|
||||||
|
cfg.control_job_yaml = "/app/data/control_job_state.yaml";
|
||||||
|
cfg.port = port;
|
||||||
|
cfg.log = logfn;
|
||||||
|
|
||||||
|
std::unique_ptr<gem::EquipmentRuntime> runtime;
|
||||||
try {
|
try {
|
||||||
desc = config::load_equipment(config_path, *model);
|
runtime = std::make_unique<gem::EquipmentRuntime>(cfg);
|
||||||
sm_cfg = config::load_control_state(state_path);
|
|
||||||
} catch (const std::exception& e) {
|
} catch (const std::exception& e) {
|
||||||
std::cerr << "[pvd] config load failed: " << e.what() << "\n";
|
std::cerr << "[pvd] config load failed: " << e.what() << "\n";
|
||||||
return 1;
|
return 1;
|
||||||
}
|
}
|
||||||
auto sm = std::make_shared<gem::ControlStateMachine>(
|
auto& R = *runtime;
|
||||||
sm_cfg.table, sm_cfg.initial);
|
gem::register_default_handlers(R);
|
||||||
|
|
||||||
|
auto model = R.model_ptr();
|
||||||
logfn("loaded " + std::to_string(model->svids.size()) + " SVIDs, " +
|
logfn("loaded " + std::to_string(model->svids.size()) + " SVIDs, " +
|
||||||
std::to_string(model->ecids.all().size()) + " ECIDs, " +
|
std::to_string(model->ecids.all().size()) + " ECIDs, " +
|
||||||
std::to_string(model->events.all_events().size()) + " CEIDs, " +
|
std::to_string(model->events.all_events().size()) + " CEIDs, " +
|
||||||
std::to_string(model->alarms.all().size()) + " alarms, " +
|
std::to_string(model->alarms.all().size()) + " alarms, " +
|
||||||
std::to_string(model->recipes.list().size()) + " recipes");
|
std::to_string(model->recipes.list().size()) + " recipes");
|
||||||
|
|
||||||
asio::io_context io;
|
// ---- Metrics exporter on a second port ---------------------------------
|
||||||
|
|
||||||
// ---- §7.3 Metrics exporter on a second port --------------------------
|
|
||||||
auto registry = std::make_shared<metrics::Registry>();
|
auto registry = std::make_shared<metrics::Registry>();
|
||||||
registry->describe("pvd_messages_total", "SECS messages dispatched",
|
registry->describe("pvd_events_total", "Collection events fired",
|
||||||
metrics::MetricType::Counter);
|
metrics::MetricType::Counter);
|
||||||
registry->describe("pvd_chamber_pressure_torr", "Process chamber pressure",
|
registry->describe("pvd_chamber_pressure_torr", "Process chamber pressure",
|
||||||
metrics::MetricType::Gauge);
|
metrics::MetricType::Gauge);
|
||||||
registry->describe("pvd_spool_depth", "Queued spool messages",
|
registry->describe("pvd_spool_depth", "Queued spool messages",
|
||||||
metrics::MetricType::Gauge);
|
metrics::MetricType::Gauge);
|
||||||
auto exporter = std::make_shared<metrics::PrometheusServer>(io, metrics_port, registry);
|
auto exporter = std::make_shared<metrics::PrometheusServer>(
|
||||||
|
R.io(), metrics_port, registry);
|
||||||
exporter->start();
|
exporter->start();
|
||||||
logfn("metrics exporter on :" + std::to_string(metrics_port) + "/metrics");
|
logfn("metrics exporter on :" + std::to_string(metrics_port) + "/metrics");
|
||||||
|
|
||||||
// ---- §7.4 Sensor simulator, EPT cycler, alarm monitor ---------------
|
// ---- Tool behaviour: sensors, EPT, alarms, recipes ----------------------
|
||||||
auto sim = std::make_shared<pvd::Simulator>(io, model);
|
// The emit helpers are the runtime's thread-safe API plus a metrics bump.
|
||||||
sim->start();
|
auto emit_event = [&R, registry](uint32_t ceid) {
|
||||||
auto ept = std::make_shared<pvd::EptCycler>(io, model);
|
R.emit_event(ceid);
|
||||||
ept->start();
|
|
||||||
|
|
||||||
// ---- §7.5 Server + handler wiring ------------------------------------
|
|
||||||
Server::Config server_cfg{port, desc.device_id, {}};
|
|
||||||
Server server(io, server_cfg);
|
|
||||||
server.on_log(logfn);
|
|
||||||
|
|
||||||
auto active_conn = std::make_shared<std::weak_ptr<Connection>>();
|
|
||||||
|
|
||||||
// Shared event-emission helper.
|
|
||||||
auto deliver_or_spool = [active_conn, model, logfn](s2::Message msg) {
|
|
||||||
auto conn = active_conn->lock();
|
|
||||||
if (!conn) {
|
|
||||||
model->spool.enqueue(msg);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (msg.reply_expected) {
|
|
||||||
conn->send_request(std::move(msg), [](std::error_code, const s2::Message&) {});
|
|
||||||
} else {
|
|
||||||
conn->send_data(std::move(msg));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
auto emit_event = [&io, model, deliver_or_spool, registry](uint32_t ceid) {
|
|
||||||
asio::post(io, [model, deliver_or_spool, ceid]() {
|
|
||||||
if (!model->is_event_enabled(ceid)) return;
|
|
||||||
auto reports = model->compose_reports_for(ceid);
|
|
||||||
deliver_or_spool(gem::s6f11_event_report(0, ceid, reports));
|
|
||||||
});
|
|
||||||
registry->inc("pvd_events_total", {{"ceid", std::to_string(ceid)}});
|
registry->inc("pvd_events_total", {{"ceid", std::to_string(ceid)}});
|
||||||
};
|
};
|
||||||
auto emit_alarm_set = [&io, model, deliver_or_spool, registry](uint32_t alid) {
|
auto emit_alarm_set = [&R](uint32_t alid) { R.set_alarm(alid); };
|
||||||
asio::post(io, [model, deliver_or_spool, alid]() {
|
auto emit_alarm_clear = [&R](uint32_t alid) { R.clear_alarm(alid); };
|
||||||
auto alarm = model->alarms.get(alid);
|
|
||||||
auto alcd = model->alarms.set_active(alid);
|
|
||||||
if (!alarm || !alcd || !model->alarms.enabled(alid)) return;
|
|
||||||
deliver_or_spool(gem::s5f1_alarm_report(*alcd, alid, alarm->text));
|
|
||||||
});
|
|
||||||
registry->inc("pvd_alarm_set_total", {{"alid", std::to_string(alid)}});
|
|
||||||
};
|
|
||||||
auto emit_alarm_clear = [&io, model, deliver_or_spool](uint32_t alid) {
|
|
||||||
asio::post(io, [model, deliver_or_spool, alid]() {
|
|
||||||
auto alarm = model->alarms.get(alid);
|
|
||||||
auto alcd = model->alarms.clear_active(alid);
|
|
||||||
if (!alarm || !alcd || !model->alarms.enabled(alid)) return;
|
|
||||||
deliver_or_spool(gem::s5f1_alarm_report(*alcd, alid, alarm->text));
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
|
auto sim = std::make_shared<pvd::Simulator>(R.io(), model);
|
||||||
|
sim->start();
|
||||||
|
auto ept = std::make_shared<pvd::EptCycler>(R.io(), model);
|
||||||
|
ept->start();
|
||||||
auto alarm_mon = std::make_shared<pvd::AlarmMonitor>(
|
auto alarm_mon = std::make_shared<pvd::AlarmMonitor>(
|
||||||
io, model, emit_alarm_set, emit_alarm_clear);
|
R.io(), model, emit_alarm_set, emit_alarm_clear);
|
||||||
alarm_mon->start();
|
alarm_mon->start();
|
||||||
|
|
||||||
auto recipe_runner = std::make_shared<pvd::RecipeRunner>(
|
auto recipe_runner = std::make_shared<pvd::RecipeRunner>(
|
||||||
io, model, *sim, emit_event);
|
R.io(), model, *sim, emit_event);
|
||||||
|
|
||||||
// Wire control-state-change → CEID emission.
|
// Host command behaviour via the set_handler hook (runs on the io thread
|
||||||
sm->set_state_change_handler(
|
// during S2F41/F21/F49 dispatch) — this used to be a hand-rolled S2F41
|
||||||
[logfn, emit_event, desc](gem::ControlState from, gem::ControlState to,
|
// router override. The YAML still declares the static ack + emit_ceid;
|
||||||
gem::ControlEvent ev) {
|
// this adds the part config can't express: actually starting the recipe.
|
||||||
logfn(std::string("control: ") + gem::control_state_name(from) +
|
model->commands.set_handler(
|
||||||
" -> " + gem::control_state_name(to) +
|
"START", [model, recipe_runner](const std::string&,
|
||||||
" (" + gem::control_event_name(ev) + ")");
|
const std::vector<gem::CommandParameter>&) {
|
||||||
if (desc.emit_on_control_change)
|
for (const auto& pjid : model->process_jobs.ids()) {
|
||||||
emit_event(*desc.emit_on_control_change);
|
auto* pj = model->process_jobs.get(pjid);
|
||||||
|
if (pj && pj->fsm->state() == gem::ProcessJobState::WaitingForStart) {
|
||||||
|
model->process_jobs.fire_internal(pjid, gem::ProcessJobEvent::Start);
|
||||||
|
recipe_runner->start(pjid);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return gem::HostCmdAck::Accept;
|
||||||
});
|
});
|
||||||
|
|
||||||
// ---- §7.6 Periodic gauge updates for Prometheus ----------------------
|
// ---- Periodic gauge updates for Prometheus ------------------------------
|
||||||
auto gauge_timer = std::make_shared<asio::steady_timer>(io);
|
auto gauge_timer = std::make_shared<asio::steady_timer>(R.io());
|
||||||
std::function<void(std::error_code)> gauge_tick =
|
auto gauge_tick = std::make_shared<std::function<void(std::error_code)>>();
|
||||||
[&gauge_tick, gauge_timer, model, registry](std::error_code ec) {
|
*gauge_tick = [gauge_tick, gauge_timer, model, registry](std::error_code ec) {
|
||||||
if (ec) return;
|
if (ec) return;
|
||||||
auto p = model->svids.get(pvd::kSvidChamberPressure);
|
auto p = model->svids.get(pvd::kSvidChamberPressure);
|
||||||
if (p) {
|
if (p && std::holds_alternative<std::vector<float>>(p->value.storage())) {
|
||||||
const float v = std::get<std::vector<float>>(p->value.storage())[0];
|
registry->set_gauge("pvd_chamber_pressure_torr",
|
||||||
registry->set_gauge("pvd_chamber_pressure_torr", v);
|
std::get<std::vector<float>>(p->value.storage())[0]);
|
||||||
}
|
}
|
||||||
registry->set_gauge("pvd_spool_depth", model->spool.size());
|
registry->set_gauge("pvd_spool_depth", model->spool.size());
|
||||||
gauge_timer->expires_after(5s);
|
gauge_timer->expires_after(5s);
|
||||||
gauge_timer->async_wait(gauge_tick);
|
gauge_timer->async_wait(*gauge_tick);
|
||||||
};
|
};
|
||||||
gauge_timer->expires_after(5s);
|
gauge_timer->expires_after(5s);
|
||||||
gauge_timer->async_wait(gauge_tick);
|
gauge_timer->async_wait(*gauge_tick);
|
||||||
|
|
||||||
// ---- §7.7 Router + per-connection handler wiring --------------------
|
|
||||||
gem::Router router;
|
|
||||||
register_handlers(router, model, sm, desc,
|
|
||||||
emit_event, emit_alarm_set, recipe_runner);
|
|
||||||
logfn("registered " + std::to_string(router.size()) + " SECS-II handlers");
|
|
||||||
|
|
||||||
server.on_connection([&io, sm, model, logfn, active_conn, &router, registry]
|
|
||||||
(std::shared_ptr<Connection> conn) {
|
|
||||||
*active_conn = conn;
|
|
||||||
conn->set_closed_handler([active_conn](const std::string&) {
|
|
||||||
active_conn->reset();
|
|
||||||
});
|
|
||||||
conn->set_selected_handler([logfn, sm]() {
|
|
||||||
logfn(std::string("host SELECTED; control=") +
|
|
||||||
gem::control_state_name(sm->state()));
|
|
||||||
});
|
|
||||||
conn->set_message_handler(
|
|
||||||
[&router, model, conn, registry](const s2::Message& msg)
|
|
||||||
-> std::optional<s2::Message> {
|
|
||||||
registry->inc("pvd_messages_total",
|
|
||||||
{{"dir", "rx"},
|
|
||||||
{"stream", std::to_string(msg.stream)},
|
|
||||||
{"function", std::to_string(msg.function)}});
|
|
||||||
return router.dispatch_with_s9(
|
|
||||||
[&](uint8_t f, const std::array<uint8_t, 10>& mh) {
|
|
||||||
conn->emit_s9(f, mh);
|
|
||||||
},
|
|
||||||
[&]() -> std::optional<std::array<uint8_t, 10>> {
|
|
||||||
auto* h = conn->current_header();
|
|
||||||
return h ? std::optional{h->encode()} : std::nullopt;
|
|
||||||
}, msg);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
server.start();
|
|
||||||
logfn("ACME-PVD-3000 ready, listening on :" + std::to_string(port));
|
logfn("ACME-PVD-3000 ready, listening on :" + std::to_string(port));
|
||||||
io.run();
|
R.run(); // accept connections + run the io_context (blocks)
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -37,6 +37,15 @@ struct EquipmentDescriptor {
|
|||||||
std::string equipment_type; // S1F20 EQPTYP
|
std::string equipment_type; // S1F20 EQPTYP
|
||||||
std::vector<std::pair<uint8_t, std::string>> capabilities; // (CCODE, CDESC)
|
std::vector<std::pair<uint8_t, std::string>> capabilities; // (CCODE, CDESC)
|
||||||
std::optional<uint32_t> emit_on_control_change;
|
std::optional<uint32_t> emit_on_control_change;
|
||||||
|
|
||||||
|
// Role bindings (`roles:` in equipment.yaml): which configured ids the
|
||||||
|
// engine's built-in behaviours are wired to. Replaces magic constants that
|
||||||
|
// silently had to match the YAML. Defaults preserve the historical wiring
|
||||||
|
// for configs without a roles block.
|
||||||
|
uint32_t control_state_svid = 1; // SVID refreshed with the control state
|
||||||
|
uint32_t clock_svid = 2; // SVID refreshed with the clock string
|
||||||
|
uint32_t cj_executing_ceid = 400; // CEID fired when a CJ enters Executing
|
||||||
|
uint32_t cj_completed_ceid = 401; // CEID fired when a CJ enters Completed
|
||||||
};
|
};
|
||||||
|
|
||||||
// Loads data/equipment.yaml into the given data model and returns the
|
// Loads data/equipment.yaml into the given data model and returns the
|
||||||
|
|||||||
@@ -0,0 +1,855 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
// The gRPC Equipment service: translates proto/secsgem/v1 RPCs onto an
|
||||||
|
// EquipmentRuntime. Header-only so both secs_gemd and the daemon tests share
|
||||||
|
// one definition.
|
||||||
|
//
|
||||||
|
// Threading: gRPC handlers run on gRPC's own threads while the engine's
|
||||||
|
// io thread owns the data model, so handlers must not read the live stores.
|
||||||
|
// The constructor therefore snapshots the name->id/format maps (immutable
|
||||||
|
// after config load) — construct the service BEFORE run_async(). All writes
|
||||||
|
// go through the runtime's posting API.
|
||||||
|
|
||||||
|
#include <grpcpp/grpcpp.h>
|
||||||
|
|
||||||
|
#include <algorithm>
|
||||||
|
#include <atomic>
|
||||||
|
#include <chrono>
|
||||||
|
#include <condition_variable>
|
||||||
|
#include <cstdint>
|
||||||
|
#include <deque>
|
||||||
|
#include <map>
|
||||||
|
#include <memory>
|
||||||
|
#include <mutex>
|
||||||
|
#include <optional>
|
||||||
|
#include <string>
|
||||||
|
#include <utility>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
#include "secsgem/gem/messages.hpp"
|
||||||
|
#include "secsgem/gem/runtime.hpp"
|
||||||
|
#include "secsgem/secs2/item.hpp"
|
||||||
|
#include "secsgem/v1/equipment.grpc.pb.h"
|
||||||
|
#include "secsgem/v1/equipment.pb.h"
|
||||||
|
|
||||||
|
namespace secsgem::daemon {
|
||||||
|
|
||||||
|
namespace gem = secsgem::gem;
|
||||||
|
namespace s2 = secsgem::secs2;
|
||||||
|
namespace pb = secsgem::v1;
|
||||||
|
|
||||||
|
// proto Value -> SECS-II Item, honouring the variable's declared wire format
|
||||||
|
// (from equipment.yaml) so the host sees the same format in S1F11 namelists
|
||||||
|
// and in reported values. E.g. real 2.5 -> F4 if the variable is F4.
|
||||||
|
inline s2::Item to_item(const pb::Value& v, s2::Format want) {
|
||||||
|
using F = s2::Format;
|
||||||
|
switch (v.kind_case()) {
|
||||||
|
case pb::Value::kText:
|
||||||
|
return s2::Item::ascii(v.text());
|
||||||
|
case pb::Value::kBoolean:
|
||||||
|
return s2::Item::boolean(v.boolean());
|
||||||
|
case pb::Value::kBinary:
|
||||||
|
return s2::Item::binary({v.binary().begin(), v.binary().end()});
|
||||||
|
case pb::Value::kReal:
|
||||||
|
return want == F::F4 ? s2::Item::f4(static_cast<float>(v.real()))
|
||||||
|
: s2::Item::f8(v.real());
|
||||||
|
case pb::Value::kInteger: {
|
||||||
|
const int64_t n = v.integer();
|
||||||
|
switch (want) {
|
||||||
|
case F::U1: return s2::Item::u1(static_cast<uint8_t>(n));
|
||||||
|
case F::U2: return s2::Item::u2(static_cast<uint16_t>(n));
|
||||||
|
case F::U4: return s2::Item::u4(static_cast<uint32_t>(n));
|
||||||
|
case F::U8: return s2::Item::u8(static_cast<uint64_t>(n));
|
||||||
|
case F::I1: return s2::Item::i1(static_cast<int8_t>(n));
|
||||||
|
case F::I2: return s2::Item::i2(static_cast<int16_t>(n));
|
||||||
|
case F::I4: return s2::Item::i4(static_cast<int32_t>(n));
|
||||||
|
case F::F4: return s2::Item::f4(static_cast<float>(n));
|
||||||
|
case F::F8: return s2::Item::f8(static_cast<double>(n));
|
||||||
|
case F::Boolean: return s2::Item::boolean(n != 0);
|
||||||
|
default: return s2::Item::i8(n);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case pb::Value::kList: {
|
||||||
|
// TODO(daemon): list elements inherit the variable's scalar format;
|
||||||
|
// honouring per-element formats needs the declared Item's nested shape.
|
||||||
|
s2::Item::List items;
|
||||||
|
for (const auto& e : v.list().items()) items.push_back(to_item(e, want));
|
||||||
|
return s2::Item::list(std::move(items));
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
// Unreachable: callers reject KIND_NOT_SET before converting (see
|
||||||
|
// value_is_set). Kept as a safe fallback for future oneof additions.
|
||||||
|
return s2::Item::ascii("");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate a client-supplied Value before conversion. An unset oneof would
|
||||||
|
// otherwise silently become ASCII "" — reject it at the API edge instead.
|
||||||
|
inline bool value_is_set(const pb::Value& v) {
|
||||||
|
return v.kind_case() != pb::Value::KIND_NOT_SET;
|
||||||
|
}
|
||||||
|
|
||||||
|
// SECS-II Item -> proto Value (the read direction). Single-element numeric
|
||||||
|
// arrays — the overwhelmingly common case — become scalars; multi-element
|
||||||
|
// arrays become a List so nothing is lost.
|
||||||
|
// TODO(daemon): C2 (2-byte Unicode) currently surfaces as integer code
|
||||||
|
// points (it shares storage with U2); convert to text when a tool needs it.
|
||||||
|
// TODO(daemon): U8 values above 2^63-1 wrap through the sint64 integer
|
||||||
|
// field; switch Value to a dedicated uint64 arm if that ever bites.
|
||||||
|
inline pb::Value from_item(const s2::Item& item) {
|
||||||
|
pb::Value out;
|
||||||
|
switch (item.format()) {
|
||||||
|
case s2::Format::List: {
|
||||||
|
auto* list = out.mutable_list();
|
||||||
|
for (const auto& child : item.as_list()) *list->add_items() = from_item(child);
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
case s2::Format::ASCII:
|
||||||
|
case s2::Format::JIS8:
|
||||||
|
out.set_text(item.as_ascii());
|
||||||
|
return out;
|
||||||
|
case s2::Format::Binary: {
|
||||||
|
const auto& b = item.as_bytes();
|
||||||
|
out.set_binary(std::string(b.begin(), b.end()));
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
case s2::Format::Boolean: {
|
||||||
|
const auto& b = item.as_bytes();
|
||||||
|
if (b.size() == 1) {
|
||||||
|
out.set_boolean(b[0] != 0);
|
||||||
|
} else {
|
||||||
|
auto* list = out.mutable_list();
|
||||||
|
for (auto v : b) list->add_items()->set_boolean(v != 0);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
break; // numeric formats: handled generically below
|
||||||
|
}
|
||||||
|
return std::visit(
|
||||||
|
[&](const auto& vec) -> pb::Value {
|
||||||
|
using V = std::decay_t<decltype(vec)>;
|
||||||
|
if constexpr (std::is_same_v<V, s2::Item::List> ||
|
||||||
|
std::is_same_v<V, std::string>) {
|
||||||
|
return out; // unreachable: handled above
|
||||||
|
} else {
|
||||||
|
auto set_one = [](pb::Value& dst, auto v) {
|
||||||
|
if constexpr (std::is_floating_point_v<decltype(v)>)
|
||||||
|
dst.set_real(static_cast<double>(v));
|
||||||
|
else
|
||||||
|
dst.set_integer(static_cast<int64_t>(v));
|
||||||
|
};
|
||||||
|
if (vec.size() == 1) {
|
||||||
|
set_one(out, vec[0]);
|
||||||
|
} else {
|
||||||
|
auto* list = out.mutable_list();
|
||||||
|
for (auto v : vec) set_one(*list->add_items(), v);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
item.storage());
|
||||||
|
}
|
||||||
|
|
||||||
|
inline pb::ControlState::State to_proto_state(gem::ControlState s) {
|
||||||
|
switch (s) {
|
||||||
|
case gem::ControlState::EquipmentOffline: return pb::ControlState::EQUIPMENT_OFFLINE;
|
||||||
|
case gem::ControlState::AttemptOnline: return pb::ControlState::ATTEMPT_ONLINE;
|
||||||
|
case gem::ControlState::HostOffline: return pb::ControlState::HOST_OFFLINE;
|
||||||
|
case gem::ControlState::OnlineLocal: return pb::ControlState::ONLINE_LOCAL;
|
||||||
|
case gem::ControlState::OnlineRemote: return pb::ControlState::ONLINE_REMOTE;
|
||||||
|
}
|
||||||
|
return pb::ControlState::EQUIPMENT_OFFLINE;
|
||||||
|
}
|
||||||
|
|
||||||
|
class EquipmentService final : public pb::Equipment::Service {
|
||||||
|
public:
|
||||||
|
// Snapshots the (immutable) name->id/format dictionaries and registers the
|
||||||
|
// health observers. Construct before run_async() so the model is read (and
|
||||||
|
// observers land) while the io thread isn't running yet.
|
||||||
|
explicit EquipmentService(gem::EquipmentRuntime& rt) : rt_(rt) {
|
||||||
|
for (const auto& sv : rt.model().svids.all())
|
||||||
|
vars_.insert({sv.name, {sv.id, sv.value.format()}});
|
||||||
|
for (const auto& dv : rt.model().dvids.all())
|
||||||
|
vars_.insert({dv.name, {dv.id, dv.value.format()}});
|
||||||
|
for (const auto& ev : rt.model().events.all_events())
|
||||||
|
events_.insert({ev.name, ev.id});
|
||||||
|
for (const auto& al : rt.model().alarms.all()) {
|
||||||
|
if (!al.name.empty()) alarms_.insert({al.name, al.id});
|
||||||
|
alarms_.insert({std::to_string(al.id), al.id}); // always addressable by id
|
||||||
|
}
|
||||||
|
// Health signals: bump a version + wake WatchHealth streams whenever the
|
||||||
|
// HSMS link or the control state changes (observers fire on the io thread;
|
||||||
|
// add_ observers survive register_default_handlers' primary set_).
|
||||||
|
rt.add_link_observer([this](bool selected) {
|
||||||
|
link_selected_.store(selected, std::memory_order_relaxed);
|
||||||
|
bump_health();
|
||||||
|
});
|
||||||
|
rt.add_control_state_observer(
|
||||||
|
[this](gem::ControlState, gem::ControlState, gem::ControlEvent) {
|
||||||
|
bump_health();
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const auto& ec : rt.model().ecids.all())
|
||||||
|
ecnames_.insert({ec.id, ec.name});
|
||||||
|
|
||||||
|
// GEM300 in-the-loop (Phase D, observe-and-report): forward job
|
||||||
|
// lifecycle, recipe downloads, and accepted EC writes onto the
|
||||||
|
// Subscribe stream. Observers fire on the io thread; push_request only
|
||||||
|
// takes the subscriber mutex briefly. add_ observers coexist with the
|
||||||
|
// primary handlers register_default_handlers installs (HandlerSlot).
|
||||||
|
rt.model().process_jobs.add_state_change_handler(
|
||||||
|
[this](const std::string& prjobid, gem::ProcessJobState,
|
||||||
|
gem::ProcessJobState to, gem::ProcessJobEvent trig) {
|
||||||
|
pb::ProcessJob::Action action;
|
||||||
|
using PS = gem::ProcessJobState;
|
||||||
|
using PE = gem::ProcessJobEvent;
|
||||||
|
if (to == PS::Processing && trig == PE::Start) action = pb::ProcessJob::START;
|
||||||
|
else if (to == PS::Processing && trig == PE::Resume) action = pb::ProcessJob::RESUME;
|
||||||
|
else if (to == PS::Paused) action = pb::ProcessJob::PAUSE;
|
||||||
|
else if (to == PS::Stopping) action = pb::ProcessJob::STOP;
|
||||||
|
else if (to == PS::Aborting) action = pb::ProcessJob::ABORT;
|
||||||
|
else return; // SettingUp/complete/etc.: not tool work
|
||||||
|
pb::HostRequest hr;
|
||||||
|
auto* job = hr.mutable_process_job();
|
||||||
|
job->set_job_id(prjobid);
|
||||||
|
job->set_action(action);
|
||||||
|
// Observer runs on the io thread — reading the store is safe.
|
||||||
|
if (const auto* pj = rt_.model().process_jobs.get(prjobid)) {
|
||||||
|
job->set_recipe(pj->ppid);
|
||||||
|
for (const auto& m : pj->mtrloutspec) job->add_carriers(m);
|
||||||
|
}
|
||||||
|
push_request(std::move(hr));
|
||||||
|
});
|
||||||
|
rt.model().recipes.add_added_handler(
|
||||||
|
[this](const std::string& ppid, const std::string& body) {
|
||||||
|
pb::HostRequest hr;
|
||||||
|
hr.mutable_process_program()->set_ppid(ppid);
|
||||||
|
hr.mutable_process_program()->set_body(body);
|
||||||
|
push_request(std::move(hr));
|
||||||
|
});
|
||||||
|
for (const auto& rcmd : rt.model().commands.names()) commands_.push_back(rcmd);
|
||||||
|
descr_model_ = rt.descriptor().model_name;
|
||||||
|
descr_rev_ = rt.descriptor().software_rev;
|
||||||
|
|
||||||
|
// E87 (D10): the host's carrier-ID decisions go to the tool. PROCEED on
|
||||||
|
// a Confirmed transition (ProceedWithCarrier or Bind), CANCEL on
|
||||||
|
// CancelCarrier. The tool announces carriers + drives access via
|
||||||
|
// ReportCarrier.
|
||||||
|
rt.model().carriers.add_id_handler(
|
||||||
|
[this](const std::string& cid, gem::CarrierIDStatus,
|
||||||
|
gem::CarrierIDStatus to, gem::CarrierIDEvent ev) {
|
||||||
|
pb::CarrierAction::Action action;
|
||||||
|
if (ev == gem::CarrierIDEvent::CancelCarrier)
|
||||||
|
action = pb::CarrierAction::CANCEL;
|
||||||
|
else if (to == gem::CarrierIDStatus::Confirmed)
|
||||||
|
action = pb::CarrierAction::PROCEED;
|
||||||
|
else
|
||||||
|
return;
|
||||||
|
pb::HostRequest hr;
|
||||||
|
auto* ca = hr.mutable_carrier();
|
||||||
|
ca->set_carrier_id(cid);
|
||||||
|
if (const auto* c = rt_.model().carriers.get(cid))
|
||||||
|
ca->set_port(c->port_id);
|
||||||
|
ca->set_action(action);
|
||||||
|
push_request(std::move(hr));
|
||||||
|
});
|
||||||
|
|
||||||
|
rt.model().ecids.add_changed_handler(
|
||||||
|
[this](uint32_t ecid, const s2::Item& value) {
|
||||||
|
pb::HostRequest hr;
|
||||||
|
auto it = ecnames_.find(ecid);
|
||||||
|
hr.mutable_constant()->set_name(
|
||||||
|
it != ecnames_.end() ? it->second : std::to_string(ecid));
|
||||||
|
*hr.mutable_constant()->mutable_value() = from_item(value);
|
||||||
|
push_request(std::move(hr));
|
||||||
|
});
|
||||||
|
|
||||||
|
// Host commands -> the Subscribe stream (the HCACK-4 contract). For every
|
||||||
|
// YAML-declared command, install a behaviour handler that pushes the
|
||||||
|
// command to subscribed tool clients and answers the host immediately
|
||||||
|
// with AcceptedWillFinishLater; with NO subscriber it returns the
|
||||||
|
// command's declarative ack — i.e. exactly the pre-daemon behaviour, and
|
||||||
|
// honest (never "will finish later" for work nobody will do). Runs on
|
||||||
|
// the io thread; only briefly takes the subscriber mutex.
|
||||||
|
for (const auto& rcmd : rt.model().commands.names()) {
|
||||||
|
const auto declarative =
|
||||||
|
rt.model().commands.spec(rcmd)->ack; // names() came from the map
|
||||||
|
rt.on_command(rcmd, [this, declarative](
|
||||||
|
const std::string& cmd,
|
||||||
|
const std::vector<gem::CommandParameter>& params) {
|
||||||
|
return forward_command(cmd, params, declarative);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
grpc::Status SetVariables(grpc::ServerContext*, const pb::VariableUpdate* req,
|
||||||
|
pb::Ack* resp) override {
|
||||||
|
for (const auto& kv : req->values()) {
|
||||||
|
auto it = vars_.find(kv.first);
|
||||||
|
if (it == vars_.end()) {
|
||||||
|
resp->set_code(pb::Ack::PARAMETER_INVALID);
|
||||||
|
resp->set_message("no variable named '" + kv.first + "'");
|
||||||
|
return grpc::Status::OK;
|
||||||
|
}
|
||||||
|
if (!value_is_set(kv.second)) {
|
||||||
|
resp->set_code(pb::Ack::PARAMETER_INVALID);
|
||||||
|
resp->set_message("value for '" + kv.first + "' has no kind set");
|
||||||
|
return grpc::Status::OK;
|
||||||
|
}
|
||||||
|
rt_.set_variable(it->second.vid, to_item(kv.second, it->second.format));
|
||||||
|
}
|
||||||
|
resp->set_code(pb::Ack::ACCEPT);
|
||||||
|
return grpc::Status::OK;
|
||||||
|
}
|
||||||
|
|
||||||
|
grpc::Status FireEvent(grpc::ServerContext*, const pb::Event* req,
|
||||||
|
pb::Ack* resp) override {
|
||||||
|
// Optional per-fire variable values, then trigger the collection event.
|
||||||
|
for (const auto& kv : req->data()) {
|
||||||
|
auto it = vars_.find(kv.first);
|
||||||
|
if (it == vars_.end()) {
|
||||||
|
resp->set_code(pb::Ack::PARAMETER_INVALID);
|
||||||
|
resp->set_message("no variable named '" + kv.first + "'");
|
||||||
|
return grpc::Status::OK;
|
||||||
|
}
|
||||||
|
if (!value_is_set(kv.second)) {
|
||||||
|
resp->set_code(pb::Ack::PARAMETER_INVALID);
|
||||||
|
resp->set_message("value for '" + kv.first + "' has no kind set");
|
||||||
|
return grpc::Status::OK;
|
||||||
|
}
|
||||||
|
rt_.set_variable(it->second.vid, to_item(kv.second, it->second.format));
|
||||||
|
}
|
||||||
|
auto ev = events_.find(req->name());
|
||||||
|
if (ev == events_.end()) {
|
||||||
|
resp->set_code(pb::Ack::PARAMETER_INVALID);
|
||||||
|
resp->set_message("no event named '" + req->name() + "'");
|
||||||
|
return grpc::Status::OK;
|
||||||
|
}
|
||||||
|
rt_.emit_event(ev->second);
|
||||||
|
resp->set_code(pb::Ack::ACCEPT);
|
||||||
|
return grpc::Status::OK;
|
||||||
|
}
|
||||||
|
|
||||||
|
grpc::Status SetAlarm(grpc::ServerContext*, const pb::Alarm* req,
|
||||||
|
pb::Ack* resp) override {
|
||||||
|
return alarm_action(req->name(), /*set=*/true, resp);
|
||||||
|
}
|
||||||
|
|
||||||
|
grpc::Status ClearAlarm(grpc::ServerContext*, const pb::Alarm* req,
|
||||||
|
pb::Ack* resp) override {
|
||||||
|
return alarm_action(req->name(), /*set=*/false, resp);
|
||||||
|
}
|
||||||
|
|
||||||
|
grpc::Status GetVariables(grpc::ServerContext*, const pb::VariableQuery* req,
|
||||||
|
pb::VariableSnapshot* resp) override {
|
||||||
|
// Resolve names against the snapshot maps (empty query = everything).
|
||||||
|
std::vector<std::pair<std::string, uint32_t>> wanted;
|
||||||
|
if (req->names().empty()) {
|
||||||
|
wanted.reserve(vars_.size());
|
||||||
|
for (const auto& [name, ref] : vars_) wanted.emplace_back(name, ref.vid);
|
||||||
|
} else {
|
||||||
|
for (const auto& name : req->names()) {
|
||||||
|
auto it = vars_.find(name);
|
||||||
|
if (it == vars_.end())
|
||||||
|
return grpc::Status(grpc::StatusCode::INVALID_ARGUMENT,
|
||||||
|
"no variable named '" + name + "'");
|
||||||
|
wanted.emplace_back(name, it->second.vid);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Mutable values live on the io thread — read them there (read_sync is
|
||||||
|
// the standard pattern; see runtime.hpp).
|
||||||
|
std::vector<uint32_t> vids;
|
||||||
|
vids.reserve(wanted.size());
|
||||||
|
for (const auto& w : wanted) vids.push_back(w.second);
|
||||||
|
auto values = rt_.read_sync([this, vids]() {
|
||||||
|
std::vector<std::optional<s2::Item>> out;
|
||||||
|
out.reserve(vids.size());
|
||||||
|
for (auto vid : vids) out.push_back(rt_.model().vid_value(vid));
|
||||||
|
return out;
|
||||||
|
});
|
||||||
|
if (!values)
|
||||||
|
return grpc::Status(grpc::StatusCode::UNAVAILABLE,
|
||||||
|
"engine io thread did not answer (not running?)");
|
||||||
|
for (std::size_t i = 0; i < wanted.size(); ++i) {
|
||||||
|
if (!(*values)[i]) continue; // vid vanished — defensive, can't happen
|
||||||
|
(*resp->mutable_values())[wanted[i].first] = from_item(*(*values)[i]);
|
||||||
|
}
|
||||||
|
return grpc::Status::OK;
|
||||||
|
}
|
||||||
|
|
||||||
|
grpc::Status GetControlState(grpc::ServerContext*, const pb::Empty*,
|
||||||
|
pb::ControlState* resp) override {
|
||||||
|
// Thread-safe: control_state() reads the runtime's atomic mirror.
|
||||||
|
resp->set_state(to_proto_state(rt_.control_state()));
|
||||||
|
return grpc::Status::OK;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Everything the host asks of the tool, as a stream. v1 contract:
|
||||||
|
// - firehose: every subscriber receives every host request;
|
||||||
|
// - NO buffering: commands arriving with no subscriber take the
|
||||||
|
// declarative ack instead (see the constructor's forwarding handler);
|
||||||
|
// - the S2F42 already went out as HCACK-4 when a command appears here —
|
||||||
|
// report the real outcome via FireEvent/SetAlarm; CompleteCommand
|
||||||
|
// correlates/audits.
|
||||||
|
grpc::Status Subscribe(grpc::ServerContext* ctx, const pb::SubscribeRequest* req,
|
||||||
|
grpc::ServerWriter<pb::HostRequest>* writer) override {
|
||||||
|
auto sub = std::make_shared<Subscriber>();
|
||||||
|
{
|
||||||
|
std::lock_guard<std::mutex> lk(subs_mu_);
|
||||||
|
subs_.push_back(sub);
|
||||||
|
}
|
||||||
|
rt_.log("tool client subscribed" +
|
||||||
|
(req->client().empty() ? "" : " (" + req->client() + ")"));
|
||||||
|
bool alive = true;
|
||||||
|
// Poll at 100ms: with the sync server one thread is parked here per open
|
||||||
|
// stream, so a cancelled client must free its worker promptly (gRPC's
|
||||||
|
// sync API only exposes cancellation via polled IsCancelled()).
|
||||||
|
while (alive && !ctx->IsCancelled()) {
|
||||||
|
std::optional<pb::HostRequest> next;
|
||||||
|
{
|
||||||
|
std::unique_lock<std::mutex> lk(subs_mu_);
|
||||||
|
subs_cv_.wait_for(lk, std::chrono::milliseconds(100),
|
||||||
|
[&] { return !sub->queue.empty(); });
|
||||||
|
if (!sub->queue.empty()) {
|
||||||
|
next = std::move(sub->queue.front());
|
||||||
|
sub->queue.pop_front();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (next) alive = writer->Write(*next); // write OUTSIDE the lock
|
||||||
|
}
|
||||||
|
{
|
||||||
|
std::lock_guard<std::mutex> lk(subs_mu_);
|
||||||
|
subs_.erase(std::remove(subs_.begin(), subs_.end(), sub), subs_.end());
|
||||||
|
}
|
||||||
|
rt_.log("tool client unsubscribed");
|
||||||
|
return grpc::Status::OK;
|
||||||
|
}
|
||||||
|
|
||||||
|
grpc::Status CompleteCommand(grpc::ServerContext*, const pb::CommandResult* req,
|
||||||
|
pb::Ack* resp) override {
|
||||||
|
std::string rcmd;
|
||||||
|
{
|
||||||
|
std::lock_guard<std::mutex> lk(subs_mu_);
|
||||||
|
auto it = pending_.find(req->id());
|
||||||
|
if (it == pending_.end()) {
|
||||||
|
resp->set_code(pb::Ack::PARAMETER_INVALID);
|
||||||
|
resp->set_message("unknown command id '" + req->id() + "'");
|
||||||
|
return grpc::Status::OK;
|
||||||
|
}
|
||||||
|
rcmd = it->second;
|
||||||
|
pending_.erase(it);
|
||||||
|
}
|
||||||
|
rt_.log("command '" + rcmd + "' id=" + req->id() + " completed by tool (ack=" +
|
||||||
|
std::to_string(static_cast<int>(req->ack().code())) + ")");
|
||||||
|
resp->set_code(pb::Ack::ACCEPT);
|
||||||
|
return grpc::Status::OK;
|
||||||
|
}
|
||||||
|
|
||||||
|
// E40 progress reports (Phase D, observe-and-report): the tool advances
|
||||||
|
// the physical work and tells the engine; the engine drives the FSM and
|
||||||
|
// emits the matching S16F9 alert / CEIDs to the host. PROCESSING is
|
||||||
|
// informational (the engine entered it when the host commanded Start).
|
||||||
|
grpc::Status ReportProcessJob(grpc::ServerContext*, const pb::ProcessJobState* req,
|
||||||
|
pb::Ack* resp) override {
|
||||||
|
const std::string job_id = req->job_id();
|
||||||
|
const auto state = req->state();
|
||||||
|
auto outcome = rt_.read_sync([this, job_id, state]() -> std::optional<bool> {
|
||||||
|
auto& pjs = rt_.model().process_jobs;
|
||||||
|
if (!pjs.has(job_id)) return std::nullopt;
|
||||||
|
switch (state) {
|
||||||
|
case pb::ProcessJobState::SETTING_UP:
|
||||||
|
return pjs.fire_internal(job_id, gem::ProcessJobEvent::SetupComplete);
|
||||||
|
case pb::ProcessJobState::COMPLETE:
|
||||||
|
return pjs.fire_internal(job_id, gem::ProcessJobEvent::ProcessComplete);
|
||||||
|
case pb::ProcessJobState::ABORTED:
|
||||||
|
return pjs.fire_internal(job_id, gem::ProcessJobEvent::AbortComplete);
|
||||||
|
default:
|
||||||
|
return true; // PROCESSING: informational
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (!outcome) {
|
||||||
|
resp->set_code(pb::Ack::CANNOT_DO_NOW);
|
||||||
|
resp->set_message("engine io thread did not answer (not running?)");
|
||||||
|
} else if (!*outcome) {
|
||||||
|
resp->set_code(pb::Ack::INVALID_OBJECT);
|
||||||
|
resp->set_message("no process job '" + job_id + "'");
|
||||||
|
} else if (!**outcome) {
|
||||||
|
resp->set_code(pb::Ack::CANNOT_DO_NOW);
|
||||||
|
resp->set_message("E40 table rejects that transition from the current state");
|
||||||
|
} else {
|
||||||
|
resp->set_code(pb::Ack::ACCEPT);
|
||||||
|
}
|
||||||
|
return grpc::Status::OK;
|
||||||
|
}
|
||||||
|
|
||||||
|
// E87 carrier reporting (observe-and-report): WAITING announces an
|
||||||
|
// arrived carrier (idempotent; updates the slot map), IN_ACCESS/COMPLETE
|
||||||
|
// drive the access FSM. See the proto for the contract.
|
||||||
|
grpc::Status ReportCarrier(grpc::ServerContext*, const pb::CarrierState* req,
|
||||||
|
pb::Ack* resp) override {
|
||||||
|
const std::string cid = req->carrier_id();
|
||||||
|
const auto port = static_cast<uint8_t>(req->port());
|
||||||
|
const auto state = req->state();
|
||||||
|
std::vector<bool> slots(req->slots().begin(), req->slots().end());
|
||||||
|
auto outcome = rt_.read_sync(
|
||||||
|
[this, cid, port, state, slots]() -> std::optional<bool> {
|
||||||
|
auto& carriers = rt_.model().carriers;
|
||||||
|
if (state == pb::CarrierState::WAITING) {
|
||||||
|
carriers.create(cid, port,
|
||||||
|
slots.empty() ? 25 : slots.size()); // idempotent
|
||||||
|
// E87 slot-map bytes: 0 = Empty, 1 = NotEmpty.
|
||||||
|
for (std::size_t i = 0; i < slots.size(); ++i)
|
||||||
|
carriers.set_slot_state(cid, i, slots[i] ? 1 : 0);
|
||||||
|
if (!slots.empty())
|
||||||
|
carriers.fire_slot_map_event(cid, gem::SlotMapEvent::Read);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (!carriers.has(cid)) return std::nullopt;
|
||||||
|
if (state == pb::CarrierState::IN_ACCESS)
|
||||||
|
return carriers.fire_access_event(cid, gem::CarrierAccessEvent::BeginAccess);
|
||||||
|
if (state == pb::CarrierState::COMPLETE)
|
||||||
|
return carriers.fire_access_event(cid, gem::CarrierAccessEvent::EndAccess);
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
if (!outcome) {
|
||||||
|
resp->set_code(pb::Ack::CANNOT_DO_NOW);
|
||||||
|
resp->set_message("engine io thread did not answer (not running?)");
|
||||||
|
} else if (!*outcome) {
|
||||||
|
resp->set_code(pb::Ack::INVALID_OBJECT);
|
||||||
|
resp->set_message("no carrier '" + cid + "' (announce it with WAITING first)");
|
||||||
|
} else if (!**outcome) {
|
||||||
|
resp->set_code(pb::Ack::CANNOT_DO_NOW);
|
||||||
|
resp->set_message("E87 access table rejects that transition");
|
||||||
|
} else {
|
||||||
|
resp->set_code(pb::Ack::ACCEPT);
|
||||||
|
}
|
||||||
|
return grpc::Status::OK;
|
||||||
|
}
|
||||||
|
|
||||||
|
grpc::Status ReportSubstrate(grpc::ServerContext*, const pb::SubstrateReport* req,
|
||||||
|
pb::Ack* resp) override {
|
||||||
|
const std::string sid = req->substrate_id();
|
||||||
|
const std::string cid = req->carrier_id();
|
||||||
|
const auto slot = static_cast<uint8_t>(req->slot());
|
||||||
|
const auto m = req->milestone();
|
||||||
|
|
||||||
|
// ARRIVED creates the substrate. A duplicate is a real conflict (re-creating
|
||||||
|
// would silently wipe the wafer's FSM state + history), so reject it with an
|
||||||
|
// accurate message rather than the generic "no such object" path.
|
||||||
|
if (m == pb::SubstrateReport::ARRIVED) {
|
||||||
|
auto created = rt_.read_sync([this, sid, cid, slot]() {
|
||||||
|
return rt_.model().substrates.create(sid, cid, slot) ==
|
||||||
|
gem::SubstrateStore::CreateResult::Created;
|
||||||
|
});
|
||||||
|
if (!created) {
|
||||||
|
resp->set_code(pb::Ack::CANNOT_DO_NOW);
|
||||||
|
resp->set_message("engine io thread did not answer (not running?)");
|
||||||
|
} else if (!*created) {
|
||||||
|
resp->set_code(pb::Ack::CANNOT_DO_NOW);
|
||||||
|
resp->set_message("substrate '" + sid + "' already exists");
|
||||||
|
} else {
|
||||||
|
resp->set_code(pb::Ack::ACCEPT);
|
||||||
|
}
|
||||||
|
return grpc::Status::OK;
|
||||||
|
}
|
||||||
|
|
||||||
|
auto outcome = rt_.read_sync([this, sid, m]() -> std::optional<bool> {
|
||||||
|
auto& subs = rt_.model().substrates;
|
||||||
|
if (!subs.has(sid)) return std::nullopt;
|
||||||
|
switch (m) {
|
||||||
|
case pb::SubstrateReport::AT_WORK:
|
||||||
|
return subs.fire_location_event(sid, gem::SubstrateEvent::Acquire, "");
|
||||||
|
case pb::SubstrateReport::AT_DESTINATION:
|
||||||
|
return subs.fire_location_event(sid, gem::SubstrateEvent::Release, "");
|
||||||
|
case pb::SubstrateReport::PROCESSING:
|
||||||
|
return subs.fire_processing_event(sid, gem::SubstrateProcessingEvent::StartProcessing);
|
||||||
|
case pb::SubstrateReport::PROCESSED:
|
||||||
|
return subs.fire_processing_event(sid, gem::SubstrateProcessingEvent::EndProcessing);
|
||||||
|
default:
|
||||||
|
return false; // unknown milestone
|
||||||
|
}
|
||||||
|
});
|
||||||
|
ack_from_outcome(outcome, resp, "substrate '" + sid + "'",
|
||||||
|
"E90 table rejects that transition");
|
||||||
|
return grpc::Status::OK;
|
||||||
|
}
|
||||||
|
|
||||||
|
grpc::Status ReportModule(grpc::ServerContext*, const pb::ModuleReport* req,
|
||||||
|
pb::Ack* resp) override {
|
||||||
|
const std::string mid = req->module_id();
|
||||||
|
const auto st = req->state();
|
||||||
|
auto outcome = rt_.read_sync([this, mid, st]() -> bool {
|
||||||
|
auto& mods = rt_.model().modules;
|
||||||
|
if (!mods.has(mid)) mods.create(mid); // auto-create; starts NotExecuting
|
||||||
|
switch (st) {
|
||||||
|
case pb::ModuleReport::GENERAL_EXECUTING:
|
||||||
|
return mods.fire(mid, gem::ModuleEvent::StartGeneral);
|
||||||
|
case pb::ModuleReport::STEP_EXECUTING:
|
||||||
|
return mods.fire(mid, gem::ModuleEvent::StartStep);
|
||||||
|
case pb::ModuleReport::STEP_COMPLETED:
|
||||||
|
return mods.fire(mid, gem::ModuleEvent::CompleteStep);
|
||||||
|
case pb::ModuleReport::NOT_EXECUTING:
|
||||||
|
return mods.fire(mid, gem::ModuleEvent::Reset);
|
||||||
|
default:
|
||||||
|
return false; // unknown state
|
||||||
|
}
|
||||||
|
});
|
||||||
|
// Module auto-creates, so "not found" can't happen; only the FSM verdict.
|
||||||
|
if (!outcome) {
|
||||||
|
resp->set_code(pb::Ack::CANNOT_DO_NOW);
|
||||||
|
resp->set_message("engine io thread did not answer");
|
||||||
|
} else if (!*outcome) {
|
||||||
|
resp->set_code(pb::Ack::CANNOT_DO_NOW);
|
||||||
|
resp->set_message("E157 table rejects that transition from the current state");
|
||||||
|
} else {
|
||||||
|
resp->set_code(pb::Ack::ACCEPT);
|
||||||
|
}
|
||||||
|
return grpc::Status::OK;
|
||||||
|
}
|
||||||
|
|
||||||
|
grpc::Status Describe(grpc::ServerContext*, const pb::Empty*,
|
||||||
|
pb::EquipmentDescription* resp) override {
|
||||||
|
resp->set_model_name(descr_model_);
|
||||||
|
resp->set_software_rev(descr_rev_);
|
||||||
|
for (const auto& [name, _] : vars_) resp->add_variables(name);
|
||||||
|
for (const auto& [name, _] : events_) resp->add_events(name);
|
||||||
|
for (const auto& [name, _] : alarms_) resp->add_alarms(name);
|
||||||
|
for (const auto& rcmd : commands_) resp->add_commands(rcmd);
|
||||||
|
for (const auto& [_, name] : ecnames_) resp->add_constants(name);
|
||||||
|
return grpc::Status::OK;
|
||||||
|
}
|
||||||
|
|
||||||
|
grpc::Status FlushSpool(grpc::ServerContext*, const pb::SpoolFlushRequest* req,
|
||||||
|
pb::Ack* resp) override {
|
||||||
|
const bool purge = req->purge();
|
||||||
|
auto done = rt_.read_sync([this, purge]() {
|
||||||
|
if (purge) rt_.model().spool.clear();
|
||||||
|
else rt_.model().spool.drain(); // toward the host (see S6F23)
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
resp->set_code(done ? pb::Ack::ACCEPT : pb::Ack::CANNOT_DO_NOW);
|
||||||
|
if (!done) resp->set_message("engine io thread did not answer");
|
||||||
|
return grpc::Status::OK;
|
||||||
|
}
|
||||||
|
|
||||||
|
grpc::Status SendTerminalMessage(grpc::ServerContext*, const pb::TerminalMessage* req,
|
||||||
|
pb::Ack* resp) override {
|
||||||
|
const auto tid = static_cast<uint8_t>(req->tid());
|
||||||
|
const std::string text = req->text();
|
||||||
|
auto delivered = rt_.read_sync([this, tid, text]() {
|
||||||
|
return rt_.deliver_or_spool(
|
||||||
|
gem::s10f1_terminal_display_single(tid, text), "S10F1 (tool)");
|
||||||
|
});
|
||||||
|
if (!delivered) {
|
||||||
|
resp->set_code(pb::Ack::CANNOT_DO_NOW);
|
||||||
|
resp->set_message("engine io thread did not answer");
|
||||||
|
} else if (!*delivered) {
|
||||||
|
resp->set_code(pb::Ack::CANNOT_DO_NOW);
|
||||||
|
resp->set_message("no host connected and stream 10 is not spoolable");
|
||||||
|
} else {
|
||||||
|
resp->set_code(pb::Ack::ACCEPT);
|
||||||
|
}
|
||||||
|
return grpc::Status::OK;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Operator-panel control-state transitions (e.g. "offline for maintenance").
|
||||||
|
// Fires the operator events on the io thread and reports what the E30 table
|
||||||
|
// actually did: ACCEPT iff the equipment landed in the requested state,
|
||||||
|
// CANNOT_DO_NOW (naming the actual state) otherwise. Note the shipped table
|
||||||
|
// has no operator path to EQUIPMENT_OFFLINE — operator_offline lands
|
||||||
|
// HOST_OFFLINE — so honesty matters here.
|
||||||
|
grpc::Status RequestControlState(grpc::ServerContext*,
|
||||||
|
const pb::ControlStateRequest* req,
|
||||||
|
pb::Ack* resp) override {
|
||||||
|
using PS = pb::ControlState;
|
||||||
|
const auto desired = req->desired();
|
||||||
|
if (desired == PS::ATTEMPT_ONLINE) {
|
||||||
|
resp->set_code(pb::Ack::PARAMETER_INVALID);
|
||||||
|
resp->set_message("ATTEMPT_ONLINE is transient; request a settled state");
|
||||||
|
return grpc::Status::OK;
|
||||||
|
}
|
||||||
|
auto final_state = rt_.read_sync([this, desired]() {
|
||||||
|
auto& sm = rt_.control();
|
||||||
|
switch (desired) {
|
||||||
|
case PS::ONLINE_LOCAL:
|
||||||
|
if (sm.state() == gem::ControlState::OnlineRemote) sm.operator_local();
|
||||||
|
else if (!sm.online()) sm.operator_online(); // table chains to OnlineLocal
|
||||||
|
break;
|
||||||
|
case PS::ONLINE_REMOTE:
|
||||||
|
if (sm.state() == gem::ControlState::OnlineLocal) sm.operator_remote();
|
||||||
|
else if (!sm.online()) {
|
||||||
|
sm.operator_online();
|
||||||
|
sm.operator_remote();
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case PS::HOST_OFFLINE:
|
||||||
|
case PS::EQUIPMENT_OFFLINE:
|
||||||
|
sm.operator_offline();
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
return sm.state();
|
||||||
|
});
|
||||||
|
if (!final_state) {
|
||||||
|
resp->set_code(pb::Ack::CANNOT_DO_NOW);
|
||||||
|
resp->set_message("engine io thread did not answer (not running?)");
|
||||||
|
return grpc::Status::OK;
|
||||||
|
}
|
||||||
|
if (to_proto_state(*final_state) == desired) {
|
||||||
|
resp->set_code(pb::Ack::ACCEPT);
|
||||||
|
} else {
|
||||||
|
resp->set_code(pb::Ack::CANNOT_DO_NOW);
|
||||||
|
resp->set_message(std::string("equipment is in ") +
|
||||||
|
gem::control_state_name(*final_state));
|
||||||
|
}
|
||||||
|
return grpc::Status::OK;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Streams a Health snapshot immediately, then again whenever the link or
|
||||||
|
// control state changes (and on spool-depth changes, sampled at the poll
|
||||||
|
// interval). Ends when the client cancels or the engine stops.
|
||||||
|
grpc::Status WatchHealth(grpc::ServerContext* ctx, const pb::Empty*,
|
||||||
|
grpc::ServerWriter<pb::Health>* writer) override {
|
||||||
|
bool first = true;
|
||||||
|
pb::Health last;
|
||||||
|
while (!ctx->IsCancelled()) {
|
||||||
|
const uint64_t seen = health_version_.load(std::memory_order_relaxed);
|
||||||
|
auto snap = make_health();
|
||||||
|
if (!snap) break; // engine stopped — end the stream
|
||||||
|
if (first || snap->link() != last.link() ||
|
||||||
|
snap->control_state() != last.control_state() ||
|
||||||
|
snap->spool_depth() != last.spool_depth()) {
|
||||||
|
if (!writer->Write(*snap)) break;
|
||||||
|
last = *snap;
|
||||||
|
first = false;
|
||||||
|
}
|
||||||
|
std::unique_lock<std::mutex> lk(health_mu_);
|
||||||
|
health_cv_.wait_for(lk, std::chrono::milliseconds(500), [&] {
|
||||||
|
return health_version_.load(std::memory_order_relaxed) != seen;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return grpc::Status::OK;
|
||||||
|
}
|
||||||
|
|
||||||
|
private:
|
||||||
|
// Map a read_sync(std::optional<bool>) result to an Ack: outer nullopt =
|
||||||
|
// io thread didn't answer; inner nullopt = object not found; false = FSM
|
||||||
|
// rejected; true = accepted.
|
||||||
|
void ack_from_outcome(const std::optional<std::optional<bool>>& outcome,
|
||||||
|
pb::Ack* resp, const std::string& what,
|
||||||
|
const std::string& reject_msg) {
|
||||||
|
if (!outcome) {
|
||||||
|
resp->set_code(pb::Ack::CANNOT_DO_NOW);
|
||||||
|
resp->set_message("engine io thread did not answer (not running?)");
|
||||||
|
} else if (!*outcome) {
|
||||||
|
resp->set_code(pb::Ack::INVALID_OBJECT);
|
||||||
|
resp->set_message("no " + what);
|
||||||
|
} else if (!**outcome) {
|
||||||
|
resp->set_code(pb::Ack::CANNOT_DO_NOW);
|
||||||
|
resp->set_message(reject_msg);
|
||||||
|
} else {
|
||||||
|
resp->set_code(pb::Ack::ACCEPT);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fan a HostRequest out to every subscriber (fire-and-forget
|
||||||
|
// notifications: jobs, recipes, EC changes). No subscriber = dropped,
|
||||||
|
// matching the no-buffering contract.
|
||||||
|
void push_request(pb::HostRequest hr) {
|
||||||
|
std::lock_guard<std::mutex> lk(subs_mu_);
|
||||||
|
for (auto& sub : subs_) sub->queue.push_back(hr);
|
||||||
|
if (!subs_.empty()) subs_cv_.notify_all();
|
||||||
|
}
|
||||||
|
|
||||||
|
// The command-forwarding handler (runs on the io thread during S2F41/F21/
|
||||||
|
// F49 dispatch). Subscriber present -> queue the command + HCACK 4; absent
|
||||||
|
// -> the command's declarative YAML ack. Never blocks.
|
||||||
|
gem::HostCmdAck forward_command(const std::string& rcmd,
|
||||||
|
const std::vector<gem::CommandParameter>& params,
|
||||||
|
gem::HostCmdAck declarative) {
|
||||||
|
std::lock_guard<std::mutex> lk(subs_mu_);
|
||||||
|
if (subs_.empty()) return declarative;
|
||||||
|
const std::string id = std::to_string(next_command_id_++);
|
||||||
|
pb::HostRequest hr;
|
||||||
|
auto* cmd = hr.mutable_command();
|
||||||
|
cmd->set_id(id);
|
||||||
|
cmd->set_name(rcmd);
|
||||||
|
for (const auto& p : params) (*cmd->mutable_params())[p.name] = from_item(p.value);
|
||||||
|
pending_.emplace(id, rcmd);
|
||||||
|
for (auto& sub : subs_) sub->queue.push_back(hr);
|
||||||
|
subs_cv_.notify_all();
|
||||||
|
rt_.log("command '" + rcmd + "' id=" + id + " -> tool stream (HCACK=4)");
|
||||||
|
return gem::HostCmdAck::AcceptedWillFinishLater;
|
||||||
|
}
|
||||||
|
|
||||||
|
void bump_health() {
|
||||||
|
health_version_.fetch_add(1, std::memory_order_relaxed);
|
||||||
|
health_cv_.notify_all();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build a Health snapshot. Control state + link come from atomics; spool
|
||||||
|
// depth is mutable engine state, read via read_sync. nullopt = engine down.
|
||||||
|
std::optional<pb::Health> make_health() {
|
||||||
|
auto depth = rt_.read_sync(
|
||||||
|
[this]() { return static_cast<uint32_t>(rt_.model().spool.size()); });
|
||||||
|
if (!depth) return std::nullopt;
|
||||||
|
pb::Health h;
|
||||||
|
// CONNECTED (TCP up, not yet SELECTED) is reserved: the runtime's link
|
||||||
|
// observer fires on SELECTED/closed only. TODO(daemon): surface the
|
||||||
|
// intermediate state if a tool ever needs it.
|
||||||
|
h.set_link(link_selected_.load(std::memory_order_relaxed)
|
||||||
|
? pb::Health::SELECTED
|
||||||
|
: pb::Health::DISCONNECTED);
|
||||||
|
h.set_spool_depth(*depth);
|
||||||
|
h.set_control_state(to_proto_state(rt_.control_state()));
|
||||||
|
return h;
|
||||||
|
}
|
||||||
|
|
||||||
|
grpc::Status alarm_action(const std::string& name, bool set, pb::Ack* resp) {
|
||||||
|
auto it = alarms_.find(name);
|
||||||
|
if (it == alarms_.end()) {
|
||||||
|
resp->set_code(pb::Ack::PARAMETER_INVALID);
|
||||||
|
resp->set_message("no alarm named '" + name + "'");
|
||||||
|
return grpc::Status::OK;
|
||||||
|
}
|
||||||
|
if (set) rt_.set_alarm(it->second);
|
||||||
|
else rt_.clear_alarm(it->second);
|
||||||
|
resp->set_code(pb::Ack::ACCEPT);
|
||||||
|
return grpc::Status::OK;
|
||||||
|
}
|
||||||
|
|
||||||
|
struct VarRef {
|
||||||
|
uint32_t vid;
|
||||||
|
s2::Format format; // declared wire format from equipment.yaml
|
||||||
|
};
|
||||||
|
|
||||||
|
gem::EquipmentRuntime& rt_;
|
||||||
|
std::map<std::string, VarRef> vars_; // SVIDs + DVIDs (SVIDs win on clash)
|
||||||
|
std::map<std::string, uint32_t> events_; // CEID by name
|
||||||
|
std::map<std::string, uint32_t> alarms_; // ALID by name AND stringified id
|
||||||
|
std::map<uint32_t, std::string> ecnames_; // ECID -> name (ConstantChange)
|
||||||
|
std::vector<std::string> commands_; // RCMDs (Describe)
|
||||||
|
std::string descr_model_, descr_rev_; // device header (Describe)
|
||||||
|
|
||||||
|
// WatchHealth plumbing: observers (io thread) bump the version and wake the
|
||||||
|
// per-stream wait loops (gRPC threads).
|
||||||
|
std::atomic<bool> link_selected_{false};
|
||||||
|
std::atomic<uint64_t> health_version_{0};
|
||||||
|
std::mutex health_mu_;
|
||||||
|
std::condition_variable health_cv_;
|
||||||
|
|
||||||
|
// Subscribe plumbing: the io-thread forwarding handler queues HostRequests;
|
||||||
|
// each Subscribe call drains its own queue on a gRPC thread.
|
||||||
|
struct Subscriber {
|
||||||
|
std::deque<pb::HostRequest> queue;
|
||||||
|
};
|
||||||
|
std::mutex subs_mu_;
|
||||||
|
std::condition_variable subs_cv_;
|
||||||
|
std::vector<std::shared_ptr<Subscriber>> subs_;
|
||||||
|
std::map<std::string, std::string> pending_; // command id -> rcmd (audit)
|
||||||
|
uint64_t next_command_id_ = 1; // guarded by subs_mu_
|
||||||
|
};
|
||||||
|
|
||||||
|
} // namespace secsgem::daemon
|
||||||
@@ -4,15 +4,37 @@
|
|||||||
|
|
||||||
namespace secsgem::gem {
|
namespace secsgem::gem {
|
||||||
|
|
||||||
// Registers the full default GEM behaviour onto a runtime: every SECS message
|
// The default GEM behaviour, decomposed along the capability lines GEM
|
||||||
// handler (S1/S2/S3/S5/S6/S7/S10/S14/S16) on its Router, plus the state-change
|
// itself defines (the S1F19 compliance list): each function registers one
|
||||||
// emitters (control state, process/control jobs, exceptions, substrates,
|
// capability's message handlers and/or state-change emitters onto a runtime.
|
||||||
// modules) on its stores. Shared by the `secs_server` app and the gRPC daemon
|
// Equipment implementing a subset of GEM (a sensor with no carriers, a
|
||||||
// so both speak byte-identical GEM — the protocol behaviour lives here, once.
|
// recipe-less tool) registers only what it is; register_default_handlers
|
||||||
|
// registers everything and is what `secs_server` and `secs_gemd` use.
|
||||||
//
|
//
|
||||||
// Call once after constructing the runtime and before run(). Application
|
// All functions are idempotent-per-slot (later registration replaces the
|
||||||
// behaviour (host-command callbacks via on_command, sensor value updates) is
|
// Router entry / primary handler slot) and must be called before run().
|
||||||
// layered on top by the caller.
|
// Application behaviour (host-command callbacks via on_command, sensor value
|
||||||
|
// updates) is layered on top by the caller.
|
||||||
|
//
|
||||||
|
// The ids the built-ins target (control-state/clock SVIDs, CJ state CEIDs)
|
||||||
|
// come from the config's `roles:` block — see EquipmentDescriptor.
|
||||||
|
void register_identification(EquipmentRuntime& R); // S1 + E30 control state
|
||||||
|
void register_equipment_constants(EquipmentRuntime& R); // S2F13/15/29
|
||||||
|
void register_clock(EquipmentRuntime& R); // S2F17/31
|
||||||
|
void register_event_reports(EquipmentRuntime& R); // S2F33/35/37, S6F5/15/19/21
|
||||||
|
void register_remote_commands(EquipmentRuntime& R); // S2F21/41/49
|
||||||
|
void register_trace_and_limits(EquipmentRuntime& R); // S2F23/45/47
|
||||||
|
void register_spooling(EquipmentRuntime& R); // S2F43, S6F23
|
||||||
|
void register_alarms(EquipmentRuntime& R); // S5F3/5/7
|
||||||
|
void register_exceptions(EquipmentRuntime& R); // S5F9/11/15 emitters, S5F13/17
|
||||||
|
void register_material_tracking(EquipmentRuntime& R); // E90/E116/E157 emitters
|
||||||
|
void register_carriers(EquipmentRuntime& R); // E87: S3F17/19/25/27
|
||||||
|
void register_recipes(EquipmentRuntime& R); // S7F1/3/5/17/19
|
||||||
|
void register_object_services(EquipmentRuntime& R); // E39: S14F1/3
|
||||||
|
void register_jobs(EquipmentRuntime& R); // E40/E94: S14F9/11, S16Fxx
|
||||||
|
void register_terminal_services(EquipmentRuntime& R); // S10F1/3/5
|
||||||
|
|
||||||
|
// Everything above, in one call.
|
||||||
void register_default_handlers(EquipmentRuntime& R);
|
void register_default_handlers(EquipmentRuntime& R);
|
||||||
|
|
||||||
} // namespace secsgem::gem
|
} // namespace secsgem::gem
|
||||||
|
|||||||
@@ -2,12 +2,15 @@
|
|||||||
|
|
||||||
#include <asio.hpp>
|
#include <asio.hpp>
|
||||||
#include <atomic>
|
#include <atomic>
|
||||||
|
#include <chrono>
|
||||||
#include <cstdint>
|
#include <cstdint>
|
||||||
#include <functional>
|
#include <functional>
|
||||||
|
#include <future>
|
||||||
#include <memory>
|
#include <memory>
|
||||||
#include <optional>
|
#include <optional>
|
||||||
#include <string>
|
#include <string>
|
||||||
#include <thread>
|
#include <thread>
|
||||||
|
#include <type_traits>
|
||||||
#include <utility>
|
#include <utility>
|
||||||
#include <vector>
|
#include <vector>
|
||||||
|
|
||||||
@@ -72,6 +75,29 @@ class EquipmentRuntime {
|
|||||||
void set_alarm(uint32_t alid);
|
void set_alarm(uint32_t alid);
|
||||||
void clear_alarm(uint32_t alid);
|
void clear_alarm(uint32_t alid);
|
||||||
|
|
||||||
|
// ---- reading mutable engine state from outside the io thread -------------
|
||||||
|
// THE standard pattern for every read of mutable state (variable values,
|
||||||
|
// alarm activity, spool depth, ...) from gRPC/binding threads: post the
|
||||||
|
// read onto the io thread — the model's single owner — and wait with a
|
||||||
|
// deadline. Always truthful (no cache invalidation), and the milliseconds
|
||||||
|
// of latency are irrelevant at SECS message rates. Returns nullopt if the
|
||||||
|
// io thread doesn't service the post in time (not running, or stalled).
|
||||||
|
// Requires run()/run_async(); in poll() mode there is nobody to serve the
|
||||||
|
// post, so a same-thread caller should read the model directly instead.
|
||||||
|
template <typename Fn>
|
||||||
|
auto read_sync(Fn&& fn,
|
||||||
|
std::chrono::milliseconds timeout = std::chrono::milliseconds(2000))
|
||||||
|
-> std::optional<std::invoke_result_t<std::decay_t<Fn>&>> {
|
||||||
|
using R = std::invoke_result_t<std::decay_t<Fn>&>;
|
||||||
|
auto prom = std::make_shared<std::promise<R>>();
|
||||||
|
auto fut = prom->get_future();
|
||||||
|
asio::post(io_, [prom, fn = std::forward<Fn>(fn)]() mutable {
|
||||||
|
prom->set_value(fn());
|
||||||
|
});
|
||||||
|
if (fut.wait_for(timeout) != std::future_status::ready) return std::nullopt;
|
||||||
|
return fut.get();
|
||||||
|
}
|
||||||
|
|
||||||
// ---- host-command behaviour hook -----------------------------------------
|
// ---- host-command behaviour hook -----------------------------------------
|
||||||
void on_command(std::string rcmd, HostCommandRegistry::Handler h) {
|
void on_command(std::string rcmd, HostCommandRegistry::Handler h) {
|
||||||
model_->commands.set_handler(std::move(rcmd), std::move(h));
|
model_->commands.set_handler(std::move(rcmd), std::move(h));
|
||||||
|
|||||||
@@ -39,6 +39,11 @@ struct Alarm {
|
|||||||
// Lower 7 bits of ALCD: severity bitmap (see AlarmSeverity). Bit 7
|
// Lower 7 bits of ALCD: severity bitmap (see AlarmSeverity). Bit 7
|
||||||
// marks set vs cleared and is applied at emit time.
|
// marks set vs cleared and is applied at emit time.
|
||||||
uint8_t severity_category;
|
uint8_t severity_category;
|
||||||
|
// Optional local key for name-based APIs (the gRPC daemon, future Python
|
||||||
|
// client). NOT on the wire — SEMI defines only numeric ALID + freetext
|
||||||
|
// ALTX. Last field so existing {id, text, category} brace-inits compile
|
||||||
|
// unchanged; empty = unnamed (address it by id).
|
||||||
|
std::string name;
|
||||||
|
|
||||||
bool has(AlarmSeverity bit) const { return has_severity(severity_category, bit); }
|
bool has(AlarmSeverity bit) const { return has_severity(severity_category, bit); }
|
||||||
bool is_safety() const {
|
bool is_safety() const {
|
||||||
|
|||||||
@@ -14,6 +14,7 @@
|
|||||||
#include <utility>
|
#include <utility>
|
||||||
#include <vector>
|
#include <vector>
|
||||||
|
|
||||||
|
#include "secsgem/gem/handler_slot.hpp"
|
||||||
#include "secsgem/gem/carrier_state.hpp"
|
#include "secsgem/gem/carrier_state.hpp"
|
||||||
#include "secsgem/gem/load_port_state.hpp"
|
#include "secsgem/gem/load_port_state.hpp"
|
||||||
|
|
||||||
@@ -62,9 +63,15 @@ class CarrierStore {
|
|||||||
CarrierStore(CarrierStore&&) = delete;
|
CarrierStore(CarrierStore&&) = delete;
|
||||||
CarrierStore& operator=(CarrierStore&&) = delete;
|
CarrierStore& operator=(CarrierStore&&) = delete;
|
||||||
|
|
||||||
|
// set_ replaces the primary handler (legacy semantics); add_ appends an
|
||||||
|
// observer that survives set_ calls (see handler_slot.hpp). The gRPC
|
||||||
|
// daemon observes via add_ to forward E87 actions onto its tool stream.
|
||||||
void set_id_handler(IDChangeHandler h) { on_id_ = std::move(h); }
|
void set_id_handler(IDChangeHandler h) { on_id_ = std::move(h); }
|
||||||
void set_slot_map_handler(SlotMapChangeHandler h) { on_sm_ = std::move(h); }
|
void set_slot_map_handler(SlotMapChangeHandler h) { on_sm_ = std::move(h); }
|
||||||
void set_access_handler(AccessChangeHandler h) { on_acc_ = std::move(h); }
|
void set_access_handler(AccessChangeHandler h) { on_acc_ = std::move(h); }
|
||||||
|
void add_id_handler(IDChangeHandler h) { on_id_.add(std::move(h)); }
|
||||||
|
void add_slot_map_handler(SlotMapChangeHandler h) { on_sm_.add(std::move(h)); }
|
||||||
|
void add_access_handler(AccessChangeHandler h) { on_acc_.add(std::move(h)); }
|
||||||
|
|
||||||
enum class CreateResult { Created, Denied_AlreadyExists };
|
enum class CreateResult { Created, Denied_AlreadyExists };
|
||||||
|
|
||||||
@@ -301,9 +308,12 @@ class CarrierStore {
|
|||||||
}
|
}
|
||||||
|
|
||||||
std::map<std::string, Carrier> carriers_;
|
std::map<std::string, Carrier> carriers_;
|
||||||
IDChangeHandler on_id_;
|
HandlerSlot<const std::string&, CarrierIDStatus, CarrierIDStatus,
|
||||||
SlotMapChangeHandler on_sm_;
|
CarrierIDEvent> on_id_;
|
||||||
AccessChangeHandler on_acc_;
|
HandlerSlot<const std::string&, SlotMapStatus, SlotMapStatus,
|
||||||
|
SlotMapEvent> on_sm_;
|
||||||
|
HandlerSlot<const std::string&, CarrierAccessStatus, CarrierAccessStatus,
|
||||||
|
CarrierAccessEvent> on_acc_;
|
||||||
bool persistent_ = false;
|
bool persistent_ = false;
|
||||||
std::filesystem::path journal_dir_;
|
std::filesystem::path journal_dir_;
|
||||||
uint64_t next_seq_ = 0;
|
uint64_t next_seq_ = 0;
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
#pragma once
|
#pragma once
|
||||||
|
|
||||||
#include <cstdint>
|
#include <cstdint>
|
||||||
|
#include <functional>
|
||||||
#include <map>
|
#include <map>
|
||||||
#include <optional>
|
#include <optional>
|
||||||
#include <stdexcept>
|
#include <stdexcept>
|
||||||
@@ -9,6 +10,7 @@
|
|||||||
#include <variant>
|
#include <variant>
|
||||||
#include <vector>
|
#include <vector>
|
||||||
|
|
||||||
|
#include "secsgem/gem/handler_slot.hpp"
|
||||||
#include "secsgem/secs2/item.hpp"
|
#include "secsgem/secs2/item.hpp"
|
||||||
|
|
||||||
namespace secsgem::gem {
|
namespace secsgem::gem {
|
||||||
@@ -59,10 +61,18 @@ class EquipmentConstantStore {
|
|||||||
if (it == by_id_.end()) return EquipmentAck::Denied_UnknownEcid;
|
if (it == by_id_.end()) return EquipmentAck::Denied_UnknownEcid;
|
||||||
if (!in_range(it->second, value)) return EquipmentAck::Denied_OutOfRange;
|
if (!in_range(it->second, value)) return EquipmentAck::Denied_OutOfRange;
|
||||||
it->second.value = std::move(value);
|
it->second.value = std::move(value);
|
||||||
|
if (on_changed_) on_changed_(id, it->second.value);
|
||||||
return EquipmentAck::Accept;
|
return EquipmentAck::Accept;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Observe ACCEPTED host writes (S2F15): fires after the value is stored.
|
||||||
|
// The tool reacts to process-parameter tuning; rejected writes don't fire.
|
||||||
|
using ChangedHandler = std::function<void(uint32_t, const s2::Item&)>;
|
||||||
|
void add_changed_handler(ChangedHandler h) { on_changed_.add(std::move(h)); }
|
||||||
|
|
||||||
private:
|
private:
|
||||||
|
HandlerSlot<uint32_t, const s2::Item&> on_changed_;
|
||||||
|
|
||||||
// For numeric formats, parse min_str / max_str as integers / doubles and
|
// For numeric formats, parse min_str / max_str as integers / doubles and
|
||||||
// compare against the first value of the array (we don't support setting
|
// compare against the first value of the array (we don't support setting
|
||||||
// multi-element ECs in this implementation).
|
// multi-element ECs in this implementation).
|
||||||
|
|||||||
@@ -71,6 +71,21 @@ class HostCommandRegistry {
|
|||||||
}
|
}
|
||||||
bool has(const std::string& rcmd) const { return by_rcmd_.count(rcmd) > 0; }
|
bool has(const std::string& rcmd) const { return by_rcmd_.count(rcmd) > 0; }
|
||||||
bool has_handler(const std::string& rcmd) const { return handlers_.count(rcmd) > 0; }
|
bool has_handler(const std::string& rcmd) const { return handlers_.count(rcmd) > 0; }
|
||||||
|
|
||||||
|
// Enumeration + spec lookup, so a front-end (e.g. the gRPC daemon) can
|
||||||
|
// attach behaviour to every declared command and fall back to the
|
||||||
|
// declarative ack when its tool client isn't connected.
|
||||||
|
std::vector<std::string> names() const {
|
||||||
|
std::vector<std::string> out;
|
||||||
|
out.reserve(by_rcmd_.size());
|
||||||
|
for (const auto& [rcmd, _] : by_rcmd_) out.push_back(rcmd);
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
std::optional<Spec> spec(const std::string& rcmd) const {
|
||||||
|
auto it = by_rcmd_.find(rcmd);
|
||||||
|
if (it == by_rcmd_.end()) return std::nullopt;
|
||||||
|
return it->second;
|
||||||
|
}
|
||||||
Result dispatch(const std::string& rcmd,
|
Result dispatch(const std::string& rcmd,
|
||||||
const std::vector<CommandParameter>& params) const {
|
const std::vector<CommandParameter>& params) const {
|
||||||
auto it = by_rcmd_.find(rcmd);
|
auto it = by_rcmd_.find(rcmd);
|
||||||
|
|||||||
@@ -1,12 +1,14 @@
|
|||||||
#pragma once
|
#pragma once
|
||||||
|
|
||||||
#include <cstdint>
|
#include <cstdint>
|
||||||
|
#include <functional>
|
||||||
#include <map>
|
#include <map>
|
||||||
#include <optional>
|
#include <optional>
|
||||||
#include <string>
|
#include <string>
|
||||||
#include <utility>
|
#include <utility>
|
||||||
#include <vector>
|
#include <vector>
|
||||||
|
|
||||||
|
#include "secsgem/gem/handler_slot.hpp"
|
||||||
#include "secsgem/secs2/item.hpp"
|
#include "secsgem/secs2/item.hpp"
|
||||||
|
|
||||||
namespace secsgem::gem {
|
namespace secsgem::gem {
|
||||||
@@ -45,8 +47,15 @@ struct FormattedRecipe {
|
|||||||
|
|
||||||
class RecipeStore {
|
class RecipeStore {
|
||||||
public:
|
public:
|
||||||
|
// Observe recipe arrivals (e.g. a host S7F3 download): fires after the
|
||||||
|
// store is updated, with (ppid, body). Multi-observer via HandlerSlot —
|
||||||
|
// the gRPC daemon forwards these onto its Subscribe stream.
|
||||||
|
using AddedHandler = std::function<void(const std::string&, const std::string&)>;
|
||||||
|
void add_added_handler(AddedHandler h) { on_added_.add(std::move(h)); }
|
||||||
|
|
||||||
void add(std::string ppid, std::string body) {
|
void add(std::string ppid, std::string body) {
|
||||||
by_ppid_.insert_or_assign(std::move(ppid), std::move(body));
|
auto it = by_ppid_.insert_or_assign(std::move(ppid), std::move(body)).first;
|
||||||
|
if (on_added_) on_added_(it->first, it->second);
|
||||||
}
|
}
|
||||||
std::optional<std::string> get(const std::string& ppid) const {
|
std::optional<std::string> get(const std::string& ppid) const {
|
||||||
auto it = by_ppid_.find(ppid);
|
auto it = by_ppid_.find(ppid);
|
||||||
@@ -95,6 +104,7 @@ class RecipeStore {
|
|||||||
|
|
||||||
private:
|
private:
|
||||||
std::map<std::string, std::string> by_ppid_;
|
std::map<std::string, std::string> by_ppid_;
|
||||||
|
HandlerSlot<const std::string&, const std::string&> on_added_;
|
||||||
std::map<std::string, FormattedRecipe> formatted_;
|
std::map<std::string, FormattedRecipe> formatted_;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1 +0,0 @@
|
|||||||
__pycache__/
|
|
||||||
@@ -17,6 +17,7 @@ ENV DEBIAN_FRONTEND=noninteractive
|
|||||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
build-essential cmake ninja-build \
|
build-essential cmake ninja-build \
|
||||||
libasio-dev libyaml-cpp-dev \
|
libasio-dev libyaml-cpp-dev \
|
||||||
|
libprotobuf-dev protobuf-compiler protobuf-compiler-grpc libgrpc++-dev \
|
||||||
python3 python3-yaml \
|
python3 python3-yaml \
|
||||||
git ca-certificates \
|
git ca-certificates \
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
@@ -24,20 +25,23 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
|||||||
WORKDIR /src
|
WORKDIR /src
|
||||||
COPY . /src
|
COPY . /src
|
||||||
RUN cmake -S /src -B /src/build -G Ninja -DCMAKE_BUILD_TYPE=Release \
|
RUN cmake -S /src -B /src/build -G Ninja -DCMAKE_BUILD_TYPE=Release \
|
||||||
&& cmake --build /src/build --target secs_server
|
&& cmake --build /src/build --target secs_server secs_gemd
|
||||||
|
|
||||||
|
|
||||||
FROM ubuntu:24.04
|
FROM ubuntu:24.04
|
||||||
|
|
||||||
ENV DEBIAN_FRONTEND=noninteractive
|
ENV DEBIAN_FRONTEND=noninteractive
|
||||||
|
# Dev packages in the runtime layer purely to pull the grpc/protobuf
|
||||||
|
# runtime libs by reliable names; image size is irrelevant for a CI harness.
|
||||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
libyaml-cpp0.8 \
|
libyaml-cpp0.8 libgrpc++-dev libprotobuf-dev \
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
# WORKDIR must be the directory holding `data/` because secs_server's
|
# WORKDIR must be the directory holding `data/` because secs_server's
|
||||||
# default --config / --state-table paths are relative ("data/...").
|
# default --config / --state-table paths are relative ("data/...").
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
COPY --from=build /src/build/secs_server /usr/local/bin/secs_server
|
COPY --from=build /src/build/secs_server /usr/local/bin/secs_server
|
||||||
|
COPY --from=build /src/build/secs_gemd /usr/local/bin/secs_gemd
|
||||||
COPY data /app/data
|
COPY data /app/data
|
||||||
|
|
||||||
EXPOSE 5000
|
EXPOSE 5000
|
||||||
|
|||||||
@@ -24,6 +24,12 @@ involve a network peer — they're either pure codec round-trips
|
|||||||
(KAT) or coverage-guided fuzzing. Listed here so the full external
|
(KAT) or coverage-guided fuzzing. Listed here so the full external
|
||||||
proof inventory lives in one place.
|
proof inventory lives in one place.
|
||||||
|
|
||||||
|
## One command for everything
|
||||||
|
|
||||||
|
`tools/run_interop.sh` (from the repo root) runs every validation step —
|
||||||
|
build, both unit suites, all the harnesses below, tshark, and secs4java8 —
|
||||||
|
with a PASS/FAIL summary. `SKIP_SECS4J=1` skips the Java image build.
|
||||||
|
|
||||||
## Running each validator
|
## Running each validator
|
||||||
|
|
||||||
### secsgem-py — secsgem-py active host → C++ server
|
### secsgem-py — secsgem-py active host → C++ server
|
||||||
@@ -34,6 +40,29 @@ docker compose run --rm interop python3 /app/interop/host_vs_cpp_server.py \
|
|||||||
--host server --port 5000 --session-id 0
|
--host server --port 5000 --session-id 0
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### daemon bridge — gRPC tool + secsgem-py host → secs_gemd
|
||||||
|
|
||||||
|
```sh
|
||||||
|
docker compose up -d --no-deps gemd
|
||||||
|
docker compose run --rm --no-deps interop python3 daemon_interop.py \
|
||||||
|
--grpc gemd:50051 --hsms-host gemd
|
||||||
|
```
|
||||||
|
|
||||||
|
Both faces of the daemon at once: 20 checks proving gRPC SetVariables/
|
||||||
|
FireEvent/SetAlarm reach the reference host as S6F11/S5F1 over HSMS, and
|
||||||
|
the HCACK-4 command loop (host S2F41 → tool stream → completion event).
|
||||||
|
|
||||||
|
### Python client — published secsgem-client package → secs_gemd
|
||||||
|
|
||||||
|
```sh
|
||||||
|
docker compose up -d --no-deps gemd
|
||||||
|
docker compose run --rm --no-deps -e PYTHONPATH=/app/clients/python interop \
|
||||||
|
python3 pyclient_interop.py --grpc gemd:50051 --hsms-host gemd
|
||||||
|
```
|
||||||
|
|
||||||
|
13 checks driving the PUBLISHED Python API (eq.set / eq.fire / eq.alarm /
|
||||||
|
@eq.on) against a live daemon, with secsgem-py judging the wire.
|
||||||
|
|
||||||
### secsgem-py — C++ host → secsgem-py equipment
|
### secsgem-py — C++ host → secsgem-py equipment
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
|
|||||||
@@ -99,6 +99,8 @@ def run(grpc_addr: str, hsms_host: str, hsms_port: int, session_id: int,
|
|||||||
|
|
||||||
ceid300 = threading.Event()
|
ceid300 = threading.Event()
|
||||||
last_s6f11 = {}
|
last_s6f11 = {}
|
||||||
|
s5f1_seen = threading.Event()
|
||||||
|
last_s5f1 = {}
|
||||||
|
|
||||||
def on_s6f11(_handler, message):
|
def on_s6f11(_handler, message):
|
||||||
decoded = client.settings.streams_functions.decode(message)
|
decoded = client.settings.streams_functions.decode(message)
|
||||||
@@ -110,7 +112,17 @@ def run(grpc_addr: str, hsms_host: str, hsms_port: int, session_id: int,
|
|||||||
ceid300.set()
|
ceid300.set()
|
||||||
client.send_response(F.SecsS06F12(0), message.header.system)
|
client.send_response(F.SecsS06F12(0), message.header.system)
|
||||||
|
|
||||||
|
def on_s5f1(_handler, message):
|
||||||
|
decoded = client.settings.streams_functions.decode(message)
|
||||||
|
body = decoded.get()
|
||||||
|
LOG.info("[alm] S5F1 body=%r", body)
|
||||||
|
if isinstance(body, dict):
|
||||||
|
last_s5f1.update(body)
|
||||||
|
s5f1_seen.set()
|
||||||
|
client.send_response(F.SecsS05F02(0), message.header.system)
|
||||||
|
|
||||||
client.register_stream_function(6, 11, on_s6f11)
|
client.register_stream_function(6, 11, on_s6f11)
|
||||||
|
client.register_stream_function(5, 1, on_s5f1)
|
||||||
|
|
||||||
client.enable()
|
client.enable()
|
||||||
try:
|
try:
|
||||||
@@ -153,6 +165,12 @@ def run(grpc_addr: str, hsms_host: str, hsms_port: int, session_id: int,
|
|||||||
check("gRPC SetVariables(ChamberPressure=2.5) -> ACCEPT",
|
check("gRPC SetVariables(ChamberPressure=2.5) -> ACCEPT",
|
||||||
ack.code == pb.Ack.ACCEPT, pb.Ack.Code.Name(ack.code))
|
ack.code == pb.Ack.ACCEPT, pb.Ack.Code.Name(ack.code))
|
||||||
|
|
||||||
|
# Read back through the API: SetVariables -> engine -> GetVariables.
|
||||||
|
snap = stub.GetVariables(pb.VariableQuery(names=["ChamberPressure"]))
|
||||||
|
got = snap.values["ChamberPressure"].real
|
||||||
|
check("gRPC GetVariables round-trips ChamberPressure=2.5",
|
||||||
|
abs(got - 2.5) < 0.01, f"got {got}")
|
||||||
|
|
||||||
ack = stub.FireEvent(pb.Event(name="ProcessStarted"))
|
ack = stub.FireEvent(pb.Event(name="ProcessStarted"))
|
||||||
check("gRPC FireEvent(ProcessStarted) -> ACCEPT",
|
check("gRPC FireEvent(ProcessStarted) -> ACCEPT",
|
||||||
ack.code == pb.Ack.ACCEPT, pb.Ack.Code.Name(ack.code))
|
ack.code == pb.Ack.ACCEPT, pb.Ack.Code.Name(ack.code))
|
||||||
@@ -166,6 +184,77 @@ def run(grpc_addr: str, hsms_host: str, hsms_port: int, session_id: int,
|
|||||||
for v in vals)
|
for v in vals)
|
||||||
check("S6F11 report carries ChamberPressure=2.5 (value flowed end-to-end)",
|
check("S6F11 report carries ChamberPressure=2.5 (value flowed end-to-end)",
|
||||||
near, f"scalars={vals}")
|
near, f"scalars={vals}")
|
||||||
|
|
||||||
|
# --- alarms: host enables ALID 1 (S5F3), gRPC raises it BY NAME,
|
||||||
|
# host receives the unsolicited S5F1 with set-bit + ALID 1 ---
|
||||||
|
client.send_and_waitfor_response(
|
||||||
|
F.SecsS05F03({"ALED": 0x80, "ALID": 1}))
|
||||||
|
ack = stub.SetAlarm(pb.Alarm(name="chiller_temp_high"))
|
||||||
|
check("gRPC SetAlarm(chiller_temp_high) -> ACCEPT",
|
||||||
|
ack.code == pb.Ack.ACCEPT, pb.Ack.Code.Name(ack.code))
|
||||||
|
got_alarm = s5f1_seen.wait(timeout=10)
|
||||||
|
check("host received S5F1 (gRPC SetAlarm bridged to HSMS)", got_alarm)
|
||||||
|
if got_alarm:
|
||||||
|
check("S5F1 carries ALID 1 with the set bit",
|
||||||
|
last_s5f1.get("ALID") == 1
|
||||||
|
and (int(last_s5f1.get("ALCD") or 0) & 0x80) != 0,
|
||||||
|
f"body={last_s5f1}")
|
||||||
|
stub.ClearAlarm(pb.Alarm(name="chiller_temp_high"))
|
||||||
|
|
||||||
|
# --- the full conformant command loop (HCACK-4 contract) ---
|
||||||
|
# Tool subscribes; host sends S2F41 START; daemon answers HCACK=4 and
|
||||||
|
# forwards the command to the tool; tool completes it and fires the
|
||||||
|
# event; host receives S6F11 — command -> tool -> outcome, end to end.
|
||||||
|
received_cmd = {}
|
||||||
|
cmd_seen = threading.Event()
|
||||||
|
|
||||||
|
def consume_stream():
|
||||||
|
try:
|
||||||
|
for hr in stub.Subscribe(pb.SubscribeRequest(client="interop-tool")):
|
||||||
|
if hr.HasField("command"):
|
||||||
|
received_cmd["cmd"] = hr.command
|
||||||
|
cmd_seen.set()
|
||||||
|
return
|
||||||
|
except grpc.RpcError:
|
||||||
|
pass # stream cancelled at teardown
|
||||||
|
|
||||||
|
tool_thread = threading.Thread(target=consume_stream, daemon=True)
|
||||||
|
tool_thread.start()
|
||||||
|
time.sleep(0.5) # let the subscription register
|
||||||
|
|
||||||
|
ceid300.clear()
|
||||||
|
rsp = client.send_and_waitfor_response(
|
||||||
|
F.SecsS02F41({"RCMD": "START", "PARAMS": []}))
|
||||||
|
body = client.settings.streams_functions.decode(rsp).get()
|
||||||
|
hcack = body.get("HCACK") if isinstance(body, dict) else None
|
||||||
|
check("host got S2F42 HCACK=4 (accepted, will finish later)",
|
||||||
|
int(hcack or -1) == 4, f"HCACK={hcack!r}")
|
||||||
|
|
||||||
|
got_cmd = cmd_seen.wait(timeout=10)
|
||||||
|
check("tool received START on the Subscribe stream", got_cmd)
|
||||||
|
if got_cmd:
|
||||||
|
cmd = received_cmd["cmd"]
|
||||||
|
check("streamed command carries name + id",
|
||||||
|
cmd.name == "START" and bool(cmd.id),
|
||||||
|
f"name={cmd.name} id={cmd.id}")
|
||||||
|
ack = stub.CompleteCommand(pb.CommandResult(
|
||||||
|
id=cmd.id, ack=pb.Ack(code=pb.Ack.ACCEPT)))
|
||||||
|
check("CompleteCommand(id) -> ACCEPT",
|
||||||
|
ack.code == pb.Ack.ACCEPT, pb.Ack.Code.Name(ack.code))
|
||||||
|
# The tool reports the real outcome as an event; the host sees it.
|
||||||
|
stub.FireEvent(pb.Event(name="ProcessStarted"))
|
||||||
|
check("host received S6F11 after tool completed the command",
|
||||||
|
ceid300.wait(timeout=10))
|
||||||
|
|
||||||
|
# --- operator-panel control state: take the tool offline via gRPC ---
|
||||||
|
ack = stub.RequestControlState(pb.ControlStateRequest(
|
||||||
|
desired=pb.ControlState.HOST_OFFLINE))
|
||||||
|
check("gRPC RequestControlState(HOST_OFFLINE) -> ACCEPT",
|
||||||
|
ack.code == pb.Ack.ACCEPT, pb.Ack.Code.Name(ack.code))
|
||||||
|
cs = stub.GetControlState(pb.Empty())
|
||||||
|
check("control state is HOST_OFFLINE after operator request",
|
||||||
|
cs.state == pb.ControlState.HOST_OFFLINE,
|
||||||
|
pb.ControlState.State.Name(cs.state))
|
||||||
finally:
|
finally:
|
||||||
client.disable()
|
client.disable()
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,237 @@
|
|||||||
|
"""End-to-end validation of the secsgem_client Python package.
|
||||||
|
|
||||||
|
The PUBLISHED client API (not raw stubs) plays the tool against a live
|
||||||
|
secs_gemd, while secsgem-py — the reference GEM implementation — plays the
|
||||||
|
fab host over HSMS. Every beautiful-API call is asserted against what the
|
||||||
|
host actually receives on the wire:
|
||||||
|
|
||||||
|
eq.set / eq["..."] -> S1F3-visible values, GetVariables round-trip
|
||||||
|
eq.fire -> host receives S6F11 with the configured report
|
||||||
|
eq.alarm / eq.clear -> host receives S5F1 set/clear
|
||||||
|
@eq.command + eq.listen -> host's S2F41 gets HCACK=4, the name-bound
|
||||||
|
handler runs, completion event reaches the host
|
||||||
|
eq.names.* -> Describe-backed, typo-safe name lookup
|
||||||
|
eq.report_substrate -> E90 wafer journey (+ unknown-wafer error path)
|
||||||
|
eq.report_module -> E157 module walk/reset (+ illegal-jump error path)
|
||||||
|
eq.control_state / request_control_state / eq.health
|
||||||
|
|
||||||
|
Exits 0 on success.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import logging
|
||||||
|
import sys
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
|
||||||
|
sys.path.insert(0, "/app/clients/python")
|
||||||
|
|
||||||
|
import secsgem.common
|
||||||
|
import secsgem.gem
|
||||||
|
import secsgem.hsms
|
||||||
|
import secsgem.secs
|
||||||
|
|
||||||
|
from secsgem_client import Equipment, Milestone, ModuleState, SecsGemError
|
||||||
|
|
||||||
|
LOG = logging.getLogger("pyclient-interop")
|
||||||
|
F = secsgem.secs.functions
|
||||||
|
|
||||||
|
|
||||||
|
def run(grpc_addr: str, hsms_host: str, hsms_port: int) -> int:
|
||||||
|
failures: list[str] = []
|
||||||
|
|
||||||
|
def check(label: str, ok: bool, detail: str = "") -> None:
|
||||||
|
LOG.info("[%s] %s%s", "OK " if ok else "FAIL", label,
|
||||||
|
f" — {detail}" if detail else "")
|
||||||
|
if not ok:
|
||||||
|
failures.append(label)
|
||||||
|
|
||||||
|
# ---- the tool, via the published client API ----
|
||||||
|
eq = Equipment(grpc_addr)
|
||||||
|
|
||||||
|
started = threading.Event()
|
||||||
|
|
||||||
|
# @eq.command binds by function name, validated against the live equipment
|
||||||
|
# (Describe) at decoration time — this is the path the host's S2F41 drives.
|
||||||
|
@eq.command
|
||||||
|
def START(cmd): # noqa: ANN001
|
||||||
|
started.set()
|
||||||
|
eq.fire(eq.names.event.ProcessStarted) # typo-safe completion signal
|
||||||
|
|
||||||
|
eq.listen(background=True)
|
||||||
|
|
||||||
|
# ---- the host, via secsgem-py ----
|
||||||
|
settings = secsgem.hsms.HsmsSettings(
|
||||||
|
address=hsms_host, port=hsms_port, session_id=0,
|
||||||
|
connect_mode=secsgem.hsms.HsmsConnectMode.ACTIVE,
|
||||||
|
device_type=secsgem.common.DeviceType.HOST)
|
||||||
|
host = secsgem.gem.GemHostHandler(settings)
|
||||||
|
|
||||||
|
ceid300 = threading.Event()
|
||||||
|
s5f1 = {}
|
||||||
|
s5f1_seen = threading.Event()
|
||||||
|
|
||||||
|
def on_s6f11(_h, message):
|
||||||
|
body = host.settings.streams_functions.decode(message).get()
|
||||||
|
if isinstance(body, dict) and body.get("CEID") == 300:
|
||||||
|
ceid300.set()
|
||||||
|
host.send_response(F.SecsS06F12(0), message.header.system)
|
||||||
|
|
||||||
|
def on_s5f1(_h, message):
|
||||||
|
body = host.settings.streams_functions.decode(message).get()
|
||||||
|
if isinstance(body, dict):
|
||||||
|
s5f1.update(body)
|
||||||
|
s5f1_seen.set()
|
||||||
|
host.send_response(F.SecsS05F02(0), message.header.system)
|
||||||
|
|
||||||
|
host.register_stream_function(6, 11, on_s6f11)
|
||||||
|
host.register_stream_function(5, 1, on_s5f1)
|
||||||
|
host.enable()
|
||||||
|
try:
|
||||||
|
if not host.waitfor_communicating(timeout=15):
|
||||||
|
check("HSMS establish-communications", False)
|
||||||
|
return 1
|
||||||
|
check("HSMS establish-communications", True)
|
||||||
|
host.send_and_waitfor_response(F.SecsS01F17())
|
||||||
|
time.sleep(0.3)
|
||||||
|
|
||||||
|
# ---- variables: kwargs, item syntax, read-back, errors ----
|
||||||
|
eq.set(ChamberPressure=2.5, WaferCounter=7)
|
||||||
|
check("eq.set(kwargs) + eq.get round-trip",
|
||||||
|
eq.get("ChamberPressure", "WaferCounter") ==
|
||||||
|
{"ChamberPressure": 2.5, "WaferCounter": 7})
|
||||||
|
eq["ChamberPressure"] = 1.25
|
||||||
|
check('eq["..."] item syntax', eq["ChamberPressure"] == 1.25)
|
||||||
|
try:
|
||||||
|
eq.set(NoSuchVariable=1)
|
||||||
|
check("unknown variable raises SecsGemError", False)
|
||||||
|
except SecsGemError as e:
|
||||||
|
check("unknown variable raises SecsGemError", "NoSuchVariable" in str(e))
|
||||||
|
|
||||||
|
# ---- control state / health ----
|
||||||
|
check("eq.control_state", eq.control_state == "ONLINE_REMOTE",
|
||||||
|
eq.control_state)
|
||||||
|
h = eq.health()
|
||||||
|
check("eq.health()", h.link == "SELECTED" and
|
||||||
|
h.control_state == "ONLINE_REMOTE", str(h))
|
||||||
|
|
||||||
|
# ---- events: configure a report host-side, fire client-side ----
|
||||||
|
host.send_and_waitfor_response(
|
||||||
|
F.SecsS02F33({"DATAID": 1, "DATA": [{"RPTID": 1, "VID": [101]}]}))
|
||||||
|
host.send_and_waitfor_response(
|
||||||
|
F.SecsS02F35({"DATAID": 1, "DATA": [{"CEID": 300, "RPTID": [1]}]}))
|
||||||
|
host.send_and_waitfor_response(F.SecsS02F37({"CEED": True, "CEID": [300]}))
|
||||||
|
eq.fire("ProcessStarted", ChamberPressure=2.75)
|
||||||
|
check("eq.fire -> host receives S6F11", ceid300.wait(timeout=10))
|
||||||
|
|
||||||
|
# ---- alarms ----
|
||||||
|
host.send_and_waitfor_response(F.SecsS05F03({"ALED": 0x80, "ALID": 1}))
|
||||||
|
eq.alarm("chiller_temp_high")
|
||||||
|
got = s5f1_seen.wait(timeout=10)
|
||||||
|
check("eq.alarm -> host receives S5F1 (set)",
|
||||||
|
got and s5f1.get("ALID") == 1 and (int(s5f1.get("ALCD") or 0) & 0x80))
|
||||||
|
s5f1_seen.clear()
|
||||||
|
eq.clear("chiller_temp_high")
|
||||||
|
got = s5f1_seen.wait(timeout=10)
|
||||||
|
check("eq.clear -> host receives S5F1 (clear)",
|
||||||
|
got and not (int(s5f1.get("ALCD") or 0) & 0x80))
|
||||||
|
|
||||||
|
# ---- the command loop through @eq.on + eq.listen ----
|
||||||
|
ceid300.clear()
|
||||||
|
rsp = host.send_and_waitfor_response(
|
||||||
|
F.SecsS02F41({"RCMD": "START", "PARAMS": []}))
|
||||||
|
body = host.settings.streams_functions.decode(rsp).get()
|
||||||
|
check("host S2F41 -> HCACK=4",
|
||||||
|
isinstance(body, dict) and int(body.get("HCACK") or -1) == 4)
|
||||||
|
check("@eq.command('START') handler ran", started.wait(timeout=10))
|
||||||
|
check("handler's eq.fire reached the host (completion signal)",
|
||||||
|
ceid300.wait(timeout=10))
|
||||||
|
|
||||||
|
# ---- Describe-backed names (every category, dir(), typo hint) ----
|
||||||
|
check("eq.names.event has ProcessStarted",
|
||||||
|
"ProcessStarted" in eq.names.event)
|
||||||
|
check("eq.names.command has START", "START" in eq.names.command)
|
||||||
|
check("eq.names.var has ChamberPressure",
|
||||||
|
"ChamberPressure" in eq.names.var)
|
||||||
|
check("eq.names.alarm has chiller_temp_high",
|
||||||
|
"chiller_temp_high" in eq.names.alarm)
|
||||||
|
check("dir(eq.names.event) lists names for autocomplete",
|
||||||
|
"ProcessStarted" in dir(eq.names.event))
|
||||||
|
try:
|
||||||
|
_ = eq.names.event.ProcessStated # one-letter typo
|
||||||
|
check("typo on eq.names raises with suggestion", False)
|
||||||
|
except AttributeError as e:
|
||||||
|
check("typo on eq.names raises with suggestion",
|
||||||
|
"ProcessStarted" in str(e)) # close-match hint present
|
||||||
|
|
||||||
|
# ---- @eq.command rejects an unknown command name at decoration time ----
|
||||||
|
try:
|
||||||
|
@eq.command
|
||||||
|
def NOT_A_REAL_COMMAND(cmd): # noqa: ANN001
|
||||||
|
pass
|
||||||
|
check("@eq.command on unknown name raises NameError", False)
|
||||||
|
except NameError:
|
||||||
|
check("@eq.command on unknown name raises NameError", True)
|
||||||
|
|
||||||
|
# ---- E90 substrate tracking: enum + string forms, then error paths ----
|
||||||
|
eq.report_substrate("WFR-PY-1", Milestone.ARRIVED,
|
||||||
|
carrier_id="FOUP-PY", slot=4) # enum form
|
||||||
|
eq.report_substrate("WFR-PY-1", "AT_WORK") # plain string, same thing
|
||||||
|
eq.report_substrate("WFR-PY-1", Milestone.PROCESSING)
|
||||||
|
eq.report_substrate("WFR-PY-1", Milestone.PROCESSED)
|
||||||
|
eq.report_substrate("WFR-PY-1", Milestone.AT_DESTINATION)
|
||||||
|
check("report_substrate full journey (enum + string) accepted", True)
|
||||||
|
try:
|
||||||
|
eq.report_substrate("WFR-PY-1", "AT_WROK") # typo: client-side reject
|
||||||
|
check("misspelled milestone raises ValueError", False)
|
||||||
|
except ValueError as e:
|
||||||
|
check("misspelled milestone raises ValueError", "AT_WORK" in str(e))
|
||||||
|
try:
|
||||||
|
eq.report_substrate("WFR-GHOST", Milestone.AT_WORK) # never ARRIVED
|
||||||
|
check("report_substrate on unknown wafer raises", False)
|
||||||
|
except SecsGemError as e:
|
||||||
|
check("report_substrate on unknown wafer raises", "WFR-GHOST" in str(e))
|
||||||
|
|
||||||
|
# ---- E157 module tracking: walk and reset ----
|
||||||
|
eq.report_module("MOD-PY-1", ModuleState.GENERAL_EXECUTING)
|
||||||
|
eq.report_module("MOD-PY-1", ModuleState.STEP_EXECUTING)
|
||||||
|
eq.report_module("MOD-PY-1", ModuleState.STEP_COMPLETED)
|
||||||
|
eq.report_module("MOD-PY-1", ModuleState.NOT_EXECUTING)
|
||||||
|
check("report_module walk + reset accepted (no raise)", True)
|
||||||
|
try:
|
||||||
|
eq.report_module("MOD-PY-2", ModuleState.STEP_EXECUTING) # illegal from idle
|
||||||
|
check("report_module illegal jump raises", False)
|
||||||
|
except SecsGemError:
|
||||||
|
check("report_module illegal jump raises", True)
|
||||||
|
|
||||||
|
# ---- operator offline via the client ----
|
||||||
|
eq.request_control_state("HOST_OFFLINE")
|
||||||
|
check("request_control_state(HOST_OFFLINE)",
|
||||||
|
eq.control_state == "HOST_OFFLINE", eq.control_state)
|
||||||
|
finally:
|
||||||
|
host.disable()
|
||||||
|
eq.close()
|
||||||
|
|
||||||
|
if failures:
|
||||||
|
LOG.error("FAILURES (%d): %s", len(failures), failures)
|
||||||
|
return 1
|
||||||
|
LOG.info("all secsgem_client interop checks passed")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
ap = argparse.ArgumentParser()
|
||||||
|
ap.add_argument("--grpc", default="gemd:50051")
|
||||||
|
ap.add_argument("--hsms-host", default="gemd")
|
||||||
|
ap.add_argument("--hsms-port", type=int, default=5000)
|
||||||
|
args = ap.parse_args()
|
||||||
|
logging.basicConfig(level=logging.INFO, format="%(message)s")
|
||||||
|
logging.getLogger("communication").setLevel(logging.WARNING)
|
||||||
|
logging.getLogger("hsms_connection").setLevel(logging.WARNING)
|
||||||
|
return run(args.grpc, args.hsms_host, args.hsms_port)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
@@ -10,6 +10,10 @@
|
|||||||
# filesystem. Wired into CI via .gitea/workflows/ci.yml.
|
# filesystem. Wired into CI via .gitea/workflows/ci.yml.
|
||||||
#
|
#
|
||||||
# Usage: bash interop/secs4j_validate.sh
|
# Usage: bash interop/secs4j_validate.sh
|
||||||
|
# TARGET=gemd bash interop/secs4j_validate.sh
|
||||||
|
# -> runs the same 55 checks against secs_gemd's HSMS face
|
||||||
|
# (the daemon must be byte-identical GEM to secs_server,
|
||||||
|
# since both sit on register_default_handlers).
|
||||||
# Exit codes:
|
# Exit codes:
|
||||||
# 0 — every check the harness defines passed
|
# 0 — every check the harness defines passed
|
||||||
# 1 — one or more checks failed
|
# 1 — one or more checks failed
|
||||||
@@ -41,13 +45,22 @@ docker build -t secsgem-secs4j-interop -f interop/secs4j/Dockerfile interop/secs
|
|||||||
docker network rm "$NET" >/dev/null 2>&1 || true
|
docker network rm "$NET" >/dev/null 2>&1 || true
|
||||||
docker network create "$NET" >/dev/null
|
docker network create "$NET" >/dev/null
|
||||||
|
|
||||||
echo "starting secs_server..."
|
echo "starting ${TARGET:-server} (secs4j peer)..."
|
||||||
# --name doubles as the DNS hostname on the user-defined network, so
|
# --name doubles as the DNS hostname on the user-defined network, so
|
||||||
# the harness reaches it as "secs4j-interop-server:5000".
|
# the harness reaches it as "secs4j-interop-server:5000".
|
||||||
docker run -d --rm \
|
if [ "${TARGET:-server}" = "gemd" ]; then
|
||||||
--name "$SERVER_NAME" \
|
docker run -d --rm \
|
||||||
--network "$NET" \
|
--name "$SERVER_NAME" \
|
||||||
secsgem-secs4j-server >/dev/null
|
--network "$NET" \
|
||||||
|
--entrypoint /usr/local/bin/secs_gemd \
|
||||||
|
secsgem-secs4j-server \
|
||||||
|
--port 5000 --grpc 0.0.0.0:50051 --config-dir /app/data >/dev/null
|
||||||
|
else
|
||||||
|
docker run -d --rm \
|
||||||
|
--name "$SERVER_NAME" \
|
||||||
|
--network "$NET" \
|
||||||
|
secsgem-secs4j-server >/dev/null
|
||||||
|
fi
|
||||||
|
|
||||||
# Give the server a moment to bind.
|
# Give the server a moment to bind.
|
||||||
sleep 1
|
sleep 1
|
||||||
|
|||||||
@@ -0,0 +1,209 @@
|
|||||||
|
"""A randomized virtual fab.
|
||||||
|
|
||||||
|
N secs_gemd equipment instances (the `fab` compose service) each get TWO
|
||||||
|
independent actors attached:
|
||||||
|
|
||||||
|
- a secsgem-py GemHostHandler playing the fab host over HSMS, and
|
||||||
|
- a secsgem_client Equipment playing the tool software over gRPC,
|
||||||
|
|
||||||
|
then a seeded random scenario drives all of them concurrently: variable
|
||||||
|
writes with read-back verification, event fires that must arrive at the
|
||||||
|
host as S6F11, alarm set/clear that must arrive as S5F1, host S1F3 polls,
|
||||||
|
and host S2F41 commands that must come back HCACK=4 and reach the tool's
|
||||||
|
handler. Every violated invariant is recorded; the seed is printed so any
|
||||||
|
failure is reproducible.
|
||||||
|
|
||||||
|
python3 virtual_fab.py --host fab --n 3 --seconds 20 [--seed 1234]
|
||||||
|
|
||||||
|
Exit 0 = every invariant held on every equipment.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import logging
|
||||||
|
import random
|
||||||
|
import sys
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
|
||||||
|
sys.path.insert(0, "/app/clients/python")
|
||||||
|
|
||||||
|
import secsgem.common
|
||||||
|
import secsgem.gem
|
||||||
|
import secsgem.hsms
|
||||||
|
import secsgem.secs
|
||||||
|
|
||||||
|
from secsgem_client import Equipment, SecsGemError
|
||||||
|
|
||||||
|
LOG = logging.getLogger("fab")
|
||||||
|
F = secsgem.secs.functions
|
||||||
|
|
||||||
|
|
||||||
|
class EquipmentActor:
|
||||||
|
"""One equipment: its host, its tool, its tallies, its verdicts."""
|
||||||
|
|
||||||
|
def __init__(self, idx: int, host: str, hsms_port: int, grpc_port: int,
|
||||||
|
rng: random.Random):
|
||||||
|
self.idx = idx
|
||||||
|
self.rng = rng
|
||||||
|
self.violations: list[str] = []
|
||||||
|
self.s6f11_seen = 0
|
||||||
|
self.s5f1_seen = 0
|
||||||
|
self.events_fired = 0
|
||||||
|
self.alarm_ops = 0
|
||||||
|
self.commands_sent = 0
|
||||||
|
self.commands_received = 0
|
||||||
|
self.ops = 0
|
||||||
|
|
||||||
|
self.tool = Equipment(f"{host}:{grpc_port}")
|
||||||
|
|
||||||
|
@self.tool.on("*")
|
||||||
|
def _any(cmd): # noqa: ANN001
|
||||||
|
self.commands_received += 1
|
||||||
|
self.tool.fire("ProcessStarted") # completion signal
|
||||||
|
|
||||||
|
self.tool.listen(background=True)
|
||||||
|
|
||||||
|
settings = secsgem.hsms.HsmsSettings(
|
||||||
|
address=host, port=hsms_port, session_id=0,
|
||||||
|
connect_mode=secsgem.hsms.HsmsConnectMode.ACTIVE,
|
||||||
|
device_type=secsgem.common.DeviceType.HOST)
|
||||||
|
self.host = secsgem.gem.GemHostHandler(settings)
|
||||||
|
self.host.register_stream_function(6, 11, self._on_s6f11)
|
||||||
|
self.host.register_stream_function(5, 1, self._on_s5f1)
|
||||||
|
self.host.enable()
|
||||||
|
|
||||||
|
def _on_s6f11(self, _h, message):
|
||||||
|
self.s6f11_seen += 1
|
||||||
|
self.host.send_response(F.SecsS06F12(0), message.header.system)
|
||||||
|
|
||||||
|
def _on_s5f1(self, _h, message):
|
||||||
|
self.s5f1_seen += 1
|
||||||
|
self.host.send_response(F.SecsS05F02(0), message.header.system)
|
||||||
|
|
||||||
|
def violate(self, what: str) -> None:
|
||||||
|
self.violations.append(what)
|
||||||
|
LOG.error("[eq%d] VIOLATION: %s", self.idx, what)
|
||||||
|
|
||||||
|
def setup(self) -> bool:
|
||||||
|
if not self.host.waitfor_communicating(timeout=20):
|
||||||
|
self.violate("HSMS establish-communications failed")
|
||||||
|
return False
|
||||||
|
self.host.send_and_waitfor_response(F.SecsS01F17())
|
||||||
|
self.host.send_and_waitfor_response(
|
||||||
|
F.SecsS02F33({"DATAID": 1, "DATA": [{"RPTID": 1, "VID": [101]}]}))
|
||||||
|
self.host.send_and_waitfor_response(
|
||||||
|
F.SecsS02F35({"DATAID": 1, "DATA": [{"CEID": 300, "RPTID": [1]}]}))
|
||||||
|
self.host.send_and_waitfor_response(
|
||||||
|
F.SecsS02F37({"CEED": True, "CEID": [300]}))
|
||||||
|
self.host.send_and_waitfor_response(
|
||||||
|
F.SecsS05F03({"ALED": 0x80, "ALID": 1}))
|
||||||
|
return True
|
||||||
|
|
||||||
|
def step(self) -> None:
|
||||||
|
"""One random action with its invariant."""
|
||||||
|
self.ops += 1
|
||||||
|
roll = self.rng.random()
|
||||||
|
try:
|
||||||
|
if roll < 0.30:
|
||||||
|
v = round(self.rng.uniform(0.1, 9.9), 3)
|
||||||
|
self.tool.set(ChamberPressure=v)
|
||||||
|
got = self.tool.get("ChamberPressure")["ChamberPressure"]
|
||||||
|
if abs(got - v) > 1e-3:
|
||||||
|
self.violate(f"round-trip: set {v} got {got}")
|
||||||
|
elif roll < 0.45:
|
||||||
|
self.tool.fire("ProcessStarted")
|
||||||
|
self.events_fired += 1
|
||||||
|
elif roll < 0.60:
|
||||||
|
if self.rng.random() < 0.5:
|
||||||
|
self.tool.alarm("chiller_temp_high")
|
||||||
|
else:
|
||||||
|
self.tool.clear("chiller_temp_high")
|
||||||
|
self.alarm_ops += 1
|
||||||
|
elif roll < 0.80:
|
||||||
|
rsp = self.host.send_and_waitfor_response(F.SecsS01F03([]))
|
||||||
|
body = self.host.settings.streams_functions.decode(rsp).get()
|
||||||
|
if not isinstance(body, list) or len(body) < 1:
|
||||||
|
self.violate(f"S1F3 poll returned {body!r}")
|
||||||
|
else:
|
||||||
|
rsp = self.host.send_and_waitfor_response(
|
||||||
|
F.SecsS02F41({"RCMD": "START", "PARAMS": []}))
|
||||||
|
body = self.host.settings.streams_functions.decode(rsp).get()
|
||||||
|
hcack = int(body.get("HCACK", -1)) if isinstance(body, dict) else -1
|
||||||
|
self.commands_sent += 1
|
||||||
|
# A tool is subscribed for the whole run: must be HCACK 4.
|
||||||
|
if hcack != 4:
|
||||||
|
self.violate(f"S2F41 with subscriber -> HCACK {hcack} (want 4)")
|
||||||
|
except SecsGemError as e:
|
||||||
|
self.violate(f"client raised: {e}")
|
||||||
|
except Exception as e: # noqa: BLE001
|
||||||
|
self.violate(f"actor raised: {type(e).__name__}: {e}")
|
||||||
|
|
||||||
|
def finish(self) -> None:
|
||||||
|
time.sleep(2.0) # let in-flight S6F11/S5F1 land
|
||||||
|
if self.events_fired + self.commands_received > 0 and self.s6f11_seen == 0:
|
||||||
|
self.violate("events fired but host never received S6F11")
|
||||||
|
if self.alarm_ops > 0 and self.s5f1_seen == 0:
|
||||||
|
self.violate("alarms toggled but host never received S5F1")
|
||||||
|
if self.commands_sent > 0 and self.commands_received == 0:
|
||||||
|
self.violate("commands sent but tool handler never ran")
|
||||||
|
try:
|
||||||
|
self.host.disable()
|
||||||
|
self.tool.close()
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def run(host: str, n: int, seconds: float, seed: int) -> int:
|
||||||
|
LOG.info("virtual fab: %d equipment, %.0fs, seed=%d", n, seconds, seed)
|
||||||
|
actors = [EquipmentActor(i, host, 5100 + i, 51000 + i,
|
||||||
|
random.Random(seed + i)) for i in range(n)]
|
||||||
|
if not all(a.setup() for a in actors):
|
||||||
|
return 1
|
||||||
|
|
||||||
|
deadline = time.monotonic() + seconds
|
||||||
|
|
||||||
|
def drive(a: EquipmentActor) -> None:
|
||||||
|
while time.monotonic() < deadline and len(a.violations) < 5:
|
||||||
|
a.step()
|
||||||
|
time.sleep(a.rng.uniform(0.01, 0.10))
|
||||||
|
|
||||||
|
threads = [threading.Thread(target=drive, args=(a,)) for a in actors]
|
||||||
|
for t in threads:
|
||||||
|
t.start()
|
||||||
|
for t in threads:
|
||||||
|
t.join()
|
||||||
|
for a in actors:
|
||||||
|
a.finish()
|
||||||
|
|
||||||
|
bad = 0
|
||||||
|
for a in actors:
|
||||||
|
LOG.info("[eq%d] ops=%d cmds=%d/%d s6f11=%d s5f1=%d violations=%d",
|
||||||
|
a.idx, a.ops, a.commands_received, a.commands_sent,
|
||||||
|
a.s6f11_seen, a.s5f1_seen, len(a.violations))
|
||||||
|
bad += len(a.violations)
|
||||||
|
if bad:
|
||||||
|
LOG.error("FAB FAILED: %d violations (seed=%d to reproduce)", bad, seed)
|
||||||
|
return 1
|
||||||
|
LOG.info("virtual fab: all invariants held on all %d equipment (seed=%d)",
|
||||||
|
n, seed)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
ap = argparse.ArgumentParser()
|
||||||
|
ap.add_argument("--host", default="fab")
|
||||||
|
ap.add_argument("--n", type=int, default=3)
|
||||||
|
ap.add_argument("--seconds", type=float, default=20.0)
|
||||||
|
ap.add_argument("--seed", type=int, default=None)
|
||||||
|
args = ap.parse_args()
|
||||||
|
logging.basicConfig(level=logging.INFO, format="%(message)s")
|
||||||
|
for noisy in ("communication", "hsms_connection", "secsgem"):
|
||||||
|
logging.getLogger(noisy).setLevel(logging.WARNING)
|
||||||
|
seed = args.seed if args.seed is not None else random.SystemRandom().randrange(1 << 31)
|
||||||
|
return run(args.host, args.n, args.seconds, seed)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
Generated
+6
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"name": "secs-gem",
|
||||||
|
"lockfileVersion": 3,
|
||||||
|
"requires": true,
|
||||||
|
"packages": {}
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
{}
|
||||||
@@ -57,6 +57,15 @@ service Equipment {
|
|||||||
|
|
||||||
// Subscribe to everything the host asks of this equipment. The daemon streams
|
// Subscribe to everything the host asks of this equipment. The daemon streams
|
||||||
// HostRequest messages for as long as the call stays open.
|
// HostRequest messages for as long as the call stays open.
|
||||||
|
//
|
||||||
|
// Delivery contract (v1):
|
||||||
|
// - firehose: every subscriber receives every host request;
|
||||||
|
// - NO buffering: a command arriving while no client is subscribed is
|
||||||
|
// answered with its declarative ack from the equipment config (the
|
||||||
|
// pre-daemon behaviour) and is NOT replayed on reconnect — the daemon
|
||||||
|
// never tells the host "will finish later" for work no tool will do;
|
||||||
|
// - when a Command does arrive here, the host has ALREADY been answered
|
||||||
|
// with S2F42 HCACK=4; report the real outcome via FireEvent/SetAlarm.
|
||||||
rpc Subscribe(SubscribeRequest) returns (stream HostRequest);
|
rpc Subscribe(SubscribeRequest) returns (stream HostRequest);
|
||||||
|
|
||||||
// Report the outcome of a Command delivered on the stream, quoting its `id`.
|
// Report the outcome of a Command delivered on the stream, quoting its `id`.
|
||||||
@@ -74,13 +83,42 @@ service Equipment {
|
|||||||
// are long-lived objects you report against as the physical work proceeds.
|
// are long-lived objects you report against as the physical work proceeds.
|
||||||
|
|
||||||
rpc ReportProcessJob(ProcessJobState) returns (Ack); // E40 — job-based tools
|
rpc ReportProcessJob(ProcessJobState) returns (Ack); // E40 — job-based tools
|
||||||
rpc ReportCarrier(CarrierState) returns (Ack); // E87 — carrier-based tools
|
|
||||||
|
// E87 — carrier-based tools. WAITING announces a physically-arrived
|
||||||
|
// carrier (creates it; idempotent — re-announce updates the slot map);
|
||||||
|
// IN_ACCESS / COMPLETE drive the access FSM. The host's S3F17 decisions
|
||||||
|
// (ProceedWithCarrier / CancelCarrier) come back on the Subscribe stream
|
||||||
|
// as CarrierAction.
|
||||||
|
rpc ReportCarrier(CarrierState) returns (Ack);
|
||||||
|
|
||||||
|
// E90 — substrate (wafer) tracking. Report each milestone of a wafer's
|
||||||
|
// journey; the daemon drives the E90 FSMs and emits the standard CEIDs to
|
||||||
|
// the host. ARRIVED creates the substrate. Only for tools that track
|
||||||
|
// individual substrates.
|
||||||
|
rpc ReportSubstrate(SubstrateReport) returns (Ack);
|
||||||
|
|
||||||
|
// E157 — module process tracking. Report a module's execution state; the
|
||||||
|
// daemon drives the E157 FSM and emits the standard CEIDs. The module is
|
||||||
|
// auto-created on first report. Only for tools with tracked modules.
|
||||||
|
rpc ReportModule(ModuleReport) returns (Ack);
|
||||||
|
|
||||||
// ---- Diagnostics --------------------------------------------------------
|
// ---- Diagnostics --------------------------------------------------------
|
||||||
|
|
||||||
// Live daemon/link status: distinguishes "host went offline" from "cable
|
// Live daemon/link status: distinguishes "host went offline" from "cable
|
||||||
// unplugged" from "spool filling up". Streams a snapshot on every change.
|
// unplugged" from "spool filling up". Streams a snapshot on every change.
|
||||||
rpc WatchHealth(Empty) returns (stream Health);
|
rpc WatchHealth(Empty) returns (stream Health);
|
||||||
|
|
||||||
|
// Everything this equipment is configured with, by name — for tooling,
|
||||||
|
// diagnostics, and client-side validation/autocomplete.
|
||||||
|
rpc Describe(Empty) returns (EquipmentDescription);
|
||||||
|
|
||||||
|
// Flush the spool: purge=true discards queued messages, purge=false drains
|
||||||
|
// them toward the host (requires a SELECTED session).
|
||||||
|
rpc FlushSpool(SpoolFlushRequest) returns (Ack);
|
||||||
|
|
||||||
|
// Equipment-initiated operator message to the host (S10F1). Fails with
|
||||||
|
// CANNOT_DO_NOW when no host is connected and stream 10 isn't spoolable.
|
||||||
|
rpc SendTerminalMessage(TerminalMessage) returns (Ack);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- Values ----------------------------------------------------------------
|
// ---- Values ----------------------------------------------------------------
|
||||||
@@ -251,6 +289,54 @@ message Health {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- Material tracking (E90 / E157) ----------------------------------------
|
||||||
|
|
||||||
|
message SubstrateReport {
|
||||||
|
string substrate_id = 1;
|
||||||
|
Milestone milestone = 2;
|
||||||
|
string carrier_id = 3; // optional; recorded on ARRIVED
|
||||||
|
uint32 slot = 4; // optional; 1-based slot within the carrier
|
||||||
|
enum Milestone {
|
||||||
|
ARRIVED = 0; // create; AtSource / NeedsProcessing
|
||||||
|
AT_WORK = 1; // picked up for processing
|
||||||
|
PROCESSING = 2; // processing started
|
||||||
|
PROCESSED = 3; // processing finished
|
||||||
|
AT_DESTINATION = 4; // returned / deposited
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
message ModuleReport {
|
||||||
|
string module_id = 1;
|
||||||
|
State state = 2;
|
||||||
|
enum State {
|
||||||
|
NOT_EXECUTING = 0;
|
||||||
|
GENERAL_EXECUTING = 1;
|
||||||
|
STEP_EXECUTING = 2;
|
||||||
|
STEP_COMPLETED = 3;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Diagnostics & operations -----------------------------------------------
|
||||||
|
|
||||||
|
message EquipmentDescription {
|
||||||
|
string model_name = 1;
|
||||||
|
string software_rev = 2;
|
||||||
|
repeated string variables = 3; // SVID + DVID names
|
||||||
|
repeated string events = 4; // collection-event names
|
||||||
|
repeated string alarms = 5; // alarm names (or stringified ALIDs)
|
||||||
|
repeated string commands = 6; // RCMDs the host may send
|
||||||
|
repeated string constants = 7; // equipment-constant names
|
||||||
|
}
|
||||||
|
|
||||||
|
message SpoolFlushRequest {
|
||||||
|
bool purge = 1; // true = discard, false = drain to host
|
||||||
|
}
|
||||||
|
|
||||||
|
message TerminalMessage {
|
||||||
|
uint32 tid = 1; // terminal id (0 = main)
|
||||||
|
string text = 2;
|
||||||
|
}
|
||||||
|
|
||||||
// ---- Acknowledgement -------------------------------------------------------
|
// ---- Acknowledgement -------------------------------------------------------
|
||||||
|
|
||||||
// Mirrors SEMI HCACK exactly. For non-command RPCs, only ACCEPT vs an error
|
// Mirrors SEMI HCACK exactly. For non-command RPCs, only ACCEPT vs an error
|
||||||
|
|||||||
@@ -147,6 +147,18 @@ EquipmentDescriptor load_equipment(const std::string& path, gem::EquipmentDataMo
|
|||||||
if (!e.IsNull()) desc.emit_on_control_change = static_cast<uint32_t>(e.as<int>());
|
if (!e.IsNull()) desc.emit_on_control_change = static_cast<uint32_t>(e.as<int>());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Role bindings: which configured ids the built-in behaviours target.
|
||||||
|
if (auto roles = root["roles"]) {
|
||||||
|
if (auto n = roles["control_state_svid"])
|
||||||
|
desc.control_state_svid = static_cast<uint32_t>(n.as<int>());
|
||||||
|
if (auto n = roles["clock_svid"])
|
||||||
|
desc.clock_svid = static_cast<uint32_t>(n.as<int>());
|
||||||
|
if (auto n = roles["cj_executing_ceid"])
|
||||||
|
desc.cj_executing_ceid = static_cast<uint32_t>(n.as<int>());
|
||||||
|
if (auto n = roles["cj_completed_ceid"])
|
||||||
|
desc.cj_completed_ceid = static_cast<uint32_t>(n.as<int>());
|
||||||
|
}
|
||||||
|
|
||||||
if (auto caps = root["capabilities"]) {
|
if (auto caps = root["capabilities"]) {
|
||||||
for (const auto& c : caps) {
|
for (const auto& c : caps) {
|
||||||
desc.capabilities.emplace_back(static_cast<uint8_t>(c["code"].as<int>()),
|
desc.capabilities.emplace_back(static_cast<uint8_t>(c["code"].as<int>()),
|
||||||
@@ -206,6 +218,7 @@ EquipmentDescriptor load_equipment(const std::string& path, gem::EquipmentDataMo
|
|||||||
static_cast<uint32_t>(a["id"].as<int>()),
|
static_cast<uint32_t>(a["id"].as<int>()),
|
||||||
a["text"].as<std::string>(),
|
a["text"].as<std::string>(),
|
||||||
static_cast<uint8_t>(a["category"].as<int>()),
|
static_cast<uint8_t>(a["category"].as<int>()),
|
||||||
|
a["name"] ? a["name"].as<std::string>() : "", // optional local key
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+57
-2
@@ -3,6 +3,7 @@
|
|||||||
#include <yaml-cpp/yaml.h>
|
#include <yaml-cpp/yaml.h>
|
||||||
|
|
||||||
#include <algorithm>
|
#include <algorithm>
|
||||||
|
#include <cctype>
|
||||||
#include <cstdint>
|
#include <cstdint>
|
||||||
#include <set>
|
#include <set>
|
||||||
#include <string>
|
#include <string>
|
||||||
@@ -117,6 +118,26 @@ std::optional<std::string> as_nonempty_string(const YAML::Node& n,
|
|||||||
return s;
|
return s;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// API-facing names (variables, events, alarms, commands) should be valid
|
||||||
|
// identifiers: language bindings expose them as kwargs/attributes
|
||||||
|
// (eq.set(chamber_pressure=...)), where "Chamber Pressure" or "temp-2" can't
|
||||||
|
// be written. Warning, not error — the wire doesn't care, only bindings do.
|
||||||
|
void warn_if_not_identifier(const std::optional<std::string>& s, Sink& sink,
|
||||||
|
const std::string& path, const YAML::Node& n) {
|
||||||
|
if (!s) return;
|
||||||
|
bool ok = !s->empty() &&
|
||||||
|
(std::isalpha(static_cast<unsigned char>((*s)[0])) || (*s)[0] == '_');
|
||||||
|
for (std::size_t i = 1; ok && i < s->size(); ++i) {
|
||||||
|
const auto c = static_cast<unsigned char>((*s)[i]);
|
||||||
|
ok = std::isalnum(c) || (*s)[i] == '_';
|
||||||
|
}
|
||||||
|
if (!ok)
|
||||||
|
sink.warn(path, "`" + *s + "` is not identifier-safe " +
|
||||||
|
"([A-Za-z_][A-Za-z0-9_]*); language bindings expose " +
|
||||||
|
"names as kwargs/attributes",
|
||||||
|
n);
|
||||||
|
}
|
||||||
|
|
||||||
// Validate one entry that has the same (id, name, units, type, value)
|
// Validate one entry that has the same (id, name, units, type, value)
|
||||||
// shape as SVID/DVID/ECID. Returns the parsed id on success so the
|
// shape as SVID/DVID/ECID. Returns the parsed id on success so the
|
||||||
// caller can dedupe.
|
// caller can dedupe.
|
||||||
@@ -124,7 +145,8 @@ std::optional<uint32_t> validate_typed_entry(
|
|||||||
const YAML::Node& entry, Sink& sink, const std::string& path) {
|
const YAML::Node& entry, Sink& sink, const std::string& path) {
|
||||||
auto id = as_int_in_range<uint32_t>(entry["id"], sink, path + ".id",
|
auto id = as_int_in_range<uint32_t>(entry["id"], sink, path + ".id",
|
||||||
0, UINT32_MAX);
|
0, UINT32_MAX);
|
||||||
as_nonempty_string(entry["name"], sink, path + ".name");
|
warn_if_not_identifier(as_nonempty_string(entry["name"], sink, path + ".name"),
|
||||||
|
sink, path + ".name", entry["name"]);
|
||||||
if (auto tn = entry["type"]) {
|
if (auto tn = entry["type"]) {
|
||||||
auto t = tn.as<std::string>();
|
auto t = tn.as<std::string>();
|
||||||
if (!valid_secs_type(t)) {
|
if (!valid_secs_type(t)) {
|
||||||
@@ -195,7 +217,9 @@ void validate_equipment_block(YAML::Node& root, Sink& sink) {
|
|||||||
const auto path = "ceids[" + std::to_string(i) + "]";
|
const auto path = "ceids[" + std::to_string(i) + "]";
|
||||||
auto id = as_int_in_range<uint32_t>(ces[i]["id"], sink, path + ".id",
|
auto id = as_int_in_range<uint32_t>(ces[i]["id"], sink, path + ".id",
|
||||||
0, UINT32_MAX);
|
0, UINT32_MAX);
|
||||||
as_nonempty_string(ces[i]["name"], sink, path + ".name");
|
warn_if_not_identifier(
|
||||||
|
as_nonempty_string(ces[i]["name"], sink, path + ".name"),
|
||||||
|
sink, path + ".name", ces[i]["name"]);
|
||||||
if (id && !ceids.insert(*id).second) {
|
if (id && !ceids.insert(*id).second) {
|
||||||
sink.error(path + ".id",
|
sink.error(path + ".id",
|
||||||
"duplicate CEID " + std::to_string(*id),
|
"duplicate CEID " + std::to_string(*id),
|
||||||
@@ -218,6 +242,12 @@ void validate_equipment_block(YAML::Node& root, Sink& sink) {
|
|||||||
// is the set/clear flag, set at emit time — must not be in YAML.
|
// is the set/clear flag, set at emit time — must not be in YAML.
|
||||||
as_int_in_range<uint8_t>(als[i]["category"], sink,
|
as_int_in_range<uint8_t>(als[i]["category"], sink,
|
||||||
path + ".category", 0, 127);
|
path + ".category", 0, 127);
|
||||||
|
// Optional local key for name-based APIs (not on the wire). If
|
||||||
|
// present it must be non-empty.
|
||||||
|
if (als[i]["name"])
|
||||||
|
warn_if_not_identifier(
|
||||||
|
as_nonempty_string(als[i]["name"], sink, path + ".name"),
|
||||||
|
sink, path + ".name", als[i]["name"]);
|
||||||
if (id && !alarm_ids.insert(*id).second) {
|
if (id && !alarm_ids.insert(*id).second) {
|
||||||
sink.error(path + ".id",
|
sink.error(path + ".id",
|
||||||
"duplicate ALID " + std::to_string(*id), als[i]);
|
"duplicate ALID " + std::to_string(*id), als[i]);
|
||||||
@@ -252,6 +282,7 @@ void validate_equipment_block(YAML::Node& root, Sink& sink) {
|
|||||||
for (std::size_t i = 0; i < cmds.size(); ++i) {
|
for (std::size_t i = 0; i < cmds.size(); ++i) {
|
||||||
const auto path = "host_commands[" + std::to_string(i) + "]";
|
const auto path = "host_commands[" + std::to_string(i) + "]";
|
||||||
auto name = as_nonempty_string(cmds[i]["name"], sink, path + ".name");
|
auto name = as_nonempty_string(cmds[i]["name"], sink, path + ".name");
|
||||||
|
warn_if_not_identifier(name, sink, path + ".name", cmds[i]["name"]);
|
||||||
if (auto ack = cmds[i]["ack"]) {
|
if (auto ack = cmds[i]["ack"]) {
|
||||||
auto s = ack.as<std::string>();
|
auto s = ack.as<std::string>();
|
||||||
if (!valid_hcack(s))
|
if (!valid_hcack(s))
|
||||||
@@ -322,6 +353,30 @@ void validate_equipment_block(YAML::Node& root, Sink& sink) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Role bindings: optional; each must be an in-range id, and the CEID
|
||||||
|
// roles must point at declared collection events (the SVID roles are
|
||||||
|
// checked loosely — built-ins write them, so a missing SVID is a
|
||||||
|
// warning-grade misconfig surfaced at runtime by set_value's no-op).
|
||||||
|
if (auto roles = root["roles"]) {
|
||||||
|
for (const char* key : {"control_state_svid", "clock_svid"}) {
|
||||||
|
if (auto n = roles[key])
|
||||||
|
as_int_in_range<uint32_t>(n, sink, std::string("roles.") + key, 0,
|
||||||
|
UINT32_MAX);
|
||||||
|
}
|
||||||
|
for (const char* key : {"cj_executing_ceid", "cj_completed_ceid"}) {
|
||||||
|
if (auto n = roles[key]) {
|
||||||
|
auto v = as_int_in_range<uint32_t>(n, sink, std::string("roles.") + key,
|
||||||
|
0, UINT32_MAX);
|
||||||
|
if (v && !ceids.count(*v)) {
|
||||||
|
sink.error(std::string("roles.") + key,
|
||||||
|
"CEID " + std::to_string(*v) +
|
||||||
|
" not declared in `ceids` section",
|
||||||
|
n);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
YAML::Node try_load(const std::string& path, Sink& sink) {
|
YAML::Node try_load(const std::string& path, Sink& sink) {
|
||||||
|
|||||||
+659
-511
File diff suppressed because it is too large
Load Diff
@@ -202,3 +202,32 @@ TEST_CASE("Validate: ships data/equipment.yaml without errors") {
|
|||||||
}
|
}
|
||||||
CHECK(v.error_count() == 0);
|
CHECK(v.error_count() == 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
TEST_CASE("Validate: non-identifier names warn (bindings expose names as kwargs)") {
|
||||||
|
auto p = scratch_path("idname", "yaml");
|
||||||
|
write(p, R"YAML(
|
||||||
|
device:
|
||||||
|
id: 0
|
||||||
|
model_name: "TEST"
|
||||||
|
software_rev: "1.0"
|
||||||
|
svids:
|
||||||
|
- {id: 1, name: "Chamber Pressure", type: F4, value: 0.0}
|
||||||
|
- {id: 2, name: GoodName, type: U4, value: 0}
|
||||||
|
ceids:
|
||||||
|
- {id: 10, name: "wafer-complete"}
|
||||||
|
alarms:
|
||||||
|
- {id: 1, name: "2bad", text: "T", category: 1}
|
||||||
|
host_commands:
|
||||||
|
- {name: "DO IT", ack: Accept}
|
||||||
|
)YAML");
|
||||||
|
ConfigValidator v;
|
||||||
|
v.validate_equipment(p.string());
|
||||||
|
CHECK(v.error_count() == 0); // identifier shape is a WARNING, not an error
|
||||||
|
CHECK(v.warning_count() == 4); // space, hyphen, leading digit, space
|
||||||
|
CHECK(any_match(v.issues(), "svids[0].name"));
|
||||||
|
CHECK(any_match(v.issues(), "ceids[0].name"));
|
||||||
|
CHECK(any_match(v.issues(), "alarms[0].name"));
|
||||||
|
CHECK(any_match(v.issues(), "host_commands[0].name"));
|
||||||
|
CHECK_FALSE(any_match(v.issues(), "svids[1].name"));
|
||||||
|
fs::remove_all(p.parent_path());
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,103 @@
|
|||||||
|
// The C++ client (clients/cpp) against the real service over loopback TCP —
|
||||||
|
// the same end-to-end shape as the Python client's interop harness, in-tree.
|
||||||
|
#include <doctest/doctest.h>
|
||||||
|
|
||||||
|
#include <atomic>
|
||||||
|
#include <chrono>
|
||||||
|
#include <thread>
|
||||||
|
|
||||||
|
#include "secsgem/gem/default_handlers.hpp"
|
||||||
|
#include "secsgem/gem/messages.hpp"
|
||||||
|
#include "secsgem/daemon/equipment_service.hpp"
|
||||||
|
#include "secsgem_client/equipment.hpp"
|
||||||
|
|
||||||
|
using namespace secsgem;
|
||||||
|
namespace gem = secsgem::gem;
|
||||||
|
namespace s2 = secsgem::secs2;
|
||||||
|
|
||||||
|
#ifndef SECSGEM_DATA_DIR
|
||||||
|
#error "SECSGEM_DATA_DIR not defined; see CMakeLists.txt"
|
||||||
|
#endif
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
gem::EquipmentRuntime::Config client_test_config() {
|
||||||
|
gem::EquipmentRuntime::Config c;
|
||||||
|
c.equipment_yaml = SECSGEM_DATA_DIR "/equipment.yaml";
|
||||||
|
c.control_state_yaml = SECSGEM_DATA_DIR "/control_state.yaml";
|
||||||
|
c.process_job_yaml = SECSGEM_DATA_DIR "/process_job_state.yaml";
|
||||||
|
c.control_job_yaml = SECSGEM_DATA_DIR "/control_job_state.yaml";
|
||||||
|
c.port = 0;
|
||||||
|
return c;
|
||||||
|
}
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
TEST_CASE("C++ client end-to-end against the service over loopback TCP") {
|
||||||
|
gem::EquipmentRuntime rt(client_test_config());
|
||||||
|
gem::register_default_handlers(rt);
|
||||||
|
secsgem::daemon::EquipmentService svc(rt);
|
||||||
|
rt.run_async();
|
||||||
|
|
||||||
|
int port = 0;
|
||||||
|
grpc::ServerBuilder builder;
|
||||||
|
builder.AddListeningPort("127.0.0.1:0", grpc::InsecureServerCredentials(), &port);
|
||||||
|
builder.RegisterService(&svc);
|
||||||
|
std::unique_ptr<grpc::Server> server(builder.BuildAndStart());
|
||||||
|
REQUIRE(server);
|
||||||
|
REQUIRE(port != 0);
|
||||||
|
|
||||||
|
secsgem_client::Equipment eq("127.0.0.1:" + std::to_string(port));
|
||||||
|
|
||||||
|
// set/get round-trip with C++ literals (int stays integer, double real).
|
||||||
|
eq.set("ChamberPressure", 2.5);
|
||||||
|
eq.set("WaferCounter", 7);
|
||||||
|
auto vals = eq.get({"ChamberPressure", "WaferCounter"});
|
||||||
|
CHECK(std::get<double>(vals.at("ChamberPressure")) == doctest::Approx(2.5));
|
||||||
|
CHECK(std::get<int64_t>(vals.at("WaferCounter")) == 7);
|
||||||
|
|
||||||
|
// Errors carry the daemon's explanation.
|
||||||
|
CHECK_THROWS_WITH_AS(eq.set("NoSuchVariable", 1),
|
||||||
|
doctest::Contains("NoSuchVariable"),
|
||||||
|
secsgem_client::SecsGemError);
|
||||||
|
|
||||||
|
// Alarms by config name.
|
||||||
|
eq.alarm("chiller_temp_high");
|
||||||
|
auto active = rt.read_sync([&rt] { return rt.model().alarms.active(1); });
|
||||||
|
REQUIRE(active.has_value());
|
||||||
|
CHECK(*active);
|
||||||
|
eq.clear("chiller_temp_high");
|
||||||
|
|
||||||
|
// Control state + health.
|
||||||
|
CHECK(eq.control_state() == "HOST_OFFLINE");
|
||||||
|
auto h = eq.health();
|
||||||
|
CHECK(h.link == "DISCONNECTED");
|
||||||
|
CHECK(h.control_state == "HOST_OFFLINE");
|
||||||
|
|
||||||
|
// The command loop: handler runs, params arrive, S2F42 says HCACK 4.
|
||||||
|
std::atomic<bool> ran{false};
|
||||||
|
std::string seen_ppid;
|
||||||
|
eq.on("START", [&](const secsgem_client::Command& cmd) {
|
||||||
|
seen_ppid = std::get<std::string>(cmd.params.at("PPID"));
|
||||||
|
ran = true;
|
||||||
|
});
|
||||||
|
eq.listen_async();
|
||||||
|
std::this_thread::sleep_for(std::chrono::milliseconds(200)); // subscribe race
|
||||||
|
|
||||||
|
auto reply = rt.read_sync([&rt]() {
|
||||||
|
return rt.router().dispatch(gem::s2f41_host_command(
|
||||||
|
"START", {{"PPID", s2::Item::ascii("RECIPE-A")}}));
|
||||||
|
});
|
||||||
|
REQUIRE(reply.has_value());
|
||||||
|
REQUIRE(reply->has_value());
|
||||||
|
auto parsed = gem::parse_s2f42(**reply);
|
||||||
|
REQUIRE(parsed.has_value());
|
||||||
|
CHECK(parsed->hcack == gem::HostCmdAck::AcceptedWillFinishLater);
|
||||||
|
|
||||||
|
for (int i = 0; i < 50 && !ran.load(); ++i)
|
||||||
|
std::this_thread::sleep_for(std::chrono::milliseconds(100));
|
||||||
|
CHECK(ran.load());
|
||||||
|
CHECK(seen_ppid == "RECIPE-A");
|
||||||
|
|
||||||
|
eq.stop();
|
||||||
|
server->Shutdown();
|
||||||
|
rt.stop();
|
||||||
|
}
|
||||||
@@ -3,7 +3,16 @@
|
|||||||
|
|
||||||
#include <grpcpp/grpcpp.h>
|
#include <grpcpp/grpcpp.h>
|
||||||
|
|
||||||
#include "equipment_service.hpp"
|
#include <atomic>
|
||||||
|
#include <chrono>
|
||||||
|
#include <random>
|
||||||
|
#include <string>
|
||||||
|
#include <thread>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
#include "secsgem/daemon/equipment_service.hpp"
|
||||||
|
#include "secsgem/gem/default_handlers.hpp"
|
||||||
|
#include "secsgem/gem/messages.hpp"
|
||||||
|
|
||||||
using namespace secsgem;
|
using namespace secsgem;
|
||||||
namespace gem = secsgem::gem;
|
namespace gem = secsgem::gem;
|
||||||
@@ -125,6 +134,30 @@ TEST_CASE("Equipment gRPC service over an in-process channel") {
|
|||||||
check_all(rt.model().dvids.all());
|
check_all(rt.model().dvids.all());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
SUBCASE("SetAlarm / ClearAlarm by config name, by stringified id, unknown rejected") {
|
||||||
|
auto call = [&](auto method, const std::string& name) {
|
||||||
|
grpc::ClientContext ctx;
|
||||||
|
pb::Alarm req;
|
||||||
|
pb::Ack resp;
|
||||||
|
req.set_name(name);
|
||||||
|
REQUIRE((stub.get()->*method)(&ctx, req, &resp).ok());
|
||||||
|
return resp.code();
|
||||||
|
};
|
||||||
|
// By config name.
|
||||||
|
CHECK(call(&pb::Equipment::Stub::SetAlarm, "chiller_temp_high") == pb::Ack::ACCEPT);
|
||||||
|
rt.poll();
|
||||||
|
CHECK(rt.model().alarms.active(1));
|
||||||
|
CHECK(call(&pb::Equipment::Stub::ClearAlarm, "chiller_temp_high") == pb::Ack::ACCEPT);
|
||||||
|
rt.poll();
|
||||||
|
CHECK_FALSE(rt.model().alarms.active(1));
|
||||||
|
// By stringified ALID — always works, even for unnamed alarms.
|
||||||
|
CHECK(call(&pb::Equipment::Stub::SetAlarm, "2") == pb::Ack::ACCEPT);
|
||||||
|
rt.poll();
|
||||||
|
CHECK(rt.model().alarms.active(2));
|
||||||
|
// Unknown name.
|
||||||
|
CHECK(call(&pb::Equipment::Stub::SetAlarm, "no_such_alarm") == pb::Ack::PARAMETER_INVALID);
|
||||||
|
}
|
||||||
|
|
||||||
SUBCASE("FireEvent accepts a known event and rejects an unknown one") {
|
SUBCASE("FireEvent accepts a known event and rejects an unknown one") {
|
||||||
{
|
{
|
||||||
grpc::ClientContext ctx;
|
grpc::ClientContext ctx;
|
||||||
@@ -146,3 +179,661 @@ TEST_CASE("Equipment gRPC service over an in-process channel") {
|
|||||||
|
|
||||||
server->Shutdown();
|
server->Shutdown();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetVariables needs a live io thread (read_sync posts onto it), so this case
|
||||||
|
// runs the engine in run_async() — the daemon's PRODUCTION threading mode —
|
||||||
|
// with real concurrency between the gRPC handler thread and the io thread.
|
||||||
|
TEST_CASE("GetVariables round-trip under run_async (production threading mode)") {
|
||||||
|
gem::EquipmentRuntime rt(test_config());
|
||||||
|
dmn::EquipmentService svc(rt); // snapshot BEFORE the io thread starts
|
||||||
|
rt.run_async();
|
||||||
|
|
||||||
|
grpc::ServerBuilder builder;
|
||||||
|
builder.RegisterService(&svc);
|
||||||
|
std::unique_ptr<grpc::Server> server(builder.BuildAndStart());
|
||||||
|
REQUIRE(server);
|
||||||
|
auto stub = pb::Equipment::NewStub(server->InProcessChannel(grpc::ChannelArguments{}));
|
||||||
|
|
||||||
|
// Write through the API, then read back through the API: exercises BOTH
|
||||||
|
// conversions (Value->Item with declared formats, Item->Value) end to end.
|
||||||
|
{
|
||||||
|
grpc::ClientContext ctx;
|
||||||
|
pb::VariableUpdate req;
|
||||||
|
pb::Ack resp;
|
||||||
|
(*req.mutable_values())["ChamberPressure"].set_real(2.5);
|
||||||
|
(*req.mutable_values())["WaferCounter"].set_integer(7);
|
||||||
|
REQUIRE(stub->SetVariables(&ctx, req, &resp).ok());
|
||||||
|
REQUIRE(resp.code() == pb::Ack::ACCEPT);
|
||||||
|
}
|
||||||
|
{
|
||||||
|
grpc::ClientContext ctx;
|
||||||
|
pb::VariableQuery req;
|
||||||
|
pb::VariableSnapshot resp;
|
||||||
|
req.add_names("ChamberPressure");
|
||||||
|
req.add_names("WaferCounter");
|
||||||
|
auto st = stub->GetVariables(&ctx, req, &resp);
|
||||||
|
REQUIRE(st.ok());
|
||||||
|
REQUIRE(resp.values().count("ChamberPressure") == 1);
|
||||||
|
REQUIRE(resp.values().count("WaferCounter") == 1);
|
||||||
|
CHECK(resp.values().at("ChamberPressure").real() == doctest::Approx(2.5));
|
||||||
|
CHECK(resp.values().at("WaferCounter").integer() == 7);
|
||||||
|
}
|
||||||
|
|
||||||
|
SUBCASE("empty query returns every configured variable") {
|
||||||
|
grpc::ClientContext ctx;
|
||||||
|
pb::VariableQuery req;
|
||||||
|
pb::VariableSnapshot resp;
|
||||||
|
REQUIRE(stub->GetVariables(&ctx, req, &resp).ok());
|
||||||
|
// The io thread is live here — model reads must go through read_sync
|
||||||
|
// (the first violation of that contract was caught by the TSan lane in
|
||||||
|
// exactly this line).
|
||||||
|
auto expected = rt.read_sync([&rt] {
|
||||||
|
return rt.model().svids.size() + rt.model().dvids.all().size();
|
||||||
|
});
|
||||||
|
REQUIRE(expected.has_value());
|
||||||
|
CHECK(resp.values().size() == *expected);
|
||||||
|
}
|
||||||
|
|
||||||
|
SUBCASE("unknown name is INVALID_ARGUMENT, naming the offender") {
|
||||||
|
grpc::ClientContext ctx;
|
||||||
|
pb::VariableQuery req;
|
||||||
|
pb::VariableSnapshot resp;
|
||||||
|
req.add_names("definitely_not_a_var");
|
||||||
|
auto st = stub->GetVariables(&ctx, req, &resp);
|
||||||
|
CHECK(st.error_code() == grpc::StatusCode::INVALID_ARGUMENT);
|
||||||
|
CHECK(st.error_message().find("definitely_not_a_var") != std::string::npos);
|
||||||
|
}
|
||||||
|
|
||||||
|
SUBCASE("RequestControlState walks the E30 table; WatchHealth pushes the change") {
|
||||||
|
// Open the health stream first: the initial snapshot arrives immediately.
|
||||||
|
grpc::ClientContext health_ctx;
|
||||||
|
pb::Empty empty;
|
||||||
|
auto reader = stub->WatchHealth(&health_ctx, empty);
|
||||||
|
pb::Health h;
|
||||||
|
REQUIRE(reader->Read(&h));
|
||||||
|
CHECK(h.link() == pb::Health::DISCONNECTED); // no HSMS host in this test
|
||||||
|
CHECK(h.control_state() == pb::ControlState::HOST_OFFLINE);
|
||||||
|
CHECK(h.spool_depth() == 0);
|
||||||
|
|
||||||
|
auto request = [&](pb::ControlState::State desired) {
|
||||||
|
grpc::ClientContext ctx;
|
||||||
|
pb::ControlStateRequest req;
|
||||||
|
pb::Ack resp;
|
||||||
|
req.set_desired(desired);
|
||||||
|
REQUIRE(stub->RequestControlState(&ctx, req, &resp).ok());
|
||||||
|
return resp;
|
||||||
|
};
|
||||||
|
|
||||||
|
// HostOffline --operator_online--> (AttemptOnline) --> OnlineLocal.
|
||||||
|
CHECK(request(pb::ControlState::ONLINE_LOCAL).code() == pb::Ack::ACCEPT);
|
||||||
|
CHECK(rt.control_state() == gem::ControlState::OnlineLocal);
|
||||||
|
|
||||||
|
// The stream pushes the change (well inside its 500ms poll interval).
|
||||||
|
REQUIRE(reader->Read(&h));
|
||||||
|
CHECK(h.control_state() == pb::ControlState::ONLINE_LOCAL);
|
||||||
|
|
||||||
|
// OnlineLocal --operator_remote--> OnlineRemote.
|
||||||
|
CHECK(request(pb::ControlState::ONLINE_REMOTE).code() == pb::Ack::ACCEPT);
|
||||||
|
CHECK(rt.control_state() == gem::ControlState::OnlineRemote);
|
||||||
|
|
||||||
|
// Transient state is not requestable.
|
||||||
|
CHECK(request(pb::ControlState::ATTEMPT_ONLINE).code() == pb::Ack::PARAMETER_INVALID);
|
||||||
|
|
||||||
|
// The shipped table has NO operator path to EquipmentOffline:
|
||||||
|
// operator_offline lands HostOffline — the API must say so honestly.
|
||||||
|
auto resp = request(pb::ControlState::EQUIPMENT_OFFLINE);
|
||||||
|
CHECK(resp.code() == pb::Ack::CANNOT_DO_NOW);
|
||||||
|
CHECK(resp.message().find("HostOffline") != std::string::npos);
|
||||||
|
CHECK(rt.control_state() == gem::ControlState::HostOffline);
|
||||||
|
|
||||||
|
// Requesting HOST_OFFLINE (the state operators actually get) succeeds —
|
||||||
|
// idempotently, since we're already there.
|
||||||
|
CHECK(request(pb::ControlState::HOST_OFFLINE).code() == pb::Ack::ACCEPT);
|
||||||
|
|
||||||
|
health_ctx.TryCancel();
|
||||||
|
pb::Health drain;
|
||||||
|
while (reader->Read(&drain)) {}
|
||||||
|
(void)reader->Finish(); // CANCELLED — expected
|
||||||
|
}
|
||||||
|
|
||||||
|
server->Shutdown();
|
||||||
|
rt.stop();
|
||||||
|
}
|
||||||
|
|
||||||
|
// The Subscribe command stream, per the HCACK-4 contract: a real S2F41 is
|
||||||
|
// dispatched through the full default-handler router ON the io thread, while
|
||||||
|
// the gRPC tool client consumes the stream — the daemon's production shape.
|
||||||
|
TEST_CASE("Subscribe: S2F41 -> stream -> HCACK 4; declarative fallback without subscriber") {
|
||||||
|
gem::EquipmentRuntime rt(test_config());
|
||||||
|
gem::register_default_handlers(rt); // the real S2F41/F21/F49 router path
|
||||||
|
dmn::EquipmentService svc(rt); // registers the forwarding handlers
|
||||||
|
rt.run_async();
|
||||||
|
|
||||||
|
grpc::ServerBuilder builder;
|
||||||
|
builder.RegisterService(&svc);
|
||||||
|
std::unique_ptr<grpc::Server> server(builder.BuildAndStart());
|
||||||
|
REQUIRE(server);
|
||||||
|
auto stub = pb::Equipment::NewStub(server->InProcessChannel(grpc::ChannelArguments{}));
|
||||||
|
|
||||||
|
// Dispatch a wire-shaped S2F41 on the io thread (the model's owner) and
|
||||||
|
// hand back the parsed S2F42 ack.
|
||||||
|
auto send_s2f41 = [&](const std::string& rcmd,
|
||||||
|
std::vector<gem::CommandParameter> params) {
|
||||||
|
auto reply = rt.read_sync([&rt, &rcmd, ¶ms]() {
|
||||||
|
return rt.router().dispatch(gem::s2f41_host_command(rcmd, params));
|
||||||
|
});
|
||||||
|
REQUIRE(reply.has_value()); // read_sync answered
|
||||||
|
REQUIRE(reply->has_value()); // router produced an S2F42
|
||||||
|
auto parsed = gem::parse_s2f42(**reply);
|
||||||
|
REQUIRE(parsed.has_value());
|
||||||
|
return parsed->hcack;
|
||||||
|
};
|
||||||
|
|
||||||
|
// --- no subscriber: declarative ack (START is Accept in equipment.yaml) ---
|
||||||
|
CHECK(send_s2f41("START", {}) == gem::HostCmdAck::Accept);
|
||||||
|
|
||||||
|
// --- subscriber connected: HCACK 4 + the command arrives on the stream ----
|
||||||
|
grpc::ClientContext sub_ctx;
|
||||||
|
pb::SubscribeRequest sreq;
|
||||||
|
sreq.set_client("test-tool");
|
||||||
|
auto reader = stub->Subscribe(&sub_ctx, sreq);
|
||||||
|
|
||||||
|
// Subscription registration races the dispatch below only in this test
|
||||||
|
// (the registry insert happens on the gRPC server thread); give it a beat.
|
||||||
|
std::this_thread::sleep_for(std::chrono::milliseconds(100));
|
||||||
|
|
||||||
|
CHECK(send_s2f41("START", {{"PPID", s2::Item::ascii("RECIPE-A")}}) ==
|
||||||
|
gem::HostCmdAck::AcceptedWillFinishLater);
|
||||||
|
|
||||||
|
pb::HostRequest hr;
|
||||||
|
REQUIRE(reader->Read(&hr));
|
||||||
|
REQUIRE(hr.has_command());
|
||||||
|
CHECK(hr.command().name() == "START");
|
||||||
|
CHECK_FALSE(hr.command().id().empty());
|
||||||
|
REQUIRE(hr.command().params().count("PPID") == 1);
|
||||||
|
CHECK(hr.command().params().at("PPID").text() == "RECIPE-A");
|
||||||
|
|
||||||
|
// --- CompleteCommand: known id accepted, unknown rejected ------------------
|
||||||
|
{
|
||||||
|
grpc::ClientContext ctx;
|
||||||
|
pb::CommandResult res;
|
||||||
|
pb::Ack ack;
|
||||||
|
res.set_id(hr.command().id());
|
||||||
|
res.mutable_ack()->set_code(pb::Ack::ACCEPT);
|
||||||
|
REQUIRE(stub->CompleteCommand(&ctx, res, &ack).ok());
|
||||||
|
CHECK(ack.code() == pb::Ack::ACCEPT);
|
||||||
|
}
|
||||||
|
{
|
||||||
|
grpc::ClientContext ctx;
|
||||||
|
pb::CommandResult res;
|
||||||
|
pb::Ack ack;
|
||||||
|
res.set_id("no-such-id");
|
||||||
|
REQUIRE(stub->CompleteCommand(&ctx, res, &ack).ok());
|
||||||
|
CHECK(ack.code() == pb::Ack::PARAMETER_INVALID);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- unsubscribe: the fallback returns ------------------------------------
|
||||||
|
sub_ctx.TryCancel();
|
||||||
|
pb::HostRequest drain;
|
||||||
|
while (reader->Read(&drain)) {}
|
||||||
|
(void)reader->Finish(); // CANCELLED — expected
|
||||||
|
// The server-side Subscribe loop notices the cancel within its 500ms poll.
|
||||||
|
std::this_thread::sleep_for(std::chrono::milliseconds(700));
|
||||||
|
CHECK(send_s2f41("START", {}) == gem::HostCmdAck::Accept);
|
||||||
|
|
||||||
|
server->Shutdown();
|
||||||
|
rt.stop();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Phase D — GEM300 in-the-loop (observe-and-report): job lifecycle, recipe
|
||||||
|
// downloads, and EC writes flow host -> engine -> Subscribe stream; the tool
|
||||||
|
// reports physical progress back and the engine drives the E40 FSM.
|
||||||
|
TEST_CASE("Phase D: jobs, recipes, and EC changes on the stream; ReportProcessJob") {
|
||||||
|
gem::EquipmentRuntime rt(test_config());
|
||||||
|
gem::register_default_handlers(rt);
|
||||||
|
dmn::EquipmentService svc(rt);
|
||||||
|
rt.run_async();
|
||||||
|
|
||||||
|
grpc::ServerBuilder builder;
|
||||||
|
builder.RegisterService(&svc);
|
||||||
|
std::unique_ptr<grpc::Server> server(builder.BuildAndStart());
|
||||||
|
REQUIRE(server);
|
||||||
|
auto stub = pb::Equipment::NewStub(server->InProcessChannel(grpc::ChannelArguments{}));
|
||||||
|
|
||||||
|
grpc::ClientContext sub_ctx;
|
||||||
|
pb::SubscribeRequest sreq;
|
||||||
|
auto reader = stub->Subscribe(&sub_ctx, sreq);
|
||||||
|
std::this_thread::sleep_for(std::chrono::milliseconds(100)); // registration
|
||||||
|
|
||||||
|
auto dispatch = [&](s2::Message msg) {
|
||||||
|
auto reply = rt.read_sync([&rt, &msg]() { return rt.router().dispatch(msg); });
|
||||||
|
REQUIRE(reply.has_value());
|
||||||
|
REQUIRE(reply->has_value());
|
||||||
|
};
|
||||||
|
|
||||||
|
// Host creates the PJ (S16F11), the tool sets up, the host starts it.
|
||||||
|
gem::PRJobCreateRequest pj{"PJ-D-1", gem::MaterialFlag::Substrate,
|
||||||
|
gem::ProcessRecipeMethod::RecipeOnly,
|
||||||
|
gem::RecipeSpec{"RECIPE-A", {}}, {"WFR-D-1"}, {}};
|
||||||
|
dispatch(gem::s16f11_pr_job_create(pj));
|
||||||
|
auto setup = rt.read_sync([&rt]() {
|
||||||
|
rt.model().process_jobs.fire_internal("PJ-D-1", gem::ProcessJobEvent::Select);
|
||||||
|
return rt.model().process_jobs.fire_internal("PJ-D-1",
|
||||||
|
gem::ProcessJobEvent::SetupComplete);
|
||||||
|
});
|
||||||
|
REQUIRE(setup.has_value());
|
||||||
|
REQUIRE(*setup);
|
||||||
|
dispatch(gem::s16f5_pr_job_command("PJ-D-1", "PJSTART"));
|
||||||
|
|
||||||
|
// The tool's stream receives the job with recipe + material bindings.
|
||||||
|
pb::HostRequest hr;
|
||||||
|
REQUIRE(reader->Read(&hr));
|
||||||
|
REQUIRE(hr.has_process_job());
|
||||||
|
CHECK(hr.process_job().job_id() == "PJ-D-1");
|
||||||
|
CHECK(hr.process_job().action() == pb::ProcessJob::START);
|
||||||
|
CHECK(hr.process_job().recipe() == "RECIPE-A");
|
||||||
|
REQUIRE(hr.process_job().carriers_size() == 1);
|
||||||
|
CHECK(hr.process_job().carriers(0) == "WFR-D-1");
|
||||||
|
|
||||||
|
// The tool finishes the physical work and reports; the engine's FSM follows.
|
||||||
|
{
|
||||||
|
grpc::ClientContext ctx;
|
||||||
|
pb::ProcessJobState rep;
|
||||||
|
pb::Ack ack;
|
||||||
|
rep.set_job_id("PJ-D-1");
|
||||||
|
rep.set_state(pb::ProcessJobState::COMPLETE);
|
||||||
|
REQUIRE(stub->ReportProcessJob(&ctx, rep, &ack).ok());
|
||||||
|
CHECK(ack.code() == pb::Ack::ACCEPT);
|
||||||
|
}
|
||||||
|
auto state = rt.read_sync(
|
||||||
|
[&rt]() { return rt.model().process_jobs.state("PJ-D-1"); });
|
||||||
|
REQUIRE(state.has_value());
|
||||||
|
CHECK(*state == gem::ProcessJobState::ProcessComplete);
|
||||||
|
|
||||||
|
// Reporting against an unknown job / an illegal transition is rejected.
|
||||||
|
{
|
||||||
|
grpc::ClientContext ctx;
|
||||||
|
pb::ProcessJobState rep;
|
||||||
|
pb::Ack ack;
|
||||||
|
rep.set_job_id("PJ-GHOST");
|
||||||
|
rep.set_state(pb::ProcessJobState::COMPLETE);
|
||||||
|
REQUIRE(stub->ReportProcessJob(&ctx, rep, &ack).ok());
|
||||||
|
CHECK(ack.code() == pb::Ack::INVALID_OBJECT);
|
||||||
|
}
|
||||||
|
{
|
||||||
|
grpc::ClientContext ctx;
|
||||||
|
pb::ProcessJobState rep;
|
||||||
|
pb::Ack ack;
|
||||||
|
rep.set_job_id("PJ-D-1"); // already ProcessComplete
|
||||||
|
rep.set_state(pb::ProcessJobState::COMPLETE);
|
||||||
|
REQUIRE(stub->ReportProcessJob(&ctx, rep, &ack).ok());
|
||||||
|
CHECK(ack.code() == pb::Ack::CANNOT_DO_NOW);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Host downloads a recipe (S7F3) -> ProcessProgram on the stream.
|
||||||
|
dispatch(gem::s7f3_process_program_send("RECIPE-NEW", "step1\nstep2\n"));
|
||||||
|
REQUIRE(reader->Read(&hr));
|
||||||
|
REQUIRE(hr.has_process_program());
|
||||||
|
CHECK(hr.process_program().ppid() == "RECIPE-NEW");
|
||||||
|
CHECK(hr.process_program().body() == "step1\nstep2\n");
|
||||||
|
|
||||||
|
// Host writes an EC (S2F15) -> ConstantChange with the configured name.
|
||||||
|
dispatch(gem::s2f15_ec_send({{10, s2::Item::u4(uint32_t{1})}}));
|
||||||
|
REQUIRE(reader->Read(&hr));
|
||||||
|
REQUIRE(hr.has_constant());
|
||||||
|
CHECK(hr.constant().name() == "TimeFormat");
|
||||||
|
CHECK(hr.constant().value().integer() == 1);
|
||||||
|
|
||||||
|
sub_ctx.TryCancel();
|
||||||
|
pb::HostRequest drain;
|
||||||
|
while (reader->Read(&drain)) {}
|
||||||
|
(void)reader->Finish();
|
||||||
|
server->Shutdown();
|
||||||
|
rt.stop();
|
||||||
|
}
|
||||||
|
|
||||||
|
// D10 + E16: carriers on the stream, Describe / FlushSpool / terminal.
|
||||||
|
TEST_CASE("carriers (D10) and the operations RPCs (E16)") {
|
||||||
|
gem::EquipmentRuntime rt(test_config());
|
||||||
|
gem::register_default_handlers(rt);
|
||||||
|
dmn::EquipmentService svc(rt);
|
||||||
|
rt.run_async();
|
||||||
|
|
||||||
|
grpc::ServerBuilder builder;
|
||||||
|
builder.RegisterService(&svc);
|
||||||
|
std::unique_ptr<grpc::Server> server(builder.BuildAndStart());
|
||||||
|
REQUIRE(server);
|
||||||
|
auto stub = pb::Equipment::NewStub(server->InProcessChannel(grpc::ChannelArguments{}));
|
||||||
|
|
||||||
|
grpc::ClientContext sub_ctx;
|
||||||
|
pb::SubscribeRequest sreq;
|
||||||
|
auto reader = stub->Subscribe(&sub_ctx, sreq);
|
||||||
|
std::this_thread::sleep_for(std::chrono::milliseconds(100));
|
||||||
|
|
||||||
|
auto report_carrier = [&](const std::string& cid, pb::CarrierState::State st,
|
||||||
|
std::vector<bool> slots = {}) {
|
||||||
|
grpc::ClientContext ctx;
|
||||||
|
pb::CarrierState req;
|
||||||
|
pb::Ack ack;
|
||||||
|
req.set_carrier_id(cid);
|
||||||
|
req.set_port(2);
|
||||||
|
req.set_state(st);
|
||||||
|
for (bool b : slots) req.add_slots(b);
|
||||||
|
REQUIRE(stub->ReportCarrier(&ctx, req, &ack).ok());
|
||||||
|
return ack.code();
|
||||||
|
};
|
||||||
|
|
||||||
|
// Tool announces an arrived FOUP with a slot map.
|
||||||
|
CHECK(report_carrier("CAR-D-1", pb::CarrierState::WAITING,
|
||||||
|
{true, true, false}) == pb::Ack::ACCEPT);
|
||||||
|
auto created = rt.read_sync([&rt]() {
|
||||||
|
const auto* c = rt.model().carriers.get("CAR-D-1");
|
||||||
|
return c && c->slots.size() == 3 && c->slots[0].state == 1 &&
|
||||||
|
c->slots[2].state == 0 && c->port_id == 2;
|
||||||
|
});
|
||||||
|
REQUIRE(created.has_value());
|
||||||
|
CHECK(*created);
|
||||||
|
|
||||||
|
// Host says ProceedWithCarrier (S3F17) -> tool stream gets PROCEED.
|
||||||
|
auto reply = rt.read_sync([&rt]() {
|
||||||
|
return rt.router().dispatch(
|
||||||
|
gem::s3f17_carrier_action(0u, "ProceedWithCarrier", "CAR-D-1", {}));
|
||||||
|
});
|
||||||
|
REQUIRE(reply.has_value());
|
||||||
|
REQUIRE(reply->has_value());
|
||||||
|
pb::HostRequest hr;
|
||||||
|
REQUIRE(reader->Read(&hr));
|
||||||
|
REQUIRE(hr.has_carrier());
|
||||||
|
CHECK(hr.carrier().carrier_id() == "CAR-D-1");
|
||||||
|
CHECK(hr.carrier().port() == 2);
|
||||||
|
CHECK(hr.carrier().action() == pb::CarrierAction::PROCEED);
|
||||||
|
|
||||||
|
// Tool drives access: begin + end; reporting against an unknown id fails.
|
||||||
|
CHECK(report_carrier("CAR-D-1", pb::CarrierState::IN_ACCESS) == pb::Ack::ACCEPT);
|
||||||
|
CHECK(report_carrier("CAR-D-1", pb::CarrierState::COMPLETE) == pb::Ack::ACCEPT);
|
||||||
|
CHECK(report_carrier("CAR-GHOST", pb::CarrierState::IN_ACCESS) ==
|
||||||
|
pb::Ack::INVALID_OBJECT);
|
||||||
|
|
||||||
|
// Host cancels CAR-D-1 (Confirmed -> NotConfirmed is a real transition, so
|
||||||
|
// the observer fires) -> CANCEL on the stream. NOTE: a CancelCarrier on a
|
||||||
|
// still-NotConfirmed carrier is a self-transition the FSM doesn't signal,
|
||||||
|
// so the tool isn't told — a known E87 edge (see DAEMON_ROADMAP).
|
||||||
|
(void)rt.read_sync([&rt]() {
|
||||||
|
return rt.router().dispatch(
|
||||||
|
gem::s3f17_carrier_action(0u, "CancelCarrier", "CAR-D-1", {}));
|
||||||
|
});
|
||||||
|
REQUIRE(reader->Read(&hr));
|
||||||
|
REQUIRE(hr.has_carrier());
|
||||||
|
CHECK(hr.carrier().action() == pb::CarrierAction::CANCEL);
|
||||||
|
|
||||||
|
// ---- E16: Describe / FlushSpool / SendTerminalMessage --------------------
|
||||||
|
{
|
||||||
|
grpc::ClientContext ctx;
|
||||||
|
pb::Empty req;
|
||||||
|
pb::EquipmentDescription d;
|
||||||
|
REQUIRE(stub->Describe(&ctx, req, &d).ok());
|
||||||
|
CHECK(d.model_name() == "SECSGEM-SIM");
|
||||||
|
auto has = [](const auto& list, const std::string& want) {
|
||||||
|
for (const auto& e : list)
|
||||||
|
if (e == want) return true;
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
CHECK(has(d.variables(), "ChamberPressure"));
|
||||||
|
CHECK(has(d.events(), "ProcessStarted"));
|
||||||
|
CHECK(has(d.alarms(), "chiller_temp_high"));
|
||||||
|
CHECK(has(d.commands(), "START"));
|
||||||
|
CHECK(has(d.constants(), "TimeFormat"));
|
||||||
|
}
|
||||||
|
{
|
||||||
|
// No host: a fired event spools (stream 6 is spoolable); purge empties it.
|
||||||
|
grpc::ClientContext fctx;
|
||||||
|
pb::Event ev;
|
||||||
|
pb::Ack ack;
|
||||||
|
ev.set_name("ProcessStarted");
|
||||||
|
// Enable the event first so emit isn't suppressed.
|
||||||
|
(void)rt.read_sync([&rt]() {
|
||||||
|
return rt.model().enable_events(true, {300});
|
||||||
|
});
|
||||||
|
REQUIRE(stub->FireEvent(&fctx, ev, &ack).ok());
|
||||||
|
std::this_thread::sleep_for(std::chrono::milliseconds(100));
|
||||||
|
auto depth = rt.read_sync([&rt]() { return rt.model().spool.size(); });
|
||||||
|
REQUIRE(depth.has_value());
|
||||||
|
CHECK(*depth == 1);
|
||||||
|
|
||||||
|
grpc::ClientContext pctx;
|
||||||
|
pb::SpoolFlushRequest freq;
|
||||||
|
pb::Ack fack;
|
||||||
|
freq.set_purge(true);
|
||||||
|
REQUIRE(stub->FlushSpool(&pctx, freq, &fack).ok());
|
||||||
|
CHECK(fack.code() == pb::Ack::ACCEPT);
|
||||||
|
depth = rt.read_sync([&rt]() { return rt.model().spool.size(); });
|
||||||
|
CHECK(*depth == 0);
|
||||||
|
}
|
||||||
|
{
|
||||||
|
// Stream 10 is not spoolable and no host is connected -> honest refusal.
|
||||||
|
grpc::ClientContext ctx;
|
||||||
|
pb::TerminalMessage req;
|
||||||
|
pb::Ack ack;
|
||||||
|
req.set_tid(0);
|
||||||
|
req.set_text("hello fab");
|
||||||
|
REQUIRE(stub->SendTerminalMessage(&ctx, req, &ack).ok());
|
||||||
|
CHECK(ack.code() == pb::Ack::CANNOT_DO_NOW);
|
||||||
|
}
|
||||||
|
|
||||||
|
sub_ctx.TryCancel();
|
||||||
|
pb::HostRequest drain;
|
||||||
|
while (reader->Read(&drain)) {}
|
||||||
|
(void)reader->Finish();
|
||||||
|
server->Shutdown();
|
||||||
|
rt.stop();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Randomized concurrent stress: several client threads fire a seeded random
|
||||||
|
// mix of RPCs against the live service while the io thread runs — the
|
||||||
|
// strongest TSan target we have, and a probe for ordering/lifetime bugs the
|
||||||
|
// scenario tests can't reach. Failures print the seed for reproduction.
|
||||||
|
TEST_CASE("randomized concurrent RPC stress (seeded)") {
|
||||||
|
const unsigned seed = static_cast<unsigned>(
|
||||||
|
std::random_device{}()); // logged below; rerun by hardcoding it
|
||||||
|
INFO("stress seed = " << seed);
|
||||||
|
|
||||||
|
gem::EquipmentRuntime rt(test_config());
|
||||||
|
gem::register_default_handlers(rt);
|
||||||
|
dmn::EquipmentService svc(rt);
|
||||||
|
rt.run_async();
|
||||||
|
|
||||||
|
grpc::ServerBuilder builder;
|
||||||
|
builder.RegisterService(&svc);
|
||||||
|
std::unique_ptr<grpc::Server> server(builder.BuildAndStart());
|
||||||
|
REQUIRE(server);
|
||||||
|
auto stub = pb::Equipment::NewStub(server->InProcessChannel(grpc::ChannelArguments{}));
|
||||||
|
|
||||||
|
constexpr int kThreads = 4;
|
||||||
|
constexpr int kOpsPerThread = 250;
|
||||||
|
std::atomic<int> failures{0};
|
||||||
|
|
||||||
|
auto worker = [&](unsigned tseed) {
|
||||||
|
std::mt19937 rng(tseed);
|
||||||
|
std::uniform_int_distribution<int> op(0, 7);
|
||||||
|
std::uniform_real_distribution<double> val(0.0, 10.0);
|
||||||
|
for (int i = 0; i < kOpsPerThread; ++i) {
|
||||||
|
grpc::ClientContext ctx;
|
||||||
|
pb::Ack ack;
|
||||||
|
grpc::Status st = grpc::Status::OK;
|
||||||
|
switch (op(rng)) {
|
||||||
|
case 0: {
|
||||||
|
pb::VariableUpdate r;
|
||||||
|
(*r.mutable_values())["ChamberPressure"].set_real(val(rng));
|
||||||
|
st = stub->SetVariables(&ctx, r, &ack);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 1: {
|
||||||
|
pb::VariableQuery r;
|
||||||
|
pb::VariableSnapshot snap;
|
||||||
|
st = stub->GetVariables(&ctx, r, &snap);
|
||||||
|
if (st.ok() && snap.values().empty()) ++failures; // config never empty
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 2: {
|
||||||
|
pb::Event r;
|
||||||
|
r.set_name("ProcessStarted");
|
||||||
|
st = stub->FireEvent(&ctx, r, &ack);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 3: {
|
||||||
|
pb::Alarm r;
|
||||||
|
r.set_name("chiller_temp_high");
|
||||||
|
st = stub->SetAlarm(&ctx, r, &ack);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 4: {
|
||||||
|
pb::Alarm r;
|
||||||
|
r.set_name("chiller_temp_high");
|
||||||
|
st = stub->ClearAlarm(&ctx, r, &ack);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 5: {
|
||||||
|
pb::Empty r;
|
||||||
|
pb::ControlState cs;
|
||||||
|
st = stub->GetControlState(&ctx, r, &cs);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 6: {
|
||||||
|
pb::Empty r;
|
||||||
|
pb::EquipmentDescription d;
|
||||||
|
st = stub->Describe(&ctx, r, &d);
|
||||||
|
if (st.ok() && d.variables_size() == 0) ++failures;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 7: {
|
||||||
|
// Subscribe/cancel churn: exercises subscriber add/remove under load.
|
||||||
|
grpc::ClientContext sctx;
|
||||||
|
pb::SubscribeRequest sr;
|
||||||
|
auto rd = stub->Subscribe(&sctx, sr);
|
||||||
|
sctx.TryCancel();
|
||||||
|
pb::HostRequest hr;
|
||||||
|
while (rd->Read(&hr)) {}
|
||||||
|
(void)rd->Finish();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!st.ok()) ++failures;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
std::vector<std::thread> threads;
|
||||||
|
for (int t = 0; t < kThreads; ++t) threads.emplace_back(worker, seed + t);
|
||||||
|
for (auto& th : threads) th.join();
|
||||||
|
|
||||||
|
CHECK(failures.load() == 0);
|
||||||
|
// The engine must still be fully responsive afterwards.
|
||||||
|
grpc::ClientContext ctx;
|
||||||
|
pb::Empty req;
|
||||||
|
pb::ControlState cs;
|
||||||
|
CHECK(stub->GetControlState(&ctx, req, &cs).ok());
|
||||||
|
|
||||||
|
server->Shutdown();
|
||||||
|
rt.stop();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Phase 16 tail: E90 substrate + E157 module reporting (observe-and-report).
|
||||||
|
TEST_CASE("ReportSubstrate (E90) and ReportModule (E157) drive the FSMs") {
|
||||||
|
gem::EquipmentRuntime rt(test_config());
|
||||||
|
gem::register_default_handlers(rt);
|
||||||
|
dmn::EquipmentService svc(rt);
|
||||||
|
rt.run_async();
|
||||||
|
|
||||||
|
grpc::ServerBuilder builder;
|
||||||
|
builder.RegisterService(&svc);
|
||||||
|
std::unique_ptr<grpc::Server> server(builder.BuildAndStart());
|
||||||
|
REQUIRE(server);
|
||||||
|
auto stub = pb::Equipment::NewStub(server->InProcessChannel(grpc::ChannelArguments{}));
|
||||||
|
|
||||||
|
auto sub = [&](const std::string& sid, pb::SubstrateReport::Milestone m) {
|
||||||
|
grpc::ClientContext ctx;
|
||||||
|
pb::SubstrateReport req;
|
||||||
|
pb::Ack ack;
|
||||||
|
req.set_substrate_id(sid);
|
||||||
|
req.set_milestone(m);
|
||||||
|
REQUIRE(stub->ReportSubstrate(&ctx, req, &ack).ok());
|
||||||
|
return ack.code();
|
||||||
|
};
|
||||||
|
|
||||||
|
auto sub_with = [&](const std::string& sid, pb::SubstrateReport::Milestone m,
|
||||||
|
const std::string& cid, uint32_t slot) {
|
||||||
|
grpc::ClientContext ctx;
|
||||||
|
pb::SubstrateReport req;
|
||||||
|
pb::Ack ack;
|
||||||
|
req.set_substrate_id(sid);
|
||||||
|
req.set_milestone(m);
|
||||||
|
req.set_carrier_id(cid);
|
||||||
|
req.set_slot(slot);
|
||||||
|
REQUIRE(stub->ReportSubstrate(&ctx, req, &ack).ok());
|
||||||
|
return ack.code();
|
||||||
|
};
|
||||||
|
|
||||||
|
// A wafer's journey: arrive -> picked up -> process -> done -> deposited.
|
||||||
|
// ARRIVED also records where the wafer came from (carrier + slot).
|
||||||
|
CHECK(sub_with("WFR-1", pb::SubstrateReport::ARRIVED, "FOUP-7", 3) == pb::Ack::ACCEPT);
|
||||||
|
auto origin = rt.read_sync([&rt]() {
|
||||||
|
const auto* s = rt.model().substrates.get("WFR-1");
|
||||||
|
return s ? std::make_pair(s->carrierid, s->slot)
|
||||||
|
: std::make_pair(std::string{}, uint8_t{0});
|
||||||
|
});
|
||||||
|
REQUIRE(origin.has_value());
|
||||||
|
CHECK(origin->first == "FOUP-7");
|
||||||
|
CHECK(static_cast<int>(origin->second) == 3);
|
||||||
|
CHECK(sub("WFR-1", pb::SubstrateReport::AT_WORK) == pb::Ack::ACCEPT);
|
||||||
|
CHECK(sub("WFR-1", pb::SubstrateReport::PROCESSING) == pb::Ack::ACCEPT);
|
||||||
|
CHECK(sub("WFR-1", pb::SubstrateReport::PROCESSED) == pb::Ack::ACCEPT);
|
||||||
|
CHECK(sub("WFR-1", pb::SubstrateReport::AT_DESTINATION) == pb::Ack::ACCEPT);
|
||||||
|
auto loc = rt.read_sync([&rt]() {
|
||||||
|
const auto* s = rt.model().substrates.get("WFR-1");
|
||||||
|
return s ? s->fsm->location_state() : gem::SubstrateState::NoState;
|
||||||
|
});
|
||||||
|
REQUIRE(loc.has_value());
|
||||||
|
CHECK(*loc == gem::SubstrateState::AtDestination);
|
||||||
|
|
||||||
|
// Reporting on a substrate that never ARRIVED is rejected (no such object).
|
||||||
|
CHECK(sub("WFR-GHOST", pb::SubstrateReport::AT_WORK) == pb::Ack::INVALID_OBJECT);
|
||||||
|
// Duplicate ARRIVED is a conflict, not a missing object: CANNOT_DO_NOW so we
|
||||||
|
// never silently wipe the existing wafer's state/history.
|
||||||
|
CHECK(sub("WFR-1", pb::SubstrateReport::ARRIVED) == pb::Ack::CANNOT_DO_NOW);
|
||||||
|
|
||||||
|
auto mod = [&](const std::string& mid, pb::ModuleReport::State st) {
|
||||||
|
grpc::ClientContext ctx;
|
||||||
|
pb::ModuleReport req;
|
||||||
|
pb::Ack ack;
|
||||||
|
req.set_module_id(mid);
|
||||||
|
req.set_state(st);
|
||||||
|
REQUIRE(stub->ReportModule(&ctx, req, &ack).ok());
|
||||||
|
return ack.code();
|
||||||
|
};
|
||||||
|
|
||||||
|
// Module: auto-created, then walked General -> Step -> StepCompleted -> Reset.
|
||||||
|
CHECK(mod("MOD-1", pb::ModuleReport::GENERAL_EXECUTING) == pb::Ack::ACCEPT);
|
||||||
|
CHECK(mod("MOD-1", pb::ModuleReport::STEP_EXECUTING) == pb::Ack::ACCEPT);
|
||||||
|
CHECK(mod("MOD-1", pb::ModuleReport::STEP_COMPLETED) == pb::Ack::ACCEPT);
|
||||||
|
auto mstate = rt.read_sync([&rt]() {
|
||||||
|
const auto* m = rt.model().modules.get("MOD-1");
|
||||||
|
return m ? m->fsm->state() : gem::ModuleState::NotExecuting;
|
||||||
|
});
|
||||||
|
REQUIRE(mstate.has_value());
|
||||||
|
CHECK(*mstate == gem::ModuleState::StepCompleted);
|
||||||
|
|
||||||
|
// NOT_EXECUTING resets the module back to idle (re-usable for the next wafer).
|
||||||
|
CHECK(mod("MOD-1", pb::ModuleReport::NOT_EXECUTING) == pb::Ack::ACCEPT);
|
||||||
|
auto reset = rt.read_sync([&rt]() {
|
||||||
|
const auto* m = rt.model().modules.get("MOD-1");
|
||||||
|
return m ? m->fsm->state() : gem::ModuleState::NoState;
|
||||||
|
});
|
||||||
|
REQUIRE(reset.has_value());
|
||||||
|
CHECK(*reset == gem::ModuleState::NotExecuting);
|
||||||
|
|
||||||
|
// An illegal jump (StepExecuting straight from a fresh NotExecuting module)
|
||||||
|
// is rejected by the E157 table.
|
||||||
|
CHECK(mod("MOD-2", pb::ModuleReport::STEP_EXECUTING) == pb::Ack::CANNOT_DO_NOW);
|
||||||
|
|
||||||
|
server->Shutdown();
|
||||||
|
rt.stop();
|
||||||
|
}
|
||||||
|
|||||||
@@ -444,3 +444,35 @@ TEST_CASE("spool persistence: clear deletes files") {
|
|||||||
|
|
||||||
fs::remove_all(dir);
|
fs::remove_all(dir);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
TEST_CASE("RecipeStore added-observer fires after the store is updated") {
|
||||||
|
RecipeStore r;
|
||||||
|
std::string seen_ppid, seen_body;
|
||||||
|
r.add_added_handler([&](const std::string& p, const std::string& b) {
|
||||||
|
seen_ppid = p;
|
||||||
|
seen_body = b;
|
||||||
|
});
|
||||||
|
r.add("R-1", "body");
|
||||||
|
CHECK(seen_ppid == "R-1");
|
||||||
|
CHECK(seen_body == "body");
|
||||||
|
CHECK(r.get("R-1").has_value()); // observer saw the post-update store
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_CASE("EquipmentConstantStore changed-observer fires on Accept only") {
|
||||||
|
EquipmentConstantStore ecids;
|
||||||
|
ecids.add({10, "Knob", "", s2::Item::u4(uint32_t{1}),
|
||||||
|
s2::Item::u4(uint32_t{1}), "0", "5"});
|
||||||
|
int fired = 0;
|
||||||
|
ecids.add_changed_handler([&](uint32_t id, const s2::Item& v) {
|
||||||
|
++fired;
|
||||||
|
CHECK(id == 10);
|
||||||
|
CHECK(v == s2::Item::u4(uint32_t{3}));
|
||||||
|
});
|
||||||
|
CHECK(ecids.set_value(10, s2::Item::u4(uint32_t{3})) == EquipmentAck::Accept);
|
||||||
|
CHECK(fired == 1);
|
||||||
|
CHECK(ecids.set_value(10, s2::Item::u4(uint32_t{99})) ==
|
||||||
|
EquipmentAck::Denied_OutOfRange);
|
||||||
|
CHECK(ecids.set_value(999, s2::Item::u4(uint32_t{1})) ==
|
||||||
|
EquipmentAck::Denied_UnknownEcid);
|
||||||
|
CHECK(fired == 1); // rejected writes never fire
|
||||||
|
}
|
||||||
|
|||||||
@@ -71,3 +71,31 @@ TEST_CASE("register_default_handlers: unknown command is rejected, hook not invo
|
|||||||
CHECK(reply->stream == 2);
|
CHECK(reply->stream == 2);
|
||||||
CHECK(reply->function == 42); // still an S2F42, carrying an error HCACK
|
CHECK(reply->function == 42); // still an S2F42, carrying an error HCACK
|
||||||
}
|
}
|
||||||
|
|
||||||
|
TEST_CASE("capability registration is subsettable (the point of the decomposition)") {
|
||||||
|
gem::EquipmentRuntime rt(test_config());
|
||||||
|
gem::register_identification(rt);
|
||||||
|
gem::register_terminal_services(rt);
|
||||||
|
|
||||||
|
// What we registered answers...
|
||||||
|
CHECK(rt.router().has_handler(1, 1)); // S1F1
|
||||||
|
CHECK(rt.router().has_handler(1, 13)); // S1F13
|
||||||
|
CHECK(rt.router().has_handler(10, 1)); // S10F1
|
||||||
|
// ...and what we didn't, doesn't: a sensor-class tool with no remote
|
||||||
|
// commands, jobs, or carriers simply never registers them.
|
||||||
|
CHECK_FALSE(rt.router().has_handler(2, 41)); // remote commands
|
||||||
|
CHECK_FALSE(rt.router().has_handler(16, 11)); // E40 jobs
|
||||||
|
CHECK_FALSE(rt.router().has_handler(3, 17)); // E87 carriers
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_CASE("role bindings drive the CJ state-change CEIDs and SVID refresh") {
|
||||||
|
gem::EquipmentRuntime rt(test_config());
|
||||||
|
gem::register_default_handlers(rt);
|
||||||
|
|
||||||
|
// The shipped roles block binds control_state_svid: 1 — S1F3 refreshes it.
|
||||||
|
auto reply = rt.router().dispatch(gem::s1f3_selected_status_request({1}));
|
||||||
|
REQUIRE(reply.has_value());
|
||||||
|
auto sv = rt.model().svids.value(1);
|
||||||
|
REQUIRE(sv.has_value());
|
||||||
|
CHECK(sv->as_ascii() == std::string("HostOffline")); // initial control state
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,224 @@
|
|||||||
|
// Table-driven conformance sweep over the default GEM handler set.
|
||||||
|
//
|
||||||
|
// The live demo and the external interop harnesses cover the happy path with
|
||||||
|
// weak assertions ("client exits 0"); this file drives EVERY registered
|
||||||
|
// (stream, function) through Router::dispatch in-process and asserts the
|
||||||
|
// reply envelope (same stream, function+1, body present). Ordered as one
|
||||||
|
// scenario so stateful handlers (event-report config, recipes, PJ/CJ,
|
||||||
|
// carriers) see the prerequisites they need — the same flow secs_conformance
|
||||||
|
// runs over a live socket, but cheap enough to run on every build.
|
||||||
|
|
||||||
|
#include <doctest/doctest.h>
|
||||||
|
|
||||||
|
#include <set>
|
||||||
|
#include <string>
|
||||||
|
#include <utility>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
#include "secsgem/gem/default_handlers.hpp"
|
||||||
|
#include "secsgem/gem/messages.hpp"
|
||||||
|
#include "secsgem/gem/runtime.hpp"
|
||||||
|
#include "secsgem/secs2/codec.hpp"
|
||||||
|
#include "secsgem/secs2/item.hpp"
|
||||||
|
|
||||||
|
using namespace secsgem;
|
||||||
|
namespace gem = secsgem::gem;
|
||||||
|
namespace s2 = secsgem::secs2;
|
||||||
|
|
||||||
|
#ifndef SECSGEM_DATA_DIR
|
||||||
|
#error "SECSGEM_DATA_DIR not defined; see CMakeLists.txt"
|
||||||
|
#endif
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
|
||||||
|
gem::EquipmentRuntime::Config test_config() {
|
||||||
|
gem::EquipmentRuntime::Config c;
|
||||||
|
c.equipment_yaml = SECSGEM_DATA_DIR "/equipment.yaml";
|
||||||
|
c.control_state_yaml = SECSGEM_DATA_DIR "/control_state.yaml";
|
||||||
|
c.process_job_yaml = SECSGEM_DATA_DIR "/process_job_state.yaml";
|
||||||
|
c.control_job_yaml = SECSGEM_DATA_DIR "/control_job_state.yaml";
|
||||||
|
c.port = 0;
|
||||||
|
return c;
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
TEST_CASE("conformance sweep: every default handler answers with the paired reply") {
|
||||||
|
gem::EquipmentRuntime rt(test_config());
|
||||||
|
gem::register_default_handlers(rt);
|
||||||
|
|
||||||
|
std::set<std::pair<int, int>> covered;
|
||||||
|
|
||||||
|
// Dispatch `req`, assert the reply is (same stream, function+1) with a
|
||||||
|
// body, record coverage, hand back the reply for targeted extra checks.
|
||||||
|
auto expect = [&](const char* label, s2::Message req) -> s2::Message {
|
||||||
|
INFO(label);
|
||||||
|
auto reply = rt.router().dispatch(req);
|
||||||
|
REQUIRE_MESSAGE(reply.has_value(), label << ": no reply");
|
||||||
|
CHECK_MESSAGE(reply->stream == req.stream, label);
|
||||||
|
CHECK_MESSAGE(reply->function == req.function + 1, label);
|
||||||
|
CHECK_MESSAGE(reply->body.has_value(), label << ": reply has no body");
|
||||||
|
covered.insert({req.stream, req.function});
|
||||||
|
return *reply;
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---- S1: identification / comms / control state -------------------------
|
||||||
|
expect("S1F1 Are You There", gem::s1f1_are_you_there());
|
||||||
|
expect("S1F13 EstablishComms", gem::s1f13_establish_comms("HOST", "1.0"));
|
||||||
|
expect("S1F17 Request ONLINE", gem::s1f17_request_online());
|
||||||
|
CHECK(rt.control_state() == gem::ControlState::OnlineRemote);
|
||||||
|
expect("S1F3 Status (all)", gem::s1f3_selected_status_request({}));
|
||||||
|
expect("S1F11 SV namelist", gem::s1f11_status_namelist_request({}));
|
||||||
|
expect("S1F19 GEM compliance", gem::s1f19_get_gem_compliance_request());
|
||||||
|
expect("S1F21 DV namelist", gem::s1f21_data_variable_namelist_request({}));
|
||||||
|
expect("S1F23 CE namelist", gem::s1f23_collection_event_namelist_request({}));
|
||||||
|
|
||||||
|
// ---- S2: ECs / clock / event-report config / commands / limits ----------
|
||||||
|
expect("S2F13 EC request", gem::s2f13_ec_request({}));
|
||||||
|
expect("S2F15 EC send", gem::s2f15_ec_send({{10, s2::Item::u4(uint32_t{1})}}));
|
||||||
|
expect("S2F17 Date-time req", gem::s2f17_date_time_request());
|
||||||
|
expect("S2F29 EC namelist", gem::s2f29_ec_namelist_request({}));
|
||||||
|
expect("S2F31 Date-time set", gem::s2f31_date_time_set("2026061012000000"));
|
||||||
|
expect("S2F33 DefineReport", gem::s2f33_define_report(1, {{1, {2}}}));
|
||||||
|
expect("S2F35 LinkEvent", gem::s2f35_link_event_report(1, {{300, {1}}}));
|
||||||
|
expect("S2F37 EnableEvent", gem::s2f37_enable_event(true, {300}));
|
||||||
|
expect("S2F41 HostCommand", gem::s2f41_host_command("START", {}));
|
||||||
|
expect("S2F21 RemoteCommand", gem::s2f21_remote_command("STOP"));
|
||||||
|
expect("S2F49 Enhanced cmd", gem::s2f49_enhanced_host_command(0u, "EQUIPMENT", "NOP", {}));
|
||||||
|
expect("S2F23 Trace init", gem::s2f23_trace_initialize_send(8001, "000001", 0, 1, {1}));
|
||||||
|
expect("S2F45 Define limits", gem::s2f45_define_variable_limits(
|
||||||
|
7, {{100, {{0, s2::Item::u4(uint32_t{1000}),
|
||||||
|
s2::Item::u4(uint32_t{0})}}}}));
|
||||||
|
expect("S2F47 Limits request", gem::s2f47_variable_limit_attribute_request({}));
|
||||||
|
expect("S2F43 Reset spooling", gem::s2f43_reset_spooling({5, 6}));
|
||||||
|
|
||||||
|
// ---- S5: alarms + exceptions ---------------------------------------------
|
||||||
|
expect("S5F3 Enable alarm", gem::s5f3_enable_alarm(gem::kAlarmEnableByte, 1));
|
||||||
|
expect("S5F5 List alarms", gem::s5f5_list_alarms_request({}));
|
||||||
|
expect("S5F7 Enabled alarms", gem::s5f7_list_enabled_alarms_request());
|
||||||
|
expect("S5F13 Exc recover", gem::s5f13_exception_recover_request(999, "NOP"));
|
||||||
|
expect("S5F17 Exc rec abort", gem::s5f17_exception_recover_abort_request(999));
|
||||||
|
|
||||||
|
// ---- S6: event reports / spool -------------------------------------------
|
||||||
|
expect("S6F5 Multi-block inq", gem::s6f5_multi_block_inquire(42, 2048));
|
||||||
|
expect("S6F15 Event rpt req", gem::s6f15_event_report_request(300));
|
||||||
|
expect("S6F19 Individual rpt", gem::s6f19_individual_report_request(1));
|
||||||
|
expect("S6F21 Annotated rpt", gem::s6f21_annotated_report_request(1));
|
||||||
|
expect("S6F23 Spool data req", gem::s6f23_request_spool_data(gem::SpoolRequestCode::Transmit));
|
||||||
|
|
||||||
|
// ---- S7: process programs -------------------------------------------------
|
||||||
|
expect("S7F1 PP load inquire", gem::s7f1_pp_load_inquire("RECIPE-X", 64u));
|
||||||
|
expect("S7F3 PP send", gem::s7f3_process_program_send("RECIPE-NEW", "step1\n"));
|
||||||
|
expect("S7F5 PP request", gem::s7f5_process_program_request("RECIPE-A"));
|
||||||
|
expect("S7F19 EPPD request", gem::s7f19_current_eppd_request());
|
||||||
|
expect("S7F17 PP delete", gem::s7f17_delete_pp_send({"RECIPE-NEW"}));
|
||||||
|
|
||||||
|
// ---- S10: terminal services -----------------------------------------------
|
||||||
|
expect("S10F1 Terminal single", gem::s10f1_terminal_display_single(0, "probe"));
|
||||||
|
expect("S10F3 Terminal single", gem::s10f3_terminal_display_single(0, "probe"));
|
||||||
|
expect("S10F5 Terminal multi", gem::s10f5_terminal_display_multi(0, {"l1", "l2"}));
|
||||||
|
|
||||||
|
// ---- S14/S16: E39 objects + E40/E94 jobs ----------------------------------
|
||||||
|
expect("S14F1 GetAttr", gem::s14f1_get_attr("OBJ", "EQUIPMENT", {}));
|
||||||
|
expect("S14F3 SetAttr", gem::s14f3_set_attr("LP-1", "MaterialLocation",
|
||||||
|
{{"PORT_STATE", s2::Item::ascii("OPEN")}}));
|
||||||
|
|
||||||
|
gem::PRJobCreateRequest pj_req{
|
||||||
|
"PJ-T-1", gem::MaterialFlag::Substrate,
|
||||||
|
gem::ProcessRecipeMethod::RecipeOnly,
|
||||||
|
gem::RecipeSpec{"RECIPE-A", {}}, {"WFR-1"}, {}};
|
||||||
|
auto r16f12 = expect("S16F11 PRJobCreate", gem::s16f11_pr_job_create(pj_req));
|
||||||
|
(void)r16f12;
|
||||||
|
CHECK(rt.model().process_jobs.has("PJ-T-1"));
|
||||||
|
|
||||||
|
expect("S16F7 PRJobMonitor", gem::s16f7_pr_job_monitor({{"PJ-T-1", gem::kAlarmEnableByte}}));
|
||||||
|
expect("S16F15 PRJob multi", gem::s16f15_pr_job_create_multi({{"PJ-M-1", "RECIPE-A", {"W2"}}}));
|
||||||
|
expect("S14F9 CreateCJ", gem::s14f9_create_control_job("CJ-T-1", {"PJ-T-1"}));
|
||||||
|
expect("S16F27 CJ command", gem::s16f27_cj_command("CJ-T-1", "CJSTOP"));
|
||||||
|
expect("S14F11 DeleteCJ", gem::s14f11_delete_control_job("CJ-T-1"));
|
||||||
|
expect("S16F5 PRJobCommand", gem::s16f5_pr_job_command("PJ-T-1", "PJABORT"));
|
||||||
|
expect("S16F13 PRJobDequeue", gem::s16f13_pr_job_dequeue("PJ-M-1"));
|
||||||
|
|
||||||
|
// ---- S3: E87 carriers -------------------------------------------------------
|
||||||
|
expect("S3F17 CarrierAction", gem::s3f17_carrier_action(0u, "ProceedWithCarrier",
|
||||||
|
"CAR-T-1", {}));
|
||||||
|
expect("S3F19 SlotMapVerify", gem::s3f19_slot_map_verify("CAR-T-1", {}));
|
||||||
|
expect("S3F25 CarrierTransfer", gem::s3f25_carrier_transfer("CAR-T-1", 1, 2));
|
||||||
|
expect("S3F27 CancelCarrier", gem::s3f27_cancel_carrier("CAR-T-1"));
|
||||||
|
|
||||||
|
// ---- S1F15 last: takes the equipment OFFLINE ------------------------------
|
||||||
|
expect("S1F15 Request OFFLINE", gem::s1f15_request_offline());
|
||||||
|
CHECK(rt.control_state() == gem::ControlState::HostOffline);
|
||||||
|
|
||||||
|
// ---- coverage + fallback ---------------------------------------------------
|
||||||
|
// 53 of the 56 registered handlers are reachable via paired request/reply
|
||||||
|
// dispatch here. The remaining three need a live wire or daemon context.
|
||||||
|
CHECK_MESSAGE(covered.size() >= 53, "covered " << covered.size() << " handlers");
|
||||||
|
|
||||||
|
// Unregistered primaries with W=1 must come back as SxF0 (abort), per the
|
||||||
|
// Router's E5 default.
|
||||||
|
auto abort_reply = rt.router().dispatch(s2::Message(1, 99, true));
|
||||||
|
REQUIRE(abort_reply.has_value());
|
||||||
|
CHECK(abort_reply->stream == 1);
|
||||||
|
CHECK(abort_reply->function == 0);
|
||||||
|
|
||||||
|
rt.poll(); // drain any emit_event side effects (e.g. START's CEID 300)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- message-level golden frame -------------------------------------------
|
||||||
|
// Hand-computed from the E5 encoding rules (NOT generated by our codec), so
|
||||||
|
// it is an external pin on message composition: format byte = (code<<2) |
|
||||||
|
// n-length-bytes; List=0o00, ASCII=0o20.
|
||||||
|
// L[2] -> 0x01 0x02
|
||||||
|
// A "HOST" -> 0x41 0x04 'H' 'O' 'S' 'T'
|
||||||
|
// A "1.0" -> 0x41 0x03 '1' '.' '0'
|
||||||
|
TEST_CASE("golden frame: S1F13 body encodes to the hand-computed E5 bytes") {
|
||||||
|
auto msg = gem::s1f13_establish_comms("HOST", "1.0");
|
||||||
|
REQUIRE(msg.body.has_value());
|
||||||
|
const std::vector<uint8_t> expected = {
|
||||||
|
0x01, 0x02,
|
||||||
|
0x41, 0x04, 'H', 'O', 'S', 'T',
|
||||||
|
0x41, 0x03, '1', '.', '0',
|
||||||
|
};
|
||||||
|
CHECK(s2::encode(*msg.body) == expected);
|
||||||
|
CHECK(msg.stream == 1);
|
||||||
|
CHECK(msg.function == 13);
|
||||||
|
CHECK(msg.reply_expected);
|
||||||
|
}
|
||||||
|
|
||||||
|
// S5F1 = L[3]{ Binary ALCD, U4 ALID, ASCII ALTX }. Format bytes per E5:
|
||||||
|
// Binary(0o10=8)->0x21, U4(0o54=44)->0xB1, ASCII(0o20=16)->0x41 (1 len byte).
|
||||||
|
TEST_CASE("golden frame: S5F1 alarm report encodes to the hand-computed E5 bytes") {
|
||||||
|
auto msg = gem::s5f1_alarm_report(0x84, 1, "Chiller Temp High");
|
||||||
|
REQUIRE(msg.body.has_value());
|
||||||
|
std::vector<uint8_t> expected = {
|
||||||
|
0x01, 0x03, // L/3
|
||||||
|
0x21, 0x01, 0x84, // B ALCD = set|category4
|
||||||
|
0xB1, 0x04, 0x00, 0x00, 0x00, 0x01, // U4 ALID = 1
|
||||||
|
0x41, 0x11, // A/17
|
||||||
|
};
|
||||||
|
for (char c : std::string("Chiller Temp High")) expected.push_back(c);
|
||||||
|
CHECK(s2::encode(*msg.body) == expected);
|
||||||
|
CHECK(msg.stream == 5);
|
||||||
|
CHECK(msg.function == 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Composed S6F11 = L[3]{ U4 DATAID, U4 CEID, L reports[ L[2]{U4 RPTID,
|
||||||
|
// L values} ] } — the production-critical event-report shape, pinned for one
|
||||||
|
// report (RPTID 1) carrying one ASCII value.
|
||||||
|
TEST_CASE("golden frame: composed S6F11 encodes to the hand-computed E5 bytes") {
|
||||||
|
std::vector<gem::ReportData> reports{{1, {s2::Item::ascii("x")}}};
|
||||||
|
auto msg = gem::s6f11_event_report(0, 300, reports);
|
||||||
|
REQUIRE(msg.body.has_value());
|
||||||
|
const std::vector<uint8_t> expected = {
|
||||||
|
0x01, 0x03, // L/3
|
||||||
|
0xB1, 0x04, 0x00, 0x00, 0x00, 0x00, // U4 DATAID = 0
|
||||||
|
0xB1, 0x04, 0x00, 0x00, 0x01, 0x2C, // U4 CEID = 300
|
||||||
|
0x01, 0x01, // L/1 reports
|
||||||
|
0x01, 0x02, // L/2 report
|
||||||
|
0xB1, 0x04, 0x00, 0x00, 0x00, 0x01, // U4 RPTID = 1
|
||||||
|
0x01, 0x01, // L/1 values
|
||||||
|
0x41, 0x01, 'x', // A "x"
|
||||||
|
};
|
||||||
|
CHECK(s2::encode(*msg.body) == expected);
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
#include <doctest/doctest.h>
|
#include <doctest/doctest.h>
|
||||||
|
|
||||||
|
#include <filesystem>
|
||||||
#include <fstream>
|
#include <fstream>
|
||||||
#include <string>
|
#include <string>
|
||||||
|
|
||||||
@@ -144,3 +145,59 @@ TEST_CASE("loader: control_job_state.yaml -> non-empty table") {
|
|||||||
REQUIRE(row->to.has_value());
|
REQUIRE(row->to.has_value());
|
||||||
CHECK(*row->to == gem::ControlJobState::Completed);
|
CHECK(*row->to == gem::ControlJobState::Completed);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
TEST_CASE("alarm optional name: parsed when present, empty when absent") {
|
||||||
|
gem::EquipmentDataModel m;
|
||||||
|
config::load_equipment(SECSGEM_DATA_DIR "/equipment.yaml", m);
|
||||||
|
// data/equipment.yaml names both alarms.
|
||||||
|
CHECK(m.alarms.get(1)->name == "chiller_temp_high");
|
||||||
|
CHECK(m.alarms.get(2)->name == "door_open");
|
||||||
|
|
||||||
|
// A config without `name:` yields an empty name (back-compat).
|
||||||
|
const auto path = std::filesystem::temp_directory_path() / "alarm_noname.yaml";
|
||||||
|
{
|
||||||
|
std::ofstream f(path);
|
||||||
|
f << "device: {id: 0, model_name: M, software_rev: R}\n"
|
||||||
|
"alarms:\n"
|
||||||
|
" - {id: 9, text: \"Unnamed\", category: 1}\n";
|
||||||
|
}
|
||||||
|
gem::EquipmentDataModel m2;
|
||||||
|
config::load_equipment(path.string(), m2);
|
||||||
|
CHECK(m2.alarms.get(9)->name.empty());
|
||||||
|
std::filesystem::remove(path);
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_CASE("roles block: parsed when present, defaults when absent") {
|
||||||
|
gem::EquipmentDataModel m;
|
||||||
|
auto desc = config::load_equipment(SECSGEM_DATA_DIR "/equipment.yaml", m);
|
||||||
|
CHECK(desc.control_state_svid == 1);
|
||||||
|
CHECK(desc.clock_svid == 2);
|
||||||
|
CHECK(desc.cj_executing_ceid == 400);
|
||||||
|
CHECK(desc.cj_completed_ceid == 401);
|
||||||
|
|
||||||
|
const auto path = std::filesystem::temp_directory_path() / "roles_custom.yaml";
|
||||||
|
{
|
||||||
|
std::ofstream f(path);
|
||||||
|
f << "device: {id: 0, model_name: M, software_rev: R}\n"
|
||||||
|
"roles: {control_state_svid: 11, clock_svid: 12,\n"
|
||||||
|
" cj_executing_ceid: 910, cj_completed_ceid: 911}\n";
|
||||||
|
}
|
||||||
|
gem::EquipmentDataModel m2;
|
||||||
|
auto d2 = config::load_equipment(path.string(), m2);
|
||||||
|
CHECK(d2.control_state_svid == 11);
|
||||||
|
CHECK(d2.clock_svid == 12);
|
||||||
|
CHECK(d2.cj_executing_ceid == 910);
|
||||||
|
CHECK(d2.cj_completed_ceid == 911);
|
||||||
|
std::filesystem::remove(path);
|
||||||
|
|
||||||
|
const auto path2 = std::filesystem::temp_directory_path() / "roles_absent.yaml";
|
||||||
|
{
|
||||||
|
std::ofstream f(path2);
|
||||||
|
f << "device: {id: 0, model_name: M, software_rev: R}\n";
|
||||||
|
}
|
||||||
|
gem::EquipmentDataModel m3;
|
||||||
|
auto d3 = config::load_equipment(path2.string(), m3);
|
||||||
|
CHECK(d3.control_state_svid == 1); // historical defaults preserved
|
||||||
|
CHECK(d3.cj_completed_ceid == 401);
|
||||||
|
std::filesystem::remove(path2);
|
||||||
|
}
|
||||||
|
|||||||
Executable
+50
@@ -0,0 +1,50 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Phase E operational checks for secs_gemd, run inside the builder image:
|
||||||
|
# 1. serves the gRPC API over a UNIX DOMAIN SOCKET (no TCP exposure),
|
||||||
|
# 2. exposes Prometheus metrics (link/control-state/spool gauges),
|
||||||
|
# 3. shuts down GRACEFULLY on SIGTERM (exit 0, "stopped cleanly").
|
||||||
|
# Exit 0 = all three hold.
|
||||||
|
set -u
|
||||||
|
cd "$(dirname "${BASH_SOURCE[0]}")/.."
|
||||||
|
|
||||||
|
SOCK=/tmp/gemd-ops.sock
|
||||||
|
LOG=/tmp/gemd-ops.log
|
||||||
|
rm -f "$SOCK"
|
||||||
|
|
||||||
|
./build/secs_gemd --port 5077 --grpc "unix://$SOCK" --metrics 9191 \
|
||||||
|
--config-dir data > "$LOG" 2>&1 &
|
||||||
|
GEMD=$!
|
||||||
|
sleep 2
|
||||||
|
|
||||||
|
fail() { echo "FAIL: $1"; cat "$LOG"; kill -9 $GEMD 2>/dev/null; exit 1; }
|
||||||
|
|
||||||
|
[ -S "$SOCK" ] || fail "unix socket not created"
|
||||||
|
|
||||||
|
# Metrics endpoint answers and carries our gauges.
|
||||||
|
exec 3<>/dev/tcp/127.0.0.1/9191 || fail "metrics port closed"
|
||||||
|
printf 'GET /metrics HTTP/1.0\r\n\r\n' >&3
|
||||||
|
METRICS=$(cat <&3)
|
||||||
|
echo "$METRICS" | grep -q "secsgem_control_state" || fail "control-state gauge missing"
|
||||||
|
echo "$METRICS" | grep -q "secsgem_spool_depth" || fail "spool gauge missing"
|
||||||
|
echo "$METRICS" | grep -q "secsgem_link_selected" || fail "link gauge missing"
|
||||||
|
|
||||||
|
# A gRPC call over the unix socket (python grpcio in the interop image; in
|
||||||
|
# the builder image fall back to checking the socket accepts a connection).
|
||||||
|
if python3 -c "import grpc" 2>/dev/null; then
|
||||||
|
python3 - "$SOCK" <<'PY' || fail "gRPC over unix socket failed"
|
||||||
|
import sys, grpc
|
||||||
|
ch = grpc.insecure_channel(f"unix://{sys.argv[1]}")
|
||||||
|
grpc.channel_ready_future(ch).result(timeout=5)
|
||||||
|
PY
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Graceful shutdown: SIGTERM -> exit 0 + the clean-stop line.
|
||||||
|
kill -TERM $GEMD
|
||||||
|
for _ in $(seq 1 50); do kill -0 $GEMD 2>/dev/null || break; sleep 0.1; done
|
||||||
|
if kill -0 $GEMD 2>/dev/null; then fail "still running 5s after SIGTERM"; fi
|
||||||
|
wait $GEMD; RC=$?
|
||||||
|
[ "$RC" = "0" ] || fail "exit code $RC after SIGTERM (want 0)"
|
||||||
|
grep -q "stopped cleanly" "$LOG" || fail "no clean-stop log line"
|
||||||
|
grep -q "shutting down" "$LOG" || fail "no shutdown log line"
|
||||||
|
|
||||||
|
echo "daemon ops: unix socket + metrics + graceful shutdown all OK"
|
||||||
+34
-1
@@ -16,7 +16,10 @@
|
|||||||
# py-host secsgem-py active host vs C++ secs_server (31 checks)
|
# py-host secsgem-py active host vs C++ secs_server (31 checks)
|
||||||
# conformance secs_conformance C++ host vs C++ secs_server (47 checks)
|
# conformance secs_conformance C++ host vs C++ secs_server (47 checks)
|
||||||
# daemon gRPC tool + secsgem-py host vs secs_gemd (bridge proof)
|
# daemon gRPC tool + secsgem-py host vs secs_gemd (bridge proof)
|
||||||
|
# pyclient published secsgem-client package vs secs_gemd (13 checks)
|
||||||
# spool spool persistence across a server restart
|
# spool spool persistence across a server restart
|
||||||
|
# daemon-ops unix-socket gRPC + Prometheus gauges + graceful SIGTERM
|
||||||
|
# fab randomized virtual fab: FAB_N daemons x (host + tool) actors
|
||||||
# tshark Wireshark HSMS dissector on a live capture
|
# tshark Wireshark HSMS dissector on a live capture
|
||||||
# secs4j secs4java8 Java host vs C++ secs_server (55 checks)
|
# secs4j secs4java8 Java host vs C++ secs_server (55 checks)
|
||||||
#
|
#
|
||||||
@@ -35,7 +38,7 @@ record() { # record <name> <exit-code>
|
|||||||
|
|
||||||
compose() { docker compose "$@"; }
|
compose() { docker compose "$@"; }
|
||||||
|
|
||||||
stop_services() { compose stop server gemd server-spool >/dev/null 2>&1 || true; }
|
stop_services() { compose stop server gemd server-spool fab >/dev/null 2>&1 || true; }
|
||||||
trap stop_services EXIT
|
trap stop_services EXIT
|
||||||
|
|
||||||
# ---- build -----------------------------------------------------------------
|
# ---- build -----------------------------------------------------------------
|
||||||
@@ -89,6 +92,22 @@ compose run --rm --no-deps interop \
|
|||||||
record daemon $?
|
record daemon $?
|
||||||
compose stop gemd >/dev/null 2>&1
|
compose stop gemd >/dev/null 2>&1
|
||||||
|
|
||||||
|
# ---- Python client package vs secs_gemd --------------------------------------
|
||||||
|
note "pyclient: secsgem_client package + secsgem-py host vs secs_gemd"
|
||||||
|
(
|
||||||
|
set -e
|
||||||
|
compose run --rm --no-deps -e PYTHONPATH=/app/clients/python interop \
|
||||||
|
python3 /app/clients/python/tests/test_values.py
|
||||||
|
compose run --rm --no-deps -e PYTHONPATH=/app/clients/python interop \
|
||||||
|
python3 /app/clients/python/tests/test_enums.py
|
||||||
|
compose up -d --no-deps gemd
|
||||||
|
sleep 2
|
||||||
|
compose run --rm --no-deps -e PYTHONPATH=/app/clients/python interop \
|
||||||
|
python3 pyclient_interop.py --grpc gemd:50051 --hsms-host gemd
|
||||||
|
)
|
||||||
|
record pyclient $?
|
||||||
|
compose stop gemd >/dev/null 2>&1
|
||||||
|
|
||||||
# ---- spool persistence across restart ---------------------------------------
|
# ---- spool persistence across restart ---------------------------------------
|
||||||
note "spool: persistence across server restart"
|
note "spool: persistence across server restart"
|
||||||
(
|
(
|
||||||
@@ -105,6 +124,20 @@ note "spool: persistence across server restart"
|
|||||||
record spool $?
|
record spool $?
|
||||||
compose stop server-spool >/dev/null 2>&1
|
compose stop server-spool >/dev/null 2>&1
|
||||||
|
|
||||||
|
# ---- daemon operations: unix socket + metrics + graceful shutdown ------------
|
||||||
|
note "daemon-ops: unix socket, Prometheus gauges, SIGTERM drains cleanly"
|
||||||
|
compose run --rm builder bash tools/check_daemon_ops.sh
|
||||||
|
record daemon-ops $?
|
||||||
|
|
||||||
|
# ---- randomized virtual fab ----------------------------------------------------
|
||||||
|
note "fab: ${FAB_N:-3} equipment x (secsgem-py host + secsgem-client tool), seeded random traffic"
|
||||||
|
compose up -d --no-deps fab
|
||||||
|
sleep 3
|
||||||
|
compose run --rm --no-deps -e PYTHONPATH=/app/clients/python interop \
|
||||||
|
python3 virtual_fab.py --host fab --n "${FAB_N:-3}" --seconds "${FAB_SECONDS:-20}"
|
||||||
|
record fab $?
|
||||||
|
compose stop fab >/dev/null 2>&1
|
||||||
|
|
||||||
# ---- Wireshark dissector ------------------------------------------------------
|
# ---- Wireshark dissector ------------------------------------------------------
|
||||||
note "tshark: Wireshark HSMS dissector"
|
note "tshark: Wireshark HSMS dissector"
|
||||||
compose run --rm builder bash interop/tshark_validate.sh
|
compose run --rm builder bash interop/tshark_validate.sh
|
||||||
|
|||||||
Executable
+20
@@ -0,0 +1,20 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Spawn a virtual fab: N secs_gemd equipment instances in one container.
|
||||||
|
# equipment i: HSMS on 5100+i, gRPC on 0.0.0.0:51000+i (docker-network
|
||||||
|
# internal — the localhost default is for real deployments).
|
||||||
|
# Used by the `fab` compose service; interop/virtual_fab.py drives them.
|
||||||
|
set -u
|
||||||
|
cd "$(dirname "${BASH_SOURCE[0]}")/.."
|
||||||
|
N=${FAB_N:-3}
|
||||||
|
PIDS=()
|
||||||
|
trap 'kill "${PIDS[@]}" 2>/dev/null; wait' TERM INT
|
||||||
|
for i in $(seq 0 $((N - 1))); do
|
||||||
|
./build/secs_gemd \
|
||||||
|
--port $((5100 + i)) \
|
||||||
|
--grpc "0.0.0.0:$((51000 + i))" \
|
||||||
|
--config-dir data \
|
||||||
|
> "/tmp/gemd-$i.log" 2>&1 &
|
||||||
|
PIDS+=($!)
|
||||||
|
done
|
||||||
|
echo "virtual fab up: $N equipment (HSMS 5100+, gRPC 51000+)"
|
||||||
|
wait
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
# ThreadSanitizer suppressions — third-party noise only.
|
||||||
|
#
|
||||||
|
# The system libgrpc/libprotobuf are NOT built with TSan instrumentation, so
|
||||||
|
# TSan cannot observe their internal synchronization and reports false
|
||||||
|
# positives entirely inside the library (e.g. Epoll1Poller::DoEpollWait's
|
||||||
|
# event-engine wakeup path). Suppressing by library keeps every frame of OUR
|
||||||
|
# code fully checked: a real race in secsgem/daemon code still has our frames
|
||||||
|
# in the stack and is NOT suppressed by these rules.
|
||||||
|
#
|
||||||
|
# Do not add suppressions for secsgem code here. Fix the race instead.
|
||||||
|
#
|
||||||
|
# libabsl_*: gRPC's absl Mutex keeps deadlock-detection bookkeeping
|
||||||
|
# (GraphCycles) whose synchronization TSan cannot see in the uninstrumented
|
||||||
|
# system build — every report's racing accesses sit fully inside
|
||||||
|
# libabsl_graphcycles_internal/libabsl_malloc_internal frames.
|
||||||
|
race:libgrpc.so
|
||||||
|
race:libgrpc++.so
|
||||||
|
race:libgpr.so
|
||||||
|
race:libabsl_
|
||||||
|
called_from_lib:libgrpc.so
|
||||||
Reference in New Issue
Block a user