├── .github
└── workflows
│ └── maven-package-and-release.yml
├── .gitignore
├── docs
├── .gitignore
├── makefile
├── mkdocs
│ ├── docs
│ ├── mkdocs.yml
│ └── overrides
│ │ └── partials
│ │ └── copyright.html
├── requirements.txt
└── src
│ ├── Pages
│ ├── Configuration
│ │ ├── 01-Broker.md
│ │ ├── 03-Tag Provider.md
│ │ ├── 04-Routing.md
│ │ └── 05-Representation.md
│ └── Getting Started
│ │ └── 01-Overview.md
│ ├── assets
│ └── hydra.png
│ ├── index.md
│ └── stylesheets
│ └── scheme.css
├── readme.md
└── src
├── .gitignore
├── build
├── doc
│ └── index.html
├── license.html
└── pom.xml
├── gateway
├── pom.xml
└── src
│ └── main
│ ├── java
│ └── com
│ │ └── mrmccormick
│ │ └── ignition
│ │ └── hydra
│ │ └── mqtt
│ │ ├── Connection.java
│ │ ├── GatewayHook.java
│ │ ├── MqttManager.java
│ │ ├── TagManager.java
│ │ ├── data
│ │ ├── DataEvent.java
│ │ ├── IDataCoder.java
│ │ ├── IDataEventSubscriber.java
│ │ ├── JsonCoder.java
│ │ ├── TimestampFormat.java
│ │ └── TimestampIntegerFormat.java
│ │ └── settings
│ │ ├── IConnectSettings.java
│ │ ├── SettingsCategory.java
│ │ ├── SettingsManager.java
│ │ ├── SettingsPageX.java
│ │ └── SettingsRecordX.java
│ └── resources
│ └── com
│ └── mrmccormick
│ └── ignition
│ └── hydra
│ └── mqtt
│ ├── SettingsCategory.properties
│ ├── SettingsPageX.properties
│ └── settings
│ └── SettingsRecordX.properties
├── generate-secrets.sh
├── makefile
├── pom.xml
└── version
/.github/workflows/maven-package-and-release.yml:
--------------------------------------------------------------------------------
1 | name: Module Package and Release
2 |
3 | on:
4 | push:
5 | branches:
6 | - main
7 | - prerelease
8 | pull_request:
9 | branches:
10 | - main
11 | - prerelease
12 |
13 | jobs:
14 | build:
15 | runs-on: ubuntu-latest
16 | steps:
17 | - name: Check out code
18 | uses: actions/checkout@v2
19 |
20 | - name: Set up Java
21 | uses: actions/setup-java@v2
22 | with:
23 | java-version: '17'
24 | distribution: 'adopt'
25 |
26 | - name: Build
27 | env:
28 | BUILD_CONFIG: "RELEASE"
29 | BUILD_NUMBER: "${{ github.run_number }}"
30 | EVENT_NAME: "${{ github.event_name }}"
31 | GIT_REPOSITORY: "${{ github.repository }}"
32 | GIT_REPOSITORY_URL: "https://github.com/${{ github.repository }}"
33 | GIT_COMMIT_HASH: "${{ github.sha }}"
34 | GIT_COMMIT_URL: "https://github.com/${{ github.repository }}/commit/${{ github.sha }}"
35 | GIT_BRANCH: "${{ github.ref }}"
36 | GIT_PULL_REQUEST_ID: "${{ github.event.pull_request.number }}"
37 | GIT_PULL_REQUEST_URL: "https://github.com/${{ github.repository }}/pull/${{ github.event.pull_request.number }}"
38 | GIT_PULL_REQUEST_HASH: "${{ github.event.pull_request.head.sha }}"
39 | GIT_PULL_REQUEST_COMMIT_URL: "https://github.com/${{ github.repository }}/pull/${{ github.event.pull_request.number }}/commits/${{ github.event.pull_request.head.sha }}"
40 | GIT_PULL_REQUEST_SOURCE_BRANCH: "${{ github.head_ref }}"
41 | GIT_PULL_REQUEST_TARGET_BRANCH: "${{ github.base_ref }}"
42 | RELEASE_IS_PRERELEASE: "${{ github.ref != 'refs/heads/main' }}"
43 | RELEASE_URL: "https://github.com/${{ github.repository }}/releases/tag/${{ github.ref_name }}"
44 | run: |
45 | ENV_FILE="src/cicd.env"
46 | if [ -f "${ENV_FILE}" ]; then
47 | rm "${ENV_FILE}"
48 | fi
49 |
50 | echo "GIT_REPOSITORY=${GIT_REPOSITORY}" >> ${ENV_FILE}
51 | echo "GIT_REPOSITORY_URL=${GIT_REPOSITORY_URL}" >> ${ENV_FILE}
52 | GIT_COMMIT_HASH_SHORT="$(git rev-parse --short ${GIT_COMMIT_HASH})"
53 | echo "GIT_COMMIT_HASH=${GIT_COMMIT_HASH}" >> ${ENV_FILE}
54 | echo "GIT_COMMIT_HASH_SHORT=${GIT_COMMIT_HASH_SHORT}" >> ${ENV_FILE}
55 | echo "GIT_COMMIT_URL=${GIT_COMMIT_URL}" >> ${ENV_FILE}
56 |
57 | RELEASE_VERSION=$(cat src/version | xargs)
58 |
59 | # Get the next release candidate (RC) number for the prerelease
60 | echo "RELEASE_VERSION: ${RELEASE_VERSION}"
61 | EXISTING_VERSIONS=$(git ls-remote --tags origin | grep "refs/tags/v${RELEASE_VERSION}" | awk '{ print $2 }' | sort)
62 | echo "EXISTING_VERSIONS: ${EXISTING_VERSIONS}"
63 | EXISTING_RELEASE_VERSIONS=$(git ls-remote --tags origin | grep "refs/tags/v${RELEASE_VERSION}$" | awk '{ print $2 }' | sort)
64 | echo "EXISTING_RELEASE_VERSIONS: ${EXISTING_RELEASE_VERSIONS}"
65 | if [ ! -z "${EXISTING_RELEASE_VERSIONS}" ]; then
66 | echo "Error: Release version ${RELEASE_VERSION} already exists"
67 | exit 1
68 | fi
69 | EXISTING_PRERELEASE_VERSIONS=$(echo "${EXISTING_VERSIONS}" | sed 's:refs/tags/v${RELEASE_VERSION}::g' | grep ".*-rc" | sort)
70 | echo "EXISTING_PRERELEASE_VERSIONS: ${EXISTING_PRERELEASE_VERSIONS}"
71 | EXISTING_RCS=$(echo "${EXISTING_PRERELEASE_VERSIONS}" | sed 's/.*-rc//g' | sort)
72 | echo "EXISTING_RCS: ${EXISTING_RCS}"
73 | LAST_RC=0
74 | if [ ! -z "${EXISTING_RCS}" ]; then
75 | LAST_RC=$(echo "${EXISTING_RCS}" | sort | tail -n1)
76 | fi
77 | echo "LAST_RC: ${LAST_RC}"
78 | NEW_RC=${LAST_RC}
79 | ((NEW_RC+=1))
80 | echo "NEW_RC: ${NEW_RC}"
81 | if [ "${NEW_RC}" -gt 9 ]; then
82 | echo "RC Limit Exceeded"
83 | exit 1
84 | fi
85 |
86 | if [ "${EVENT_NAME}" == "push" ]; then
87 | GIT_BRANCH_NAME=$(echo "${GIT_BRANCH}" | sed 's:^refs/heads/::g')
88 | GIT_BRANCH_URL="https://github.com/${GIT_REPOSITORY}/tree/${GIT_BRANCH_NAME}"
89 | echo "GIT_BRANCH=${GIT_BRANCH}" >> ${ENV_FILE}
90 | echo "GIT_BRANCH_NAME=${GIT_BRANCH_NAME}" >> ${ENV_FILE}
91 | echo "GIT_BRANCH_URL=${GIT_BRANCH_URL}" >> ${ENV_FILE}
92 |
93 | if [ "${GIT_BRANCH_NAME}" != "main" ]; then
94 | BUILD_CONFIG="PRERELEASE"
95 | RELEASE_VERSION="${RELEASE_VERSION}-rc${NEW_RC}"
96 | fi
97 |
98 | elif [ "${EVENT_NAME}" == "pull_request" ]; then
99 | echo "GIT_PULL_REQUEST_ID=${GIT_PULL_REQUEST_ID}" >> ${ENV_FILE}
100 | echo "GIT_PULL_REQUEST_URL=${GIT_PULL_REQUEST_URL}" >> ${ENV_FILE}
101 | echo "GIT_PULL_REQUEST_COMMIT_URL=${GIT_PULL_REQUEST_COMMIT_URL}" >> ${ENV_FILE}
102 | echo "GIT_PULL_REQUEST_SOURCE_BRANCH=${GIT_PULL_REQUEST_SOURCE_BRANCH}" >> ${ENV_FILE}
103 | echo "GIT_PULL_REQUEST_TARGET_BRANCH=${GIT_PULL_REQUEST_TARGET_BRANCH}" >> ${ENV_FILE}
104 |
105 | if [ "${GIT_PULL_REQUEST_TARGET_BRANCH}" != "main" ]; then
106 | BUILD_CONFIG="PRERELEASE"
107 | RELEASE_VERSION="${RELEASE_VERSION}-rc${NEW_RC}"
108 | fi
109 |
110 | else
111 | echo "EVENT_NAME is not 'push' or 'pull_request'"
112 | exit 1
113 | fi
114 |
115 | echo "BUILD_CONFIG=${BUILD_CONFIG}" >> ${ENV_FILE}
116 | echo "BUILD_NUMBER=${BUILD_NUMBER}" >> ${ENV_FILE}
117 |
118 | RELEASE_DATE=$(date "+%Y-%m-%d")
119 | RELEASE_TAG_NAME="v${RELEASE_VERSION}"
120 | RELEASE_NAME="v${RELEASE_VERSION} (${RELEASE_DATE})"
121 | echo "RELEASE_VERSION=${RELEASE_VERSION}" >> ${ENV_FILE}
122 | echo "RELEASE_DATE=${RELEASE_DATE}" >> ${ENV_FILE}
123 | echo "RELEASE_TAG_NAME=${RELEASE_TAG_NAME}" >> ${ENV_FILE}
124 | echo "RELEASE_NAME=${RELEASE_NAME}" >> ${ENV_FILE}
125 | echo "RELEASE_IS_PRERELEASE=${RELEASE_IS_PRERELEASE}" >> ${ENV_FILE}
126 | echo "RELEASE_URL=${RELEASE_URL}" >> ${ENV_FILE}
127 |
128 | cat ${ENV_FILE}
129 |
130 | echo "RELEASE_IS_PRERELEASE=${RELEASE_IS_PRERELEASE}" >> $GITHUB_ENV
131 | echo "RELEASE_TAG_NAME=${RELEASE_TAG_NAME}" >> $GITHUB_ENV
132 | echo "RELEASE_NAME=${RELEASE_NAME}" >> $GITHUB_ENV
133 |
134 | SECRETS_DIR="src/secrets/"
135 | mkdir "${SECRETS_DIR}"
136 | echo "${{ secrets.ALIAS_NAME }}" | base64 -d > ${SECRETS_DIR}/alias_name.txt
137 | echo "${{ secrets.ALIAS_PASSWORD }}" | base64 -d > ${SECRETS_DIR}/alias_password.txt
138 | echo "${{ secrets.CHAIN }}" | base64 -d > ${SECRETS_DIR}/chain.p7b
139 | echo "${{ secrets.ENV }}" | base64 -d > ${SECRETS_DIR}/env
140 | echo "${{ secrets.KEYSTORE }}" | base64 -d > ${SECRETS_DIR}/keystore.jks
141 | echo "${{ secrets.KEYSTORE_PASSWORD }}" | base64 -d > ${SECRETS_DIR}/keystore_password.txt
142 |
143 | BUILD_CONFIG="${BUILD_CONFIG}" make -C src
144 |
145 | - name: Test
146 | env:
147 | BUILD_NUMBER: ${{ github.run_number }}
148 | run: |
149 | echo "Tested!"
150 | echo "Branch (github.ref): ${{ github.ref }}"
151 | echo "Commit Sha (github.sha): ${{ github.sha }}"
152 | echo "Commit Hash (steps.set_output.outputs.commit_hash): ${{ steps.set_output.outputs.commit_hash }}"
153 |
154 | - name: Check Release Version
155 | run: |
156 | echo "RELEASE_IS_PRERELEASE: ${{ env.RELEASE_IS_PRERELEASE }}"
157 | echo "RELEASE_NAME: ${{ env.RELEASE_NAME }}"
158 | echo "RELEASE_TAG_NAME: ${{ env.RELEASE_TAG_NAME }}"
159 |
160 | if git ls-remote --tags origin | grep -q "refs/tags/${{ env.RELEASE_TAG_NAME }}$"; then
161 | >&2 echo "Error: Tag ${{ env.RELEASE_TAG_NAME }} already exists"
162 | exit 1
163 | else
164 | echo "Tag ${TAG_NAME} does not exist"
165 | fi
166 |
167 | - name: Create Release
168 | id: create_release
169 | uses: actions/create-release@v1
170 | if: >
171 | github.event_name == 'push' && (
172 | startsWith(github.ref, 'refs/heads/main')
173 | ||
174 | startsWith(github.ref, 'refs/heads/prerelease')
175 | )
176 | env:
177 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
178 | with:
179 | tag_name: ${{ env.RELEASE_TAG_NAME }}
180 | release_name: ${{ env.RELEASE_NAME }}
181 | draft: false
182 | prerelease: ${{ env.RELEASE_IS_PRERELEASE }}
183 |
184 | - name: Upload Artifacts to Release
185 | env:
186 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
187 | if: >
188 | github.event_name == 'push' && (
189 | startsWith(github.ref, 'refs/heads/main')
190 | ||
191 | startsWith(github.ref, 'refs/heads/prerelease')
192 | )
193 | shell: bash
194 | run: |
195 | ARTIFACT_SEARCH_PATH="src/"
196 | echo "Artifact Search Path: ${ARTIFACT_SEARCH_PATH}"
197 |
198 | echo ""
199 | echo "Artifacts:"
200 | ARTIFACT_PATHS=$(find "${ARTIFACT_SEARCH_PATH}" -maxdepth 1 -type f -name '*.modl')
201 | for ARTIFACT_PATH in ${ARTIFACT_PATHS}; do
202 | echo " - ${ARTIFACT_PATH}..."
203 | done
204 |
205 | echo ""
206 | echo "Uploading Artifacts..."
207 | for ARTIFACT_PATH in ${ARTIFACT_PATHS}; do
208 | ARTIFACT_NAME=$(basename "${ARTIFACT_PATH}")
209 | ARTIFACT_NAME="${ARTIFACT_NAME}"
210 | echo " - Uploading ${ARTIFACT_NAME}..."
211 |
212 | RAW_UPLOAD_URL="${{ steps.create_release.outputs.upload_url }}"
213 | # Remove ...assets{?name,label} from the end of the url
214 | BASE_UPLOAD_URL="$(echo "${RAW_UPLOAD_URL}" | sed "s/assets.*/assets/g")"
215 | # Add the artifact name to the URL
216 | UPLOAD_URL="${BASE_UPLOAD_URL}?name=${ARTIFACT_NAME}"
217 |
218 | HTTP_CODE=$(curl \
219 | --silent \
220 | --output curl_output.txt \
221 | --write-out "%{http_code}" \
222 | -X POST \
223 | -H "Authorization: token ${{ secrets.GITHUB_TOKEN }}" \
224 | -H "Content-Type: application/octet-stream" \
225 | --data-binary @${ARTIFACT_PATH} \
226 | "${UPLOAD_URL}" \
227 | ) || true
228 |
229 | if [[ ${HTTP_CODE} -lt 200 || ${HTTP_CODE} -gt 299 ]] ; then
230 | >&2 cat curl_output.txt
231 | >&2 echo "Could not upload ${ARTIFACT_NAME}"
232 | exit 1
233 | fi
234 | rm curl_output.txt
235 |
236 | echo " Uploaded ${ARTIFACT_NAME} Successfully"
237 | done
238 |
239 | echo ""
240 | echo "Uploaded All Artifacts Successfully"
241 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | .idea/
2 |
--------------------------------------------------------------------------------
/docs/.gitignore:
--------------------------------------------------------------------------------
1 | _site/
2 | .venv/
3 | venv/
4 |
--------------------------------------------------------------------------------
/docs/makefile:
--------------------------------------------------------------------------------
1 |
2 | all: venv/touchfile
3 | export SITE_NAME="Hydra-MQTT v`cat ../src/version`" && \
4 | echo "SITE_NAME: $${SITE_NAME}" && \
5 | . venv/bin/activate; cd mkdocs && mkdocs build --site-dir ../_site \
6 |
7 | debug: venv/touchfile
8 | export SITE_NAME="Hydra-MQTT v`cat ../src/version`" && \
9 | echo "SITE_NAME: $${SITE_NAME}" && \
10 | . venv/bin/activate && cd mkdocs && mkdocs serve -a localhost:4000
11 |
12 | clean:
13 | @rm -rf build
14 | @rm -rf venv
15 |
16 | # Update venv when requirements.txt changes
17 | venv/touchfile: requirements.txt
18 | # Create a virtual environment if it doesn't exist
19 | test -d venv || python3 -m venv venv
20 | # Install python dependencies from requirements.txt
21 | . venv/bin/activate; pip install -Ur requirements.txt
22 | # Signal that venv has been updated for due to requirements.txt change
23 | touch venv/touchfile
24 | # List Installed Packages
25 | . venv/bin/activate && pip list && sleep 2
26 |
27 |
--------------------------------------------------------------------------------
/docs/mkdocs/docs:
--------------------------------------------------------------------------------
1 | ../src/
--------------------------------------------------------------------------------
/docs/mkdocs/mkdocs.yml:
--------------------------------------------------------------------------------
1 | site_name: !ENV SITE_NAME
2 | site_url: "https://hydra-mqtt.netlify.app/"
3 | theme:
4 | name: material
5 | custom_dir: overrides
6 | features:
7 | - navigation.instant
8 | - navigation.tracking
9 | - navigation.expanded
10 | - navigation.top
11 | - navigation.indexes
12 | - navigation.path
13 | - toc.follow
14 | - search.suggest
15 | - search.highlight
16 | - content.code.annotate
17 | - content.code.copy
18 | - content.tooltips
19 | palette:
20 | - scheme: custom
21 | logo: assets/hydra.png
22 | favicon: assets/hydra.png
23 |
24 | # Admonitions
25 | icon:
26 | admonition:
27 | note: octicons/tag-16
28 | abstract: octicons/checklist-16
29 | info: octicons/info-16
30 | tip: octicons/squirrel-16
31 | success: octicons/check-16
32 | question: octicons/question-16
33 | warning: octicons/alert-16
34 | failure: octicons/x-circle-16
35 | danger: octicons/zap-16
36 | bug: octicons/bug-16
37 | example: octicons/beaker-16
38 | quote: octicons/quote-16
39 | markdown_extensions:
40 | - toc:
41 | permalink: true
42 | baselevel: 2
43 |
44 | - pymdownx.betterem:
45 | smart_enable: all
46 |
47 | - pymdownx.magiclink:
48 | repo_url_shorthand: true
49 | user: squidfunk
50 | repo: mkdocs-material
51 |
52 | - pymdownx.smartsymbols
53 |
54 | # Admonitions
55 | - admonition
56 | - pymdownx.details
57 | - pymdownx.superfences
58 |
59 | # Annotations
60 | - attr_list
61 | - md_in_html
62 | - pymdownx.superfences
63 |
64 | # Buttons
65 | - attr_list
66 |
67 | # Code Blocks
68 | - pymdownx.highlight:
69 | anchor_linenums: true
70 | - pymdownx.inlinehilite
71 | - pymdownx.snippets:
72 | check_paths: true
73 | - pymdownx.superfences
74 |
75 | # Content Tabs
76 | - pymdownx.superfences
77 | - pymdownx.tabbed:
78 | alternate_style: true
79 |
80 | # Data Tables
81 | - tables
82 |
83 | # Diagrams
84 | - pymdownx.superfences:
85 | custom_fences:
86 | - name: mermaid
87 | class: mermaid
88 | format: !!python/name:pymdownx.superfences.fence_code_format
89 |
90 | # Footnotes
91 | - footnotes
92 |
93 | # Formatting
94 | - pymdownx.critic
95 | - pymdownx.caret
96 | - pymdownx.keys
97 | - pymdownx.mark
98 | - pymdownx.tilde
99 |
100 | # Grids
101 | - attr_list
102 | - md_in_html
103 |
104 | # Icons, Emojis
105 | - attr_list
106 | - pymdownx.emoji:
107 | emoji_index: !!python/name:material.extensions.emoji.twemoji
108 | emoji_generator: !!python/name:material.extensions.emoji.to_svg
109 |
110 | # Images
111 | - attr_list
112 | - md_in_html
113 |
114 | # Lists
115 | - def_list
116 | - pymdownx.tasklist:
117 | custom_checkbox: true
118 |
119 | # MathJax
120 | - pymdownx.arithmatex:
121 | generic: true
122 |
123 | # Tooltips
124 | - abbr
125 | - attr_list
126 | - pymdownx.snippets
127 |
128 | extra_css:
129 | - stylesheets/scheme.css
130 |
131 | plugins:
132 | - search
133 | - include_dir_to_nav:
134 | reverse_sort_directory: true
135 | - offline
136 |
137 | repo_url: https://github.com/m-r-mccormick/Hydra-MQTT
138 | edit_uri: edit/main/docs/src
139 |
140 | nav:
141 | - index.md
142 | - Pages
143 |
--------------------------------------------------------------------------------
/docs/mkdocs/overrides/partials/copyright.html:
--------------------------------------------------------------------------------
1 |
2 |
3 | {% if config.copyright %}
4 |
5 | {{ config.copyright }}
6 |
7 | {% endif %}
8 | {% if not config.extra.generator == false %}
9 |
34 | {% endif %}
35 |
36 |
37 |
38 |
--------------------------------------------------------------------------------
/docs/requirements.txt:
--------------------------------------------------------------------------------
1 | mkdocs>=1.4.2
2 | mkdocs-include-dir-to-nav>=1.2.0
3 | mkdocs-material>=9.0.6
4 | mkdocs-material-extensions>=1.1.1
5 |
--------------------------------------------------------------------------------
/docs/src/Pages/Configuration/01-Broker.md:
--------------------------------------------------------------------------------
1 | ---
2 | title: 'Broker'
3 | ---
4 |
5 | # Host
6 |
7 | This option specifies the `IP Address` or `DNS Name` of the MQTT broker.
8 |
9 | | Location | Type | Provider | Address |
10 | | -------- | ---- | -------- | ------- |
11 | | Local Network | `IP Address` | You | `192.168.1.105` |
12 | | Internet | `DNS Name` | Eclipse | `mqtt.eclipse.org` |
13 | | Internet | `DNS Name` | Mosquitto | `test.mosquitto.org` |
14 | | Internet | `DNS Name` | HiveMQ | `broker.hivemq.com` |
15 | | Docker Network | `DNS Name` | [Real-Time Manufacturing Datasets](https://github.com/m-r-mccormick/Real-Time-Manufacturing-Datasets){ target=_blank } | `broker` |
16 |
17 | ???+ example
18 |
19 | Using [Real-Time Manufacturing Datasets](https://github.com/m-r-mccormick/Real-Time-Manufacturing-Datasets){ target=_blank }:
20 |
21 | ```text title="Host"
22 | broker
23 | ```
24 |
25 | # Port
26 |
27 | This option specifies the `Port` that the broker service is using on the host. While not a standard, a convention is that brokers with no security typically use port `1883`, while brokers using Transport Layer Security (TLS) typically use port `8883`.
28 |
29 | | Type | Port |
30 | | ---- | ---- |
31 | | No Security | `1883` |
32 | | TLS | `8883` |
33 |
34 | ???+ info
35 |
36 | TLS is not currently supported.
37 |
38 | ???+ example
39 |
40 | ```text title="Port"
41 | 1883
42 | ```
43 |
44 | # Publish QoS
45 |
46 | This option specifies the Quality of Service (QoS) level for data published to the broker. Higher QoS levels can improve data integrity at the cost of performance.
47 |
48 | | Level | Meaning |
49 | | ----- | ------- |
50 | | 0 | At Most Once |
51 | | 1 | At Least Once |
52 | | 2 | Exactly Once |
53 |
54 | ???+ example
55 |
56 | ```text title="Publish QoS"
57 | 0
58 | ```
59 |
60 | # Subscribe QoS
61 |
62 | This option specifies the Quality of Service (QoS) level for subscriptions to (i.e., data received from) the broker. Higher QoS levels can improve data integrity at the cost of performance.
63 |
64 | | Level | Meaning |
65 | | ----- | ------- |
66 | | 0 | At Most Once |
67 | | 1 | At Least Once |
68 | | 2 | Exactly Once |
69 |
70 | ???+ example
71 |
72 | ```text title="Subscribe QoS"
73 | 0
74 | ```
75 |
76 | # Subscriptions
77 |
78 | This option specifies broker subscriptions, with one subscription per line.
79 |
80 | ???+ example
81 |
82 | ```text title="Subscribe to All Broker Data (Excluding $SYS)"
83 | #
84 | ```
85 |
86 | ```text title="Subscribe to All Subtopics of 'A/a/' and 'B/b/'"
87 | A/a/#
88 | B/b/#
89 | ```
90 |
91 |
--------------------------------------------------------------------------------
/docs/src/Pages/Configuration/03-Tag Provider.md:
--------------------------------------------------------------------------------
1 | ---
2 | title: 'Tag Provider'
3 | ---
4 |
5 |
6 |
7 |
8 | # Publish-Only Tag Provider
9 |
10 | This setting specifies the `Name` of a deciated `Realtime Tag Provider` defined in `Config > Tags > Realtime`. Changes to a `Tag` in the specified `Realtime Tag Provider` will be published to the broker, but any events received from the broker will not result in `Tag` changes. As such, this `Tag Provider` is _Publish Only_.
11 |
12 | When a `Tag` in the specified `Realtime Tag Provider` changes, the `Tag Path` (i.e., folder hierarchy) is translated into an equivalent `Topic`. For example a `Tag` with the following `Tag Path` would publish to the specified `Topic`:
13 |
14 | | Tag Path | Topic |
15 | | -------- | ----- |
16 | | `e1/s1/a1/l2/c1/v2` | `E1/S1/A1/L2/C1/V2` |
17 |
18 |
19 |
20 | - :fontawesome-solid-tag:{ .middle } __Ignition Tag Provider__{.lg .middle} :fontawesome-solid-arrow-right:{ .middle }
21 |
22 | ---
23 |
24 | ```yaml
25 | [Folder]
26 | └─e1: [Folder]
27 | │ └─s1: [Folder]
28 | │ │ └─a1: [Folder]
29 | │ │ │ └─l1: [Folder]
30 | │ │ │ │ └─c1: [Folder]
31 | │ │ │ │ │ └─v1: [Tag]
32 | │ │ │ │ │ └─v2: [Tag]
33 | │ │ │ │ └─c2: [Folder]
34 | │ │ │ │ │ └─v1: [Tag]
35 | │ │ │ │ │ └─v2: [Tag]
36 | │ │ │ └─l2: [Line]
37 | │ │ │ │ └─c1: [Folder]
38 | │ │ │ │ │ └─v1: [Tag]
39 | │ │ │ │ │ └─v2: [Tag]
40 | │ │ │ │ └─c2: [Folder]
41 | │ │ │ │ │ └─v1: [Tag]
42 | │ │ │ │ │ └─v2: [Tag]
43 | ```
44 |
45 | - :fontawesome-solid-arrow-right:{ .middle } :fontawesome-solid-circle-nodes:{ .middle } __MQTT Broker__{.lg .middle}
46 |
47 | ---
48 |
49 | ```yaml
50 | [Tag Provider Root]
51 | └─E1: [Enterprise]
52 | │ └─S1: [Site]
53 | │ │ └─A1: [Area]
54 | │ │ │ └─L1: [Line]
55 | │ │ │ │ └─C1: [Cell]
56 | │ │ │ │ │ └─V1: [Variable]
57 | │ │ │ │ │ └─V2: [Variable]
58 | │ │ │ │ └─C2: [Cell]
59 | │ │ │ │ │ └─V1: [Variable]
60 | │ │ │ │ │ └─V2: [Variable]
61 | │ │ │ └─L2: [Line]
62 | │ │ │ │ └─C1: [Cell]
63 | │ │ │ │ │ └─V1: [Variable]
64 | │ │ │ │ │ └─V2: [Variable]
65 | │ │ │ │ └─C2: [Cell]
66 | │ │ │ │ │ └─V1: [Variable]
67 | │ │ │ │ │ └─V2: [Variable]
68 | ```
69 |
70 |
71 |
72 | # Subscribe-Only Tag Provider
73 |
74 | This setting specifies the `Name` of a deciated `Realtime Tag Provider` defined in `Config > Tags > Realtime`. An event received from the broker results in a `Tag` change in the specified `Realtime Tag Provider`, but any changes to `Tag`s will not result in changes being published to the broker. As such, this `Tag Provider` is _Subscribe Only_.
75 |
76 | When a broker event is received, the `Topic` of the event is translated into an equivalent `Tag Path`. For example, an event with the following `Topic` will be translated to the equivalent `Tag Path` resulting in that `Tag` being modified:
77 |
78 | | Topic | Tag Path |
79 | | -------- | ----- |
80 | | `E1/S1/A1/L2/C1/V2` | `e1/s1/a1/l2/c1/v2` |
81 |
82 | If a `Tag` with the specified `Tag Path` does not exist, it will be automatically created, along with all parent `Folder`s. If a `Folder` already exists at the `Tag Path`, the event will be __silently dropped__.
83 |
84 |
85 |
86 | - :fontawesome-solid-circle-nodes:{ .middle } __MQTT Broker__{.lg .middle} :fontawesome-solid-arrow-right:{ .middle }
87 |
88 | ---
89 |
90 | ```yaml
91 | [Tag Provider Root]
92 | └─E1: [Enterprise]
93 | │ └─S1: [Site]
94 | │ │ └─A1: [Area]
95 | │ │ │ └─L1: [Line]
96 | │ │ │ │ └─C1: [Cell]
97 | │ │ │ │ │ └─V1: [Variable]
98 | │ │ │ │ │ └─V2: [Variable]
99 | │ │ │ │ └─C2: [Cell]
100 | │ │ │ │ │ └─V1: [Variable]
101 | │ │ │ │ │ └─V2: [Variable]
102 | │ │ │ └─L2: [Line]
103 | │ │ │ │ └─C1: [Cell]
104 | │ │ │ │ │ └─V1: [Variable]
105 | │ │ │ │ │ └─V2: [Variable]
106 | │ │ │ │ └─C2: [Cell]
107 | │ │ │ │ │ └─V1: [Variable]
108 | │ │ │ │ │ └─V2: [Variable]
109 | ```
110 |
111 | - :fontawesome-solid-arrow-right:{ .middle } :fontawesome-solid-tag:{ .middle } __Ignition Tag Provider__{.lg .middle}
112 |
113 | ---
114 |
115 | ```yaml
116 | [Folder]
117 | └─e1: [Folder]
118 | │ └─s1: [Folder]
119 | │ │ └─a1: [Folder]
120 | │ │ │ └─l1: [Folder]
121 | │ │ │ │ └─c1: [Folder]
122 | │ │ │ │ │ └─v1: [Tag]
123 | │ │ │ │ │ └─v2: [Tag]
124 | │ │ │ │ └─c2: [Folder]
125 | │ │ │ │ │ └─v1: [Tag]
126 | │ │ │ │ │ └─v2: [Tag]
127 | │ │ │ └─l2: [Line]
128 | │ │ │ │ └─c1: [Folder]
129 | │ │ │ │ │ └─v1: [Tag]
130 | │ │ │ │ │ └─v2: [Tag]
131 | │ │ │ │ └─c2: [Folder]
132 | │ │ │ │ │ └─v1: [Tag]
133 | │ │ │ │ │ └─v2: [Tag]
134 | ```
135 |
136 |
137 |
--------------------------------------------------------------------------------
/docs/src/Pages/Configuration/04-Routing.md:
--------------------------------------------------------------------------------
1 | ---
2 | title: 'Routing'
3 | ---
4 |
5 |
6 | # Settings
7 |
8 | ## Publish Topic Suffix
9 |
10 | This option:
11 |
12 | 1. Appends the specified suffix to the `Topic` when publishing `Tag` changes.
13 | 2. Ignores received events from `Topic`s with the specified suffix.
14 |
15 | This is intended help prevent feedback loops that may occur when using a `PubSub Tag Provider`, or to help prevent a `Subscribe-Only Tag Provider` from receiving data published by a `Publish-Only Tag Provider`.
16 |
17 | ???+ example
18 |
19 | ```text title="Publish Topic Suffix"
20 | _write
21 | ```
22 |
--------------------------------------------------------------------------------
/docs/src/Pages/Configuration/05-Representation.md:
--------------------------------------------------------------------------------
1 | ---
2 | title: 'Representation'
3 | ---
4 |
5 | # Publish Value Path
6 |
7 | This option specifies the location within a json payload where the `Value` should be, with a period (`.`) delimiting levels (i.e., dictionaries) within the payload.
8 |
9 | ???+ example
10 |
11 | ```bash title='Publish Value Path'
12 | Value
13 | ```
14 |
15 | ```json title='Payload'
16 | {
17 | "Value": 42
18 | }
19 | ```
20 |
21 |
22 | ???+ example
23 |
24 | ```bash title='Publish Value Path'
25 | Observation.Value
26 | ```
27 |
28 | ```json title='Payload'
29 | {
30 | "Observation": {
31 | "Value": 42
32 | }
33 | }
34 | ```
35 |
36 |
37 | # Publish Timestamp Path
38 |
39 | This option specifies the location within a json payload where the `Timestamp` should be, with a period (`.`) delimiting levels (i.e., dictionaries) within the payload.
40 |
41 | ???+ example
42 |
43 | ```bash title='Publish Timestamp Path'
44 | Timestamp
45 | ```
46 |
47 | ```json title='Payload'
48 | {
49 | "Timestamp": "2025-01-01T00:00:00.000+00:00"
50 | }
51 | ```
52 |
53 |
54 | ???+ example
55 |
56 | ```bash title='Publish Timestamp Path'
57 | Observation.Timestamp
58 | ```
59 |
60 | ```json title='Payload'
61 | {
62 | "Observation": {
63 | "Timestamp": "2025-01-01T00:00:00.000+00:00"
64 | }
65 | }
66 | ```
67 |
68 |
69 | # Publish Timestamp Format
70 |
71 | This option specifies the format that `Timestamp`s should be published in.
72 |
73 | | Format | Json Type | Output |
74 | | ------ | --------- | ------ |
75 | | ISO8601 | String | "2025-01-01T00:00:00.000+00:00" |
76 | | DoubleUnixEpochSeconds | Double | 1735707600.000000000 |
77 | | IntegerUnixEpochSeconds | Integer | 1735707600 |
78 | | IntegerUnixEpochNanoseconds | Integer | 1735707600000000000 |
79 |
80 | ???+ example
81 |
82 | ```bash title='Publish Timestamp Format'
83 | ISO8601
84 | ```
85 |
86 | ```json title='Payload'
87 | {
88 | "Timestamp": "2025-01-01T00:00:00.000+00:00"
89 | }
90 | ```
91 |
92 |
93 | ???+ example
94 |
95 | ```bash title='Publish Timestamp Format'
96 | DoubleUnixEpochSeconds
97 | ```
98 |
99 | ```json title='Payload'
100 | {
101 | "Timestamp": 1735707600.000000000
102 | }
103 | ```
104 |
105 |
106 | ???+ example
107 |
108 | ```bash title='Publish Timestamp Format'
109 | IntegerUnixEpochSeconds
110 | ```
111 |
112 | ```json title='Payload'
113 | {
114 | "Timestamp": 1735707600
115 | }
116 | ```
117 |
118 |
119 | ???+ example
120 |
121 | ```bash title='Publish Timestamp Format'
122 | IntegerUnixEpochNanoseconds
123 | ```
124 |
125 | ```json title='Payload'
126 | {
127 | "Timestamp": 1735707600000000000
128 | }
129 | ```
130 |
131 |
132 | # Subscribe Value Path
133 |
134 | This option specifies the location within a json payload where the `Value` should be, with a period (`.`) delimiting levels (i.e., dictionaries) within the payload. If a payload is received which is 1) not deserializable json or 2) does not contain the `Value` at the specified location, the payload is __silently discarded__.
135 |
136 | ???+ example
137 |
138 | ```bash title='Subscribe Value Path'
139 | Value
140 | ```
141 |
142 | ```json title='Payload'
143 | {
144 | "Value": 42
145 | }
146 | ```
147 |
148 |
149 | ???+ example
150 |
151 | ```bash title='Subscribe Value Path'
152 | Observation.Value
153 | ```
154 |
155 | ```json title='Payload'
156 | {
157 | "Observation": {
158 | "Value": 42
159 | }
160 | }
161 | ```
162 |
163 |
164 | # Subscribe Timestamp Path
165 |
166 | This option specifies the location within a json payload where the `Timestamp` should be, with a period (`.`) delimiting levels (i.e., dictionaries) within the payload.
167 |
168 | If `Subscribe Timestamp Path` is defined and a payload is received which is 1) not deserializable json or 2) does not contain the `Timestamp` at the specified location, the payload is __silently discarded__.
169 |
170 | If `Subscribe Timestamp Path` is not defined, a `Timestamp` is not checked for in received payloads, and the current time is assigned as the `Timestamp` in stead. However, a `Value` at the `Subscribe Value Path` must still be found.
171 |
172 | ???+ info "Future Timestamps"
173 |
174 | The Ignition SDK automatically changes any future timestamps (i.e., timestamps which indicate a time in the future)
175 | to the current time. As a result, if `Subscribe Timestamp Path` is set and a payload contains a future timestamp,
176 | the timestamp will be __silently__ adjusted to the time that the payload was received.
177 |
178 | ???+ example
179 |
180 | ```bash title='Subscribe Timestamp Path'
181 | Timestamp
182 | ```
183 |
184 | ```json title='Payload'
185 | {
186 | "Timestamp": "2025-01-01T00:00:00.000+00:00"
187 | }
188 | ```
189 |
190 |
191 | ???+ example
192 |
193 | ```bash title='Subscribe Timestamp Path'
194 | Observation.Timestamp
195 | ```
196 |
197 | ```json title='Payload'
198 | {
199 | "Observation": {
200 | "Timestamp": "2025-01-01T00:00:00.000+00:00"
201 | }
202 | }
203 | ```
204 |
205 |
206 | # Subscribe Timestamp Integer Format
207 |
208 | When decoding a `Timestamp`, strings are assumed to be ISO8601 format, and doubles are assumed to be seconds since the Unix Epoch (January 1, 1970). However, two formats/precisions are commonly represented by integers:
209 |
210 | 1. Seconds since the Unix Epoch: `UnixEpochSeconds`
211 | 2. Nanoseconds since the Unix Epoch `UnixEpochNanoseconds`
212 |
213 | This option specifies which format integer `Timestamp`s should be decoded as.
214 |
215 | | Json Type | Input | Assumed Format/Precision |
216 | | --------- | ----- | ------------------------ |
217 | | String | "2025-01-01T00:00:00.000+00:00" | ISO8601 |
218 | | Double | 1735707600.000000000 | DoubleUnixEpochSeconds |
219 | | Integer | 1735707600 | :fontawesome-solid-triangle-exclamation: `Unknown` |
220 | | Integer | 1735707600000000000 | :fontawesome-solid-triangle-exclamation: `Unknown` |
221 |
222 | ???+ example
223 |
224 | ```bash title='Subscribe Timestamp Integer Format'
225 | UnixEpochSeconds
226 | ```
227 |
228 | ```json title='Payload'
229 | {
230 | "Timestamp": 1735707600
231 | }
232 | ```
233 |
234 |
235 | ???+ example
236 |
237 | ```bash title='Subscribe Timestamp Integer Format'
238 | UnixEpochNanoseconds
239 | ```
240 |
241 | ```json title='Payload'
242 | {
243 | "Timestamp": 1735707600000000000
244 | }
245 | ```
246 |
247 |
248 |
--------------------------------------------------------------------------------
/docs/src/Pages/Getting Started/01-Overview.md:
--------------------------------------------------------------------------------
1 | ---
2 | title: 'Overview'
3 | ---
4 |
5 | # Demo
6 |
7 | [Real-Time Manufacturing Datasets](https://github.com/m-r-mccormick/Real-Time-Manufacturing-Datasets){target=_blank} is a educational resource which utilizes this module and includes pre-configured tooling (i.e., docker-compose stacks) and manufacturing datasets. As such, it is a useful resource for learning to use this module.
8 |
9 | # Tag Providers
10 |
11 | This module uses two distinct tag providers, one dedicated to publishing tag changes to the broker (`Publish-Only Tag Provider`), and another for subscribing to changes in the broker (`Subscribe-Only Tag Provider`).
12 |
13 | The `Subscribe-Only Tag Provider` is automatically populated with tags. The path to each tag (i.e., `Tag Path`) is derived from the `Topic` of the received payload. By extension, parent folders are automatically created in the `Subscribe-Only Tag Provider` to house the created tags.
14 |
15 | Conversely, the `Publish-Only Tag Provider` must be manually constructed by adding folders and tags. However, any changes made to tags in the `Publish-Only Tag Provider` are automatically published to the broker. In addition, the `Topic` which the change is published to is derived from the `Tag Path` in the `Publish-Only Tag Provider`.
16 |
17 | Consequently, the `Publish-Only Tag Provider`, broker, and `Subscribe-Only Tag Provider` inherently share the same structure, which is illustrated using the ISA95 hierarchy of `Enterprise`, `Site`, `Area`, `Line`, `Cell`, `...` below:
18 |
19 |
20 |
21 | - :fontawesome-solid-tag:{ .middle } __Publish-Only Tag Provider__{.lg .middle}
22 |
23 | ---
24 |
25 | ```yaml
26 | [Pub-Only Root]
27 | └─E1: [Folder]
28 | │ └─S1: [Folder]
29 | │ │ └─A1: [Folder]
30 | │ │ │ └─L1: [Folder]
31 | │ │ │ │ └─C1: [Folder]
32 | │ │ │ │ │ └─V1: [Tag]
33 | │ │ │ │ │ └─V2: [Tag]
34 | │ │ │ │ └─C2: [Folder]
35 | │ │ │ │ │ └─V1: [Tag]
36 | │ │ │ │ │ └─V2: [Tag]
37 | │ │ │ └─L2: [Line]
38 | │ │ │ │ └─C1: [Folder]
39 | │ │ │ │ │ └─V1: [Tag]
40 | │ │ │ │ │ └─V2: [Tag]
41 | │ │ │ │ └─C2: [Folder]
42 | │ │ │ │ │ └─V1: [Tag]
43 | │ │ │ │ │ └─V2: [Tag]
44 | ```
45 |
46 | - :fontawesome-solid-circle-nodes:{ .middle } __MQTT Broker__{.lg .middle}
47 |
48 | ---
49 |
50 | ```yaml
51 | [MQTT Topic Root]
52 | └─E1: [Enterprise]
53 | │ └─S1: [Site]
54 | │ │ └─A1: [Area]
55 | │ │ │ └─L1: [Line]
56 | │ │ │ │ └─C1: [Cell]
57 | │ │ │ │ │ └─V1: [Variable]
58 | │ │ │ │ │ └─V2: [Variable]
59 | │ │ │ │ └─C2: [Cell]
60 | │ │ │ │ │ └─V1: [Variable]
61 | │ │ │ │ │ └─V2: [Variable]
62 | │ │ │ └─L2: [Line]
63 | │ │ │ │ └─C1: [Cell]
64 | │ │ │ │ │ └─V1: [Variable]
65 | │ │ │ │ │ └─V2: [Variable]
66 | │ │ │ │ └─C2: [Cell]
67 | │ │ │ │ │ └─V1: [Variable]
68 | │ │ │ │ │ └─V2: [Variable]
69 | ```
70 |
71 | - :fontawesome-solid-tag:{ .middle } __Subscribe-Only Tag Provider__{.lg .middle}
72 |
73 | ---
74 |
75 | ```yaml
76 | [Sub-Only Root]
77 | └─E1: [Folder]
78 | │ └─S1: [Folder]
79 | │ │ └─A1: [Folder]
80 | │ │ │ └─L1: [Folder]
81 | │ │ │ │ └─C1: [Folder]
82 | │ │ │ │ │ └─V1: [Tag]
83 | │ │ │ │ │ └─V2: [Tag]
84 | │ │ │ │ └─C2: [Folder]
85 | │ │ │ │ │ └─V1: [Tag]
86 | │ │ │ │ │ └─V2: [Tag]
87 | │ │ │ └─L2: [Line]
88 | │ │ │ │ └─C1: [Folder]
89 | │ │ │ │ │ └─V1: [Tag]
90 | │ │ │ │ │ └─V2: [Tag]
91 | │ │ │ │ └─C2: [Folder]
92 | │ │ │ │ │ └─V1: [Tag]
93 | │ │ │ │ │ └─V2: [Tag]
94 | ```
95 |
96 |
97 |
98 |
99 |
100 | - :fontawesome-solid-arrow-right:{ .middle } __Publish-Only Tag Provider To Broker__{.lg .middle}
101 |
102 | ---
103 |
104 | | Tag Path | Topic |
105 | | -------- | ----- |
106 | | `E1/S1/A1/L2/C1/V2` | `E1/S1/A1/L2/C1/V2` |
107 |
108 | - :fontawesome-solid-arrow-right:{ .middle } __Broker To Subscribe-Only Tag Provider__{.lg .middle}
109 |
110 | ---
111 |
112 | | Topic | Tag Path |
113 | | -------- | ----- |
114 | | `E1/S1/A1/L2/C1/V2` | `E1/S1/A1/L2/C1/V2` |
115 |
116 |
117 |
118 | # Data Representations
119 |
120 | This module only supports payloads which are in JavaScript Object Notation (JSON) format. Payloads __must__ contain a `Value`, and __may__ contain a `Timestamp` (depending on configuration options). The location of each within a payload is configurable via a `Value Path` and `Timestamp Path`.
121 |
122 | ???+ example "Flat Example"
123 |
124 | ```bash title='Configuration'
125 | Value_Path="Value"
126 | Timestamp_Path="Timestamp"
127 | ```
128 |
129 | ```json title='Payload'
130 | {
131 | "Value": 42,
132 | "Timestamp": "2025-01-01T00:00:00.000+00:00"
133 | }
134 | ```
135 |
136 |
137 | ???+ example "Nested Example"
138 |
139 | ```bash title='Configuration'
140 | Value_Path="Observation.Value"
141 | Timestamp_Path="Observation.Timestamp"
142 | ```
143 |
144 | ```json title='Payload'
145 | {
146 | "Observation": {
147 | "Value": 42,
148 | "Timestamp": "2025-01-01T00:00:00.000+00:00"
149 | }
150 | }
151 | ```
152 |
153 |
154 | ??? example "Mixed Example"
155 |
156 | ```bash title='Configuration'
157 | Value_Path="Value"
158 | Timestamp_Path="Observation.Timestamp"
159 | ```
160 |
161 | ```json title='Payload'
162 | {
163 | "Value": 42
164 | "Observation": {
165 | "Timestamp": "2025-01-01T00:00:00.000+00:00"
166 | }
167 | }
168 | ```
169 |
170 |
171 | These values are indepdently configurable for both publishing and subscribing. While the `Publish Timestamp Path` is required and is always published, the `Subscribe Timestamp Path` is optional. If the `Subscribe Timestamp Path` is specified and is not present in a received payload, the payload is __silently discarded__. If the `Subscribe Timestamp Path` is not specified, the time of receipt of a payload is assigned as the `Timestamp`, and whether the payload contains a `Timestamp` is disregarded. So, If the `Subscribe Timestamp Path` is not specified, as long as a `Value` is present at the `Subscribe Value Path`, the associated `Tag` will be updated accordingly.
172 |
173 |
--------------------------------------------------------------------------------
/docs/src/assets/hydra.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/m-r-mccormick/Hydra-MQTT/6b18f26a0f3f90a325c0e20d8cbb0e09f6406f1a/docs/src/assets/hydra.png
--------------------------------------------------------------------------------
/docs/src/index.md:
--------------------------------------------------------------------------------
1 |
10 |
11 |
24 |
25 |
26 |
27 | - :fontawesome-brands-github:{ .lg .middle } __Github Repository__{.lg .middle}
28 |
29 | ---
30 |
31 |
32 | Install Hydra-MQTT and start utilizing MQTT data.
33 |
34 |
35 |
36 | [Releases :fontawesome-solid-box:](https://github.com/m-r-mccormick/Hydra-MQTT/releases){ target=_blank .md-button .md-button--primary }
37 |
38 | [Source :fontawesome-solid-code:](https://github.com/m-r-mccormick/Hydra-MQTT/){ target=_blank .md-button .md-button--primary }
39 |
40 |
41 |
42 |
43 |
44 |
45 | - :material-clock-fast:{ .lg .middle } __Set It Up__{.lg .middle}
46 |
47 | ---
48 |
49 |
50 | Get up and running in minutes.
51 |
52 |
53 |
54 | [Getting Started :fontawesome-solid-play:](Pages/Getting Started/01-Overview.md){ .md-button }
55 |
56 |
57 | - :fontawesome-solid-thumbtack:{ .middle } __Brass Tacks__{.lg .middle}
58 |
59 | ---
60 |
61 |
62 | Dig into the details.
63 |
64 |
65 |
66 | [Configuration :fontawesome-solid-gear:](Pages/Configuration/01-Broker.md){ .md-button }
67 |
68 |
69 |
70 |
71 |
72 |
73 | - :fontawesome-solid-at:{ .middle } __Author__{.lg .middle}
74 |
75 | ---
76 |
77 |
78 | Learn more about the author and related research.
79 |
80 |
81 |
82 | [Linkedin :fontawesome-brands-linkedin:](https://www.linkedin.com/in/m-r-mccormick/){ target=_blank .md-button }
83 |
84 | [GitHub :fontawesome-brands-github-alt:](https://github.com/m-r-mccormick/){ target=_blank .md-button }
85 |
86 | [ResearchGate :fontawesome-brands-researchgate:](https://www.researchgate.net/profile/M-Mccormick-4/research){ target=_blank .md-button }
87 |
88 |
89 |
90 |
--------------------------------------------------------------------------------
/docs/src/stylesheets/scheme.css:
--------------------------------------------------------------------------------
1 |
2 | [data-md-color-scheme="custom"] {
3 |
4 | /* Color Definitions */
5 | --md-default-fg-color: #BBBBBB; /* Body Text */
6 | --md-default-fg-color--light: #BEBEBE;
7 | --md-default-fg-color--lighter: #DDDDDD;
8 | --md-default-fg-color--lightest: #FFFFFF;
9 | --md-default-bg-color--darkest: #111111;
10 | --md-default-bg-color--darker: #222222;
11 | --md-default-bg-color--dark: #292929;
12 | --md-default-bg-color: #333333; /* Body Background */
13 | /* Blue Accents */
14 | --md-primary-fg-color: #6485ce; /* #6485ce */
15 | --md-accent-fg-color: #1c4c8e; /* #2a6bc7 */
16 |
17 | /* Main, Header, Tabs, and Footer */
18 | --md-main-bg-color: var(--md-default-bg-color);
19 | --md-header-fg-color: var(--md-default-fg-color--light);
20 | --md-header-bg-color: var(--md-default-bg-color--darker);
21 | --md-header-search-fg-color: var(--md-default-fg-color);
22 | --md-header-search-fg-input-color: var(--md-default-fg-color--light);
23 | --md-header-search-icon-color: var(--md-default-fg-color);
24 | --md-header-search-bg-color: var(--md-default-bg-color);
25 | --md-header-search-highlight-color: var(--md-accent-fg-color);
26 | --md-header-search-output-bg-color: var(--md-default-bg-color--darker);
27 | --md-header-topbutton-fg-color: var(--md-default-fg-color--lighter);
28 | --md-header-topbutton-fg-hover-color: var(--md-default-fg-color--lightest);
29 | --md-header-topbutton-bg-color: var(--md-default-bg-color--dark);
30 | --md-header-topbutton-bg-hover-color: var(--md-default-bg-color--darker);
31 | --md-tabs-fg-color: var(--md-default-fg-color--lighter);
32 | --md-tabs-fg-active-color: var(--md-primary-fg-color);
33 | --md-tabs-fg-hover-color: var(--md-accent-fg-color);
34 | --md-tabs-bg-color: var(--md-default-bg-color--dark);
35 | --md-footer-fg-color: var(--md-default-fg-color);
36 | --md-footer-bg-color: var(--md-default-bg-color--darker);
37 | --md-footer-fg-color: #666666;
38 | --md-footer-fg-link-color: #666666;
39 | --md-footer-fg-link-hover-color: #777777;
40 | --md-footer-visible: visible; /* hidden */
41 |
42 | /* Nav and TOC */
43 | --md-nav-fg-color: var(--md-default-fg-color);
44 | --md-nav-fg-hover-color: var(--md-accent-fg-color);
45 | --md-nav-fg-nested-color: var(--md-default-fg-color);
46 | --md-nav-fg-nested-decoration: underline;
47 | --md-nav-fg-nested-decoration-color: #555555;
48 | --md-nav-fg-active-color: var(--md-primary-fg-color);
49 |
50 | /* Table of Contents (TOC) */
51 | --md-toc-fg-title-color: var(--md-primary-fg-color);
52 | --md-toc-fg-title-decoration: underline;
53 | --md-toc-fg-title-decoration-color: #555555;
54 | --md-toc-fg-hover-color: var(--md-accent-fg-color);
55 | --md-toc-fg-active-above-color: var(--md-default-fg-color);
56 | --md-toc-fg-active-color: var(--md-primary-fg-color);
57 | --md-toc-fg-active-below-color: var(--md-default-fg-color--lighter);
58 |
59 | /* Body */
60 | --md-typeset-color: var(--md-default-fg-color);
61 | --md-typeset-line-height: 30px; /* 18 */
62 | --md-typeset-font-size: 18px; /* 15 */
63 | --md-typeset-font-size-h1: 34px;
64 | --md-typeset-font-size-h2: 27px;
65 | --md-typeset-font-size-h3: 24px;
66 | --md-typeset-font-size-h4: 21px;
67 | --md-typeset-font-size-h5: 20px;
68 | --md-typeset-font-size-h6: 19px;
69 | --md-typeset-font-size-cr: 10px;
70 | --md-typeset-a-color: var(--md-primary-fg-color);
71 | --md-typeset-a-hover-color: var(--md-accent-fg-color);
72 | --md-typeset-h-color: var(--md-primary-fg-color); /* var(--md-default-fg-color--lighter); */
73 | --md-typeset-h-decoration: none; /* underline */
74 | --md-typeset-h-decoration-color: #555555;
75 | --md-typeset-h-line-height: 40px;
76 | --md-typeset-h-margin-top: 30px;
77 | --md-typeset-h-margin-bottom: 20px;
78 | --md-typeset-h1-color: var(--md-default-fg-color);
79 | --md-typeset-h1-decoration: none; /* underline */
80 | --md-typeset-h1-decoration-color: var(--md-default-fg-color);
81 | --md-typeset-h2-decoration: underline;
82 | --md-typeset-h2-decoration-color: var(--md-primary-fg-color);
83 |
84 | /* Admonition */
85 | --md-admonition-fg-color: var(--md-default-fg-color);
86 | --md-admonition-bg-color: var(--md-default-bg-color);
87 |
88 | /* Annotations */
89 | --md-annotation-fg-color: var(--md-default-fg-color);
90 | --md-annotation-bg-color: var(--md-primary-fg-color);
91 | --md-annotation-bg-highlight-color: var(--md-accent-fg-color);
92 | --md-annotation-tooltip-fg-color: var(--md-default-fg-color);
93 | --md-annotation-tooltip-bg-color: var(--md-default-bg-color--darker);
94 |
95 | /* Buttons */
96 |
97 | /* Code */
98 | --md-code-fg-color: var(--md-default-fg-color); /* Default Code Color */
99 | --md-code-bg-color: var(--md-default-bg-color--dark); /* Code Background */
100 | --md-code-clipboard-color: var(--md-code-bg-color);
101 | --md-code-clipboard-hover-color: var(--md-accent-fg-color);
102 | --md-code-dialog-fg-color: var(--md-default-fg-color--light);
103 | --md-code-dialog-bg-color: var(--md-default-bg-color--darker);
104 | --md-code-table-divider-color: var(--md-primary-fg-color);
105 |
106 | /* Code Highlighting */
107 | --md-code-hl-color: rgba(119, 157, 227, 0.15); /* hl_lines="2 3" color */
108 | --md-code-hl-number-color: hsla(6, 74%, 63%, 1);
109 | --md-code-hl-special-color: hsla(340, 83%, 66%, 1);
110 | --md-code-hl-function-color: hsla(291, 57%, 65%, 1);
111 | --md-code-hl-constant-color: hsla(250, 62%, 70%, 1);
112 | --md-code-hl-keyword-color: hsla(219, 66%, 64%, 1);
113 | --md-code-hl-string-color: hsla(150, 58%, 44%, 1);
114 | --md-code-hl-name-color: var(--md-code-fg-color);
115 | --md-code-hl-operator-color: var(--md-default-fg-color--light);
116 | --md-code-hl-punctuation-color: var(--md-default-fg-color--light);
117 | --md-code-hl-comment-color: rgb(59, 190, 98);
118 | --md-code-hl-generic-color: rgb(240, 176, 134); /* var(--md-default-fg-color--light); */
119 | --md-code-hl-variable-color: rgb(107, 184, 219); /* Bash variable color */
120 |
121 | /* Content Tabs */
122 | --md-contenttab-active-color: var(--md-primary-fg-color);
123 | --md-contenttab-color: var(--md-primary-fg-color);
124 |
125 | /* Data Tables */
126 | --md-table-header-color: var(--md-primary-fg-color);
127 | --md-table-border-color: var(--md-default-fg-color);/* var(--md-primary-fg-color); */
128 |
129 | /* Diagrams */
130 | --md-mermaid-node-fg-color: var(--md-primary-fg-color);
131 | --md-mermaid-node-bg-color: var(--md-default-bg-color--dark);
132 | --md-mermaid-label-bg-color: var(--md-default-bg-color--dark);
133 |
134 | /* Footnotes */
135 |
136 | /* Formatting */
137 | /*--md-typeset-mark-color: purple;*/
138 | --md-typeset-fg-color: var(--md-default-bg-color--darkest);
139 | --md-typeset-mark-color: var(--md-primary-fg-color); /* Highlight Color */
140 | /*// Typeset `kbd` color shades*/
141 | --md-typeset-kbd-color: var(--md-default-bg-color); /* Keyboard Highlight */
142 | --md-typeset-kbd-accent-color: var(--md-default-bg-color--dark); /* Keyboard Background */
143 | --md-typeset-kbd-border-color: var(--md-default-bg-color--darker);
144 | /*// Typeset `table` color shades*/
145 | /*--md-typeset-table-color: yellow;*/
146 |
147 | /* Grids */
148 | --md-grid-border-color: var(--md-default-fg-color);/* var(--md-primary-fg-color); */
149 |
150 |
151 | /* Icons, Emojis */
152 |
153 | /* Images */
154 |
155 | /* Lists */
156 | --md-checkbox-checked-bg-color: var(--md-primary-fg-color);
157 | --md-checkbox-unchecked-bg-color: var(--md-default-fg-color);
158 |
159 | /* MathJax */
160 |
161 | /* Tooltips */
162 |
163 | /* Shadow depth 1*/
164 | --md-shadow-z1: 0 #{px2rem(4px)} #{px2rem(10px)} hsla(0, 0%, 0%, 0.2),
165 | 0 0 #{px2rem(1px)} hsla(0, 0%, 0%, 0.1);
166 |
167 | /* Shadow depth 2*/
168 | --md-shadow-z2: 0 #{px2rem(4px)} #{px2rem(10px)} hsla(0, 0%, 0%, 0.3),
169 | 0 0 #{px2rem(1px)} hsla(0, 0%, 0%, 0.25);
170 |
171 | /* Shadow depth 3*/
172 | --md-shadow-z3: 0 #{px2rem(4px)} #{px2rem(10px)} hsla(0, 0%, 0%, 0.4),
173 | 0 0 #{px2rem(1px)} hsla(0, 0%, 0%, 0.35);
174 |
175 |
176 | }
177 |
178 | /* Switching in progress - disable all transitions temporarily */
179 | [data-md-color-switching] *,
180 | [data-md-color-switching] *::before,
181 | [data-md-color-switching] *::after {
182 | transition-duration: 0ms !important; /* stylelint-disable-line */
183 | }
184 |
185 | /* Main, Header, Tabs, and Footer */
186 | .md-nav
187 | {
188 | font-size: var(--md-typeset-font-size);
189 | }
190 | /*html **/
191 | /*{*/
192 | /* font-size: var(--md-typeset-font-size);*/
193 | /*}*/
194 | .md-main {
195 | background-color: var(--md-main-bg-color);
196 | }
197 | .md-header {
198 | color: var(--md-header-fg-color);
199 | background-color: var(--md-header-bg-color);
200 | }
201 | .md-search__input + .md-search__icon {
202 | color: var(--md-header-search-icon-color);
203 | }
204 | .md-search__input {
205 | background-color: var(--md-header-search-bg-color);
206 | --md-primary-bg-color--light: var(--md-header-search-fg-color); /* No Typed Text Color */
207 | color: var(--md-header-search-fg-input-color); /* Typed Text Color */
208 | }
209 | .md-search-result .md-typeset mark {
210 | color: var(--md-header-search-highlight-color);
211 | /*--md-typeset-fg-color: var(--md-header-search-highlight-color);*/
212 | }
213 | .md-search__output {
214 | --md-default-fg-color--lightest: var(--md-header-search-output-bg-color);
215 | }
216 | .md-search-result__meta {
217 | color: var(--md-header-search-fg-color);
218 | }
219 | .md-tabs {
220 | background-color: var(--md-tabs-bg-color);
221 | color: var(--md-tabs-fg-color);
222 | }
223 | .md-tabs__link--active {
224 | color: var(--md-tabs-fg-active-color);
225 | }
226 | .md-tabs__link:hover {
227 | color: var(--md-tabs-fg-hover-color);
228 | }
229 | .md-footer {
230 | background-color: var(--md-footer-bg-color);
231 | }
232 | .md-copyright {
233 | font-size: var(--md-typeset-font-size-cr);
234 | color: var(--md-footer-fg-color);
235 | /*visibility: hidden;*/
236 | }
237 | html .md-footer-meta.md-typeset {
238 | color: var(--md-footer-fg-color);
239 | }
240 | html .md-footer-meta.md-typeset a {
241 | color: var(--md-footer-fg-link-color);
242 | text-decoration: underline;
243 | }
244 | html .md-footer-meta.md-typeset a:hover {
245 | color: var(--md-footer-fg-link-hover-color);
246 | }
247 | .md-top {
248 | color: var(--md-header-topbutton-fg-color);
249 | background-color: var(--md-header-topbutton-bg-color);
250 | }
251 | .md-top:hover {
252 | color: var(--md-header-topbutton-fg-hover-color);
253 | background-color: var(--md-header-topbutton-bg-hover-color);
254 | }
255 | .md-footer {
256 | visibility: var(--md-footer-visible);
257 | }
258 | .md-footer-meta__inner md-grid,
259 | .md-footer-meta__inner,
260 | .md-copyright {
261 | padding-top: 2px;
262 | padding-bottom: 2px;
263 | width: 100%;
264 | }
265 | .md-copyright a {
266 | color: red;
267 | }
268 | .md-copyright-table {
269 | width: 100%;
270 | }
271 | .md-copyright-table__right {
272 | text-align: right;
273 | }
274 | .md-social {
275 | padding-top: 2px;
276 | padding-bottom: 2px;
277 | }
278 | .md-social__link {
279 | padding-top: 2px;
280 | padding-bottom: 2px;
281 | }
282 |
283 | /* Nav */
284 | .md-nav__item--nested > .md-nav__link {
285 | color: var(--md-nav-fg-nested-color);
286 | text-decoration: var(--md-nav-fg-nested-decoration);
287 | text-decoration-color: var(--md-nav-fg-nested-decoration-color);
288 | }
289 | .md-nav__link {
290 | color: var(--md-nav-fg-color); /* Nav + TOC Non-Active */
291 | /*--md-default-fg-color--light: var(--md-nav-fg-color); !* TOC Below Active *!*/
292 | /*--md-primary-fg-color: orange; */
293 | }
294 | .md-nav__link:hover {
295 | color: var(--md-nav-fg-hover-color); /* Nav + TOC Non-Active */
296 | --md-accent-fg-color: var(--md-nav-fg-hover-color);
297 | /*--md-default-fg-color--light: var(--md-nav-fg-color); !* TOC Below Active *!*/
298 | /*--md-primary-fg-color: orange; */
299 | }
300 | .md-nav__item--section > .md-nav__link[for] {
301 | /* Nav items with no index with navigation.sections enabled in config */
302 | color: var(--md-nav-fg-nested-color); /* Nav No Index */
303 | }
304 | .md-sidebar .md-nav__link--active {
305 | color: var(--md-nav-fg-active-color); /* Nav Active */
306 | }
307 | /* Mobile Form Factor Nav */
308 | .md-nav--primary .md-nav__title[for="__drawer"] {
309 | background-color: var(--md-header-bg-color);
310 | }
311 | .md-nav--primary .md-nav__title {
312 | background-color: var(--md-header-bg-color);
313 | }
314 | .md-nav--primary .md-nav__title[for="__drawer"] {
315 | visibility: hidden;
316 | height: 0;
317 | }
318 |
319 |
320 | /* TOC */
321 | .md-nav--secondary .md-nav__title {
322 | color: var(--md-toc-fg-title-color); /* TOC Title */
323 | text-decoration: var(--md-toc-fg-title-decoration);
324 | text-decoration-color: var(--md-toc-fg-title-decoration-color);
325 | }
326 | .md-nav--secondary .md-nav__item .md-nav__link--active {
327 | color: var(--md-toc-fg-active-color); /* TOC Active */
328 | }
329 | .md-nav--secondary .md-nav__link {
330 | color: var(--md-toc-fg-active-below-color);
331 | }
332 | .md-nav--secondary .md-nav__link--passed {
333 | color: var(--md-toc-fg-active-above-color); /* TOC Above Active */
334 | }
335 | .md-nav--secondary .md-nav__link:hover {
336 | color: var(--md-toc-fg-hover-color);
337 | }
338 | .md-sidebar__scrollwrap {
339 | --md-default-fg-color--lighter: var(--md-primary-fg-color);
340 | }
341 |
342 |
343 | /* Body */
344 | .md-typeset {
345 | font-size: var(--md-typeset-font-size);
346 | line-height: var(--md-typeset-line-height);
347 | }
348 | .md-typeset a:hover {
349 | color: var(--md-typeset-a-color);
350 | }
351 | .md-typeset a:hover {
352 | color: var(--md-typeset-a-hover-color);
353 | }
354 | .md-typeset h5 {
355 | color: var(--md-default-fg-color);
356 | }
357 | .md-typeset h1, .md-typeset h2, .md-typeset h3,
358 | .md-typeset h4, .md-typeset h5, .md-typeset h6 {
359 | color: var(--md-typeset-h-color);
360 | text-decoration: var(--md-typeset-h-decoration);
361 | text-decoration-color: var(--md-typeset-h-decoration-color);
362 | font-weight: 400;
363 | text-transform: none;
364 | }
365 | .md-typeset h1 {
366 | font-size: var(--md-typeset-font-size-h1);
367 | line-height: var(--md-typeset-h-line-height);
368 | margin-top: var(--md-typeset-h-margin-top);
369 | margin-bottom: var(--md-typeset-h-margin-bottom);
370 | text-align: center;
371 | /* Second var argument is fallback value */
372 | color: var(--md-typeset-h1-color, --md-typeset-h-color);
373 | text-decoration: var(--md-typeset-h1-decoration, --md-typeset-h-decoration);
374 | text-decoration-color: var(--md-typeset-h1-decoration-color, --md-typeset-h-decoration);
375 | }
376 | .md-typeset h2 {
377 | font-size: var(--md-typeset-font-size-h2);
378 | line-height: var(--md-typeset-h-line-height);
379 | margin-top: var(--md-typeset-h-margin-top);
380 | margin-bottom: var(--md-typeset-h-margin-bottom);
381 | text-decoration: var(--md-typeset-h2-decoration, --md-typeset-h-decoration);
382 | text-decoration-color: var(--md-typeset-h2-decoration-color, --md-typeset-h-decoration);
383 | }
384 | .md-typeset h3 {
385 | font-size: var(--md-typeset-font-size-h3);
386 | line-height: var(--md-typeset-h-line-height);
387 | margin-top: var(--md-typeset-h-margin-top);
388 | margin-bottom: var(--md-typeset-h-margin-bottom);;
389 | }
390 | .md-typeset h4 {
391 | font-size: var(--md-typeset-font-size-h4);
392 | line-height: var(--md-typeset-h-line-height);
393 | margin-top: var(--md-typeset-h-margin-top);
394 | margin-bottom: var(--md-typeset-h-margin-bottom);
395 | }
396 | .md-typeset h5 {
397 | font-size: var(--md-typeset-font-size-h5);
398 | line-height: var(--md-typeset-h-line-height);
399 | margin-top: var(--md-typeset-h-margin-top);
400 | margin-bottom: var(--md-typeset-h-margin-bottom);
401 | }
402 | .md-typeset h6 {
403 | font-size: var(--md-typeset-font-size-h6);
404 | line-height: var(--md-typeset-h-line-height);
405 | margin-top: var(--md-typeset-h-margin-top);
406 | margin-bottom: var(--md-typeset-h-margin-bottom);
407 | }
408 |
409 | /* Annotations */
410 | .md-typeset pre > code {
411 | color: var(--md-annotation-fg-color);
412 | --md-default-fg-color--lighter: var(--md-annotation-bg-color);
413 | }
414 | .md-typeset pre > code:hover {
415 | --md-default-fg-color--lighter: var(--md-annotation-bg-highlight-color);
416 | }
417 | .md-tooltip {
418 | color: var(--md-annotation-tooltip-fg-color);
419 | background-color: var(--md-annotation-tooltip-bg-color);
420 | }
421 |
422 | /* Buttons */
423 |
424 | /* Code */
425 | .md-clipboard {
426 | color: var(--md-code-clipboard-color);
427 | }
428 | .md-clipboard:hover {
429 | color: var(--md-code-clipboard-hover-color);
430 | }
431 | .md-dialog__inner {
432 | color: var(--md-code-dialog-fg-color);
433 | }
434 | .md-dialog {
435 | background-color: var(--md-code-dialog-bg-color);
436 | }
437 | .highlighttable {
438 | --md-default-fg-color--lightest: var(--md-code-table-divider-color);
439 | }
440 | .md-typeset hr {
441 | --md-default-fg-color--lightest: var(--md-code-table-divider-color);
442 | }
443 | .highlight span.filename {
444 | /* Code Block Title Bottom Border */
445 | border-bottom-color: var(--md-code-table-divider-color);
446 | }
447 | .md-typeset .highlight + .result {
448 | /* Border Around "Result" Blocks */
449 | border: .5rem solid var(--md-code-bg-color);
450 | /* padding: 0.25rem; */
451 | padding-left: 0.5rem;
452 | padding-right: 0.5rem;
453 | padding-top: 0.25rem;
454 | padding-bottom: 0.25rem;
455 | }
456 | .md-typeset .result .highlight pre,
457 | .md-typeset .result .highlight span {
458 | /* Remove margins around "Result"
blocks
459 | so padding can be set by "Result"
block */
460 | /*margin-left: 0.25em;
461 | margin-right: 0.25em;
462 | margin-top: 0.125rem;
463 | margin-bottom: 0.125rem;*/
464 | margin: 0;
465 | }
466 | .md-typeset .result .highlight {
467 | padding: 0.25rem;
468 | padding-left: 0;
469 | padding-right: 0;
470 | padding-top: 0.25rem;
471 | padding-bottom: 0.25rem;
472 | }
473 |
474 | /* Code Highlighting */
475 |
476 | /* Content Tabs */
477 | .md-typeset .tabbed-set > input:first-child:checked ~ .tabbed-labels > :first-child, .md-typeset .tabbed-set > input:nth-child(10):checked ~ .tabbed-labels > :nth-child(10), .md-typeset .tabbed-set > input:nth-child(11):checked ~ .tabbed-labels > :nth-child(11), .md-typeset .tabbed-set > input:nth-child(12):checked ~ .tabbed-labels > :nth-child(12), .md-typeset .tabbed-set > input:nth-child(13):checked ~ .tabbed-labels > :nth-child(13), .md-typeset .tabbed-set > input:nth-child(14):checked ~ .tabbed-labels > :nth-child(14), .md-typeset .tabbed-set > input:nth-child(15):checked ~ .tabbed-labels > :nth-child(15), .md-typeset .tabbed-set > input:nth-child(16):checked ~ .tabbed-labels > :nth-child(16), .md-typeset .tabbed-set > input:nth-child(17):checked ~ .tabbed-labels > :nth-child(17), .md-typeset .tabbed-set > input:nth-child(18):checked ~ .tabbed-labels > :nth-child(18), .md-typeset .tabbed-set > input:nth-child(19):checked ~ .tabbed-labels > :nth-child(19), .md-typeset .tabbed-set > input:nth-child(2):checked ~ .tabbed-labels > :nth-child(2), .md-typeset .tabbed-set > input:nth-child(20):checked ~ .tabbed-labels > :nth-child(20), .md-typeset .tabbed-set > input:nth-child(3):checked ~ .tabbed-labels > :nth-child(3), .md-typeset .tabbed-set > input:nth-child(4):checked ~ .tabbed-labels > :nth-child(4), .md-typeset .tabbed-set > input:nth-child(5):checked ~ .tabbed-labels > :nth-child(5), .md-typeset .tabbed-set > input:nth-child(6):checked ~ .tabbed-labels > :nth-child(6), .md-typeset .tabbed-set > input:nth-child(7):checked ~ .tabbed-labels > :nth-child(7), .md-typeset .tabbed-set > input:nth-child(8):checked ~ .tabbed-labels > :nth-child(8), .md-typeset .tabbed-set > input:nth-child(9):checked ~ .tabbed-labels > :nth-child(9) {
478 | color: var(--md-contenttab-active-color);
479 | }
480 | .js .md-typeset .tabbed-labels::before {
481 | --md-default-fg-color--lightest: cyan;
482 | background-color: var(--md-contenttab-active-color);
483 | height: 3px;
484 | }
485 | .tabbed-labels {
486 | --md-default-fg-color--lightest: var(--md-contenttab-color);
487 | }
488 |
489 | /* Data Tables */
490 | .md-typeset table:not([class]) {
491 | --md-typeset-table-color: var(--md-table-border-color);
492 | }
493 | .md-typeset__table thead {
494 | color: var(--md-default-fg-color);
495 | border-color: var(--md-default-fg-color);
496 | }
497 |
498 | /* Diagrams */
499 |
500 | /* Footnotes */
501 |
502 | /* Formatting */
503 | .md-typeset mark {
504 | color: var(--md-typeset-fg-color);
505 | padding: +2px;
506 | /*background-color: red;*/
507 | }
508 |
509 | /* Grids */
510 | .md-typeset .grid.cards ul li {
511 | color: var(--md-default-fg-color);
512 | border-color: var(--md-default-fg-color);
513 | }
514 | .md-typeset .grid.cards ul li:hover {
515 | border-color: var(--md-default-fg-color--lightest);
516 | }
517 | .md-typeset .grid.cards.custom { /* Custom Grid */
518 | grid-template-columns: .5fr .5fr .5fr
519 | }
520 | .md-typeset .grid.cards.custom p { /* Custom Grid */
521 | /*line-height: 15px;*/
522 | /*font-size: 12px;*/
523 | height: 60px;
524 | }
525 | .md-typeset .grid.cards.custom code { /* Custom Grid */
526 | font-size: 11px;
527 | }
528 |
529 | /* Icons, Emojis */
530 |
531 | /* Images */
532 |
533 | /* Lists */
534 | .md-typeset [type="checkbox"]:checked + .task-list-indicator::before {
535 | --md-default-bg-color: white;
536 | background-color: var(--md-checkbox-checked-bg-color);
537 | }
538 | .md-typeset .task-list-indicator::before {
539 | --md-default-bg-color: white;
540 | background-color: var(--md-checkbox-unchecked-bg-color);
541 | }
542 |
543 |
544 | .md-content__inner::before {
545 | height: 0px;
546 | }
547 |
--------------------------------------------------------------------------------
/readme.md:
--------------------------------------------------------------------------------
1 | # Hydra-MQTT
2 |
3 | |
| Hydra-MQTT is a module for [Inductive Automation Ignition](https://inductiveautomation.com/). It facilitates the utilization of non-Sparkplug MQTT. |
4 | |----------------------------------------------------------------------|-----------------------------------------------------------------------------------------------------------------------------------------------------|
5 |
6 | This module is a prototype and currently under development. Check the [Releases](https://github.com/m-r-mccormick/Hydra-MQTT/releases) page for pre-release builds.
7 |
8 |
9 | ## Documentation
10 |
11 | - [Pre-Release Documentation](https://hydra-mqtt-prerelease.netlify.app/)
12 |
13 |
14 | ## Academic Citations
15 |
16 | If utilizing this module to support an academic publication, please cite [this](http://dx.doi.org/10.13140/RG.2.2.25803.60967) paper.
17 |
18 | ```bibtex
19 | @article{mccormick-2024-real-time-manufacturing,
20 | author = "McCormick, M. R. and Wuest, Thorsten",
21 | title = "Real-Time Manufacturing Datasets: An Event Sourcing Approach",
22 | year = "2024",
23 | month = "07",
24 | doi = "10.13140/RG.2.2.25803.60967",
25 | url = "http://dx.doi.org/10.13140/RG.2.2.25803.60967",
26 | }
27 | ```
28 |
29 |
--------------------------------------------------------------------------------
/src/.gitignore:
--------------------------------------------------------------------------------
1 | # A general .gitignore for Gradle or Maven built Ignition SDK projects that
2 | # use IntelliJ or Eclipse as an IDE
3 |
4 | # Ignition Module files
5 | *.modl
6 |
7 | # Java class files
8 | *.class
9 |
10 | # generated files
11 | bin/
12 | gen/
13 |
14 | # Local configuration file used for proj. specific settings (sdk paths, etc)
15 | local.properties
16 |
17 | # Eclipse project files
18 | .classpath
19 | .project
20 |
21 | # Intellij project files
22 | *.iml
23 | *.ipr
24 | *.iws
25 | .idea/
26 |
27 | # git repos
28 | .git/
29 |
30 | # hg repos
31 | .hg/
32 |
33 | # Maven related
34 | */target/
35 | *.versionsBackup
36 |
37 | # Ignition Module Signer
38 | module-signer.jar
39 |
40 | # Secrets for Ignition Module Signer
41 | secrets/
42 |
43 |
--------------------------------------------------------------------------------
/src/build/doc/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
Module User Manual
6 |
7 |
8 |
Instructions
9 |
This is the root of the Managed Tag Provider example user manual.
10 |
11 |
--------------------------------------------------------------------------------
/src/build/license.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
Module License
6 |
7 |
8 |
9 |
--------------------------------------------------------------------------------
/src/build/pom.xml:
--------------------------------------------------------------------------------
1 |
2 |
5 | 4.0.0
6 |
7 |
8 | com.inductiveautomation.ignition.mrmccormick
9 | module
10 | 0.0.0-SNAPSHOT
11 |
12 |
13 | build
14 |
15 |
16 |
17 | com.inductiveautomation.ignition.mrmccormick
18 | gateway
19 | 0.0.0-SNAPSHOT
20 |
21 |
22 |
23 |
24 |
25 |
26 | com.inductiveautomation.ignitionsdk
27 | ignition-maven-plugin
28 | 1.1.0
29 |
30 |
31 |
32 | package
33 |
34 | modl
35 |
36 |
37 |
38 |
39 |
40 |
41 |
42 | gateway
43 | G
44 |
45 |
46 |
47 | com.mrmccormick.ignition.hydra.mqtt.tag-provider
48 | ${module-name}
49 | ${module-description}
50 | ${project.version}
51 | ${ignition-platform-version}
52 | license.html
53 | doc/index.html
54 |
55 |
56 |
57 | G
58 | com.mrmccormick.ignition.hydra.mqtt.GatewayHook
59 |
60 |
61 |
62 |
63 |
64 |
65 |
66 |
67 |
68 |
--------------------------------------------------------------------------------
/src/gateway/pom.xml:
--------------------------------------------------------------------------------
1 |
2 |
5 | 4.0.0
6 |
7 |
8 | com.inductiveautomation.ignition.mrmccormick
9 | module
10 | 0.0.0-SNAPSHOT
11 |
12 |
13 | gateway
14 |
15 |
16 |
17 | com.inductiveautomation.ignitionsdk
18 | ignition-common
19 | ${ignition-sdk-version}
20 | pom
21 | provided
22 |
23 |
24 |
25 | com.inductiveautomation.ignitionsdk
26 | gateway-api
27 | ${ignition-sdk-version}
28 | pom
29 | provided
30 |
31 |
32 |
33 | org.eclipse.paho
34 | org.eclipse.paho.client.mqttv3
35 | 1.2.5
36 |
37 |
38 |
39 | com.fasterxml.jackson.core
40 | jackson-databind
41 | 2.12.3
42 |
43 |
44 |
45 |
46 |
47 |
48 |
49 | org.apache.maven.plugins
50 | maven-compiler-plugin
51 | 3.2
52 |
53 | 17
54 | 17
55 |
56 |
57 |
58 |
59 |
60 |
--------------------------------------------------------------------------------
/src/gateway/src/main/java/com/mrmccormick/ignition/hydra/mqtt/Connection.java:
--------------------------------------------------------------------------------
1 | package com.mrmccormick.ignition.hydra.mqtt;
2 |
3 | import com.inductiveautomation.ignition.gateway.model.GatewayContext;
4 | import org.apache.log4j.Logger;
5 |
6 | public class Connection {
7 |
8 | public final String name;
9 |
10 | private final Logger _logger = GatewayHook.GetLogger(getClass());
11 |
12 | private final TagManager _tagManagerPublish;
13 | private final TagManager _tagManagerSubscribe;
14 | private final MqttManager _mqttManager;
15 |
16 | public Connection(String name, MqttManager mqttManager, TagManager tagManagerPublish, TagManager tagManagerSubscribe) {
17 | if (name == null)
18 | throw new IllegalArgumentException("name can not be null");
19 | this.name = name;
20 |
21 | if (mqttManager == null)
22 | throw new IllegalArgumentException("mqttManager can not be null");
23 | _mqttManager = mqttManager;
24 |
25 | _tagManagerPublish = tagManagerPublish;
26 | _tagManagerSubscribe = tagManagerSubscribe;
27 |
28 |
29 | try {
30 | if (_tagManagerPublish != null) {
31 | _tagManagerPublish.DataEventSubscribers.add(_mqttManager);
32 | }
33 | if (_tagManagerSubscribe != null) {
34 | _mqttManager.DataEventSubscribers.add(_tagManagerSubscribe);
35 | }
36 | } catch (Exception e) {
37 | _logger.error("Error setting up " + name + ": " + e.getMessage(), e);
38 | throw e;
39 | }
40 | }
41 |
42 | public void Start(GatewayContext _context) throws Exception {
43 | if (_tagManagerPublish != null)
44 | _tagManagerPublish.Startup();
45 | if (_tagManagerSubscribe != null)
46 | _tagManagerSubscribe.Startup();
47 |
48 | _mqttManager.Startup(true);
49 | }
50 |
51 | public void Stop() throws Exception {
52 | if (_mqttManager != null) {
53 | _mqttManager.Shutdown();
54 | }
55 |
56 | if (_tagManagerPublish != null) {
57 | _tagManagerPublish.Shutdown();
58 | }
59 | if (_tagManagerSubscribe != null) {
60 | _tagManagerSubscribe.Shutdown();
61 | }
62 | }
63 |
64 | public void maintain(){
65 | if (!_mqttManager.IsConnected())
66 | _mqttManager.Reconnect();
67 | }
68 | }
69 |
--------------------------------------------------------------------------------
/src/gateway/src/main/java/com/mrmccormick/ignition/hydra/mqtt/GatewayHook.java:
--------------------------------------------------------------------------------
1 | package com.mrmccormick.ignition.hydra.mqtt;
2 |
3 | import com.mrmccormick.ignition.hydra.mqtt.settings.SettingsManager;
4 |
5 | import com.inductiveautomation.ignition.common.licensing.LicenseState;
6 | import com.inductiveautomation.ignition.gateway.model.AbstractGatewayModuleHook;
7 | import com.inductiveautomation.ignition.gateway.model.GatewayContext;
8 | import com.inductiveautomation.ignition.gateway.web.models.*;
9 | import java.util.*;
10 | import java.time.LocalDateTime;
11 | import java.time.Month;
12 | import java.util.ArrayList;
13 | import org.apache.log4j.Level;
14 | import org.apache.log4j.LogManager;
15 | import org.apache.log4j.Logger;
16 |
17 | public class GatewayHook extends AbstractGatewayModuleHook {
18 |
19 | public static Logger GetLogger(Class c) {
20 | String root = "Hydra-MQTT";
21 | if (c == null) {
22 | return LogManager.getLogger(root);
23 | }
24 | var names = c.getName().split("\\.");
25 | var name = names[names.length - 1];
26 | var logger = LogManager.getLogger(root + "_" + name);
27 | logger.setLevel(Level.TRACE);
28 | return logger;
29 | }
30 |
31 | private final Logger _logger = GetLogger(null);
32 | private GatewayContext _context;
33 | private SettingsManager _settingsManager;
34 | private final List
_connections = new ArrayList<>();
35 |
36 | @Override
37 | public void setup(GatewayContext context) {
38 |
39 | _logger.info("Configuring module...");
40 | var startTimeMs = System.currentTimeMillis();
41 |
42 | if (context == null) {
43 | _logger.fatal("Module setup function received null gateway context");
44 | return;
45 | }
46 |
47 | try {
48 | _settingsManager = new SettingsManager(context, this);
49 | } catch (Exception e) {
50 | _logger.fatal("Error loading configuration: " + e.getMessage(), e);
51 | return;
52 | }
53 |
54 | _context = context;
55 | String duration = String.format("%.3f", ((double)(System.currentTimeMillis() - startTimeMs)) / 1000);
56 | _logger.info("Configured module successfully in " + duration + " seconds.");
57 | }
58 |
59 | @Override
60 | public void startup(LicenseState activationState) {
61 | if (_context == null) {
62 | _logger.warn("Module was not successfully configured, can not start.");
63 | return;
64 | }
65 |
66 | _logger.info("Starting module...");
67 | var startTimeMs = System.currentTimeMillis();
68 |
69 | try {
70 | _settingsManager.Startup();
71 | } catch (Exception e) {
72 | _logger.fatal("Error starting settings manager: " + e.getMessage(), e);
73 | try {
74 | _settingsManager.Shutdown();
75 | } catch (Exception e2) {
76 | _logger.error("Error stopping settings manager after failed start: " + e2.getMessage(), e);
77 | }
78 | return;
79 | }
80 |
81 | List enabledConnections;
82 | try {
83 | enabledConnections = _settingsManager.getEnabledConnections(_context);
84 | } catch (Exception e) {
85 | _logger.fatal("Error getting enabled connections: " + e.getMessage(), e);
86 | return;
87 | }
88 |
89 | for (var connection : enabledConnections) {
90 | try {
91 | connection.Start(_context);
92 | _connections.add(connection);
93 | } catch (Exception e) {
94 | _logger.warn("Could not establish " + connection.name + ": " + e.getMessage(), e);
95 | }
96 | }
97 | for (var connection : _connections) {
98 | enabledConnections.remove(connection);
99 | }
100 | var connectionFailed = !enabledConnections.isEmpty();
101 |
102 | boolean registerMaintainTaskFailed = false;
103 | try {
104 | _context.getExecutionManager().register(getClass().getName(), "Maintain", this::maintain_mqtt_connection, 5000);
105 | } catch (Exception e) {
106 | _logger.fatal("Error registering Maintain task: " + e.getMessage(), e);
107 | registerMaintainTaskFailed = true;
108 | }
109 |
110 | if (connectionFailed || registerMaintainTaskFailed) {
111 | if (!enabledConnections.isEmpty()) {
112 | _logger.fatal("Failed to establish a connection.");
113 | for (var connection : _connections) {
114 | try {
115 | connection.Start(_context);
116 | _connections.add(connection);
117 | } catch (Exception e) {
118 | _logger.warn("Could not disconnect " + connection.name +
119 | " after failed connect: "+ e.getMessage(), e);
120 | }
121 | }
122 | _connections.clear();
123 | return;
124 | }
125 | if (!registerMaintainTaskFailed)
126 | {
127 | try {
128 | _context.getExecutionManager().unRegister(getClass().getName(), "Maintain");
129 | } catch (Exception e) {
130 | _logger.warn("Error unregistering Maintain task: " + e.getMessage(), e);
131 | }
132 | }
133 | return;
134 | }
135 |
136 | String duration = String.format("%.3f", ((double)(System.currentTimeMillis() - startTimeMs)) / 1000);
137 | _logger.info("Module started in " + duration + " seconds.");
138 | }
139 |
140 | @Override
141 | public void shutdown() {
142 | _logger.info("Stopping module...");
143 | var startTimeMs = System.currentTimeMillis();
144 |
145 | try {
146 | _context.getExecutionManager().unRegister(getClass().getName(), "TagBatchProcessor");
147 | } catch (Exception e) {
148 | _logger.warn("Error unregistering TagBatchProcessor task: " + e.getMessage(), e);
149 | }
150 |
151 | try {
152 | _context.getExecutionManager().unRegister(getClass().getName(), "Maintain");
153 | } catch (Exception e) {
154 | _logger.warn("Error unregistering Maintain task: " + e.getMessage(), e);
155 | }
156 |
157 | for (var connection : _connections) {
158 | try {
159 | connection.Stop();
160 | } catch (Exception e) {
161 | _logger.warn("Error disconnecting " + connection.name + ": "+ e.getMessage(), e);
162 | }
163 | }
164 | _connections.clear();
165 |
166 | String duration = String.format("%.3f", ((double)(System.currentTimeMillis() - startTimeMs)) / 1000);
167 | _logger.info("Stopped module in " + duration + " seconds.");
168 | }
169 |
170 | private synchronized void maintain_mqtt_connection() {
171 | for (var connection : _connections){
172 | connection.maintain();
173 | }
174 | }
175 | public boolean isFreeModule() {
176 | return true;
177 | }
178 |
179 | public boolean isMakerEditionCompatible() {
180 | return true;
181 | }
182 |
183 | @Override
184 | public List getConfigCategories() {
185 | return SettingsManager.ConfigCategories;
186 | }
187 |
188 | @Override
189 | public List extends IConfigTab> getConfigPanels() {
190 | return SettingsManager.ConfigPanels;
191 | }
192 | }
193 |
--------------------------------------------------------------------------------
/src/gateway/src/main/java/com/mrmccormick/ignition/hydra/mqtt/MqttManager.java:
--------------------------------------------------------------------------------
1 | package com.mrmccormick.ignition.hydra.mqtt;
2 |
3 | import com.mrmccormick.ignition.hydra.mqtt.data.IDataCoder;
4 | import com.mrmccormick.ignition.hydra.mqtt.data.DataEvent;
5 | import com.mrmccormick.ignition.hydra.mqtt.data.IDataEventSubscriber;
6 |
7 | import java.io.IOException;
8 | import java.security.*;
9 | import java.security.cert.CertificateException;
10 | import java.security.spec.InvalidKeySpecException;
11 | import java.util.*;
12 | import java.util.ArrayList;
13 | import java.util.Objects;
14 | import java.util.UUID;
15 | import javax.net.ssl.*;
16 | import javax.net.ssl.SSLSocketFactory;
17 | import org.apache.log4j.Logger;
18 | import org.eclipse.paho.client.mqttv3.*;
19 | import org.eclipse.paho.client.mqttv3.persist.MemoryPersistence;
20 |
21 | public class MqttManager implements MqttCallback, IDataEventSubscriber {
22 |
23 | private final Logger _logger = GatewayHook.GetLogger(getClass());
24 |
25 | private final MqttClient _client;
26 | private final String _brokerUrl;
27 | private final int _pubQos;
28 | private final int _subQos;
29 | private final String _writeSuffix;
30 | private final List _subscriptions;
31 | private final IDataCoder _dataCoder;
32 |
33 | public List DataEventSubscribers = new ArrayList<>();
34 |
35 | public MqttManager(String host, int port, int pubQos, int subQos, IDataCoder dataCoder, String writeSuffix,
36 | List subscriptions) throws MqttException, CertificateException, IOException, InvalidKeySpecException, NoSuchAlgorithmException, KeyException {
37 | if (host == null) {
38 | throw new IllegalArgumentException("host cannot be null");
39 | }
40 | if (port < 0) {
41 | throw new IllegalArgumentException("port cannot be negative");
42 | }
43 |
44 | if (pubQos < 0 || pubQos > 2) {
45 | throw new IllegalArgumentException("pubQos must be between 0 and 2");
46 | }
47 | _pubQos = pubQos;
48 |
49 | if (subQos < 0 || subQos > 2) {
50 | throw new IllegalArgumentException("subQos must be between 0 and 2");
51 | }
52 | _subQos = subQos;
53 |
54 | if (dataCoder == null) {
55 | throw new IllegalArgumentException("dataCoder can not be null");
56 | }
57 | _dataCoder = dataCoder;
58 |
59 | _writeSuffix = writeSuffix;
60 |
61 | if (subscriptions == null) {
62 | _subscriptions = new ArrayList<>();
63 | } else {
64 | _subscriptions = subscriptions;
65 | }
66 |
67 | _brokerUrl = "tcp://" + host + ":" + port;
68 | String clientId = "Hydra-MQTT-" + UUID.randomUUID();
69 | _client = new MqttClient(_brokerUrl, clientId, new MemoryPersistence());
70 | }
71 |
72 | public void Startup(boolean cleanSession) throws Exception {
73 | MqttConnectOptions connOpts = new MqttConnectOptions();
74 | connOpts.setCleanSession(cleanSession);
75 |
76 | if (_client.isConnected()) {
77 | _logger.info("Client is already connected.");
78 | return;
79 | }
80 |
81 | try {
82 | _client.connect(connOpts);
83 | } catch (MqttException e) {
84 | if (!Objects.equals(e.getMessage(), "Connect already in progress")) {
85 | throw e;
86 | }
87 | _logger.info("Connect already in progress...");
88 | return;
89 | } catch (Exception e) {
90 | throw e;
91 | }
92 |
93 | for (int i = 0; i < 20; i++) {
94 | if (!_client.isConnected()) {
95 | Thread.sleep(100);
96 | if (i % 10 == 0) {
97 | _logger.debug("Waiting to connect...");
98 | }
99 | }
100 | }
101 |
102 | if (!_client.isConnected()) {
103 | _logger.warn("Connect timeout reached.");
104 | }
105 |
106 | _client.setCallback(this);
107 | for (String subscription : _subscriptions) {
108 | _client.subscribe(subscription, _subQos);
109 | }
110 | }
111 |
112 | public void Reconnect() {
113 | if (_client.isConnected()) {
114 | _logger.info("Already connected to MQTT broker, can not reconnect.");
115 | return;
116 | }
117 |
118 | _logger.info("Attempting to reconnect to MQTT broker...");
119 |
120 | try {
121 | _client.reconnect();
122 | _logger.info("Reconnected to MQTT broker.");
123 | } catch (Exception e) {
124 | _logger.error("Could not reconnect to MQTT broker. Cause: " + e.getMessage(), e);
125 |
126 | try {
127 | Thread.sleep(1000);
128 | } catch (Exception se) {
129 | _logger.error("Could not sleep.");
130 | return;
131 | }
132 |
133 | _logger.info("Attempting to create new connection to MQTT broker...");
134 | try {
135 | Startup(true);
136 | } catch (MqttException me) {
137 | _logger.error("Could not create a new connection to MQTT broker. Cause: " + me.getMessage(), e);
138 | } catch (Exception me) {
139 | throw new RuntimeException(e);
140 | }
141 | }
142 | }
143 |
144 | public void Shutdown() throws Exception {
145 | _client.disconnect();
146 | _client.close();
147 | }
148 |
149 | public boolean IsConnected() {
150 | return _client.isConnected();
151 | }
152 |
153 | public void Publish(DataEvent dataEvent) throws Exception {
154 | MqttMessage message = new MqttMessage(_dataCoder.Encode(dataEvent));
155 | message.setQos(_pubQos);
156 |
157 | String basePath;
158 | String path;
159 | if (dataEvent.PathOverride == null) {
160 | // Append the _writeSuffix if available
161 | basePath = dataEvent.Path;
162 | if (_writeSuffix == null) {
163 | path = basePath;
164 | } else {
165 | path = basePath + _writeSuffix;
166 | }
167 | } else {
168 | // Do not append _writeSuffix when overriding the path
169 | basePath = dataEvent.PathOverride;
170 | path = basePath;
171 | }
172 |
173 | _client.publish(path, message);
174 | }
175 |
176 | @Override
177 | public void connectionLost(Throwable cause) {
178 | _logger.info("MQTT client connection to " + _brokerUrl + " lost. Cause: " + cause.getMessage());
179 | try {
180 | _client.reconnect();
181 | } catch (MqttException e) {
182 | _logger.error("Connection to MQTT broker lost. Could not re-establish connection. Cause: " + cause.getMessage(), e);
183 | } catch (Exception e) {
184 | throw new RuntimeException(e);
185 | }
186 | }
187 |
188 | @Override
189 | public void messageArrived(String topic, MqttMessage message) throws Exception {
190 | if (_writeSuffix != null) {
191 | if (topic.endsWith(_writeSuffix)) {
192 | return;
193 | }
194 | }
195 |
196 | DataEvent event;
197 | try {
198 | event = _dataCoder.Decode(topic, message.getPayload());
199 | } catch (Exception e) {
200 | return;
201 | }
202 |
203 | for (var handler : DataEventSubscribers) {
204 | try {
205 | handler.HandleDataEvent(event);
206 | } catch (Exception e) {
207 | _logger.error("Could not handle data event: " + handler.getClass(), e);
208 | }
209 | }
210 | }
211 |
212 | @Override
213 | public void deliveryComplete(IMqttDeliveryToken token) {
214 |
215 | }
216 |
217 | @Override
218 | public void HandleDataEvent(DataEvent dataEvent) {
219 | try {
220 | Publish(dataEvent);
221 | } catch (Exception e) {
222 | _logger.error("Failed to publish to MQTT broker " + e.getMessage());
223 | }
224 | }
225 | }
226 |
--------------------------------------------------------------------------------
/src/gateway/src/main/java/com/mrmccormick/ignition/hydra/mqtt/TagManager.java:
--------------------------------------------------------------------------------
1 | package com.mrmccormick.ignition.hydra.mqtt;
2 |
3 | import com.google.common.collect.ImmutableList;
4 | import com.inductiveautomation.ignition.common.BundleUtil;
5 | import com.inductiveautomation.ignition.common.config.*;
6 | import com.inductiveautomation.ignition.common.i18n.LocalizedString;
7 | import com.inductiveautomation.ignition.gateway.web.models.ConfigCategory;
8 | import com.mrmccormick.ignition.hydra.mqtt.data.DataEvent;
9 | import com.mrmccormick.ignition.hydra.mqtt.data.IDataEventSubscriber;
10 |
11 | import java.util.*;
12 | import java.util.concurrent.TimeUnit;
13 |
14 | import com.inductiveautomation.ignition.common.browsing.BrowseFilter;
15 | //import com.inductiveautomation.ignition.common.tags.config.TagConfiguration;
16 | import com.inductiveautomation.ignition.common.model.values.BasicQualifiedValue;
17 | import com.inductiveautomation.ignition.common.model.values.QualityCode;
18 | import com.inductiveautomation.ignition.common.sqltags.model.types.DataType;
19 | import com.inductiveautomation.ignition.common.tags.browsing.NodeDescription;
20 | import com.inductiveautomation.ignition.common.tags.config.BasicTagConfiguration;
21 | import com.inductiveautomation.ignition.common.tags.config.CollisionPolicy;
22 | import com.inductiveautomation.ignition.common.tags.config.TagConfigurationModel;
23 | import com.inductiveautomation.ignition.common.tags.config.properties.WellKnownTagProps;
24 | import com.inductiveautomation.ignition.common.tags.config.types.TagObjectType;
25 | import com.inductiveautomation.ignition.common.tags.model.SecurityContext;
26 | import com.inductiveautomation.ignition.common.tags.model.TagPath;
27 | import com.inductiveautomation.ignition.common.tags.model.TagProvider;
28 | import com.inductiveautomation.ignition.common.tags.model.event.InvalidListenerException;
29 | import com.inductiveautomation.ignition.common.tags.model.event.TagChangeEvent;
30 | import com.inductiveautomation.ignition.common.tags.model.event.TagChangeListener;
31 | import com.inductiveautomation.ignition.common.tags.paths.parser.TagPathParser;
32 | import com.inductiveautomation.ignition.gateway.model.GatewayContext;
33 | import com.inductiveautomation.ignition.gateway.tags.model.GatewayTagManager;
34 | import com.inductiveautomation.ignition.gateway.tags.model.TagStructureEvent;
35 | import com.inductiveautomation.ignition.gateway.tags.model.TagStructureListener;
36 | import org.apache.log4j.Logger;
37 |
38 | import com.inductiveautomation.ignition.common.tags.config.TagConfiguration;
39 |
40 | public class TagManager implements TagChangeListener, TagStructureListener, IDataEventSubscriber {
41 |
42 |
43 |
44 | private final GatewayContext _context;
45 | private final GatewayTagManager _tagManager;
46 | private TagProvider _tagProvider;
47 | private final String _tagProviderName;
48 | private final Logger _logger = GatewayHook.GetLogger(getClass());
49 |
50 | public List DataEventSubscribers = new ArrayList<>();
51 |
52 | public TagManager(GatewayContext gatewayContext, String tagProviderName) {
53 | _context = gatewayContext;
54 | _tagManager = _context.getTagManager();
55 | _tagProviderName = tagProviderName;
56 | }
57 |
58 | public void Startup() throws Exception {
59 | _tagProvider = _tagManager.getTagProvider(_tagProviderName);
60 | if (_tagProvider == null) {
61 | _logger.fatal("Could not find tag provider: " + _tagProviderName);
62 | throw new RuntimeException("Could not find tag provider: " + _tagProviderName);
63 | }
64 | _tagManager.addTagStructureListener(_tagProviderName, this);
65 | for (var tag : BrowseTags()) {
66 | _tagManager.subscribeAsync(tag.getPath(), this);
67 | }
68 | }
69 |
70 | public void Shutdown() throws Exception {
71 | _tagManager.removeTagStructureListener(_tagProviderName, this);
72 | for (var tag : BrowseTags()) {
73 | _tagManager.unsubscribeAsync(tag.getPath(), this);
74 | }
75 | }
76 |
77 | private void InitializeProperty(BasicConfigurationProperty property, String name, Class clazz, Object defaultValue) {
78 | property.setName(name);
79 | property.setClazz(clazz);
80 | property.setDefaultValue(defaultValue);
81 | property.setDisplayName(new LocalizedString("hydramqtt.TagProperties." + name + ".Name"));
82 | property.setCategory(new LocalizedString("hydramqtt.TagProperties." + name + ".Category"));
83 | property.setDescription(new LocalizedString("hydramqtt.TagProperties." + name + ".Description"));
84 | }
85 |
86 | public static Property DataEventTimestamp = new BasicDescriptiveProperty(
87 | "DataEventTimestamp", // Name
88 | WellKnownTagProps.propertyKey("DataEventTimestamp"), // Display Name Key
89 | WellKnownTagProps.categoryKey("value"), // Category Key
90 | WellKnownTagProps.descriptionKey("DataEventTimestamp"), // Description Key
91 | Date.class, // Type
92 | (Object)null // Default Value
93 | );
94 |
95 | public void EditTag(String tagPath, Date timestamp, Object value) throws Exception {
96 | TagPath path = TagPathParser.parse(_tagProviderName, tagPath);
97 |
98 | // Check whether tag exists
99 | TagConfigurationModel tagConfig = _tagProvider
100 | .getTagConfigsAsync(Collections.singletonList(path), false, true)
101 | .get(30, TimeUnit.SECONDS)
102 | .get(0);
103 | DataType oldTagDataType = tagConfig.get(WellKnownTagProps.DataType);
104 | DataType newTagDataType = GetDataType(value);
105 | boolean saveTagConfig = false;
106 | if (tagConfig.getType() == TagObjectType.Unknown) {
107 | // Tag does not exist, so create it
108 | saveTagConfig = true;
109 | CreateTagParentFolders(tagPath, timestamp, value);
110 | }
111 | if (oldTagDataType != newTagDataType) {
112 | // oldTagDataType == null -> New Tag, so set type, or
113 | // Tag has changed type
114 | saveTagConfig = true;
115 | tagConfig.set(WellKnownTagProps.DataType, newTagDataType);
116 | }
117 | if (saveTagConfig) {
118 |
119 | QualityCode saveResult = _tagProvider
120 | .saveTagConfigsAsync(Collections.singletonList(tagConfig), CollisionPolicy.Abort)
121 | .get(30, TimeUnit.SECONDS)
122 | .get(0);
123 | if (saveResult.isNotGood()) {
124 | _logger.error("Could not edit tag: " + path.toStringFull());
125 | throw new Exception("Could not edit tag: " + path.toStringFull());
126 | }
127 | }
128 |
129 | var qualifiedValue = new BasicQualifiedValue(value, QualityCode.Good, timestamp);
130 |
131 | QualityCode writeResult =
132 | _tagProvider.writeAsync(Collections.singletonList(path),
133 | Collections.singletonList(qualifiedValue),
134 | SecurityContext.emptyContext())
135 | .get(30, TimeUnit.SECONDS)
136 | .get(0);
137 | if (writeResult.isNotGood()) {
138 | _logger.error("Could not write value to tag: " + path.toStringFull());
139 | throw new Exception("Could not write value to tag: " + path.toStringFull());
140 | }
141 | }
142 |
143 | private void CreateTagParentFolders(String tagPath, Date timestamp, Object initialValue) throws Exception {
144 | List createConfigs = new ArrayList<>();
145 | TagPath path = TagPathParser.parse(_tagProviderName, tagPath);
146 |
147 | // Create a list of parent folders
148 | List parentPaths = new ArrayList<>();
149 | TagPath parentPath = path;
150 | String root_path = "[" + _tagProviderName + "]";
151 | while (true)
152 | {
153 | parentPath = parentPath.getParentPath();
154 | var parentPathPartial = parentPath.toStringPartial();
155 | if (Objects.equals(parentPathPartial, ""))
156 | break;
157 | parentPaths.add(parentPath);
158 | }
159 |
160 | if (!parentPaths.isEmpty()) {
161 | // Check whether parent folders exist
162 | List parentModels = _tagProvider
163 | .getTagConfigsAsync(parentPaths, false, true)
164 | .get(30, TimeUnit.SECONDS);
165 | // Reverse order so tree is traversed top down
166 | Collections.reverse(parentModels);
167 | for (var parentModel : parentModels) {
168 | if (parentModel.getType() == TagObjectType.Unknown) {
169 | // The parent folder does not exist, add a creation config for it
170 | TagConfiguration folderConfig = BasicTagConfiguration.createNew(parentModel.getPath());
171 | folderConfig.setType(TagObjectType.Folder);
172 | createConfigs.add(folderConfig);
173 | }
174 | }
175 | }
176 |
177 | List results = _tagProvider
178 | .saveTagConfigsAsync(createConfigs, CollisionPolicy.Abort)
179 | .get(30, TimeUnit.SECONDS);
180 | for (int i = 0; i < results.size(); i++) {
181 | QualityCode result = results.get(i);
182 | if (result.isNotGood()) {
183 | _logger.error("Could not create tag: " + parentPaths.get(i).toStringFull());
184 | throw new Exception("Could not create tag: " + parentPaths.get(i).toStringFull());
185 | }
186 | }
187 | }
188 |
189 | private DataType GetDataType(Object object) throws Exception {
190 | if (object instanceof Boolean) return DataType.Boolean;
191 | if (object instanceof String) return DataType.String;
192 | if (object instanceof Double) return DataType.Float8;
193 | if (object instanceof Float) return DataType.Float4;
194 | if (object instanceof Long) return DataType.Int8;
195 | if (object instanceof Integer) return DataType.Int4;
196 | if (object instanceof Short) return DataType.Int2;
197 | if (object instanceof Byte) return DataType.Int1;
198 | if (object instanceof Date) return DataType.DateTime;
199 | throw new Exception("Invalid Type: " + object.getClass());
200 | }
201 |
202 | public List BrowseTags() throws Exception {
203 | List tagConfigs = new ArrayList<>();
204 | TagPath path = TagPathParser.parseSafe("");
205 | BrowseFolderRecursive(path, tagConfigs);
206 | List removeConfigs = new ArrayList<>();
207 |
208 | return tagConfigs;
209 | }
210 |
211 | private void BrowseFolderRecursive(TagPath path, List tagList) throws Exception {
212 | var results = _tagProvider.browseAsync(path, BrowseFilter.NONE).get();
213 |
214 | if (results.getResultQuality().isNotGood()){
215 | throw new Exception("BrowseFolderRecursive Bad Quality Results");
216 | }
217 |
218 | for (NodeDescription nodeDescription : results.getResults()) {
219 | TagPath childPath = path.getChildPath(nodeDescription.getName());
220 | List childPaths = new ArrayList<>();
221 | childPaths.add(childPath);
222 | var tagConfigurationModels = _tagProvider.getTagConfigsAsync(childPaths, true, true).get();
223 | tagList.addAll(tagConfigurationModels);
224 |
225 | // Don't include Documents (such as UDTs)
226 | if (nodeDescription.hasChildren() && nodeDescription.getDataType() != DataType.Document) {
227 | BrowseFolderRecursive(childPath, tagList);
228 | }
229 | }
230 | }
231 |
232 | @Override
233 | public void tagChanged(TagChangeEvent tagChangeEvent) {
234 | var tagPath = tagChangeEvent.getTagPath();
235 | var tagValue = tagChangeEvent.getValue();
236 | var timestamp = tagValue.getTimestamp();
237 | var value = tagValue.getValue();
238 |
239 | TagConfigurationModel tagConfig;
240 | try {
241 | // Check whether tag exists
242 | tagConfig = _tagProvider
243 | .getTagConfigsAsync(Collections.singletonList(tagPath), false, true)
244 | .get(30, TimeUnit.SECONDS)
245 | .get(0);
246 | } catch (Exception e) {
247 | _logger.error("Could not get tag configuration: " + tagPath.toStringFull());
248 | return;
249 | }
250 | if (tagConfig.get(WellKnownTagProps.DataType) == DataType.Document)
251 | // The tag is a UDT, don't publish
252 | return;
253 | if (tagConfig.getType() == TagObjectType.Folder)
254 | // The tag is folder, don't publish
255 | return;
256 | if (tagChangeEvent.isInitial())
257 | // Tag was just created, don't publish
258 | return;
259 |
260 | var publishEnabled = true;
261 | String publishTopic = null;
262 |
263 | if (!publishEnabled)
264 | return;
265 |
266 | DataEvent event = new DataEvent(tagPath.toStringPartial(), timestamp, value, publishTopic);
267 | for (var handler : DataEventSubscribers) {
268 | handler.HandleDataEvent(event);
269 | }
270 | }
271 |
272 | @Override
273 | public void tagStructureChanged(TagStructureEvent tagStructureEvent) {
274 | var addedTags = tagStructureEvent.getAddedTags();
275 | var removedTags = tagStructureEvent.getRemovedTagsInfo();
276 | for (var addedTag : addedTags) {
277 | if (addedTag.getDataType() == DataType.Document)
278 | // Tag is a UDT, so don't subscribe
279 | continue;
280 |
281 | if (addedTag.getObjectType() == TagObjectType.Folder)
282 | // Tag is a folder, so don't subscribe
283 | continue;
284 |
285 | try {
286 | _tagManager.subscribeAsync(addedTag.getFullPath(), this);
287 | _logger.debug("Subscribed to tag changes: " + addedTag.getFullPath());
288 | } catch (Exception e) {
289 | _logger.fatal("Could not subscribe to tag changes: " + addedTag.getFullPath(), e);
290 | }
291 | }
292 | for (var removedTag : removedTags) {
293 | _tagManager.unsubscribeAsync(removedTag.getFullPath(), this);
294 | }
295 | }
296 |
297 | @Override
298 | public void HandleDataEvent(DataEvent dataEvent) {
299 | try {
300 | EditTag(dataEvent.Path, dataEvent.Timestamp, dataEvent.Value);
301 | } catch (Exception e) {
302 | }
303 | }
304 | }
305 |
--------------------------------------------------------------------------------
/src/gateway/src/main/java/com/mrmccormick/ignition/hydra/mqtt/data/DataEvent.java:
--------------------------------------------------------------------------------
1 | package com.mrmccormick.ignition.hydra.mqtt.data;
2 |
3 | import java.util.Date;
4 | import java.util.Objects;
5 |
6 | public class DataEvent {
7 |
8 | public final Date Timestamp;
9 | public final String Path;
10 | public final Object Value;
11 | public final String PathOverride;
12 |
13 | public DataEvent(String path, Date timestamp, Object value, String pathOverride) {
14 | if (path == null)
15 | throw new IllegalArgumentException("path cannot be null");
16 | if (path.isEmpty())
17 | throw new IllegalArgumentException("path cannot be empty");
18 | Path = path;
19 |
20 | if (timestamp == null)
21 | throw new IllegalArgumentException("timestamp cannot be null");
22 | Timestamp = timestamp;
23 |
24 | // Value can be null
25 | Value = value;
26 |
27 | // PathOverride can be null
28 | PathOverride = pathOverride;
29 | }
30 |
31 | public DataEvent(String path, Date timestamp, Object value) {
32 | if (path == null)
33 | throw new IllegalArgumentException("path cannot be null");
34 | if (path.isEmpty())
35 | throw new IllegalArgumentException("path cannot be empty");
36 | Path = path;
37 |
38 | if (timestamp == null)
39 | throw new IllegalArgumentException("timestamp cannot be null");
40 | Timestamp = timestamp;
41 |
42 | // Value can be null
43 | Value = value;
44 |
45 | // PathOverride can be null
46 | PathOverride = null;
47 | }
48 |
49 | @Override
50 | public boolean equals(Object obj) {
51 | if (this == obj) return true;
52 | if (obj == null || getClass() != obj.getClass()) return false;
53 | DataEvent dataEvent = (DataEvent) obj;
54 | return Objects.equals(Path, dataEvent.Path) &&
55 | Objects.equals(Value, dataEvent.Value);
56 | }
57 |
58 | @Override
59 | public int hashCode() {
60 | return Objects.hash(Path, Timestamp, Value);
61 | }
62 | }
63 |
--------------------------------------------------------------------------------
/src/gateway/src/main/java/com/mrmccormick/ignition/hydra/mqtt/data/IDataCoder.java:
--------------------------------------------------------------------------------
1 | package com.mrmccormick.ignition.hydra.mqtt.data;
2 |
3 | public interface IDataCoder {
4 | byte[] Encode(DataEvent event) throws Exception;
5 |
6 | DataEvent Decode(String path, byte[] bytes) throws Exception;
7 | }
8 |
--------------------------------------------------------------------------------
/src/gateway/src/main/java/com/mrmccormick/ignition/hydra/mqtt/data/IDataEventSubscriber.java:
--------------------------------------------------------------------------------
1 | package com.mrmccormick.ignition.hydra.mqtt.data;
2 |
3 | public interface IDataEventSubscriber {
4 | void HandleDataEvent(DataEvent dataEvent);
5 | }
6 |
--------------------------------------------------------------------------------
/src/gateway/src/main/java/com/mrmccormick/ignition/hydra/mqtt/data/JsonCoder.java:
--------------------------------------------------------------------------------
1 | package com.mrmccormick.ignition.hydra.mqtt.data;
2 |
3 | import com.mrmccormick.ignition.hydra.mqtt.GatewayHook;
4 |
5 | import com.fasterxml.jackson.databind.ObjectMapper;
6 | import org.apache.log4j.Logger;
7 | import java.security.KeyException;
8 | import java.time.*;
9 | import java.time.format.DateTimeFormatter;
10 | import java.util.*;
11 |
12 | public class JsonCoder implements IDataCoder {
13 |
14 | private final ObjectMapper _mapper = new ObjectMapper();
15 | private final Logger _logger = GatewayHook.GetLogger(getClass());
16 | public final String SubscribeValuePath;
17 | public final String SubscribeTimestampPath;
18 | public final String PublishValuePath;
19 | public final String PublishTimestampPath;
20 | public final TimestampFormat PublishFormat;
21 | public final TimestampIntegerFormat SubscribeFormat;
22 |
23 | public JsonCoder(String subscribeValuePath,
24 | String subscribeTimestampPath,
25 | String publishValuePath,
26 | String publishTimestampPath,
27 | TimestampFormat publishFormat,
28 | TimestampIntegerFormat subscribeFormat) {
29 | if (subscribeValuePath == null)
30 | throw new IllegalArgumentException("subscribeValuePath cannot be null");
31 | if (subscribeValuePath.isEmpty())
32 | throw new IllegalArgumentException("subscribeValuePath cannot be empty");
33 | SubscribeValuePath = subscribeValuePath;
34 |
35 | if (subscribeTimestampPath != null)
36 | if (subscribeTimestampPath.isEmpty())
37 | throw new IllegalArgumentException("subscribeTimestampPath cannot be empty");
38 | SubscribeTimestampPath = subscribeTimestampPath;
39 |
40 | if (publishValuePath == null)
41 | throw new IllegalArgumentException("publishValuePath cannot be null");
42 | if (publishValuePath.isEmpty())
43 | throw new IllegalArgumentException("publishValuePath cannot be empty");
44 | PublishValuePath = publishValuePath;
45 |
46 | if (publishTimestampPath == null)
47 | throw new IllegalArgumentException("publishTimestampPath cannot be null");
48 | if (publishTimestampPath.isEmpty())
49 | throw new IllegalArgumentException("publishTimestampPath cannot be empty");
50 | PublishTimestampPath = publishTimestampPath;
51 |
52 | PublishFormat = publishFormat;
53 | SubscribeFormat = subscribeFormat;
54 | }
55 |
56 | @Override
57 | public byte[] Encode(DataEvent event) throws Exception {
58 | if (event == null) {
59 | throw new IllegalArgumentException("event cannot be null");
60 | }
61 |
62 | Object timestamp = DateToObject(event.Timestamp);
63 |
64 | Map map = new HashMap<>();
65 | var timestampPath = new LinkedList<>(Arrays.stream(PublishTimestampPath.split("\\.")).toList());
66 | var valuePath = new LinkedList<>(Arrays.stream(PublishValuePath.split("\\.")).toList());
67 | PathEncode(map, timestampPath, timestamp);
68 | PathEncode(map, valuePath, event.Value);
69 |
70 | String json;
71 | try {
72 | json = _mapper.writeValueAsString(map);
73 | } catch (Exception e) {
74 | _logger.error("Could not serialize map to json (" + e.getMessage() + "): " +
75 | timestamp + ", " + event.Value.toString(), e);
76 | throw new Exception("Could not serialize map to json (" + e.getMessage() + "): " +
77 | timestamp + ", " + event.Value.toString(), e);
78 | }
79 |
80 | byte[] bytes;
81 | try {
82 | bytes = json.getBytes();
83 | } catch (Exception e) {
84 | _logger.error("Could not convert json to bytes (" + e.getMessage() + "): " + json, e);
85 | throw new Exception("Could not convert json to bytes (" + e.getMessage() + "): " + json, e);
86 | }
87 |
88 | return bytes;
89 | }
90 |
91 | private void PathEncode(Map parent, List paths, Object value) throws Exception {
92 | var path = paths.remove(0);
93 | if (paths.isEmpty()) {
94 | if (parent.containsKey(path)) {
95 | throw new KeyException("path key already exists: " + path);
96 | }
97 | parent.put(path, value);
98 | return;
99 | }
100 |
101 | Map child;
102 | if (parent.containsKey(path)) {
103 | Object obj = parent.get(path);
104 | if (!(obj instanceof Map)) {
105 | throw new Exception("Expected Map type not found");
106 | }
107 | //noinspection unchecked
108 | child = (Map) obj;
109 | } else {
110 | child = new HashMap<>();
111 | }
112 | PathEncode(child, paths, value);
113 | parent.put(path, child);
114 | }
115 |
116 | private Object PathDecode(Map parent, List paths) throws Exception {
117 | Map current = parent;
118 | while (paths.size() > 1) {
119 | var path = paths.remove(0);
120 | if (!current.containsKey(path))
121 | throw new KeyException("Expected Key " + path + " Not Found");
122 | var currentObj = current.get(path);
123 | if (!(currentObj instanceof Map))
124 | throw new Exception("Expected Type Map Not Found");
125 | current = (Map)currentObj;
126 | }
127 | var path = paths.remove(0);
128 | if (!current.containsKey(path))
129 | throw new KeyException("Expected Key " + path + " Not Found");
130 | return current.get(path);
131 | }
132 |
133 | @Override
134 | public DataEvent Decode(String path, byte[] bytes) throws Exception {
135 | if (bytes == null) {
136 | throw new IllegalArgumentException("bytes can not be null");
137 | }
138 | if (bytes.length == 0) {
139 | throw new IllegalArgumentException("bytes can not be empty");
140 | }
141 |
142 | String json;
143 | try {
144 | json = new String(bytes);
145 | } catch (Exception e) {
146 | _logger.error("Could not convert byte array to string");
147 | throw new Exception("Could not convert byte array to string", e);
148 | }
149 |
150 | //noinspection rawtypes
151 | Map map;
152 | try {
153 | map = _mapper.readValue(json, Map.class);
154 | } catch (Exception e) {
155 | _logger.error("Could not parse json (" + e.getMessage() + "): " + json, e);
156 | throw new Exception("Could not parse json (" + e.getMessage() + "): " + json, e);
157 | }
158 |
159 | Date timestamp;
160 | if (SubscribeTimestampPath == null) {
161 | timestamp = Date.from(Instant.now());
162 | } else {
163 | var timestampPath = new LinkedList<>(Arrays.stream(SubscribeTimestampPath.split("\\.")).toList());
164 | Date tryTimestamp = ObjectToDate(PathDecode(map, timestampPath));
165 | if (tryTimestamp == null)
166 | throw new Exception("Could not parse timestamp at " + SubscribeTimestampPath);
167 |
168 | // When writing a tag to a tag provider, if the timestamp is in the future, ignition will silently change
169 | // the timestamp to the current time. This can cause unexpected behavior when one timestamp is written
170 | // then another is later received for the same instance of a value/event. So, to ensure that timestamps
171 | // are consistent throughout the pipeline for a given instance of a value/event, set any future payload
172 | // timestamps to be the current time to preempt this behavior and ensure consistent timestamps throughout
173 | // the pipeline.
174 | var now = Date.from(Instant.now());
175 | if (tryTimestamp.before(now)) {
176 | // The payload timestamp is in the past, so use it
177 | timestamp = tryTimestamp;
178 | } else {
179 | // The payload timestamp is in the future, so replace it with the current time
180 | timestamp = now;
181 | }
182 | }
183 |
184 | var valuePath = new LinkedList<>(Arrays.stream(SubscribeValuePath.split("\\.")).toList());
185 | Object valueObject = PathDecode(map, valuePath);
186 |
187 | Object value;
188 | Object tryValue = TryDateStringToDate(valueObject);
189 | if (tryValue == null)
190 | value = valueObject;
191 | else
192 | value = tryValue;
193 |
194 | return new DataEvent(path, timestamp, value);
195 | }
196 |
197 | private Object DateToObject(Date date) throws Exception {
198 | switch (PublishFormat) {
199 | case ISO8601 -> {
200 | DateTimeFormatter formatter = DateTimeFormatter
201 | //.ofPattern("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'")
202 | .ofPattern("yyyy-MM-dd'T'HH:mm:ss.SSS'+00:00'")
203 | .withZone(ZoneOffset.UTC);
204 |
205 | String timestampString;
206 | try {
207 | timestampString = formatter.format(date.toInstant());
208 | } catch (Exception e) {
209 | _logger.error("Could not format timestamp (" + e.getMessage() + "): " + date.toString(), e);
210 | throw new Exception("Could not format timestamp (" + e.getMessage() + "): " + date, e);
211 | }
212 | return timestampString;
213 | }
214 | case DoubleUnixEpochSeconds -> {
215 | Instant instant = date.toInstant();
216 | return instant.getEpochSecond() + (instant.getNano() / 1_000_000_000.0);
217 | }
218 | case IntegerUnixEpochSeconds -> {
219 | Instant instant = date.toInstant();
220 | return instant.getEpochSecond();
221 | }
222 | case IntegerUnixEpochNanoseconds -> {
223 | Instant instant = date.toInstant();
224 | return (instant.getEpochSecond() * 1_000_000_000L) + instant.getNano();
225 | }
226 | default -> {
227 | _logger.warn("Invalid TimestampFormat: " + PublishFormat);
228 | throw new Exception("Invalid TimestampFormat: " + PublishFormat);
229 | }
230 | }
231 | }
232 |
233 | private Date ObjectToDate(Object object) throws Exception {
234 | if (object instanceof Date) {
235 | return (Date)object;
236 | }
237 | if (object instanceof String) {
238 | return TryDateStringToDate(object);
239 | }
240 | if (object instanceof Integer || object instanceof Long) {
241 | long ts = ((Number)object).longValue();
242 | long s; // Seconds part
243 | long ns; // Nanoseconds part
244 | switch (SubscribeFormat) {
245 | case UnixEpochSeconds -> {
246 | s = ts; // Seconds part
247 | ns = 0L; // Nanoseconds part
248 | }
249 | case UnixEpochNanoseconds -> {
250 | s = ts / 1_000_000_000L; // Seconds part
251 | ns = ts % 1_000_000_000L; // Nanoseconds part
252 | }
253 | default -> {
254 | _logger.warn("Invalid TimestampIntegerFormat: " + SubscribeFormat);
255 | throw new Exception("Invalid TimestampIntegerFormat: " + SubscribeFormat);
256 | }
257 | }
258 |
259 | Instant instant = Instant.ofEpochSecond(s, ns);
260 | return Date.from(instant);
261 | }
262 | if (object instanceof Double) {
263 | long s = (long)((double)object); // Seconds part
264 | long ns = (long)(((double)object - (double)s) * (double)1_000_000_000); // Nanoseconds part
265 | Instant instant = Instant.ofEpochSecond(s, ns);
266 | return Date.from(instant);
267 | }
268 |
269 | _logger.warn("Invalid Date Type: " + object.getClass().getName());
270 | throw new Exception("Invalid Date Type: " + object.getClass().getName());
271 | }
272 |
273 | private Date TryDateStringToDate(Object object) {
274 | if (!(object instanceof String))
275 | return null;
276 |
277 | Date ts;
278 | try {
279 | OffsetDateTime offsetDateTime = OffsetDateTime.parse((String) object);
280 | ts = Date.from(offsetDateTime.toInstant());
281 | } catch (Exception e) {
282 | // Do nothing
283 | return null;
284 | }
285 | return ts;
286 | }
287 | }
288 |
--------------------------------------------------------------------------------
/src/gateway/src/main/java/com/mrmccormick/ignition/hydra/mqtt/data/TimestampFormat.java:
--------------------------------------------------------------------------------
1 | package com.mrmccormick.ignition.hydra.mqtt.data;
2 |
3 | public enum TimestampFormat {
4 | ISO8601,
5 | DoubleUnixEpochSeconds,
6 | IntegerUnixEpochSeconds,
7 | IntegerUnixEpochNanoseconds,
8 | }
9 |
--------------------------------------------------------------------------------
/src/gateway/src/main/java/com/mrmccormick/ignition/hydra/mqtt/data/TimestampIntegerFormat.java:
--------------------------------------------------------------------------------
1 | package com.mrmccormick.ignition.hydra.mqtt.data;
2 |
3 | public enum TimestampIntegerFormat {
4 | UnixEpochSeconds,
5 | UnixEpochNanoseconds,
6 | }
7 |
--------------------------------------------------------------------------------
/src/gateway/src/main/java/com/mrmccormick/ignition/hydra/mqtt/settings/IConnectSettings.java:
--------------------------------------------------------------------------------
1 | package com.mrmccormick.ignition.hydra.mqtt.settings;
2 |
3 | import com.inductiveautomation.ignition.gateway.model.GatewayContext;
4 | import com.mrmccormick.ignition.hydra.mqtt.Connection;
5 |
6 | public interface IConnectSettings {
7 | public boolean getEnabled();
8 | public Connection getConnection(GatewayContext context) throws Exception;
9 | }
10 |
--------------------------------------------------------------------------------
/src/gateway/src/main/java/com/mrmccormick/ignition/hydra/mqtt/settings/SettingsCategory.java:
--------------------------------------------------------------------------------
1 | package com.mrmccormick.ignition.hydra.mqtt.settings;
2 |
3 | import com.mrmccormick.ignition.hydra.mqtt.GatewayHook;
4 |
5 | import com.inductiveautomation.ignition.common.BundleUtil;
6 | import com.inductiveautomation.ignition.gateway.web.models.ConfigCategory;
7 |
8 | public class SettingsCategory {
9 |
10 | public static final String CATEGORY_NAME = "Hydra-MQTT";
11 |
12 | public static final String BUNDLE_FILE_NAME = SettingsCategory.class.getSimpleName();
13 |
14 | public static final String BUNDLE_PREFIX = SettingsCategory.class.getSimpleName();
15 |
16 | public static final ConfigCategory CONFIG_CATEGORY =
17 | new ConfigCategory(CATEGORY_NAME, BUNDLE_PREFIX + ".nav.header", 700);
18 |
19 | public static void Setup(GatewayHook gatewayHook) {
20 | BundleUtil.get().addBundle(BUNDLE_PREFIX, gatewayHook.getClass(), BUNDLE_FILE_NAME);
21 | }
22 |
23 | public static void Shutdown() {
24 | BundleUtil.get().removeBundle(BUNDLE_PREFIX);
25 | }
26 | }
27 |
--------------------------------------------------------------------------------
/src/gateway/src/main/java/com/mrmccormick/ignition/hydra/mqtt/settings/SettingsManager.java:
--------------------------------------------------------------------------------
1 | package com.mrmccormick.ignition.hydra.mqtt.settings;
2 |
3 | import com.mrmccormick.ignition.hydra.mqtt.Connection;
4 | import com.mrmccormick.ignition.hydra.mqtt.GatewayHook;
5 |
6 | import com.inductiveautomation.ignition.gateway.model.GatewayContext;
7 | import com.inductiveautomation.ignition.gateway.web.models.ConfigCategory;
8 | import com.inductiveautomation.ignition.gateway.web.models.IConfigTab;
9 | import java.sql.SQLException;
10 | import java.util.ArrayList;
11 | import java.util.List;
12 | import org.apache.log4j.Logger;
13 |
14 | public class SettingsManager {
15 |
16 | private final Logger _logger = GatewayHook.GetLogger(getClass());
17 |
18 | public static final List ConfigCategories = List.of(
19 | SettingsCategory.CONFIG_CATEGORY
20 | );
21 |
22 | public static final List ConfigPanels = List.of(
23 | SettingsPageX.CONFIG_ENTRY
24 | );
25 |
26 | public final List SettingsBrokers = new ArrayList<>();
27 |
28 | public SettingsManager(GatewayContext context, GatewayHook gatewayHook) throws Exception {
29 | SettingsCategory.Setup(gatewayHook);
30 | SettingsPageX.Setup(gatewayHook);
31 |
32 | try {
33 | SettingsRecordX.UpdateSchema(context);
34 | } catch (SQLException e) {
35 | throw new Exception("Error updating configuration schema.", e);
36 | }
37 |
38 | try {
39 | var settingsRecord = context.getLocalPersistenceInterface().createNew(SettingsRecordX.META);
40 | settingsRecord.setId(0L);
41 | SettingsRecordX.SetDefaults(settingsRecord);
42 | context.getSchemaUpdater().ensureRecordExists(settingsRecord);
43 | } catch (Exception e) {
44 | throw new Exception("Error initializing configuration.", e);
45 | }
46 |
47 | SettingsBrokers.add(context.getLocalPersistenceInterface().find(SettingsRecordX.META, 0L));
48 | }
49 |
50 | public List getEnabledConnections(GatewayContext context) throws Exception {
51 | List enabledConnections = new ArrayList<>();
52 |
53 | var getFailed = false;
54 | for (var setting : SettingsBrokers) {
55 | if (setting.getEnabled())
56 | {
57 | try {
58 | enabledConnections.add(setting.getConnection(context));
59 | } catch (Exception e) {
60 | getFailed = true;
61 | _logger.error(e.getMessage());
62 | }
63 | }
64 | }
65 | if (getFailed)
66 | throw new Exception("At least one Connection failed to configure.");
67 |
68 | return enabledConnections;
69 | }
70 |
71 | public void Startup() {
72 |
73 | }
74 |
75 | public void Shutdown() {
76 | SettingsCategory.Shutdown();
77 | SettingsPageX.Shutdown();
78 | }
79 | }
80 |
--------------------------------------------------------------------------------
/src/gateway/src/main/java/com/mrmccormick/ignition/hydra/mqtt/settings/SettingsPageX.java:
--------------------------------------------------------------------------------
1 | package com.mrmccormick.ignition.hydra.mqtt.settings;
2 |
3 | import com.mrmccormick.ignition.hydra.mqtt.GatewayHook;
4 |
5 | import com.inductiveautomation.ignition.common.BundleUtil;
6 | import com.inductiveautomation.ignition.gateway.model.IgnitionWebApp;
7 | import com.inductiveautomation.ignition.gateway.web.components.RecordEditForm;
8 | import com.inductiveautomation.ignition.gateway.web.models.DefaultConfigTab;
9 | import com.inductiveautomation.ignition.gateway.web.models.IConfigTab;
10 | import com.inductiveautomation.ignition.gateway.web.models.LenientResourceModel;
11 | import com.inductiveautomation.ignition.gateway.web.pages.IConfigPage;
12 | import org.apache.commons.lang3.tuple.Pair;
13 | import org.apache.wicket.Application;
14 |
15 | public class SettingsPageX extends RecordEditForm {
16 |
17 | public static final String MENU_LOCATION_KEY = SettingsPageX.class.getSimpleName();
18 |
19 | public static final String BUNDLE_FILE_NAME = SettingsPageX.class.getSimpleName();
20 |
21 | public static final String BUNDLE_PREFIX = SettingsPageX.class.getSimpleName();
22 |
23 | public static final IConfigTab CONFIG_ENTRY = DefaultConfigTab.builder()
24 | .category(SettingsCategory.CONFIG_CATEGORY)
25 | .name(SettingsPageX.MENU_LOCATION_KEY)
26 | .i18n(BUNDLE_PREFIX + ".nav.settings.title")
27 | .page(SettingsPageX.class)
28 | .terms("Settings")
29 | .build();
30 |
31 | public static final Pair MENU_LOCATION =
32 | Pair.of(SettingsCategory.CONFIG_CATEGORY.getName(), MENU_LOCATION_KEY);
33 |
34 | public SettingsPageX(final IConfigPage configPage) {
35 | super(configPage,
36 | null,
37 | new LenientResourceModel( BUNDLE_PREFIX + ".nav.settings.panelTitle"),
38 | ((IgnitionWebApp) Application.get()).getContext().getPersistenceInterface().find(SettingsRecordX.META,
39 | 0L)
40 | );
41 | }
42 |
43 | @Override
44 | public Pair getMenuLocation() {
45 | return MENU_LOCATION;
46 | }
47 |
48 | public static void Setup(GatewayHook gatewayHook) {
49 | BundleUtil.get().addBundle(BUNDLE_PREFIX, gatewayHook.getClass(), BUNDLE_FILE_NAME);
50 | }
51 |
52 | public static void Shutdown() {
53 | BundleUtil.get().removeBundle(BUNDLE_PREFIX);
54 | }
55 | }
56 |
--------------------------------------------------------------------------------
/src/gateway/src/main/java/com/mrmccormick/ignition/hydra/mqtt/settings/SettingsRecordX.java:
--------------------------------------------------------------------------------
1 | package com.mrmccormick.ignition.hydra.mqtt.settings;
2 |
3 | import com.inductiveautomation.ignition.gateway.localdb.persistence.*;
4 | import com.inductiveautomation.ignition.gateway.model.GatewayContext;
5 | import com.mrmccormick.ignition.hydra.mqtt.*;
6 | import com.mrmccormick.ignition.hydra.mqtt.data.JsonCoder;
7 | import com.mrmccormick.ignition.hydra.mqtt.data.TimestampFormat;
8 | import com.mrmccormick.ignition.hydra.mqtt.data.TimestampIntegerFormat;
9 | import org.apache.log4j.Logger;
10 | import simpleorm.dataset.SFieldFlags;
11 |
12 | import java.sql.SQLException;
13 | import java.util.ArrayList;
14 | import java.util.List;
15 | import java.util.Objects;
16 |
17 | public class SettingsRecordX extends PersistentRecord implements IConnectSettings {
18 |
19 | private final Logger _logger = GatewayHook.GetLogger(getClass());
20 |
21 | public static final String BUNDLE_REFERENCE_NAME = SettingsRecordX.class.getSimpleName();
22 |
23 | public static final String BUNDLE_PREFIX = SettingsRecordX.class.getSimpleName();
24 |
25 | public static final RecordMeta META = new RecordMeta<>(
26 | SettingsRecordX.class, BUNDLE_REFERENCE_NAME)
27 | .setNounKey(BUNDLE_PREFIX + ".Noun")
28 | .setNounPluralKey(BUNDLE_PREFIX + ".Noun.Plural");
29 |
30 | @Override
31 | public RecordMeta> getMeta() {
32 | return META;
33 | }
34 |
35 | public static final IdentityField Id = new IdentityField(META);
36 |
37 | public void setId(Long id) {
38 | setLong(Id, id);
39 | }
40 |
41 | public Long getId() {
42 | return getLong(Id);
43 | }
44 |
45 | public static void UpdateSchema(GatewayContext context) throws SQLException {
46 | context.getSchemaUpdater().updatePersistentRecords(META);
47 | }
48 |
49 | public static void SetDefaults(SettingsRecordX settingsRecord) {
50 | settingsRecord.setTagProviderPub("");
51 | settingsRecord.setTagProviderSub("");
52 |
53 | settingsRecord.setBrokerHost("");
54 | settingsRecord.setBrokerPort(1883);
55 | settingsRecord.setBrokerPublishQos(0);
56 | settingsRecord.setBrokerSubscribeQos(0);
57 | settingsRecord.setBrokerSubscriptions("#");
58 | settingsRecord.setBrokerPublishTopicSuffix("");
59 |
60 | settingsRecord.setRepresentationPublishValuePath("Value");
61 | settingsRecord.setRepresentationPublishTimestampPath("Timestamp");
62 | settingsRecord.setRepresentationPublishTimestampFormat(TimestampFormat.ISO8601);
63 | settingsRecord.setRepresentationSubscribeValuePath("Value");
64 | settingsRecord.setRepresentationSubscribeTimestampPath("");
65 | settingsRecord.setRepresentationSubscribeTimestampIntegerFormat(TimestampIntegerFormat.UnixEpochNanoseconds);
66 | }
67 |
68 | @Override
69 | public boolean getEnabled() {
70 | return true;
71 | }
72 |
73 | @Override
74 | public Connection getConnection(GatewayContext context) throws Exception {
75 | var configError = false;
76 |
77 | // The name of the connection being created
78 | var name = getClass().getSimpleName();
79 | name = name.replace("SettingsRecord", "");
80 | name = name.replace(this.getClass().getSimpleName(), "");
81 | if (name.isBlank())
82 | name = "0";
83 | name = "Connection " + name;
84 | var logPrefix = name + " Configuration Error: ";
85 |
86 | // Broker Section
87 | var host = getBrokerHost();
88 | var port = getBrokerPort();
89 | var pubQos = getBrokerPublishQos();
90 | var subQos = getBrokerSubscribeQos();;
91 | var brokerSubscriptions = getBrokerSubscriptions();
92 | List subscriptions = new ArrayList<>();
93 | for (String subscription : brokerSubscriptions.split("\r\n"))
94 | if (!subscription.isBlank())
95 | subscriptions.add(subscription);
96 |
97 | if (getBrokerPublishQos() < 0 || getBrokerPublishQos() > 2) {
98 | configError = true;
99 | _logger.error(logPrefix + "Broker Publish QoS must be between 0 and 2");
100 | }
101 | if (getBrokerSubscribeQos() < 0 || getBrokerSubscribeQos() > 2) {
102 | configError = true;
103 | _logger.error(logPrefix + "Broker Publish QoS must be between 0 and 2");
104 | }
105 |
106 | // Tag Provider Section
107 | var pubProvider = getTagProviderPub();
108 | var subProvider = getTagProviderSub();
109 |
110 | if (pubProvider == null &&
111 | subProvider == null) {
112 | configError = true;
113 | _logger.error(logPrefix + "Publish-Only Tag Provider or Subscribe-Only Tag Provider must be specified");
114 | }
115 | if (pubProvider != null && Objects.equals(pubProvider, subProvider)) {
116 | configError = true;
117 | _logger.error(logPrefix + "Publish-Only Tag Provider and Subscribe-Only Tag Provider can not be the same");
118 | }
119 |
120 | // Routing Section
121 | var writeSuffix = getBrokerPublishTopicSuffix();
122 |
123 | // Representation Section
124 | var publishValuePath = getRepresentationPublishValuePath();
125 | var publishTimestampPath = getRepresentationPublishTimestampPath();
126 | var publishTimestampFormat = getRepresentationPublishTimestampFormat();
127 | var subscribeValuePath = getRepresentationSubscribeValuePath();
128 | var subscribeTimestampPath = getRepresentationSubscribeTimestampPath();
129 | var subscribeTimestampIntegerFormat = getRepresentationSubscribeTimestampIntegerFormat();
130 |
131 |
132 |
133 |
134 |
135 | TagManager tagManagerPublish = null;
136 | if (pubProvider != null) {
137 | try {
138 | tagManagerPublish = new TagManager(context, pubProvider);
139 | } catch (Exception e) {
140 | configError = true;
141 | _logger.error(logPrefix + "Could not configure Publish-Only Tag Provider: " + e.getMessage(), e);
142 | }
143 | }
144 |
145 | TagManager tagManagerSubscribe = null;
146 | if (subProvider != null) {
147 | try {
148 | tagManagerSubscribe = new TagManager(context, subProvider);
149 | } catch (Exception e) {
150 | configError = true;
151 | _logger.error(logPrefix + "Could not configure Subscribe-Only Tag Provider: " + e.getMessage(), e);
152 | }
153 | }
154 |
155 | JsonCoder coder = null;
156 | try {
157 | coder = new JsonCoder(subscribeValuePath, subscribeTimestampPath, publishValuePath,
158 | publishTimestampPath, publishTimestampFormat, subscribeTimestampIntegerFormat);
159 | } catch (Exception e) {
160 | configError = true;
161 | _logger.error(logPrefix + "Could not configure JsonCoder: " + e.getMessage(), e);
162 | }
163 |
164 | MqttManager mqttManager = null;
165 | try {
166 | mqttManager = new MqttManager(host, port, pubQos, subQos, coder, writeSuffix, subscriptions);
167 | } catch (Exception e) {
168 | configError = true;
169 | _logger.warn(logPrefix + "Could not configure MqttManager: " + e.getMessage(), e);
170 | }
171 |
172 | Connection connection = null;
173 | try {
174 | connection = new Connection(name, mqttManager, tagManagerPublish, tagManagerSubscribe);
175 | } catch (Exception e) {
176 | configError = true;
177 | _logger.warn(logPrefix + "Could not configure Connection: " + e.getMessage(), e);
178 | }
179 |
180 | if (configError)
181 | throw new Exception("Could not configure Connection: " + name);
182 |
183 | return connection;
184 | }
185 |
186 |
187 |
188 |
189 |
190 |
191 |
192 |
193 |
194 |
195 | // Broker Configuration
196 | public static final StringField BrokerHost = new StringField(META, "BrokerHost", SFieldFlags.SMANDATORY);
197 | public static final IntField BrokerPort = new IntField(META, "BrokerPort", SFieldFlags.SMANDATORY).setDefault(1883);
198 | public static final IntField BrokerPublishQos = new IntField(META, "BrokerPublishQoS", SFieldFlags.SMANDATORY).setDefault(0);
199 | public static final IntField BrokerSubscribeQos = new IntField(META, "BrokerSubscribeQoS", SFieldFlags.SMANDATORY).setDefault(0);
200 | public static final StringField BrokerSubscriptions = new StringField(META, "BrokerSubscriptions").setDefault("#").setMultiLine();
201 |
202 | static final Category Broker = new Category(BUNDLE_PREFIX + ".Category.Broker", 1001)
203 | .include(BrokerHost, BrokerPort, BrokerPublishQos, BrokerSubscribeQos, BrokerSubscriptions);
204 |
205 | public void setBrokerHost(String value) {
206 | setString(BrokerHost, value);
207 | }
208 |
209 | public String getBrokerHost() {
210 | return getString(BrokerHost);
211 | }
212 |
213 | public void setBrokerPort(int value) {
214 | setInt(BrokerPort, value);
215 | }
216 |
217 | public int getBrokerPort() {
218 | return getInt(BrokerPort);
219 | }
220 |
221 | public void setBrokerPublishQos(int value) {
222 | setInt(BrokerPublishQos, value);
223 | }
224 |
225 | public int getBrokerPublishQos() {
226 | return getInt(BrokerPublishQos);
227 | }
228 |
229 | public void setBrokerSubscribeQos(int value) {
230 | setInt(BrokerSubscribeQos, value);
231 | }
232 |
233 | public int getBrokerSubscribeQos() {
234 | return getInt(BrokerSubscribeQos);
235 | }
236 |
237 | public void setBrokerSubscriptions(String value) {
238 | setString(BrokerSubscriptions, value);
239 | }
240 |
241 | public String getBrokerSubscriptions() {
242 | return getString(BrokerSubscriptions);
243 | }
244 |
245 |
246 |
247 |
248 |
249 |
250 | // Tag Provider Configuration
251 | public static final StringField TagProviderPub = new StringField(META, "TagProviderPub");
252 | public static final StringField TagProviderSub = new StringField(META, "TagProviderSub");
253 |
254 | static final Category TagProviders = new Category(BUNDLE_PREFIX + ".Category.TagProviders", 1003)
255 | .include(TagProviderPub, TagProviderSub);
256 |
257 | public void setTagProviderSub(String value) {
258 | setString(TagProviderSub, value);
259 | }
260 |
261 | public String getTagProviderSub() {
262 | return getString(TagProviderSub);
263 | }
264 |
265 | public void setTagProviderPub(String value) {
266 | setString(TagProviderPub, value);
267 | }
268 |
269 | public String getTagProviderPub() {
270 | return getString(TagProviderPub);
271 | }
272 |
273 |
274 |
275 |
276 |
277 |
278 |
279 |
280 |
281 |
282 |
283 |
284 |
285 |
286 |
287 |
288 |
289 | public static final StringField BrokerPublishTopicSuffix = new StringField(META, "BrokerPublishTopicSuffix");
290 |
291 | static final Category Routing = new Category(BUNDLE_PREFIX + ".Category.Routing", 1004)
292 | .include(BrokerPublishTopicSuffix);
293 |
294 | public void setBrokerPublishTopicSuffix(String value) {
295 | setString(BrokerPublishTopicSuffix, value);
296 | }
297 |
298 | public String getBrokerPublishTopicSuffix() {
299 | return getString(BrokerPublishTopicSuffix);
300 | }
301 |
302 |
303 |
304 |
305 |
306 |
307 | // Representation Configuration
308 | public static final StringField RepresentationPublishValuePath = new StringField(META,
309 | "RepresentationPublishValuePath", SFieldFlags.SMANDATORY)
310 | .setDefault("Value");
311 | public static final StringField RepresentationPublishTimestampPath = new StringField(META,
312 | "RepresentationPublishTimestampPath", SFieldFlags.SMANDATORY)
313 | .setDefault("Timestamp");
314 | public static final EnumField RepresentationPublishTimestampFormat = new EnumField(META, "RepresentationPublishTimestampFormat", TimestampFormat.class).setDefault(TimestampFormat.ISO8601);
315 | public static final StringField RepresentationSubscribeValuePath = new StringField(META,
316 | "RepresentationSubscribeValuePath", SFieldFlags.SMANDATORY)
317 | .setDefault("Value");
318 | public static final StringField RepresentationSubscribeTimestampPath = new StringField(META,
319 | "RepresentationSubscribeTimestampPath");
320 | public static final EnumField RepresentationSubscribeTimestampIntegerFormat = new EnumField(META, "RepresentationSubscribeTimestampIntegerFormat", TimestampIntegerFormat.class).setDefault(TimestampIntegerFormat.UnixEpochNanoseconds);
321 |
322 |
323 | static final Category Representation = new Category(BUNDLE_PREFIX + ".Category.Representation", 1005)
324 | .include(RepresentationPublishValuePath, RepresentationPublishTimestampPath,
325 | RepresentationPublishTimestampFormat,
326 | RepresentationSubscribeValuePath, RepresentationSubscribeTimestampPath,
327 | RepresentationSubscribeTimestampIntegerFormat);
328 |
329 | public void setRepresentationPublishValuePath(String value) {
330 | setString(RepresentationPublishValuePath, value);
331 | }
332 |
333 | public String getRepresentationPublishValuePath() {
334 | return getString(RepresentationPublishValuePath);
335 | }
336 |
337 | public void setRepresentationPublishTimestampPath(String value) {
338 | setString(RepresentationPublishTimestampPath, value);
339 | }
340 |
341 | public String getRepresentationPublishTimestampPath() {
342 | return getString(RepresentationPublishTimestampPath);
343 | }
344 |
345 | public void setRepresentationPublishTimestampFormat(TimestampFormat value) {
346 | setEnum(RepresentationPublishTimestampFormat, value);
347 | }
348 |
349 | public TimestampFormat getRepresentationPublishTimestampFormat() {
350 | return (TimestampFormat)getEnum(RepresentationPublishTimestampFormat);
351 | }
352 |
353 | public void setRepresentationSubscribeValuePath(String value) {
354 | setString(RepresentationSubscribeValuePath, value);
355 | }
356 |
357 | public String getRepresentationSubscribeValuePath() {
358 | return getString(RepresentationSubscribeValuePath);
359 | }
360 |
361 | public void setRepresentationSubscribeTimestampPath(String value) {
362 | setString(RepresentationSubscribeTimestampPath, value);
363 | }
364 |
365 | public String getRepresentationSubscribeTimestampPath() {
366 | return getString(RepresentationSubscribeTimestampPath);
367 | }
368 |
369 | public void setRepresentationSubscribeTimestampIntegerFormat(TimestampIntegerFormat value) {
370 | setEnum(RepresentationSubscribeTimestampIntegerFormat, value);
371 | }
372 |
373 | public TimestampIntegerFormat getRepresentationSubscribeTimestampIntegerFormat() {
374 | return (TimestampIntegerFormat)getEnum(RepresentationSubscribeTimestampIntegerFormat);
375 | }
376 |
377 |
378 |
379 |
380 |
381 |
382 |
383 |
384 | }
385 |
--------------------------------------------------------------------------------
/src/gateway/src/main/resources/com/mrmccormick/ignition/hydra/mqtt/SettingsCategory.properties:
--------------------------------------------------------------------------------
1 | nav.header=Hydra
2 |
--------------------------------------------------------------------------------
/src/gateway/src/main/resources/com/mrmccormick/ignition/hydra/mqtt/SettingsPageX.properties:
--------------------------------------------------------------------------------
1 | nav.settings.title=MQTT
2 | nav.settings.panelTitle=MQTT
3 |
--------------------------------------------------------------------------------
/src/gateway/src/main/resources/com/mrmccormick/ignition/hydra/mqtt/settings/SettingsRecordX.properties:
--------------------------------------------------------------------------------
1 | Noun=Setting
2 | Noun.Plural=Settings
3 |
4 | Category.Broker=Broker
5 |
6 | BrokerHost.Name=Host
7 | BrokerPort.Name=Port
8 | BrokerPublishQoS.Name=Publish QoS
9 | BrokerSubscribeQoS.Name=Subscribe QoS
10 | BrokerSubscriptions.Name=Subscriptions
11 |
12 | Category.TagProviders=Tag Provider
13 |
14 | TagProviderSub.Name=Subscribe-Only Tag Provider
15 | TagProviderPub.Name=Publish-Only Tag Provider
16 |
17 |
18 | Category.Routing=Routing
19 |
20 | BrokerPublishTopicSuffix.Name=Publish Topic Suffix
21 |
22 |
23 | Category.Representation=Representation
24 |
25 | RepresentationPublishValuePath.Name=Publish Value Path
26 | RepresentationPublishTimestampPath.Name=Publish Timestamp Path
27 | RepresentationPublishTimestampFormat.Name=Publish Timestamp Format
28 | RepresentationSubscribeValuePath.Name=Subscribe Value Path
29 | RepresentationSubscribeTimestampPath.Name=Subscribe Timestamp Path
30 | RepresentationSubscribeTimestampIntegerFormat.Name=Subscribe Timestamp Integer Format
31 |
32 |
--------------------------------------------------------------------------------
/src/generate-secrets.sh:
--------------------------------------------------------------------------------
1 | #!/bin/bash
2 |
3 | secrets_dir="secrets"
4 | if [ ! -d "${secrets_dir}" ]; then
5 | mkdir "${secrets_dir}"
6 | if [ $? -ne 0 ]; then
7 | echo "Error: could not create keys directory"
8 | exit 1
9 | fi
10 | fi
11 |
12 | env_file="${secrets_dir}/env"
13 | if [ -f ${env_file} ]; then
14 | echo "Error: ${env_file} already exists"
15 | exit 1
16 | fi
17 |
18 | echo -e "\n\n\n\n\n\n\n"
19 | echo "-- This Requires User Input --"
20 | echo ""
21 | clear
22 | echo "Enter Certificate Signer Name:"
23 | read ca_name
24 |
25 | ca_password="password"
26 | keystore_name="keystore"
27 | keystore_password="password"
28 | alias_name="alias"
29 | alias_password="password"
30 | chain_name="chain"
31 |
32 | keystore="${secrets_dir}/${keystore_name}.jks"
33 | keystore_password_file="${secrets_dir}/keystore_password.txt"
34 | alias_name_file="${secrets_dir}/alias_name.txt"
35 | alias_password_file="${secrets_dir}/alias_password.txt"
36 | sign_crt="${secrets_dir}/sign.crt"
37 | chain_p7b="${secrets_dir}/${chain_name}.p7b"
38 |
39 | echo "keystore_file=\"${keystore}\"" >> "${env_file}"
40 | echo "keystore_password_file=\"${keystore_password_file}\"" >> "${env_file}"
41 | echo "alias_name_file=\"${alias_name_file}\"" >> "${env_file}"
42 | echo "alias_password_file=\"${alias_password_file}\"" >> "${env_file}"
43 | echo "chain_file=\"${chain_p7b}\"" >> "${env_file}"
44 |
45 | echo "${alias_name}" > "${alias_name_file}"
46 | echo "${keystore_password}" > "${keystore_password_file}"
47 | echo "${alias_password}" > "${alias_password_file}"
48 |
49 |
50 | ca_srl=$(echo "${ca_key}" | sed 's/.key$/.srl/g')
51 |
52 | # Create a keystore and key for signing
53 | echo ""
54 | echo "Generating a Keystore and Key..."
55 | keytool -genkeypair -alias "${alias_name}" -keyalg RSA -keysize 2048 -keystore "${keystore}" -validity 3650 -storepass "${keystore_password}" -keypass "${alias_password}" -dname "CN=${ca_name}, OU=YourOrganizationalUnit, O=YourOrganization, L=YourCity, S=YourState, C=YourCountry"
56 | if [ $? -ne 0 ]; then
57 | echo "Could not Generate Keystore and Key"
58 | exit 1
59 | fi
60 |
61 | echo ""
62 | echo "Exporting Certificate..."
63 | keytool -exportcert -alias "${alias_name}" -keystore "${keystore}" -storepass "${keystore_password}" -file "${sign_crt}" -rfc
64 |
65 | echo ""
66 | echo "Converting Certificate to pkcs7 format..."
67 | openssl crl2pkcs7 -nocrl -certfile "${sign_crt}" -out "${chain_p7b}"
68 | if [ $? -ne 0 ]; then
69 | echo "Could not Convert to pkcs7 format."
70 | exit 1
71 | fi
72 |
73 | echo ""
74 | echo "Removing temporary files..."
75 | if [ -f "${ca_srl}" ]; then
76 | rm "${ca_srl}"
77 | fi
78 | if [ -f "${sign_csr}" ]; then
79 | rm "${sign_csr}"
80 | fi
81 | if [ -f "${sign_crt}" ]; then
82 | rm "${sign_crt}"
83 | fi
84 |
85 | echo ""
86 | echo "Completed"
87 |
88 |
--------------------------------------------------------------------------------
/src/makefile:
--------------------------------------------------------------------------------
1 | .PHONY: all sign build
2 |
3 | all: sign
4 |
5 | clean:
6 | @echo Cleaning Module...
7 |
8 | @if [ -f pom.xml.versionsBackup ]; then \
9 | mv pom.xml.versionsBackup pom.xml; \
10 | fi
11 | @if [ -f build/pom.xml.versionsBackup ]; then \
12 | mv build/pom.xml.versionsBackup build/pom.xml; \
13 | fi
14 | @if [ -f gateway/pom.xml.versionsBackup ]; then \
15 | mv gateway/pom.xml.versionsBackup gateway/pom.xml; \
16 | fi
17 |
18 | @rm -f *.modl
19 | @mvn clean -q
20 | @echo Clean Module Complete.
21 |
22 | build:
23 | @if [ -f pom.xml.versionsBackup ]; then \
24 | mv pom.xml.versionsBackup pom.xml; \
25 | fi
26 | @if [ -f build/pom.xml.versionsBackup ]; then \
27 | mv build/pom.xml.versionsBackup build/pom.xml; \
28 | fi
29 | @if [ -f gateway/pom.xml.versionsBackup ]; then \
30 | mv gateway/pom.xml.versionsBackup gateway/pom.xml; \
31 | fi
32 |
33 | @VERSION=$$(cat version) && \
34 | BUILD_VERSION="-SNAPSHOT"; \
35 | if [ "$${BUILD_CONFIG}" = "RELEASE" ]; then \
36 | BUILD_VERSION=""; \
37 | echo "Building Module in Release Configuration..."; \
38 | else \
39 | echo "Building Module in Pre-Release Configuration..."; \
40 | fi; \
41 | if [ -z $${BUILD_NUMBER} ]; then BUILD_NUMBER=1; fi && \
42 | mvn versions:set -DnewVersion=$${VERSION}$${BUILD_VERSION} -Dbuild.number=$${BUILD_NUMBER} -DgenerateBackupPoms=true -f pom.xml -q
43 | @mvn package -q
44 |
45 | @if [ -f pom.xml.versionsBackup ]; then \
46 | mv pom.xml.versionsBackup pom.xml; \
47 | fi
48 | @if [ -f build/pom.xml.versionsBackup ]; then \
49 | mv build/pom.xml.versionsBackup build/pom.xml; \
50 | fi
51 | @if [ -f gateway/pom.xml.versionsBackup ]; then \
52 | mv gateway/pom.xml.versionsBackup gateway/pom.xml; \
53 | fi
54 |
55 | @echo Build Module Complete.
56 |
57 | FIND_DIR="."
58 | OUTPUT_DIR="."
59 | SECRETS_DIR="secrets"
60 | sign: build
61 | @if [ ! -f "module-signer.jar" ]; then \
62 | rm -rf "module-signer/"; \
63 | echo "Downloading and building module signer..."; \
64 | git clone --quiet https://github.com/inductiveautomation/module-signer; \
65 | $$(cd "module-signer" && mvn package -q); \
66 | target=$$(find "module-signer/target/" | grep "module-signer.*with-dependencies.jar"); \
67 | echo "target: $${target}"; \
68 | mv "$${target}" "module-signer.jar"; \
69 | rm -rf "module-signer/"; \
70 | fi
71 |
72 | @if [ ! -d "$(SECRETS_DIR)" ]; then mkdir "$(SECRETS_DIR)"; fi
73 | @if [ ! -f "$(SECRETS_DIR)/env" ]; then \
74 | ./generate-secrets.sh; \
75 | fi
76 |
77 | @targets=$$(find $(FIND_DIR) -mindepth 2 -type f -name '*unsigned.modl'); \
78 | if [ -z "$${targets}" ]; then \
79 | $(MAKE) build; \
80 | fi
81 |
82 | @targets=$$(find $(FIND_DIR) -mindepth 2 -type f -name '*unsigned.modl'); \
83 | if [ -z "$${targets}" ]; then \
84 | echo "Error: No Modules To Sign"; \
85 | exit 1; \
86 | fi
87 |
88 | @if [ ! -d $(OUTPUT_DIR) ]; then mkdir $(OUTPUT_DIR); fi
89 |
90 | @echo "Signing Modules..."
91 |
92 | @. ./$(SECRETS_DIR)/env; \
93 | keystore_password=$$(cat $${keystore_password_file}) && \
94 | alias_name=$$(cat $${alias_name_file}) && \
95 | alias_password=$$(cat $${alias_password_file}) && \
96 | targets=$$(find $(FIND_DIR) -type f -name '*unsigned.modl') && \
97 | version=$$(cat "../src/version") && \
98 | for target in $${targets}; do \
99 | file_base_name=$$(basename "$${target}" | sed 's/-unsigned.modl//g') && \
100 | # file_name="$${file_base_name}-$${version}.modl" && \
101 | file_name="$${file_base_name}.modl" && \
102 | java -jar module-signer.jar -keystore=$${keystore_file} -keystore-pwd=$${keystore_password} -alias=$${alias_name} -alias-pwd=$${alias_password} -chain=$${chain_file} -module-in=$${target} -module-out=$(OUTPUT_DIR)/$${file_name} && \
103 | echo "Module Signed: $${file_name}"; \
104 | done
105 |
106 | @echo "All Modules Signed"
107 |
108 |
--------------------------------------------------------------------------------
/src/pom.xml:
--------------------------------------------------------------------------------
1 |
2 |
5 | 4.0.0
6 |
7 | com.inductiveautomation.ignition.mrmccormick
8 | module
9 | 0.0.0-SNAPSHOT
10 | pom
11 |
12 |
13 | build
14 | gateway
15 |
16 |
17 |
18 | 8.1.40
19 | ${ignition-platform-version}
20 | Hydra-MQTT
21 | A MQTT connector.
22 | UTF-8
23 | UTF-8
24 |
25 |
26 |
27 |
28 | releases
29 | https://nexus.inductiveautomation.com/repository/inductiveautomation-releases
30 |
31 | true
32 | always
33 |
34 |
35 | false
36 |
37 |
38 |
39 |
40 |
41 |
42 | releases
43 | https://nexus.inductiveautomation.com/repository/inductiveautomation-releases
44 |
45 | false
46 |
47 |
48 | true
49 | always
50 |
51 |
52 |
53 |
54 | snapshots
55 | https://nexus.inductiveautomation.com/repository/inductiveautomation-snapshots
56 |
57 | true
58 | always
59 |
60 |
61 | false
62 |
63 |
64 |
65 |
66 | thirdparty
67 | https://nexus.inductiveautomation.com/repository/inductiveautomation-thirdparty
68 |
69 | true
70 | always
71 |
72 |
73 | false
74 |
75 |
76 |
77 |
78 |
--------------------------------------------------------------------------------
/src/version:
--------------------------------------------------------------------------------
1 | 0.0.2
2 |
--------------------------------------------------------------------------------