├── .dockerignore ├── .github ├── FUNDING.yml └── workflows │ └── build_publish.yml ├── .gitignore ├── .travis.yml ├── Docker ├── Dockerfile └── hooks │ ├── README.md │ └── build ├── LICENSE.txt ├── README.md ├── app ├── cli.ts ├── electron.ts ├── interfaces │ ├── Config.ts │ ├── Device.ts │ ├── SocketMessage.ts │ └── Telegram.ts ├── logo.jpg ├── package-lock.json ├── package.json ├── scripts │ └── make-node-pkg.js ├── server.ts ├── src │ ├── DutyCyclePerTelegram.ts │ ├── SnifferParser.ts │ ├── Trigger.ts │ ├── deviceList.ts │ ├── errors.ts │ ├── init.ts │ ├── persistentStorage.ts │ ├── serialIn.ts │ ├── server.ts │ ├── store.ts │ ├── triggerActions.ts │ └── websocket-rpc.ts └── tsconfig.json ├── docs ├── AskSinAnalyzerXS-DutyCycle.png ├── AskSinAnalyzerXS-TelegramList.png ├── Install_as_Debian_Service.md ├── Install_with_docker-compose.md ├── NanoCul_with_display.jpg ├── NodeRED-RSSI-Noise_Alert-Trigger_Example.png ├── NodeRED-Websocket_example.png ├── NodeRED.md └── Proxmox-LXC-USB-paththrough.md └── ui ├── .browserslistrc ├── README.md ├── babel.config.js ├── package-lock.json ├── package.json ├── postcss.config.js ├── public ├── android-icon-144x144.png ├── android-icon-192x192.png ├── android-icon-36x36.png ├── android-icon-48x48.png ├── android-icon-72x72.png ├── android-icon-96x96.png ├── apple-icon-114x114.png ├── apple-icon-120x120.png ├── apple-icon-144x144.png ├── apple-icon-152x152.png ├── apple-icon-180x180.png ├── apple-icon-57x57.png ├── apple-icon-60x60.png ├── apple-icon-72x72.png ├── apple-icon-76x76.png ├── apple-icon-precomposed.png ├── apple-icon.png ├── browserconfig.xml ├── favicon-16x16.png ├── favicon-32x32.png ├── favicon-96x96.png ├── favicon.ico ├── index.html ├── logos │ ├── Electron.png │ ├── Highcharts.png │ ├── NodeJS.svg │ ├── Quasar.svg │ └── Vue.js.svg ├── manifest.json ├── ms-icon-144x144.png ├── ms-icon-150x150.png ├── ms-icon-310x310.png └── ms-icon-70x70.png ├── src ├── App.vue ├── Service.js ├── assets │ └── logo.png ├── components │ ├── DutyCyclePerDevice.vue │ ├── Errors.vue │ ├── FlagChip.vue │ ├── HistoryFileList.vue │ ├── PieChart.vue │ ├── RssiValue.vue │ ├── Settings.vue │ ├── TelegramList.vue │ ├── TimeChart.vue │ ├── Trigger.vue │ └── filters │ │ ├── RssiFilter.vue │ │ ├── SelectFilter.vue │ │ └── TimeFilter.vue ├── filter │ ├── date.js │ ├── filesize.js │ └── index.js ├── main.js ├── router.js ├── styles │ ├── quasar.styl │ └── quasar.variables.styl ├── views │ ├── 404.vue │ ├── Einstellungen.vue │ ├── HistoryFiles.vue │ ├── Home.vue │ ├── Info.vue │ ├── TelegramList.vue │ └── WithTimeChart.vue └── webSocketClient.js └── vue.config.js /.dockerignore: -------------------------------------------------------------------------------- 1 | **/node_modules 2 | **/dist 3 | **/tmp 4 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | ko_fi: christoph_psi 2 | -------------------------------------------------------------------------------- /.github/workflows/build_publish.yml: -------------------------------------------------------------------------------- 1 | name: Build and Publish 2 | 3 | on: 4 | push: 5 | tags: 6 | - 'v*.*.*' 7 | branches: 8 | - master 9 | 10 | 11 | jobs: 12 | update_dev_tag: 13 | name: Update dev tag 14 | runs-on: ubuntu-latest 15 | if: ${{ github.ref_name == 'master' }} 16 | steps: 17 | - name: Check out Git repository 18 | uses: actions/checkout@v2 19 | - name: Update tag 20 | run: | 21 | export OWNER=$(echo $GITHUB_REPOSITORY | cut -d/ -f1) 22 | git config --global user.name 'Github Action' 23 | git config --global user.email $OWNER@users.noreply.github.com 24 | git tag -f v0.0.0 25 | git push -f origin v0.0.0 26 | 27 | prepare_release: 28 | name: Prepare Release 29 | runs-on: ubuntu-latest 30 | steps: 31 | - name: Derive correct Tag 32 | id: derive_tag_name 33 | run: | 34 | if [ $GITHUB_REF_NAME == "master" ]; then 35 | echo "::set-output name=tag_name::v0.0.0" 36 | else 37 | echo "::set-output name=tag_name::$GITHUB_REF_NAME" 38 | fi 39 | - name: Release 40 | if: ${{ github.ref_name != 'master' }} 41 | uses: softprops/action-gh-release@v1 42 | with: 43 | tag_name: ${{ steps.derive_tag_name.outputs.tag_name }} 44 | prerelease: true 45 | outputs: 46 | tag: ${{ steps.derive_tag_name.outputs.tag_name }} 47 | 48 | build: 49 | name: App build 50 | needs: [ prepare_release ] 51 | runs-on: ${{ matrix.os }} 52 | strategy: 53 | matrix: 54 | os: [ macos-latest, ubuntu-latest, windows-latest ] 55 | 56 | steps: 57 | - name: Check out Git repository 58 | uses: actions/checkout@v2 59 | 60 | - name: Install Node.js and NPM 61 | uses: actions/setup-node@v2 62 | with: 63 | node-version: 14 64 | registry-url: 'https://registry.npmjs.org' 65 | 66 | - name: Update version in package.json 67 | run: | 68 | cd app 69 | npm version ${{ needs.prepare_release.outputs.tag }} --allow-same-version 70 | 71 | - name: Build AskSinAnalyzerXS UI 72 | run: | 73 | cd ui 74 | npm ci 75 | npm run build 76 | 77 | - name: Build AskSinAnalyzerXS 78 | run: | 79 | cd app 80 | npm ci 81 | npm run tsc 82 | 83 | - name: Create NPM bundle 84 | if: runner.os == 'Linux' 85 | run: | 86 | cd app 87 | node scripts/make-node-pkg.js 88 | 89 | - name: Publish NPM bundle 90 | if: runner.os == 'Linux' && needs.prepare_release.outputs.tag != 'v0.0.0' 91 | env: 92 | NODE_AUTH_TOKEN: ${{ secrets.NPM_API_TOKEN }} 93 | run: | 94 | cd builds/asksin-analyzer-xs-*-node 95 | npm publish 96 | 97 | - name: Build Electron App 98 | uses: samuelmeuli/action-electron-builder@v1 99 | with: 100 | package_root: app 101 | github_token: ${{ secrets.github_token }} 102 | 103 | - name: Release 104 | uses: softprops/action-gh-release@v1 105 | with: 106 | tag_name: ${{ needs.prepare_release.outputs.tag }} 107 | fail_on_unmatched_files: false 108 | files: | 109 | builds/asksin-analyzer-xs-*-node.tar.gz 110 | builds/asksin-analyzer-xs-*-win.* 111 | builds/asksin-analyzer-xs-*-mac.* 112 | builds/asksin-analyzer-xs-*-linux.* 113 | 114 | build_docker: 115 | name: Docker build 116 | runs-on: ubuntu-latest 117 | steps: 118 | - name: Derive docker tag 119 | id: vars 120 | run: | 121 | if [ $GITHUB_REF_NAME == "master" ]; then 122 | echo "::set-output name=tag::latest" 123 | else 124 | echo "::set-output name=tag::${GITHUB_REF_NAME:1}" 125 | fi 126 | 127 | - name: Checkout 128 | uses: actions/checkout@v2 129 | 130 | - name: Setup QEMU 131 | uses: docker/setup-qemu-action@v1 132 | 133 | - name: Setup Docker buildx 134 | uses: docker/setup-buildx-action@v1 135 | 136 | - name: Login to DockerHub 137 | uses: docker/login-action@v1 138 | with: 139 | username: psitrax 140 | password: ${{ secrets.DOCKERHUB_TOKEN }} 141 | 142 | - name: Login to GitHub Container Registry 143 | uses: docker/login-action@v1 144 | with: 145 | registry: ghcr.io 146 | username: ${{ github.repository_owner }} 147 | password: ${{ secrets.GITHUB_TOKEN }} 148 | 149 | - name: Build Docker image 150 | uses: docker/build-push-action@v2 151 | env: 152 | DOCKER_TAG: ${{ steps.vars.outputs.tag }} 153 | with: 154 | file: Docker/Dockerfile 155 | platforms: linux/amd64,linux/arm64,linux/arm/v7 156 | push: true 157 | tags: | 158 | psitrax/asksinanalyzer:${{ steps.vars.outputs.tag }} 159 | ghcr.io/psi-4ward/asksinanalyzerxs:${{ steps.vars.outputs.tag }} 160 | 161 | 162 | publish_release: 163 | name: Publish Release 164 | if: ${{ github.ref_name != 'master' }} 165 | runs-on: ubuntu-latest 166 | needs: [ build ] 167 | steps: 168 | - name: Create Changelog 169 | id: github_release 170 | uses: mikepenz/release-changelog-builder-action@v1 171 | env: 172 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 173 | with: 174 | commitMode: true 175 | 176 | - name: Release 177 | uses: softprops/action-gh-release@v1 178 | with: 179 | prerelease: false 180 | body: ${{ needs.prepare_release.outputs.tag }} 181 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | CHANGELOG.html 2 | .DS_Store 3 | node_modules 4 | /dist* 5 | /app/dist 6 | /ui/dist 7 | /temp 8 | /htdocs 9 | /builds 10 | /tmp 11 | 12 | # local env files 13 | .env.local 14 | .env.*.local 15 | 16 | # Log files 17 | npm-debug.log* 18 | yarn-debug.log* 19 | yarn-error.log* 20 | 21 | # Editor directories and files 22 | .idea 23 | .vscode 24 | *.suo 25 | *.ntvs* 26 | *.njsproj 27 | *.sln 28 | *.sw? 29 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | jobs: 2 | # Build in parallel 3 | include: 4 | - os: osx 5 | osx_image: xcode10.2 6 | language: node_js 7 | node_js: "12" 8 | env: 9 | - ELECTRON_CACHE=$HOME/.cache/electron 10 | - ELECTRON_BUILDER_CACHE=$HOME/.cache/electron-builder 11 | script: 12 | # Use 0.0.0 as TRAVIS_TAG if no tag was pushed; used in deploy section 13 | # Update version in package.json 14 | - > 15 | [ -n "$TRAVIS_TAG" ] && (cd app && npm version $TRAVIS_TAG) || export TRAVIS_TAG=0.0.0 16 | - echo Current Tag is $TRAVIS_TAG 17 | # Update DevTag 0.0.0 to current master branch 18 | - > 19 | [ "$TRAVIS_TAG" == "0.0.0" ] 20 | && git pull --tags 21 | && git tag -f $TRAVIS_TAG 22 | && git push -f https://$GH_TOKEN@github.com/$TRAVIS_REPO_SLUG.git $TRAVIS_TAG 23 | || true 24 | # Build 25 | - (cd ui && npm ci && npm run build) 26 | - (cd app && npm ci && npm run tsc && npm run electron:build -- -m) 27 | - (cd app && node scripts/make-node-pkg.js) ; 28 | # npm deploy (not using travis deploy cause of different conditions) 29 | - > 30 | if [ "$TRAVIS_TAG" != "0.0.0" ] ; then 31 | cd builds/asksin-analyzer-xs-*-node ; 32 | npm set //registry.npmjs.org/:_authToken $NPM_API_TOKEN ; 33 | npm publish ; 34 | cd .. 35 | rm -rf asksin-analyzer-xs-*-node 36 | cd .. ; 37 | fi 38 | 39 | - os: linux 40 | services: docker 41 | language: generic 42 | before_cache: 43 | - rm -rf $HOME/.cache/electron-builder/wine 44 | script: 45 | - > 46 | [ -n "$TRAVIS_TAG" ] && (cd app && npm version $TRAVIS_TAG) || export TRAVIS_TAG=0.0.0 47 | - > 48 | docker run --rm 49 | --env GH_TOKEN=$GH_TOKEN --env VUE_APP_COMMIT_HASH=$VUE_APP_COMMIT_HASH --env TRAVIS_TAG=$TRAVIS_TAG 50 | -v ${PWD}:/project 51 | -v ~/.cache/electron:/root/.cache/electron 52 | -v ~/.cache/electron-builder:/root/.cache/electron-builder 53 | electronuserland/builder:wine 54 | /bin/bash -c " 55 | echo Current Tag is $TRAVIS_TAG && 56 | [ $TRAVIS_TAG != 0.0.0 ] && (cd app && npm version $TRAVIS_TAG) || true && 57 | echo Current Tag is $TRAVIS_TAG Commit Hash is $VUE_APP_COMMIT_HASH && 58 | (cd ui && npm ci && npm run build) && 59 | (cd app && npm ci && npm run tsc && npm run electron:build -- -lw) && 60 | chown -R $UID /root/.cache/electron* 61 | " 62 | 63 | before_script: 64 | - export VUE_APP_COMMIT_HASH=$(git log --pretty=format:'%h' -n 1) 65 | 66 | # Create Github Release / Update Assets for existing releases 67 | deploy: 68 | provider: releases 69 | token: $GH_TOKEN 70 | overwrite: true 71 | file_glob: true 72 | file: $TRAVIS_BUILD_DIR/builds/asksin-analyzer-xs-*.* 73 | skip_cleanup: true 74 | on: 75 | all_branches: true 76 | 77 | cache: 78 | directories: 79 | - app/node_modules 80 | - ui/node_modules 81 | - $HOME/.cache/electron 82 | - $HOME/.cache/electron-builder 83 | 84 | # Prevent build loop when re-tagging 0.0.0 85 | branches: 86 | except: 87 | - "0.0.0" 88 | -------------------------------------------------------------------------------- /Docker/Dockerfile: -------------------------------------------------------------------------------- 1 | # docker build -t asksinanalyzer -f Docker/Dockerfile . 2 | # docker run --rm --name analyzer -p 8081:8081 -v $PWD/data:/data --device=/dev/ttyUSB0 psitrax/asksinanalyzer 3 | FROM node:12-alpine 4 | MAINTAINER Christoph Wiechert 5 | 6 | ARG DOCKER_TAG=${DOCKER_TAG} 7 | 8 | COPY /app /tmp/src/app 9 | COPY /ui /tmp/src/ui 10 | COPY /README.md /tmp/src/ 11 | 12 | RUN ls -l /tmp/src/app 13 | 14 | RUN mkdir /data && \ 15 | chown node /data && \ 16 | apk add --no-cache udev && \ 17 | apk add --no-cache --virtual build-deps python3 make g++ linux-headers && \ 18 | if [ -n "$DOCKER_TAG" ] ; then cd /tmp/src/app && npm version --allow-same-version $DOCKER_TAG ; fi && \ 19 | cd /tmp/src/app && npm ci && npm run tsc && \ 20 | cd /tmp/src/ui && npm ci && npm run build && \ 21 | cd /tmp/src/app && node scripts/make-node-pkg.js && \ 22 | npm install -g --quiet --unsafe /tmp/src/builds/asksin-analyzer-xs-*-node.tar.gz && \ 23 | rm -rf /tmp/src && rm -rf /root/.cache && rm -rf /root/.npm && \ 24 | apk del build-deps 25 | 26 | WORKDIR /app 27 | ENV NODE_ENV=production 28 | 29 | EXPOSE 8081 30 | VOLUME ["/data"] 31 | 32 | CMD ["asksin-analyzer-xs", "-d", "/data"] 33 | -------------------------------------------------------------------------------- /Docker/hooks/README.md: -------------------------------------------------------------------------------- 1 | Docker automated build hooks 2 | -------------------------------------------------------------------------------- /Docker/hooks/build: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | cd .. 3 | echo BUILD FOR TAG: $DOCKER_TAG 4 | if [ "$DOCKER_TAG" != "latest" ] ; then 5 | docker build --build-arg DOCKER_TAG=$DOCKER_TAG -f Docker/Dockerfile -t $IMAGE_NAME . 6 | else 7 | docker build -f Docker/Dockerfile -t $IMAGE_NAME . 8 | fi 9 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | Attribution-NonCommercial-ShareAlike 4.0 International 2 | 3 | ======================================================================= 4 | 5 | Creative Commons Corporation ("Creative Commons") is not a law firm and 6 | does not provide legal services or legal advice. Distribution of 7 | Creative Commons public licenses does not create a lawyer-client or 8 | other relationship. Creative Commons makes its licenses and related 9 | information available on an "as-is" basis. Creative Commons gives no 10 | warranties regarding its licenses, any material licensed under their 11 | terms and conditions, or any related information. Creative Commons 12 | disclaims all liability for damages resulting from their use to the 13 | fullest extent possible. 14 | 15 | Using Creative Commons Public Licenses 16 | 17 | Creative Commons public licenses provide a standard set of terms and 18 | conditions that creators and other rights holders may use to share 19 | original works of authorship and other material subject to copyright 20 | and certain other rights specified in the public license below. The 21 | following considerations are for informational purposes only, are not 22 | exhaustive, and do not form part of our licenses. 23 | 24 | Considerations for licensors: Our public licenses are 25 | intended for use by those authorized to give the public 26 | permission to use material in ways otherwise restricted by 27 | copyright and certain other rights. Our licenses are 28 | irrevocable. Licensors should read and understand the terms 29 | and conditions of the license they choose before applying it. 30 | Licensors should also secure all rights necessary before 31 | applying our licenses so that the public can reuse the 32 | material as expected. Licensors should clearly mark any 33 | material not subject to the license. This includes other CC- 34 | licensed material, or material used under an exception or 35 | limitation to copyright. More considerations for licensors: 36 | wiki.creativecommons.org/Considerations_for_licensors 37 | 38 | Considerations for the public: By using one of our public 39 | licenses, a licensor grants the public permission to use the 40 | licensed material under specified terms and conditions. If 41 | the licensor's permission is not necessary for any reason--for 42 | example, because of any applicable exception or limitation to 43 | copyright--then that use is not regulated by the license. Our 44 | licenses grant only permissions under copyright and certain 45 | other rights that a licensor has authority to grant. Use of 46 | the licensed material may still be restricted for other 47 | reasons, including because others have copyright or other 48 | rights in the material. A licensor may make special requests, 49 | such as asking that all changes be marked or described. 50 | Although not required by our licenses, you are encouraged to 51 | respect those requests where reasonable. More_considerations 52 | for the public: 53 | wiki.creativecommons.org/Considerations_for_licensees 54 | 55 | ======================================================================= 56 | 57 | Creative Commons Attribution-NonCommercial-ShareAlike 4.0 International 58 | Public License 59 | 60 | By exercising the Licensed Rights (defined below), You accept and agree 61 | to be bound by the terms and conditions of this Creative Commons 62 | Attribution-NonCommercial-ShareAlike 4.0 International Public License 63 | ("Public License"). To the extent this Public License may be 64 | interpreted as a contract, You are granted the Licensed Rights in 65 | consideration of Your acceptance of these terms and conditions, and the 66 | Licensor grants You such rights in consideration of benefits the 67 | Licensor receives from making the Licensed Material available under 68 | these terms and conditions. 69 | 70 | 71 | Section 1 -- Definitions. 72 | 73 | a. Adapted Material means material subject to Copyright and Similar 74 | Rights that is derived from or based upon the Licensed Material 75 | and in which the Licensed Material is translated, altered, 76 | arranged, transformed, or otherwise modified in a manner requiring 77 | permission under the Copyright and Similar Rights held by the 78 | Licensor. For purposes of this Public License, where the Licensed 79 | Material is a musical work, performance, or sound recording, 80 | Adapted Material is always produced where the Licensed Material is 81 | synched in timed relation with a moving image. 82 | 83 | b. Adapter's License means the license You apply to Your Copyright 84 | and Similar Rights in Your contributions to Adapted Material in 85 | accordance with the terms and conditions of this Public License. 86 | 87 | c. BY-NC-SA Compatible License means a license listed at 88 | creativecommons.org/compatiblelicenses, approved by Creative 89 | Commons as essentially the equivalent of this Public License. 90 | 91 | d. Copyright and Similar Rights means copyright and/or similar rights 92 | closely related to copyright including, without limitation, 93 | performance, broadcast, sound recording, and Sui Generis Database 94 | Rights, without regard to how the rights are labeled or 95 | categorized. For purposes of this Public License, the rights 96 | specified in Section 2(b)(1)-(2) are not Copyright and Similar 97 | Rights. 98 | 99 | e. Effective Technological Measures means those measures that, in the 100 | absence of proper authority, may not be circumvented under laws 101 | fulfilling obligations under Article 11 of the WIPO Copyright 102 | Treaty adopted on December 20, 1996, and/or similar international 103 | agreements. 104 | 105 | f. Exceptions and Limitations means fair use, fair dealing, and/or 106 | any other exception or limitation to Copyright and Similar Rights 107 | that applies to Your use of the Licensed Material. 108 | 109 | g. License Elements means the license attributes listed in the name 110 | of a Creative Commons Public License. The License Elements of this 111 | Public License are Attribution, NonCommercial, and ShareAlike. 112 | 113 | h. Licensed Material means the artistic or literary work, database, 114 | or other material to which the Licensor applied this Public 115 | License. 116 | 117 | i. Licensed Rights means the rights granted to You subject to the 118 | terms and conditions of this Public License, which are limited to 119 | all Copyright and Similar Rights that apply to Your use of the 120 | Licensed Material and that the Licensor has authority to license. 121 | 122 | j. Licensor means the individual(s) or entity(ies) granting rights 123 | under this Public License. 124 | 125 | k. NonCommercial means not primarily intended for or directed towards 126 | commercial advantage or monetary compensation. For purposes of 127 | this Public License, the exchange of the Licensed Material for 128 | other material subject to Copyright and Similar Rights by digital 129 | file-sharing or similar means is NonCommercial provided there is 130 | no payment of monetary compensation in connection with the 131 | exchange. 132 | 133 | l. Share means to provide material to the public by any means or 134 | process that requires permission under the Licensed Rights, such 135 | as reproduction, public display, public performance, distribution, 136 | dissemination, communication, or importation, and to make material 137 | available to the public including in ways that members of the 138 | public may access the material from a place and at a time 139 | individually chosen by them. 140 | 141 | m. Sui Generis Database Rights means rights other than copyright 142 | resulting from Directive 96/9/EC of the European Parliament and of 143 | the Council of 11 March 1996 on the legal protection of databases, 144 | as amended and/or succeeded, as well as other essentially 145 | equivalent rights anywhere in the world. 146 | 147 | n. You means the individual or entity exercising the Licensed Rights 148 | under this Public License. Your has a corresponding meaning. 149 | 150 | 151 | Section 2 -- Scope. 152 | 153 | a. License grant. 154 | 155 | 1. Subject to the terms and conditions of this Public License, 156 | the Licensor hereby grants You a worldwide, royalty-free, 157 | non-sublicensable, non-exclusive, irrevocable license to 158 | exercise the Licensed Rights in the Licensed Material to: 159 | 160 | a. reproduce and Share the Licensed Material, in whole or 161 | in part, for NonCommercial purposes only; and 162 | 163 | b. produce, reproduce, and Share Adapted Material for 164 | NonCommercial purposes only. 165 | 166 | 2. Exceptions and Limitations. For the avoidance of doubt, where 167 | Exceptions and Limitations apply to Your use, this Public 168 | License does not apply, and You do not need to comply with 169 | its terms and conditions. 170 | 171 | 3. Term. The term of this Public License is specified in Section 172 | 6(a). 173 | 174 | 4. Media and formats; technical modifications allowed. The 175 | Licensor authorizes You to exercise the Licensed Rights in 176 | all media and formats whether now known or hereafter created, 177 | and to make technical modifications necessary to do so. The 178 | Licensor waives and/or agrees not to assert any right or 179 | authority to forbid You from making technical modifications 180 | necessary to exercise the Licensed Rights, including 181 | technical modifications necessary to circumvent Effective 182 | Technological Measures. For purposes of this Public License, 183 | simply making modifications authorized by this Section 2(a) 184 | (4) never produces Adapted Material. 185 | 186 | 5. Downstream recipients. 187 | 188 | a. Offer from the Licensor -- Licensed Material. Every 189 | recipient of the Licensed Material automatically 190 | receives an offer from the Licensor to exercise the 191 | Licensed Rights under the terms and conditions of this 192 | Public License. 193 | 194 | b. Additional offer from the Licensor -- Adapted Material. 195 | Every recipient of Adapted Material from You 196 | automatically receives an offer from the Licensor to 197 | exercise the Licensed Rights in the Adapted Material 198 | under the conditions of the Adapter's License You apply. 199 | 200 | c. No downstream restrictions. You may not offer or impose 201 | any additional or different terms or conditions on, or 202 | apply any Effective Technological Measures to, the 203 | Licensed Material if doing so restricts exercise of the 204 | Licensed Rights by any recipient of the Licensed 205 | Material. 206 | 207 | 6. No endorsement. Nothing in this Public License constitutes or 208 | may be construed as permission to assert or imply that You 209 | are, or that Your use of the Licensed Material is, connected 210 | with, or sponsored, endorsed, or granted official status by, 211 | the Licensor or others designated to receive attribution as 212 | provided in Section 3(a)(1)(A)(i). 213 | 214 | b. Other rights. 215 | 216 | 1. Moral rights, such as the right of integrity, are not 217 | licensed under this Public License, nor are publicity, 218 | privacy, and/or other similar personality rights; however, to 219 | the extent possible, the Licensor waives and/or agrees not to 220 | assert any such rights held by the Licensor to the limited 221 | extent necessary to allow You to exercise the Licensed 222 | Rights, but not otherwise. 223 | 224 | 2. Patent and trademark rights are not licensed under this 225 | Public License. 226 | 227 | 3. To the extent possible, the Licensor waives any right to 228 | collect royalties from You for the exercise of the Licensed 229 | Rights, whether directly or through a collecting society 230 | under any voluntary or waivable statutory or compulsory 231 | licensing scheme. In all other cases the Licensor expressly 232 | reserves any right to collect such royalties, including when 233 | the Licensed Material is used other than for NonCommercial 234 | purposes. 235 | 236 | 237 | Section 3 -- License Conditions. 238 | 239 | Your exercise of the Licensed Rights is expressly made subject to the 240 | following conditions. 241 | 242 | a. Attribution. 243 | 244 | 1. If You Share the Licensed Material (including in modified 245 | form), You must: 246 | 247 | a. retain the following if it is supplied by the Licensor 248 | with the Licensed Material: 249 | 250 | i. identification of the creator(s) of the Licensed 251 | Material and any others designated to receive 252 | attribution, in any reasonable manner requested by 253 | the Licensor (including by pseudonym if 254 | designated); 255 | 256 | ii. a copyright notice; 257 | 258 | iii. a notice that refers to this Public License; 259 | 260 | iv. a notice that refers to the disclaimer of 261 | warranties; 262 | 263 | v. a URI or hyperlink to the Licensed Material to the 264 | extent reasonably practicable; 265 | 266 | b. indicate if You modified the Licensed Material and 267 | retain an indication of any previous modifications; and 268 | 269 | c. indicate the Licensed Material is licensed under this 270 | Public License, and include the text of, or the URI or 271 | hyperlink to, this Public License. 272 | 273 | 2. You may satisfy the conditions in Section 3(a)(1) in any 274 | reasonable manner based on the medium, means, and context in 275 | which You Share the Licensed Material. For example, it may be 276 | reasonable to satisfy the conditions by providing a URI or 277 | hyperlink to a resource that includes the required 278 | information. 279 | 3. If requested by the Licensor, You must remove any of the 280 | information required by Section 3(a)(1)(A) to the extent 281 | reasonably practicable. 282 | 283 | b. ShareAlike. 284 | 285 | In addition to the conditions in Section 3(a), if You Share 286 | Adapted Material You produce, the following conditions also apply. 287 | 288 | 1. The Adapter's License You apply must be a Creative Commons 289 | license with the same License Elements, this version or 290 | later, or a BY-NC-SA Compatible License. 291 | 292 | 2. You must include the text of, or the URI or hyperlink to, the 293 | Adapter's License You apply. You may satisfy this condition 294 | in any reasonable manner based on the medium, means, and 295 | context in which You Share Adapted Material. 296 | 297 | 3. You may not offer or impose any additional or different terms 298 | or conditions on, or apply any Effective Technological 299 | Measures to, Adapted Material that restrict exercise of the 300 | rights granted under the Adapter's License You apply. 301 | 302 | 303 | Section 4 -- Sui Generis Database Rights. 304 | 305 | Where the Licensed Rights include Sui Generis Database Rights that 306 | apply to Your use of the Licensed Material: 307 | 308 | a. for the avoidance of doubt, Section 2(a)(1) grants You the right 309 | to extract, reuse, reproduce, and Share all or a substantial 310 | portion of the contents of the database for NonCommercial purposes 311 | only; 312 | 313 | b. if You include all or a substantial portion of the database 314 | contents in a database in which You have Sui Generis Database 315 | Rights, then the database in which You have Sui Generis Database 316 | Rights (but not its individual contents) is Adapted Material, 317 | including for purposes of Section 3(b); and 318 | 319 | c. You must comply with the conditions in Section 3(a) if You Share 320 | all or a substantial portion of the contents of the database. 321 | 322 | For the avoidance of doubt, this Section 4 supplements and does not 323 | replace Your obligations under this Public License where the Licensed 324 | Rights include other Copyright and Similar Rights. 325 | 326 | 327 | Section 5 -- Disclaimer of Warranties and Limitation of Liability. 328 | 329 | a. UNLESS OTHERWISE SEPARATELY UNDERTAKEN BY THE LICENSOR, TO THE 330 | EXTENT POSSIBLE, THE LICENSOR OFFERS THE LICENSED MATERIAL AS-IS 331 | AND AS-AVAILABLE, AND MAKES NO REPRESENTATIONS OR WARRANTIES OF 332 | ANY KIND CONCERNING THE LICENSED MATERIAL, WHETHER EXPRESS, 333 | IMPLIED, STATUTORY, OR OTHER. THIS INCLUDES, WITHOUT LIMITATION, 334 | WARRANTIES OF TITLE, MERCHANTABILITY, FITNESS FOR A PARTICULAR 335 | PURPOSE, NON-INFRINGEMENT, ABSENCE OF LATENT OR OTHER DEFECTS, 336 | ACCURACY, OR THE PRESENCE OR ABSENCE OF ERRORS, WHETHER OR NOT 337 | KNOWN OR DISCOVERABLE. WHERE DISCLAIMERS OF WARRANTIES ARE NOT 338 | ALLOWED IN FULL OR IN PART, THIS DISCLAIMER MAY NOT APPLY TO YOU. 339 | 340 | b. TO THE EXTENT POSSIBLE, IN NO EVENT WILL THE LICENSOR BE LIABLE 341 | TO YOU ON ANY LEGAL THEORY (INCLUDING, WITHOUT LIMITATION, 342 | NEGLIGENCE) OR OTHERWISE FOR ANY DIRECT, SPECIAL, INDIRECT, 343 | INCIDENTAL, CONSEQUENTIAL, PUNITIVE, EXEMPLARY, OR OTHER LOSSES, 344 | COSTS, EXPENSES, OR DAMAGES ARISING OUT OF THIS PUBLIC LICENSE OR 345 | USE OF THE LICENSED MATERIAL, EVEN IF THE LICENSOR HAS BEEN 346 | ADVISED OF THE POSSIBILITY OF SUCH LOSSES, COSTS, EXPENSES, OR 347 | DAMAGES. WHERE A LIMITATION OF LIABILITY IS NOT ALLOWED IN FULL OR 348 | IN PART, THIS LIMITATION MAY NOT APPLY TO YOU. 349 | 350 | c. The disclaimer of warranties and limitation of liability provided 351 | above shall be interpreted in a manner that, to the extent 352 | possible, most closely approximates an absolute disclaimer and 353 | waiver of all liability. 354 | 355 | 356 | Section 6 -- Term and Termination. 357 | 358 | a. This Public License applies for the term of the Copyright and 359 | Similar Rights licensed here. However, if You fail to comply with 360 | this Public License, then Your rights under this Public License 361 | terminate automatically. 362 | 363 | b. Where Your right to use the Licensed Material has terminated under 364 | Section 6(a), it reinstates: 365 | 366 | 1. automatically as of the date the violation is cured, provided 367 | it is cured within 30 days of Your discovery of the 368 | violation; or 369 | 370 | 2. upon express reinstatement by the Licensor. 371 | 372 | For the avoidance of doubt, this Section 6(b) does not affect any 373 | right the Licensor may have to seek remedies for Your violations 374 | of this Public License. 375 | 376 | c. For the avoidance of doubt, the Licensor may also offer the 377 | Licensed Material under separate terms or conditions or stop 378 | distributing the Licensed Material at any time; however, doing so 379 | will not terminate this Public License. 380 | 381 | d. Sections 1, 5, 6, 7, and 8 survive termination of this Public 382 | License. 383 | 384 | 385 | Section 7 -- Other Terms and Conditions. 386 | 387 | a. The Licensor shall not be bound by any additional or different 388 | terms or conditions communicated by You unless expressly agreed. 389 | 390 | b. Any arrangements, understandings, or agreements regarding the 391 | Licensed Material not stated herein are separate from and 392 | independent of the terms and conditions of this Public License. 393 | 394 | 395 | Section 8 -- Interpretation. 396 | 397 | a. For the avoidance of doubt, this Public License does not, and 398 | shall not be interpreted to, reduce, limit, restrict, or impose 399 | conditions on any use of the Licensed Material that could lawfully 400 | be made without permission under this Public License. 401 | 402 | b. To the extent possible, if any provision of this Public License is 403 | deemed unenforceable, it shall be automatically reformed to the 404 | minimum extent necessary to make it enforceable. If the provision 405 | cannot be reformed, it shall be severed from this Public License 406 | without affecting the enforceability of the remaining terms and 407 | conditions. 408 | 409 | c. No term or condition of this Public License will be waived and no 410 | failure to comply consented to unless expressly agreed to by the 411 | Licensor. 412 | 413 | d. Nothing in this Public License constitutes or may be interpreted 414 | as a limitation upon, or waiver of, any privileges and immunities 415 | that apply to the Licensor or You, including from the legal 416 | processes of any jurisdiction or authority. 417 | 418 | ======================================================================= 419 | 420 | Creative Commons is not a party to its public 421 | licenses. Notwithstanding, Creative Commons may elect to apply one of 422 | its public licenses to material it publishes and in those instances 423 | will be considered the “Licensor.” The text of the Creative Commons 424 | public licenses is dedicated to the public domain under the CC0 Public 425 | Domain Dedication. Except for the limited purpose of indicating that 426 | material is shared under a Creative Commons public license or as 427 | otherwise permitted by the Creative Commons policies published at 428 | creativecommons.org/policies, Creative Commons does not authorize the 429 | use of the trademark "Creative Commons" or any other trademark or logo 430 | of Creative Commons without its prior written consent including, 431 | without limitation, in connection with any unauthorized modifications 432 | to any of its public licenses or any other arrangements, 433 | understandings, or agreements concerning use of licensed material. For 434 | the avoidance of doubt, this paragraph does not form part of the 435 | public licenses. 436 | 437 | Creative Commons may be contacted at creativecommons.org. 438 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # AskSin Analyzer XS 2 | 3 | [![latest release](https://img.shields.io/github/v/release/psi-4ward/AskSinAnalyzerXS)](https://github.com/psi-4ward/AskSinAnalyzerXS/releases/latest) 4 | [![github downloads](https://img.shields.io/github/downloads/psi-4ward/asksinanalyzerxs/total.svg?color=%23a7a71f&label=github%20downloads)](https://somsubhra.com/github-release-stats/?username=psi-4ward&repository=AskSinAnalyzerXS) 5 | [![npm downloads](https://img.shields.io/npm/dt/asksin-analyzer-xs?color=%23a7a71f&label=npm%20downloads&)](https://www.npmjs.com/package/asksin-analyzer-xs) 6 | [![Docker Pulls](https://img.shields.io/docker/pulls/psitrax/asksinanalyzer.svg?color=%23a7a71f&label=docker%20pulls)](https://hub.docker.com/r/psitrax/asksinanalyzer/) 7 | [![Build and Publish](https://github.com/psi-4ward/AskSinAnalyzerXS/actions/workflows/build_publish.yml/badge.svg)](https://github.com/psi-4ward/AskSinAnalyzerXS/actions) 8 | 9 | Funktelegramm-Dekodierer für den Einsatz in HomeMatic Umgebungen. 10 | 11 | Betrieb ist sowohl als Desktop-Anwendung unter Windows, Mac und Linux möglich sowie auf Servern als Node.js-App oder über Docker. 12 | 13 | Der AskSin Analyzer XS ist eine alternative Implementierung des [AskSinAnalyzer](https://github.com/jp112sdl/AskSinAnalyzer) ohne ESP32 und Display was die Umsetzung der Hardware vereinfacht. Es genügt ein AVR (ATMega 328P) mit CC1101 Funkmodul sowie ein USB-UART Adapter. 14 | 15 | **Features**: 16 | * Dekodieren von Homematic Telegrammen 17 | * Berechnung des DutyCycle pro Device (und Empfänger) 18 | * Darstellen der RSSI-Noise (Störsender); Alert-Trigger wenn ein Schwellwert überstiegen wird 19 | * Langzeitsaufzeichnungen 20 | * Auflösen von Device-Namen über eine CCU oder FHEM 21 | * [RedMatic support](https://github.com/psi-4ward/AskSinAnalyzerXS/blob/master/docs/NodeRED.md) 22 | 23 | ![AskSinAnalyzerXS-TelegramList](https://raw.githubusercontent.com/psi-4ward/AskSinAnalyzerXS/master/docs/AskSinAnalyzerXS-TelegramList.png) 24 | 25 | ![AskSinAnalyzerXS-DutyCycle](https://raw.githubusercontent.com/psi-4ward/AskSinAnalyzerXS/master/docs/AskSinAnalyzerXS-DutyCycle.png) 26 | 27 | ## AskSinSniffer328P Hardware 28 | 29 | Die Daten des AskSinSniffer328P werden über einen UART Schnittstelle an den AskSinAnalyzerXS übertragen und dort ausgewertet und visualisiert. 30 | 31 | * Sniffer 32 | * Arduino Pro Mini 8Mhz 3.3V oder Arduino-Nano 33 | * CC1101 Funkmodul 34 | * UART Schnittstelle 35 | * USB-UART Adapter (FTDI, CP2102, etc), bei Arduino-Nano on-board 36 | * Alternativ kann der Arduino auch [direkt an die UART GPIOs eines RaspberryPi](https://homematic-forum.de/forum/viewtopic.php?f=76&t=56395&start=70#p569429), Tinkerboard etc angeschlossen werden. 37 | 38 | Der Aufbau folgt der [allgemeingültige Verdrahtung des Pro Mini mit dem CC1101 Funkmodul](https://asksinpp.de/Grundlagen/01_hardware.html#verdrahtung). Der Config-Taster findet keine Verwendung und die Status-LED ist optional. 39 | 40 | Alternativ funktioniert auch der [nanoCUL CC1101](https://www.nanocul.de/) mit [angepasstem GPIO Mapping](https://homematic-forum.de/forum/viewtopic.php?f=76&t=56395&start=10#p562580). Siehe [Flash-Anleitung NANO CUL 868MHz Stick für Windows](https://homematic-forum.de/forum/viewtopic.php?f=76&t=56395&sid=d766ef0ef66df7a52864774cf45f8bad&start=220#p581363). 41 | 42 | Siehe auch: [AskSin Analyzer XS Board](https://github.com/TomMajor/SmartHome/tree/master/PCB/AskSin-Analyzer-XS) von Tom Major. 43 | 44 | Optional kann ein kleines Display verbaut werden was den RSSI-Noise Pegel darstellt. 45 | 46 | ![NanoCul with OLED Display](./docs/NanoCul_with_display.jpg) 47 | 48 | ## Installation 49 | 50 | * [Latest release](https://github.com/psi-4ward/AskSinAnalyzerXS/releases/latest) 51 | * [Develop release](https://github.com/psi-4ward/AskSinAnalyzerXS/releases/tag/v0.0.0) v0.0.0 52 | 53 | ### AVR Sketch 54 | 55 | Auf dem ATmega328P wird der [AskSinSniffer328P-Sketch](https://github.com/psi-4ward/AskSinAnalyzer-Sniffer) geflasht. Das Vorgehen ist auf [asksinpp.de](https://asksinpp.de/Grundlagen/) erläutert. 56 | 57 | :point_up: **Achtung:** Die AskSinPP-Library 4.1.2 enthält noch nicht alle nötigen Funktionen für den Sniffer. Es ist der aktuelle [Master](https://github.com/pa-pa/AskSinPP/archive/master.zip) zu verwenden. 58 | 59 | ### Electron-App 60 | 61 | Die Desktop-Anwendung steht für Windows, MacOS und Linux zum Download unter [Releases](https://github.com/psi-4ward/AskSinAnalyzerXS/releases) bereit. 62 | 63 | Tipp: Der AskSinAnalyzerXS gibt einige Debug-Informationen auf der Commando-Zeile aus. Bei Problemen empfiehlt sich also ein Start über ein Terminal. (Bash, cmd). 64 | 65 | ### Node-App (npm) 66 | 67 | Der AskSinAnalyzerXS kann auch als Node.js Anwendung betrieben werden was z.B. auf einem Server sinnvoll sein kann. 68 | 69 | ```bash 70 | $ npm i -g asksin-analyzer-xs 71 | $ asksin-analyzer-xs 72 | Detected SerialPort: /dev/ttyUSB0 (FTDI) 73 | Server started on port 8081 74 | ``` 75 | 76 | Die WebUI kann über den Browser auf [http://localhost:8081](http://localhost:8081) aufgerufen werden. 77 | 78 | **Achtung:** Will man _wirklich_ ein npm-install als `root` durchführen ist der Parameter `--unsafe` nötig. 79 | 80 | * Der develop-build des master-Branch ist **nicht** als npm-Paket verfügbar, kann aber trotzdem direkt installiert werden: 81 | ```bash 82 | npm i -g https://github.com/psi-4ward/AskSinAnalyzerXS/releases/download/0.0.0/asksin-analyzer-xs-0.0.0-node.tar.gz 83 | ``` 84 | 85 | * [Anleitung zur Installation als Debian-Service (z.B auf einem Raspberry Pi)](https://github.com/psi-4ward/AskSinAnalyzerXS/blob/master/docs/Install_as_Debian_Service.md) 86 | 87 | ### Docker 88 | 89 | Der Analyzer XS ist auch als Docker-Image verfügbar. Der Device-Paramter ist entsprechend anzupassen. 90 | 91 | ```bash 92 | docker run --rm --name analyzer -p 8081:8081 -v $PWD/data:/data --device=/dev/ttyUSB0 psitrax/asksinanalyzer 93 | ``` 94 | 95 | 96 | ## Konfiguration 97 | 98 | ### Auflösung von Gerätenamen 99 | 100 | Der AskSinSniffer328P sieht nur die _Device-Addresses_, nicht aber deren Seriennummern oder Namen. Damit die Adressen in Klartextnamen aufgelöst werden können muss eine DeviceListe von der CCU geladen werden wofür ein Script auf der CCU nötig ist. Siehe [AskSinAnalyzer CCU Untersützung](https://github.com/jp112sdl/AskSinAnalyzer/wiki/CCU_Unterst%C3%BCtzung). 101 | 102 | Soll die Geräteliste von FHEM abgerufen werden ist in der `99_myUtils.pm` folgende Funktion einzufügen: 103 | 104 | ```ruby 105 | sub printHMDevs { 106 | my @data; 107 | foreach my $device (devspec2array("TYPE=CUL_HM")) { 108 | my $snr = AttrVal($device,'serialNr',''); 109 | $snr = "" if AttrVal($device,'model','') eq 'CCU-FHEM'; 110 | if( $snr ne '' ) { 111 | my $name = AttrVal($device,'alias',$device); 112 | my $addr = InternalVal($device,'DEF','0'); 113 | push @data, { name => $name, serial => $snr, address => hex($addr) }; 114 | } 115 | } 116 | return JSON->new->encode( { created => time, devices => \@data } ); 117 | } 118 | ``` 119 | 120 | Im AskSinAnalyzerXS ist bei Verwendung vom FHEM die Option `Device-List Backend ist eine CCU` zu deaktivieren und als `Device-List URL` wird der Wert `http://fhem.local:8083/fhem?cmd={printHMDevs()}&XHR=1` eingetragen. 121 | 122 | ## Debugging / Fehlersuche 123 | 124 | 1. Der Analyzer gibt Debug-Informationen auf der Kommandozeile aus. 125 | 126 | Windows-User müssen den `asksin-analyzer-xs-...-win.zip` Build laden und beim Start den Parameter `--enable-logging` anhängen. 127 | 128 | 2. Die Anwendung besitzt _DevTools_ die über das Menü `View -> Toggle Developer Tools` aufgerufen werden können. 129 | 130 | ## Lizenz 131 | 132 | CC BY-NC-SA 4.0 133 | -------------------------------------------------------------------------------- /app/cli.ts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | require("./server"); 3 | -------------------------------------------------------------------------------- /app/electron.ts: -------------------------------------------------------------------------------- 1 | import {app, screen, BrowserWindow} from 'electron'; 2 | import store from "./src/store"; 3 | import {init} from './src/init'; 4 | import * as path from "path"; 5 | 6 | store.config.recentHistoryMins = 0; 7 | store.init(app.getPath('userData')); 8 | 9 | function createWindow(url: string) { 10 | const {width, height} = screen.getPrimaryDisplay().workAreaSize; 11 | const win = new BrowserWindow({ 12 | width: Math.round(width * 0.7) , 13 | height: height, 14 | webPreferences: { 15 | nodeIntegration: false 16 | }, 17 | icon: path.join(__dirname, '..', 'logo.jpg') 18 | }); 19 | win.loadURL(url); 20 | } 21 | 22 | app.on('ready', async () => { 23 | const port = await init(); 24 | createWindow(`http://localhost:`+ port + '/index.html'); 25 | }); 26 | -------------------------------------------------------------------------------- /app/interfaces/Config.ts: -------------------------------------------------------------------------------- 1 | import {PortInfo} from "serialport"; 2 | 3 | export interface Config { 4 | deviceListUrl: string | null, 5 | isCCU: boolean, 6 | serialPort: string | null, 7 | serialBaudRate: number | string, 8 | _availableSerialPorts: PortInfo[], 9 | maxTelegrams: number, 10 | recentHistoryMins: number, 11 | animations: boolean, 12 | persistentStorage: { 13 | enabled: boolean, 14 | keepFiles: number, 15 | flushInterval: number, 16 | maxBufferSize: number 17 | }, 18 | rssiNoiseTrigger: { 19 | enabled: boolean, 20 | value: number, 21 | timeWindow: number, 22 | action: 'httpGet' | 'httpPost', 23 | actionOpts: { 24 | url: string 25 | } 26 | }, 27 | _began: number | null, 28 | dropUnkownDevices: boolean, 29 | } 30 | -------------------------------------------------------------------------------- /app/interfaces/Device.ts: -------------------------------------------------------------------------------- 1 | export interface Device { 2 | name: string, 3 | serial: string, 4 | address: number 5 | } 6 | 7 | export interface DeviceList { 8 | createdAt: number | null; 9 | sanitizedUrl: string | null; 10 | devices: Device[] 11 | } 12 | -------------------------------------------------------------------------------- /app/interfaces/SocketMessage.ts: -------------------------------------------------------------------------------- 1 | export enum SocketMessageType { 2 | telegram = 'telegram', 3 | telegrams = 'telegrams', 4 | rssiNoise = 'rssiNoise', 5 | rssiNoises = 'rssiNoises', 6 | config = 'config', 7 | error = 'error', 8 | csvFiles = 'csvFiles', 9 | confirm = 'confirm' 10 | } 11 | 12 | export interface SocketMessage { 13 | type: SocketMessageType, 14 | payload: T 15 | } 16 | -------------------------------------------------------------------------------- /app/interfaces/Telegram.ts: -------------------------------------------------------------------------------- 1 | export interface Telegram { 2 | tstamp: number, 3 | rssi: number, 4 | len: number, 5 | cnt: number, 6 | flags: string[], 7 | type: string, 8 | fromAddr: number, 9 | toAddr: number, 10 | fromName: string, 11 | toName: string, 12 | fromSerial: string, 13 | toSerial: string, 14 | toIsIp: boolean, 15 | fromIsIp: boolean, 16 | dc: number, 17 | payload: string, 18 | raw: string 19 | } 20 | -------------------------------------------------------------------------------- /app/logo.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/psi-4ward/AskSinAnalyzerXS/0b607e16befb13b73818788b6b761a50fa49fb94/app/logo.jpg -------------------------------------------------------------------------------- /app/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "asksin-analyzer-xs", 3 | "version": "0.0.0", 4 | "description": "Analyzer for radio telegrams in a HomeMatic environment", 5 | "homepage": "https://github.com/psi-4ward/AskSinAnalyzerXS", 6 | "repository": "github:psi-4ward/AskSinAnalyzerXS", 7 | "license": "CC BY-NC-SA 4.0", 8 | "main": "dist/electron.js", 9 | "scripts": { 10 | "tsc": "tsc --build tsconfig.json", 11 | "tsc:watch": "tsc --build tsconfig.json -w", 12 | "server:watch": "HTDOCS_PATH=$(realpath ..)/htdocs PORT=3000 nodemon -w dist dist/server.js -- -d ../tmp", 13 | "dev": "concurrently -k -p \"[{name}]\" -n \"TypeScript,Node\" -c \"yellow.bold,cyan.bold\" npm:tsc:watch npm:server:watch", 14 | "electron": "electron dist/electron.js", 15 | "electron:build": "electron-builder", 16 | "test": "echo \"Error: no test specified\" && exit 1" 17 | }, 18 | "build": { 19 | "productName": "AskSinAnalyerXS", 20 | "appId": "com.AskSinAnalyzerXS", 21 | "artifactName": "${name}-${version}-${os}.${ext}", 22 | "publish": false, 23 | "directories": { 24 | "output": "../builds" 25 | }, 26 | "files": [ 27 | "dist/electron.js", 28 | "dist/**/*", 29 | "logo.jpg" 30 | ], 31 | "extraResources": [ 32 | { 33 | "from": "../htdocs", 34 | "to": "htdocs" 35 | } 36 | ], 37 | "mac": { 38 | "category": "public.app-category.utilities", 39 | "target": [ 40 | "tar.gz", 41 | "dmg" 42 | ] 43 | }, 44 | "linux": { 45 | "category": "Utility", 46 | "target": [ 47 | "tar.gz", 48 | "AppImage" 49 | ] 50 | }, 51 | "win": { 52 | "target": [ 53 | "portable", 54 | "zip" 55 | ] 56 | } 57 | }, 58 | "author": { 59 | "name": "Christoph Wiechert", 60 | "email": "asksinanalyzerxs@psi.cx", 61 | "url": "https://github.com/psi-4ward/AskSinAnalyzerXS" 62 | }, 63 | "dependencies": { 64 | "commander": "^5.1.0", 65 | "express": "^4.17.1", 66 | "got": "^11.8.0", 67 | "serialport": "^9.0.0", 68 | "ws": "^7.3.0" 69 | }, 70 | "devDependencies": { 71 | "@types/express": "^4.17.6", 72 | "@types/node": "^12.12.41", 73 | "@types/serialport": "^8.0.1", 74 | "@types/ws": "^7.2.4", 75 | "concurrently": "^5.2.0", 76 | "electron": "^8.3.0", 77 | "electron-builder": "^22.6.1", 78 | "electron-rebuild": "^1.11.0", 79 | "typescript": "^3.9.3" 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /app/scripts/make-node-pkg.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs'); 2 | const path = require('path'); 3 | const { execSync } = require('child_process'); 4 | const pkg = require('../package.json'); 5 | 6 | const dirName = `asksin-analyzer-xs-${ pkg.version }-node`; 7 | const target = path.resolve(__dirname, `../../builds/${dirName}`); 8 | const appDist = path.resolve(__dirname, '../dist'); 9 | const uiDist = path.resolve(__dirname, '../../htdocs'); 10 | 11 | execSync(`rm -rf ${target}`); 12 | execSync(`mkdir -p ${target}`); 13 | execSync(`cp -a ${ appDist }/* ${target}`); 14 | execSync(`cp -a ${ uiDist } ${target}/`); 15 | execSync(`cp ${ path.resolve(__dirname, '../../README.md') } ${target}/`); 16 | 17 | delete pkg.build; 18 | delete pkg.scripts; 19 | delete pkg.devDependencies; 20 | pkg.main = "server.js"; 21 | pkg.bin = { 22 | 'asksin-analyzer-xs': 'cli.js' 23 | }; 24 | 25 | fs.writeFileSync(target + '/package.json', JSON.stringify(pkg, null, 2)); 26 | 27 | execSync(`cd ${path.resolve(target, '..')} && tar czf ${dirName}.tar.gz ${dirName}`); 28 | // execSync(`rm -rf ${target}`); 29 | 30 | console.log(`${ dirName }.tar.gz created.`); 31 | -------------------------------------------------------------------------------- /app/server.ts: -------------------------------------------------------------------------------- 1 | import {existsSync} from 'fs'; 2 | import {resolve} from 'path'; 3 | import commander from 'commander'; 4 | import {init} from './src/init'; 5 | import store from "./src/store"; 6 | import serialIn from "./src/serialIn"; 7 | import persistentStorage from './src/persistentStorage'; 8 | 9 | let version = '0.0.0'; 10 | if (existsSync(resolve(__dirname, './package.json'))) { 11 | ({version} = require('./package.json')); 12 | } else if (existsSync(resolve(__dirname, '../package.json'))) { 13 | ({version} = require('../package.json')); 14 | } 15 | 16 | // No default values here to not overwrite store-defaults 17 | commander 18 | .description(`AskSin Analyzer XS v${version}\nhttps://github.com/psi-4ward/AskSinAnalyzerXS`) 19 | .version(version, '-v, --version', 'output the current version') 20 | .option('-l, --list-ports', 'List available serial ports') 21 | .option('-p, --serial-port ', 'SerialPort') 22 | .option('-b, --baud ', 'BaudRate of SerialPort') 23 | .option('-u, --url ', 'Host or IP of the CCU or URL to fetch the device-list') 24 | .option('-c, --ccu ', 'Fetch the device-list from a CCU', 25 | (arg: string) => !(arg === 'false' || arg === 'no' || arg === '0')) 26 | .option('-d, --data ', 'Directory to store persistent data') 27 | .parse(process.argv); 28 | 29 | const opts = commander.opts(); 30 | 31 | if (!process.env.PORT) { 32 | process.env.PORT = "8081"; 33 | } 34 | 35 | (async function f() { 36 | if (opts.listPorts) { 37 | await serialIn.listPorts(); 38 | process.exit(0); 39 | } 40 | 41 | opts.data && store.init(opts.data); 42 | opts.serialPort && store.setConfig('serialPort', opts.serialPort); 43 | opts.baud && store.setConfig('serialBaudRate', opts.baud); 44 | opts.ccu && store.setConfig('isCCU', opts.ccu); 45 | opts.url && store.setConfig('deviceListUrl', opts.url); 46 | 47 | await init(true); 48 | })(); 49 | 50 | // graceful shutdown 51 | async function shutdown() { 52 | console.log('PsiTransfer shutting down'); 53 | await persistentStorage.closeFD(); 54 | process.exit(0) 55 | } 56 | process.on('SIGTERM', shutdown); 57 | process.on('SIGINT', shutdown); 58 | -------------------------------------------------------------------------------- /app/src/DutyCyclePerTelegram.ts: -------------------------------------------------------------------------------- 1 | import {Transform, TransformOptions} from 'stream'; 2 | import {Telegram} from "../interfaces/Telegram"; 3 | import {SocketMessage} from "../interfaces/SocketMessage"; 4 | 5 | const dcs = new Map(); 6 | 7 | export default class DutyCyclePerTelegram extends Transform { 8 | constructor(opts: TransformOptions = {}) { 9 | super({...opts, objectMode: true}); 10 | } 11 | 12 | _transform(obj: SocketMessage, encoding: string, cb: Function) { 13 | if (obj.type === 'telegram') { 14 | const telegram = obj.payload as Telegram; 15 | if (!dcs.has(telegram.fromAddr)) { 16 | dcs.set(telegram.fromAddr, { 17 | val: 0, 18 | counts: [] 19 | }); 20 | } 21 | let data = dcs.get(telegram.fromAddr); 22 | // Calc DC and update value 23 | // (len + 11) * 0.81 => transmission time in ms 24 | // 1% airtime allowed => 36sec * 1000ms/sec is 100% DC 25 | let sendTime = 0; 26 | if(telegram.flags.includes('BURST')) { 27 | // 360 ms burst instead of 4 bytes preamble 28 | sendTime = 360 + (telegram.len + 7) * 0.81; 29 | } else { 30 | sendTime = (telegram.len + 11) * 0.81; 31 | } 32 | const dc = sendTime / 360; 33 | data.val += dc; 34 | // Store tstamp and DC for this sender 35 | data.counts.push([telegram.tstamp, dc]); 36 | // Remove DC older than 1h 37 | const firstTstampOfHour = telegram.tstamp - 3600000; 38 | while (data.counts[0][0] < firstTstampOfHour) { 39 | data.val -= data.counts[0][1]; 40 | data.counts.shift(); 41 | } 42 | obj.payload.dc = Math.round(data.val * 10) / 10; 43 | // console.log('DC', telegram.fromName, data.val); 44 | } 45 | this.push(obj); 46 | cb(); 47 | } 48 | }; 49 | -------------------------------------------------------------------------------- /app/src/SnifferParser.ts: -------------------------------------------------------------------------------- 1 | import {Transform, TransformOptions} from 'stream'; 2 | import devList from './deviceList'; 3 | import {Device} from "../interfaces/Device"; 4 | import {Telegram} from "../interfaces/Telegram"; 5 | import store from "./store"; 6 | 7 | const strPosBeginnings = { 8 | rssi: 1, 9 | len: 3, 10 | cnt: 5, 11 | flags: 7, 12 | type: 9, 13 | fromAddr: 11, 14 | toAddr: 17, 15 | payload: 23 16 | }; 17 | 18 | function getFlags(flagsInt: number) { 19 | const res = []; 20 | if (flagsInt & 0x01) res.push("WKUP"); 21 | if (flagsInt & 0x02) res.push("WKMEUP"); 22 | if (flagsInt & 0x04) res.push("BCAST"); 23 | if (flagsInt & 0x10) res.push("BURST"); 24 | if (flagsInt & 0x20) res.push("BIDI"); 25 | if (flagsInt & 0x40) res.push("RPTED"); 26 | if (flagsInt & 0x80) res.push("RPTEN"); 27 | if (flagsInt == 0x00) res.push("HMIP_UNKNOWN"); 28 | 29 | return res.sort(); 30 | } 31 | 32 | function getType(typeInt: number) { 33 | if (typeInt == 0x00) return "DEVINFO"; 34 | else if (typeInt == 0x01) return "CONFIG"; 35 | else if (typeInt == 0x02) return "RESPONSE"; 36 | else if (typeInt == 0x03) return "RESPONSE_AES"; 37 | else if (typeInt == 0x04) return "KEY_EXCHANGE"; 38 | else if (typeInt == 0x10) return "INFO"; 39 | else if (typeInt == 0x11) return "ACTION"; 40 | else if (typeInt == 0x12) return "HAVE_DATA"; 41 | else if (typeInt == 0x3E) return "SWITCH_EVENT"; 42 | else if (typeInt == 0x3F) return "TIMESTAMP"; 43 | else if (typeInt == 0x40) return "REMOTE_EVENT"; 44 | else if (typeInt == 0x41) return "SENSOR_EVENT"; 45 | else if (typeInt == 0x53) return "SENSOR_DATA"; 46 | else if (typeInt == 0x58) return "CLIMATE_EVENT"; 47 | else if (typeInt == 0x5A) return "CLIMATECTRL_EVENT"; 48 | else if (typeInt == 0x5E) return "POWER_EVENT"; 49 | else if (typeInt == 0x5F) return "POWER_EVENT_CYCLIC"; 50 | else if (typeInt == 0x70) return "WEATHER"; 51 | else if (typeInt >= 0x80) return "HMIP_TYPE"; 52 | return ""; 53 | } 54 | 55 | function getDevice(addr: number): Device | undefined { 56 | return devList.devices.find((dev: Device) => dev.address === addr); 57 | } 58 | 59 | export default class SnifferParser extends Transform { 60 | constructor(opts: TransformOptions = {}) { 61 | super({...opts, objectMode: true}); 62 | } 63 | 64 | _transform(line: string, encoding: string, callback: Function) { 65 | // trim potential whitespace including \r 66 | line = line.trim(); 67 | // Messages have to start with ":" 68 | if (!line.startsWith(':') || !line.endsWith(';')) { 69 | console.log('I:', line); 70 | return callback(); 71 | } 72 | 73 | if (line.length === 4) { 74 | // RSSI Noise measure 75 | this.push({ 76 | type: 'rssiNoise', 77 | payload: [Date.now(), -1 * parseInt(line.substr(1, 2), 16)], 78 | }); 79 | return callback(); 80 | } 81 | 82 | const fromAddr = parseInt(line.substr(11, 6), 16); 83 | const toAddr = parseInt(line.substr(17, 6), 16); 84 | const fromDev = getDevice(fromAddr); 85 | const toDev = getDevice(toAddr); 86 | 87 | if(store.getConfig('dropUnkownDevices') && !fromDev && !toDev) { 88 | return callback(); 89 | } 90 | 91 | const type = getType(parseInt(line.substr(9, 2), 16)); 92 | let flags: string[] = []; 93 | // Only parse HM-Flags. We've not enough knowlege to understand HM-IP Flags Byte 94 | if(type !== 'HMIP_TYPE') { 95 | flags = getFlags(parseInt(line.substr(7, 2), 16)); 96 | } 97 | 98 | const telegram: Telegram = { 99 | tstamp: Date.now(), 100 | rssi: -1 * parseInt(line.substr(1, 2), 16), 101 | len: parseInt(line.substr(3, 2), 16), 102 | cnt: parseInt(line.substr(5, 2), 16), 103 | flags, 104 | type, 105 | fromAddr, 106 | toAddr, 107 | fromName: fromDev && fromDev.name || '', 108 | toName: toDev && toDev.name || '', 109 | fromSerial: fromDev && fromDev.serial || '', 110 | toSerial: toDev && toDev.serial || '', 111 | toIsIp: toDev && toDev.serial && toDev.serial.length === 14 || toDev && toDev.serial === 'HmIP-RF', 112 | fromIsIp: fromDev && fromDev.serial && fromDev.serial.length === 14 || fromDev && fromDev.serial === 'HmIP-RF', 113 | dc: 0, 114 | payload: line.substring(23, line.length - 1), 115 | raw: line, 116 | }; 117 | 118 | this.push({ 119 | type: 'telegram', 120 | payload: telegram 121 | }); 122 | callback(); 123 | } 124 | } 125 | -------------------------------------------------------------------------------- /app/src/Trigger.ts: -------------------------------------------------------------------------------- 1 | import {Transform, TransformOptions} from 'stream'; 2 | import {Telegram} from "../interfaces/Telegram"; 3 | import {SocketMessage} from "../interfaces/SocketMessage"; 4 | import store from './store'; 5 | import {exec} from './triggerActions'; 6 | import errors from "./errors"; 7 | 8 | export default class Trigger extends Transform { 9 | rssiTrigger: { 10 | tstamp: number; 11 | active: boolean; 12 | triggered: boolean; 13 | }; 14 | 15 | constructor(opts: TransformOptions = {}) { 16 | super({...opts, objectMode: true}); 17 | this.rssiTrigger = { 18 | tstamp: 0, 19 | active: false, 20 | triggered: false 21 | }; 22 | } 23 | 24 | _transform(obj: SocketMessage, encoding: string, cb: Function) { 25 | // we do not alter the obj 26 | this.push(obj); 27 | cb(); 28 | 29 | if (obj.type === 'telegram') { 30 | 31 | } else if (obj.type === 'rssiNoise') { 32 | const trigger = store.getConfig('rssiNoiseTrigger'); 33 | if (!trigger.enabled) return; 34 | const [tstamp, rssi] = obj.payload; 35 | if (rssi > trigger.value) { 36 | if(this.rssiTrigger.active === false) { 37 | this.rssiTrigger.tstamp = tstamp; 38 | } 39 | this.rssiTrigger.active = true; 40 | if(!this.rssiTrigger.triggered && tstamp - this.rssiTrigger.tstamp > trigger.timeWindow * 1000) { 41 | console.log(`RSSI-Noise alert triggered`, trigger.actionOpts.url); 42 | exec(trigger.action, trigger.actionOpts) 43 | .catch(err => { 44 | errors.add('rssi-noise-trigger', `RSSI-Noise Trigger: ${err.toString()}`); 45 | console.error('Error RSSI-Noise Trigger', err); 46 | }); 47 | this.rssiTrigger.triggered = true; 48 | this.rssiTrigger.tstamp = tstamp; 49 | } 50 | } else { 51 | this.rssiTrigger.active = false; 52 | this.rssiTrigger.triggered = false; 53 | } 54 | } 55 | 56 | } 57 | }; 58 | -------------------------------------------------------------------------------- /app/src/deviceList.ts: -------------------------------------------------------------------------------- 1 | import fs from 'fs'; 2 | import path from 'path'; 3 | import got from 'got'; 4 | import {Device, DeviceList} from "../interfaces/Device"; 5 | import store from "./store"; 6 | 7 | export interface DeviceListResponse { 8 | created_at: number; 9 | devices: Device[] 10 | } 11 | 12 | const htmlEntities: {[key: string]: string} = { 13 | nbsp: ' ', 14 | cent: '¢', 15 | pound: '£', 16 | yen: '¥', 17 | euro: '€', 18 | copy: '©', 19 | reg: '®', 20 | lt: '<', 21 | gt: '>', 22 | quot: '"', 23 | amp: '&', 24 | apos: '\'' 25 | }; 26 | 27 | function unescapeHTML(str: string): string { 28 | return str.replace(/&([^;]+);/g, function (entity, entityCode) { 29 | let match; 30 | 31 | if (entityCode in htmlEntities) { 32 | return htmlEntities[entityCode]; 33 | } else if (match = entityCode.match(/^#x([\da-fA-F]+)$/)) { 34 | return String.fromCharCode(parseInt(match[1], 16)); 35 | } else if (match = entityCode.match(/^#(\d+)$/)) { 36 | return String.fromCharCode(~~match[1]); 37 | } else { 38 | return entity; 39 | } 40 | }); 41 | } 42 | 43 | const deviceList: DeviceList = { 44 | createdAt: null, 45 | sanitizedUrl: null, 46 | devices: [], 47 | }; 48 | 49 | function _setDeviceListDate(url: string, data: DeviceListResponse) { 50 | const sanitizedUrl = url.replace(/(?:https?:\/\/)?([^:]+:[^@]+)@/, ''); 51 | deviceList.devices = data.devices; 52 | deviceList.createdAt = data.created_at * 1000; 53 | deviceList.sanitizedUrl = sanitizedUrl; 54 | console.log('Fetched Device List from', sanitizedUrl); 55 | } 56 | 57 | async function _fetch(url: string) { 58 | const isCCU = store.getConfig('isCCU'); 59 | 60 | let body = await got(url, { 61 | encoding: isCCU ? "latin1" : 'utf-8' 62 | }).text(); 63 | 64 | if (isCCU) { 65 | const bodyJson = body.match(/(.+?)<\/ret>/); 66 | if (!bodyJson) { 67 | throw new Error('Invalid XML'); 68 | } 69 | body = unescapeHTML(bodyJson[1]); 70 | } 71 | let deviceListRes = null; 72 | deviceListRes = JSON.parse(body) as DeviceListResponse; 73 | _setDeviceListDate(url, deviceListRes); 74 | return deviceList; 75 | } 76 | 77 | export async function fetchDevList() { 78 | let deviceListUrl = store.getConfig('deviceListUrl'); 79 | const isCCU = store.getConfig('isCCU'); 80 | const appPath = store.appPath; 81 | 82 | if (!deviceListUrl) return; 83 | 84 | if(isCCU && deviceListUrl.startsWith('http')) { 85 | deviceListUrl = deviceListUrl.replace(/^https?:\/\//,''); 86 | } 87 | 88 | let url = isCCU 89 | ? `http://${deviceListUrl}:8181/a.exe?ret=dom.GetObject(ID_SYSTEM_VARIABLES).Get(%22AskSinAnalyzerDevList%22).Value()` 90 | : deviceListUrl; 91 | 92 | const file = path.resolve(appPath, 'deviceList.json'); 93 | 94 | try { 95 | if(url.startsWith('http')) { 96 | await _fetch(url); 97 | } else { 98 | console.log('Read device-list from file', url); 99 | _setDeviceListDate(url, JSON.parse(fs.readFileSync(url, 'utf-8'))); 100 | } 101 | fs.writeFileSync(file, JSON.stringify(deviceList), 'utf-8'); 102 | } catch(err) { 103 | console.error(err); 104 | try { 105 | Object.assign(deviceList, JSON.parse(fs.readFileSync(file, 'utf-8'))); 106 | const err = new Error('Using cached version, created at ' + (new Date(deviceList.createdAt)).toLocaleString() + ' from ' + deviceList.sanitizedUrl); 107 | // @ts-ignore 108 | err.code = 12; 109 | throw err; 110 | } catch (err2) { 111 | if(err2.code !== 12) throw err; 112 | else throw err2; 113 | } 114 | } 115 | } 116 | 117 | // Refetch deviceList every hour 118 | setTimeout(async () => { 119 | try { 120 | await fetchDevList() 121 | } catch (e) { 122 | } 123 | }, 60*60*1000); 124 | 125 | 126 | export default deviceList; 127 | -------------------------------------------------------------------------------- /app/src/errors.ts: -------------------------------------------------------------------------------- 1 | import {EventEmitter} from 'events'; 2 | 3 | class Errors extends EventEmitter { 4 | errors = new Map(); 5 | 6 | add(key: string, e: Error | String) { 7 | console.error(e); 8 | this.errors.set(key, e.toString()); 9 | this.emit('change'); 10 | } 11 | 12 | delete(key: string) { 13 | this.errors.delete(key); 14 | this.emit('change'); 15 | } 16 | 17 | clear() { 18 | this.errors.clear(); 19 | this.emit('change'); 20 | } 21 | 22 | getErrors() { 23 | return Array.from(this.errors.entries()); 24 | } 25 | } 26 | 27 | export default new Errors(); 28 | -------------------------------------------------------------------------------- /app/src/init.ts: -------------------------------------------------------------------------------- 1 | import {httpServer, htdocsPath} from './server'; 2 | import {AddressInfo} from 'net'; 3 | import {begin} from './websocket-rpc'; 4 | import serialIn from "./serialIn"; 5 | import store from "./store"; 6 | 7 | async function listen(): Promise { 8 | return new Promise((resolve) => { 9 | httpServer.listen(process.env.PORT || 0, () => { 10 | const {port} = httpServer.address() as AddressInfo; 11 | console.log(`Server started on port ${port}`); 12 | console.log('Serving UI from', htdocsPath); 13 | resolve(port); 14 | }); 15 | }); 16 | } 17 | 18 | httpServer.on('error', e => { 19 | console.error(e); 20 | process.exit(1); 21 | }); 22 | 23 | export async function init(forcePortOpening: boolean = false): Promise { 24 | const storedPort = store.getConfig('serialPort'); 25 | if(forcePortOpening) { 26 | begin(); 27 | } else { 28 | const uartPorts = await serialIn.listPorts(); 29 | store.setConfig("_availableSerialPorts", uartPorts); 30 | // auto-begin if storedPort is in the liste of available ports 31 | if (storedPort && uartPorts.some((p) => p.path === storedPort)) { 32 | begin(); 33 | } 34 | } 35 | return listen(); 36 | } 37 | -------------------------------------------------------------------------------- /app/src/persistentStorage.ts: -------------------------------------------------------------------------------- 1 | import Stream from "stream"; 2 | import * as fs from 'fs'; 3 | import {promisify} from 'util'; 4 | import * as path from "path"; 5 | import store from "./store"; 6 | import {SocketMessage} from "../interfaces/SocketMessage"; 7 | import errors from "./errors"; 8 | 9 | const csvFields = ['tstamp', 'date', 'rssi', 'len', 'cnt', 'dc', 'flags', 'type', 'fromAddr', 'toAddr', 'fromName', 'toName', 'fromSerial', 'toSerial', 'toIsIp', 'fromIsIp', 'payload', 'raw']; 10 | 11 | function p(v: string | number) { 12 | return ('0' + v).slice(-2); 13 | } 14 | 15 | class PersistentStorage { 16 | fd: number | null = null; 17 | enabled: boolean; 18 | nextDayTstamp: number | null; 19 | flushIntervalClk: number | null; 20 | buffer: string = ''; 21 | 22 | async enable(stream: Stream) { 23 | this.enabled = true; 24 | this.flushIntervalClk && clearInterval(this.flushIntervalClk); 25 | await this.openFD(); 26 | 27 | const { flushInterval } = store.getConfig('persistentStorage'); 28 | if(flushInterval) { 29 | console.log('Persist storage every ' + flushInterval + ' seconds.'); 30 | setInterval(() => this.write(null), flushInterval * 1000); 31 | } 32 | 33 | stream.on('data', async (data: SocketMessage) => { 34 | if (!this.enabled) return; 35 | if (data.type !== 'telegram') return; 36 | if (data.payload.tstamp >= this.nextDayTstamp) { 37 | await this.openFD(); 38 | } 39 | const d = new Date(data.payload.tstamp); 40 | data.payload.date = `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} ${(p(d.getHours()))}:${p(d.getMinutes())}:${p(d.getSeconds())}.${('00' + d.getMilliseconds()).slice(-3)}`; 41 | const res = csvFields.map(fld => data.payload[fld]); 42 | this.writeLn(res.join(';')); 43 | }); 44 | } 45 | 46 | async disable() { 47 | this.enabled = false; 48 | this.flushIntervalClk && clearInterval(this.flushIntervalClk); 49 | await this.closeFD(); 50 | } 51 | 52 | getCurrentFilename() { 53 | const d = new Date(); 54 | return `TelegramsXS_${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())}.csv`; 55 | } 56 | 57 | writeLn(data: string) { 58 | if(store.getConfig('persistentStorage').flushInterval) { 59 | this.buffer += data + "\n"; 60 | if(this.buffer.length > store.getConfig('persistentStorage').maxBufferSize) { 61 | this.write(null); 62 | } 63 | } else { 64 | this.write(data + "\n") 65 | } 66 | } 67 | 68 | private async write(data: string = null) { 69 | try { 70 | // use buffer when data === null 71 | if(data === null) { 72 | data = this.buffer; 73 | // do nothing for empty buffers 74 | if(!data.length) { 75 | return; 76 | } 77 | } 78 | this.buffer = ''; 79 | await promisify(fs.write)(this.fd, data); 80 | } catch (err) { 81 | console.error(err); 82 | console.error('Persistent storage stopped.'); 83 | errors.add('pstoreWrite', `Storage write error: ${ err.toString() }`); 84 | await this.disable(); 85 | } 86 | } 87 | 88 | async openFD() { 89 | if (this.fd) await this.closeFD(); 90 | const d = new Date(); 91 | this.nextDayTstamp = d.setHours(24, 0, 0, 0); 92 | const file = path.resolve(store.appPath, this.getCurrentFilename()); 93 | console.log('Opening', file, 'for persistent storage.'); 94 | return new Promise(async (resolve) => { 95 | try { 96 | const exists = await promisify(fs.exists)(file); 97 | this.fd = await promisify(fs.open)(file, 'a'); 98 | if (!exists) { 99 | // Add header to new files 100 | this.writeLn(csvFields.join(';')); 101 | } 102 | } catch (err) { 103 | errors.add('pstoreOpen', `Storage open error: ${err.toString()}`); 104 | console.error(err); 105 | } 106 | resolve(); 107 | }); 108 | } 109 | 110 | async closeFD() { 111 | if (this.fd) { 112 | await promisify(fs.close)(this.fd); 113 | } 114 | } 115 | 116 | async getFiles() { 117 | try { 118 | let files = await promisify(fs.readdir)(store.appPath); 119 | files = files.filter(file => file.match(/^TelegramsXS_.+\.csv$/)); 120 | files.sort(); 121 | files.reverse(); 122 | return await Promise.all(files.map(async f => ({ 123 | name: f, 124 | size: (await promisify(fs.stat)(path.resolve(store.appPath, f))).size 125 | }))); 126 | } catch (e) { 127 | errors.add('pstore', `Could not fetch file list: ${e.toString()}`); 128 | console.error(e); 129 | return []; 130 | } 131 | } 132 | 133 | async getFileContent(file: string) { 134 | return promisify(fs.readFile)(path.resolve(store.appPath, file), 'utf8'); 135 | } 136 | 137 | async deleteFile(fileToDelete: string) { 138 | const file = path.resolve(store.appPath, fileToDelete); 139 | try { 140 | await promisify(fs.unlink)(file); 141 | } catch (err) { 142 | console.error(err); 143 | errors.add('pstoreDelExp', `Storage expired file deletion error: ${err.toString()}`); 144 | } 145 | } 146 | 147 | async deleteExpiredFiles() { 148 | const maxFiles = store.getConfig('persistentStorage').keepFiles; 149 | if (maxFiles === 0) return; 150 | (await this.getFiles()) 151 | .slice(maxFiles) 152 | .forEach((file) => { 153 | console.log('Delete expired file', file.name); 154 | this.deleteFile(file.name); 155 | }); 156 | } 157 | } 158 | 159 | const ps = new PersistentStorage(); 160 | 161 | // garbage collection every 24h 162 | setInterval( 163 | () => ps.deleteExpiredFiles(), 164 | 3600 * 24 * 1000 165 | ); 166 | 167 | // garbage collection once 5m after start 168 | setTimeout( 169 | () => ps.deleteExpiredFiles(), 170 | 5 * 60 * 1000 171 | ); 172 | 173 | // Singleton 174 | export default ps; 175 | -------------------------------------------------------------------------------- /app/src/serialIn.ts: -------------------------------------------------------------------------------- 1 | import SerialPort from 'serialport'; 2 | import Stream from 'stream'; 3 | import SnifferParser from './SnifferParser'; 4 | import DutyCyclePerTelegram from "./DutyCyclePerTelegram"; 5 | import Trigger from "./Trigger"; 6 | 7 | class SerialIn { 8 | rawStream: Stream | null; 9 | dataStream: Stream | null; 10 | con: SerialPort | null; 11 | 12 | async listPorts() { 13 | const ports = await SerialPort.list(); 14 | ports.forEach(port => console.log(`Detected SerialPort: ${port.path} (${port.manufacturer || "unknown manufacturer"})`)); 15 | return ports; 16 | } 17 | 18 | async open(port: string, baudRate: number = 57600): Promise { 19 | try { 20 | await this.close(); 21 | } catch (e) { 22 | } 23 | 24 | return new Promise((resolve, reject) => { 25 | this.con = new SerialPort(port, {baudRate}, (err: Error | null) => { 26 | if (err) return reject(err); 27 | console.log(`Connected to ${port}`); 28 | this.rawStream = this.con.pipe(new SerialPort.parsers.Readline({delimiter: '\n'})); 29 | this.dataStream = this.rawStream 30 | .pipe(new SnifferParser()) 31 | .pipe(new DutyCyclePerTelegram()) 32 | .pipe(new Trigger()); 33 | resolve(this.dataStream); 34 | }); 35 | }); 36 | } 37 | 38 | async close() { 39 | return new Promise((resolve, reject) => { 40 | this.con.close((err) => { 41 | if (err) return reject(err); 42 | resolve(); 43 | }) 44 | }); 45 | } 46 | } 47 | 48 | // Singleton 49 | export default new SerialIn(); 50 | -------------------------------------------------------------------------------- /app/src/server.ts: -------------------------------------------------------------------------------- 1 | import http from 'http'; 2 | import path from 'path'; 3 | import express from 'express'; 4 | import WebSocket from 'ws'; 5 | 6 | export let htdocsPath: string; 7 | if(process.env.HTDOCS_PATH) { 8 | htdocsPath = process.env.HTDOCS_PATH; 9 | } else if (process.versions.electron) { 10 | htdocsPath = path.resolve(__dirname, '../../../htdocs'); 11 | } else { 12 | htdocsPath = path.resolve(__dirname, '../htdocs'); 13 | } 14 | 15 | const app = express(); 16 | app.use(express.static(htdocsPath)); 17 | 18 | export const httpServer = http.createServer(app); 19 | export const wsServer = new WebSocket.Server({server: httpServer}); 20 | 21 | function noop() { 22 | } 23 | 24 | function heartbeat() { 25 | this.isAlive = true; 26 | } 27 | 28 | wsServer.on('connection', (ws: WebSocket & { isAlive: boolean }) => { 29 | ws.isAlive = true; 30 | ws.on('pong', heartbeat); 31 | }); 32 | 33 | setInterval(function ping() { 34 | wsServer.clients.forEach(function each(ws: WebSocket & { isAlive: boolean }) { 35 | if (ws.isAlive === false) return ws.terminate(); 36 | ws.isAlive = false; 37 | ws.ping(noop); 38 | }); 39 | }, 15 * 1000); 40 | -------------------------------------------------------------------------------- /app/src/store.ts: -------------------------------------------------------------------------------- 1 | import path from 'path'; 2 | import fs from 'fs'; 3 | import {Config} from '../interfaces/Config'; 4 | 5 | class Store { 6 | config: Config = { 7 | isCCU: true, 8 | deviceListUrl: null, 9 | serialPort: null, 10 | serialBaudRate: 57600, 11 | _availableSerialPorts: [], 12 | maxTelegrams: 20000, 13 | recentHistoryMins: 70, 14 | animations: false, 15 | persistentStorage: { 16 | enabled: false, 17 | keepFiles: 180, 18 | flushInterval: 5, 19 | maxBufferSize: 500000 20 | }, 21 | rssiNoiseTrigger: { 22 | enabled: false, 23 | value: -100, 24 | timeWindow: 30, 25 | action: 'httpGet', 26 | actionOpts: { 27 | url: 'http://raspberrymatic:8181/a.exe?r=dom.GetObject("AskSinAnalyzerAlarm").State(true)' 28 | } 29 | }, 30 | _began: null, 31 | dropUnkownDevices: false, 32 | }; 33 | 34 | appPath: string = path.resolve(__dirname, '..'); 35 | persistData: boolean = true; 36 | 37 | init(appPath: string | null) { 38 | if(appPath) this.appPath = path.resolve(appPath); 39 | console.log('Data-Path:', this.appPath); 40 | if (fs.existsSync(this.appPath + '/userdata.json')) { 41 | const userConf = JSON.parse(fs.readFileSync(this.appPath + '/userdata.json', 'utf-8')); 42 | this.config = { 43 | ...this.config, 44 | ...userConf 45 | }; 46 | } 47 | } 48 | 49 | setConfigData(data: Partial) { 50 | this.config = {...this.config, ...data}; 51 | this.persist(); 52 | } 53 | 54 | getConfigData() { 55 | return this.config; 56 | } 57 | 58 | setConfig(key: K, value: V) { 59 | this.config[key] = value; 60 | this.persist(); 61 | } 62 | 63 | getConfig(key: K) { 64 | return this.config[key]; 65 | } 66 | 67 | private persist() { 68 | if(!this.persistData) return; 69 | const cfg = {...this.config}; 70 | Object.keys(cfg) 71 | .filter(key => key.startsWith('_')) 72 | // @ts-ignore 73 | .forEach(key => delete cfg[key]); 74 | fs.writeFileSync(this.appPath + '/userdata.json', JSON.stringify(cfg, null, 2), {encoding: 'utf-8'}); 75 | } 76 | } 77 | 78 | export default new Store(); 79 | -------------------------------------------------------------------------------- /app/src/triggerActions.ts: -------------------------------------------------------------------------------- 1 | import {parse} from 'url'; 2 | import {IncomingMessage, request} from "http"; 3 | 4 | export function exec(action: string, opts: any) { 5 | const {protocol, auth, hostname, port, path} = parse(opts.url); 6 | return new Promise((resolve, reject) => { 7 | request({ 8 | protocol, auth, hostname, port, path, 9 | method: (action === 'httpPost') ? 'POST' : 'GET' 10 | }, 11 | (res: IncomingMessage) => { 12 | if (res.statusCode !== 200) { 13 | return reject(new Error(`${res.statusCode} ${res.statusMessage}`)); 14 | } 15 | resolve(); 16 | }) 17 | .on('error', (err) => { 18 | reject(err); 19 | }) 20 | .end(); 21 | }); 22 | } 23 | -------------------------------------------------------------------------------- /app/src/websocket-rpc.ts: -------------------------------------------------------------------------------- 1 | import WebSocket from 'ws'; 2 | 3 | import {wsServer} from "./server"; 4 | import {SocketMessage, SocketMessageType} from "../interfaces/SocketMessage"; 5 | import serialIn from "./serialIn"; 6 | import store from './store'; 7 | import {fetchDevList} from "./deviceList"; 8 | import persistentStorage from './persistentStorage'; 9 | import errors from "./errors"; 10 | import {Telegram} from "../interfaces/Telegram"; 11 | 12 | const recentHistory: Telegram[] = []; 13 | const rssiNoiseHistory: number[][] = []; 14 | 15 | export function send(ws: WebSocket, type: SocketMessageType, payload: any = null, uuid: string = null) { 16 | ws.send(JSON.stringify({type: type.toString(), payload, uuid})); 17 | } 18 | 19 | export function broadcast(type: SocketMessageType, payload: any = null) { 20 | wsServer.clients.forEach(client => { 21 | if (client.readyState !== WebSocket.OPEN) return; 22 | client.send(JSON.stringify({type, payload})); 23 | }); 24 | } 25 | 26 | function broadcastConfig(uuid: string = null) { 27 | broadcast(SocketMessageType.config, { 28 | ...store.getConfigData(), 29 | _appPath: store.appPath 30 | }); 31 | } 32 | 33 | errors.on('change', () => { 34 | broadcast(SocketMessageType.error, errors.getErrors()); 35 | }); 36 | 37 | function serialCloseHandler() { 38 | errors.add('serialClosed', 'Serial connection closed.'); 39 | } 40 | 41 | export async function begin(): Promise { 42 | errors.clear(); 43 | 44 | await fetchDevList().catch((err) => { 45 | errors.add('devListFetch', `Error fetching device list: ${err.toString()}`); 46 | console.error(err); 47 | }); 48 | 49 | const port = store.getConfig('serialPort'); 50 | const serialBaudRate = parseInt(store.getConfig('serialBaudRate') as string, 10); 51 | if (!port) { 52 | errors.add('noSerialPortConfigured', "No SerialPort configured."); 53 | return; 54 | } 55 | 56 | let stream; 57 | try { 58 | if (serialIn.con) serialIn.con.off('close', serialCloseHandler); 59 | stream = await serialIn.open(port, serialBaudRate); 60 | } catch (e) { 61 | errors.add('serialOpen', `Could not open serial port: ${e.toString()}`); 62 | console.error(e); 63 | return; 64 | } 65 | 66 | stream.on('data', (data: SocketMessage) => { 67 | // Broadcast to new data to all socket clients 68 | broadcast(data.type, data.payload); 69 | 70 | // Store in-memory history data 71 | if(store.getConfig('recentHistoryMins') > 0) { 72 | if (data.type === SocketMessageType.telegram) { 73 | recentHistory.push(data.payload); 74 | const lastTestamp = Date.now() - store.getConfig('recentHistoryMins') * 60 * 1000; 75 | while (recentHistory[0].tstamp < lastTestamp) { 76 | recentHistory.shift(); 77 | } 78 | } 79 | if (data.type === SocketMessageType.rssiNoise) { 80 | rssiNoiseHistory.push(data.payload); 81 | const lastTestamp = Date.now() - store.getConfig('recentHistoryMins') * 60 * 1000; 82 | while (rssiNoiseHistory[0][0] < lastTestamp) { 83 | rssiNoiseHistory.shift(); 84 | } 85 | } 86 | } 87 | }); 88 | 89 | if (store.getConfig('persistentStorage').enabled) { 90 | persistentStorage.enable(stream); // does not reject 91 | } else { 92 | persistentStorage.disable(); 93 | } 94 | 95 | serialIn.con.on('close', serialCloseHandler); 96 | stream.on('error', (err) => { 97 | errors.add('snInStream', `Serial stream error: ${err.toString()}`); 98 | console.error(err); 99 | }); 100 | 101 | store.setConfig('_began', Date.now()); 102 | broadcastConfig(); 103 | } 104 | 105 | wsServer.on('connection', (ws: WebSocket) => { 106 | // Propagate config 107 | broadcastConfig(); 108 | 109 | // Send histories 110 | send(ws, SocketMessageType.telegrams, recentHistory); 111 | send(ws, SocketMessageType.rssiNoises, rssiNoiseHistory); 112 | 113 | // Propagate errors 114 | send(ws, SocketMessageType.error, errors.getErrors()); 115 | 116 | ws.on('message', async (data: string) => { 117 | let type, payload, uuid: string; 118 | try { 119 | ({type, payload, uuid} = JSON.parse(data)); 120 | } catch (e) { 121 | console.error('Could not parse WebSocket message:', data); 122 | console.log(e); 123 | return; 124 | } 125 | // RPC 126 | switch (type) { 127 | case 'get csv-files': 128 | const files = await persistentStorage.getFiles(); 129 | send(ws, SocketMessageType.csvFiles, files, uuid); 130 | break; 131 | case 'get config': 132 | serialIn.listPorts() 133 | .then(ports => { 134 | store.setConfig("_availableSerialPorts", ports); 135 | send(ws, SocketMessageType.config, { 136 | ...store.getConfigData(), 137 | _appPath: store.appPath, 138 | _mem: process.memoryUsage() 139 | }, uuid); 140 | }); 141 | break; 142 | case 'set config': 143 | store.setConfigData(payload); 144 | if(payload.recentHistoryMins === 0) { 145 | recentHistory.splice(0, recentHistory.length); 146 | rssiNoiseHistory.splice(0, rssiNoiseHistory.length); 147 | } 148 | begin(); 149 | break; 150 | case 'get recentHistory': 151 | send(ws, SocketMessageType.telegrams, recentHistory, uuid); 152 | send(ws, SocketMessageType.rssiNoises, rssiNoiseHistory, uuid); 153 | break; 154 | case 'delete error': 155 | errors.delete(payload); 156 | break; 157 | case 'delete csv-file': 158 | await persistentStorage.deleteFile(payload); 159 | send(ws, SocketMessageType.confirm, true, uuid); 160 | break; 161 | case 'get csv-content': 162 | const content = await persistentStorage.getFileContent(payload); 163 | send(ws, SocketMessageType.confirm, content, uuid); 164 | break; 165 | } 166 | }); 167 | }); 168 | -------------------------------------------------------------------------------- /app/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "commonjs", 4 | "esModuleInterop": true, 5 | "allowSyntheticDefaultImports": true, 6 | "target": "es6", 7 | "noImplicitAny": true, 8 | "moduleResolution": "node", 9 | "sourceMap": true, 10 | "outDir": "dist", 11 | "baseUrl": ".", 12 | "paths": { 13 | "*": [ 14 | "node_modules/*", 15 | "src/types/*" 16 | ] 17 | } 18 | }, 19 | "include": [ 20 | "interfaces/**/*", 21 | "src/**/*", 22 | "cli.ts", 23 | "server.ts", 24 | "electron.ts" 25 | ] 26 | } 27 | -------------------------------------------------------------------------------- /docs/AskSinAnalyzerXS-DutyCycle.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/psi-4ward/AskSinAnalyzerXS/0b607e16befb13b73818788b6b761a50fa49fb94/docs/AskSinAnalyzerXS-DutyCycle.png -------------------------------------------------------------------------------- /docs/AskSinAnalyzerXS-TelegramList.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/psi-4ward/AskSinAnalyzerXS/0b607e16befb13b73818788b6b761a50fa49fb94/docs/AskSinAnalyzerXS-TelegramList.png -------------------------------------------------------------------------------- /docs/Install_as_Debian_Service.md: -------------------------------------------------------------------------------- 1 | # Installation als Debian Service 2 | 3 | Raspberry Pi Images basieren oft auf Debian. Hier wird erläutert wie man den Analyzer als Systemd-Service unter Strech installieren kann. 4 | 5 | 1. **Node.js installieren** 6 | 7 | Falls noch nicht vorhanden wird zuerst curl installiert, anschließend Node.js (Version 12). 8 | 9 | ```bash 10 | sudo apt-get update 11 | sudo apt-get install curl 12 | curl -sL https://deb.nodesource.com/setup_12.x | sudo -E bash - 13 | sudo apt-get install -y nodejs 14 | ``` 15 | 16 | Nun sollte `node` und `npm` vorhanden sein 17 | ```bash 18 | # node -v 19 | v12.16.1 20 | # npm -v 21 | v6.13.4 22 | ``` 23 | 24 | 1. **AskSin Analyzer XS installieren** 25 | 26 | Die Installation erfolgt über npm als "global" womit die ausführbare Datei `asksin-analyzer-xs` direkt zur Verfügung steht. 27 | 28 | ```bash 29 | sudo npm install -g --unsafe asksin-analyzer-xs 30 | ``` 31 | 32 | 1. **Benutzer anlegen** 33 | 34 | Aus Sicherheitsgründen legt man einen eigenen Benutzer an unter dem der Analyzer später betrieben wird. 35 | 36 | ```bash 37 | sudo useradd -M -N -r -s /bin/false -c "AskSin Analyzer user" -G dialout analyzer 38 | ``` 39 | 40 | Der Benutzer muss das UART-Device öffnen können. Je nach Anschlussvariante `/dev/ttyUSB0`, `/dev/ttyS1` oder ähnlich. 41 | 42 | ```bash 43 | ls -l /dev/ttyUSB0 44 | crw-rw---- 1 root dialout 4, 65 Mär 14 10:43 /dev/ttyUSB0 45 | ``` 46 | 47 | In der obigen beispielhaften Ausgabe sieht man, dass `ttyUSB0` der Gruppe `dialout` angehört welche wir beim Erstellen des Users mit angegeben habe. 48 | 49 | 1. **Datenverzeichnis erstellen** 50 | 51 | Für die persistente Speicherung der Konfiguration und der Nutzdaten wird ein Verzeichnis anlegen mit entsprechenden Berechtigungen. Dies könnte man auch auf eine SD-Karte auslagern. 52 | 53 | ```bash 54 | sudo mkdir -p /opt/analyzer 55 | sudo chown analyzer /opt/analyzer 56 | ``` 57 | 58 | 1. **Systemd Unit erstellen** 59 | 60 | Da der Analyzer als Systemdienst betrieben werden soll wird ein Systemd Unit File erstellt unter `/etc/systemd/system/analyzer.service` mit folgendem Inhalt: 61 | 62 | ```ini 63 | [Unit] 64 | Description=Analyzer for radio telegrams in a HomeMatic environment 65 | Documentation=https://github.com/psi-4ward/AskSinAnalyzerXS 66 | After=network-online.target 67 | 68 | [Service] 69 | Environment="PORT=8088" 70 | ExecStart=/usr/local/bin/asksin-analyzer-xs -d /opt/analyzer 71 | Type=simple 72 | User=analyzer 73 | Restart=on-failure 74 | 75 | [Install] 76 | WantedBy=multi-user.target 77 | ``` 78 | 79 | Ggf. liegt die ausführbare `asksin-analyzer-xs` Datei auch nur unter `/user/bin`. Dies kann mit `which asksin-analyzer-xs` überprüft werden. 80 | 81 | In diesem Unit-File gibt die Env-Vart `PORT` den Port für den Analyzer an. Dieser muss aufgrund von Sicherheitsbestimmungen über 1024 sein (privileged ports). Das Argument `-d` gibt den Speicherort der persistenten Daten an, dieser kann natürlich nach belieben angepasst werden. Wichtig ist nur, dass der Benutzer `analyzer` hier Lese- und Schreibrechte hat. 82 | 83 | 1. **Service aktivieren und starten** 84 | 85 | Zum Schluss wird der Service gestartet und der autostart beim Booten enabled. 86 | 87 | ```bash 88 | # AskSin Analyzer Service starten 89 | sudo systemctl start analyzer 90 | # Status des Service überprüfen 91 | sudo systemctl status analyzer 92 | # Autostart nach dem Bootvorgang aktivieren 93 | sudo systemctl enable analyzer 94 | # Autostart ggf. deaktivieren 95 | sudo systemctl disable analyzer 96 | ``` 97 | 98 | Die Logs von Systemd Services landen standardmäßig bei journald: 99 | ```bash 100 | sudo journalctl -u analyzer 101 | -------------------------------------------------------------------------------- /docs/Install_with_docker-compose.md: -------------------------------------------------------------------------------- 1 | # Beispiel Konfiguration für Docker-Compose 2 | 3 | Im gewünschten Zielpfad einfach eine ```docker-compose.yml``` anlegen. 4 | Folgenden Inhalt einfügen, ggf. muss der Port angepasst werden, falls unter diesem Port bereits ein Dienst läuft und das USB-Device sollte gemäß dem verwendeten Gerät ausgewählt werden. 5 | 6 | ``` 7 | version: '3.2' 8 | services: 9 | asksinanalyzer: 10 | container_name: asksinanalyzer 11 | image: psitrax/asksinanalyzer 12 | restart: unless-stopped 13 | volumes: 14 | - ./data:/data 15 | - /run/udev:/run/udev:ro 16 | ports: 17 | - 8081:8081 18 | environment: 19 | - TZ=Europe/Berlin 20 | devices: 21 | # Make sure this matched your adapter location 22 | - /dev/ttyUSB1:/dev/ttyUSB1 23 | ``` 24 | 25 | Danach ```docker-compose pull``` und ```docker-compose up -d``` 26 | Die Konfigurationsdaten und persistent gespeicherten Daten werden unter dem angegebenen Pfad (hier der Unterordner "./data") abgelegt. 27 | -------------------------------------------------------------------------------- /docs/NanoCul_with_display.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/psi-4ward/AskSinAnalyzerXS/0b607e16befb13b73818788b6b761a50fa49fb94/docs/NanoCul_with_display.jpg -------------------------------------------------------------------------------- /docs/NodeRED-RSSI-Noise_Alert-Trigger_Example.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/psi-4ward/AskSinAnalyzerXS/0b607e16befb13b73818788b6b761a50fa49fb94/docs/NodeRED-RSSI-Noise_Alert-Trigger_Example.png -------------------------------------------------------------------------------- /docs/NodeRED-Websocket_example.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/psi-4ward/AskSinAnalyzerXS/0b607e16befb13b73818788b6b761a50fa49fb94/docs/NodeRED-Websocket_example.png -------------------------------------------------------------------------------- /docs/NodeRED.md: -------------------------------------------------------------------------------- 1 | # NodeRED Integration 2 | 3 | [NodeRED](https://nodered.org/) ist als Addon [RedMatic](https://github.com/rdmtc/RedMatic#readme) für die CCU verfügbar. 4 | 5 | ## RSSI-Noise Alert Trigger 6 | 7 | Der RSSI-Noise Alert Trigger kann einen HTTP-Request ausführen, wenn der Threshold für die angegebene Zeit 8 | überschritten wurde. Dieser kann u.a. in NodeRED weiter verarbeitet werden um z.B. eine E-Mail oder Telegram-Messenger 9 | Nachricht zu verschicken. 10 | 11 | ![NodeRED RSSI-Noise Alert Trigger Example](./NodeRED-RSSI-Noise_Alert-Trigger_Example.png) 12 | 13 | Eine `http in` Node empfängt den HTTP-Request. **Wichtig** ist, dass dieser Request über eine `http response` Node mit einem 14 | Status-Code 200 quittiert wird. Zudem kann der Request nun weiterverarbeitet werden, z.B. über eine `change` Node 15 | um den Payload als leserlichen Text zu setzen und anschließend eine E-Mail zu verschicken. 16 | 17 | 18 | ## WebSocket Verbindung 19 | 20 | Der AskSin Analyzer XS exposed einen WebSocket der über NodeRED konsumiert werden kann. 21 | Hier kommt die `websocket in` Node zum Einsatz. In der Node-Konfigruation wird als Typ `Verbinden mit` gewählt 22 | und die URL entsprechend dem Server gesetzt auf dem der Analyzer läuft. Z.B. `ws://mein-server:8081`. 23 | **Wichtig**: Unter Senden/Empfangen wird `gesamte Nachricht` eingestellt, damit die Daten als _Object_ und nicht als _String_ interpretiert werden. 24 | 25 | Es werden nun alle Nachrichtenobjekte des Analyzers in den Flow injiziert. Interessant dürften hier vor allem `type=rssiNoise` 26 | und `type=telegram` sein. Der `payload` besteht aus den Nutzdaten des jeweiligen Typs. Zeitstempel (telegram.tstamp bzw rssiNoise[0]) 27 | sind JavaScript-like in Millisekunden. 28 | 29 | Damit kann NodeRED anhand von Telegrammen, DutyCycle oder RSSI-Noise weitere Aktionen auszuführen. 30 | 31 | ![NodeRED Websocket Example](./NodeRED-Websocket_example.png) 32 | -------------------------------------------------------------------------------- /docs/Proxmox-LXC-USB-paththrough.md: -------------------------------------------------------------------------------- 1 | 1. Den Analyzer anstecken 2 | 3 | 2. Prüfen ob und wie der USB serial converter erkannt wird. Mit dem Befehl `dmesg`. 4 | ```text 5 | $ dmesg 6 | ... 7 | usb 1-3: New USB device found, idVendor=0403, idProduct=6001, bcdDevice= 6.00 8 | usb 1-3: New USB device strings: Mfr=1, Product=2, SerialNumber=3 9 | usb 1-3: Product: FT232R USB UART 10 | usb 1-3: Manufacturer: FTDI 11 | usb 1-3: SerialNumber: 00301O3O 12 | ``` 13 | Wichtig sind hier vor allem `idVendor` und `idProduct`, auch kann man einen Blick auf `SerialNumber` werfen. 14 | 15 | 3. UDEV-Rule erstellen 16 | Neue Datei erstellen: `/etc/udev/rules.d/80-AskSinAnalyzer.rules` mit folgendem Inhalt wobei idVendor und idProduct sowie serial entsprechend der Ausgabe von `dmesg` angepasst werden muss. Andere USB-Serial-Adapter wie der CH340 haben **keine** Serial, dann wird dieser Filter aus der Rule gestrichen. 17 | ```text 18 | SUBSYSTEM=="tty", ATTRS{idVendor}=="0403", ATTRS{idProduct}=="6001", ATTRS{serial}=="00301O3O", GROUP="users", MODE="0666", SYMLINK+="ttyUSB_AskSinAnalyzer" 19 | ``` 20 | Anschließend wird die UDEV-Rule geladen: `udevadm control --reload-rules` und die Trigger ausgeführt: `udevadm trigger`. Nun sollte das neue Device angelegt worden sein: 21 | ```bash 22 | $ ls -l /dev/ttyUSB* 23 | crw-rw-rw- 1 root uucp 188, 0 31. Aug 19:54 /dev/ttyUSB0 24 | crwxrwxrwx 1 root root 7 31. Aug 19:54 /dev/ttyUSB_AskSinAnalyzer -> ttyUSB0 25 | ``` 26 | In der ersten Zeile zeigt sich (in diesem Fall) das FTDI-Gerät, welches in /dev/ttyUSB0 gehängt wird und in der zweiten Zeile das "Symlinkgerät" welches auf "ttyUSB0" zeigt. 27 | Die oben angebene UDEV-Rule verhintert also, dass wenn das Gerät einmal in "ttyUSB1" gehängt wird, dass es nicht auffindbar ist, da es in der LXC Config nur mit dem Symlink angesprochen wird. 28 | 29 | Wichtig zudem ist hier noch die ID von `188`. 30 | 31 | 4. Anpassung der LXC Config (zB: `/etc/pve/nodes/proxmox/lxc/.conf`) 32 | ```text 33 | lxc.cgroup.devices.allow: c 188:* rwm 34 | lxc.mount.entry: /dev/ttyUSB_AskSinAnalyzer dev/ttyUSB0 none bind,optional,create=file 35 | ``` 36 | Hier wird zum einen das Gerät mit der Major ID 188 für den Container erlaubt und zu anderen das Gerät welches wir unter den Symlink "/dev/ttyUSB_AskSinAnalyzer" eingehängt haben im Containter unter "dev/ttyUSB0" (ohne voranstehendes "/") verfügbar gemacht. 37 | Im Container sollte nun `/dev/ttyUSB0` vorhanden sein welcher im Analyzer verwendet werden kann. 38 | 39 | -------------------------------------------------------------------------------- /ui/.browserslistrc: -------------------------------------------------------------------------------- 1 | > 0.5% 2 | not dead 3 | not ie < 12 4 | not ie_mob < 12 -------------------------------------------------------------------------------- /ui/README.md: -------------------------------------------------------------------------------- 1 | # AskSin Analyzer WebUI 2 | 3 | Built with [Vue.js](http://vuejs.org) and [Qasar](https://quasar.dev). 4 | 5 | ![WebUI](../Images/web_main.png) 6 | 7 | ## Project setup 8 | ``` 9 | npm install 10 | ``` 11 | 12 | ### Compiles and hot-reloads for development 13 | ``` 14 | npm run dev 15 | ``` 16 | 17 | ### Compiles and minifies for production 18 | ``` 19 | export VUE_APP_CDN_URL=https://user.github.io/AskSinAnalyzer 20 | npm run build 21 | ``` 22 | -------------------------------------------------------------------------------- /ui/babel.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | "presets": [ 3 | ["@vue/app", { useBuiltIns: "entry" }] 4 | ], 5 | "plugins": [ 6 | [ 7 | "transform-imports", 8 | { 9 | "quasar": { 10 | "transform": require("quasar/dist/babel-transforms/imports.js"), 11 | "preventFullImport": false 12 | } 13 | } 14 | ] 15 | ] 16 | }; 17 | -------------------------------------------------------------------------------- /ui/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "asksin-analyzer-ui", 3 | "description": "AskSinAnalyzerXS VueJS WebUI", 4 | "version": "0.0.0", 5 | "license": "CC BY-NC-SA 4.0", 6 | "private": true, 7 | "scripts": { 8 | "serve": "vue-cli-service serve", 9 | "build": "vue-cli-service build", 10 | "dev": "npm run serve", 11 | "mock": "node backend-mock" 12 | }, 13 | "dependencies": { 14 | "@quasar/extras": "^1.8.1", 15 | "core-js": "^3.6.5", 16 | "file-saver": "^2.0.2", 17 | "highcharts": "^7.2.2", 18 | "quasar": "^1.11.3", 19 | "semver-compare": "^1.0.0", 20 | "vue": "^2.6.11", 21 | "vue-router": "^3.2.0" 22 | }, 23 | "devDependencies": { 24 | "@vue/cli-plugin-babel": "^4.3.1", 25 | "@vue/cli-service": "^4.3.1", 26 | "babel-plugin-transform-imports": "^2.0.0", 27 | "highlightjs": "^9.16.2", 28 | "marked": "^1.1.0", 29 | "stylus": "^0.54.7", 30 | "stylus-loader": "^3.0.2", 31 | "vue-cli-plugin-quasar": "^2.0.2", 32 | "vue-template-compiler": "^2.6.11" 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /ui/postcss.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | autoprefixer: {} 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /ui/public/android-icon-144x144.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/psi-4ward/AskSinAnalyzerXS/0b607e16befb13b73818788b6b761a50fa49fb94/ui/public/android-icon-144x144.png -------------------------------------------------------------------------------- /ui/public/android-icon-192x192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/psi-4ward/AskSinAnalyzerXS/0b607e16befb13b73818788b6b761a50fa49fb94/ui/public/android-icon-192x192.png -------------------------------------------------------------------------------- /ui/public/android-icon-36x36.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/psi-4ward/AskSinAnalyzerXS/0b607e16befb13b73818788b6b761a50fa49fb94/ui/public/android-icon-36x36.png -------------------------------------------------------------------------------- /ui/public/android-icon-48x48.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/psi-4ward/AskSinAnalyzerXS/0b607e16befb13b73818788b6b761a50fa49fb94/ui/public/android-icon-48x48.png -------------------------------------------------------------------------------- /ui/public/android-icon-72x72.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/psi-4ward/AskSinAnalyzerXS/0b607e16befb13b73818788b6b761a50fa49fb94/ui/public/android-icon-72x72.png -------------------------------------------------------------------------------- /ui/public/android-icon-96x96.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/psi-4ward/AskSinAnalyzerXS/0b607e16befb13b73818788b6b761a50fa49fb94/ui/public/android-icon-96x96.png -------------------------------------------------------------------------------- /ui/public/apple-icon-114x114.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/psi-4ward/AskSinAnalyzerXS/0b607e16befb13b73818788b6b761a50fa49fb94/ui/public/apple-icon-114x114.png -------------------------------------------------------------------------------- /ui/public/apple-icon-120x120.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/psi-4ward/AskSinAnalyzerXS/0b607e16befb13b73818788b6b761a50fa49fb94/ui/public/apple-icon-120x120.png -------------------------------------------------------------------------------- /ui/public/apple-icon-144x144.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/psi-4ward/AskSinAnalyzerXS/0b607e16befb13b73818788b6b761a50fa49fb94/ui/public/apple-icon-144x144.png -------------------------------------------------------------------------------- /ui/public/apple-icon-152x152.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/psi-4ward/AskSinAnalyzerXS/0b607e16befb13b73818788b6b761a50fa49fb94/ui/public/apple-icon-152x152.png -------------------------------------------------------------------------------- /ui/public/apple-icon-180x180.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/psi-4ward/AskSinAnalyzerXS/0b607e16befb13b73818788b6b761a50fa49fb94/ui/public/apple-icon-180x180.png -------------------------------------------------------------------------------- /ui/public/apple-icon-57x57.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/psi-4ward/AskSinAnalyzerXS/0b607e16befb13b73818788b6b761a50fa49fb94/ui/public/apple-icon-57x57.png -------------------------------------------------------------------------------- /ui/public/apple-icon-60x60.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/psi-4ward/AskSinAnalyzerXS/0b607e16befb13b73818788b6b761a50fa49fb94/ui/public/apple-icon-60x60.png -------------------------------------------------------------------------------- /ui/public/apple-icon-72x72.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/psi-4ward/AskSinAnalyzerXS/0b607e16befb13b73818788b6b761a50fa49fb94/ui/public/apple-icon-72x72.png -------------------------------------------------------------------------------- /ui/public/apple-icon-76x76.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/psi-4ward/AskSinAnalyzerXS/0b607e16befb13b73818788b6b761a50fa49fb94/ui/public/apple-icon-76x76.png -------------------------------------------------------------------------------- /ui/public/apple-icon-precomposed.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/psi-4ward/AskSinAnalyzerXS/0b607e16befb13b73818788b6b761a50fa49fb94/ui/public/apple-icon-precomposed.png -------------------------------------------------------------------------------- /ui/public/apple-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/psi-4ward/AskSinAnalyzerXS/0b607e16befb13b73818788b6b761a50fa49fb94/ui/public/apple-icon.png -------------------------------------------------------------------------------- /ui/public/browserconfig.xml: -------------------------------------------------------------------------------- 1 | 2 | #ffffff -------------------------------------------------------------------------------- /ui/public/favicon-16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/psi-4ward/AskSinAnalyzerXS/0b607e16befb13b73818788b6b761a50fa49fb94/ui/public/favicon-16x16.png -------------------------------------------------------------------------------- /ui/public/favicon-32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/psi-4ward/AskSinAnalyzerXS/0b607e16befb13b73818788b6b761a50fa49fb94/ui/public/favicon-32x32.png -------------------------------------------------------------------------------- /ui/public/favicon-96x96.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/psi-4ward/AskSinAnalyzerXS/0b607e16befb13b73818788b6b761a50fa49fb94/ui/public/favicon-96x96.png -------------------------------------------------------------------------------- /ui/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/psi-4ward/AskSinAnalyzerXS/0b607e16befb13b73818788b6b761a50fa49fb94/ui/public/favicon.ico -------------------------------------------------------------------------------- /ui/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | AskSinAnalyzer XS 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 30 |
31 |
32 |

AskSin Analyzer XS

33 |
34 |
35 |
36 | 37 | 38 | -------------------------------------------------------------------------------- /ui/public/logos/Electron.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/psi-4ward/AskSinAnalyzerXS/0b607e16befb13b73818788b6b761a50fa49fb94/ui/public/logos/Electron.png -------------------------------------------------------------------------------- /ui/public/logos/Highcharts.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/psi-4ward/AskSinAnalyzerXS/0b607e16befb13b73818788b6b761a50fa49fb94/ui/public/logos/Highcharts.png -------------------------------------------------------------------------------- /ui/public/logos/NodeJS.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /ui/public/logos/Quasar.svg: -------------------------------------------------------------------------------- 1 | 2 | 17 | 19 | 20 | 22 | image/svg+xml 23 | 25 | 26 | 27 | 28 | 29 | 31 | 52 | 55 | 60 | 65 | 70 | 78 | 85 | 94 | 103 | 112 | 121 | 130 | 139 | 140 | 141 | 142 | -------------------------------------------------------------------------------- /ui/public/logos/Vue.js.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /ui/public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "AskSinAnalyzer XS", 3 | "icons": [ 4 | { 5 | "src": "\/android-icon-36x36.png", 6 | "sizes": "36x36", 7 | "type": "image\/png", 8 | "density": "0.75" 9 | }, 10 | { 11 | "src": "\/android-icon-48x48.png", 12 | "sizes": "48x48", 13 | "type": "image\/png", 14 | "density": "1.0" 15 | }, 16 | { 17 | "src": "\/android-icon-72x72.png", 18 | "sizes": "72x72", 19 | "type": "image\/png", 20 | "density": "1.5" 21 | }, 22 | { 23 | "src": "\/android-icon-96x96.png", 24 | "sizes": "96x96", 25 | "type": "image\/png", 26 | "density": "2.0" 27 | }, 28 | { 29 | "src": "\/android-icon-144x144.png", 30 | "sizes": "144x144", 31 | "type": "image\/png", 32 | "density": "3.0" 33 | }, 34 | { 35 | "src": "\/android-icon-192x192.png", 36 | "sizes": "192x192", 37 | "type": "image\/png", 38 | "density": "4.0" 39 | } 40 | ] 41 | } 42 | -------------------------------------------------------------------------------- /ui/public/ms-icon-144x144.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/psi-4ward/AskSinAnalyzerXS/0b607e16befb13b73818788b6b761a50fa49fb94/ui/public/ms-icon-144x144.png -------------------------------------------------------------------------------- /ui/public/ms-icon-150x150.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/psi-4ward/AskSinAnalyzerXS/0b607e16befb13b73818788b6b761a50fa49fb94/ui/public/ms-icon-150x150.png -------------------------------------------------------------------------------- /ui/public/ms-icon-310x310.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/psi-4ward/AskSinAnalyzerXS/0b607e16befb13b73818788b6b761a50fa49fb94/ui/public/ms-icon-310x310.png -------------------------------------------------------------------------------- /ui/public/ms-icon-70x70.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/psi-4ward/AskSinAnalyzerXS/0b607e16befb13b73818788b6b761a50fa49fb94/ui/public/ms-icon-70x70.png -------------------------------------------------------------------------------- /ui/src/App.vue: -------------------------------------------------------------------------------- 1 | 40 | 41 | 54 | 55 | 70 | -------------------------------------------------------------------------------- /ui/src/Service.js: -------------------------------------------------------------------------------- 1 | function createUuid() { 2 | let dt = new Date().getTime(); 3 | return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function(c) { 4 | let r = (dt + Math.random() * 16) % 16 | 0; 5 | dt = Math.floor(dt / 16); 6 | return (c === 'x' ? r : (r & 0x3 | 0x8)).toString(16); 7 | }); 8 | } 9 | 10 | export default class Service { 11 | 12 | data = { 13 | telegrams: [], 14 | devices: [], 15 | config: { 16 | isCCU: true, 17 | deviceListUrl: null, 18 | serialPort: null, 19 | serialBaudRate: 57600, 20 | _availableSerialPorts: [], 21 | maxTelegrams: 20000, 22 | recentHistoryMins: 70, 23 | animations: false, 24 | persistentStorage: { 25 | enabled: false, 26 | keepFiles: 0, 27 | flushInterval: 0, 28 | maxBufferSize: 500000 29 | }, 30 | rssiNoiseTrigger: { 31 | enabled: false, 32 | value: -80, 33 | timeWindow: 5, 34 | action: 'httpPost', 35 | actionOpts: { 36 | url: '' 37 | } 38 | }, 39 | _appPath: null, 40 | _began: Date.now(), 41 | _mem: { 42 | heapUsed: 0, 43 | rss: 0 44 | } 45 | }, 46 | beErrors: [], 47 | feErrors: [], 48 | currentVersion: null, 49 | latestVersion: null, 50 | devlistCreated: null, 51 | liveData: true, 52 | dropUnkownDevices: false, 53 | }; 54 | rssiLog = []; 55 | ws = null; 56 | rpcResponseMap = new Map(); 57 | devicesSet = new Set(); 58 | 59 | async openWebsocket() { 60 | return new Promise((resolve, reject) => { 61 | // TODO: reconnect 62 | let resolved = false; 63 | this.ws = new WebSocket(`${document.location.protocol === 'https:' ? 'wss' : 'ws'}://${ document.location.host }/ws`); 64 | 65 | this.ws.onopen = () => { 66 | this.data.beErrors = []; 67 | resolved = true; 68 | resolve(this.ws); 69 | }; 70 | 71 | this.ws.onerror = (err) => { 72 | const msg = 'Verbindung zum Analyzer wurde unterbrochen.'; 73 | console.error(err); 74 | if (!resolved) { 75 | resolved = true; 76 | reject(new Error(msg)); 77 | } else { 78 | this.data.feErrors.unshift(msg); 79 | } 80 | }; 81 | 82 | this.ws.onclose = (err) => { 83 | this.data.feErrors.unshift('Verbindung zum Analyzer wurde getrennt.'); 84 | }; 85 | 86 | this.ws.onmessage = msg => { 87 | let data = JSON.parse(msg.data); 88 | // console.log('WS-Msg:', data); 89 | if (!Array.isArray(data)) data = [data]; 90 | data.forEach(({ type, payload, uuid }) => { 91 | if (uuid && this.rpcResponseMap.has(uuid)) { 92 | const { resolve, reject } = this.rpcResponseMap.get(uuid); 93 | resolve(payload); 94 | this.rpcResponseMap.delete(uuid); 95 | return; 96 | } 97 | switch (type) { 98 | case 'telegram': 99 | if (!this.data.liveData) return; 100 | this.addTelegram(payload); 101 | break; 102 | case 'telegrams': 103 | if (!this.data.liveData) return; 104 | payload.forEach(telegram => this.addTelegram(telegram)); 105 | break; 106 | case 'rssiNoise': 107 | if (!this.data.liveData) return; 108 | this.addRssiNoise(...payload); 109 | break; 110 | case 'rssiNoises': 111 | if (!this.data.liveData) return; 112 | payload.forEach(rssiNoise => this.addRssiNoise(...rssiNoise)); 113 | break; 114 | case 'error': 115 | this.data.beErrors = payload; 116 | break; 117 | case 'config': 118 | this.data.config = { ...this.data.config, ...payload }; 119 | break; 120 | } 121 | }); 122 | }; 123 | }); 124 | } 125 | 126 | send(type, payload = null) { 127 | this.ws.send(JSON.stringify({ type, payload })); 128 | } 129 | 130 | async req(type, payload = null) { 131 | const uuid = createUuid(); 132 | const promise = new Promise((resolve, reject) => { 133 | this.rpcResponseMap.set(uuid, { resolve, reject }); 134 | }); 135 | this.ws.send(JSON.stringify({ type, payload, uuid })); 136 | return promise 137 | } 138 | 139 | addRssiNoise(mtstamp, value) { 140 | const lastIndex = this.rssiLog.length - 1; 141 | // round milliseconds 142 | mtstamp = Math.round(mtstamp / 1000) * 1000; 143 | if (lastIndex > 0 && this.rssiLog[lastIndex][0] === mtstamp) { 144 | this.rssiLog[lastIndex][1] = Math.round((this.rssiLog[lastIndex][1] + value) / 2); 145 | } else { 146 | this.rssiLog.push([mtstamp, value]); 147 | } 148 | 149 | // Cap collection 150 | if (this.rssiLog.length > this.maxTelegrams) { 151 | this.rssiLog.splice(0, this.rssiLog.length - this.maxTelegrams); 152 | } 153 | } 154 | 155 | addTelegram(telegram, liveData = true) { 156 | this.data.telegrams.push(telegram); 157 | 158 | if (liveData) { 159 | // Cap collection 160 | if (this.data.telegrams.length > this.maxTelegrams - 200) { 161 | this.data.telegrams.splice(0, this.data.telegrams.length - this.maxTelegrams); 162 | this.generateDeviceList(); // Regenerate deviceList 163 | } else { 164 | this.generateDeviceList(telegram); // Add possible new devices 165 | } 166 | } 167 | } 168 | 169 | // Generate unique devices list 170 | generateDeviceList(telegram = null) { 171 | let telegrams = [telegram]; 172 | if(telegram === null) { 173 | this.devicesSet = new Set(['==Unbekannt==']); 174 | telegrams = this.data.telegrams; 175 | } 176 | let newDeviceAdded = false; 177 | telegrams.forEach(({ fromName, toName, toAddr, fromAddr }) => { 178 | if (fromName && !this.devicesSet.has(fromName)) { 179 | this.devicesSet.add(fromName); 180 | newDeviceAdded = true; 181 | } else if (!fromName && !this.devicesSet.has(fromAddr)) { 182 | this.devicesSet.add(fromAddr); 183 | newDeviceAdded = true; 184 | } 185 | if (toName && !this.devicesSet.has(toName)) { 186 | this.devicesSet.add(toName); 187 | newDeviceAdded = true; 188 | } else if (!toName && !this.devicesSet.has(toAddr)) { 189 | this.devicesSet.add(toAddr); 190 | newDeviceAdded = true; 191 | } 192 | }); 193 | if(newDeviceAdded) { 194 | const devices = Array.from(this.devicesSet); 195 | devices.sort((a, b) => a.toString().toLowerCase().localeCompare(b.toString().toLowerCase())); 196 | this.data.devices.splice(0, devices.length, ...devices); 197 | } 198 | } 199 | 200 | clear() { 201 | this.data.devices = []; 202 | this.data.telegrams = []; 203 | this.rssiLog = []; 204 | } 205 | 206 | async loadCsvData(data) { 207 | this.data.liveData = false; 208 | const lines = data.split(/\n\r?/); 209 | const header = lines.shift().split(';'); 210 | lines.forEach(line => { 211 | if (line.length < 10) return; 212 | const cells = line.split(';'); 213 | const res = {}; 214 | cells.forEach((cell, i) => { 215 | const fld = header[i]; 216 | if (fld === 'date') return; // not needed, tstamp is used 217 | switch (fld) { 218 | case 'flags': 219 | cell = cell.split(','); 220 | break; 221 | case 'tstamp': 222 | case 'cnt': 223 | case 'len': 224 | case 'rssi': 225 | cell = parseInt(cell, 10); 226 | break; 227 | case 'dc': 228 | cell = parseFloat(cell); 229 | break; 230 | case 'fromIsIp': 231 | case 'toIsIp': 232 | cell = cell === "true"; 233 | break; 234 | } 235 | res[fld] = cell; 236 | }); 237 | this.addTelegram(res, false); 238 | }); 239 | this.generateDeviceList(); 240 | } 241 | 242 | enableLiveData() { 243 | this.clear(); 244 | this.send('get recentHistory'); 245 | this.data.liveData = true; 246 | } 247 | 248 | } 249 | -------------------------------------------------------------------------------- /ui/src/assets/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/psi-4ward/AskSinAnalyzerXS/0b607e16befb13b73818788b6b761a50fa49fb94/ui/src/assets/logo.png -------------------------------------------------------------------------------- /ui/src/components/DutyCyclePerDevice.vue: -------------------------------------------------------------------------------- 1 | 15 | 16 | 109 | -------------------------------------------------------------------------------- /ui/src/components/Errors.vue: -------------------------------------------------------------------------------- 1 | 23 | 24 | 38 | -------------------------------------------------------------------------------- /ui/src/components/FlagChip.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 44 | 45 | -------------------------------------------------------------------------------- /ui/src/components/HistoryFileList.vue: -------------------------------------------------------------------------------- 1 | 60 | 61 | 164 | 165 | 170 | -------------------------------------------------------------------------------- /ui/src/components/PieChart.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 88 | 89 | 92 | -------------------------------------------------------------------------------- /ui/src/components/RssiValue.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 35 | 36 | 42 | -------------------------------------------------------------------------------- /ui/src/components/Settings.vue: -------------------------------------------------------------------------------- 1 | 153 | 154 | 193 | 194 | 198 | -------------------------------------------------------------------------------- /ui/src/components/TelegramList.vue: -------------------------------------------------------------------------------- 1 | 120 | 121 | 123 | 124 | 239 | 240 | 252 | -------------------------------------------------------------------------------- /ui/src/components/TimeChart.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 135 | 136 | 139 | -------------------------------------------------------------------------------- /ui/src/components/Trigger.vue: -------------------------------------------------------------------------------- 1 | 52 | 53 | 73 | 74 | 78 | -------------------------------------------------------------------------------- /ui/src/components/filters/RssiFilter.vue: -------------------------------------------------------------------------------- 1 | 14 | 15 | 38 | 39 | 42 | -------------------------------------------------------------------------------- /ui/src/components/filters/SelectFilter.vue: -------------------------------------------------------------------------------- 1 | 18 | 19 | 20 | 74 | -------------------------------------------------------------------------------- /ui/src/components/filters/TimeFilter.vue: -------------------------------------------------------------------------------- 1 | 15 | 16 | 17 | 85 | -------------------------------------------------------------------------------- /ui/src/filter/date.js: -------------------------------------------------------------------------------- 1 | import Vue from 'vue'; 2 | 3 | function pad(v) { 4 | return ('00' + v.toString()).slice(-2); 5 | } 6 | 7 | Vue.filter('date', v => { 8 | const d = new Date(v); 9 | return `${ pad(d.getHours()) }:${ pad(d.getMinutes()) }:${ pad(d.getSeconds()) }`; 10 | }); 11 | -------------------------------------------------------------------------------- /ui/src/filter/filesize.js: -------------------------------------------------------------------------------- 1 | import Vue from 'vue'; 2 | 3 | function formatBytes(bytes, decimals = 2) { 4 | if (bytes === 0) return '0 Bytes'; 5 | const k = 1024; 6 | const dm = decimals < 0 ? 0 : decimals; 7 | const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB']; 8 | const i = Math.floor(Math.log(bytes) / Math.log(k)); 9 | return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + ' ' + sizes[i]; 10 | } 11 | 12 | Vue.filter('filesize', formatBytes); 13 | -------------------------------------------------------------------------------- /ui/src/filter/index.js: -------------------------------------------------------------------------------- 1 | import './date'; 2 | import './filesize'; 3 | -------------------------------------------------------------------------------- /ui/src/main.js: -------------------------------------------------------------------------------- 1 | import Vue from 'vue' 2 | import { Quasar, Ripple, ClosePopup, Loading } from 'quasar'; 3 | import lang from 'quasar/lang/de.js' 4 | import './styles/quasar.styl' 5 | import '@quasar/extras/material-icons/material-icons.css' 6 | 7 | import {version} from '../../app/package.json'; 8 | 9 | import App from './App.vue' 10 | import './filter'; 11 | import router from './router' 12 | import Service from './Service'; 13 | 14 | // Init Service 15 | const service = new Service(); 16 | Vue.prototype.$service = service; 17 | 18 | Vue.prototype.$debounce = function(fn, delay) { 19 | let timeoutID = null; 20 | return function() { 21 | clearTimeout(timeoutID); 22 | const args = arguments; 23 | const that = this; 24 | timeoutID = setTimeout(function() { 25 | fn.apply(that, args) 26 | }, delay) 27 | } 28 | }; 29 | 30 | // Init Vue 31 | Vue.use(Quasar, { lang, directives: { Ripple, ClosePopup } }); 32 | Vue.config.productionTip = false; 33 | 34 | const vm = new Vue({ 35 | router, 36 | data() { 37 | return { 38 | version, 39 | COMMIT_HASH: process.env.VUE_APP_COMMIT_HASH || 'master', 40 | data: service.data, 41 | timefilter: { 42 | start: null, 43 | stop: null 44 | } 45 | }; 46 | }, 47 | beforeMount() { 48 | if(!service.data.config.serialPort && this.$route.path !== '/settings') { 49 | this.$router.push('/settings'); 50 | } 51 | }, 52 | render: h => h(App) 53 | }); 54 | 55 | // Init 56 | (async function() { 57 | try { 58 | await service.openWebsocket(); 59 | // TODO: Update notifier 60 | } catch (e) { 61 | console.error(e); 62 | vm.data.feErrors.unshift(e.toString()); 63 | } 64 | setTimeout(() => { 65 | vm.$mount('#app'); 66 | },500); 67 | })(); 68 | 69 | 70 | -------------------------------------------------------------------------------- /ui/src/router.js: -------------------------------------------------------------------------------- 1 | import Vue from 'vue' 2 | import Router from 'vue-router' 3 | 4 | import Home from './views/Home.vue' 5 | import WithTimeChart from './views/WithTimeChart.vue' 6 | import TelegramList from './views/TelegramList.vue' 7 | import Einstellungen from './views/Einstellungen.vue' 8 | import Info from './views/Info.vue' 9 | import Page404 from './views/404.vue' 10 | import HistoryFiles from "./views/HistoryFiles"; 11 | 12 | Vue.use(Router); 13 | 14 | const router = new Router({ 15 | routes: [ 16 | { 17 | path: '/', 18 | redirect: '/home' 19 | }, 20 | { 21 | path: '/home', 22 | component: WithTimeChart, 23 | children: [ 24 | { 25 | path: '', 26 | component: Home 27 | }, 28 | { 29 | path: '/list', 30 | component: TelegramList 31 | }, 32 | ] 33 | }, 34 | { 35 | path: '/history', 36 | component: HistoryFiles 37 | }, 38 | { 39 | path: '/settings', 40 | component: Einstellungen 41 | }, 42 | { 43 | path: '/info', 44 | component: Info 45 | }, 46 | { 47 | path: '*', 48 | component: Page404 49 | } 50 | ] 51 | }); 52 | 53 | export default router; 54 | -------------------------------------------------------------------------------- /ui/src/styles/quasar.styl: -------------------------------------------------------------------------------- 1 | @import './quasar.variables' 2 | @import '~quasar-styl' 3 | @import "~highlightjs/styles/github.css" 4 | 5 | .route-enter-active, .route-leave-active 6 | transition: all 0.35s 7 | 8 | .route-enter, .route-leave-to 9 | opacity: 0 10 | transform: translate(-20%, 0); 11 | 12 | body 13 | background-color #FAFAFA 14 | 15 | a 16 | color $primary 17 | text-decoration none 18 | 19 | header 20 | background linear-gradient(145deg, #027be3 11%, #014a88 75%) !important 21 | 22 | .page 23 | padding 1.5rem 2rem 24 | 25 | h1, h2, h3, h4, h5 26 | margin 0 0 1rem 0 27 | padding-bottom 0.4rem 28 | border-bottom 1px solid #8f8f8f 29 | 30 | h1 31 | font-size 2.5rem 32 | line-height 3rem 33 | 34 | h2 35 | font-size 2rem 36 | line-height 2.5rem 37 | 38 | h3 39 | font-size 1.5rem 40 | line-height 2rem 41 | 42 | h4 43 | font-size 1.1rem 44 | line-height 1.5rem 45 | 46 | form 47 | max-width none !important 48 | 49 | .q-loading.fullscreen 50 | font-size 1.2rem 51 | font-weight bold 52 | background rgba(0, 0, 0, 0.4) 53 | 54 | div 55 | max-width none 56 | 57 | .bg-amber 58 | color: black !important 59 | 60 | .q-btn 61 | &.q-btn__rotate-icon 62 | padding-right: 45px 63 | 64 | i 65 | position absolute 66 | right: 0 67 | animation: 4s spin infinite steps(3600) 68 | @keyframes spin 69 | 0% 70 | transform: rotateZ(0deg) 71 | 100% 72 | transform: rotateZ(360deg) 73 | 74 | body .main-loading 75 | background rgba(0, 0, 0, 0.5); 76 | -------------------------------------------------------------------------------- /ui/src/styles/quasar.variables.styl: -------------------------------------------------------------------------------- 1 | $primary = #027BE3 2 | $secondary = #139325 3 | $accent = #9C27B0 4 | 5 | $positive = #21BA45 6 | $negative = #C10015 7 | $info = #025FAF 8 | $warning = #F2C037 9 | 10 | @import '~quasar-variables-styl' 11 | -------------------------------------------------------------------------------- /ui/src/views/404.vue: -------------------------------------------------------------------------------- 1 | 12 | -------------------------------------------------------------------------------- /ui/src/views/Einstellungen.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 17 | 18 | 20 | -------------------------------------------------------------------------------- /ui/src/views/HistoryFiles.vue: -------------------------------------------------------------------------------- 1 | 7 | 8 | 18 | 19 | 21 | -------------------------------------------------------------------------------- /ui/src/views/Home.vue: -------------------------------------------------------------------------------- 1 | 12 | 13 | 42 | -------------------------------------------------------------------------------- /ui/src/views/Info.vue: -------------------------------------------------------------------------------- 1 | 199 | 200 | 244 | 245 | 262 | -------------------------------------------------------------------------------- /ui/src/views/TelegramList.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 18 | -------------------------------------------------------------------------------- /ui/src/views/WithTimeChart.vue: -------------------------------------------------------------------------------- 1 | 11 | 12 | 13 | 23 | 24 | -------------------------------------------------------------------------------- /ui/src/webSocketClient.js: -------------------------------------------------------------------------------- 1 | const timestamp = () => new Date().toISOString().replace('T', ' ').substr(0, 19); 2 | 3 | function WebSocketClient(url) { 4 | let client; 5 | let timeout; 6 | let connecting = false; 7 | let backoff = 250; 8 | const init = () => { 9 | console.error(timestamp(), 'WebSocketClient :: connecting'); 10 | connecting = false; 11 | if (client !== undefined) { 12 | client.removeAllListeners(); 13 | } 14 | client = new WebSocket(url); 15 | const heartbeat = () => { 16 | if (timeout !== undefined) { 17 | clearTimeout(timeout); 18 | timeout = undefined; 19 | } 20 | timeout = setTimeout(() => client.terminate(), 35000); 21 | }; 22 | client.on('ping', () => { 23 | console.log(timestamp(), 'WebSocketClient :: pinged'); 24 | heartbeat(); 25 | }); 26 | client.on('open', (e) => { 27 | if (typeof this.onOpen === 'function') { 28 | this.onOpen(); 29 | } else { 30 | console.log(timestamp(), 'WebSocketClient :: opened'); 31 | console.log(e); 32 | } 33 | heartbeat(); 34 | }); 35 | client.on('message', (e) => { 36 | if (typeof this.onMessage === 'function') { 37 | this.onMessage(e); 38 | } else { 39 | console.log(timestamp(), 'WebSocketClient :: messaged'); 40 | } 41 | heartbeat(); 42 | }); 43 | client.on('close', (e) => { 44 | if (e.code !== 1000) { 45 | if (connecting === false) { // abnormal closure 46 | backoff = backoff === 8000 ? 250 : backoff * 2; 47 | setTimeout(() => init(), backoff); 48 | connecting = true; 49 | } 50 | } else if (typeof this.onClose === 'function') { 51 | this.onClose(); 52 | } else { 53 | console.error(timestamp(), 'WebSocketClient :: closed'); 54 | console.error(e); 55 | } 56 | }); 57 | client.on('error', (e) => { 58 | if (e.code === 'ECONREFUSED') { 59 | if (connecting === false) { // abnormal closure 60 | backoff = backoff === 8000 ? 250 : backoff * 2; 61 | setTimeout(() => init(), backoff); 62 | connecting = true; 63 | } 64 | } else if (typeof this.onError === 'function') { 65 | this.onError(e); 66 | } else { 67 | console.error(timestamp(), 'WebSocketClient :: errored'); 68 | console.error(e); 69 | } 70 | }); 71 | this.send = client.send.bind(client); 72 | }; 73 | init(); 74 | } 75 | 76 | module.exports = WebSocketClient; 77 | -------------------------------------------------------------------------------- /ui/vue.config.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | 3 | module.exports = { 4 | outputDir: path.resolve(__dirname, '../htdocs'), 5 | 6 | filenameHashing: true, 7 | 8 | pluginOptions: { 9 | quasar: { 10 | treeShake: true, 11 | importStrategy: 'kebab' 12 | } 13 | }, 14 | 15 | transpileDependencies: [ 16 | /[\\\/]node_modules[\\\/]quasar[\\\/]/ 17 | ], 18 | 19 | devServer: { 20 | proxy: { 21 | '/ws': { 22 | target:'http://localhost:3000', 23 | ws: true 24 | } 25 | } 26 | }, 27 | 28 | chainWebpack: config => { 29 | config.resolve.alias 30 | .set('@components', path.join(__dirname, 'src/components')); 31 | config.devServer.disableHostCheck = true; 32 | } 33 | 34 | // Variables that start with VUE_APP_ will be statically embedded into the client bundle with webpack.DefinePlugin 35 | }; 36 | --------------------------------------------------------------------------------