├── .devcontainer ├── Dockerfile └── devcontainer.json ├── .dockerignore ├── .gitattributes ├── .github └── workflows │ ├── docker.yml │ └── e2e.yml ├── .gitignore ├── .gitmodules ├── .gitpod.yml ├── CMakeLists.txt ├── COPYING ├── Dockerfile ├── Dockerfile.gitpod ├── Procfile.multi ├── Procfile.single ├── README.md ├── config ├── multi-device │ ├── device-1.xml │ └── device-2.xml ├── phenix-image.yml ├── single-device │ ├── device.xml │ └── node-red-hmi.json └── wind-turbine │ ├── blade-1.xml │ ├── blade-2.xml │ ├── blade-3.xml │ ├── main-controller.xml │ ├── signal-converter.xml │ └── yaw-controller.xml ├── data └── weather.csv ├── install-node-red.sh ├── ot-sim.code-workspace ├── src ├── c++ │ ├── .vscode │ │ └── c_cpp_properties.json │ ├── CMakeLists.txt │ ├── cmd │ │ ├── ot-sim-dnp3-module │ │ │ ├── CMakeLists.txt │ │ │ └── main.cpp │ │ └── ot-sim-e2e-dnp3-master │ │ │ ├── CMakeLists.txt │ │ │ ├── handler.hpp │ │ │ └── main.cpp │ ├── dnp3 │ │ ├── CMakeLists.txt │ │ ├── client.cpp │ │ ├── client.hpp │ │ ├── common.hpp │ │ ├── master.cpp │ │ ├── master.hpp │ │ ├── outstation.cpp │ │ ├── outstation.hpp │ │ ├── server.cpp │ │ └── server.hpp │ └── msgbus │ │ ├── CMakeLists.txt │ │ ├── envelope.hpp │ │ ├── metrics.cpp │ │ ├── metrics.hpp │ │ ├── pusher.cpp │ │ ├── pusher.hpp │ │ ├── subscriber.cpp │ │ └── subscriber.hpp ├── c │ ├── .vscode │ │ └── c_cpp_properties.json │ ├── CMakeLists.txt │ └── cmd │ │ └── ot-sim-message-bus │ │ ├── CMakeLists.txt │ │ └── main.c ├── go │ ├── .gitignore │ ├── Makefile │ ├── cmd │ │ ├── ot-sim-cpu-module │ │ │ └── main.go │ │ ├── ot-sim-logic-module │ │ │ └── main.go │ │ ├── ot-sim-modbus-module │ │ │ └── main.go │ │ ├── ot-sim-mqtt-module │ │ │ └── main.go │ │ ├── ot-sim-node-red-module │ │ │ └── main.go │ │ ├── ot-sim-sunspec-module │ │ │ └── main.go │ │ ├── ot-sim-tailscale-module │ │ │ └── main.go │ │ └── ot-sim-telnet-module │ │ │ └── main.go │ ├── cpu │ │ ├── api.go │ │ ├── context.go │ │ ├── cpu.go │ │ ├── execute.go │ │ ├── internal.go │ │ ├── metrics.go │ │ └── monitor.go │ ├── go.mod │ ├── go.sum │ ├── logic │ │ ├── logic.go │ │ └── logic_test.go │ ├── modbus │ │ ├── client │ │ │ └── client.go │ │ ├── modbus.go │ │ ├── server │ │ │ ├── coil.go │ │ │ ├── discrete.go │ │ │ ├── holding.go │ │ │ ├── input.go │ │ │ └── server.go │ │ └── util │ │ │ ├── bytes.go │ │ │ ├── bytes_test.go │ │ │ ├── register.go │ │ │ └── register_test.go │ ├── mqtt │ │ ├── mqtt.go │ │ └── types.go │ ├── msgbus │ │ ├── envelope.go │ │ ├── health.go │ │ ├── metric.go │ │ ├── module.go │ │ ├── pusher.go │ │ ├── runtime.go │ │ └── subscriber.go │ ├── nodered │ │ ├── nodered.go │ │ └── settings.js.tmpl │ ├── ot-sim.go │ ├── staticcheck.conf │ ├── sunspec │ │ ├── README.md │ │ ├── client │ │ │ ├── client.go │ │ │ └── util.go │ │ ├── common │ │ │ ├── common.go │ │ │ ├── register.go │ │ │ ├── schema.go │ │ │ └── types.go │ │ ├── server │ │ │ └── server.go │ │ └── sunspec.go │ ├── tailscale │ │ └── tailscale.go │ ├── telnet │ │ ├── banner.go │ │ ├── modules.go │ │ └── telnet.go │ └── util │ │ ├── context.go │ │ ├── exit.go │ │ ├── sigterm │ │ └── context.go │ │ └── slice.go ├── js │ └── node-red │ │ ├── icons │ │ └── zeromq.png │ │ ├── ot-sim.html │ │ ├── ot-sim.js │ │ └── package.json ├── old │ ├── README.md │ ├── c │ │ ├── CMakeLists.txt │ │ ├── Makefile │ │ ├── cmd │ │ │ └── ot-sim-io-module │ │ │ │ ├── CMakeLists.txt │ │ │ │ └── main.c │ │ └── msgbus │ │ │ ├── debugger.c │ │ │ ├── logger.c │ │ │ ├── msgbus-ini-example.c │ │ │ └── msgbus-test.c │ └── python │ │ └── dnp3 │ │ ├── client.py │ │ ├── dnp3.py │ │ ├── envelope.py │ │ ├── logger.py │ │ ├── master.py │ │ ├── outstation.py │ │ ├── point.py │ │ ├── server.py │ │ └── variations.py └── python │ ├── .gitignore │ ├── otsim │ ├── __init__.py │ ├── ground_truth │ │ ├── __init__.py │ │ └── ground_truth.py │ ├── helics_helper │ │ ├── README.md │ │ ├── __init__.py │ │ └── version.py │ ├── io │ │ ├── __init__.py │ │ └── io.py │ ├── msgbus │ │ ├── __init__.py │ │ ├── envelope.py │ │ ├── metrics.py │ │ ├── pusher.py │ │ └── subscriber.py │ ├── rpi_gpio │ │ ├── README.md │ │ ├── __init__.py │ │ └── rpi_gpio.py │ └── wind_turbine │ │ ├── __init__.py │ │ ├── anemometer │ │ ├── __init__.py │ │ └── anemometer.py │ │ └── power_output │ │ ├── __init__.py │ │ └── power_output.py │ └── setup.py └── testing ├── dnp3 ├── master.py └── visitors.py └── e2e ├── Procfile ├── README.md ├── configs ├── device-1.xml └── device-2.xml └── helics ├── broker.py ├── data └── IEEE13 │ ├── IEEE13Node_BusXY.csv │ ├── IEEE13Nodeckt.dss │ ├── IEEELineCodes.dss │ └── LoadShape1.csv └── opendss-federate.py /.devcontainer/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM debian:bookworm 2 | 3 | ENV TZ=America/Denver 4 | RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone 5 | 6 | RUN apt update && apt install -y \ 7 | bash-completion build-essential cmake curl git git-lfs mbpoll sudo tmux tree vim wget xz-utils \ 8 | cmake libboost-dev libczmq-dev libxml2-dev libzmq3-dev pkg-config python3-dev python3-pip 9 | 10 | ARG USERNAME=vscode 11 | ARG USER_UID=1000 12 | 13 | RUN useradd -l -u $USER_UID -md /home/$USERNAME -s /bin/bash -p $USERNAME $USERNAME \ 14 | && echo $USERNAME ALL=\(root\) NOPASSWD:ALL > /etc/sudoers.d/$USERNAME \ 15 | && chmod 0440 /etc/sudoers.d/$USERNAME 16 | 17 | ADD https://raw.githubusercontent.com/git/git/master/contrib/completion/git-prompt.sh /home/$USERNAME/.bash/git-prompt.sh 18 | RUN chown $USERNAME:$USERNAME /home/$USERNAME/.bash/git-prompt.sh \ 19 | && chmod +x /home/$USERNAME/.bash/git-prompt.sh \ 20 | && echo "\nsource ~/.bash/git-prompt.sh" >> /home/$USERNAME/.bashrc \ 21 | && echo "export GIT_PS1_SHOWCOLORHINTS=true" >> /home/$USERNAME/.bashrc \ 22 | && echo "export PROMPT_COMMAND='__git_ps1 \"\W\" \" » \"'" >> /home/$USERNAME/.bashrc 23 | 24 | ARG GOLANG_VERSION=1.21.1 25 | 26 | RUN wget -O go.tgz https://golang.org/dl/go${GOLANG_VERSION}.linux-amd64.tar.gz \ 27 | && tar -C /usr/local -xzf go.tgz && rm go.tgz \ 28 | && ln -s /usr/local/go/bin/* /usr/local/bin 29 | 30 | ENV GOPATH /go 31 | ENV PATH $GOPATH/bin:$PATH 32 | 33 | RUN mkdir -p "$GOPATH/src" "$GOPATH/bin" \ 34 | && chmod -R 777 "$GOPATH" 35 | 36 | RUN go install github.com/ramya-rao-a/go-outline@latest 2>&1 37 | RUN go install github.com/mdempsky/gocode@latest 2>&1 38 | RUN go install github.com/stamblerre/gocode@latest 2>&1 39 | RUN go install github.com/rogpeppe/godef@latest 2>&1 40 | RUN go install github.com/uudashr/gopkgs/v2/cmd/gopkgs@latest 2>&1 41 | RUN go install golang.org/x/tools/gopls@latest 2>&1 42 | RUN go install honnef.co/go/tools/cmd/staticcheck@latest 2>&1 43 | RUN go install github.com/cweill/gotests/gotests@latest 2>&1 44 | RUN go install github.com/fatih/gomodifytags@latest 2>&1 45 | RUN go install github.com/josharian/impl@latest 2>&1 46 | RUN go install github.com/haya14busa/goplay/cmd/goplay@latest 2>&1 47 | RUN go install github.com/go-delve/delve/cmd/dlv@latest 2>&1 48 | 49 | RUN chmod -R a+rwX /go/pkg && rm -rf /go/src/* 50 | 51 | RUN wget -O hivemind.gz https://github.com/DarthSim/hivemind/releases/download/v1.1.0/hivemind-v1.1.0-linux-amd64.gz \ 52 | && gunzip --stdout hivemind.gz > /usr/local/bin/hivemind \ 53 | && chmod +x /usr/local/bin/hivemind \ 54 | && rm hivemind.gz 55 | 56 | RUN wget -O overmind.gz https://github.com/DarthSim/overmind/releases/download/v2.4.0/overmind-v2.4.0-linux-amd64.gz \ 57 | && gunzip --stdout overmind.gz > /usr/local/bin/overmind \ 58 | && chmod +x /usr/local/bin/overmind \ 59 | && rm overmind.gz 60 | 61 | ADD install-node-red.sh /root/install-node-red.sh 62 | 63 | # needed by nod-red install script 64 | ARG TARGETARCH 65 | RUN /root/install-node-red.sh \ 66 | && rm /root/install-node-red.sh 67 | 68 | ADD ./src/js/node-red /root/.node-red/nodes/ot-sim 69 | RUN cd /root/.node-red/nodes/ot-sim && npm install 70 | 71 | RUN python3 -m pip install --break-system-packages opendssdirect.py~=0.8.4 72 | -------------------------------------------------------------------------------- /.devcontainer/devcontainer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "ot-sim", 3 | "build": { 4 | "dockerfile": "Dockerfile", 5 | "context": "..", 6 | "args": { 7 | "GOLANG_VERSION": "1.25.0" 8 | } 9 | }, 10 | 11 | "runArgs": [ 12 | "--security-opt", "seccomp=unconfined" // needed for debug support 13 | ], 14 | 15 | "mounts": [], 16 | 17 | "customizations": { 18 | "vscode": { 19 | "settings": { 20 | "terminal.integrated.profiles.linux": { 21 | "bash": { 22 | "path": "/bin/bash" 23 | } 24 | }, 25 | 26 | "terminal.integrated.defaultProfile.linux": "bash" 27 | }, 28 | 29 | "extensions": [ 30 | "golang.go", 31 | "ms-python.python", 32 | "ms-vscode.cpptools", 33 | "ms-vsliveshare.vsliveshare-pack" 34 | ] 35 | } 36 | }, 37 | 38 | "remoteUser": "vscode" 39 | } 40 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | build 2 | Dockerfile 3 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | *.csv filter=lfs diff=lfs merge=lfs -text 2 | -------------------------------------------------------------------------------- /.github/workflows/e2e.yml: -------------------------------------------------------------------------------- 1 | name: End-to-End Testing 2 | on: 3 | - workflow_call 4 | jobs: 5 | e2e: 6 | name: Run End-to-End Tests 7 | runs-on: ubuntu-latest 8 | container: debian:bookworm # OT-sim I/O module segfaults in ubuntu-latest directly 9 | permissions: 10 | contents: read 11 | env: 12 | GOPATH: /go 13 | steps: 14 | - name: Install Build Dependencies 15 | run: | 16 | apt update 17 | apt install -y git git-lfs wget build-essential cmake libboost-dev libczmq-dev libxml2-dev libzmq5-dev pkg-config python3-dev python3-pip 18 | python3 -m pip install --break-system-packages opendssdirect.py~=0.8.4 19 | wget -O /tmp/go.tgz https://golang.org/dl/go1.22.1.linux-amd64.tar.gz \ 20 | && tar -C /usr/local -xzf /tmp/go.tgz && rm /tmp/go.tgz \ 21 | && ln -s /usr/local/go/bin/* /usr/local/bin 22 | echo "/go/bin" >> $GITHUB_PATH 23 | mkdir -p /go/src /go/bin 24 | chmod -R 777 /go 25 | wget -O /tmp/hivemind.gz https://github.com/DarthSim/hivemind/releases/download/v1.1.0/hivemind-v1.1.0-linux-amd64.gz \ 26 | && gunzip --stdout /tmp/hivemind.gz > /usr/local/bin/hivemind \ 27 | && chmod +x /usr/local/bin/hivemind \ 28 | && rm /tmp/hivemind.gz 29 | - name: Check Out Repo 30 | uses: actions/checkout@v3 31 | with: 32 | lfs: true 33 | - name: Build Code 34 | run: | 35 | chown -R root:root /$GITHUB_WORKSPACE 36 | go version 37 | cmake -S . -B ./build -DBUILD_E2E=ON 38 | cmake --build ./build -j $(nproc) --target install 39 | git submodule update --init --recursive -- src/go/sunspec/common/models 40 | make -C ./src/go install 41 | python3 -m pip install --break-system-packages ./src/python 42 | ldconfig 43 | - name: Run Tests 44 | working-directory: testing/e2e 45 | run: | 46 | hivemind &> /tmp/test.log & 47 | sleep 15 # give devices time to propagate data 48 | ot-sim-e2e-dnp3-master || (cat /tmp/test.log ; exit 1) 49 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | bin 2 | build 3 | 4 | __pycache__ 5 | -------------------------------------------------------------------------------- /.gitmodules: -------------------------------------------------------------------------------- 1 | [submodule "src/old/c/deps/json-c"] 2 | path = src/old/c/deps/json-c 3 | url = https://github.com/json-c/json-c.git 4 | [submodule "src/c++/deps/cppzmq"] 5 | path = src/c++/deps/cppzmq 6 | url = https://github.com/zeromq/cppzmq.git 7 | [submodule "src/c++/deps/fmt"] 8 | path = src/c++/deps/fmt 9 | url = https://github.com/fmtlib/fmt.git 10 | [submodule "src/c++/deps/json"] 11 | path = src/c++/deps/json 12 | url = https://github.com/nlohmann/json.git 13 | [submodule "src/c++/deps/opendnp3"] 14 | path = src/c++/deps/opendnp3 15 | url = https://github.com/dnp3/opendnp3.git 16 | [submodule "src/go/sunspec/common/models"] 17 | path = src/go/sunspec/common/models 18 | url = https://github.com/sunspec/models.git 19 | -------------------------------------------------------------------------------- /.gitpod.yml: -------------------------------------------------------------------------------- 1 | image: 2 | file: Dockerfile.gitpod 3 | context: . 4 | -------------------------------------------------------------------------------- /CMakeLists.txt: -------------------------------------------------------------------------------- 1 | cmake_minimum_required(VERSION 3.11) 2 | 3 | project(ot-sim) 4 | 5 | find_package(Git QUIET) 6 | if (GIT_FOUND AND EXISTS "${CMAKE_CURRENT_SOURCE_DIR}/.git") 7 | message(STATUS "Executing git submodule update") 8 | execute_process( 9 | COMMAND ${GIT_EXECUTABLE} submodule update --init --recursive 10 | WORKING_DIRECTORY ${CMAKE_CURRENT_SOURCE_DIR} 11 | RESULT_VARIABLE GIT_SUBMOD_RESULT 12 | ) 13 | 14 | if (NOT GIT_SUBMOD_RESULT EQUAL "0") 15 | message( 16 | FATAL_ERROR "git submodule update failed with ${GIT_SUBMOD_RESULT}" 17 | ) 18 | endif () 19 | endif () 20 | 21 | OPTION(BUILD_E2E "Build E2E test executables" OFF) 22 | 23 | add_subdirectory(src/c) 24 | add_subdirectory(src/c++) 25 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM golang:1.25.0-bookworm AS gobuild 2 | 3 | ENV TZ=Etc/UTC 4 | RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone 5 | 6 | RUN apt update && apt install -y \ 7 | libzmq5-dev \ 8 | make \ 9 | pkg-config 10 | 11 | ADD .git /usr/local/src/ot-sim/.git 12 | ADD .gitmodules /usr/local/src/ot-sim/.gitmodules 13 | 14 | ADD src/go /usr/local/src/ot-sim/src/go 15 | RUN git -C /usr/local/src/ot-sim submodule update --init --recursive -- src/go/sunspec/common/models 16 | RUN make -C /usr/local/src/ot-sim/src/go install 17 | 18 | FROM python:3.11-bookworm AS pybuild 19 | 20 | ADD .git /usr/local/src/ot-sim/.git 21 | 22 | ADD src/python /usr/local/src/ot-sim/src/python 23 | RUN python3 -m pip install /usr/local/src/ot-sim/src/python 24 | 25 | FROM debian:bookworm AS build 26 | 27 | ENV TZ=Etc/UTC 28 | RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone 29 | 30 | RUN apt update && apt install -y \ 31 | build-essential \ 32 | cmake \ 33 | git \ 34 | libboost-dev \ 35 | libczmq-dev \ 36 | libxml2-dev \ 37 | libzmq3-dev \ 38 | pkg-config \ 39 | python3-dev \ 40 | python3-pip \ 41 | wget 42 | 43 | ADD .git /usr/local/src/ot-sim/.git 44 | 45 | ADD CMakeLists.txt /usr/local/src/ot-sim/CMakeLists.txt 46 | ADD src/c /usr/local/src/ot-sim/src/c 47 | ADD src/c++ /usr/local/src/ot-sim/src/c++ 48 | RUN cmake -S /usr/local/src/ot-sim -B /usr/local/src/ot-sim/build \ 49 | && cmake --build /usr/local/src/ot-sim/build -j $(nproc) --target install 50 | 51 | FROM debian:bookworm AS prod 52 | 53 | ENV TZ=Etc/UTC 54 | RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone 55 | 56 | RUN apt update && apt install -y \ 57 | bash-completion curl git tmux tree vim wget xz-utils \ 58 | libczmq4 libsodium23 libxml2 libzmq5 python3-pip 59 | 60 | RUN curl -fsSL https://pkgs.tailscale.com/stable/debian/bookworm.noarmor.gpg \ 61 | | tee /usr/share/keyrings/tailscale-archive-keyring.gpg >/dev/null \ 62 | && curl -fsSL https://pkgs.tailscale.com/stable/debian/bookworm.tailscale-keyring.list \ 63 | | tee /etc/apt/sources.list.d/tailscale.list \ 64 | && apt update && apt install -y tailscale 65 | 66 | WORKDIR /root 67 | 68 | ADD install-node-red.sh . 69 | 70 | # needed by nod-red install script 71 | ARG TARGETARCH 72 | RUN /root/install-node-red.sh \ 73 | && rm /root/install-node-red.sh 74 | 75 | ADD ./src/js/node-red /root/.node-red/nodes/ot-sim 76 | RUN cd /root/.node-red/nodes/ot-sim && npm install && cd /root 77 | 78 | COPY --from=gobuild /usr/local /usr/local 79 | COPY --from=pybuild /usr/local /usr/local 80 | COPY --from=build /usr/local /usr/local 81 | 82 | RUN ldconfig 83 | 84 | WORKDIR / 85 | 86 | CMD ["ot-sim-cpu-module", "/etc/ot-sim/config.xml"] 87 | 88 | FROM debian:bookworm AS test 89 | 90 | ENV TZ=Etc/UTC 91 | RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone 92 | 93 | RUN apt update && apt install -y \ 94 | bash-completion curl git mbpoll tmux tree vim wget xz-utils \ 95 | build-essential cmake libczmq4 libsodium23 libxml2 libzmq5 python3-dev python3-pip 96 | 97 | RUN curl -fsSL https://pkgs.tailscale.com/stable/debian/bookworm.noarmor.gpg \ 98 | | tee /usr/share/keyrings/tailscale-archive-keyring.gpg >/dev/null \ 99 | && curl -fsSL https://pkgs.tailscale.com/stable/debian/bookworm.tailscale-keyring.list \ 100 | | tee /etc/apt/sources.list.d/tailscale.list \ 101 | && apt update && apt install -y tailscale 102 | 103 | RUN wget -O hivemind.gz https://github.com/DarthSim/hivemind/releases/download/v1.1.0/hivemind-v1.1.0-linux-amd64.gz \ 104 | && gunzip --stdout hivemind.gz > /usr/local/bin/hivemind \ 105 | && chmod +x /usr/local/bin/hivemind \ 106 | && rm hivemind.gz 107 | 108 | RUN wget -O overmind.gz https://github.com/DarthSim/overmind/releases/download/v2.2.2/overmind-v2.2.2-linux-amd64.gz \ 109 | && gunzip --stdout overmind.gz > /usr/local/bin/overmind \ 110 | && chmod +x /usr/local/bin/overmind \ 111 | && rm overmind.gz 112 | 113 | WORKDIR /root 114 | 115 | ADD install-node-red.sh . 116 | 117 | # needed by nod-red install script 118 | ARG TARGETARCH 119 | RUN /root/install-node-red.sh \ 120 | && rm /root/install-node-red.sh 121 | 122 | ADD ./src/js/node-red /root/.node-red/nodes/ot-sim 123 | RUN cd /root/.node-red/nodes/ot-sim && npm install && cd /root 124 | 125 | COPY --from=gobuild /usr/local /usr/local 126 | COPY --from=pybuild /usr/local /usr/local 127 | COPY --from=build /usr/local /usr/local 128 | 129 | RUN python3 -m pip install --break-system-packages opendssdirect.py~=0.8.4 130 | 131 | RUN ldconfig 132 | 133 | ADD . /usr/local/src/ot-sim 134 | WORKDIR /usr/local/src/ot-sim 135 | -------------------------------------------------------------------------------- /Dockerfile.gitpod: -------------------------------------------------------------------------------- 1 | FROM debian:bookworm 2 | 3 | ENV TZ=America/Denver 4 | RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone 5 | 6 | RUN apt update && apt install -y \ 7 | bash-completion build-essential cmake curl git git-lfs mbpoll sudo tmux tree vim wget xz-utils \ 8 | cmake libboost-dev libczmq-dev libxml2-dev libzmq3-dev pkg-config python3-dev python3-pip 9 | 10 | RUN useradd -l -u 33333 -G sudo -md /home/gitpod -s /bin/bash -p gitpod gitpod 11 | 12 | ADD https://raw.githubusercontent.com/git/git/master/contrib/completion/git-prompt.sh /home/gitpod/.bash/git-prompt.sh 13 | RUN chown gitpod:gitpod /home/gitpod/.bash/git-prompt.sh \ 14 | && chmod +x /home/gitpod/.bash/git-prompt.sh \ 15 | && echo "\nsource ~/.bash/git-prompt.sh" >> /home/gitpod/.bashrc \ 16 | && echo "export GIT_PS1_SHOWCOLORHINTS=true" >> /home/gitpod/.bashrc \ 17 | && echo "export PROMPT_COMMAND='__git_ps1 \"\W\" \" » \"'" >> /home/gitpod/.bashrc 18 | 19 | ENV GOLANG_VERSION=1.22.1 20 | 21 | RUN wget -O go.tgz https://golang.org/dl/go${GOLANG_VERSION}.linux-amd64.tar.gz \ 22 | && tar -C /usr/local -xzf go.tgz && rm go.tgz \ 23 | && ln -s /usr/local/go/bin/* /usr/local/bin 24 | 25 | ENV GOPATH /go 26 | ENV PATH $GOPATH/bin:$PATH 27 | 28 | RUN mkdir -p "$GOPATH/src" "$GOPATH/bin" \ 29 | && chmod -R 777 "$GOPATH" 30 | 31 | RUN go install github.com/ramya-rao-a/go-outline@latest 2>&1 32 | RUN go install github.com/mdempsky/gocode@latest 2>&1 33 | RUN go install github.com/stamblerre/gocode@latest 2>&1 34 | RUN go install github.com/rogpeppe/godef@latest 2>&1 35 | RUN go install github.com/uudashr/gopkgs/v2/cmd/gopkgs@latest 2>&1 36 | RUN go install golang.org/x/tools/gopls@latest 2>&1 37 | RUN go install honnef.co/go/tools/cmd/staticcheck@latest 2>&1 38 | RUN go install github.com/cweill/gotests/gotests@latest 2>&1 39 | RUN go install github.com/fatih/gomodifytags@latest 2>&1 40 | RUN go install github.com/josharian/impl@latest 2>&1 41 | RUN go install github.com/haya14busa/goplay/cmd/goplay@latest 2>&1 42 | RUN go install github.com/go-delve/delve/cmd/dlv@latest 2>&1 43 | 44 | RUN chmod -R a+rwX /go/pkg && rm -rf /go/src/* 45 | 46 | RUN wget -O hivemind.gz https://github.com/DarthSim/hivemind/releases/download/v1.1.0/hivemind-v1.1.0-linux-amd64.gz \ 47 | && gunzip --stdout hivemind.gz > /usr/local/bin/hivemind \ 48 | && chmod +x /usr/local/bin/hivemind \ 49 | && rm hivemind.gz 50 | 51 | RUN wget -O overmind.gz https://github.com/DarthSim/overmind/releases/download/v2.4.0/overmind-v2.4.0-linux-amd64.gz \ 52 | && gunzip --stdout overmind.gz > /usr/local/bin/overmind \ 53 | && chmod +x /usr/local/bin/overmind \ 54 | && rm overmind.gz 55 | 56 | ADD install-node-red.sh /root/install-node-red.sh 57 | 58 | # needed by nod-red install script 59 | ARG TARGETARCH 60 | RUN /root/install-node-red.sh \ 61 | && rm /root/install-node-red.sh 62 | 63 | ADD ./src/js/node-red /root/.node-red/nodes/ot-sim 64 | RUN cd /root/.node-red/nodes/ot-sim && npm install 65 | 66 | ADD .git /workspaces/ot-sim/.git 67 | 68 | ADD CMakeLists.txt /workspaces/ot-sim/CMakeLists.txt 69 | ADD src/c /workspaces/ot-sim/src/c 70 | ADD src/c++ /workspaces/ot-sim/src/c++ 71 | RUN cmake -S /workspaces/ot-sim -B /workspaces/ot-sim/build \ 72 | && cmake --build /workspaces/ot-sim/build -j $(nproc) --target install \ 73 | && ldconfig 74 | 75 | ADD src/go /workspaces/ot-sim/src/go 76 | RUN make -C /workspaces/ot-sim/src/go install 77 | 78 | ADD src/python /workspaces/ot-sim/src/python 79 | RUN python3 -m pip install --break-system-packages /workspaces/ot-sim/src/python 80 | 81 | RUN python3 -m pip install --break-system-packages opendssdirect.py~=0.8.4 82 | 83 | USER gitpod 84 | -------------------------------------------------------------------------------- /Procfile.multi: -------------------------------------------------------------------------------- 1 | broker: python3 testing/e2e/helics/broker.py 2 | dss: python3 testing/e2e/helics/opendss-federate.py 3 | cpu-2: ot-sim-cpu-module config/multi-device/device-2.xml 4 | cpu-1: ot-sim-cpu-module config/multi-device/device-1.xml 5 | -------------------------------------------------------------------------------- /Procfile.single: -------------------------------------------------------------------------------- 1 | broker: python3 testing/e2e/helics/broker.py 2 | dss: python3 testing/e2e/helics/opendss-federate.py 3 | cpu: ot-sim-cpu-module config/single-device/device.xml 4 | -------------------------------------------------------------------------------- /config/multi-device/device-1.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 1 5 | tcp://127.0.0.1:1234 6 | tcp://127.0.0.1:5678 7 | 8 | 9 | ot-sim-message-bus {{config_file}} 10 | ot-sim-modbus-module {{config_file}} 11 | ot-sim-dnp3-module {{config_file}} 12 | 13 | 14 | 0.0.0.0:20000 15 | 15 16 | 17 | 1024 18 | 1 19 | 5 20 | 21 |
0
22 | line-650632.closed 23 | Group1Var1 24 | Group2Var1 25 | Class1 26 | 27 | 28 |
10
29 | line-650632.closed 30 | Group10Var2 31 | Group11Var2 32 | Class1 33 | false 34 |
35 | 36 |
0
37 | line-650632.kW 38 | Group30Var6 39 | Group32Var6 40 | Class1 41 | 42 |
43 |
44 | 45 | 127.0.0.1:5502 46 | 2s 47 | 48 |
0
49 | line-650632.closed 50 |
51 | 52 |
30000
53 | line-650632.kW 54 | 2 55 |
56 |
57 |
58 | -------------------------------------------------------------------------------- /config/multi-device/device-2.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | tcp://127.0.0.1:9012 5 | tcp://127.0.0.1:3456 6 | 7 | 8 | ot-sim-message-bus {{config_file}} 9 | ot-sim-io-module {{config_file}} 10 | ot-sim-modbus-module {{config_file}} 11 | 12 | 13 | 127.0.0.1:5502 14 | 15 |
0
16 | line-650632.closed 17 |
18 | 19 |
30000
20 | line-650632.kW 21 | -2 22 |
23 |
24 | 25 | localhost 26 | ot-sim-io 27 | 28 | OpenDSS/line-650632.kW 29 | double 30 | line-650632.kW 31 | 32 | 33 | OpenDSS/line-650632.kVAR 34 | double 35 | line-650632.kVAR 36 | 37 | 38 | OpenDSS/line-650632.closed 39 | boolean 40 | line-650632.closed 41 | 42 | 43 | OpenDSS/switch-671692.closed 44 | boolean 45 | switch-671692.closed 46 | 47 | 48 | line-650632.closed 49 | 50 | 51 |
52 | -------------------------------------------------------------------------------- /config/single-device/device.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | tcp://127.0.0.1:1234 5 | tcp://127.0.0.1:5678 6 | 7 | 8 | ot-sim-message-bus {{config_file}} 9 | ot-sim-io-module {{config_file}} 10 | ot-sim-modbus-module {{config_file}} 11 | ot-sim-dnp3-module {{config_file}} 12 | ot-sim-telnet-module {{config_file}} 13 | ot-sim-node-red-module {{config_file}} 14 | 15 | 16 | node-red 17 | /etc/node-red.js 18 | dark 19 | config/single-device/node-red-hmi.json 20 | 26 | 27 | 28 | 29 | :23 30 | default 31 | 32 | 33 | 127.0.0.1:5502 34 | 35 |
0
36 | line-650632.closed 37 |
38 | 39 |
30000
40 | line-650632.kW 41 | -2 42 |
43 |
44 | 45 | 127.0.0.1:20000 46 | 15 47 | 48 | 1024 49 | 1 50 | 5 51 | 52 |
0
53 | line-650632.closed 54 | Group1Var1 55 | Group2Var1 56 | Class1 57 | 58 | 59 |
10
60 | line-650632.closed 61 | Group10Var2 62 | Group11Var2 63 | Class1 64 | false 65 |
66 | 67 |
0
68 | line-650632.kW 69 | Group30Var6 70 | Group32Var6 71 | Class1 72 | 73 |
74 |
75 | 76 | localhost 77 | ot-sim-io 78 | 79 | OpenDSS/line-650632.kW 80 | double 81 | line-650632.kW 82 | 83 | 84 | OpenDSS/line-650632.kVAR 85 | double 86 | line-650632.kVAR 87 | 88 | 89 | OpenDSS/line-650632.closed 90 | boolean 91 | line-650632.closed 92 | 93 | 94 | OpenDSS/switch-671692.closed 95 | boolean 96 | switch-671692.closed 97 | 98 | 99 | line-650632.closed 100 | 101 | 102 |
103 | -------------------------------------------------------------------------------- /config/wind-turbine/blade-1.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | tcp://127.0.0.1:1234 4 | tcp://127.0.0.1:5678 5 | 6 | 7 | 0.0.0.0:9101 8 | ot-sim-message-bus {{config_file}} 9 | ot-sim-modbus-module {{config_file}} 10 | 11 | 12 | 1.1.1.31:502 13 | 14 |
1
15 | feathered 16 |
17 |
18 |
19 | -------------------------------------------------------------------------------- /config/wind-turbine/blade-2.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | tcp://127.0.0.1:1234 4 | tcp://127.0.0.1:5678 5 | 6 | 7 | 0.0.0.0:9101 8 | ot-sim-message-bus {{config_file}} 9 | ot-sim-modbus-module {{config_file}} 10 | 11 | 12 | 1.1.1.32:502 13 | 14 |
1
15 | feathered 16 |
17 |
18 |
19 | -------------------------------------------------------------------------------- /config/wind-turbine/blade-3.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | tcp://127.0.0.1:1234 4 | tcp://127.0.0.1:5678 5 | 6 | 7 | 0.0.0.0:9101 8 | ot-sim-message-bus {{config_file}} 9 | ot-sim-modbus-module {{config_file}} 10 | 11 | 12 | 1.1.1.33:502 13 | 14 |
1
15 | feathered 16 |
17 |
18 |
19 | -------------------------------------------------------------------------------- /config/wind-turbine/signal-converter.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | tcp://127.0.0.1:1234 4 | tcp://127.0.0.1:5678 5 | 6 | 7 | 0.0.0.0:9101 8 | ot-sim-message-bus {{config_file}} 9 | ot-sim-wind-turbine-anemometer-module {{config_file}} 10 | ot-sim-modbus-module {{config_file}} 11 | 12 | 13 | 14 | 15 | speed.high 16 | speed.med 17 | speed.low 18 | dir.high 19 | dir.med 20 | dir.low 21 | temp.high 22 | temp.low 23 | pressure 24 | 25 | /workspaces/ot-sim/data/weather.csv 26 | 27 | 28 | 29 | 1.1.1.21:502 30 | 31 |
30001
32 | speed.high 33 | -2 34 |
35 | 36 |
30002
37 | speed.med 38 | -2 39 |
40 | 41 |
30003
42 | speed.low 43 | -2 44 |
45 | 46 |
30004
47 | dir.high 48 | -2 49 |
50 | 51 |
30005
52 | dir.med 53 | -2 54 |
55 | 56 |
30006
57 | dir.low 58 | -2 59 |
60 | 61 |
30007
62 | temp.high 63 | -2 64 |
65 | 66 |
30008
67 | temp.low 68 | -2 69 |
70 | 71 |
30009
72 | pressure 73 | -2 74 |
75 |
76 |
77 | -------------------------------------------------------------------------------- /config/wind-turbine/yaw-controller.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | tcp://127.0.0.1:1234 4 | tcp://127.0.0.1:5678 5 | 6 | 7 | 0.0.0.0:9101 8 | ot-sim-message-bus {{config_file}} 9 | ot-sim-modbus-module {{config_file}} 10 | ot-sim-logic-module {{config_file}} 11 | 12 | 13 | 1.1.1.11:502 14 | 15 |
30001
16 | yaw.current 17 | -2 18 |
19 | 20 |
40001
21 | yaw.setpoint 22 | -2 23 |
24 |
25 | 26 | 1s 27 | true 28 | current_yaw ? 1 : -1 32 | current_yaw = adjust ? current_yaw + (dir * 0.1) : current_yaw 33 | ]]> 34 | 35 | 0 36 | 0 37 | 38 | 39 |
40 | -------------------------------------------------------------------------------- /data/weather.csv: -------------------------------------------------------------------------------- 1 | version https://git-lfs.github.com/spec/v1 2 | oid sha256:35d0c31dfca5135eb2b30781006b218862768a5c4c5bddb444f84663b56207f7 3 | size 6384023 4 | -------------------------------------------------------------------------------- /install-node-red.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | echo "TARGET ARCHITECTURE: ${TARGETARCH}" 4 | 5 | wget -O installer.sh \ 6 | https://raw.githubusercontent.com/node-red/linux-installers/master/deb/update-nodejs-and-nodered 7 | 8 | if [[ ${TARGETARCH} = arm* ]]; then 9 | echo "BUILDING FOR ARM" 10 | 11 | bash ./installer.sh --confirm-root --confirm-install --no-init \ 12 | && rm installer.sh 13 | 14 | apt install -y libzmq3-dev 15 | 16 | pushd /root/.node-red 17 | 18 | npm install zeromq --zmq-shared 19 | 20 | apt purge -y libzmq3-dev && apt autoremove -y 21 | else 22 | echo "NOT BUILDING FOR ARM" 23 | 24 | bash ./installer.sh --confirm-root --confirm-install --no-init --skip-pi \ 25 | && rm installer.sh 26 | 27 | pushd /root/.node-red 28 | 29 | npm install zeromq 30 | fi 31 | 32 | npm install \ 33 | node-red-dashboard \ 34 | node-red-contrib-modbus \ 35 | @node-red-contrib-themes/theme-collection 36 | 37 | popd 38 | -------------------------------------------------------------------------------- /ot-sim.code-workspace: -------------------------------------------------------------------------------- 1 | { 2 | "folders": [ 3 | { 4 | "path": "." 5 | }, 6 | { 7 | "path": "src/c" 8 | }, 9 | { 10 | "path": "src/c++" 11 | }, 12 | { 13 | "path": "src/go" 14 | }, 15 | { 16 | "path": "src/python" 17 | } 18 | ], 19 | "extensions": { 20 | "recommendations": [ 21 | "golang.go", 22 | "ms-python.python", 23 | "ms-vscode.cpptools" 24 | ] 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/c++/.vscode/c_cpp_properties.json: -------------------------------------------------------------------------------- 1 | { 2 | "configurations": [ 3 | { 4 | "name": "Linux", 5 | "includePath": [ 6 | "${workspaceFolder}", 7 | "${workspaceFolder}/deps", 8 | "${workspaceFolder}/deps/fmt/include", 9 | "${workspaceFolder}/deps/json/single_include", 10 | "${workspaceFolder}/deps/opendnp3/cpp/lib/include", 11 | "/usr/include/**", 12 | "/usr/local/include/**" 13 | ], 14 | "defines": [], 15 | "compilerPath": "/usr/bin/gcc", 16 | "cStandard": "gnu17", 17 | "cppStandard": "gnu++17", 18 | "intelliSenseMode": "linux-gcc-x64" 19 | } 20 | ], 21 | "version": 4 22 | } 23 | -------------------------------------------------------------------------------- /src/c++/CMakeLists.txt: -------------------------------------------------------------------------------- 1 | set(CMAKE_CXX_STANDARD 17) 2 | set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} -pthread -Wall") 3 | 4 | set(CPPZMQ_INCLUDE_DIRS 5 | ${CMAKE_CURRENT_SOURCE_DIR}/deps 6 | ) 7 | 8 | set(FMT_INCLUDE_DIRS 9 | ${CMAKE_CURRENT_SOURCE_DIR}/deps/fmt/include 10 | ) 11 | 12 | set(JSON_INCLUDE_DIRS 13 | ${CMAKE_CURRENT_SOURCE_DIR}/deps/json/single_include 14 | ) 15 | 16 | set(OPENDNP3_INCLUDE_DIRS 17 | ${CMAKE_CURRENT_SOURCE_DIR}/deps/opendnp3/cpp/lib/include 18 | ) 19 | 20 | set(OTSIM_INCLUDE_DIRS 21 | ${CMAKE_CURRENT_SOURCE_DIR} 22 | ) 23 | 24 | set(CPPZMQ_BUILD_TESTS OFF) 25 | set(JSON_BuildTests OFF) 26 | 27 | set(CMAKE_POLICY_DEFAULT_CMP0077 NEW) 28 | set(CMAKE_POSITION_INDEPENDENT_CODE ON) 29 | 30 | add_subdirectory(deps/cppzmq) 31 | add_subdirectory(deps/fmt) 32 | add_subdirectory(deps/json) 33 | add_subdirectory(deps/opendnp3) 34 | 35 | add_subdirectory(dnp3) 36 | add_subdirectory(msgbus) 37 | 38 | add_subdirectory(cmd/ot-sim-dnp3-module) 39 | 40 | if(BUILD_E2E) 41 | add_subdirectory(cmd/ot-sim-e2e-dnp3-master) 42 | endif() 43 | -------------------------------------------------------------------------------- /src/c++/cmd/ot-sim-dnp3-module/CMakeLists.txt: -------------------------------------------------------------------------------- 1 | find_package(Boost REQUIRED) 2 | 3 | include_directories( 4 | ${Boost_INCLUDE_DIRS} 5 | ${CPPZMQ_INCLUDE_DIRS} 6 | ${FMT_INCLUDE_DIRS} 7 | ${OPENDNP3_INCLUDE_DIRS} 8 | ${OTSIM_INCLUDE_DIRS} 9 | ) 10 | 11 | link_directories( 12 | ${Boost_LIBRARY_DIRS} 13 | ) 14 | 15 | add_definitions(-DBOOST_ALL_NO_LIB -DBOOST_ALL_DYN_LINK) 16 | 17 | add_executable(ot-sim-dnp3-module 18 | main.cpp 19 | ) 20 | 21 | target_link_libraries(ot-sim-dnp3-module 22 | ${Boost_LIBRARIES} 23 | fmt::fmt 24 | ot-sim-dnp3 25 | ot-sim-msgbus 26 | ) 27 | 28 | install(TARGETS ot-sim-dnp3-module 29 | RUNTIME DESTINATION bin 30 | ) -------------------------------------------------------------------------------- /src/c++/cmd/ot-sim-e2e-dnp3-master/CMakeLists.txt: -------------------------------------------------------------------------------- 1 | include_directories( 2 | ${OPENDNP3_INCLUDE_DIRS} 3 | ) 4 | 5 | add_executable(ot-sim-e2e-dnp3-master 6 | handler.hpp 7 | main.cpp 8 | ) 9 | 10 | target_link_libraries(ot-sim-e2e-dnp3-master 11 | opendnp3 12 | ) 13 | 14 | install(TARGETS ot-sim-e2e-dnp3-master 15 | RUNTIME DESTINATION bin 16 | ) -------------------------------------------------------------------------------- /src/c++/cmd/ot-sim-e2e-dnp3-master/handler.hpp: -------------------------------------------------------------------------------- 1 | #ifndef OTSIM_E2E_DNP3_MASTER_HANDLER_HPP 2 | #define OTSIM_E2E_DNP3_MASTER_HANDLER_HPP 3 | 4 | #include 5 | #include 6 | 7 | #include "opendnp3/master/ISOEHandler.h" 8 | 9 | class TestHandler : public opendnp3::ISOEHandler { 10 | public: 11 | void Reset() { 12 | binaryInput.clear(); 13 | binaryOutput.clear(); 14 | analogInput.clear(); 15 | analogOutput.clear(); 16 | } 17 | 18 | bool GetBinaryInput(int idx) { 19 | std::scoped_lock guard(binaryMu); 20 | return binaryInput.at(idx); 21 | } 22 | 23 | bool GetBinaryOutput(int idx) { 24 | std::scoped_lock guard(binaryMu); 25 | return binaryOutput.at(idx); 26 | } 27 | 28 | double GetAnalogInput(int idx) { 29 | std::scoped_lock guard(analogMu); 30 | return analogInput.at(idx); 31 | } 32 | 33 | double GetAnalogOutput(int idx) { 34 | std::scoped_lock guard(analogMu); 35 | return analogOutput.at(idx); 36 | } 37 | 38 | void Process(const opendnp3::HeaderInfo& info, const opendnp3::ICollection>& values) { 39 | std::scoped_lock guard(binaryMu); 40 | 41 | values.ForeachItem([&](const opendnp3::Indexed& value) { 42 | binaryInput[value.index] = value.value.value; 43 | }); 44 | } 45 | 46 | void Process(const opendnp3::HeaderInfo& info, const opendnp3::ICollection>& values) { 47 | std::scoped_lock guard(binaryMu); 48 | 49 | values.ForeachItem([&](const opendnp3::Indexed& value) { 50 | binaryOutput[value.index] = value.value.value; 51 | }); 52 | } 53 | 54 | void Process(const opendnp3::HeaderInfo& info, const opendnp3::ICollection>& values) { 55 | std::scoped_lock guard(analogMu); 56 | 57 | values.ForeachItem([&](const opendnp3::Indexed& value) { 58 | analogInput[value.index] = value.value.value; 59 | }); 60 | } 61 | 62 | void Process(const opendnp3::HeaderInfo& info, const opendnp3::ICollection>& values) { 63 | std::scoped_lock guard(analogMu); 64 | 65 | values.ForeachItem([&](const opendnp3::Indexed& value) { 66 | analogOutput[value.index] = value.value.value; 67 | }); 68 | } 69 | 70 | // BEGIN NOT IMPLEMENTED 71 | virtual void Process(const opendnp3::HeaderInfo& info, const opendnp3::ICollection>& values) override {} 72 | virtual void Process(const opendnp3::HeaderInfo& info, const opendnp3::ICollection>& values) override {} 73 | virtual void Process(const opendnp3::HeaderInfo& info, const opendnp3::ICollection>& values) override {} 74 | virtual void Process(const opendnp3::HeaderInfo& info, const opendnp3::ICollection>& values) override {} 75 | virtual void Process(const opendnp3::HeaderInfo& info, const opendnp3::ICollection>& values) override {} 76 | virtual void Process(const opendnp3::HeaderInfo& info, const opendnp3::ICollection>& values) override {} 77 | virtual void Process(const opendnp3::HeaderInfo& info, const opendnp3::ICollection>& values) override {} 78 | virtual void Process(const opendnp3::HeaderInfo& info, const opendnp3::ICollection& values) override {} 79 | 80 | virtual void BeginFragment(const opendnp3::ResponseInfo& info) final {} 81 | virtual void EndFragment(const opendnp3::ResponseInfo& info) final {} 82 | // END NOT IMPLEMENTED 83 | 84 | private: 85 | std::map binaryInput; 86 | std::map binaryOutput; 87 | std::map analogInput; 88 | std::map analogOutput; 89 | 90 | std::mutex binaryMu; 91 | std::mutex analogMu; 92 | }; 93 | 94 | #endif // OTSIM_E2E_DNP3_MASTER_HANDLER_HPP 95 | -------------------------------------------------------------------------------- /src/c++/dnp3/CMakeLists.txt: -------------------------------------------------------------------------------- 1 | include_directories( 2 | ${CPPZMQ_INCLUDE_DIRS} 3 | ${FMT_INCLUDE_DIRS} 4 | ${OPENDNP3_INCLUDE_DIRS} 5 | ${OTSIM_INCLUDE_DIRS} 6 | ) 7 | 8 | file(GLOB_RECURSE ot-sim-dnp3_SRC *.cpp *.hpp) 9 | 10 | add_library(ot-sim-dnp3 SHARED 11 | ${ot-sim-dnp3_SRC} 12 | ) 13 | 14 | target_link_libraries(ot-sim-dnp3 15 | cppzmq 16 | fmt::fmt 17 | opendnp3 18 | ot-sim-msgbus 19 | ) 20 | 21 | install(TARGETS ot-sim-dnp3 22 | ARCHIVE DESTINATION lib 23 | LIBRARY DESTINATION lib 24 | RUNTIME DESTINATION bin 25 | ) 26 | -------------------------------------------------------------------------------- /src/c++/dnp3/client.cpp: -------------------------------------------------------------------------------- 1 | #include 2 | #include 3 | 4 | #include "client.hpp" 5 | 6 | #include "opendnp3/ConsoleLogger.h" 7 | #include "opendnp3/master/DefaultMasterApplication.h" 8 | 9 | namespace otsim { 10 | namespace dnp3 { 11 | 12 | Client::Client() { 13 | manager.reset(new opendnp3::DNP3Manager(std::thread::hardware_concurrency(), opendnp3::ConsoleLogger::Create())); 14 | } 15 | 16 | bool Client::Init(const std::string& id, const opendnp3::IPEndpoint endpoint, std::shared_ptr listener, const opendnp3::ChannelRetry channelRetry) { 17 | try { 18 | channel = manager->AddTCPClient( 19 | id, 20 | opendnp3::levels::NORMAL, 21 | channelRetry, 22 | std::vector{endpoint}, 23 | "0.0.0.0", 24 | listener 25 | ); 26 | } catch (std::exception& e) { 27 | std::cout << "Failed to add TCPClient due to the error: " << std::string(e.what()); 28 | return false; 29 | } 30 | 31 | return true; 32 | } 33 | 34 | bool Client::Init(const std::string& id, const opendnp3::SerialSettings serial, std::shared_ptr listener, const opendnp3::ChannelRetry channelRetry) { 35 | try { 36 | channel = manager->AddSerial( 37 | id, 38 | opendnp3::levels::NORMAL, 39 | channelRetry, 40 | serial, 41 | listener 42 | ); 43 | } catch (std::exception& e) { 44 | std::cout << "Failed to add TCPClient due to the error: " << std::string(e.what()); 45 | return false; 46 | } 47 | 48 | return true; 49 | } 50 | 51 | std::shared_ptr Client::AddMaster(std::string id, std::uint16_t local, std::uint16_t remote, std::int64_t timeout, Pusher pusher) { 52 | std::cout << "adding master " << local << " --> " << remote << std::endl; 53 | 54 | auto master = Master::Create(id, pusher); 55 | auto config = master->BuildConfig(local, remote, timeout); 56 | 57 | auto iMaster = channel->AddMaster(id, master, opendnp3::DefaultMasterApplication::Create(), config); 58 | 59 | master->SetIMaster(iMaster); 60 | masters[remote] = master; 61 | 62 | return master; 63 | } 64 | 65 | void Client::Start() { 66 | for (const auto& kv : masters) { 67 | std::cout << "enabling master to " << kv.first << std::endl; 68 | 69 | kv.second->Enable(); 70 | } 71 | } 72 | 73 | void Client::Stop() { 74 | for (const auto& kv : masters) { 75 | std::cout << "disabling master to " << kv.first << std::endl; 76 | 77 | kv.second->Disable(); 78 | } 79 | } 80 | 81 | } // namespace dnp3 82 | } // namespace otsim 83 | -------------------------------------------------------------------------------- /src/c++/dnp3/client.hpp: -------------------------------------------------------------------------------- 1 | #ifndef OTSIM_DNP3_CLIENT_HPP 2 | #define OTSIM_DNP3_CLIENT_HPP 3 | 4 | #include "common.hpp" 5 | #include "master.hpp" 6 | 7 | #include "opendnp3/DNP3Manager.h" 8 | 9 | namespace otsim { 10 | namespace dnp3 { 11 | 12 | class Client : public std::enable_shared_from_this 13 | { 14 | public: 15 | static std::shared_ptr Create() { 16 | return std::make_shared(); 17 | } 18 | 19 | Client(); 20 | ~Client() {}; 21 | 22 | bool Init(const std::string& id, const opendnp3::IPEndpoint endpoint, std::shared_ptr = nullptr, const opendnp3::ChannelRetry channelRetry = opendnp3::ChannelRetry::Default()); 23 | bool Init(const std::string& id, const opendnp3::SerialSettings serial, std::shared_ptr = nullptr, const opendnp3::ChannelRetry channelRetry = opendnp3::ChannelRetry::Default()); 24 | 25 | std::shared_ptr AddMaster(std::string id, std::uint16_t local, std::uint16_t remote, std::int64_t timeout, Pusher pusher); 26 | 27 | void Start(); 28 | void Stop(); 29 | 30 | private: 31 | std::shared_ptr manager; // DNP3 stack manager 32 | std::shared_ptr channel; // TCPServer channel 33 | 34 | std::map> masters; 35 | }; 36 | 37 | } // namespace dnp3 38 | } // namespace otsim 39 | 40 | #endif // OTSIM_DNP3_CLIENT_HPP 41 | -------------------------------------------------------------------------------- /src/c++/dnp3/common.hpp: -------------------------------------------------------------------------------- 1 | #ifndef OTSIM_DNP3_COMMON_HPP 2 | #define OTSIM_DNP3_COMMON_HPP 3 | 4 | #include "msgbus/metrics.hpp" 5 | #include "msgbus/pusher.hpp" 6 | 7 | #include "opendnp3/gen/EventAnalogVariation.h" 8 | #include "opendnp3/gen/EventAnalogOutputStatusVariation.h" 9 | #include "opendnp3/gen/EventBinaryVariation.h" 10 | #include "opendnp3/gen/EventBinaryOutputStatusVariation.h" 11 | #include "opendnp3/gen/PointClass.h" 12 | #include "opendnp3/gen/StaticAnalogVariation.h" 13 | #include "opendnp3/gen/StaticAnalogOutputStatusVariation.h" 14 | #include "opendnp3/gen/StaticBinaryVariation.h" 15 | #include "opendnp3/gen/StaticBinaryOutputStatusVariation.h" 16 | 17 | namespace otsim { 18 | namespace dnp3 { 19 | 20 | template 21 | struct Point { 22 | std::uint16_t address {}; 23 | std::string tag {}; 24 | 25 | S svariation {}; 26 | E evariation {}; 27 | 28 | bool output {}; 29 | bool sbo {}; 30 | 31 | opendnp3::PointClass clazz; 32 | double deadband; 33 | }; 34 | 35 | typedef Point BinaryInputPoint; 36 | typedef Point AnalogInputPoint; 37 | 38 | typedef Point BinaryOutputPoint; 39 | typedef Point AnalogOutputPoint; 40 | 41 | typedef std::shared_ptr Pusher; 42 | typedef std::shared_ptr MetricsPusher; 43 | 44 | } // namespace dnp3 45 | } // namespace otsim 46 | 47 | #endif // OTSIM_DNP3_COMMON_HPP -------------------------------------------------------------------------------- /src/c++/dnp3/server.cpp: -------------------------------------------------------------------------------- 1 | #include 2 | 3 | #include "server.hpp" 4 | 5 | #include "opendnp3/channel/ChannelRetry.h" 6 | #include "opendnp3/channel/IPEndpoint.h" 7 | #include "opendnp3/channel/SerialSettings.h" 8 | #include "opendnp3/ConsoleLogger.h" 9 | #include "opendnp3/gen/ServerAcceptMode.h" 10 | #include "opendnp3/logging/LogLevels.h" 11 | #include "opendnp3/outstation/DefaultOutstationApplication.h" 12 | #include "opendnp3/outstation/IOutstationApplication.h" 13 | #include "opendnp3/outstation/UpdateBuilder.h" 14 | 15 | namespace otsim { 16 | namespace dnp3 { 17 | 18 | Server::Server(const std::uint16_t cold) : coldRestartSecs(cold) 19 | { 20 | manager.reset(new opendnp3::DNP3Manager(std::thread::hardware_concurrency(), opendnp3::ConsoleLogger::Create())); 21 | } 22 | 23 | bool Server::Init(const std::string& id, const opendnp3::IPEndpoint endpoint, const opendnp3::ServerAcceptMode acceptMode) { 24 | try { 25 | channel = manager->AddTCPServer( 26 | id, 27 | opendnp3::levels::NORMAL, 28 | acceptMode, 29 | endpoint, 30 | nullptr 31 | ); 32 | } catch (std::exception& e) { 33 | return false; 34 | } 35 | 36 | return true; 37 | } 38 | 39 | bool Server::Init(const std::string& id, const opendnp3::SerialSettings serial, const opendnp3::ChannelRetry channelRetry) { 40 | try { 41 | channel = manager->AddSerial( 42 | id, 43 | opendnp3::levels::NORMAL, 44 | channelRetry, 45 | serial, 46 | nullptr 47 | ); 48 | } catch (std::exception& e) { 49 | return false; 50 | } 51 | 52 | return true; 53 | } 54 | 55 | std::shared_ptr Server::AddOutstation(OutstationConfig config, OutstationRestartConfig restart, Pusher pusher) { 56 | std::cout << "adding outstation " << config.remoteAddr << " --> " << config.localAddr << std::endl; 57 | 58 | restart.cold = coldRestartSecs; 59 | restart.coldRestarter = std::bind(&Server::HandleColdRestart, this, std::placeholders::_1); 60 | 61 | auto outstation = Outstation::Create(config, restart, pusher); 62 | outstations[config.localAddr] = outstation; 63 | 64 | return outstation; 65 | } 66 | 67 | void Server::Start() { 68 | for (const auto& kv : outstations) { 69 | auto outstation = kv.second; 70 | auto config = outstation->Init(); 71 | 72 | auto iOutstation = channel->AddOutstation(outstation->ID(), outstation, outstation, config); 73 | 74 | outstation->SetIOutstation(iOutstation); 75 | outstation->Enable(); 76 | 77 | threads.push_back(std::thread(std::bind(&Outstation::Run, outstation))); 78 | } 79 | } 80 | 81 | void Server::Stop() { 82 | for (const auto& kv : outstations) { 83 | kv.second->Disable(); 84 | } 85 | 86 | for (auto &t : threads) { 87 | if (t.joinable()) { 88 | t.join(); 89 | } 90 | } 91 | } 92 | 93 | void Server::HandleColdRestart(std::uint16_t outstation) { 94 | for (const auto& kv : outstations) { 95 | std::cout << "disabling outstation " << kv.first << " for " << coldRestartSecs << " seconds" << std::endl; 96 | 97 | auto outstation = kv.second; 98 | 99 | outstation->ResetOutputs(); 100 | outstation->Disable(); 101 | } 102 | 103 | std::this_thread::sleep_for(std::chrono::seconds(coldRestartSecs)); 104 | 105 | for (const auto& kv : outstations) { 106 | std::cout << "enabling outstation " << kv.first << std::endl; 107 | kv.second->Enable(); 108 | } 109 | } 110 | 111 | } // namespace dnp3 112 | } // namespace otsim 113 | -------------------------------------------------------------------------------- /src/c++/dnp3/server.hpp: -------------------------------------------------------------------------------- 1 | #ifndef OTSIM_DNP3_SERVER_HPP 2 | #define OTSIM_DNP3_SERVER_HPP 3 | 4 | #include 5 | 6 | #include "outstation.hpp" 7 | 8 | #include "opendnp3/DNP3Manager.h" 9 | 10 | namespace otsim { 11 | namespace dnp3 { 12 | 13 | class Server : public std::enable_shared_from_this { 14 | public: 15 | static std::shared_ptr Create(const std::uint16_t cold) { 16 | return std::make_shared(cold); 17 | } 18 | 19 | Server(const std::uint16_t cold); 20 | ~Server() {}; 21 | 22 | bool Init(const std::string& id, const opendnp3::IPEndpoint endpoint, const opendnp3::ServerAcceptMode acceptMode = opendnp3::ServerAcceptMode::CloseNew); 23 | bool Init(const std::string& id, const opendnp3::SerialSettings serial, const opendnp3::ChannelRetry channelRetry = opendnp3::ChannelRetry::Default()); 24 | 25 | std::shared_ptr AddOutstation(OutstationConfig config, OutstationRestartConfig restart, Pusher pusher); 26 | 27 | void Start(); 28 | void Stop(); 29 | 30 | void HandleColdRestart(std::uint16_t outstation); 31 | 32 | private: 33 | std::shared_ptr manager; // Outstation stack manager 34 | std::shared_ptr channel; // TCPServer channel 35 | 36 | std::uint16_t coldRestartSecs; 37 | 38 | // Keep track of warm restart delay and binary/analog points per-outstation. 39 | // The key is the outstation local address. 40 | std::map> outstations; 41 | 42 | // Keep outstation threads in scope so they don't terminate immediately. 43 | std::vector threads; 44 | }; 45 | 46 | } // namespace dnp3 47 | } // namespace otsim 48 | 49 | #endif // OTSIM_DNP3_SERVER_HPP 50 | -------------------------------------------------------------------------------- /src/c++/msgbus/CMakeLists.txt: -------------------------------------------------------------------------------- 1 | include_directories( 2 | ${CPPZMQ_INCLUDE_DIRS} 3 | ${JSON_INCLUDE_DIRS} 4 | ) 5 | 6 | file(GLOB_RECURSE ot-sim-msgbus_SRC *.cpp *.hpp) 7 | 8 | add_library(ot-sim-msgbus SHARED 9 | ${ot-sim-msgbus_SRC} 10 | ) 11 | 12 | target_link_libraries(ot-sim-msgbus 13 | cppzmq 14 | nlohmann_json 15 | ) 16 | 17 | install(TARGETS ot-sim-msgbus 18 | ARCHIVE DESTINATION lib 19 | LIBRARY DESTINATION lib 20 | RUNTIME DESTINATION bin 21 | ) -------------------------------------------------------------------------------- /src/c++/msgbus/envelope.hpp: -------------------------------------------------------------------------------- 1 | #ifndef OTSIM_MSGBUS_ENVELOPE_HPP 2 | #define OTSIM_MSGBUS_ENVELOPE_HPP 3 | 4 | #include 5 | #include 6 | #include 7 | 8 | #include "nlohmann/json.hpp" 9 | 10 | using json = nlohmann::json; 11 | 12 | namespace otsim { 13 | namespace msgbus { 14 | 15 | typedef std::map Metadata; 16 | typedef std::map ConfirmationErrors; 17 | 18 | template 19 | struct Envelope { 20 | std::string version {}; 21 | std::string kind {}; 22 | Metadata metadata {}; 23 | T contents {}; 24 | }; 25 | 26 | struct Point { 27 | std::string tag {}; 28 | double value {}; 29 | std::uint64_t ts {}; 30 | }; 31 | 32 | using Points = std::vector; 33 | 34 | struct Status { 35 | Points measurements {}; 36 | }; 37 | 38 | struct Update { 39 | Points updates {}; 40 | std::string recipient {}; 41 | std::string confirm {}; 42 | }; 43 | 44 | struct Confirmation { 45 | std::string confirm {}; 46 | ConfirmationErrors errors {}; 47 | }; 48 | 49 | struct Metric { 50 | std::string kind {}; 51 | std::string name {}; 52 | std::string desc {}; 53 | double value {}; 54 | }; 55 | 56 | struct Metrics { 57 | std::vector metrics {}; 58 | }; 59 | 60 | template 61 | Envelope NewEnvelope(const std::string &sender, T contents) { 62 | Envelope env = { 63 | .version = "v1", 64 | .metadata = std::map {{ "sender", sender}}, 65 | .contents = contents, 66 | }; 67 | 68 | if (std::is_same_v) { 69 | env.kind = "Status"; 70 | } else if (std::is_same_v) { 71 | env.kind = "Update"; 72 | } else if (std::is_same_v) { 73 | env.kind = "Metric"; 74 | } 75 | 76 | return env; 77 | } 78 | 79 | template 80 | std::string GetEnvelopeSender(Envelope env) { 81 | if (env.metadata.count("sender")) { 82 | return env.metadata.at("sender"); 83 | } 84 | 85 | return ""; 86 | } 87 | 88 | template 89 | void to_json(json& j, const Envelope& e) { 90 | j["version"] = e.version; 91 | j["kind"] = e.kind; 92 | j["metadata"] = e.metadata; 93 | j["contents"] = e.contents; 94 | } 95 | 96 | template 97 | void from_json(const json& j, Envelope& e) { 98 | j.at("version").get_to(e.version); 99 | j.at("kind").get_to(e.kind); 100 | j.at("metadata").get_to(e.metadata); 101 | j.at("contents").get_to(e.contents); 102 | } 103 | 104 | NLOHMANN_DEFINE_TYPE_NON_INTRUSIVE(Point, tag, value, ts) 105 | NLOHMANN_DEFINE_TYPE_NON_INTRUSIVE(Status, measurements) 106 | NLOHMANN_DEFINE_TYPE_NON_INTRUSIVE(Update, updates, recipient, confirm) 107 | NLOHMANN_DEFINE_TYPE_NON_INTRUSIVE(Confirmation, confirm, errors) 108 | NLOHMANN_DEFINE_TYPE_NON_INTRUSIVE(Metric, kind, name, desc, value) 109 | NLOHMANN_DEFINE_TYPE_NON_INTRUSIVE(Metrics, metrics) 110 | 111 | } // namespace msgbus 112 | } // namespace otsim 113 | 114 | #endif // OTSIM_MSGBUS_ENVELOPE_HPP -------------------------------------------------------------------------------- /src/c++/msgbus/metrics.cpp: -------------------------------------------------------------------------------- 1 | #include "metrics.hpp" 2 | 3 | namespace otsim { 4 | namespace msgbus { 5 | 6 | void MetricsPusher::Start(std::shared_ptr pusher, const std::string& name) { 7 | thread = std::thread(&MetricsPusher::run, this, pusher, name); 8 | } 9 | 10 | void MetricsPusher::Stop() { 11 | running.store(false); 12 | 13 | if (thread.joinable()) { 14 | thread.join(); 15 | } 16 | } 17 | 18 | void MetricsPusher::NewMetric(const std::string& kind, const std::string& name, const std::string& desc) { 19 | Metric metric = { 20 | .kind = kind, 21 | .name = name, 22 | .desc = desc, 23 | }; 24 | 25 | metrics[name] = metric; 26 | } 27 | 28 | void MetricsPusher::IncrMetric(const std::string &name) { 29 | try { 30 | auto lock = std::unique_lock(metricsMu); 31 | 32 | auto metric = metrics.at(name); 33 | metric.value += 1.0; 34 | 35 | metrics[name] = metric; 36 | } catch(const std::out_of_range&) {} 37 | } 38 | 39 | void MetricsPusher::IncrMetricBy(const std::string &name, int val) { 40 | try { 41 | auto lock = std::unique_lock(metricsMu); 42 | 43 | auto metric = metrics.at(name); 44 | metric.value += val; 45 | 46 | metrics[name] = metric; 47 | } catch(const std::out_of_range&) {} 48 | } 49 | 50 | void MetricsPusher::SetMetric(const std::string &name, double val) { 51 | try { 52 | auto lock = std::unique_lock(metricsMu); 53 | 54 | auto metric = metrics.at(name); 55 | metric.value = val; 56 | 57 | metrics[name] = metric; 58 | } catch(const std::out_of_range&) {} 59 | } 60 | 61 | void MetricsPusher::run(std::shared_ptr pusher, const std::string& name) { 62 | auto prefix = name + "_"; 63 | 64 | running.store(true); 65 | 66 | while (running) { 67 | std::vector updates; 68 | 69 | { 70 | auto lock = std::unique_lock(metricsMu); 71 | 72 | for (auto [name, metric] : metrics) { 73 | auto copy = metric; 74 | 75 | auto pos = std::mismatch(prefix.begin(), prefix.end(), copy.name.begin()); 76 | if (pos.first != name.end()) { 77 | copy.name = prefix + copy.name; 78 | } 79 | 80 | updates.push_back(copy); 81 | } 82 | } 83 | 84 | if (updates.size() > 0) { 85 | auto env = NewEnvelope(name, Metrics{.metrics = updates}); 86 | pusher->Push("HEALTH", env); 87 | } 88 | 89 | std::this_thread::sleep_for(std::chrono::seconds(5)); 90 | } 91 | } 92 | 93 | } // namespace msgbus 94 | } // namespace otsim -------------------------------------------------------------------------------- /src/c++/msgbus/metrics.hpp: -------------------------------------------------------------------------------- 1 | #ifndef OTSIM_MSGBUS_METRICS_HPP 2 | #define OTSIM_MSGBUS_METRICS_HPP 3 | 4 | #include 5 | #include 6 | #include 7 | 8 | #include "envelope.hpp" 9 | #include "pusher.hpp" 10 | 11 | namespace otsim { 12 | namespace msgbus { 13 | 14 | class MetricsPusher { 15 | public: 16 | static std::shared_ptr Create() { 17 | return std::make_shared(); 18 | } 19 | 20 | void Start(std::shared_ptr pusher, const std::string& name); 21 | void Stop(); 22 | 23 | void NewMetric(const std::string& kind, const std::string& name, const std::string& desc); 24 | void IncrMetric(const std::string& name); 25 | void IncrMetricBy(const std::string& name, int val); 26 | void SetMetric(const std::string& name, double val); 27 | 28 | private: 29 | void run(std::shared_ptr pusher, const std::string& name); 30 | 31 | std::atomic running; 32 | std::thread thread; 33 | 34 | std::map metrics; 35 | std::mutex metricsMu; 36 | }; 37 | 38 | } // namespace msgbus 39 | } // namespace otsim 40 | 41 | #endif // OTSIM_MSGBUS_METRICS_HPP -------------------------------------------------------------------------------- /src/c++/msgbus/pusher.cpp: -------------------------------------------------------------------------------- 1 | #include "pusher.hpp" 2 | 3 | namespace otsim { 4 | namespace msgbus { 5 | 6 | Pusher::Pusher(const std::string& endpoint) { 7 | socket = zmq::socket_t(ctx, ZMQ_PUSH); 8 | 9 | socket.connect(endpoint); 10 | socket.set(zmq::sockopt::linger, 0); 11 | } 12 | 13 | Pusher::~Pusher() { 14 | socket.close(); 15 | ctx.close(); 16 | } 17 | 18 | } // namespace msgbus 19 | } // namespace otsim -------------------------------------------------------------------------------- /src/c++/msgbus/pusher.hpp: -------------------------------------------------------------------------------- 1 | #ifndef OTSIM_MSGBUS_PUSHER_HPP 2 | #define OTSIM_MSGBUS_PUSHER_HPP 3 | 4 | #include 5 | #include 6 | 7 | #include "envelope.hpp" 8 | #include "cppzmq/zmq.hpp" 9 | #include "nlohmann/json.hpp" 10 | 11 | using json = nlohmann::json; 12 | 13 | namespace otsim { 14 | namespace msgbus { 15 | 16 | class Pusher { 17 | public: 18 | static std::shared_ptr Create(const std::string& endpoint) { 19 | return std::make_shared(endpoint); 20 | } 21 | 22 | Pusher(const std::string& endpoint); 23 | ~Pusher(); 24 | 25 | template // must be implemented in header file since it's templated 26 | void Push(const std::string& topic, const Envelope& env) { 27 | json j = env; 28 | 29 | std::stringstream msg; 30 | msg << j; 31 | 32 | socket.send(zmq::message_t(topic), zmq::send_flags::sndmore); 33 | socket.send(zmq::message_t(msg.str()), zmq::send_flags::none); 34 | } 35 | 36 | private: 37 | zmq::context_t ctx; 38 | zmq::socket_t socket; 39 | }; 40 | 41 | } // namespace msgbus 42 | } // namespace otsim 43 | 44 | #endif // OTSIM_MSGBUS_PUSHER_HPP -------------------------------------------------------------------------------- /src/c++/msgbus/subscriber.cpp: -------------------------------------------------------------------------------- 1 | #include "subscriber.hpp" 2 | #include "nlohmann/json.hpp" 3 | 4 | using json = nlohmann::json; 5 | 6 | namespace otsim { 7 | namespace msgbus { 8 | 9 | Subscriber::Subscriber(const std::string& endpoint) { 10 | socket = zmq::socket_t(ctx, ZMQ_SUB); 11 | 12 | socket.connect(endpoint); 13 | socket.set(zmq::sockopt::linger, 0); 14 | } 15 | 16 | Subscriber::~Subscriber() { 17 | socket.close(); 18 | ctx.close(); 19 | } 20 | 21 | void Subscriber::Start(const std::string& topic) { 22 | thread = std::thread(&Subscriber::run, this, topic); 23 | } 24 | 25 | void Subscriber::Stop() { 26 | running.store(false); 27 | ctx.shutdown(); 28 | 29 | if (thread.joinable()) { 30 | thread.join(); 31 | } 32 | } 33 | 34 | void Subscriber::run(const std::string& topic) { 35 | socket.set(zmq::sockopt::subscribe, topic); 36 | 37 | running.store(true); 38 | 39 | while (running) { 40 | zmq::message_t t; 41 | zmq::recv_result_t ret; 42 | 43 | try { 44 | ret = socket.recv(t); 45 | if (!ret.has_value()) { 46 | continue; 47 | } 48 | } catch (zmq::error_t&) { 49 | continue; 50 | } 51 | 52 | // This shouldn't ever really happen... 53 | if (t.to_string() != topic) { 54 | continue; 55 | } 56 | 57 | zmq::message_t msg; 58 | 59 | try { 60 | ret = socket.recv(msg); 61 | if (!ret.has_value()) { 62 | continue; 63 | } 64 | } catch (zmq::error_t&) { 65 | continue; 66 | } 67 | 68 | std::stringstream str(msg.to_string()); 69 | 70 | json j; 71 | str >> j; 72 | 73 | if (j["kind"] == "Status") { 74 | auto env = j.get>(); 75 | 76 | for (auto &handler : statusHandlers) { 77 | handler(env); 78 | } 79 | } 80 | 81 | if (j["kind"] == "Update") { 82 | auto env = j.get>(); 83 | 84 | for (auto &handler : updateHandlers) { 85 | handler(env); 86 | } 87 | } 88 | } 89 | } 90 | 91 | } // namespace msgbus 92 | } // namespace otsim -------------------------------------------------------------------------------- /src/c++/msgbus/subscriber.hpp: -------------------------------------------------------------------------------- 1 | #ifndef OTSIM_MSGBUS_SUBSCRIBER_HPP 2 | #define OTSIM_MSGBUS_SUBSCRIBER_HPP 3 | 4 | #include 5 | #include 6 | #include 7 | 8 | #include "envelope.hpp" 9 | #include "cppzmq/zmq.hpp" 10 | 11 | namespace otsim { 12 | namespace msgbus { 13 | 14 | typedef std::function&)> StatusHandler; 15 | typedef std::function&)> UpdateHandler; 16 | 17 | class Subscriber { 18 | public: 19 | static std::shared_ptr Create(const std::string& endpoint) { 20 | return std::make_shared(endpoint); 21 | } 22 | 23 | Subscriber(const std::string& endpoint); 24 | ~Subscriber(); 25 | 26 | void AddHandler(StatusHandler handler) { 27 | statusHandlers.push_back(handler); 28 | } 29 | 30 | void AddHandler(UpdateHandler handler) { 31 | updateHandlers.push_back(handler); 32 | } 33 | 34 | void Start(const std::string& topic); 35 | void Stop(); 36 | 37 | private: 38 | void run(const std::string& topic); 39 | 40 | zmq::context_t ctx; 41 | zmq::socket_t socket; 42 | 43 | std::atomic running; 44 | std::thread thread; 45 | 46 | std::vector statusHandlers; 47 | std::vector updateHandlers; 48 | }; 49 | 50 | } // namespace msgbus 51 | } // namespace otsim 52 | 53 | #endif // OTSIM_MSGBUS_SUBSCRIBER_HPP -------------------------------------------------------------------------------- /src/c/.vscode/c_cpp_properties.json: -------------------------------------------------------------------------------- 1 | { 2 | "configurations": [ 3 | { 4 | "name": "Linux", 5 | "includePath": [ 6 | "${workspaceFolder}/**", 7 | "/usr/include/**", 8 | "/usr/include/libxml2/**", 9 | "/usr/local/include/**" 10 | ], 11 | "defines": [], 12 | "compilerPath": "/usr/bin/gcc", 13 | "cStandard": "gnu17", 14 | "cppStandard": "gnu++14", 15 | "intelliSenseMode": "linux-gcc-x64" 16 | } 17 | ], 18 | "version": 4 19 | } 20 | -------------------------------------------------------------------------------- /src/c/CMakeLists.txt: -------------------------------------------------------------------------------- 1 | add_subdirectory(cmd/ot-sim-message-bus) 2 | -------------------------------------------------------------------------------- /src/c/cmd/ot-sim-message-bus/CMakeLists.txt: -------------------------------------------------------------------------------- 1 | find_package(LibXml2 REQUIRED) 2 | 3 | include_directories( 4 | ${LIBXML2_INCLUDE_DIR} 5 | ) 6 | 7 | add_executable(ot-sim-message-bus 8 | main.c 9 | ) 10 | 11 | target_link_libraries(ot-sim-message-bus 12 | czmq 13 | ${LIBXML2_LIBRARIES} 14 | zmq 15 | ) 16 | 17 | install(TARGETS ot-sim-message-bus 18 | RUNTIME DESTINATION bin 19 | ) -------------------------------------------------------------------------------- /src/c/cmd/ot-sim-message-bus/main.c: -------------------------------------------------------------------------------- 1 | #include 2 | #include 3 | 4 | typedef struct { 5 | int verbose; 6 | const char *pull; 7 | const char *pub; 8 | const char *debug; 9 | } config; 10 | 11 | #define MATCHXML(e, n) xmlStrcmp(e->name, (const xmlChar*) n) == 0 12 | 13 | static int xml_handler(config *c, xmlDoc *doc, xmlNode *node) { 14 | xmlChar *text; 15 | 16 | node = node->xmlChildrenNode; 17 | 18 | while (node != NULL) { 19 | text = xmlNodeListGetString(doc, node->xmlChildrenNode, 1); 20 | 21 | if (MATCHXML(node, "verbose")) { 22 | c->verbose = atoi(text); 23 | } else if (MATCHXML(node, "pull-endpoint")) { 24 | c->pull = strdup(text); 25 | } else if (MATCHXML(node, "pub-endpoint")) { 26 | c->pub = strdup(text); 27 | } else if (MATCHXML(node, "debug-endpoint")) { 28 | c->debug = strdup(text); 29 | } 30 | 31 | xmlFree(text); 32 | node = node->next; 33 | } 34 | 35 | return 0; 36 | } 37 | 38 | static int xml_parse(char *path, config *c) { 39 | xmlDoc *doc; 40 | xmlNode *node; 41 | 42 | doc = xmlParseFile(path); 43 | 44 | if (doc == NULL) { 45 | return -1; 46 | } 47 | 48 | node = xmlDocGetRootElement(doc); 49 | 50 | if (node == NULL) { 51 | xmlFreeDoc(doc); 52 | return -1; 53 | } 54 | 55 | if (!MATCHXML(node, "ot-sim")) { 56 | xmlFreeDoc(doc); 57 | return -1; 58 | } 59 | 60 | node = node->xmlChildrenNode; 61 | 62 | while (node != NULL) { 63 | // look for top-level message-bus element only 64 | if (MATCHXML(node, "message-bus")) { 65 | int rc = xml_handler(c, doc, node); 66 | 67 | xmlFreeDoc(doc); 68 | return rc; 69 | } 70 | 71 | node = node->next; 72 | } 73 | 74 | xmlFreeDoc(doc); 75 | return 0; 76 | } 77 | 78 | int main(int argc, char *argv[]) { 79 | config c; 80 | 81 | c.verbose = 0; 82 | c.pull = "tcp://127.0.0.1:1234"; 83 | c.pub = "tcp://127.0.0.1:5678"; 84 | c.debug = NULL; 85 | 86 | if (argc == 2) { 87 | printf("loading config %s\n", argv[1]); 88 | 89 | if (xml_parse(argv[1], &c) != 0) { 90 | puts("cannot load XML config"); 91 | return 1; 92 | } 93 | } 94 | 95 | zactor_t *proxy = zactor_new(zproxy, NULL); 96 | assert (proxy); 97 | 98 | if (c.verbose) { 99 | puts("setting proxy to verbose"); 100 | 101 | zstr_sendx(proxy, "VERBOSE", NULL); 102 | zsock_wait(proxy); 103 | } 104 | 105 | printf("using %s for PULL endpoint\n", c.pull); 106 | 107 | zstr_sendx(proxy, "FRONTEND", "PULL", c.pull, NULL); 108 | zsock_wait(proxy); 109 | 110 | printf("using %s for PUB endpoint\n", c.pub); 111 | 112 | zstr_sendx(proxy, "BACKEND", "PUB", c.pub, NULL); 113 | zsock_wait(proxy); 114 | 115 | if (c.debug) { 116 | printf("setting up proxy capture to debug endpoint %s\n", c.debug); 117 | 118 | zstr_sendx(proxy, "CAPTURE", c.debug, NULL); 119 | zsock_wait(proxy); 120 | } 121 | 122 | while(1) { 123 | puts("proxy running"); 124 | 125 | unsigned int remaining = sleep(UINT_MAX); 126 | 127 | // likely not interrupted with signal 128 | if (remaining == 0) { 129 | continue; 130 | } 131 | 132 | // interrupted with signal 133 | break; 134 | } 135 | 136 | printf("\nexiting proxy\n"); 137 | 138 | zactor_destroy(&proxy); 139 | return 0; 140 | } -------------------------------------------------------------------------------- /src/go/.gitignore: -------------------------------------------------------------------------------- 1 | sunspec/types.go 2 | -------------------------------------------------------------------------------- /src/go/cmd/ot-sim-cpu-module/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "fmt" 7 | "os" 8 | 9 | otsim "github.com/patsec/ot-sim" 10 | "github.com/patsec/ot-sim/util" 11 | "github.com/patsec/ot-sim/util/sigterm" 12 | 13 | // This will cause the CPU module to register itself with the otsim package so 14 | // it gets run by the otsim.Start function below. 15 | _ "github.com/patsec/ot-sim/cpu" 16 | ) 17 | 18 | func main() { 19 | if len(os.Args) != 2 { 20 | panic("path to config file not provided") 21 | } 22 | 23 | if err := otsim.ParseConfigFile(os.Args[1]); err != nil { 24 | fmt.Printf("Error parsing config file: %v\n", err) 25 | os.Exit(util.ExitNoRestart) 26 | } 27 | 28 | ctx := sigterm.CancelContext(context.Background()) 29 | ctx = util.SetConfigFile(ctx, os.Args[1]) 30 | 31 | if err := otsim.Start(ctx); err != nil { 32 | fmt.Printf("Error starting CPU module: %v\n", err) 33 | 34 | var exitErr util.ExitError 35 | if errors.As(err, &exitErr) { 36 | os.Exit(exitErr.ExitCode) 37 | } 38 | 39 | os.Exit(1) 40 | } 41 | 42 | <-ctx.Done() 43 | otsim.Waiter.Wait() // wait for all started modules to stop 44 | 45 | if err := ctx.Err(); err != nil && !errors.Is(err, context.Canceled) { 46 | fmt.Printf("Error running CPU module: %v\n", err) 47 | 48 | var exitErr util.ExitError 49 | if errors.As(err, &exitErr) { 50 | os.Exit(exitErr.ExitCode) 51 | } 52 | 53 | os.Exit(1) 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /src/go/cmd/ot-sim-logic-module/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "fmt" 7 | "os" 8 | 9 | otsim "github.com/patsec/ot-sim" 10 | "github.com/patsec/ot-sim/util" 11 | "github.com/patsec/ot-sim/util/sigterm" 12 | 13 | // This will cause the Logic module to register itself with the otsim package 14 | // so it gets run by the otsim.Start function below. 15 | _ "github.com/patsec/ot-sim/logic" 16 | ) 17 | 18 | func main() { 19 | if len(os.Args) != 2 { 20 | panic("path to config file not provided") 21 | } 22 | 23 | if err := otsim.ParseConfigFile(os.Args[1]); err != nil { 24 | fmt.Printf("Error parsing config file: %v\n", err) 25 | os.Exit(util.ExitNoRestart) 26 | } 27 | 28 | ctx := sigterm.CancelContext(context.Background()) 29 | 30 | if err := otsim.Start(ctx); err != nil { 31 | fmt.Printf("Error starting Logic module: %v\n", err) 32 | 33 | var exitErr util.ExitError 34 | if errors.As(err, &exitErr) { 35 | os.Exit(exitErr.ExitCode) 36 | } 37 | 38 | os.Exit(1) 39 | } 40 | 41 | <-ctx.Done() 42 | 43 | if err := ctx.Err(); err != nil && !errors.Is(err, context.Canceled) { 44 | fmt.Printf("Error running Logic module: %v\n", err) 45 | 46 | var exitErr util.ExitError 47 | if errors.As(err, &exitErr) { 48 | os.Exit(exitErr.ExitCode) 49 | } 50 | 51 | os.Exit(1) 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /src/go/cmd/ot-sim-modbus-module/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "fmt" 7 | "os" 8 | 9 | otsim "github.com/patsec/ot-sim" 10 | "github.com/patsec/ot-sim/util" 11 | "github.com/patsec/ot-sim/util/sigterm" 12 | 13 | // This will cause the Modbus module to register itself with the otsim package 14 | // so it gets run by the otsim.Start function below. 15 | _ "github.com/patsec/ot-sim/modbus" 16 | ) 17 | 18 | func main() { 19 | if len(os.Args) != 2 { 20 | panic("path to config file not provided") 21 | } 22 | 23 | if err := otsim.ParseConfigFile(os.Args[1]); err != nil { 24 | fmt.Printf("Error parsing config file: %v\n", err) 25 | os.Exit(util.ExitNoRestart) 26 | } 27 | 28 | ctx := sigterm.CancelContext(context.Background()) 29 | 30 | if err := otsim.Start(ctx); err != nil { 31 | fmt.Printf("Error starting Modbus module: %v\n", err) 32 | 33 | var exitErr util.ExitError 34 | if errors.As(err, &exitErr) { 35 | os.Exit(exitErr.ExitCode) 36 | } 37 | 38 | os.Exit(1) 39 | } 40 | 41 | <-ctx.Done() 42 | 43 | if err := ctx.Err(); err != nil && !errors.Is(err, context.Canceled) { 44 | fmt.Printf("Error running Modbus module: %v\n", err) 45 | 46 | var exitErr util.ExitError 47 | if errors.As(err, &exitErr) { 48 | os.Exit(exitErr.ExitCode) 49 | } 50 | 51 | os.Exit(1) 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /src/go/cmd/ot-sim-mqtt-module/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "fmt" 7 | "os" 8 | 9 | otsim "github.com/patsec/ot-sim" 10 | "github.com/patsec/ot-sim/util" 11 | "github.com/patsec/ot-sim/util/sigterm" 12 | 13 | // This will cause the MQTT module to register itself with the otsim package 14 | // so it gets run by the otsim.Start function below. 15 | _ "github.com/patsec/ot-sim/mqtt" 16 | ) 17 | 18 | func main() { 19 | if len(os.Args) != 2 { 20 | panic("path to config file not provided") 21 | } 22 | 23 | if err := otsim.ParseConfigFile(os.Args[1]); err != nil { 24 | fmt.Printf("Error parsing config file: %v\n", err) 25 | os.Exit(util.ExitNoRestart) 26 | } 27 | 28 | ctx := sigterm.CancelContext(context.Background()) 29 | 30 | if err := otsim.Start(ctx); err != nil { 31 | fmt.Printf("Error starting MQTT module: %v\n", err) 32 | 33 | var exitErr util.ExitError 34 | if errors.As(err, &exitErr) { 35 | os.Exit(exitErr.ExitCode) 36 | } 37 | 38 | os.Exit(1) 39 | } 40 | 41 | <-ctx.Done() 42 | 43 | if err := ctx.Err(); err != nil && !errors.Is(err, context.Canceled) { 44 | fmt.Printf("Error running MQTT module: %v\n", err) 45 | 46 | var exitErr util.ExitError 47 | if errors.As(err, &exitErr) { 48 | os.Exit(exitErr.ExitCode) 49 | } 50 | 51 | os.Exit(1) 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /src/go/cmd/ot-sim-node-red-module/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "fmt" 7 | "os" 8 | 9 | otsim "github.com/patsec/ot-sim" 10 | "github.com/patsec/ot-sim/util" 11 | "github.com/patsec/ot-sim/util/sigterm" 12 | 13 | // This will cause the Node-RED module to register itself with the otsim 14 | // package so it gets run by the otsim.Start function below. 15 | _ "github.com/patsec/ot-sim/nodered" 16 | ) 17 | 18 | func main() { 19 | if len(os.Args) != 2 { 20 | panic("path to config file not provided") 21 | } 22 | 23 | if err := otsim.ParseConfigFile(os.Args[1]); err != nil { 24 | fmt.Printf("Error parsing config file: %v\n", err) 25 | os.Exit(util.ExitNoRestart) 26 | } 27 | 28 | ctx := sigterm.CancelContext(context.Background()) 29 | 30 | if err := otsim.Start(ctx); err != nil { 31 | fmt.Printf("Error starting Node-RED module: %v\n", err) 32 | 33 | var exitErr util.ExitError 34 | if errors.As(err, &exitErr) { 35 | os.Exit(exitErr.ExitCode) 36 | } 37 | 38 | os.Exit(1) 39 | } 40 | 41 | <-ctx.Done() 42 | 43 | if err := ctx.Err(); err != nil && !errors.Is(err, context.Canceled) { 44 | fmt.Printf("Error running Node-RED module: %v\n", err) 45 | 46 | var exitErr util.ExitError 47 | if errors.As(err, &exitErr) { 48 | os.Exit(exitErr.ExitCode) 49 | } 50 | 51 | os.Exit(1) 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /src/go/cmd/ot-sim-sunspec-module/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "fmt" 7 | "os" 8 | 9 | otsim "github.com/patsec/ot-sim" 10 | "github.com/patsec/ot-sim/util" 11 | "github.com/patsec/ot-sim/util/sigterm" 12 | 13 | // This will cause the SunSpec module to register itself with the otsim package 14 | // so it gets run by the otsim.Start function below. 15 | _ "github.com/patsec/ot-sim/sunspec" 16 | ) 17 | 18 | func main() { 19 | if len(os.Args) != 2 { 20 | panic("path to config file not provided") 21 | } 22 | 23 | if err := otsim.ParseConfigFile(os.Args[1]); err != nil { 24 | fmt.Printf("Error parsing config file: %v\n", err) 25 | os.Exit(util.ExitNoRestart) 26 | } 27 | 28 | ctx := sigterm.CancelContext(context.Background()) 29 | 30 | if err := otsim.Start(ctx); err != nil { 31 | fmt.Printf("Error starting SunSpec module: %v\n", err) 32 | 33 | var exitErr util.ExitError 34 | if errors.As(err, &exitErr) { 35 | os.Exit(exitErr.ExitCode) 36 | } 37 | 38 | os.Exit(1) 39 | } 40 | 41 | <-ctx.Done() 42 | 43 | if err := ctx.Err(); err != nil && !errors.Is(err, context.Canceled) { 44 | fmt.Printf("Error running SunSpec module: %v\n", err) 45 | 46 | var exitErr util.ExitError 47 | if errors.As(err, &exitErr) { 48 | os.Exit(exitErr.ExitCode) 49 | } 50 | 51 | os.Exit(1) 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /src/go/cmd/ot-sim-tailscale-module/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "fmt" 7 | "os" 8 | 9 | otsim "github.com/patsec/ot-sim" 10 | "github.com/patsec/ot-sim/util" 11 | "github.com/patsec/ot-sim/util/sigterm" 12 | 13 | // This will cause the Tailscale module to register itself with the otsim 14 | // package so it gets run by the otsim.Start function below. 15 | _ "github.com/patsec/ot-sim/tailscale" 16 | ) 17 | 18 | func main() { 19 | if len(os.Args) != 2 { 20 | panic("path to config file not provided") 21 | } 22 | 23 | if err := otsim.ParseConfigFile(os.Args[1]); err != nil { 24 | fmt.Printf("Error parsing config file: %v\n", err) 25 | os.Exit(util.ExitNoRestart) 26 | } 27 | 28 | ctx := sigterm.CancelContext(context.Background()) 29 | 30 | if err := otsim.Start(ctx); err != nil { 31 | fmt.Printf("Error starting Tailscale module: %v\n", err) 32 | 33 | var exitErr util.ExitError 34 | if errors.As(err, &exitErr) { 35 | os.Exit(exitErr.ExitCode) 36 | } 37 | 38 | os.Exit(1) 39 | } 40 | 41 | <-ctx.Done() 42 | 43 | if err := ctx.Err(); err != nil && !errors.Is(err, context.Canceled) { 44 | fmt.Printf("Error running Tailscale module: %v\n", err) 45 | 46 | var exitErr util.ExitError 47 | if errors.As(err, &exitErr) { 48 | os.Exit(exitErr.ExitCode) 49 | } 50 | 51 | os.Exit(1) 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /src/go/cmd/ot-sim-telnet-module/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "fmt" 7 | "os" 8 | 9 | otsim "github.com/patsec/ot-sim" 10 | "github.com/patsec/ot-sim/util" 11 | "github.com/patsec/ot-sim/util/sigterm" 12 | 13 | // This will cause the Telnet module to register itself with the otsim package 14 | // so it gets run by the otsim.Start function below. 15 | _ "github.com/patsec/ot-sim/telnet" 16 | ) 17 | 18 | func main() { 19 | if len(os.Args) != 2 { 20 | panic("path to config file not provided") 21 | } 22 | 23 | if err := otsim.ParseConfigFile(os.Args[1]); err != nil { 24 | fmt.Printf("Error parsing config file: %v\n", err) 25 | os.Exit(util.ExitNoRestart) 26 | } 27 | 28 | ctx := sigterm.CancelContext(context.Background()) 29 | 30 | if err := otsim.Start(ctx); err != nil { 31 | fmt.Printf("Error starting Telnet module: %v\n", err) 32 | 33 | var exitErr util.ExitError 34 | if errors.As(err, &exitErr) { 35 | os.Exit(exitErr.ExitCode) 36 | } 37 | 38 | os.Exit(1) 39 | } 40 | 41 | <-ctx.Done() 42 | 43 | if err := ctx.Err(); err != nil && !errors.Is(err, context.Canceled) { 44 | fmt.Printf("Error running Telnet module: %v\n", err) 45 | 46 | var exitErr util.ExitError 47 | if errors.As(err, &exitErr) { 48 | os.Exit(exitErr.ExitCode) 49 | } 50 | 51 | os.Exit(1) 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /src/go/cpu/context.go: -------------------------------------------------------------------------------- 1 | package cpu 2 | 3 | import "context" 4 | 5 | type ( 6 | elasticEndpoint struct{} 7 | elasticIndex struct{} 8 | lokiEndpoint struct{} 9 | ) 10 | 11 | func ctxSetElasticEndpoint(ctx context.Context, endpoint string) context.Context { 12 | return context.WithValue(ctx, elasticEndpoint{}, endpoint) 13 | } 14 | 15 | func ctxGetElasticEndpoint(ctx context.Context) string { 16 | value := ctx.Value(elasticEndpoint{}) 17 | 18 | if value == nil { 19 | return "" 20 | } 21 | 22 | endpoint, ok := value.(string) 23 | if !ok { 24 | return "" 25 | } 26 | 27 | return endpoint 28 | } 29 | 30 | func ctxSetElasticIndex(ctx context.Context, index string) context.Context { 31 | return context.WithValue(ctx, elasticIndex{}, index) 32 | } 33 | 34 | func ctxGetElasticIndex(ctx context.Context) string { 35 | value := ctx.Value(elasticIndex{}) 36 | 37 | if value == nil { 38 | return "" 39 | } 40 | 41 | index, ok := value.(string) 42 | if !ok { 43 | return "" 44 | } 45 | 46 | return index 47 | } 48 | 49 | func ctxSetLokiEndpoint(ctx context.Context, endpoint string) context.Context { 50 | return context.WithValue(ctx, lokiEndpoint{}, endpoint) 51 | } 52 | 53 | func ctxGetLokiEndpoint(ctx context.Context) string { 54 | value := ctx.Value(lokiEndpoint{}) 55 | 56 | if value == nil { 57 | return "" 58 | } 59 | 60 | endpoint, ok := value.(string) 61 | if !ok { 62 | return "" 63 | } 64 | 65 | return endpoint 66 | } 67 | -------------------------------------------------------------------------------- /src/go/cpu/internal.go: -------------------------------------------------------------------------------- 1 | package cpu 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "log" 7 | 8 | "github.com/patsec/ot-sim/msgbus" 9 | ) 10 | 11 | func (this CPU) internalHandler(topic, msg string) error { 12 | env, err := msgbus.ParseEnvelope([]byte(msg)) 13 | if err != nil { 14 | return fmt.Errorf("creating new envelope: %w", err) 15 | } 16 | 17 | switch env.Kind { 18 | case msgbus.ENVELOPE_MODULE_CONTROL: 19 | control, err := env.ModuleControl() 20 | if err != nil { 21 | if errors.Is(err, msgbus.ErrKindNotModuleControl) { 22 | return nil 23 | } 24 | 25 | return fmt.Errorf("getting module controls from envelope: %w", err) 26 | } 27 | 28 | if control.Recipient != "" && control.Recipient != "CPU" { 29 | return nil 30 | } 31 | 32 | var ( 33 | results = make(map[string]any) 34 | errs = make(msgbus.ConfirmationErrors) 35 | ) 36 | 37 | if control.List { 38 | for _, mod := range modules { 39 | if mod.canceler == nil { 40 | results[mod.name] = "disabled" 41 | } else { 42 | results[mod.name] = "enabled" 43 | } 44 | } 45 | } 46 | 47 | for _, name := range control.Enable { 48 | if mod, ok := modules[name]; ok { 49 | if mod.canceler == nil { 50 | if err := StartModule(mod.ctx, mod); err == nil { 51 | results[mod.name] = "enabled" 52 | } else { 53 | log.Printf("[CPU] [ERROR] failed to enable module %s: %v\n", name, err) 54 | 55 | errs[mod.name] = err.Error() 56 | } 57 | } else { 58 | errs[mod.name] = "already enabled" 59 | } 60 | } else { 61 | errs[name] = "does not exist" 62 | } 63 | } 64 | 65 | for _, name := range control.Disable { 66 | if mod, ok := modules[name]; ok { 67 | if mod.canceler != nil { 68 | close(mod.canceler) 69 | 70 | results[mod.name] = "disabled" 71 | } else { 72 | errs[mod.name] = "already disabled" 73 | } 74 | } else { 75 | errs[name] = "does not exist" 76 | } 77 | } 78 | 79 | if control.Confirm != "" { 80 | confirm := msgbus.Confirmation{Confirm: control.Confirm, Results: results, Errors: errs} 81 | 82 | env, err := msgbus.NewEnvelope("CPU", confirm) 83 | if err != nil { 84 | return fmt.Errorf("creating new confirmation envelope: %w", err) 85 | } 86 | 87 | if err := this.pusher.Push("INTERNAL", env); err != nil { 88 | log.Printf("[CPU] [ERROR] sending module control confirmation message: %v", err) 89 | 90 | return err 91 | } 92 | } 93 | } 94 | 95 | return nil 96 | } 97 | -------------------------------------------------------------------------------- /src/go/cpu/metrics.go: -------------------------------------------------------------------------------- 1 | package cpu 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "net/http" 7 | "regexp" 8 | 9 | "github.com/patsec/ot-sim/msgbus" 10 | 11 | "github.com/prometheus/client_golang/prometheus" 12 | "github.com/prometheus/client_golang/prometheus/promauto" 13 | "github.com/prometheus/client_golang/prometheus/promhttp" 14 | ) 15 | 16 | var ( 17 | counters = make(map[string]prometheus.Counter) 18 | gauges = make(map[string]prometheus.Gauge) 19 | 20 | nameRegex = regexp.MustCompile(`-|:|\.`) 21 | ) 22 | 23 | func metricsHandler(topic, msg string) error { 24 | env, err := msgbus.ParseEnvelope([]byte(msg)) 25 | if err != nil { 26 | return fmt.Errorf("creating new envelope: %w", err) 27 | } 28 | 29 | metrics, err := env.Metrics() 30 | if err != nil { 31 | if errors.Is(err, msgbus.ErrKindNotMetric) { 32 | return nil 33 | } 34 | 35 | return fmt.Errorf("getting metrics from envelope: %w", err) 36 | } 37 | 38 | for _, metric := range metrics.Updates { 39 | switch metric.Kind { 40 | case msgbus.METRIC_COUNTER: 41 | counter, ok := counters[metric.Name] 42 | if !ok { 43 | counter = promauto.NewCounter(prometheus.CounterOpts{ 44 | Name: nameRegex.ReplaceAllString(metric.Name, "_"), 45 | Help: metric.Desc, 46 | }) 47 | 48 | counters[metric.Name] = counter 49 | } 50 | 51 | counter.Add(metric.Value) 52 | case msgbus.METRIC_GAUGE: 53 | gauge, ok := gauges[metric.Name] 54 | if !ok { 55 | gauge = promauto.NewGauge(prometheus.GaugeOpts{ 56 | Name: nameRegex.ReplaceAllString(metric.Name, "_"), 57 | Help: metric.Desc, 58 | }) 59 | 60 | gauges[metric.Name] = gauge 61 | } 62 | 63 | gauge.Set(metric.Value) 64 | } 65 | } 66 | 67 | return nil 68 | } 69 | 70 | func init() { 71 | mux := http.NewServeMux() 72 | mux.Handle("/metrics", promhttp.Handler()) 73 | 74 | server := http.Server{Addr: ":9100", Handler: mux} 75 | go server.ListenAndServe() 76 | } 77 | -------------------------------------------------------------------------------- /src/go/cpu/monitor.go: -------------------------------------------------------------------------------- 1 | package cpu 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | 7 | zmq "github.com/pebbe/zmq4" 8 | ) 9 | 10 | type MsgBusHandler func(string, string) error 11 | 12 | func MonitorMsgBusChannel(ctx context.Context, endpoint, topic string, handlers []MsgBusHandler, errors chan error) { 13 | sendErr := func(err error) { 14 | if errors != nil { 15 | errors <- err 16 | } 17 | } 18 | 19 | socket, err := zmq.NewSocket(zmq.SUB) 20 | if err != nil { 21 | sendErr(fmt.Errorf("creating new SUB socket: %w", err)) 22 | return 23 | } 24 | 25 | if err := socket.Connect(endpoint); err != nil { 26 | sendErr(fmt.Errorf("connecting to publisher: %w", err)) 27 | return 28 | } 29 | 30 | socket.SetSubscribe(topic) 31 | 32 | for { 33 | select { 34 | case <-ctx.Done(): 35 | socket.Close() 36 | return 37 | default: 38 | msg, err := socket.RecvMessage(0) 39 | if err != nil { 40 | sendErr(fmt.Errorf("receiving message from publisher: %w", err)) 41 | return 42 | } 43 | 44 | // This shouldn't ever really happen... 45 | if msg[0] != topic { 46 | continue 47 | } 48 | 49 | for _, handler := range handlers { 50 | if err := handler(topic, msg[1]); err != nil { 51 | sendErr(fmt.Errorf("running handler: %w", err)) 52 | return 53 | } 54 | } 55 | } 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /src/go/go.mod: -------------------------------------------------------------------------------- 1 | module github.com/patsec/ot-sim 2 | 3 | go 1.22.0 4 | 5 | require ( 6 | actshad.dev/mbserver v0.3.1 7 | actshad.dev/modbus v0.2.1 8 | github.com/antonmedv/expr v1.12.5 9 | github.com/beevik/etree v1.1.0 10 | github.com/cenkalti/backoff v2.2.1+incompatible 11 | github.com/eclipse/paho.mqtt.golang v1.4.2 12 | github.com/goburrow/serial v0.1.0 13 | github.com/gofrs/uuid v4.4.0+incompatible 14 | github.com/gorilla/mux v1.8.0 15 | github.com/gorilla/websocket v1.5.0 16 | github.com/pebbe/zmq4 v1.2.7 17 | github.com/prometheus/client_golang v1.12.2 18 | github.com/reiver/go-telnet v0.0.0-20180421082511-9ff0b2ab096e 19 | golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9 20 | gopkg.in/natefinch/lumberjack.v2 v2.2.1 21 | ) 22 | 23 | require ( 24 | github.com/beorn7/perks v1.0.1 // indirect 25 | github.com/cespare/xxhash/v2 v2.1.2 // indirect 26 | github.com/golang/protobuf v1.5.2 // indirect 27 | github.com/google/go-cmp v0.5.8 // indirect 28 | github.com/matttproud/golang_protobuf_extensions v1.0.1 // indirect 29 | github.com/prometheus/client_model v0.2.0 // indirect 30 | github.com/prometheus/common v0.32.1 // indirect 31 | github.com/prometheus/procfs v0.7.3 // indirect 32 | github.com/reiver/go-oi v1.0.0 // indirect 33 | golang.org/x/net v0.0.0-20210525063256-abc453219eb5 // indirect 34 | golang.org/x/sync v0.0.0-20210220032951-036812b2e83c // indirect 35 | golang.org/x/sys v0.12.0 // indirect 36 | google.golang.org/protobuf v1.26.0 // indirect 37 | ) 38 | -------------------------------------------------------------------------------- /src/go/logic/logic_test.go: -------------------------------------------------------------------------------- 1 | package logic 2 | 3 | import ( 4 | "math" 5 | "strings" 6 | "testing" 7 | 8 | "github.com/antonmedv/expr" 9 | ) 10 | 11 | var testFilterLogic = ` 12 | mod = 5 13 | count = count + 1 14 | foo = int(count) % mod 15 | bar = int(count) % mod 16 | active = filter(variables, {# matches "_active_power$"}) 17 | active_sum = sum(active) 18 | active_avg = avg(active) 19 | ` 20 | 21 | var filterLogicVariables = map[string]float64{ 22 | "foo_active_power": 5.0, 23 | "foo_reactive_power": 0.0, 24 | "bar_active_power": 3.2, 25 | "bar_reactive_power": 0.0, 26 | } 27 | 28 | func TestFilterLogic(t *testing.T) { 29 | l := New("test") 30 | 31 | lines := strings.Split(testFilterLogic, "\n") 32 | 33 | l.order = make([]string, len(lines)) 34 | 35 | for i, line := range lines { 36 | if line == "" { 37 | continue 38 | } 39 | 40 | sides := strings.SplitN(line, "=", 2) 41 | 42 | var ( 43 | left = strings.TrimSpace(sides[0]) 44 | right = strings.TrimSpace(sides[1]) 45 | ) 46 | 47 | code, err := expr.Compile(right) 48 | if err != nil { 49 | t.Logf("compiling program code '%s': %v", right, err) 50 | t.FailNow() 51 | } 52 | 53 | l.program[i] = code 54 | l.order[i] = left 55 | 56 | if _, ok := l.env[left]; !ok { 57 | // Initialize variable in environment used by program, but only if 58 | // it wasn't already initialized by a variable definition. 59 | l.env[left] = 0.0 60 | } 61 | } 62 | 63 | for k, v := range filterLogicVariables { 64 | l.variables = append(l.variables, k) 65 | l.env[k] = v 66 | } 67 | 68 | l.initEnv() 69 | l.execute() 70 | l.execute() 71 | 72 | val, ok := l.env["active"] 73 | if !ok { 74 | t.FailNow() 75 | } 76 | 77 | active, ok := val.([]any) 78 | if !ok { 79 | t.FailNow() 80 | } 81 | 82 | activeExpected := []any{"foo_active_power", "bar_active_power"} 83 | 84 | for i, e := range active { 85 | if e != activeExpected[i] { 86 | t.FailNow() 87 | } 88 | } 89 | 90 | val, ok = l.env["active_sum"] 91 | if !ok { 92 | t.FailNow() 93 | } 94 | 95 | sum, ok := val.(float64) 96 | if !ok { 97 | t.FailNow() 98 | } 99 | 100 | if sum != 8.2 { 101 | t.FailNow() 102 | } 103 | 104 | val, ok = l.env["active_avg"] 105 | if !ok { 106 | t.FailNow() 107 | } 108 | 109 | avg, ok := val.(float64) 110 | if !ok { 111 | t.FailNow() 112 | } 113 | 114 | if avg != 4.1 { 115 | t.FailNow() 116 | } 117 | } 118 | 119 | var testMathLogic = ` 120 | power = [gen1, gen2, gen3] 121 | total_gen = sum(power) 122 | 123 | dir = [dir1, dir2, dir3] 124 | filtered_dir = filter(dir, {# != 0}) 125 | wind_dir = avg(filtered_dir) 126 | 127 | speed = [speed1, speed2, speed3] 128 | filtered_speed = filter(speed, {# != 0}) 129 | wind_speed = avg(filtered_speed) 130 | ` 131 | 132 | var mathLogicVariables = map[string]float64{ 133 | "gen1": 4.5, 134 | "gen2": 5.4, 135 | "gen3": 0.0, 136 | "dir1": 330.6, 137 | "dir2": 330.7, 138 | "dir3": 0.0, 139 | "speed1": 31.4, 140 | "speed2": 32.3, 141 | "speed3": 0.0, 142 | "total_gen": 0.0, 143 | "wind_dir": 0.0, 144 | "wind_speed": 0.0, 145 | } 146 | 147 | func TestMathLogic(t *testing.T) { 148 | l := New("test") 149 | 150 | lines := strings.Split(testMathLogic, "\n") 151 | 152 | l.order = make([]string, len(lines)) 153 | 154 | for i, line := range lines { 155 | if line == "" { 156 | continue 157 | } 158 | 159 | sides := strings.SplitN(line, "=", 2) 160 | 161 | var ( 162 | left = strings.TrimSpace(sides[0]) 163 | right = strings.TrimSpace(sides[1]) 164 | ) 165 | 166 | code, err := expr.Compile(right) 167 | if err != nil { 168 | t.Logf("compiling program code '%s': %v", right, err) 169 | t.FailNow() 170 | } 171 | 172 | l.program[i] = code 173 | l.order[i] = left 174 | 175 | if _, ok := l.env[left]; !ok { 176 | // Initialize variable in environment used by program, but only if 177 | // it wasn't already initialized by a variable definition. 178 | l.env[left] = 0.0 179 | } 180 | } 181 | 182 | for k, v := range mathLogicVariables { 183 | l.variables = append(l.variables, k) 184 | l.env[k] = v 185 | } 186 | 187 | l.initEnv() 188 | l.execute() 189 | 190 | expected := map[string]float64{ 191 | "total_gen": 9.9, 192 | "wind_dir": 330.65, 193 | "wind_speed": 31.85, 194 | } 195 | 196 | for k, v := range expected { 197 | val, ok := l.env[k] 198 | if !ok { 199 | t.FailNow() 200 | } 201 | 202 | value, ok := val.(float64) 203 | if !ok { 204 | t.FailNow() 205 | } 206 | 207 | if math.Abs(v-value) > 1e-6 { 208 | t.FailNow() 209 | } 210 | } 211 | } 212 | -------------------------------------------------------------------------------- /src/go/modbus/modbus.go: -------------------------------------------------------------------------------- 1 | package modbus 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | 7 | otsim "github.com/patsec/ot-sim" 8 | "github.com/patsec/ot-sim/modbus/client" 9 | "github.com/patsec/ot-sim/modbus/server" 10 | 11 | "github.com/beevik/etree" 12 | ) 13 | 14 | type Factory struct{} 15 | 16 | func (Factory) NewModule(e *etree.Element) (otsim.Module, error) { 17 | mode := e.SelectAttrValue("mode", "server") 18 | 19 | switch strings.ToLower(mode) { 20 | case "server": 21 | name := e.SelectAttrValue("name", "modbus-server") 22 | return server.New(name), nil 23 | case "client": 24 | name := e.SelectAttrValue("name", "modbus-client") 25 | return client.New(name), nil 26 | } 27 | 28 | return nil, fmt.Errorf("unknown mode '%s' provided for Modbus module", mode) 29 | } 30 | 31 | func init() { 32 | otsim.AddModuleFactory("modbus", new(Factory)) 33 | } 34 | -------------------------------------------------------------------------------- /src/go/modbus/server/coil.go: -------------------------------------------------------------------------------- 1 | package server 2 | 3 | import ( 4 | "context" 5 | "encoding/binary" 6 | 7 | mbutil "github.com/patsec/ot-sim/modbus/util" 8 | 9 | "actshad.dev/mbserver" 10 | "github.com/patsec/ot-sim/msgbus" 11 | ) 12 | 13 | func (this *ModbusServer) readCoilRegisters(_ context.Context, f mbserver.Framer) ([]byte, *mbserver.Exception) { 14 | if this.registers["coil"] == nil { 15 | return nil, &mbserver.IllegalDataAddress 16 | } 17 | 18 | var ( 19 | data = f.GetData() 20 | start = int(binary.BigEndian.Uint16(data[0:2])) 21 | count = int(binary.BigEndian.Uint16(data[2:4])) 22 | ) 23 | 24 | var bits []int 25 | 26 | for addr := start; addr < start+count; addr++ { 27 | reg, ok := this.registers["coil"][addr] 28 | if !ok { 29 | return nil, &mbserver.IllegalDataAddress 30 | } 31 | 32 | this.tagsMu.RLock() 33 | value := this.tags[reg.Tag] 34 | this.tagsMu.RUnlock() 35 | 36 | if value == 0 { 37 | bits = append(bits, 0) 38 | } else { 39 | bits = append(bits, 1) 40 | } 41 | } 42 | 43 | data = mbutil.BitsToBytes(bits) 44 | size := len(data) 45 | 46 | return append([]byte{byte(size)}, data...), &mbserver.Success 47 | } 48 | 49 | func (this *ModbusServer) writeCoilRegister(ctx context.Context, f mbserver.Framer) ([]byte, *mbserver.Exception) { 50 | if this.registers["coil"] == nil { 51 | return nil, &mbserver.IllegalDataAddress 52 | } 53 | 54 | var ( 55 | data = f.GetData() 56 | addr = int(binary.BigEndian.Uint16(data[0:2])) 57 | ) 58 | 59 | reg, ok := this.registers["coil"][addr] 60 | if !ok { 61 | return nil, &mbserver.IllegalDataAddress 62 | } 63 | 64 | value, err := reg.Value(data[2:4]) 65 | if err != nil { 66 | return nil, &mbserver.IllegalDataValue 67 | } 68 | 69 | this.tagsMu.Lock() 70 | this.tags[reg.Tag] = value 71 | this.tagsMu.Unlock() 72 | 73 | this.log("updating tag %s --> %t", reg.Tag, value != 0) 74 | 75 | updates := []msgbus.Point{{Tag: reg.Tag, Value: value}} 76 | 77 | env, err := msgbus.NewEnvelope(this.name, msgbus.Update{Updates: updates}) 78 | if err != nil { 79 | this.log("[ERROR] creating new update message: %v", err) 80 | return nil, &mbserver.SlaveDeviceFailure 81 | } 82 | 83 | if err := this.pusher.Push("RUNTIME", env); err != nil { 84 | this.log("[ERROR] sending update message: %v", err) 85 | return nil, &mbserver.SlaveDeviceFailure 86 | } 87 | 88 | this.metrics.IncrMetric("coil_writes_count") 89 | 90 | return data[0:4], &mbserver.Success 91 | } 92 | 93 | func (this *ModbusServer) writeCoilRegisters(ctx context.Context, f mbserver.Framer) ([]byte, *mbserver.Exception) { 94 | if this.registers["coil"] == nil { 95 | return nil, &mbserver.IllegalDataAddress 96 | } 97 | 98 | var ( 99 | data = f.GetData() 100 | start = int(binary.BigEndian.Uint16(data[0:2])) 101 | count = int(binary.BigEndian.Uint16(data[2:4])) 102 | 103 | // beginning of data to be written starts at offset 5 104 | bits = mbutil.BytesToBits(data[5:]) 105 | updates []msgbus.Point 106 | ) 107 | 108 | for addr := start; addr < start+count; addr++ { 109 | reg, ok := this.registers["coil"][addr] 110 | if !ok { 111 | return nil, &mbserver.IllegalDataAddress 112 | } 113 | 114 | idx := addr - start 115 | val := float64(bits[idx]) 116 | 117 | this.tagsMu.Lock() 118 | this.tags[reg.Tag] = val 119 | this.tagsMu.Unlock() 120 | 121 | this.log("updating tag %s --> %t", reg.Tag, val != 0) 122 | 123 | updates = append(updates, msgbus.Point{Tag: reg.Tag, Value: val}) 124 | } 125 | 126 | if len(updates) > 0 { 127 | env, err := msgbus.NewEnvelope(this.name, msgbus.Update{Updates: updates}) 128 | if err != nil { 129 | this.log("[ERROR] creating new update message: %v", err) 130 | return nil, &mbserver.SlaveDeviceFailure 131 | } 132 | 133 | if err := this.pusher.Push("RUNTIME", env); err != nil { 134 | this.log("[ERROR] sending update message: %v", err) 135 | return nil, &mbserver.SlaveDeviceFailure 136 | } 137 | } 138 | 139 | this.metrics.IncrMetricBy("coil_writes_count", count) 140 | 141 | return data[0:4], &mbserver.Success 142 | } 143 | -------------------------------------------------------------------------------- /src/go/modbus/server/discrete.go: -------------------------------------------------------------------------------- 1 | package server 2 | 3 | import ( 4 | "context" 5 | "encoding/binary" 6 | 7 | "actshad.dev/mbserver" 8 | ) 9 | 10 | func (this *ModbusServer) readDiscreteRegisters(_ context.Context, f mbserver.Framer) ([]byte, *mbserver.Exception) { 11 | if this.registers["discrete"] == nil { 12 | return nil, &mbserver.IllegalDataAddress 13 | } 14 | 15 | var ( 16 | data = f.GetData() 17 | start = int(binary.BigEndian.Uint16(data[0:2])) 18 | count = int(binary.BigEndian.Uint16(data[2:4])) 19 | ) 20 | 21 | size := count / 8 22 | 23 | if (count % 8) != 0 { 24 | size++ 25 | } 26 | 27 | data = make([]byte, size) 28 | idx := 0 29 | 30 | for addr := start; addr < start+count; addr++ { 31 | reg, ok := this.registers["discrete"][addr] 32 | if !ok { 33 | return nil, &mbserver.IllegalDataAddress 34 | } 35 | 36 | this.tagsMu.RLock() 37 | value := this.tags[reg.Tag] 38 | this.tagsMu.RUnlock() 39 | 40 | if value != 0 { 41 | shift := uint(idx) % 8 42 | data[idx/8] |= byte(1 << shift) 43 | } 44 | 45 | idx++ 46 | } 47 | 48 | return append([]byte{byte(size)}, data...), &mbserver.Success 49 | } 50 | -------------------------------------------------------------------------------- /src/go/modbus/server/holding.go: -------------------------------------------------------------------------------- 1 | package server 2 | 3 | import ( 4 | "context" 5 | "encoding/binary" 6 | 7 | "actshad.dev/mbserver" 8 | "github.com/patsec/ot-sim/msgbus" 9 | ) 10 | 11 | func (this *ModbusServer) readHoldingRegisters(_ context.Context, f mbserver.Framer) ([]byte, *mbserver.Exception) { 12 | if this.registers["holding"] == nil { 13 | return nil, &mbserver.IllegalDataAddress 14 | } 15 | 16 | var ( 17 | data = f.GetData() 18 | start = int(binary.BigEndian.Uint16(data[0:2])) 19 | count = int(binary.BigEndian.Uint16(data[2:4])) 20 | size int 21 | ) 22 | 23 | data = nil 24 | 25 | for addr := start; addr < start+count; { 26 | reg, ok := this.registers["holding"][addr] 27 | if !ok { 28 | return nil, &mbserver.IllegalDataAddress 29 | } 30 | 31 | this.tagsMu.RLock() 32 | buf, err := reg.Bytes(this.tags[reg.Tag]) 33 | this.tagsMu.RUnlock() 34 | 35 | if err != nil { 36 | return nil, &mbserver.SlaveDeviceFailure 37 | } 38 | 39 | size = size + (reg.Count * 2) 40 | data = append(data, buf...) 41 | 42 | addr = addr + reg.Count 43 | } 44 | 45 | return append([]byte{byte(size)}, data...), &mbserver.Success 46 | } 47 | 48 | func (this *ModbusServer) writeHoldingRegister(_ context.Context, f mbserver.Framer) ([]byte, *mbserver.Exception) { 49 | if this.registers["holding"] == nil { 50 | return nil, &mbserver.IllegalDataAddress 51 | } 52 | 53 | var ( 54 | data = f.GetData() 55 | addr = int(binary.BigEndian.Uint16(data[0:2])) 56 | ) 57 | 58 | reg, ok := this.registers["holding"][addr] 59 | if !ok { 60 | return nil, &mbserver.IllegalDataAddress 61 | } 62 | 63 | value, err := reg.Value(data[2:4]) 64 | if err != nil { 65 | return nil, &mbserver.IllegalDataValue 66 | } 67 | 68 | this.tagsMu.Lock() 69 | this.tags[reg.Tag] = value 70 | this.tagsMu.Unlock() 71 | 72 | this.log("updating tag %s --> %f", reg.Tag, value) 73 | 74 | updates := []msgbus.Point{{Tag: reg.Tag, Value: value}} 75 | 76 | env, err := msgbus.NewEnvelope(this.name, msgbus.Update{Updates: updates}) 77 | if err != nil { 78 | this.log("[ERROR] creating new update message: %v", err) 79 | return nil, &mbserver.SlaveDeviceFailure 80 | } 81 | 82 | if err := this.pusher.Push("RUNTIME", env); err != nil { 83 | this.log("[ERROR] sending update message: %v", err) 84 | return nil, &mbserver.SlaveDeviceFailure 85 | } 86 | 87 | this.metrics.IncrMetric("holding_writes_count") 88 | 89 | return data[0:4], &mbserver.Success 90 | } 91 | 92 | func (this *ModbusServer) writeHoldingRegisters(_ context.Context, f mbserver.Framer) ([]byte, *mbserver.Exception) { 93 | if this.registers["holding"] == nil { 94 | return nil, &mbserver.IllegalDataAddress 95 | } 96 | 97 | var ( 98 | data = f.GetData() 99 | start = int(binary.BigEndian.Uint16(data[0:2])) 100 | count = int(binary.BigEndian.Uint16(data[2:4])) 101 | 102 | // beginning of data to be written starts at offset 5 103 | begin = 5 104 | updates []msgbus.Point 105 | ) 106 | 107 | for addr := start; addr < start+count; { 108 | reg, ok := this.registers["holding"][addr] 109 | if !ok { 110 | return nil, &mbserver.IllegalDataAddress 111 | } 112 | 113 | end := begin + (reg.Count * 2) // each holding register is 2 bytes long 114 | 115 | value, err := reg.Value(data[begin:end]) 116 | if err != nil { 117 | return nil, &mbserver.IllegalDataValue 118 | } 119 | 120 | this.tagsMu.Lock() 121 | this.tags[reg.Tag] = value 122 | this.tagsMu.Unlock() 123 | 124 | this.log("updating tag %s --> %f", reg.Tag, value) 125 | 126 | updates = append(updates, msgbus.Point{Tag: reg.Tag, Value: value}) 127 | 128 | begin = end 129 | addr = addr + reg.Count 130 | } 131 | 132 | if len(updates) > 0 { 133 | env, err := msgbus.NewEnvelope(this.name, msgbus.Update{Updates: updates}) 134 | if err != nil { 135 | this.log("[ERROR] creating new update message: %v", err) 136 | return nil, &mbserver.SlaveDeviceFailure 137 | } 138 | 139 | if err := this.pusher.Push("RUNTIME", env); err != nil { 140 | this.log("[ERROR] sending update message: %v", err) 141 | return nil, &mbserver.SlaveDeviceFailure 142 | } 143 | } 144 | 145 | this.metrics.IncrMetricBy("holding_writes_count", count) 146 | 147 | return data[0:4], &mbserver.Success 148 | } 149 | -------------------------------------------------------------------------------- /src/go/modbus/server/input.go: -------------------------------------------------------------------------------- 1 | package server 2 | 3 | import ( 4 | "context" 5 | "encoding/binary" 6 | 7 | "actshad.dev/mbserver" 8 | ) 9 | 10 | func (this *ModbusServer) readInputRegisters(_ context.Context, f mbserver.Framer) ([]byte, *mbserver.Exception) { 11 | if this.registers["input"] == nil { 12 | return nil, &mbserver.IllegalDataAddress 13 | } 14 | 15 | var ( 16 | data = f.GetData() 17 | start = int(binary.BigEndian.Uint16(data[0:2])) 18 | count = int(binary.BigEndian.Uint16(data[2:4])) 19 | size int 20 | ) 21 | 22 | data = nil 23 | 24 | for addr := start; addr < start+count; { 25 | reg, ok := this.registers["input"][addr] 26 | if !ok { 27 | return nil, &mbserver.IllegalDataAddress 28 | } 29 | 30 | this.tagsMu.RLock() 31 | buf, err := reg.Bytes(this.tags[reg.Tag]) 32 | this.tagsMu.RUnlock() 33 | 34 | if err != nil { 35 | return nil, &mbserver.SlaveDeviceFailure 36 | } 37 | 38 | size = size + (reg.Count * 2) 39 | data = append(data, buf...) 40 | 41 | addr = addr + reg.Count 42 | } 43 | 44 | return append([]byte{byte(size)}, data...), &mbserver.Success 45 | } 46 | -------------------------------------------------------------------------------- /src/go/modbus/util/bytes.go: -------------------------------------------------------------------------------- 1 | package util 2 | 3 | import ( 4 | "math/bits" 5 | ) 6 | 7 | func BytesToBits(data []byte) []int { 8 | result := make([]int, len(data)*8) 9 | 10 | for i, d := range data { 11 | // switch from LSB to MSB 12 | d = bits.Reverse8(d) 13 | 14 | for j := 0; j < 8; j++ { 15 | idx := i*8 + j 16 | 17 | if bits.LeadingZeros8(d) == 0 { 18 | result[idx] = 1 19 | } else { 20 | result[idx] = 0 21 | } 22 | 23 | d = d << 1 24 | } 25 | } 26 | 27 | return result 28 | } 29 | 30 | func BitsToBytes(bits []int) []byte { 31 | var ( 32 | count = len(bits) 33 | size = count / 8 34 | ) 35 | 36 | if (count % 8) != 0 { 37 | size++ 38 | } 39 | 40 | var ( 41 | data = make([]byte, size) 42 | idx = 0 43 | ) 44 | 45 | for _, b := range bits { 46 | if b != 0 { 47 | shift := uint(idx) % 8 48 | data[idx/8] |= byte(1 << shift) 49 | } 50 | 51 | idx++ 52 | } 53 | 54 | return data 55 | } 56 | -------------------------------------------------------------------------------- /src/go/modbus/util/bytes_test.go: -------------------------------------------------------------------------------- 1 | package util 2 | 3 | import "fmt" 4 | 5 | func ExampleBytesToBits() { 6 | data := []byte{0xcd, 0x01} 7 | bits := BytesToBits(data) 8 | 9 | fmt.Printf("%v\n", bits) 10 | // Output: [1 0 1 1 0 0 1 1 1 0 0 0 0 0 0 0] 11 | } 12 | 13 | func ExampleBitsToBytes() { 14 | bits := []int{1, 0, 1, 1, 0, 0, 1, 1, 1, 0} 15 | data := BitsToBytes(bits) 16 | 17 | fmt.Printf("%x\n", data) 18 | // Output: cd01 19 | } 20 | -------------------------------------------------------------------------------- /src/go/modbus/util/register_test.go: -------------------------------------------------------------------------------- 1 | package util 2 | 3 | import ( 4 | "fmt" 5 | "testing" 6 | ) 7 | 8 | func TestInputRegister(t *testing.T) { 9 | data := []byte{0x00, 0x00, 0xbb, 0x80} 10 | 11 | reg := new(Register) 12 | reg.Type = "input" 13 | reg.DataType = "uint32" 14 | reg.Scaling = 2 15 | 16 | if err := reg.Init(); err != nil { 17 | t.Log(err) 18 | t.FailNow() 19 | } 20 | 21 | val, err := reg.Value(data) 22 | if err != nil { 23 | t.Log(err) 24 | t.FailNow() 25 | } 26 | 27 | if val != 480 { 28 | t.FailNow() 29 | } 30 | } 31 | 32 | func TestCoilRegister(t *testing.T) { 33 | reg := new(Register) 34 | reg.Type = "coil" 35 | 36 | if err := reg.Init(); err != nil { 37 | t.Log(err) 38 | t.FailNow() 39 | } 40 | 41 | val, err := reg.Bytes(1) 42 | if err != nil { 43 | t.Log(err) 44 | t.FailNow() 45 | } 46 | 47 | expected := []byte{0xff, 0x00} 48 | 49 | if len(val) != len(expected) { 50 | t.FailNow() 51 | } 52 | 53 | for i, e := range val { 54 | if e != expected[i] { 55 | t.FailNow() 56 | } 57 | } 58 | } 59 | 60 | func TestServerRegister(t *testing.T) { 61 | reg := new(Register) 62 | reg.Type = "input" 63 | reg.DataType = "uint32" 64 | reg.Scaling = 2 65 | 66 | if err := reg.Init(); err != nil { 67 | t.Log(err) 68 | t.FailNow() 69 | } 70 | 71 | data, err := reg.Bytes(480) 72 | if err != nil { 73 | t.Log(err) 74 | t.FailNow() 75 | } 76 | 77 | expected := []byte{0x00, 0x00, 0xbb, 0x80} 78 | 79 | for i, e := range data { 80 | if e != expected[i] { 81 | t.FailNow() 82 | } 83 | } 84 | 85 | val, err := reg.Value(data) 86 | if err != nil { 87 | t.Log(err) 88 | t.FailNow() 89 | } 90 | 91 | if val != 480 { 92 | t.FailNow() 93 | } 94 | } 95 | 96 | func ExampleRegister() { 97 | reg := new(Register) 98 | reg.Type = "input" 99 | reg.DataType = "uint32" 100 | reg.Scaling = 2 101 | 102 | if err := reg.Init(); err != nil { 103 | fmt.Println(err) 104 | return 105 | } 106 | 107 | data, err := reg.Bytes(480) 108 | if err != nil { 109 | fmt.Println(err) 110 | return 111 | } 112 | 113 | fmt.Printf("%x\n", data) 114 | // Output: 0000bb80 115 | } 116 | -------------------------------------------------------------------------------- /src/go/mqtt/types.go: -------------------------------------------------------------------------------- 1 | package mqtt 2 | 3 | import ( 4 | "bytes" 5 | "crypto/tls" 6 | "crypto/x509" 7 | "fmt" 8 | "net/url" 9 | "os" 10 | "text/template" 11 | ) 12 | 13 | type endpoint struct { 14 | url string 15 | 16 | caPath string 17 | keyPath string 18 | certPath string 19 | 20 | uri *url.URL 21 | cert tls.Certificate 22 | roots *x509.CertPool 23 | 24 | insecure bool 25 | } 26 | 27 | func (this *endpoint) validate() error { 28 | var err error 29 | 30 | this.uri, err = url.Parse(this.url) 31 | if err != nil { 32 | return fmt.Errorf("parsing endpoint URL %s: %w", this.url, err) 33 | } 34 | 35 | if this.uri.Scheme == "" { 36 | return fmt.Errorf("endpoint URL is missing a scheme (must be tcp, ssl, or tls)") 37 | } 38 | 39 | if this.uri.Scheme == "ssl" || this.uri.Scheme == "tls" { 40 | if this.certPath == "" || this.keyPath == "" { 41 | return fmt.Errorf("must provide 'certificate' and 'key' for MQTT module config when using ssl/tls") 42 | } 43 | 44 | this.cert, err = tls.LoadX509KeyPair(this.certPath, this.keyPath) 45 | if err != nil { 46 | return fmt.Errorf("loading MQTT module certificate and key: %w", err) 47 | } 48 | 49 | if this.caPath != "" { 50 | caCert, err := os.ReadFile(this.caPath) 51 | if err != nil { 52 | return fmt.Errorf("reading MQTT module CA certificate: %w", err) 53 | } 54 | 55 | this.roots = x509.NewCertPool() 56 | 57 | if ok := this.roots.AppendCertsFromPEM(caCert); !ok { 58 | return fmt.Errorf("failed to parse MQTT module CA certificate") 59 | } 60 | } 61 | } 62 | 63 | return nil 64 | } 65 | 66 | // publication payload data 67 | type data struct { 68 | Epoch int64 69 | Timestamp string 70 | Client string 71 | Topic string 72 | Value any 73 | } 74 | 75 | func (this data) execute(tmpl *template.Template) (string, error) { 76 | var buf bytes.Buffer 77 | 78 | if err := tmpl.Execute(&buf, this); err != nil { 79 | return "", fmt.Errorf("executing template: %w", err) 80 | } 81 | 82 | return buf.String(), nil 83 | } 84 | -------------------------------------------------------------------------------- /src/go/msgbus/envelope.go: -------------------------------------------------------------------------------- 1 | package msgbus 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | ) 7 | 8 | type ( 9 | EnvelopeKind string 10 | EnvelopeMetadata map[string]string 11 | ) 12 | 13 | type Envelope struct { 14 | Version string `json:"version"` 15 | Kind EnvelopeKind `json:"kind"` 16 | Metadata EnvelopeMetadata `json:"metadata"` 17 | Contents json.RawMessage `json:"contents"` 18 | } 19 | 20 | func ParseEnvelope(data []byte) (Envelope, error) { 21 | var env Envelope 22 | 23 | if err := json.Unmarshal(data, &env); err != nil { 24 | return env, fmt.Errorf("unmarshaling envelope: %w", err) 25 | } 26 | 27 | return env, nil 28 | } 29 | 30 | func (this Envelope) Sender() string { 31 | if this.Metadata == nil { 32 | return "" 33 | } 34 | 35 | if sender, ok := this.Metadata["sender"]; ok { 36 | return sender 37 | } 38 | 39 | return "" 40 | } 41 | -------------------------------------------------------------------------------- /src/go/msgbus/health.go: -------------------------------------------------------------------------------- 1 | package msgbus 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | ) 7 | 8 | const ( 9 | ENVELOPE_HEALTHCHECK EnvelopeKind = "HealthCheck" 10 | ) 11 | 12 | var ( 13 | ErrKindNotHealthCheck error = fmt.Errorf("not a HealthCheck message") 14 | ) 15 | 16 | type HealthCheck struct { 17 | State string `json:"state"` 18 | } 19 | 20 | func NewHealthCheckEnvelope(sender string, hc HealthCheck) (Envelope, error) { 21 | var env Envelope 22 | 23 | raw, err := json.Marshal(hc) 24 | if err != nil { 25 | return env, fmt.Errorf("marshaling HealthCheck envelope contents: %w", err) 26 | } 27 | 28 | env = Envelope{ 29 | Version: "v1", 30 | Kind: ENVELOPE_HEALTHCHECK, 31 | Metadata: map[string]string{ 32 | "sender": sender, 33 | }, 34 | Contents: raw, 35 | } 36 | 37 | return env, nil 38 | } 39 | 40 | func (this Envelope) HealthCheck() (HealthCheck, error) { 41 | var hc HealthCheck 42 | 43 | if this.Kind != ENVELOPE_HEALTHCHECK { 44 | return hc, ErrKindNotHealthCheck 45 | } 46 | 47 | if err := json.Unmarshal(this.Contents, &hc); err != nil { 48 | return hc, fmt.Errorf("unmarshaling HealthCheck message: %w", err) 49 | } 50 | 51 | return hc, nil 52 | } 53 | -------------------------------------------------------------------------------- /src/go/msgbus/metric.go: -------------------------------------------------------------------------------- 1 | package msgbus 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "strings" 7 | "sync" 8 | "time" 9 | ) 10 | 11 | type ( 12 | MetricKind string 13 | ) 14 | 15 | const ( 16 | ENVELOPE_METRIC EnvelopeKind = "Metric" 17 | 18 | METRIC_COUNTER MetricKind = "Counter" 19 | METRIC_GAUGE MetricKind = "Gauge" 20 | ) 21 | 22 | var ( 23 | ErrKindNotMetric error = fmt.Errorf("not a Metric message") 24 | ) 25 | 26 | type MetricsPusher struct { 27 | sync.RWMutex 28 | 29 | metrics map[string]Metric 30 | } 31 | 32 | func NewMetricsPusher() *MetricsPusher { 33 | return &MetricsPusher{ 34 | metrics: make(map[string]Metric), 35 | } 36 | } 37 | 38 | func (this *MetricsPusher) Start(pusher *Pusher, name string) { 39 | prefix := name + "_" 40 | 41 | go func() { 42 | for range time.Tick(5 * time.Second) { 43 | var updates []Metric 44 | 45 | this.RLock() 46 | 47 | for _, metric := range this.metrics { 48 | copy := metric 49 | 50 | if !strings.HasPrefix(copy.Name, prefix) { 51 | copy.Name = prefix + copy.Name 52 | } 53 | 54 | updates = append(updates, copy) 55 | } 56 | 57 | this.RUnlock() 58 | 59 | if len(updates) > 0 { 60 | env, err := NewMetricEnvelope(name, Metrics{Updates: updates}) 61 | if err != nil { 62 | pusher.PushString("LOG", "[%s] [ERROR] %v", name, err) 63 | continue 64 | } 65 | 66 | if err := pusher.Push("HEALTH", env); err != nil { 67 | pusher.PushString("LOG", "[%s] [ERROR] %v", name, err) 68 | } 69 | } 70 | } 71 | }() 72 | } 73 | 74 | func (this *MetricsPusher) NewMetric(kind MetricKind, name, desc string) { 75 | this.Lock() 76 | defer this.Unlock() 77 | 78 | metric := Metric{ 79 | Kind: kind, 80 | Name: name, 81 | Desc: desc, 82 | } 83 | 84 | this.metrics[name] = metric 85 | } 86 | 87 | func (this *MetricsPusher) IncrMetric(name string) { 88 | this.Lock() 89 | defer this.Unlock() 90 | 91 | if metric, ok := this.metrics[name]; ok { 92 | metric.Value += 1.0 93 | this.metrics[name] = metric 94 | } 95 | } 96 | 97 | func (this *MetricsPusher) IncrMetricBy(name string, val int) { 98 | this.Lock() 99 | defer this.Unlock() 100 | 101 | if metric, ok := this.metrics[name]; ok { 102 | metric.Value += float64(val) 103 | this.metrics[name] = metric 104 | } 105 | } 106 | 107 | func (this *MetricsPusher) SetMetric(name string, val float64) { 108 | this.Lock() 109 | defer this.Unlock() 110 | 111 | if metric, ok := this.metrics[name]; ok { 112 | metric.Value = val 113 | this.metrics[name] = metric 114 | } 115 | } 116 | 117 | type Metric struct { 118 | Kind MetricKind `json:"kind"` 119 | Name string `json:"name"` 120 | Desc string `json:"desc"` 121 | Value float64 `json:"value"` 122 | } 123 | 124 | type Metrics struct { 125 | Updates []Metric `json:"metrics"` 126 | } 127 | 128 | func NewMetricEnvelope(sender string, metrics Metrics) (Envelope, error) { 129 | var env Envelope 130 | 131 | raw, err := json.Marshal(metrics) 132 | if err != nil { 133 | return env, fmt.Errorf("marshaling Metric envelope contents: %w", err) 134 | } 135 | 136 | env = Envelope{ 137 | Version: "v1", 138 | Kind: ENVELOPE_METRIC, 139 | Metadata: map[string]string{ 140 | "sender": sender, 141 | }, 142 | Contents: raw, 143 | } 144 | 145 | return env, nil 146 | } 147 | 148 | func (this Envelope) Metrics() (Metrics, error) { 149 | var metrics Metrics 150 | 151 | if this.Kind != ENVELOPE_METRIC { 152 | return metrics, ErrKindNotMetric 153 | } 154 | 155 | if err := json.Unmarshal(this.Contents, &metrics); err != nil { 156 | return metrics, fmt.Errorf("unmarshaling Metric message: %w", err) 157 | } 158 | 159 | return metrics, nil 160 | } 161 | -------------------------------------------------------------------------------- /src/go/msgbus/module.go: -------------------------------------------------------------------------------- 1 | package msgbus 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | ) 7 | 8 | const ( 9 | ENVELOPE_MODULE_CONTROL EnvelopeKind = "ModuleControl" 10 | ) 11 | 12 | var ( 13 | ErrKindNotModuleControl error = fmt.Errorf("not a ModuleControl message") 14 | ) 15 | 16 | type ModuleControl struct { 17 | List bool `json:"list"` 18 | Enable []string `json:"enable"` 19 | Disable []string `json:"disable"` 20 | 21 | Recipient string `json:"recipient"` 22 | Confirm string `json:"confirm"` 23 | } 24 | 25 | func (ModuleControl) Kind() EnvelopeKind { 26 | return ENVELOPE_MODULE_CONTROL 27 | } 28 | 29 | func (this Envelope) ModuleControl() (ModuleControl, error) { 30 | var control ModuleControl 31 | 32 | if this.Kind != ENVELOPE_MODULE_CONTROL { 33 | return control, ErrKindNotModuleControl 34 | } 35 | 36 | if err := json.Unmarshal(this.Contents, &control); err != nil { 37 | return control, fmt.Errorf("unmarshaling ModuleControl message: %w", err) 38 | } 39 | 40 | return control, nil 41 | } 42 | -------------------------------------------------------------------------------- /src/go/msgbus/pusher.go: -------------------------------------------------------------------------------- 1 | package msgbus 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | 7 | zmq "github.com/pebbe/zmq4" 8 | ) 9 | 10 | type Pusher struct { 11 | ctx *zmq.Context 12 | socket *zmq.Socket 13 | } 14 | 15 | func MustNewPusher(endpoint string) *Pusher { 16 | push, err := NewPusher(endpoint) 17 | if err != nil { 18 | panic(err) 19 | } 20 | 21 | return push 22 | } 23 | 24 | func NewPusher(endpoint string) (*Pusher, error) { 25 | ctx, err := zmq.NewContext() 26 | if err != nil { 27 | return nil, fmt.Errorf("creating ZMQ context: %w", err) 28 | } 29 | 30 | socket, err := ctx.NewSocket(zmq.PUSH) 31 | if err != nil { 32 | return nil, fmt.Errorf("creating ZMQ PUSH socket: %w", err) 33 | } 34 | 35 | if err := socket.Connect(endpoint); err != nil { 36 | return nil, fmt.Errorf("connecting ZMQ PUSH socket to %s: %w", endpoint, err) 37 | } 38 | 39 | if err := socket.SetLinger(0); err != nil { 40 | return nil, fmt.Errorf("setting ZMQ PUSH socket linger: %w", err) 41 | } 42 | 43 | return &Pusher{ctx: ctx, socket: socket}, nil 44 | } 45 | 46 | func (this Pusher) Push(topic string, env Envelope) error { 47 | body, err := json.Marshal(env) 48 | if err != nil { 49 | return fmt.Errorf("marshaling Envelope for topic %s: %w", topic, err) 50 | } 51 | 52 | if _, err := this.socket.SendMessage(topic, string(body)); err != nil { 53 | return fmt.Errorf("sending Envelope to topic %s: %w", topic, err) 54 | } 55 | 56 | return nil 57 | } 58 | 59 | func (this Pusher) PushString(topic, format string, a ...any) error { 60 | msg := fmt.Sprintf(format, a...) 61 | 62 | if _, err := this.socket.SendMessage(topic, msg); err != nil { 63 | return fmt.Errorf("sending string to topic %s: %w", topic, err) 64 | } 65 | 66 | return nil 67 | } 68 | -------------------------------------------------------------------------------- /src/go/msgbus/runtime.go: -------------------------------------------------------------------------------- 1 | package msgbus 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | ) 7 | 8 | type ( 9 | ConfirmationResults map[string]any 10 | ConfirmationErrors map[string]string 11 | ) 12 | 13 | const ( 14 | ENVELOPE_STATUS EnvelopeKind = "Status" 15 | ENVELOPE_UPDATE EnvelopeKind = "Update" 16 | ENVELOPE_CONFIRMATION EnvelopeKind = "Confirmation" 17 | ) 18 | 19 | var ( 20 | ErrKindNotStatus error = fmt.Errorf("not a Status message") 21 | ErrKindNotUpdate error = fmt.Errorf("not an Update message") 22 | ErrKindNotConfirmation error = fmt.Errorf("not a Confirmation message") 23 | ) 24 | 25 | type Point struct { 26 | Tag string `json:"tag"` 27 | Value float64 `json:"value"` 28 | Tstamp uint64 `json:"ts"` 29 | } 30 | 31 | type Status struct { 32 | Measurements []Point `json:"measurements"` 33 | } 34 | 35 | func (Status) Kind() EnvelopeKind { 36 | return ENVELOPE_STATUS 37 | } 38 | 39 | type Update struct { 40 | Updates []Point `json:"updates"` 41 | Recipient string `json:"recipient"` 42 | Confirm string `json:"confirm"` 43 | } 44 | 45 | func (Update) Kind() EnvelopeKind { 46 | return ENVELOPE_UPDATE 47 | } 48 | 49 | type Confirmation struct { 50 | Confirm string `json:"confirm"` 51 | Results ConfirmationResults `json:"results"` 52 | Errors ConfirmationErrors `json:"errors"` 53 | } 54 | 55 | func (Confirmation) Kind() EnvelopeKind { 56 | return ENVELOPE_CONFIRMATION 57 | } 58 | 59 | type IEnvelope interface { 60 | Status | Update | ModuleControl | Confirmation 61 | Kind() EnvelopeKind 62 | } 63 | 64 | func NewEnvelope[T IEnvelope](sender string, contents T) (Envelope, error) { 65 | var env Envelope 66 | 67 | raw, err := json.Marshal(contents) 68 | if err != nil { 69 | return env, fmt.Errorf("marshaling envelope contents: %w", err) 70 | } 71 | 72 | env = Envelope{ 73 | Version: "v1", 74 | Kind: contents.Kind(), 75 | Metadata: map[string]string{ 76 | "sender": sender, 77 | }, 78 | Contents: raw, 79 | } 80 | 81 | return env, nil 82 | } 83 | 84 | func (this Envelope) Status() (Status, error) { 85 | var status Status 86 | 87 | if this.Kind != ENVELOPE_STATUS { 88 | return status, ErrKindNotStatus 89 | } 90 | 91 | if err := json.Unmarshal(this.Contents, &status); err != nil { 92 | return status, fmt.Errorf("unmarshaling Status message: %w", err) 93 | } 94 | 95 | return status, nil 96 | } 97 | 98 | func (this Envelope) Update() (Update, error) { 99 | var update Update 100 | 101 | if this.Kind != ENVELOPE_UPDATE { 102 | return update, ErrKindNotUpdate 103 | } 104 | 105 | if err := json.Unmarshal(this.Contents, &update); err != nil { 106 | return update, fmt.Errorf("unmarshaling Update message: %w", err) 107 | } 108 | 109 | return update, nil 110 | } 111 | 112 | func (this Envelope) Conformation() (Confirmation, error) { 113 | var conf Confirmation 114 | 115 | if this.Kind != ENVELOPE_CONFIRMATION { 116 | return conf, ErrKindNotConfirmation 117 | } 118 | 119 | if err := json.Unmarshal(this.Contents, &conf); err != nil { 120 | return conf, fmt.Errorf("unmarshaling Confirmation message: %w", err) 121 | } 122 | 123 | return conf, nil 124 | } 125 | -------------------------------------------------------------------------------- /src/go/msgbus/subscriber.go: -------------------------------------------------------------------------------- 1 | package msgbus 2 | 3 | import ( 4 | "fmt" 5 | 6 | zmq "github.com/pebbe/zmq4" 7 | ) 8 | 9 | type ( 10 | StatusHandler func(Envelope) 11 | UpdateHandler func(Envelope) 12 | HealthCheckHandler func(Envelope) 13 | ) 14 | 15 | type Subscriber struct { 16 | ctx *zmq.Context 17 | socket *zmq.Socket 18 | 19 | running bool 20 | 21 | statusHandlers []StatusHandler 22 | updateHandlers []UpdateHandler 23 | healthCheckHandlers []HealthCheckHandler 24 | 25 | confirmationHandlers map[string]chan Confirmation 26 | } 27 | 28 | func MustNewSubscriber(endpoint string) *Subscriber { 29 | sub, err := NewSubscriber(endpoint) 30 | if err != nil { 31 | panic(err) 32 | } 33 | 34 | return sub 35 | } 36 | 37 | func NewSubscriber(endpoint string) (*Subscriber, error) { 38 | ctx, err := zmq.NewContext() 39 | if err != nil { 40 | return nil, fmt.Errorf("creating ZMQ context: %w", err) 41 | } 42 | 43 | socket, err := ctx.NewSocket(zmq.SUB) 44 | if err != nil { 45 | return nil, fmt.Errorf("creating ZMQ SUB socket: %w", err) 46 | } 47 | 48 | if err := socket.Connect(endpoint); err != nil { 49 | return nil, fmt.Errorf("connecting ZMQ SUB socket to %s: %w", endpoint, err) 50 | } 51 | 52 | if err := socket.SetLinger(0); err != nil { 53 | return nil, fmt.Errorf("setting ZMQ SUB socket linger: %w", err) 54 | } 55 | 56 | return &Subscriber{ctx: ctx, socket: socket, confirmationHandlers: make(map[string]chan Confirmation)}, nil 57 | } 58 | 59 | func (this *Subscriber) AddStatusHandler(handler StatusHandler) { 60 | this.statusHandlers = append(this.statusHandlers, handler) 61 | } 62 | 63 | func (this *Subscriber) AddUpdateHandler(handler UpdateHandler) { 64 | this.updateHandlers = append(this.updateHandlers, handler) 65 | } 66 | 67 | func (this *Subscriber) AddHealthCheckHandler(handler HealthCheckHandler) { 68 | this.healthCheckHandlers = append(this.healthCheckHandlers, handler) 69 | } 70 | 71 | func (this *Subscriber) RegisterConfirmationHandler(confirmation string) chan Confirmation { 72 | conf := make(chan Confirmation) 73 | 74 | this.confirmationHandlers[confirmation] = conf 75 | return conf 76 | } 77 | 78 | func (this *Subscriber) Start(topic string) { 79 | this.running = true 80 | go this.run(topic) 81 | } 82 | 83 | func (this *Subscriber) Stop() { 84 | this.running = false 85 | 86 | this.socket.Close() 87 | this.ctx.Term() 88 | } 89 | 90 | func (this Subscriber) run(topic string) { 91 | this.socket.SetSubscribe(topic) 92 | 93 | for this.running { 94 | msg, err := this.socket.RecvMessage(0) 95 | if err != nil { 96 | fmt.Printf("[ERROR] reading from ZMQ SUB socket: %v\n", err) 97 | continue 98 | } 99 | 100 | // This shouldn't ever really happen... 101 | if msg[0] != topic { 102 | continue 103 | } 104 | 105 | env, err := ParseEnvelope([]byte(msg[1])) 106 | if err != nil { 107 | fmt.Printf("[ERROR] creating envelope from message: %v\n", err) 108 | continue 109 | } 110 | 111 | switch env.Kind { 112 | case EnvelopeKind(ENVELOPE_STATUS): 113 | for _, handler := range this.statusHandlers { 114 | handler(env) 115 | } 116 | case EnvelopeKind(ENVELOPE_UPDATE): 117 | for _, handler := range this.updateHandlers { 118 | handler(env) 119 | } 120 | case EnvelopeKind(ENVELOPE_HEALTHCHECK): 121 | for _, handler := range this.healthCheckHandlers { 122 | handler(env) 123 | } 124 | case EnvelopeKind(ENVELOPE_CONFIRMATION): 125 | confirmation, err := env.Conformation() 126 | if err != nil { 127 | continue 128 | } 129 | 130 | conf, ok := this.confirmationHandlers[confirmation.Confirm] 131 | if !ok { 132 | continue 133 | } 134 | 135 | conf <- confirmation 136 | 137 | delete(this.confirmationHandlers, confirmation.Confirm) 138 | } 139 | } 140 | } 141 | -------------------------------------------------------------------------------- /src/go/ot-sim.go: -------------------------------------------------------------------------------- 1 | package otsim 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "sync" 7 | 8 | "github.com/beevik/etree" 9 | ) 10 | 11 | type ModuleFactory interface { 12 | NewModule(*etree.Element) (Module, error) 13 | } 14 | 15 | type Module interface { 16 | Name() string 17 | Configure(*etree.Element) error 18 | } 19 | 20 | type Runner interface { 21 | Name() string 22 | Run(context.Context, string, string) error 23 | } 24 | 25 | var Waiter sync.WaitGroup 26 | 27 | var ( 28 | factories = make(map[string]ModuleFactory) 29 | runners []Runner 30 | 31 | pubEndpoint string 32 | pullEndpoint string 33 | ) 34 | 35 | func AddModuleFactory(tag string, factory ModuleFactory) { 36 | factories[tag] = factory 37 | } 38 | 39 | func ParseConfigFile(path string) error { 40 | doc := etree.NewDocument() 41 | 42 | if err := doc.ReadFromFile(path); err != nil { 43 | return fmt.Errorf("reading XML config file %s: %w", path, err) 44 | } 45 | 46 | root := doc.SelectElement("ot-sim") 47 | 48 | if root == nil { 49 | return fmt.Errorf("root element of XML config file must be 'ot-sim'") 50 | } 51 | 52 | if bus := root.FindElement("message-bus"); bus != nil { 53 | for _, child := range bus.ChildElements() { 54 | switch child.Tag { 55 | case "pub-endpoint": 56 | pubEndpoint = child.Text() 57 | case "pull-endpoint": 58 | pullEndpoint = child.Text() 59 | } 60 | } 61 | } 62 | 63 | for _, child := range root.ChildElements() { 64 | if factory, ok := factories[child.Tag]; ok { 65 | module, err := factory.NewModule(child) 66 | if err != nil { 67 | return fmt.Errorf("creating new module for %s: %w", child.Tag, err) 68 | } 69 | 70 | if err := module.Configure(child); err != nil { 71 | return fmt.Errorf("running configure for module %s for %s: %w", module.Name(), child.Tag, err) 72 | } 73 | 74 | if runner, ok := module.(Runner); ok { 75 | runners = append(runners, runner) 76 | } 77 | } 78 | } 79 | 80 | return nil 81 | } 82 | 83 | func Start(ctx context.Context) error { 84 | if len(runners) == 0 { 85 | return fmt.Errorf("no registered runners") 86 | } 87 | 88 | for _, runner := range runners { 89 | if err := runner.Run(ctx, pubEndpoint, pullEndpoint); err != nil { 90 | return fmt.Errorf("starting %s: %w", runner.Name(), err) 91 | } 92 | } 93 | 94 | return nil 95 | } 96 | -------------------------------------------------------------------------------- /src/go/staticcheck.conf: -------------------------------------------------------------------------------- 1 | checks = ["all", "-ST1006"] -------------------------------------------------------------------------------- /src/go/sunspec/README.md: -------------------------------------------------------------------------------- 1 | # OT-sim SunSpec Module 2 | 3 | Specify list of model IDs to include in SunSpec device. 4 | 5 | Use existing Modbus server and client implementations with support for different 6 | holding register data types. 7 | 8 | * will need to add support for string types that are used by SunSpec 9 | 10 | Client requests should be for groups of holding registers that map to entire 11 | SunSpec model point groups. On the server side, this means parsing through the 12 | point IDs and grabbing configured static values or values from OT-sim tags to 13 | stuff into the response. 14 | 15 | What will be different between client and server config options? 16 | 17 | * hopefully nothing? 18 | 19 | How do we go about mapping certain SunSpec model points (correct term?) to 20 | OT-sim tags? 21 | 22 | * map point IDs to OT-sim tag or static value? 23 | * have default static values for point IDs that can be overwritten? 24 | 25 | How do we want to specify point scalings? 26 | 27 | * map scaling values to SF values? 28 | * have deault scaling values for SF's that can be overwritten? 29 | 30 | ## Server 31 | 32 | * If XML config element for SunSpec point is a string: 33 | * if schema for point says it's type string: 34 | * static value for point 35 | * if schema for point says it's type other than string: 36 | * OT-sim tag to get value from 37 | * If XML config element for SunSpec point is a number: 38 | * if schema for point says it's type string: 39 | * ERROR 40 | * if schema for point says it's type other than string: 41 | * static value for point 42 | 43 | XML config element values will always be strings. Should we try to parse as 44 | number, catch error, and assume string on error, or should we use an XML element 45 | attribute to denote static values? I vote first option... less typing in config. 46 | 47 | ## TODO 48 | 49 | * [x] Build out initial Model 1 (static data) 50 | * [x] Add OT-sim msg bus status handler 51 | * do we need an update handler? 52 | * [x] Figure out how to handle scaling config 53 | * [ ] Support mapping OT-sim tag names client-side 54 | * [ ] Support different scan rates for different models client-side 55 | * [-] Support writes client-side (subscribe to updates) 56 | 57 | Server-side is "pretty much" done. Client side needs work 1) continuing to read 58 | available models, and 2) mapping model points to OT-sim tags. The client doesn't 59 | really need to know what models the server side is providing ahead of time since 60 | it can query the server for that, but configuration-wise users will need to know 61 | so they can assign tags to points. Alternatively, we could default to a 62 | well-defined method of automatically mapping points to tags. This could be as 63 | easy as the SunSpec point's name. If we find that point names are not unique 64 | across all SunSpec models, we could prefix the name with the model number. We 65 | can also do things like skip publishing scaling factors within OT-sim. 66 | -------------------------------------------------------------------------------- /src/go/sunspec/client/util.go: -------------------------------------------------------------------------------- 1 | package client 2 | 3 | import ( 4 | "fmt" 5 | 6 | "actshad.dev/modbus" 7 | "github.com/patsec/ot-sim/sunspec/common" 8 | ) 9 | 10 | func confirmIdentifier(c modbus.Client) error { 11 | r := common.IdentifierRegister 12 | 13 | body, err := c.ReadHoldingRegisters(40000, uint16(r.Count)) 14 | if err != nil { 15 | return fmt.Errorf("reading identifier: %w", err) 16 | } 17 | 18 | identifier, err := r.Value(body, 0.0) 19 | if err != nil { 20 | return fmt.Errorf("parsing identifier: %w", err) 21 | } 22 | 23 | if identifier != common.SunSpecIdentifier { 24 | return fmt.Errorf("invalid identifier provided by remote device") 25 | } 26 | 27 | return nil 28 | } 29 | 30 | func nextModel(c modbus.Client, a int) (int, int, error) { 31 | r := common.Register{DataType: "uint16"} 32 | 33 | if err := r.Init(); err != nil { 34 | return 0, 0, fmt.Errorf("initializing generic model register %d: %w", a, err) 35 | } 36 | 37 | // read model and length at same time 38 | d, err := c.ReadHoldingRegisters(uint16(a), 2) 39 | if err != nil { 40 | return 0, 0, fmt.Errorf("reading model ID and length: %w", err) 41 | } 42 | 43 | m, err := r.Value(d[0:2], 0.0) 44 | if err != nil { 45 | return 0, 0, fmt.Errorf("parsing model ID: %w", err) 46 | } 47 | 48 | l, err := r.Value(d[2:4], 0.0) 49 | if err != nil { 50 | return 0, 0, fmt.Errorf("parsing model length: %w", err) 51 | } 52 | 53 | return int(m), int(l), nil 54 | } 55 | 56 | func modelData(c modbus.Client, m, a, l int) (map[string]*common.Register, error) { 57 | regs := make(map[string]*common.Register) 58 | 59 | d, err := c.ReadHoldingRegisters(uint16(a), uint16(l)) 60 | if err != nil { 61 | return nil, fmt.Errorf("reading Model %d data: %w", m, err) 62 | } 63 | 64 | s, err := common.GetModelSchema(m) 65 | if err != nil { 66 | return nil, fmt.Errorf("getting Model %d schema: %w", m, err) 67 | } 68 | 69 | // track position of current model data array 70 | var pos int 71 | 72 | for i, p := range s.Group.Points { 73 | if i < 2 { 74 | continue 75 | } 76 | 77 | dt := string(p.Type) 78 | if dt == string(common.PointTypeString) { 79 | dt = fmt.Sprintf("string%d", p.Size) 80 | } 81 | 82 | r := &common.Register{ 83 | DataType: dt, 84 | Name: p.Name, 85 | Model: m, 86 | } 87 | 88 | if p.Access == common.PointAccessRW { 89 | r.Addr = a + pos 90 | } 91 | 92 | switch sf := p.Sf.(type) { 93 | case nil: 94 | // noop 95 | case int: 96 | r.Scaling = float64(sf) 97 | case string: 98 | r.ScaleRegister = sf 99 | default: 100 | return nil, fmt.Errorf("unknown type when parsing scaling factor for %s", p.Name) 101 | } 102 | 103 | if err := r.Init(); err != nil { 104 | return nil, fmt.Errorf("initializing %s register: %w", p.Name, err) 105 | } 106 | 107 | r.Raw = d[pos : pos+(p.Size*2)] 108 | 109 | regs[r.Name] = r 110 | 111 | pos += p.Size * 2 112 | } 113 | 114 | return regs, nil 115 | } 116 | -------------------------------------------------------------------------------- /src/go/sunspec/common/common.go: -------------------------------------------------------------------------------- 1 | package common 2 | 3 | var SunSpecIdentifier = float64(1400204883) // SunS 4 | 5 | var IdentifierRegister = Register{ 6 | DataType: "uint32", 7 | Name: "SunSpec_Identifier", 8 | InternalValue: SunSpecIdentifier, 9 | } 10 | 11 | var EndRegister = Register{ 12 | DataType: "uint16", 13 | Name: "SunSpec_EndRegister", 14 | InternalValue: 65535, 15 | } 16 | 17 | var EndRegisterLength = Register{ 18 | DataType: "uint16", 19 | Name: "SunSpec_EndRegister_Length", 20 | InternalValue: 0, 21 | } 22 | 23 | type Models struct { 24 | Order []int 25 | Settings map[int]ModelSettings 26 | } 27 | 28 | type ModelSettings struct { 29 | Model int 30 | StartAddr int 31 | Length int 32 | } 33 | 34 | func init() { 35 | if err := IdentifierRegister.Init(); err != nil { 36 | panic(err) 37 | } 38 | 39 | if err := EndRegister.Init(); err != nil { 40 | panic(err) 41 | } 42 | 43 | if err := EndRegisterLength.Init(); err != nil { 44 | panic(err) 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /src/go/sunspec/common/schema.go: -------------------------------------------------------------------------------- 1 | package common 2 | 3 | import ( 4 | "embed" 5 | "encoding/json" 6 | "fmt" 7 | ) 8 | 9 | //go:embed models/json/* 10 | var schemas embed.FS 11 | 12 | func GetModelSchema(id int) (SchemaJson, error) { 13 | var model SchemaJson 14 | 15 | schema, err := schemas.ReadFile(fmt.Sprintf("models/json/model_%d.json", id)) 16 | if err != nil { 17 | return model, fmt.Errorf("reading model %d schema: %w", id, err) 18 | } 19 | 20 | if err := json.Unmarshal(schema, &model); err != nil { 21 | return model, fmt.Errorf("unmarshaling model %d schema: %w", id, err) 22 | } 23 | 24 | return model, nil 25 | } 26 | 27 | func GetModelLength(model SchemaJson) int { 28 | var length int 29 | 30 | for idx, point := range model.Group.Points { 31 | if idx < 2 { 32 | continue 33 | } 34 | 35 | length += point.Size 36 | } 37 | 38 | return length 39 | } 40 | -------------------------------------------------------------------------------- /src/go/sunspec/sunspec.go: -------------------------------------------------------------------------------- 1 | package sunspec 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | 7 | otsim "github.com/patsec/ot-sim" 8 | "github.com/patsec/ot-sim/sunspec/client" 9 | "github.com/patsec/ot-sim/sunspec/server" 10 | 11 | "github.com/beevik/etree" 12 | ) 13 | 14 | type Factory struct{} 15 | 16 | func (Factory) NewModule(e *etree.Element) (otsim.Module, error) { 17 | mode := e.SelectAttrValue("mode", "server") 18 | 19 | switch strings.ToLower(mode) { 20 | case "server": 21 | name := e.SelectAttrValue("name", "sunspec-server") 22 | return server.New(name), nil 23 | case "client": 24 | name := e.SelectAttrValue("name", "sunspec-client") 25 | return client.New(name), nil 26 | } 27 | 28 | return nil, fmt.Errorf("unknown mode '%s' provided for SunSpec module", mode) 29 | } 30 | 31 | func init() { 32 | otsim.AddModuleFactory("sunspec", new(Factory)) 33 | } 34 | -------------------------------------------------------------------------------- /src/go/telnet/banner.go: -------------------------------------------------------------------------------- 1 | package telnet 2 | 3 | var bannerOTSim = ` 4 | _______ _________ _______ _________ _______ 5 | ( ___ )\__ __/ ( ____ \\__ __/( ) 6 | | ( ) | ) ( | ( \/ ) ( | () () | 7 | | | | | | | _____ | (_____ | | | || || | 8 | | | | | | |(_____)(_____ ) | | | |(_)| | 9 | | | | | | | ) | | | | | | | 10 | | (___) | | | /\____) |___) (___| ) ( | 11 | (_______) )_( \_______)\_______/|/ \| 12 | 13 | ` 14 | 15 | var banners = map[string]string{"default": bannerOTSim} 16 | -------------------------------------------------------------------------------- /src/go/telnet/modules.go: -------------------------------------------------------------------------------- 1 | package telnet 2 | 3 | import ( 4 | "fmt" 5 | "io" 6 | "time" 7 | 8 | "github.com/patsec/ot-sim/msgbus" 9 | "github.com/reiver/go-telnet" 10 | "github.com/reiver/go-telnet/telsh" 11 | 12 | "github.com/gofrs/uuid" 13 | ) 14 | 15 | func (this Telnet) modulesHandlerFunc(stdin io.ReadCloser, stdout io.WriteCloser, stderr io.WriteCloser, args ...string) error { 16 | defer fmt.Fprintln(stdout) 17 | 18 | var ( 19 | confirmation = uuid.Must(uuid.NewV4()).String() 20 | control = msgbus.ModuleControl{List: true, Recipient: "CPU", Confirm: confirmation} 21 | ) 22 | 23 | conf := this.internal.RegisterConfirmationHandler(confirmation) 24 | 25 | env, err := msgbus.NewEnvelope(this.name, control) 26 | if err != nil { 27 | this.log("[ERROR] creating new module control message: %v", err) 28 | return err 29 | } 30 | 31 | if err := this.pusher.Push("INTERNAL", env); err != nil { 32 | this.log("[ERROR] sending module control message: %v", err) 33 | return err 34 | } 35 | 36 | select { 37 | case c := <-conf: 38 | for k, v := range c.Results { 39 | fmt.Fprintf(stdout, "%s --> %s\n", k, v) 40 | } 41 | case <-time.After(5 * time.Second): 42 | fmt.Fprintln(stderr, "request for module list timed out") 43 | } 44 | 45 | return nil 46 | } 47 | 48 | func (this Telnet) modulesProducerFunc(ctx telnet.Context, name string, args ...string) telsh.Handler { 49 | return telsh.PromoteHandlerFunc(this.modulesHandlerFunc, args...) 50 | } 51 | 52 | func (this Telnet) disableModuleHandlerFunc(stdin io.ReadCloser, stdout io.WriteCloser, stderr io.WriteCloser, args ...string) error { 53 | defer fmt.Fprintln(stdout) 54 | 55 | if len(args) == 0 { 56 | fmt.Fprintln(stderr, "must provide at least one module to disable") 57 | return fmt.Errorf("must provide at least one module to disable") 58 | } 59 | 60 | var ( 61 | confirmation = uuid.Must(uuid.NewV4()).String() 62 | control = msgbus.ModuleControl{Disable: args, Recipient: "CPU", Confirm: confirmation} 63 | ) 64 | 65 | conf := this.internal.RegisterConfirmationHandler(confirmation) 66 | 67 | env, err := msgbus.NewEnvelope(this.name, control) 68 | if err != nil { 69 | this.log("[ERROR] creating new module control message: %v", err) 70 | return err 71 | } 72 | 73 | if err := this.pusher.Push("INTERNAL", env); err != nil { 74 | this.log("[ERROR] sending module control message: %v", err) 75 | return err 76 | } 77 | 78 | select { 79 | case c := <-conf: 80 | for k, v := range c.Results { 81 | fmt.Fprintf(stdout, "%s --> %s\n", k, v) 82 | } 83 | 84 | for k, v := range c.Errors { 85 | fmt.Fprintf(stderr, "%s --> %s\n", k, v) 86 | } 87 | case <-time.After(5 * time.Second): 88 | fmt.Fprintln(stderr, "request for module list timed out") 89 | } 90 | 91 | return nil 92 | } 93 | 94 | func (this Telnet) disableModuleProducerFunc(ctx telnet.Context, name string, args ...string) telsh.Handler { 95 | return telsh.PromoteHandlerFunc(this.disableModuleHandlerFunc, args...) 96 | } 97 | 98 | func (this Telnet) enableModuleHandlerFunc(stdin io.ReadCloser, stdout io.WriteCloser, stderr io.WriteCloser, args ...string) error { 99 | defer fmt.Fprintln(stdout) 100 | 101 | if len(args) == 0 { 102 | fmt.Fprintln(stderr, "must provide at least one module to enable") 103 | return fmt.Errorf("must provide at least one module to enable") 104 | } 105 | 106 | var ( 107 | confirmation = uuid.Must(uuid.NewV4()).String() 108 | control = msgbus.ModuleControl{Enable: args, Recipient: "CPU", Confirm: confirmation} 109 | ) 110 | 111 | conf := this.internal.RegisterConfirmationHandler(confirmation) 112 | 113 | env, err := msgbus.NewEnvelope(this.name, control) 114 | if err != nil { 115 | this.log("[ERROR] creating new module control message: %v", err) 116 | return err 117 | } 118 | 119 | if err := this.pusher.Push("INTERNAL", env); err != nil { 120 | this.log("[ERROR] sending module control message: %v", err) 121 | return err 122 | } 123 | 124 | select { 125 | case c := <-conf: 126 | for k, v := range c.Results { 127 | fmt.Fprintf(stdout, "%s --> %s\n", k, v) 128 | } 129 | 130 | for k, v := range c.Errors { 131 | fmt.Fprintf(stderr, "%s --> %s\n", k, v) 132 | } 133 | case <-time.After(5 * time.Second): 134 | fmt.Fprintln(stderr, "request for module list timed out") 135 | } 136 | 137 | return nil 138 | } 139 | 140 | func (this Telnet) enableModuleProducerFunc(ctx telnet.Context, name string, args ...string) telsh.Handler { 141 | return telsh.PromoteHandlerFunc(this.enableModuleHandlerFunc, args...) 142 | } 143 | -------------------------------------------------------------------------------- /src/go/util/context.go: -------------------------------------------------------------------------------- 1 | package util 2 | 3 | import "context" 4 | 5 | type configFileKey struct{} 6 | 7 | func SetConfigFile(ctx context.Context, path string) context.Context { 8 | return context.WithValue(ctx, configFileKey{}, path) 9 | } 10 | 11 | func ConfigFile(ctx context.Context) (string, bool) { 12 | path, ok := ctx.Value(configFileKey{}).(string) 13 | return path, ok 14 | } 15 | 16 | func MustConfigFile(ctx context.Context) string { 17 | if path, ok := ConfigFile(ctx); ok { 18 | return path 19 | } 20 | 21 | panic("config file not set in context") 22 | } 23 | -------------------------------------------------------------------------------- /src/go/util/exit.go: -------------------------------------------------------------------------------- 1 | package util 2 | 3 | import "fmt" 4 | 5 | const ( 6 | ExitNoRestart int = 101 7 | ) 8 | 9 | type ExitError struct { 10 | ExitCode int 11 | errorMsg string 12 | } 13 | 14 | func NewExitError(code int, format string, a ...any) ExitError { 15 | return ExitError{ 16 | ExitCode: code, 17 | errorMsg: fmt.Sprintf(format, a...), 18 | } 19 | } 20 | 21 | func (this ExitError) Error() string { 22 | return this.errorMsg 23 | } 24 | -------------------------------------------------------------------------------- /src/go/util/sigterm/context.go: -------------------------------------------------------------------------------- 1 | package sigterm 2 | 3 | import ( 4 | "context" 5 | "os" 6 | "os/signal" 7 | "syscall" 8 | ) 9 | 10 | func CancelContext(ctx context.Context) context.Context { 11 | ctxWithCancel, cancel := context.WithCancel(ctx) 12 | 13 | go func() { 14 | defer cancel() 15 | 16 | term := make(chan os.Signal, 1) 17 | signal.Notify(term, syscall.SIGTERM, syscall.SIGINT) 18 | 19 | select { 20 | case <-term: 21 | case <-ctx.Done(): 22 | } 23 | }() 24 | 25 | return ctxWithCancel 26 | } 27 | -------------------------------------------------------------------------------- /src/go/util/slice.go: -------------------------------------------------------------------------------- 1 | package util 2 | 3 | func SliceContains[T comparable](s []T, v T) bool { 4 | for _, e := range s { 5 | if v == e { 6 | return true 7 | } 8 | } 9 | 10 | return false 11 | } 12 | -------------------------------------------------------------------------------- /src/js/node-red/icons/zeromq.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/patsec/ot-sim/5e7c2e7e75d54242015b03035d26307858bb9a02/src/js/node-red/icons/zeromq.png -------------------------------------------------------------------------------- /src/js/node-red/ot-sim.html: -------------------------------------------------------------------------------- 1 | 17 | 18 | 28 | 29 | 32 | 33 | 48 | 49 | 55 | 56 | 59 | -------------------------------------------------------------------------------- /src/js/node-red/ot-sim.js: -------------------------------------------------------------------------------- 1 | module.exports = function(RED) { 2 | "use strict"; 3 | var zmq = require('zeromq'); 4 | 5 | function OTsimIn(config) { 6 | RED.nodes.createNode(this, config); 7 | 8 | this.tag = config.tag; 9 | this.updates = config.updates; 10 | 11 | var node = this; 12 | 13 | node.endpoint = 'tcp://localhost:5678'; 14 | 15 | if (RED.settings.otsim && RED.settings.otsim.pub) { 16 | node.endpoint = RED.settings.otsim.pub; 17 | } 18 | 19 | node.sock = zmq.socket('sub'); 20 | 21 | node.sock.connect(node.endpoint); 22 | node.sock.subscribe('RUNTIME'); 23 | 24 | node.status({fill: "green", shape: "ring", text: "subscribing"}); 25 | 26 | node.sock.on('message', function(topic, msg) { 27 | msg = JSON.parse(msg.toString()); 28 | 29 | if (msg.kind === 'Status') { 30 | for (const m of msg.contents.measurements) { 31 | if (m.tag === node.tag) { 32 | node.send({topic: node.tag, payload: m.value}); 33 | } 34 | } 35 | } 36 | 37 | if (node.updates && msg.kind === 'Update') { 38 | for (const u of msg.contents.updates) { 39 | if (u.tag === node.tag) { 40 | node.send({payload: u.value}); 41 | } 42 | } 43 | } 44 | }); 45 | } 46 | 47 | RED.nodes.registerType("ot-sim in", OTsimIn); 48 | 49 | function OTsimOut(config) { 50 | RED.nodes.createNode(this, config); 51 | 52 | this.tag = config.tag; 53 | var node = this; 54 | 55 | node.endpoint = 'tcp://localhost:1234'; 56 | 57 | if (RED.settings.otsim && RED.settings.otsim.pull) { 58 | node.endpoint = RED.settings.otsim.pull; 59 | } 60 | 61 | node.sock = zmq.socket('push'); 62 | 63 | node.sock.connect(node.endpoint); 64 | node.sock.setsockopt(zmq.ZMQ_LINGER, 0); 65 | 66 | node.status({fill: "yellow", shape: "ring", text: "idle"}); 67 | 68 | node.on('input', function(msg) { 69 | var value = parseFloat(msg.payload); 70 | 71 | if (isNaN(value)) { 72 | console.log('payload was not a valid floating point number'); 73 | return; 74 | } 75 | 76 | var update = { 77 | version: 'v1', 78 | kind: 'Update', 79 | metadata: { 80 | sender: 'Node-RED' 81 | }, 82 | contents: { 83 | updates: [ 84 | { 85 | tag: node.tag, 86 | value: value, 87 | ts: 0.0 88 | } 89 | ], 90 | recipient: '', 91 | confirm: '' 92 | } 93 | } 94 | 95 | node.status({fill: "green", shape: "ring", text: "updating"}); 96 | node.sock.send(['RUNTIME', JSON.stringify(update)]) 97 | node.status({fill: "yellow", shape: "ring", text: "idle"}); 98 | }); 99 | } 100 | 101 | RED.nodes.registerType("ot-sim out", OTsimOut); 102 | } 103 | -------------------------------------------------------------------------------- /src/js/node-red/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "node-red-contrib-ot-sim", 3 | "version": "0.0.1", 4 | "dependencies": { 5 | "zeromq": "^5.3.1" 6 | }, 7 | "node-red": { 8 | "nodes": { 9 | "ot-sim": "ot-sim.js" 10 | } 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /src/old/README.md: -------------------------------------------------------------------------------- 1 | # Old Code 2 | 3 | This is all old code that I don't necessarily want to get rid of yet because 4 | it's got some good examples of how to do things like use pydnp3, use JSON and 5 | XML in C, etc. 6 | 7 | ## Noteworthy 8 | 9 | * The pydnp3 master implementation is lacking because pydnp3 doesn't provide a 10 | way to get at the values gathered from scheduled class scans. 11 | * The pydnp3 code does not use the `msgbus` package the other Python code uses. 12 | * The C implementation of the `ot-sim-io-module` is fully functional, but it 13 | currently expects `` and `` elements to be wrapped 14 | in `` and `` parent elements. The Python 15 | implementation of the `ot-sim-io-module` does not look for the parent elements. 16 | -------------------------------------------------------------------------------- /src/old/c/CMakeLists.txt: -------------------------------------------------------------------------------- 1 | cmake_minimum_required(VERSION 3.11) 2 | 3 | project(ot-sim LANGUAGES C) 4 | 5 | set(JSON_INCLUDE_DIRS 6 | ${CMAKE_SOURCE_DIR}/deps 7 | ) 8 | 9 | add_subdirectory(deps/json-c) 10 | 11 | add_subdirectory(cmd/ot-sim-io-module) -------------------------------------------------------------------------------- /src/old/c/Makefile: -------------------------------------------------------------------------------- 1 | .DEFAULT_GOAL := all 2 | 3 | .PHONY: all 4 | all: bin/debugger bin/logger bin/msgbus-test 5 | 6 | .PHONY: clean 7 | clean: 8 | $(RM) bin/* 9 | 10 | bin/debugger: msgbus/debugger.c 11 | mkdir -p bin 12 | gcc msgbus/debugger.c -lczmq -lzmq -o bin/debugger 13 | 14 | bin/logger: msgbus/logger.c 15 | mkdir -p bin 16 | gcc msgbus/logger.c -lczmq -lzmq -o bin/logger 17 | 18 | bin/msgbus-test: msgbus/msgbus-test.c 19 | mkdir -p bin 20 | gcc msgbus/msgbus-test.c -lczmq -lzmq -o bin/msgbus-test 21 | -------------------------------------------------------------------------------- /src/old/c/cmd/ot-sim-io-module/CMakeLists.txt: -------------------------------------------------------------------------------- 1 | find_package(HELICS REQUIRED) 2 | find_package(LibXml2 REQUIRED) 3 | 4 | include_directories( 5 | ${JSON_INCLUDE_DIRS} 6 | ${LIBXML2_INCLUDE_DIR} 7 | ) 8 | 9 | add_executable(ot-sim-io-module 10 | main.c 11 | ) 12 | 13 | target_link_libraries(ot-sim-io-module 14 | czmq 15 | ${LIBXML2_LIBRARIES} 16 | ${HELICS_C_SHARED_LIBRARY} 17 | json-c 18 | pthread 19 | zmq 20 | ) 21 | 22 | install(TARGETS ot-sim-io-module 23 | RUNTIME DESTINATION bin 24 | ) -------------------------------------------------------------------------------- /src/old/c/msgbus/debugger.c: -------------------------------------------------------------------------------- 1 | #include "czmq.h" 2 | 3 | int main(int argc, char *argv[]) { 4 | if (argc != 2) { 5 | puts("PULL endpoint must be provided as argument"); 6 | return 1; 7 | } 8 | 9 | zsock_t *capture = zsock_new_pull(argv[1]); 10 | assert (capture); 11 | 12 | while(1) { 13 | puts("waiting..."); 14 | 15 | char *topic, *msg; 16 | if (zstr_recvx(capture, &topic, &msg, NULL) < 0) { 17 | break; 18 | } 19 | 20 | printf("%s: %s\n", topic, msg); 21 | 22 | zstr_free(&topic); 23 | zstr_free(&msg); 24 | } 25 | 26 | printf("\nexiting debugger\n"); 27 | 28 | zsock_destroy(&capture); 29 | return 0; 30 | } -------------------------------------------------------------------------------- /src/old/c/msgbus/logger.c: -------------------------------------------------------------------------------- 1 | #include "czmq.h" 2 | 3 | int main(int argc, char *argv[]) { 4 | if (argc != 3) { 5 | puts("PUB endpoint and filter must be provided as arguments"); 6 | return 1; 7 | } 8 | 9 | zsock_t *sub = zsock_new_sub(argv[1], argv[2]); 10 | assert (sub); 11 | 12 | while(1) { 13 | puts("waiting..."); 14 | 15 | char *topic, *msg; 16 | if (zstr_recvx(sub, &topic, &msg, NULL) < 0) { 17 | break; 18 | } 19 | 20 | printf("%s: %s\n", topic, msg); 21 | 22 | zstr_free(&topic); 23 | zstr_free(&msg); 24 | } 25 | 26 | printf("\nexiting logger\n"); 27 | 28 | zsock_destroy(&sub); 29 | return 0; 30 | } -------------------------------------------------------------------------------- /src/old/c/msgbus/msgbus-ini-example.c: -------------------------------------------------------------------------------- 1 | #include 2 | 3 | #include "czmq.h" 4 | // #include "ini.h" 5 | 6 | typedef struct { 7 | int verbose; 8 | const char *pull; 9 | const char *pub; 10 | const char *debug; 11 | } config; 12 | 13 | #define MATCHXML(e, n) xmlStrcmp(e->name, (const xmlChar*) n) == 0 14 | 15 | static int xml_handler(config *c, xmlDoc *doc, xmlNode *node) { 16 | xmlChar *text; 17 | 18 | node = node->xmlChildrenNode; 19 | 20 | while (node != NULL) { 21 | text = xmlNodeListGetString(doc, node->xmlChildrenNode, 1); 22 | 23 | if (MATCHXML(node, "verbose")) { 24 | c->verbose = atoi(text); 25 | } else if (MATCHXML(node, "pull-endpoint")) { 26 | c->pull = strdup(text); 27 | } else if (MATCHXML(node, "pub-endpoint")) { 28 | c->pub = strdup(text); 29 | } else if (MATCHXML(node, "debug-endpoint")) { 30 | c->debug = strdup(text); 31 | } 32 | 33 | xmlFree(text); 34 | node = node->next; 35 | } 36 | 37 | return 0; 38 | } 39 | 40 | static int xml_parse(char *path, config *c) { 41 | xmlDoc *doc; 42 | xmlNode *node; 43 | 44 | doc = xmlParseFile(path); 45 | 46 | if (doc == NULL) { 47 | return -1; 48 | } 49 | 50 | node = xmlDocGetRootElement(doc); 51 | 52 | if (node == NULL) { 53 | xmlFreeDoc(doc); 54 | return -1; 55 | } 56 | 57 | if (!MATCHXML(node, "ot-sim")) { 58 | xmlFreeDoc(doc); 59 | return -1; 60 | } 61 | 62 | node = node->xmlChildrenNode; 63 | 64 | while (node != NULL) { 65 | // look for top-level message-bus element only 66 | if (MATCHXML(node, "message-bus")) { 67 | int rc = xml_handler(c, doc, node); 68 | 69 | xmlFreeDoc(doc); 70 | return rc; 71 | } 72 | 73 | node = node->next; 74 | } 75 | 76 | xmlFreeDoc(doc); 77 | return 0; 78 | } 79 | 80 | /* 81 | static int ini_handler(void *user, const char *section, const char *name, const char *value) { 82 | config *pconfig = (config*)user; 83 | 84 | #define MATCH(s, n) strcmp(section, s) == 0 && strcmp(name, n) == 0 85 | 86 | if (MATCH("proxy", "verbose")) { 87 | pconfig->verbose = atoi(value); 88 | } else if (MATCH("proxy", "pull")) { 89 | pconfig->pull = strdup(value); 90 | } else if (MATCH("proxy", "pub")) { 91 | pconfig->pub = strdup(value); 92 | } else if (MATCH("proxy", "debug")) { 93 | pconfig->debug = strdup(value); 94 | } 95 | 96 | return 0; 97 | } 98 | */ 99 | 100 | int main(int argc, char *argv[]) { 101 | config c; 102 | 103 | c.verbose = 0; 104 | c.pull = "tcp://127.0.0.1:7777"; 105 | c.pub = "tcp://127.0.0.1:8888"; 106 | c.debug = NULL; 107 | 108 | if (argc == 2) { 109 | printf("loading config %s\n", argv[1]); 110 | 111 | if (xml_parse(argv[1], &c) != 0) { 112 | puts("cannot load XML config"); 113 | return 1; 114 | } 115 | 116 | /* 117 | if (ini_parse(argv[1], ini_handler, &c) < 0) { 118 | puts("cannot load config"); 119 | return 1; 120 | } 121 | */ 122 | } 123 | 124 | zactor_t *proxy = zactor_new(zproxy, NULL); 125 | assert (proxy); 126 | 127 | if (c.verbose) { 128 | puts("setting proxy to verbose"); 129 | 130 | zstr_sendx(proxy, "VERBOSE", NULL); 131 | zsock_wait(proxy); 132 | } 133 | 134 | printf("using %s for PULL endpoint\n", c.pull); 135 | 136 | zstr_sendx(proxy, "FRONTEND", "PULL", c.pull, NULL); 137 | zsock_wait(proxy); 138 | 139 | printf("using %s for PUB endpoint\n", c.pub); 140 | 141 | zstr_sendx(proxy, "BACKEND", "PUB", c.pub, NULL); 142 | zsock_wait(proxy); 143 | 144 | if (c.debug) { 145 | printf("setting up proxy capture to debug endpoint %s\n", c.debug); 146 | 147 | zstr_sendx(proxy, "CAPTURE", c.debug, NULL); 148 | zsock_wait(proxy); 149 | } 150 | 151 | while(1) { 152 | puts("proxy running"); 153 | 154 | unsigned int remaining = sleep(UINT_MAX); 155 | 156 | // likely not interrupted with signal 157 | if (remaining == 0) { 158 | continue; 159 | } 160 | 161 | // interrupted with signal 162 | break; 163 | } 164 | 165 | printf("\nexiting proxy\n"); 166 | 167 | zactor_destroy(&proxy); 168 | return 0; 169 | } -------------------------------------------------------------------------------- /src/old/c/msgbus/msgbus-test.c: -------------------------------------------------------------------------------- 1 | #include "czmq.h" 2 | 3 | int main(int argc, char *argv[]) { 4 | if (argc != 2) { 5 | puts("PULL endpoint must be provided as argument"); 6 | return 1; 7 | } 8 | 9 | zsock_t *pusher = zsock_new(ZMQ_PUSH); 10 | assert (pusher); 11 | 12 | zactor_t *mon = zactor_new(zmonitor, pusher); 13 | assert (mon); 14 | 15 | zstr_send(mon, "VERBOSE"); 16 | zstr_sendx(mon, "LISTEN", "ALL", NULL); 17 | zstr_send(mon, "START"); 18 | zsock_wait(mon); 19 | 20 | printf("connecting to %s\n", argv[1]); 21 | 22 | if (zsock_connect(pusher, "%s", argv[1]) != 0) { 23 | printf("error connecting: %s\n", zmq_strerror(errno)); 24 | }; 25 | 26 | int rc = zstr_sendx(pusher, "LOG", "Hello, world!", NULL); 27 | if (rc != 0 ) { 28 | printf("error sending log message: %s\n", zmq_strerror(errno)); 29 | } 30 | 31 | zmq_poll(NULL, 0, 200); 32 | 33 | zactor_destroy(&mon); 34 | zsock_destroy(&pusher); 35 | 36 | return 0; 37 | } -------------------------------------------------------------------------------- /src/old/python/dnp3/client.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import json, signal, sys, threading, time, typing, zmq 4 | import xml.etree.ElementTree as ET 5 | 6 | from pydnp3 import asiodnp3, asiopal, opendnp3, openpal 7 | 8 | import variations 9 | 10 | from logger import Logger 11 | from master import Master, MasterConfig 12 | from point import Point 13 | 14 | LOG_LEVELS = opendnp3.levels.NORMAL | opendnp3.levels.ALL_COMMS 15 | Masters = typing.Dict[int, Master] 16 | 17 | class Client: 18 | def __init__(self: Client, logger: Logger): 19 | self.logger = logger 20 | 21 | def init_client(self: Client, id: str, endpoint: str) -> bool: 22 | try: 23 | host, port = endpoint.split(':') 24 | except ValueError: 25 | host = endpoint 26 | port = 20000 27 | 28 | self.manager = asiodnp3.DNP3Manager(1, asiodnp3.ConsoleLogger().Create()) 29 | 30 | self.channel = self.manager.AddTCPClient( 31 | id, 32 | LOG_LEVELS, 33 | asiopal.ChannelRetry().Default(), 34 | host, 35 | '0.0.0.0', 36 | int(port), 37 | None, 38 | ) 39 | 40 | self.masters: Masters = {} 41 | return True 42 | 43 | def add_master(self: Client, id: str, local: int, remote: int, pusher: zmq.Socket) -> Master: 44 | config = MasterConfig(id, local, remote) 45 | master = Master(config, self.logger, pusher) 46 | self.masters[local] = master 47 | 48 | return master 49 | 50 | def start(self: Client, sub: zmq.Socket) -> None: 51 | for master in self.masters.values(): 52 | config = master.init_stack_config() 53 | iMaster = self.channel.AddMaster(master.config.id, master, asiodnp3.DefaultMasterApplication().Create(), config) 54 | 55 | master.iMaster = iMaster 56 | 57 | master.add_class_scan(opendnp3.ClassField().AllClasses(), openpal.TimeDuration().Seconds(10)) 58 | master.enable() 59 | 60 | self.logger.log(f'started master {master.config.id}') 61 | 62 | self.logger.log('started client') -------------------------------------------------------------------------------- /src/old/python/dnp3/envelope.py: -------------------------------------------------------------------------------- 1 | import enum, json, typing 2 | 3 | class EnvelopeKind(enum.Enum): 4 | STATUS = 'Status' 5 | UPDATE = 'Update' 6 | CONFIRMATION = 'Confirmation' 7 | 8 | class Envelope(typing.TypedDict): 9 | apiVersion: str 10 | kind: EnvelopeKind 11 | metadata: typing.Dict[str, str] 12 | contents: str 13 | 14 | class Point(typing.TypedDict): 15 | tag: str 16 | value: float 17 | ts: int 18 | 19 | class Status(typing.TypedDict): 20 | measurements: typing.List[Point] 21 | 22 | class Update(typing.TypedDict): 23 | updates: typing.List[Point] 24 | recipient: str 25 | confirm: str 26 | 27 | class Confirmation(typing.TypedDict): 28 | confirm: str 29 | errors: typing.Dict[str, str] 30 | 31 | def new_update_envelope(sender: str, update: Update) -> Envelope: 32 | env: Envelope = { 33 | 'apiVersion': 'v1', 34 | 'kind': EnvelopeKind.UPDATE.value, 35 | 'metadata': {'sender': sender}, 36 | 'contents': update, 37 | } 38 | 39 | return env 40 | 41 | def status_from_envelope(env: Envelope) -> Status: 42 | if env['kind'] != EnvelopeKind.STATUS.value: 43 | return None 44 | 45 | return env['contents'] -------------------------------------------------------------------------------- /src/old/python/dnp3/logger.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import zmq 4 | 5 | class Logger: 6 | def __init__(self: Logger, name: str, pusher: zmq.Socket): 7 | self.name = name 8 | self.pusher = pusher 9 | 10 | def log(self: Logger, msg: str) -> None: 11 | self.pusher.send_multipart((b'LOG', f'[{self.name}] {msg}'.encode())) -------------------------------------------------------------------------------- /src/old/python/dnp3/master.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import json, time, typing, zmq 4 | 5 | from collections import namedtuple 6 | from pydnp3 import asiodnp3, opendnp3, openpal 7 | 8 | import envelope 9 | from logger import Logger 10 | from point import Point 11 | 12 | class MasterConfig(typing.NamedTuple): 13 | id: str 14 | local_addr: int 15 | remote_addr: int 16 | 17 | class TaskCallback(opendnp3.ITaskCallback): 18 | def __init__(self: TaskCallback, id: str, logger: Logger): 19 | opendnp3.ITaskCallback.__init__(self) 20 | 21 | self.id = id 22 | self.logger = logger 23 | 24 | self.logger.log(f'creating new task callback {self.id}') 25 | 26 | def OnStart(self: TaskCallback) -> None: 27 | self.logger.log(f'Task callback {self.id} started.') 28 | 29 | def OnComplete(self: TaskCallback, c: opendnp3.TaskCompletion) -> None: 30 | self.logger.log(f'Task callback {self.id} completed: {c}') 31 | 32 | def OnDestroyed(self: TaskCallback) -> None: 33 | self.logger.log(f'Task callback {self.id} destroyed.') 34 | 35 | class Master(opendnp3.ISOEHandler): 36 | def __init__(self: Master, config: MasterConfig, logger: Logger, pusher: zmq.Socket): 37 | opendnp3.ISOEHandler.__init__(self) 38 | 39 | self.logger = logger 40 | self.config = config 41 | self.pusher = pusher 42 | 43 | # will be set by the client 44 | self.iMaster: opendnp3.IMaster = None 45 | 46 | self.binary_inputs: typing.List[Point] = [] 47 | self.binary_outputs: typing.List[Point] = [] 48 | self.analog_inputs: typing.List[Point] = [] 49 | self.analog_outputs: typing.List[Point] = [] 50 | 51 | def init_stack_config(self: Master) -> asiodnp3.MasterStackConfig: 52 | stack_config = asiodnp3.MasterStackConfig() 53 | stack_config.master.responseTimeout = openpal.TimeDuration().Seconds(2) 54 | stack_config.link.LocalAddr = self.config.local_addr 55 | stack_config.link.RemoteAddr = self.config.remote_addr 56 | 57 | return stack_config 58 | 59 | def run(self: Master) -> None: 60 | # TODO: periodically query outstations 61 | # this will likely just be handled by class scans 62 | 63 | while True: 64 | print('hello, world!') 65 | time.sleep(5) 66 | 67 | def add_binary(self: Master, point: Point) -> bool: 68 | if point.output: 69 | self.binary_outputs.append(point) 70 | else: 71 | self.binary_inputs.append(point) 72 | 73 | return True 74 | 75 | def add_analog(self: Master, point: Point) -> bool: 76 | if point.output: 77 | self.analog_outputs.append(point) 78 | else: 79 | self.analog_inputs.append(point) 80 | 81 | return True 82 | 83 | def enable(self: Master) -> bool: 84 | return self.iMaster.Enable() 85 | 86 | def disable(self: Master) -> bool: 87 | return self.iMaster.Disable() 88 | 89 | # TODO: implement 90 | def add_class_scan(self: Master, field: opendnp3.ClassField, period: openpal.TimeDuration) -> None: 91 | self.logger.log(f'adding class scan to {self.config.id}') 92 | self.iMaster.AddClassScan(field, period, opendnp3.TaskConfig().With(TaskCallback('class-scan', self.logger))) 93 | 94 | # TODO: implement 95 | def restart(self: Master, typ: opendnp3.RestartType) -> int: 96 | return 0 97 | 98 | # Overridden ISOEHandler method 99 | def Start(self: Master) -> None: 100 | pass 101 | 102 | # Overridden ISOEHandler method 103 | def End(self: Master) -> None: 104 | pass 105 | 106 | # Overridden ISOEHandler method 107 | def Process(self: Master, info: opendnp3.HeaderInfo, values: typing.Any) -> None: 108 | # TODO: publish point status messages 109 | 110 | if isinstance(values, opendnp3.ICollectionIndexedBinary): 111 | pass 112 | 113 | if isinstance(values, opendnp3.ICollectionIndexedAnalog): 114 | pass 115 | 116 | if isinstance(values, opendnp3.ICollectionIndexedBinaryOutputStatus): 117 | pass 118 | 119 | if isinstance(values, opendnp3.ICollectionIndexedAnalogOutputStatus): 120 | pass -------------------------------------------------------------------------------- /src/old/python/dnp3/point.py: -------------------------------------------------------------------------------- 1 | import typing 2 | 3 | from pydnp3 import opendnp3 4 | 5 | class Point(typing.NamedTuple): 6 | address: int 7 | tag: str 8 | svariation: typing.Any 9 | evariation: typing.Any 10 | output: bool 11 | sbo: bool 12 | clazz: opendnp3.PointClass 13 | deadband: float 14 | 15 | Points = typing.Dict[int, Point] 16 | -------------------------------------------------------------------------------- /src/old/python/dnp3/server.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import json, signal, sys, threading, time, typing, zmq 4 | import xml.etree.ElementTree as ET 5 | 6 | from pydnp3 import asiodnp3, asiopal, opendnp3 7 | 8 | import envelope, variations 9 | 10 | from logger import Logger 11 | from outstation import Outstation, OutstationConfig 12 | from point import Point 13 | 14 | LOG_LEVELS = opendnp3.levels.NORMAL | opendnp3.levels.ALL_COMMS 15 | Outstations = typing.Dict[int, Outstation] 16 | 17 | class Server: 18 | def __init__(self: Server, logger: Logger, cold: int): 19 | self.logger = logger 20 | self.cold_restart_secs = cold 21 | 22 | def init_server(self: Server, id: str, endpoint: str) -> bool: 23 | try: 24 | host, port = endpoint.split(':') 25 | except ValueError: 26 | host = endpoint 27 | port = 20000 28 | 29 | self.manager = asiodnp3.DNP3Manager(1, asiodnp3.ConsoleLogger().Create()) 30 | 31 | self.channel = self.manager.AddTCPServer( 32 | id, 33 | LOG_LEVELS, 34 | asiopal.ChannelRetry().Default(), 35 | host, 36 | int(port), 37 | None, 38 | ) 39 | 40 | self.outstations: Outstations = {} 41 | return True 42 | 43 | def add_outstation(self: Server, id: str, local: int, remote: int, warm: int, pusher: zmq.Socket) -> Outstation: 44 | config = OutstationConfig(id, local, remote, self.cold_restart_secs, warm, self.handle_cold_restart) 45 | outstation = Outstation(config, pusher) 46 | self.outstations[local] = outstation 47 | 48 | return outstation 49 | 50 | def start(self: Server, sub: zmq.Socket) -> None: 51 | tags: typing.Dict[str, float] = {} 52 | 53 | t = threading.Thread(target=self.handle_publications, args=(sub, tags,), daemon=True) 54 | t.start() 55 | 56 | for outstation in self.outstations.values(): 57 | config = outstation.init_stack_config() 58 | iOutstation = self.channel.AddOutstation(outstation.config.id, outstation, outstation, config) 59 | 60 | outstation.iOutstation = iOutstation 61 | outstation.enable() 62 | 63 | t = threading.Thread(target=outstation.run, args=(tags,), daemon=True) 64 | t.start() 65 | 66 | self.logger.log(f'started outstation {outstation.config.id}') 67 | 68 | self.logger.log('started server') 69 | 70 | def handle_cold_restart(self: Server, _: int) -> None: 71 | for outstation in self.outstations.values(): 72 | outstation.reset_outputs() 73 | outstation.disable() 74 | 75 | time.sleep(self.cold_restart_secs) 76 | 77 | for outstation in self.outstations.values(): 78 | outstation.enable() 79 | 80 | def handle_publications(self: Server, sub: zmq.Socket, tags: typing.Dict[str, float]) -> None: 81 | while True: 82 | data = sub.recv_multipart() 83 | 84 | # this should never happen... 85 | if data[0].decode() != 'RUNTIME': 86 | continue 87 | 88 | env = json.loads(data[1]) 89 | md = env.get('metadata', {}) 90 | 91 | if md.get('sender', '') == 'dnp3': 92 | continue 93 | 94 | status = envelope.status_from_envelope(env) 95 | 96 | if not status: 97 | continue 98 | 99 | for point in status['measurements']: 100 | self.logger.log(f"[DNP3] setting tag {point['tag']} to value {point['value']}") 101 | tags[point['tag']] = point['value'] -------------------------------------------------------------------------------- /src/old/python/dnp3/variations.py: -------------------------------------------------------------------------------- 1 | from pydnp3 import opendnp3 2 | 3 | static_binary_variations = { 4 | 'Group1Var1': opendnp3.StaticBinaryVariation.Group1Var1, 5 | 'Group1Var2': opendnp3.StaticBinaryVariation.Group1Var2, 6 | 'Group10Var2': opendnp3.StaticBinaryOutputStatusVariation.Group10Var2, 7 | } 8 | 9 | event_binary_variations = { 10 | 'Group2Var1': opendnp3.EventBinaryVariation.Group2Var1, 11 | 'Group2Var2': opendnp3.EventBinaryVariation.Group2Var2, 12 | 'Group2Var3': opendnp3.EventBinaryVariation.Group2Var3, 13 | 'Group11Var1': opendnp3.EventBinaryOutputStatusVariation.Group11Var1, 14 | 'Group11Var2': opendnp3.EventBinaryOutputStatusVariation.Group11Var2, 15 | } 16 | 17 | static_analog_variations = { 18 | 'Group30Var1': opendnp3.StaticAnalogVariation.Group30Var1, 19 | 'Group30Var2': opendnp3.StaticAnalogVariation.Group30Var2, 20 | 'Group30Var3': opendnp3.StaticAnalogVariation.Group30Var3, 21 | 'Group30Var4': opendnp3.StaticAnalogVariation.Group30Var4, 22 | 'Group30Var5': opendnp3.StaticAnalogVariation.Group30Var5, 23 | 'Group30Var6': opendnp3.StaticAnalogVariation.Group30Var6, 24 | 'Group40Var1': opendnp3.StaticAnalogOutputStatusVariation.Group40Var1, 25 | 'Group40Var2': opendnp3.StaticAnalogOutputStatusVariation.Group40Var2, 26 | 'Group40Var3': opendnp3.StaticAnalogOutputStatusVariation.Group40Var3, 27 | 'Group40Var4': opendnp3.StaticAnalogOutputStatusVariation.Group40Var4, 28 | } 29 | 30 | event_analog_variations = { 31 | 'Group32Var1': opendnp3.EventAnalogVariation.Group32Var1, 32 | 'Group32Var2': opendnp3.EventAnalogVariation.Group32Var2, 33 | 'Group32Var3': opendnp3.EventAnalogVariation.Group32Var3, 34 | 'Group32Var4': opendnp3.EventAnalogVariation.Group32Var4, 35 | 'Group32Var5': opendnp3.EventAnalogVariation.Group32Var5, 36 | 'Group32Var6': opendnp3.EventAnalogVariation.Group32Var6, 37 | 'Group32Var7': opendnp3.EventAnalogVariation.Group32Var7, 38 | 'Group32Var8': opendnp3.EventAnalogVariation.Group32Var8, 39 | 'Group42Var1': opendnp3.EventAnalogOutputStatusVariation.Group42Var1, 40 | 'Group42Var2': opendnp3.EventAnalogOutputStatusVariation.Group42Var2, 41 | 'Group42Var3': opendnp3.EventAnalogOutputStatusVariation.Group42Var3, 42 | 'Group42Var4': opendnp3.EventAnalogOutputStatusVariation.Group42Var4, 43 | 'Group42Var5': opendnp3.EventAnalogOutputStatusVariation.Group42Var5, 44 | 'Group42Var6': opendnp3.EventAnalogOutputStatusVariation.Group42Var6, 45 | 'Group42Var7': opendnp3.EventAnalogOutputStatusVariation.Group42Var7, 46 | 'Group42Var8': opendnp3.EventAnalogOutputStatusVariation.Group42Var8, 47 | } 48 | 49 | point_classes = { 50 | 'Class0': opendnp3.PointClass.Class0, 51 | 'Class1': opendnp3.PointClass.Class1, 52 | 'Class2': opendnp3.PointClass.Class2, 53 | 'Class3': opendnp3.PointClass.Class3, 54 | } -------------------------------------------------------------------------------- /src/python/.gitignore: -------------------------------------------------------------------------------- 1 | mypy.ini 2 | -------------------------------------------------------------------------------- /src/python/otsim/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/patsec/ot-sim/5e7c2e7e75d54242015b03035d26307858bb9a02/src/python/otsim/__init__.py -------------------------------------------------------------------------------- /src/python/otsim/ground_truth/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/patsec/ot-sim/5e7c2e7e75d54242015b03035d26307858bb9a02/src/python/otsim/ground_truth/__init__.py -------------------------------------------------------------------------------- /src/python/otsim/ground_truth/ground_truth.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import json, logging, requests, signal, socket, sys, threading, time, typing 4 | 5 | import otsim.msgbus.envelope as envelope 6 | import xml.etree.ElementTree as ET 7 | 8 | from otsim.msgbus.envelope import Envelope 9 | from otsim.msgbus.subscriber import Subscriber 10 | 11 | 12 | class GroundTruth: 13 | def __init__(self: GroundTruth, pub: str, el: ET.Element): 14 | self.name = el.get('name', default='ot-sim-ground-truth') 15 | self.hostname = socket.gethostname() 16 | 17 | self.elastic = 'http://localhost:9200' 18 | self.index_base = 'ot-sim' 19 | self.labels = {} 20 | 21 | self.opensearch = False 22 | 23 | elastic = el.find('elastic') 24 | 25 | if elastic: 26 | self.elastic = elastic.findtext('endpoint', default=self.elastic) 27 | self.index_base = elastic.findtext('index-base-name', default=self.index_base) 28 | 29 | self.opensearch = elastic.get('opensearch', default='false').lower() in ['true', 'yes', 'enabled', '1'] 30 | 31 | for field in elastic.findall('label'): 32 | name = field.get('name') 33 | self.labels[name] = field.text 34 | 35 | pub_endpoint = el.findtext('pub-endpoint', default=pub) 36 | self.subscriber = Subscriber(pub_endpoint) 37 | 38 | self.subscriber.add_status_handler(self.handle_msgbus_status) 39 | 40 | self.__ensure_index_template() 41 | 42 | 43 | def start(self: GroundTruth): 44 | self.subscriber.start('RUNTIME') 45 | 46 | 47 | def stop(self: GroundTruth): 48 | self.subscriber.stop() 49 | 50 | 51 | def handle_msgbus_status(self: GroundTruth, env: Envelope): 52 | status = envelope.status_from_envelope(env) 53 | 54 | if status: 55 | headers = {'Content-Type': 'application/json'} 56 | index = time.strftime(f'{self.index_base}-%Y.%m.%d', time.localtime()) 57 | 58 | for point in status.get('measurements', []): 59 | ts = point['ts'] 60 | 61 | if not ts: 62 | # milliseconds since epoch 63 | ts = time.time_ns() // 1000000 64 | 65 | doc = { 66 | '@timestamp': ts, 67 | 'source': self.hostname, 68 | 'field': point['tag'], 69 | 'value': point['value'], 70 | } 71 | 72 | if self.labels: 73 | doc['labels'] = self.labels 74 | 75 | resp = requests.post(f'{self.elastic}/{index}/_doc', data=json.dumps(doc), headers=headers) 76 | 77 | if not resp.ok: 78 | print(f'ERROR: sending data to Elastic - (HTTP Status {resp.status_code}) -- {resp.text}') 79 | 80 | 81 | def __ensure_index_template(self: GroundTruth): 82 | headers = {'Content-Type': 'application/json'} 83 | template = { 84 | 'index_patterns': [f'{self.index_base}-*'], 85 | 'template': { 86 | 'mappings': { 87 | 'properties': { 88 | '@timestamp': {'type': 'date'}, 89 | 'source': {'type': 'keyword'}, # don't analyze 90 | 'field': {'type': 'text'}, # analize 91 | 'value': {'type': 'double'}, 92 | 'labels': {'type': 'flat_object' if self.opensearch else 'flattened'}, 93 | } 94 | } 95 | } 96 | } 97 | 98 | resp = requests.put(f'{self.elastic}/_index_template/{self.index_base}', data=json.dumps(template), headers=headers) 99 | 100 | if not resp.ok: 101 | print(f'ERROR: creating index template in Elastic - (HTTP Status {resp.status_code}) -- {resp.text}') 102 | 103 | 104 | def main(): 105 | logging.basicConfig(level=logging.ERROR) 106 | 107 | if len(sys.argv) < 2: 108 | print('no config file provided') 109 | sys.exit(1) 110 | 111 | tree = ET.parse(sys.argv[1]) 112 | 113 | root = tree.getroot() 114 | assert root.tag == 'ot-sim' 115 | 116 | mb = root.find('message-bus') 117 | 118 | if mb: 119 | pub = mb.findtext('pub-endpoint') 120 | else: 121 | pub = 'tcp://127.0.0.1:5678' 122 | 123 | modules: typing.List[GroundTruth] = [] 124 | 125 | for gt in root.findall('ground-truth'): 126 | module = GroundTruth(pub, gt) 127 | module.start() 128 | 129 | modules.append(module) 130 | 131 | waiter = threading.Event() 132 | 133 | def handler(*_): 134 | waiter.set() 135 | 136 | signal.signal(signal.SIGINT, handler) 137 | waiter.wait() 138 | 139 | for module in modules: 140 | module.stop() 141 | -------------------------------------------------------------------------------- /src/python/otsim/helics_helper/README.md: -------------------------------------------------------------------------------- 1 | # helics-helper 2 | 3 | HELICS utility helper functions and classes. An improved version of this code 4 | will soon be included as part of the official `pyhelics` Python package. 5 | 6 | ### Author 7 | 8 | Dheepak Krishnamurthy (NREL) 9 | 10 | ### Usage 11 | 12 | Write your federate in a Python class, and describe your actions in functions. 13 | 14 | ```python 15 | from helics_helper import HelicsFederate, GlobalPublication, Publication, DataType 16 | 17 | class SenderFederate(HelicsFederate): 18 | # Set federate name here as "Sender" 19 | federate_name = "Sender" 20 | 21 | # Set start time here as 5 seconds 22 | start_time = 5 23 | 24 | # Set end time here as 8 seconds 25 | end_time = 8 26 | 27 | # Set list of publications to be registered with HELICS 28 | # Publications can be global or not 29 | # Publications must include a topic name and a type of the data being published 30 | # Publications can be strings, integers, doubles, complex numbers or a list of doubles 31 | publications = [ 32 | GlobalPublication("topic_name1", DataType.string), 33 | Publication("topic_name2", DataType.string), 34 | Publication("topic_name3", DataType.int), 35 | Publication("topic_name4", DataType.double), 36 | Publication("topic_name5", DataType.complex), 37 | Publication("topic_name6", DataType.vector), 38 | ] 39 | 40 | def action_publications(self, data, current_time): 41 | """ 42 | Action to be taken before publications are published 43 | """ 44 | 45 | # This action_publications function is called after every step time (defaults to 1 second) 46 | # The data dictionary should be populated by the user 47 | # Once populated, after this function returns, the data in the dictionary is published via helics. 48 | # If publications are defined above, this function must be implemented and the data dictionary must be populated 49 | 50 | time.sleep(1) 51 | 52 | # Sets `topic_name1` to "hello world at time {current_time}" 53 | data["topic_name1"] = "hello world at time {}".format(current_time) 54 | 55 | # Sets `Sender/topic_name2` to "goodbye world at time {current_time}" 56 | data["topic_name2"] = "goodbye world at time {}".format(current_time) 57 | 58 | # Data set to the various topics should match the publication DataType described above 59 | data["topic_name3"] = 7 60 | data["topic_name4"] = math.pi 61 | data["topic_name5"] = complex(1, 1) 62 | data["topic_name6"] = [1, 1.0, 2, 3.0, 5, 8.0, 13, 21.0] # In Python, list of doubles can be list of integers and floats 63 | 64 | ``` -------------------------------------------------------------------------------- /src/python/otsim/helics_helper/version.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | __version__ = "0.1.0" 3 | -------------------------------------------------------------------------------- /src/python/otsim/io/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/patsec/ot-sim/5e7c2e7e75d54242015b03035d26307858bb9a02/src/python/otsim/io/__init__.py -------------------------------------------------------------------------------- /src/python/otsim/msgbus/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/patsec/ot-sim/5e7c2e7e75d54242015b03035d26307858bb9a02/src/python/otsim/msgbus/__init__.py -------------------------------------------------------------------------------- /src/python/otsim/msgbus/envelope.py: -------------------------------------------------------------------------------- 1 | import enum, typing 2 | 3 | class EnvelopeKind(enum.Enum): 4 | STATUS = 'Status' 5 | UPDATE = 'Update' 6 | CONFIRMATION = 'Confirmation' 7 | METRIC = 'Metric' 8 | 9 | class MetricKind(enum.Enum): 10 | COUNTER = 'Counter' 11 | GAUGE = 'Gauge' 12 | 13 | class Point(typing.TypedDict): 14 | tag: str 15 | value: float 16 | ts: int 17 | 18 | class Status(typing.TypedDict): 19 | measurements: typing.List[Point] 20 | 21 | class Update(typing.TypedDict): 22 | updates: typing.List[Point] 23 | recipient: str 24 | confirm: str 25 | 26 | class Confirmation(typing.TypedDict): 27 | confirm: str 28 | errors: typing.Dict[str, str] 29 | 30 | class Metric(typing.TypedDict): 31 | kind: str 32 | name: str 33 | desc: str 34 | value: float 35 | 36 | class Metrics(typing.TypedDict): 37 | metrics: typing.List[Metric] 38 | 39 | class Envelope(typing.TypedDict): 40 | version: str 41 | kind: EnvelopeKind 42 | metadata: typing.Dict[str, str] 43 | contents: str 44 | 45 | def new_status_envelope(sender: str, status: Status) -> Envelope: 46 | env: Envelope = { 47 | 'version': 'v1', 48 | 'kind': EnvelopeKind.STATUS.value, 49 | 'metadata': {'sender': sender}, 50 | 'contents': status, 51 | } 52 | 53 | return env 54 | 55 | def new_update_envelope(sender: str, update: Update) -> Envelope: 56 | if 'recipient' not in update: 57 | update['recipient'] = '' 58 | 59 | if 'confirm' not in update: 60 | update['confirm'] = '' 61 | 62 | env: Envelope = { 63 | 'version': 'v1', 64 | 'kind': EnvelopeKind.UPDATE.value, 65 | 'metadata': {'sender': sender}, 66 | 'contents': update, 67 | } 68 | 69 | return env 70 | 71 | def new_confirmation_envelope(sender: str, conf: Confirmation) -> Envelope: 72 | env: Envelope = { 73 | 'version': 'v1', 74 | 'kind': EnvelopeKind.CONFIRMATION.value, 75 | 'metadata': {'sender': sender}, 76 | 'contents': conf, 77 | } 78 | 79 | return env 80 | 81 | def new_metric_envelope(sender: str, metrics: Metrics) -> Envelope: 82 | env: Envelope = { 83 | 'version': 'v1', 84 | 'kind': EnvelopeKind.METRIC.value, 85 | 'metadata': {'sender': sender}, 86 | 'contents': metrics, 87 | } 88 | 89 | return env 90 | 91 | def status_from_envelope(env: Envelope) -> Status: 92 | if env['kind'] != EnvelopeKind.STATUS.value: 93 | return None 94 | 95 | return env['contents'] 96 | 97 | def update_from_envelope(env: Envelope) -> Update: 98 | if env['kind'] != EnvelopeKind.UPDATE.value: 99 | return None 100 | 101 | return env['contents'] 102 | 103 | def confirmation_from_envelope(env: Envelope) -> Confirmation: 104 | if env['kind'] != EnvelopeKind.CONFIRMATION.value: 105 | return None 106 | 107 | return env['contents'] -------------------------------------------------------------------------------- /src/python/otsim/msgbus/metrics.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import json, threading, time, typing 4 | 5 | import otsim.msgbus.envelope as envelope 6 | 7 | from otsim.msgbus.envelope import Metric, MetricKind 8 | from otsim.msgbus.pusher import Pusher 9 | 10 | class MetricsPusher: 11 | def __init__(self: MetricsPusher): 12 | self.running = False 13 | self.metrics: typing.Dict[str, Metric] = {} 14 | 15 | def start(self: MetricsPusher, pusher: Pusher, name: str) -> None: 16 | self.running = True 17 | 18 | self.thread = threading.Thread(target=self.__run, args=(pusher, name,)) 19 | self.thread.start() 20 | 21 | def stop(self: MetricsPusher) -> None: 22 | self.running = False 23 | self.thread.join() 24 | 25 | def new_metric(self: MetricsPusher, kind: MetricKind, name: str, desc: str) -> None: 26 | self.metrics[name] = {'kind': kind.value, 'name': name, 'desc': desc, 'value': 0.0} 27 | 28 | def incr_metric(self: MetricsPusher, name: str) -> None: 29 | if name in self.metrics: 30 | metric = self.metrics[name] 31 | metric['value'] += 1.0 32 | self.metrics[name] = metric 33 | 34 | def incr_metric_by(self: MetricsPusher, name: str, val: int) -> None: 35 | if name in self.metrics: 36 | metric = self.metrics[name] 37 | metric['value'] += float(val) 38 | self.metrics[name] = metric 39 | 40 | def set_metric(self: MetricsPusher, name: str, val: float) -> None: 41 | if name in self.metrics: 42 | metric = self.metrics[name] 43 | metric['value'] = val 44 | self.metrics[name] = metric 45 | 46 | def __run(self: MetricsPusher, pusher: Pusher, name: str) -> None: 47 | prefix = name + "_" 48 | 49 | while self.running: 50 | updates: typing.List[Metric] = [] 51 | 52 | for metric in self.metrics.values(): 53 | copy = metric 54 | 55 | if not copy['name'].startswith(prefix): 56 | copy['name'] = prefix + copy['name'] 57 | 58 | updates.append(copy) 59 | 60 | if len(updates) > 0: 61 | env = envelope.new_metric_envelope(name, {'metrics': updates}) 62 | pusher.push('HEALTH', env) 63 | 64 | time.sleep(5) -------------------------------------------------------------------------------- /src/python/otsim/msgbus/pusher.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import json, zmq 4 | 5 | from otsim.msgbus.envelope import Envelope 6 | 7 | class Pusher: 8 | def __init__(self: Pusher, endpoint: str): 9 | self.ctx = zmq.Context() 10 | self.socket = self.ctx.socket(zmq.PUSH) 11 | 12 | self.socket.connect(endpoint) 13 | self.socket.setsockopt(zmq.LINGER, 0) 14 | 15 | def push(self: Pusher, topic: str, env: Envelope) -> None: 16 | self.socket.send_multipart((topic.encode(), json.dumps(env).encode())) -------------------------------------------------------------------------------- /src/python/otsim/msgbus/subscriber.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import json, threading, typing, zmq 4 | 5 | from otsim.msgbus.envelope import Envelope 6 | 7 | status_handler = typing.Callable[[Envelope], None] 8 | update_handler = typing.Callable[[Envelope], None] 9 | 10 | class Subscriber: 11 | def __init__(self: Subscriber, endpoint: str): 12 | self.status_handlers: typing.List[status_handler] = [] 13 | self.update_handlers: typing.List[update_handler] = [] 14 | 15 | self.running = False 16 | 17 | self.ctx = zmq.Context() 18 | self.socket = self.ctx.socket(zmq.SUB) 19 | 20 | self.socket.connect(endpoint) 21 | self.socket.setsockopt(zmq.LINGER, 0) 22 | 23 | def add_status_handler(self: Subscriber, handler: status_handler) -> None: 24 | self.status_handlers.append(handler) 25 | 26 | def add_update_handler(self: Subscriber, handler: update_handler) -> None: 27 | self.update_handlers.append(handler) 28 | 29 | def start(self: Subscriber, topic: str) -> None: 30 | self.running = True 31 | 32 | self.thread = threading.Thread(target=self.__run, args=(topic,)) 33 | self.thread.start() 34 | 35 | def stop(self: Subscriber) -> None: 36 | self.running = False 37 | 38 | self.socket.close() 39 | self.ctx.term() 40 | 41 | self.thread.join() 42 | 43 | def __run(self: Subscriber, topic: str) -> None: 44 | self.socket.setsockopt(zmq.SUBSCRIBE, topic.encode()) 45 | 46 | while self.running: 47 | data = self.socket.recv_multipart() 48 | 49 | # this should never happen... 50 | if data[0].decode() != topic: 51 | continue 52 | 53 | env = json.loads(data[1]) 54 | 55 | if env['kind'] == 'Status': 56 | for handler in self.status_handlers: 57 | handler(env) 58 | elif env['kind'] == 'Update': 59 | for handler in self.update_handlers: 60 | handler(env) -------------------------------------------------------------------------------- /src/python/otsim/rpi_gpio/README.md: -------------------------------------------------------------------------------- 1 | # Raspberry Pi GPIO Module 2 | 3 | This OT-sim module leverages the [RPi.GPIO](https://pypi.org/project/RPi.GPIO/) 4 | Python module to associate OT-sim tags with input and output pins. 5 | 6 | Please note the following about this module: 7 | 8 | 1. This module will only work when OT-sim is run on a Raspberry Pi. To avoid 9 | long compilation times, the suggested way to do so is to run OT-sim on a 10 | Raspberry Pi using the OT-sim Docker image, which is a multi-architecture build. 11 | 1. Each input and output is used to setup the corresponding GPIO channel, 12 | defined by the `pin` assignment. 13 | 1. This module only supports boolean inputs and outputs (`PWM` is not 14 | supported). 15 | 1. This module only subscribes to `update` messages from the OT-sim message bus, 16 | and only publishes `status` messages to the OT-sim message bus. GPIO inputs are 17 | mapped to input tags, and output tags are mapped to GPIO outputs. 18 | 1. On exit, `GPIO.cleanup()` is called, which will set all used channels back to 19 | inputs with no pull up/down. This may cause anything physically connected to the 20 | channels to be affected. 21 | 22 | ## Example Configuration 23 | 24 | ``` 25 | 26 | 1 27 | 28 | led-11 29 | 30 | 31 | relay-1-3 32 | 33 | 34 | switch-17 35 | 36 | 37 | ``` -------------------------------------------------------------------------------- /src/python/otsim/rpi_gpio/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/patsec/ot-sim/5e7c2e7e75d54242015b03035d26307858bb9a02/src/python/otsim/rpi_gpio/__init__.py -------------------------------------------------------------------------------- /src/python/otsim/rpi_gpio/rpi_gpio.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import logging, signal, sys, threading, time, typing 4 | 5 | import otsim.msgbus.envelope as envelope 6 | import xml.etree.ElementTree as ET 7 | 8 | from otsim.msgbus.envelope import Envelope, Point 9 | from otsim.msgbus.pusher import Pusher 10 | from otsim.msgbus.subscriber import Subscriber 11 | 12 | import RPi.GPIO as GPIO 13 | 14 | 15 | class RPiGPIO: 16 | def __init__(self: RPiGPIO, pub: str, pull: str, el: ET.Element): 17 | # map pin numbers --> tag 18 | self.inputs: typing.Dict[int, str] = {} 19 | # map tags --> pin number 20 | self.outputs: typing.Dict[str, int] = {} 21 | 22 | self.monitoring = True 23 | 24 | self.name = el.get('name', default='ot-sim-rpi-gpio') 25 | mode = el.get('mode', default='BOARD') 26 | 27 | GPIO.setwarnings(False) 28 | 29 | if mode.upper() == 'BOARD': 30 | GPIO.setmode(GPIO.BOARD) 31 | elif mode.upper() == 'BCM': 32 | GPIO.setmode(GPIO.BCM) 33 | else: 34 | print(f"unknown GPIO mode '{mode}' - defaulting to 'BOARD' mode") 35 | GPIO.setmode(GPIO.BOARD) 36 | 37 | pub_endpoint = el.findtext('pub-endpoint', default=pub) 38 | pull_endpoint = el.findtext('pull-endpoint', default=pull) 39 | 40 | self.subscriber = Subscriber(pub_endpoint) 41 | self.pusher = Pusher(pull_endpoint) 42 | 43 | self.period = float(el.findtext('period', default=5)) 44 | 45 | for o in el.findall('input'): 46 | pin = int(o.get('pin')) 47 | tag = o.findtext('tag') 48 | 49 | self.inputs[pin] = tag 50 | GPIO.setup(pin, GPIO.IN) 51 | 52 | for o in el.findall('output'): 53 | pin = int(o.get('pin')) 54 | tag = o.findtext('tag') 55 | 56 | self.outputs[tag] = pin 57 | GPIO.setup(pin, GPIO.OUT) 58 | 59 | self.subscriber.add_update_handler(self.handle_msgbus_update) 60 | 61 | 62 | def start(self: RPiGPIO): 63 | self.subscriber.start('RUNTIME') 64 | 65 | # run GPIO monitor in a thread 66 | self.monitor_thread = threading.Thread(target=self.monitor, daemon=True) 67 | self.monitor_thread.start() 68 | 69 | 70 | def stop(self: RPiGPIO): 71 | self.monitoring = False 72 | self.monitor_thread.join(self.period) 73 | 74 | GPIO.cleanup() 75 | self.subscriber.stop() 76 | 77 | 78 | def monitor(self: RPiGPIO): 79 | if len(self.inputs) == 0: 80 | return 81 | 82 | while self.monitoring: 83 | points: typing.List[Point] = [] 84 | 85 | for pin, tag in self.inputs.items(): 86 | val = GPIO.input(pin) 87 | points.append({'tag': tag, 'value': float(val), 'ts': 0}) 88 | 89 | env = envelope.new_status_envelope(self.name, {'measurements': points}) 90 | self.pusher.push('RUNTIME', env) 91 | 92 | time.sleep(self.period) 93 | 94 | 95 | def handle_msgbus_update(self: RPiGPIO, env: Envelope): 96 | update = envelope.update_from_envelope(env) 97 | 98 | if update: 99 | for point in update['updates']: 100 | tag = point['tag'] 101 | 102 | if tag in self.outputs: 103 | GPIO.output(self.outputs[tag], point['value']) 104 | 105 | 106 | def main(): 107 | logging.basicConfig(level=logging.ERROR) 108 | 109 | if len(sys.argv) < 2: 110 | print('no config file provided') 111 | sys.exit(1) 112 | 113 | tree = ET.parse(sys.argv[1]) 114 | 115 | root = tree.getroot() 116 | assert root.tag == 'ot-sim' 117 | 118 | mb = root.find('message-bus') 119 | 120 | if mb: 121 | pub = mb.findtext('pub-endpoint') 122 | pull = mb.findtext('pull-endpoint') 123 | else: 124 | pub = 'tcp://127.0.0.1:5678' 125 | pull = 'tcp://127.0.0.1:1234' 126 | 127 | devices: typing.List[RPiGPIO] = [] 128 | 129 | for gpio in root.findall('rpi-gpio'): 130 | device = RPiGPIO(pub, pull, gpio) 131 | device.start() 132 | 133 | devices.append(device) 134 | 135 | waiter = threading.Event() 136 | 137 | def handler(*_): 138 | waiter.set() 139 | 140 | signal.signal(signal.SIGINT, handler) 141 | waiter.wait() 142 | 143 | for device in devices: 144 | device.stop() 145 | -------------------------------------------------------------------------------- /src/python/otsim/wind_turbine/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/patsec/ot-sim/5e7c2e7e75d54242015b03035d26307858bb9a02/src/python/otsim/wind_turbine/__init__.py -------------------------------------------------------------------------------- /src/python/otsim/wind_turbine/anemometer/__init__.py: -------------------------------------------------------------------------------- 1 | ''' 2 | 3 | 4 | 5 | /tmp/weather.csv 6 | 7 | speed.high 8 | dir.high 9 | temp.high 10 | speed.med 11 | dir.med 12 | speed.low 13 | dir.low 14 | temp.low 15 | pressure 16 | 17 | 18 | 19 | 20 | ''' -------------------------------------------------------------------------------- /src/python/otsim/wind_turbine/anemometer/anemometer.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import csv, logging, signal, sys, threading, time, typing 4 | 5 | import otsim.msgbus.envelope as envelope 6 | import xml.etree.ElementTree as ET 7 | 8 | from otsim.msgbus.envelope import Point 9 | from otsim.msgbus.pusher import Pusher 10 | 11 | 12 | class Anemometer: 13 | def __init__(self: Anemometer, pull: str, el: ET.Element): 14 | self.name = el.get('name', default='ot-sim-wind-turbine-anemometer') 15 | 16 | self.data_path = el.findtext('data-path') 17 | 18 | weather_data = el.find('weather-data') 19 | assert(weather_data) 20 | 21 | self.weather_data_columns = [] 22 | self.weather_data_tags = [] 23 | 24 | for c in weather_data.findall('column'): 25 | self.weather_data_columns.append(c.get('name')) 26 | self.weather_data_tags.append(c.text) 27 | 28 | pull_endpoint = el.findtext('pull-endpoint', default=pull) 29 | self.pusher = Pusher(pull_endpoint) 30 | 31 | 32 | def start(self: Anemometer): 33 | threading.Thread(target=self.run, daemon=True).start() 34 | 35 | 36 | def stop(self: Anemometer): 37 | pass 38 | 39 | 40 | def run(self: Anemometer): 41 | ts = 0 42 | rows = None 43 | 44 | with open(self.data_path, newline='') as f: 45 | reader = csv.DictReader(f) 46 | rows = list(reader) 47 | 48 | while True: 49 | points: typing.List[Point] = [] 50 | 51 | for i, c in enumerate(self.weather_data_columns): 52 | tag = self.weather_data_tags[i] 53 | value = rows[ts][c] 54 | 55 | points.append({'tag': tag, 'value': float(value), 'ts': 0}) 56 | 57 | if len(points) > 0: 58 | env = envelope.new_status_envelope(self.name, {'measurements': points}) 59 | self.pusher.push('RUNTIME', env) 60 | 61 | ts += 1 62 | time.sleep(1) 63 | 64 | 65 | def main(): 66 | logging.basicConfig(level=logging.ERROR) 67 | 68 | if len(sys.argv) < 2: 69 | print('no config file provided') 70 | sys.exit(1) 71 | 72 | tree = ET.parse(sys.argv[1]) 73 | 74 | root = tree.getroot() 75 | assert root.tag == 'ot-sim' 76 | 77 | mb = root.find('message-bus') 78 | 79 | if mb: 80 | pull = mb.findtext('pull-endpoint') 81 | else: 82 | pull = 'tcp://127.0.0.1:1234' 83 | 84 | modules: typing.List[Anemometer] = [] 85 | 86 | for wp in root.findall('./wind-turbine/anemometer'): 87 | module = Anemometer(pull, wp) 88 | module.start() 89 | 90 | modules.append(module) 91 | 92 | waiter = threading.Event() 93 | 94 | def handler(*_): 95 | waiter.set() 96 | 97 | signal.signal(signal.SIGINT, handler) 98 | waiter.wait() 99 | 100 | for module in modules: 101 | module.stop() 102 | -------------------------------------------------------------------------------- /src/python/otsim/wind_turbine/power_output/__init__.py: -------------------------------------------------------------------------------- 1 | ''' 2 | 3 | 4 | 5 | E-126/4200 6 | 135 7 | 0.15 8 | 9 | speed.high 10 | temp.high 11 | speed.med 12 | speed.low 13 | temp.low 14 | pressure 15 | 16 | 17 | turbine.cut-in 18 | turbine.cut-out 19 | turbine.mw-output 20 | turbine.emergency-stop 21 | 22 | 23 | 24 | 25 | ''' -------------------------------------------------------------------------------- /src/python/setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | import platform 4 | 5 | from setuptools import setup, find_packages 6 | 7 | REQUIRES = [ 8 | 'helics~=3.6.1', 9 | 'numpy', 10 | 'pandas', 11 | 'pyzmq', 12 | 'requests', 13 | 'windpowerlib', 14 | ] 15 | 16 | SCRIPTS = [ 17 | 'ot-sim-ground-truth-module = otsim.ground_truth.ground_truth:main', 18 | 'ot-sim-io-module = otsim.io.io:main', 19 | 'ot-sim-wind-turbine-anemometer-module = otsim.wind_turbine.anemometer.anemometer:main', 20 | 'ot-sim-wind-turbine-power-output-module = otsim.wind_turbine.power_output.power_output:main', 21 | ] 22 | 23 | if platform.machine() == 'arm64': 24 | REQUIRES.append('RPi.GPIO') 25 | SCRIPTS.append('ot-sim-rpi-gpio-module = otsim.rpi_gpio.rpi_gpio:main') 26 | 27 | ENTRIES = { 28 | 'console_scripts' : SCRIPTS, 29 | } 30 | 31 | setup( 32 | name = 'otsim', 33 | version = '0.0.1', 34 | description = 'OT-sim Python modules', 35 | license = 'GPLv3 License', 36 | platforms = 'Linux', 37 | classifiers = [ 38 | 'License :: OSI Approved :: GPLv3 License', 39 | 'Development Status :: 4 - Beta', 40 | 'Operating System :: POSIX :: Linux', 41 | 'Programming Language :: Python :: 3.5', 42 | 'Intended Audience :: Developers', 43 | 'Natural Language :: English', 44 | ], 45 | entry_points = ENTRIES, 46 | packages = find_packages(), 47 | install_requires = REQUIRES, 48 | include_package_data = True, 49 | 50 | package_data = { 51 | # Include mako template files found in all packages. 52 | '': ["**/*.mako"] 53 | } 54 | ) 55 | -------------------------------------------------------------------------------- /testing/dnp3/master.py: -------------------------------------------------------------------------------- 1 | import logging, sys, time 2 | 3 | from pydnp3 import opendnp3, openpal, asiopal, asiodnp3 4 | from visitors import * 5 | 6 | FILTERS = opendnp3.levels.NOTHING 7 | HOST = "127.0.0.1" 8 | LOCAL = "0.0.0.0" 9 | PORT = 20000 10 | 11 | stdout_stream = logging.StreamHandler(sys.stdout) 12 | stdout_stream.setFormatter(logging.Formatter('%(asctime)s\t%(name)s\t%(levelname)s\t%(message)s')) 13 | 14 | _log = logging.getLogger(__name__) 15 | _log.addHandler(stdout_stream) 16 | _log.setLevel(logging.DEBUG) 17 | 18 | 19 | class TestDNP3Master: 20 | def __init__(self): 21 | self.manager = asiodnp3.DNP3Manager(1, asiodnp3.ConsoleLogger().Create()) 22 | 23 | self.retry = asiopal.ChannelRetry().Default() 24 | self.channel = self.manager.AddTCPClient( 25 | "tcpclient", 26 | FILTERS, 27 | self.retry, 28 | HOST, 29 | LOCAL, 30 | PORT, 31 | asiodnp3.PrintingChannelListener().Create(), 32 | ) 33 | 34 | self.stack_config = asiodnp3.MasterStackConfig() 35 | self.stack_config.master.responseTimeout = openpal.TimeDuration().Seconds(2) 36 | self.stack_config.link.RemoteAddr = 1024 37 | 38 | self.master = self.channel.AddMaster( 39 | "master", 40 | asiodnp3.PrintingSOEHandler().Create(), 41 | asiodnp3.DefaultMasterApplication().Create(), 42 | self.stack_config, 43 | ) 44 | 45 | self.scan = self.master.AddClassScan( 46 | opendnp3.ClassField().AllClasses(), 47 | openpal.TimeDuration().Minutes(10), 48 | opendnp3.TaskConfig().Default(), 49 | ) 50 | 51 | self.master.Enable() 52 | time.sleep(5) 53 | 54 | def send_direct_operate_command(self, command, index, callback=asiodnp3.PrintingCommandCallback.Get()): 55 | self.master.DirectOperate(command, index, callback, opendnp3.TaskConfig().Default()) 56 | 57 | def send_select_and_operate_command(self, command, index, callback=asiodnp3.PrintingCommandCallback.Get()): 58 | self.master.SelectAndOperate(command, index, callback, opendnp3.TaskConfig().Default()) 59 | 60 | def shutdown(self): 61 | del self.scan 62 | del self.master 63 | del self.channel 64 | self.manager.Shutdown() 65 | 66 | 67 | def collection_callback(result=None): 68 | print("Header: {0} | Index: {1} | State: {2} | Status: {3}".format( 69 | result.headerIndex, 70 | result.index, 71 | opendnp3.CommandPointStateToString(result.state), 72 | opendnp3.CommandStatusToString(result.status) 73 | )) 74 | 75 | 76 | def command_callback(result=None): 77 | print("Received command result with summary: {}".format(opendnp3.TaskCompletionToString(result.summary))) 78 | result.ForeachItem(collection_callback) 79 | 80 | 81 | def restart_callback(result=opendnp3.RestartOperationResult()): 82 | if result.summary == opendnp3.TaskCompletion.SUCCESS: 83 | print("Restart success | Restart Time: {}".format(result.restartTime.GetMilliseconds())) 84 | else: 85 | print("Restart fail | Failure: {}".format(opendnp3.TaskCompletionToString(result.summary))) 86 | 87 | 88 | def main(): 89 | app = TestDNP3Master() 90 | 91 | # ad-hoc tests can be performed at this point. See master_cmd.py for examples. 92 | app.send_direct_operate_command( 93 | opendnp3.ControlRelayOutputBlock(opendnp3.ControlCode.LATCH_OFF), 94 | 10, 95 | command_callback, 96 | ) 97 | 98 | time.sleep(10) 99 | app.scan.Demand() 100 | time.sleep(5) 101 | 102 | # uncomment the two lines below and watch all the output values from the 103 | # outstation get set to their zero value 104 | #app.master.Restart(opendnp3.RestartType.COLD, restart_callback) 105 | #time.sleep(5) 106 | 107 | app.shutdown() 108 | 109 | 110 | if __name__ == '__main__': 111 | main() 112 | -------------------------------------------------------------------------------- /testing/dnp3/visitors.py: -------------------------------------------------------------------------------- 1 | """ 2 | The master uses these data-type-specific Visitor class definitions 3 | when it processes measurements received from the outstation. 4 | """ 5 | from pydnp3 import opendnp3 6 | 7 | 8 | class VisitorIndexedBinary(opendnp3.IVisitorIndexedBinary): 9 | def __init__(self): 10 | super(VisitorIndexedBinary, self).__init__() 11 | self.index_and_value = [] 12 | 13 | def OnValue(self, indexed_instance): 14 | self.index_and_value.append((indexed_instance.index, indexed_instance.value.value)) 15 | 16 | 17 | class VisitorIndexedDoubleBitBinary(opendnp3.IVisitorIndexedDoubleBitBinary): 18 | def __init__(self): 19 | super(VisitorIndexedDoubleBitBinary, self).__init__() 20 | self.index_and_value = [] 21 | 22 | def OnValue(self, indexed_instance): 23 | self.index_and_value.append((indexed_instance.index, indexed_instance.value.value)) 24 | 25 | 26 | class VisitorIndexedCounter(opendnp3.IVisitorIndexedCounter): 27 | def __init__(self): 28 | super(VisitorIndexedCounter, self).__init__() 29 | self.index_and_value = [] 30 | 31 | def OnValue(self, indexed_instance): 32 | self.index_and_value.append((indexed_instance.index, indexed_instance.value.value)) 33 | 34 | 35 | class VisitorIndexedFrozenCounter(opendnp3.IVisitorIndexedFrozenCounter): 36 | def __init__(self): 37 | super(VisitorIndexedFrozenCounter, self).__init__() 38 | self.index_and_value = [] 39 | 40 | def OnValue(self, indexed_instance): 41 | self.index_and_value.append((indexed_instance.index, indexed_instance.value.value)) 42 | 43 | 44 | class VisitorIndexedAnalog(opendnp3.IVisitorIndexedAnalog): 45 | def __init__(self): 46 | super(VisitorIndexedAnalog, self).__init__() 47 | self.index_and_value = [] 48 | 49 | def OnValue(self, indexed_instance): 50 | self.index_and_value.append((indexed_instance.index, indexed_instance.value.value)) 51 | 52 | 53 | class VisitorIndexedBinaryOutputStatus(opendnp3.IVisitorIndexedBinaryOutputStatus): 54 | def __init__(self): 55 | super(VisitorIndexedBinaryOutputStatus, self).__init__() 56 | self.index_and_value = [] 57 | 58 | def OnValue(self, indexed_instance): 59 | self.index_and_value.append((indexed_instance.index, indexed_instance.value.value)) 60 | 61 | 62 | class VisitorIndexedAnalogOutputStatus(opendnp3.IVisitorIndexedAnalogOutputStatus): 63 | def __init__(self): 64 | super(VisitorIndexedAnalogOutputStatus, self).__init__() 65 | self.index_and_value = [] 66 | 67 | def OnValue(self, indexed_instance): 68 | self.index_and_value.append((indexed_instance.index, indexed_instance.value.value)) 69 | 70 | 71 | class VisitorIndexedTimeAndInterval(opendnp3.IVisitorIndexedTimeAndInterval): 72 | def __init__(self): 73 | super(VisitorIndexedTimeAndInterval, self).__init__() 74 | self.index_and_value = [] 75 | 76 | def OnValue(self, indexed_instance): 77 | # The TimeAndInterval class is a special case, because it doesn't have a "value" per se. 78 | ti_instance = indexed_instance.value 79 | ti_dnptime = ti_instance.time 80 | ti_interval = ti_instance.interval 81 | self.index_and_value.append((indexed_instance.index, (ti_dnptime.value, ti_interval))) 82 | -------------------------------------------------------------------------------- /testing/e2e/Procfile: -------------------------------------------------------------------------------- 1 | broker: python3 helics/broker.py 2 | dss: python3 helics/opendss-federate.py 3 | device-2: ot-sim-cpu-module configs/device-2.xml 4 | device-1: ot-sim-cpu-module configs/device-1.xml 5 | -------------------------------------------------------------------------------- /testing/e2e/README.md: -------------------------------------------------------------------------------- 1 | # End-to-End Integration Testing 2 | 3 | This project's end-to-end (e2e) testing constists of two separate OT-sim 4 | devices; device 1 acting as a RTU/gateway device and device 2 acting as an 5 | IED/leaf device connected to (virtual) sensors and actuators. To support the 6 | test, a HELICS broker and OpenDSS federate are also included. 7 | 8 | Device 1 consists of the following OT-sim modules: 9 | 10 | * CPU (manages other modules; collects logs) 11 | * DNP3 (acting as outstation) 12 | * Modbus (acting as master) 13 | 14 | Device 2 consists of the following OT-sim modules: 15 | 16 | * CPU (manages other modules; collects logs) 17 | * Logic (not really used; just present to demo) 18 | * Modbus (acting as outstation) 19 | * I/O (acting as HELICS federate) 20 | 21 | The test consists of a DNP3 master 22 | ([ot-sim-e2e-dnp3-master](../../src/c++/cmd/ot-sim-e2e-dnp3-master)) sending a 23 | series of DNP3 commands to device 1 to confirm that end-to-end communications 24 | are working from the test master all the way to the HELICS co-simulation via the 25 | I/O module on device 2. 26 | 27 | The DNP3 commands sent are as follows: 28 | 29 | * on-demand class 0 scan to get values being generated by OpenDSS federate 30 | * direct operate command to change a value in the OpenDSS federate 31 | * on-demand class 0 scan to confirm the value changed in the OpenDSS federate 32 | 33 | The DNP3 commands are sent to device 1, which then have to be processed and sent 34 | to device 2 via the Modbus client on device 1. Once received on device 2, they 35 | have to be processed by the I/O module and sent to the OpenDSS federate via the 36 | HELICS broker. 37 | 38 | This e2e test effectively validates the following: 39 | 40 | * The I/O module on device 2 is receiving updates from other HELICS federates 41 | and making them available to other modules on device 2 via the message bus. 42 | * The Modbus module on device 2 is processing messages from the I/O module and 43 | making them available to Modbus master queries. 44 | * The Modbus module on device 1 is periodically querying the Modbus module on 45 | device 2 and making the updates available to other modules on device 1. 46 | * The DNP3 module on device 1 is processing messages from the Modbus module and 47 | making them available to DNP3 master scans. 48 | * The DNP3 module on device 1 receives operate commands from DNP3 masters and 49 | pushes the appropriate messages to other modules on device 1. 50 | * The Modbus module on device 1 receives updates from other modules on device 1 51 | and generates the appropriate write commands to be sent to the Modbus 52 | outstation on device 2. 53 | * The Modbus module on device 2 receives write commands from Modbus masters and 54 | pushes the appropriate messages to other modules on device 2. 55 | * The I/O module on device 2 receives updates from other modules on device 2 and 56 | publishes them appropriately to other federates within the HELICS 57 | co-simulation. 58 | -------------------------------------------------------------------------------- /testing/e2e/configs/device-1.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | tcp://127.0.0.1:1234 5 | tcp://127.0.0.1:5678 6 | 7 | 8 | ot-sim-message-bus {{config_file}} 9 | ot-sim-modbus-module {{config_file}} 10 | ot-sim-dnp3-module {{config_file}} 11 | 12 | 13 | 127.0.0.1:20000 14 | 15 15 | 16 | 1024 17 | 1 18 | 5 19 | 20 |
0
21 | line-650632.closed 22 | Group10Var2 23 | Group11Var2 24 | Class1 25 | true 26 |
27 | 28 |
0
29 | line-650632.kW 30 | Group30Var6 31 | Group32Var6 32 | Class1 33 | 34 |
35 |
36 | 37 | 127.0.0.1:5502 38 | 2s 39 | 40 |
0
41 | line-650632.closed 42 |
43 | 44 |
30000
45 | line-650632.kW 46 | 2 47 |
48 |
49 |
50 | -------------------------------------------------------------------------------- /testing/e2e/configs/device-2.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | tcp://127.0.0.1:9012 5 | tcp://127.0.0.1:3456 6 | 7 | 8 | ot-sim-message-bus {{config_file}} 9 | ot-sim-io-module {{config_file}} 10 | ot-sim-modbus-module {{config_file}} 11 | 12 | 13 | 127.0.0.1:5502 14 | 15 |
0
16 | line-650632.closed 17 |
18 | 19 |
30000
20 | line-650632.kW 21 | -2 22 |
23 |
24 | 25 | localhost 26 | ot-sim-io 27 | 28 | OpenDSS/line-650632.kW 29 | double 30 | line-650632.kW 31 | 32 | 33 | OpenDSS/line-650632.kVAR 34 | double 35 | line-650632.kVAR 36 | 37 | 38 | OpenDSS/line-650632.closed 39 | boolean 40 | line-650632.closed 41 | 42 | 43 | OpenDSS/switch-671692.closed 44 | boolean 45 | switch-671692.closed 46 | 47 | 48 | line-650632.closed 49 | 50 | 51 |
52 | -------------------------------------------------------------------------------- /testing/e2e/helics/broker.py: -------------------------------------------------------------------------------- 1 | import time 2 | 3 | import helics as h 4 | 5 | b = h.helicsCreateBroker("zmq", "", "-f 2 --ipv4") 6 | 7 | if h.helicsBrokerIsConnected(b): 8 | print('HELICS broker started') 9 | 10 | try: 11 | time.sleep(3600) 12 | except Exception as ex: 13 | pass 14 | else: 15 | print('HELICS broker failed to start') 16 | -------------------------------------------------------------------------------- /testing/e2e/helics/data/IEEE13/IEEE13Node_BusXY.csv: -------------------------------------------------------------------------------- 1 | version https://git-lfs.github.com/spec/v1 2 | oid sha256:f6ced2744bba6258e4c32e1cfbe123dac9184e753f62e583ecc4c13126317610 3 | size 225 4 | -------------------------------------------------------------------------------- /testing/e2e/helics/data/IEEE13/LoadShape1.csv: -------------------------------------------------------------------------------- 1 | version https://git-lfs.github.com/spec/v1 2 | oid sha256:8c253f87220d29d8f8f567514f02e1c71dc6039ee72a521a2623cecd6439553c 3 | size 27200 4 | --------------------------------------------------------------------------------