├── .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 | 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 | | description | 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 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 | --------------------------------------------------------------------------------