├── .editorconfig ├── .env.example ├── .github └── workflows │ └── build_and_deploy.yml ├── .gitignore ├── README.md ├── build.sh ├── docker-compose-frontend.yml ├── docker-compose-portainer.yml ├── docker-compose-rtl433.yaml ├── docker-compose-server.yml ├── docker-compose-zigbee2mqtt.yml ├── docker-compose.yml ├── docs ├── DOCKER.md ├── FRONTEND.md ├── README.md ├── SERVER.md └── screenshots │ ├── listing.png │ └── raspiscan.jpg ├── frontend ├── .editorconfig ├── .eslintrc.json ├── .gitignore ├── .stylelintrc ├── Dockerfile ├── config │ ├── BrowserSync.js │ ├── WebpackConfigClass.js │ ├── WebpackConfigCommon.js │ ├── WebpackConfigDev.js │ ├── WebpackConfigProd.js │ └── WebpackRun.js ├── dist │ └── prod │ │ ├── app.js │ │ ├── index.html │ │ └── version ├── package-lock.json ├── package.json ├── public │ ├── css │ │ └── fonts │ │ │ └── Barlow │ │ │ ├── barlow-v12-latin-100.woff2 │ │ │ ├── barlow-v12-latin-200.woff2 │ │ │ ├── barlow-v12-latin-300.woff2 │ │ │ ├── barlow-v12-latin-500.woff2 │ │ │ ├── barlow-v12-latin-600.woff2 │ │ │ ├── barlow-v12-latin-700.woff2 │ │ │ ├── barlow-v12-latin-800.woff2 │ │ │ ├── barlow-v12-latin-900.woff2 │ │ │ └── barlow-v12-latin-regular.woff2 │ ├── dev.html │ ├── favicon.ico │ ├── index.html │ └── index_template.html └── src │ ├── app.js │ ├── lib │ ├── Global │ │ ├── Events.js │ │ ├── Globals.js │ │ ├── Log.js │ │ ├── Module.js │ │ ├── Ramda.js │ │ └── Utils.js │ ├── Home │ │ ├── Templates │ │ │ ├── Device.html │ │ │ ├── DeviceTopics.html │ │ │ ├── Exclude.html │ │ │ ├── Topic.html │ │ │ ├── TopicAdd.html │ │ │ └── layout.html │ │ ├── device.js │ │ ├── devices.js │ │ ├── excludes.js │ │ ├── index.js │ │ ├── topic.js │ │ └── topics.js │ ├── Icons │ │ ├── book.html │ │ ├── check.html │ │ ├── close.html │ │ ├── eye.html │ │ ├── eye_alt.html │ │ ├── heart.html │ │ ├── home.html │ │ ├── index.js │ │ ├── mouth.html │ │ ├── music.html │ │ ├── options.html │ │ ├── pause.html │ │ ├── pen.html │ │ ├── play.html │ │ ├── plus.html │ │ ├── podcast.html │ │ ├── skip-next.html │ │ ├── skip-prev.html │ │ ├── stop.html │ │ ├── svg │ │ │ ├── book.svg │ │ │ ├── check.svg │ │ │ ├── close.svg │ │ │ ├── eye.svg │ │ │ ├── eye_alt.svg │ │ │ ├── heart.svg │ │ │ ├── home.svg │ │ │ ├── mouth.svg │ │ │ ├── music.svg │ │ │ ├── options.svg │ │ │ ├── pause.svg │ │ │ ├── pen.svg │ │ │ ├── play.svg │ │ │ ├── plus.svg │ │ │ ├── podcast.svg │ │ │ ├── skip-next.svg │ │ │ ├── skip-prev.svg │ │ │ ├── stop.svg │ │ │ └── user.svg │ │ └── user.html │ ├── Locale │ │ ├── Translations.js │ │ ├── de.json │ │ ├── en.json │ │ └── index.js │ ├── Main.js │ ├── Navigation │ │ ├── Templates │ │ │ └── navigation.html │ │ └── index.js │ └── Tab.js │ └── scss │ ├── app.scss │ ├── global │ ├── _index.scss │ ├── background.scss │ ├── base.scss │ ├── button.scss │ ├── colors.scss │ ├── form.scss │ ├── modal.scss │ ├── responsive.scss │ ├── tab.scss │ └── typo.scss │ └── modules │ ├── _index.scss │ ├── devices │ └── _index.scss │ ├── excludes │ └── _index.scss │ ├── home │ └── _index.scss │ ├── navigation │ └── _index.scss │ └── topics │ ├── _index.scss │ ├── add.scss │ └── topics.scss ├── rtl_433 ├── rtl-sdr.rules └── rtl_433.conf ├── server ├── .babelrc ├── .dockerignore ├── Dockerfile ├── app.js ├── config │ ├── default.conf.example │ ├── excludes.json.example │ ├── mapping.json.example │ └── types.json ├── entrypoint.sh ├── index.js ├── lib │ ├── Globals.js │ ├── Mqtt │ │ ├── Client.js │ │ └── index.js │ ├── RTL433 │ │ ├── Device.js │ │ ├── DeviceTopic.js │ │ ├── Excludes.js │ │ ├── Topics.js │ │ └── index.js │ └── Server │ │ ├── Route.js │ │ ├── base64.js │ │ ├── index.js │ │ └── routes │ │ ├── Device.js │ │ ├── Devices.js │ │ ├── Exclude.js │ │ ├── Excludes.js │ │ ├── Home.js │ │ ├── Topic.js │ │ ├── Topics.js │ │ └── index.js ├── package.json ├── testing.js └── webpack-app-pkg.config.js ├── setup.sh ├── setupBuildx.sh ├── shared ├── lib │ ├── Config.js │ ├── Events.js │ ├── Log.js │ ├── Module.js │ └── Utils.js └── package.json └── zigbee2mqtt └── configuration.yaml.example /.env.example: -------------------------------------------------------------------------------- 1 | # globals 2 | # --------------------------------------------------- 3 | 4 | # used for naming 5 | PROJECT_NAME=raspiscan 6 | 7 | # the docker compose binary from github 8 | DOCKER_COMPOSE_SOURCE=https://github.com/docker/compose/releases/download/v2.12.2/docker-compose-linux-armv7 9 | 10 | # docker build 11 | DOCKER_IMAGE_VERSION=0.2 12 | DOCKER_IMAGE_NAME=node-rtl433-ui 13 | DOCKER_REGISTRY=ghcr.io/seekwhencer 14 | DOCKER_BUILDX_PLATFORM=linux/arm64/v8,linux/amd64 15 | DOCKER_BUILDX_NAME=spachtelmasse 16 | DOCKER_QEMU_VERSION=7.2.0-1 17 | 18 | # the target for pkg binary (obsolete) 19 | BUILD_FILENAME=server_arm64 20 | BUILD_TARGET=node16-linux-arm64 21 | 22 | # Server App 23 | # --------------------------------------------------- 24 | 25 | # as title ;) 26 | SERVER_PORT=3000 27 | 28 | # container outside 29 | SERVER_PORT_OUTSIDE=3000 30 | 31 | # the RELATIVE path to the frontend production bundle 32 | SERVER_FRONTEND_PATH=../frontend/dist/prod 33 | 34 | # console output on topic event 35 | SERVER_LOG_TOPICS=true 36 | 37 | # USB Device 38 | # --------------------------------------------------- 39 | USB_DEVICE_SOURCE=/dev/bus/usb/001/003 40 | USB_DEVICE_TARGET=/dev/bus/usb/001/003 41 | 42 | # MQTT 43 | # --------------------------------------------------- 44 | # this is the reachabe ip of the mqtt broker - used by extra hosts in compose file 45 | MQTT_HOST_IP=192.... 46 | # docker network internal name, set by extra_hosts in compose file 47 | MQTT_HOST=broker 48 | # the mqtt port of broker 49 | MQTT_PORT=1886 50 | 51 | # Frontend Dev 52 | # --------------------------------------------------- 53 | # the dev proxy port 54 | FRONTEND_SERVER_PORT=4000 55 | 56 | # the hostname (docker hostname = service name) 57 | FRONTEND_PROXY_TARGET_HOST=raspiscan_server 58 | 59 | # the server port. same as SERVER_PORT 60 | FRONTEND_PROXY_TARGET_PORT=3000 61 | -------------------------------------------------------------------------------- /.github/workflows/build_and_deploy.yml: -------------------------------------------------------------------------------- 1 | name: build_and_deploy 2 | on: 3 | push: 4 | branches: 5 | - master 6 | paths: 7 | - frontend/** 8 | jobs: 9 | build-and-deploy: 10 | runs-on: ubuntu-latest 11 | defaults: 12 | run: 13 | working-directory: frontend 14 | steps: 15 | - name: Checkout 16 | uses: actions/checkout@v3 17 | 18 | - name: Setup Node 19 | uses: actions/setup-node@v3 20 | with: 21 | node-version: '16' 22 | 23 | - name: Run frontend build 24 | run: | 25 | npm install --legacy-peer-deps 26 | node --experimental-modules --experimental-json-modules config/WebpackConfigProd.js 27 | 28 | - name: Deploy 29 | uses: JamesIves/github-pages-deploy-action@releases/v4 30 | with: 31 | branch: frontend-production 32 | folder: frontend/dist/prod 33 | clean: true 34 | single-commit: true 35 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .idea/**/* 2 | node_modules 3 | .hot 4 | npm-debug.log 5 | .env 6 | 7 | frontend/dist 8 | server/dist 9 | server/config/**/* 10 | 11 | rtl_433/mapping.json 12 | zigbee2mqtt/**/* 13 | 14 | !.gitkeep 15 | !server/config/*.example 16 | !server/config/type.json 17 | !zigbee2mqtt/configuration.yaml.example -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # node-rtl433-ui 2 | 3 | This is a simple and user guided, graphical way to map a value from a 433 Mhz device to a MQTT topic. 4 | 5 | - scan for 433Mhz devices with [merbanan/rtl_433](https://github.com/merbanan/rtl_433) 6 | - map a **device** and **field** to a **topic** per web ui. 7 | - server works as MQTT client to your broker 8 | - server delivers the frontend statics and some api endpoints 9 | - server sends a value (single float value, not json) from a device on a specific MQTT topic to your broker - only when the value changes 10 | - server can update mapping at runtime 11 | - ui language can be edited and set 12 | - drop topic (delete mapped topic for a device and value) 13 | - add model to exclude list 14 | - add device to exclude list 15 | - drop entry from exclude list 16 | - sort device listing by *last update* or *signal count* 17 | - enable and disable list update 18 | - forget unmapped device after x seconds / minutes 19 | - enable and disable removing unmapped devices from list 20 | 21 | ![Screenshot device listing](../master/docs/screenshots/listing.png?raw=true "Screenshot device listing") 22 | 23 | ## Setup (Raspberry Pi 4) 24 | - take the lite OS image without desktop 25 | - set up your raspberry pi as you will (expand filesystem, enable ssh, disable bluetooth and wifi etc.) 26 | - install docker and docker compose, create docker volumes 27 | ```bash 28 | # use your home folder 29 | cd ~ 30 | 31 | # clone repo 32 | git clone https://github.com/seekwhencer/node-rtl433-ui.git raspiscan 33 | 34 | # the folder 35 | cd raspiscan # or: /home/pi/raspiscan or: ~/raspiscan 36 | 37 | # make the setup script runable 38 | chmod +x ./setup.sh 39 | 40 | # run the script not as sudo 41 | ./setup.sh 42 | ``` 43 | 44 | ## Config 45 | 46 | duplicate *.example files as * 47 | 48 | - edit the file `.env` 49 | - edit the file `server/config/default.conf` 50 | - edit the file `rtl433/rtl_433.conf` (if needed) 51 | 52 | ## Run 53 | 54 | - ```bash 55 | docker-compose up -d 56 | ``` 57 | > Now open: http://RASPBERRYPI:3000 58 | 59 | ## Roadmap 60 | what's next? 61 | - ui get the forget state at start 62 | 63 | ## In the wild 64 | ![raspiscan](../master/docs/screenshots/raspiscan.jpg?raw=true "raspiscan") 65 | -------------------------------------------------------------------------------- /build.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # 4 | # run this script on a docker host with buildx 5 | # 6 | 7 | # load .env file 8 | loadConfig() { 9 | DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" 10 | export $(egrep -v '^#' "${DIR}/.env" | xargs) 11 | 12 | export TAG_LATEST="${DOCKER_REGISTRY}/${DOCKER_IMAGE_NAME}:latest" 13 | export TAG_VERSION="${DOCKER_REGISTRY}/${DOCKER_IMAGE_NAME}:${DOCKER_IMAGE_VERSION}" 14 | 15 | } 16 | 17 | # build the docker image 18 | build() { 19 | env 20 | #docker build . -t ${TAG_LATEST} -t ${TAG_VERSION} -f ./server/Dockerfile 21 | 22 | docker buildx build . --no-cache --builder=${DOCKER_BUILDX_NAME} --platform=${DOCKER_BUILDX_PLATFORM} --push -t ${TAG_LATEST} -t ${TAG_VERSION} -f ./server/Dockerfile 23 | } 24 | 25 | # 26 | loadConfig 27 | build -------------------------------------------------------------------------------- /docker-compose-frontend.yml: -------------------------------------------------------------------------------- 1 | version: "3.6" 2 | 3 | networks: 4 | 5 | raspiscan: 6 | external: false 7 | name: ${PROJECT_NAME} 8 | 9 | services: 10 | 11 | kidsplayer_frontend: 12 | build: 13 | context: . 14 | dockerfile: frontend/Dockerfile 15 | image: ${PROJECT_NAME}_frontend 16 | working_dir: /raspiscan/frontend 17 | command: 'tail -f /dev/null' 18 | 19 | # or run on container start 20 | # command: "--experimental-modules --experimental-json-modules config/WebpackConfigDev.js" 21 | 22 | container_name: ${PROJECT_NAME}_frontend 23 | volumes: 24 | - .:/raspiscan 25 | networks: 26 | - raspiscan 27 | ports: 28 | - '${FRONTEND_SERVER_PORT}:${FRONTEND_SERVER_PORT_OUTSIDE}' 29 | environment: 30 | - DEBUG=true 31 | - ENVIRONMENT=default 32 | - PROXY_TARGET_HOST=${FRONTEND_PROXY_TARGET_HOST} 33 | - PROXY_TARGET_PORT=${FRONTEND_PROXY_TARGET_PORT} 34 | - SERVER_PORT=${FRONTEND_SERVER_PORT} 35 | -------------------------------------------------------------------------------- /docker-compose-portainer.yml: -------------------------------------------------------------------------------- 1 | version: '3.1' 2 | 3 | volumes: 4 | portainer_data: 5 | driver: local 6 | 7 | services: 8 | portainer: 9 | image: portainer/portainer 10 | container_name: "portainer-app" 11 | restart: always 12 | command: -H unix:///var/run/docker.sock 13 | ports: 14 | - "9000:9000" 15 | volumes: 16 | - /var/run/docker.sock:/var/run/docker.sock 17 | - /etc/localtime:/etc/localtime:ro 18 | - /etc/timezone:/etc/timezone:ro 19 | - portainer_data:/data 20 | -------------------------------------------------------------------------------- /docker-compose-rtl433.yaml: -------------------------------------------------------------------------------- 1 | version: "3.6" 2 | 3 | services: 4 | 5 | rtl433: 6 | restart: always 7 | image: hertzg/rtl_433:latest 8 | container_name: ${PROJECT_NAME}-rtl433 9 | command: "-F http" 10 | devices: 11 | - /dev/bus/usb/001/003:/dev/bus/usb/001/003 12 | ports: 13 | - "8433:8433" 14 | volumes: 15 | - ./rtl_433/rtl_433.conf:/etc/rtl_433/rtl_433.conf 16 | 17 | -------------------------------------------------------------------------------- /docker-compose-server.yml: -------------------------------------------------------------------------------- 1 | version: "3.6" 2 | 3 | networks: 4 | 5 | raspiscan: 6 | external: false 7 | name: ${PROJECT_NAME} 8 | 9 | services: 10 | 11 | raspiscan_server: 12 | build: 13 | context: . 14 | dockerfile: server/Dockerfile 15 | image: ${PROJECT_NAME}_server 16 | working_dir: /raspiscan/server 17 | 18 | # the app is not starting ! 19 | # just enter: 20 | # 21 | # docker exec -it raspiscan_server /bin/sh -c "node --experimental-modules --experimental-json-modules index.js" 22 | # 23 | # to run the server 24 | 25 | command: 'tail -f /dev/null' 26 | 27 | user: root 28 | container_name: ${PROJECT_NAME}_server 29 | restart: always 30 | volumes: 31 | - .:/raspiscan 32 | - ./rtl_433/rtl_433.conf:/etc/rtl_433/rtl_433.conf 33 | - ./rtl_433/rtl-sdr.rules:/etc/udev/rules.d/rtl-sdr.rules 34 | networks: 35 | - raspiscan 36 | ports: 37 | - "${SERVER_PORT}:${SERVER_PORT_OUTSIDE}" 38 | extra_hosts: 39 | - "${MQTT_HOST}:${MQTT_HOST_IP}" 40 | environment: 41 | - DEBUG=true 42 | - VERBOSE=2 43 | - ENVIRONMENT=default 44 | - NODE_ENV=development 45 | - PWD=/raspiscan/server 46 | # important change for development 47 | - SERVER_FRONTEND_PATH=../frontend/dist/dev 48 | devices: 49 | - "${USB_DEVICE_SOURCE}:${USB_DEVICE_TARGET}" 50 | privileged: true 51 | 52 | -------------------------------------------------------------------------------- /docker-compose-zigbee2mqtt.yml: -------------------------------------------------------------------------------- 1 | version: '3.8' 2 | 3 | networks: 4 | 5 | raspiscan: 6 | external: false 7 | name: ${PROJECT_NAME} 8 | 9 | services: 10 | 11 | zigbee2mqtt: 12 | container_name: ${PROJECT_NAME}_zigbee2mqtt 13 | restart: always 14 | #command: 'tail -f /dev/null' 15 | privileged: false 16 | image: koenkk/zigbee2mqtt:latest 17 | volumes: 18 | - ./zigbee2mqtt:/app/data 19 | - /run/udev:/run/udev:ro 20 | networks: 21 | - raspiscan 22 | ports: 23 | - "${ZIGBEE_PORT}:${ZIGBEE_PORT_OUTSIDE}/tcp" 24 | extra_hosts: 25 | - "${MQTT_HOST}:${MQTT_HOST_IP}" 26 | environment: 27 | - TZ=Europe/Berlin 28 | devices: 29 | - /dev/serial/by-id/usb-Silicon_Labs_Sonoff_Zigbee_3.0_USB_Dongle_Plus_0001-if00-port0:/dev/ttyUSB0 30 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: "3.6" 2 | 3 | networks: 4 | 5 | raspiscan: 6 | external: false 7 | name: ${PROJECT_NAME} 8 | 9 | services: 10 | 11 | raspiscan_server: 12 | image: ghcr.io/seekwhencer/node-rtl433-ui:${DOCKER_IMAGE_VERSION} 13 | working_dir: /${PROJECT_NAME}/server 14 | command: '--experimental-modules --experimental-json-modules index.js' 15 | user: root 16 | container_name: ${PROJECT_NAME}_server 17 | restart: always 18 | volumes: 19 | - .env:/raspiscan/.env 20 | - ./server/config:/raspiscan/server/config 21 | 22 | # or only the config file 23 | # - ./server/config/default.conf:/raspiscan/server/config/default.conf 24 | networks: 25 | - raspiscan 26 | ports: 27 | - "${SERVER_PORT}:${SERVER_PORT_OUTSIDE}" 28 | extra_hosts: 29 | - "${MQTT_HOST}:${MQTT_HOST_IP}" 30 | environment: 31 | - DEBUG=true 32 | - VERBOSE=2 33 | # equals the config file: server/config/{ENVIRONMENT}.conf 34 | - ENVIRONMENT=default 35 | - NODE_ENV=production 36 | - PWD=/raspiscan/server 37 | # important, don't change it. the frontend production bundle is placed there 38 | - SERVER_FRONTEND_PATH=frontend 39 | # log topic and device events 40 | - SERVER_LOG_TOPICS=false 41 | devices: 42 | - "${USB_DEVICE_SOURCE}:${USB_DEVICE_TARGET}" 43 | privileged: true 44 | 45 | -------------------------------------------------------------------------------- /docs/DOCKER.md: -------------------------------------------------------------------------------- 1 | ## *node-rtl433-ui* 2 | # Docker 3 | 4 | You can drop all files from this repo, except: 5 | 6 | ```bash 7 | .env 8 | docker-compose.yml 9 | server/config/default.conf 10 | ``` 11 | 12 | 13 | ## Todo -------------------------------------------------------------------------------- /docs/FRONTEND.md: -------------------------------------------------------------------------------- 1 | ## *node-rtl433-ui* 2 | # Frontend 3 | 4 | - The frontend production bundle is not part of this repository. 5 | - A github action builds the production bundle and push it to the branch: [frontend-production](https://github.com/seekwhencer/node-rtl433-ui/tree/frontend-production) 6 | - The `server/entrypoint.sh` places the content of the **frontend-production** branch into: `server/config/frontend` 7 | - For production:`SERVER_FRONTEND_PATH=config/frontend` set in `docker-compose.yml` 8 | - For development:`SERVER_FRONTEND_PATH=../frontend/dist/dev` set in `docker-compose-server.yml` 9 | 10 | ## Development 11 | - ### frontend dev build pipeline with file watcher and proxy 12 | > *on a second console* 13 | ```bash 14 | # start only the container 15 | docker-compose -f docker-compose-frontend.yml 16 | 17 | # start the file watcher build pipeline 18 | docker exec -it raspiscan_frontend /bin/sh -c "node --experimental-modules --experimental-json-modules config/WebpackConfigDev.js" 19 | ``` 20 | 21 | > Now open: http://RASPBERRYPI:4000/dev.html 22 | 23 | 24 | - ### frontend production build 25 | ```bash 26 | # start only the container - if not running 27 | docker-compose -f docker-compose-frontend.yml 28 | 29 | # bundling 30 | docker exec -it raspiscan_frontend /bin/sh -c "node --experimental-modules --experimental-json-modules config/WebpackConfigProd.js" 31 | ``` 32 | Creates a bundle in `frontend/dist/prod` 33 | 34 | > This will be triggered by a github workflow action and only the result will be pushed to the branch: [frontend-production](https://github.com/seekwhencer/node-rtl433-ui/tree/frontend-production) 35 | 36 | ## Todo 37 | - ... -------------------------------------------------------------------------------- /docs/README.md: -------------------------------------------------------------------------------- 1 | # node-rtl433-ui 2 | 3 | -------------------------------------------------------------------------------- /docs/SERVER.md: -------------------------------------------------------------------------------- 1 | ## *node-rtl433-ui* 2 | # Server 3 | 4 | ## Production 5 | - ```bash 6 | docker-compose up -d 7 | ``` 8 | > Now open: http://RASPBERRYPI:3000 9 | 10 | ## Development 11 | 12 | - ### stop production container 13 | ```bash 14 | docker-compose down 15 | ``` 16 | - ### start server container in dev mode 17 | ```bash 18 | # start server container in dev mode, not the server 19 | docker-compose -f docker-compose-server.yml up -d 20 | 21 | # start the server 22 | docker exec -it raspiscan_server /bin/sh -c "node --experimental-modules --experimental-json-modules index.js" 23 | ``` 24 | 25 | ## Todo -------------------------------------------------------------------------------- /docs/screenshots/listing.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/seekwhencer/node-rtl433-ui/964cb51dc3d332a124cac4a1b193c1df7c0399b3/docs/screenshots/listing.png -------------------------------------------------------------------------------- /docs/screenshots/raspiscan.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/seekwhencer/node-rtl433-ui/964cb51dc3d332a124cac4a1b193c1df7c0399b3/docs/screenshots/raspiscan.jpg -------------------------------------------------------------------------------- /frontend/.editorconfig: -------------------------------------------------------------------------------- 1 | # http://editorconfig.org 2 | root = true 3 | 4 | [*] 5 | charset = utf-8 6 | end_of_line = lf 7 | indent_size = 4 8 | indent_style = space 9 | insert_final_newline = true 10 | max_line_length = 80 11 | trim_trailing_whitespace = true 12 | 13 | [*.md] 14 | max_line_length = 0 15 | trim_trailing_whitespace = false 16 | -------------------------------------------------------------------------------- /frontend/.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "browser": true, 4 | "es6": true 5 | }, 6 | "extends": "eslint:recommended", 7 | "parserOptions": { 8 | "ecmaVersion": 9, 9 | "sourceType": "module" 10 | }, 11 | "rules": { 12 | "no-redeclare": "off", 13 | "no-undef": "off", 14 | "no-unused-vars": "off", 15 | "no-fallthrough" : "off", 16 | "no-constant-condition" : "off", 17 | "no-duplicate-case" : "off", 18 | "no-useless-escape" : "off", 19 | "no-case-declarations": "off" 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /frontend/.gitignore: -------------------------------------------------------------------------------- 1 | .idea 2 | .hot 3 | node_modules 4 | npm-debug.log 5 | -------------------------------------------------------------------------------- /frontend/.stylelintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "stylelint-config-standard-scss", 3 | "rules": { 4 | "max-line-length": null, 5 | "block-no-empty": null, 6 | "color-no-invalid-hex": true, 7 | "comment-empty-line-before": [ 8 | "always", 9 | { 10 | "ignore": [ 11 | "stylelint-commands", 12 | "after-comment" 13 | ] 14 | } 15 | ], 16 | "declaration-colon-space-after": "always", 17 | "indentation": [ 18 | 4, 19 | { 20 | "except": [ 21 | "value" 22 | ] 23 | } 24 | ], 25 | "max-empty-lines": 2, 26 | "rule-empty-line-before": [ 27 | "always", 28 | { 29 | "except": [ 30 | "first-nested" 31 | ], 32 | "ignore": [ 33 | "after-comment" 34 | ] 35 | } 36 | ], 37 | "unit-allowed-list": [ 38 | "px", 39 | "em", 40 | "rem", 41 | "%", 42 | "s", 43 | "deg", 44 | "fr", 45 | "vw", 46 | "vh" 47 | ], 48 | "at-rule-allowed-list": [ 49 | "media", 50 | "mixin", 51 | "content", 52 | "font-face", 53 | "include", 54 | "keyframes", 55 | "import" 56 | ], 57 | "scss/at-import-no-partial-leading-underscore": null, 58 | "scss/at-import-partial-extension": "always", 59 | "selector-pseudo-element-colon-notation": "single", 60 | "no-descending-specificity": null, 61 | "color-function-notation": null, 62 | "alpha-value-notation": "number", 63 | "selector-class-pattern": null, 64 | "keyframes-name-pattern": null, 65 | "declaration-block-no-redundant-longhand-properties": null, 66 | "font-family-no-missing-generic-family-keyword": null, 67 | "font-family-name-quotes": null, 68 | "scss/no-global-function-names": null, 69 | "selector-attribute-quotes": "never", 70 | "scss/dollar-variable-empty-line-before": "never" 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /frontend/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:16-alpine 2 | 3 | VOLUME ["/raspiscan/frontend/node_modules"] 4 | 5 | RUN npm config set legacy-peer-deps true 6 | 7 | WORKDIR /raspiscan/frontend 8 | COPY frontend/package.json . 9 | RUN npm install 10 | COPY frontend . 11 | 12 | WORKDIR /raspiscan 13 | COPY .env.example .env 14 | -------------------------------------------------------------------------------- /frontend/config/BrowserSync.js: -------------------------------------------------------------------------------- 1 | import path from "path"; 2 | import browserSync from "browser-sync"; 3 | import {createProxyMiddleware} from "http-proxy-middleware"; 4 | import {responseInterceptor} from "http-proxy-middleware"; 5 | 6 | export default class { 7 | constructor(parent) { 8 | this.parent = parent; 9 | this.proxyMiddleware = createProxyMiddleware; 10 | this.responseInterceptor = responseInterceptor; 11 | 12 | this.port = parseInt(SERVER_PORT) || 4000; 13 | this.proxyTargetHost = PROXY_TARGET_HOST || 'localhost'; 14 | this.proxyTargetPort = PROXY_TARGET_PORT || 3000; 15 | this.proxyTarget = `http://${this.proxyTargetHost}${this.proxyTargetPort === 80 ? null : `:${this.proxyTargetPort}`}`; 16 | this.bundePath = path.resolve(`${process.cwd()}/dist/dev`); 17 | 18 | console.log(''); 19 | console.log('>>>', this.proxyTarget); 20 | console.log('>>> BUNDLE PATH:', this.bundePath); 21 | console.log(''); 22 | 23 | this.engine = browserSync.create(); 24 | 25 | this.proxy = this.proxyMiddleware({ 26 | target: this.proxyTarget, 27 | changeOrigin: true, 28 | secure: false, 29 | rejectUnauthorized: false 30 | }); 31 | 32 | this.engine.init({ 33 | watch: true, 34 | cwd: `${this.bundePath}/css/`, 35 | injectChanges: true, 36 | files: ["*.css"], 37 | port: this.port, 38 | open: false, 39 | reloadDebounce: 50, 40 | server: { 41 | middleware: [this.proxy] 42 | } 43 | }); 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /frontend/config/WebpackConfigClass.js: -------------------------------------------------------------------------------- 1 | import path from 'path'; 2 | import WebpackRun from './WebpackRun.js'; 3 | import WebpackConfigCommon from './WebpackConfigCommon.js'; 4 | import { merge } from 'webpack-merge'; 5 | 6 | export default class WebpackConfigClass { 7 | constructor() { 8 | this.getEnv(); 9 | this.appPath = `${path.resolve(process.env.PWD)}`; 10 | this.common = new WebpackConfigCommon(this); 11 | this.runner = new WebpackRun(this); 12 | } 13 | 14 | merge() { 15 | this.config = merge(this.common.config, this.config); 16 | } 17 | 18 | run(){ 19 | this.runner.run(); 20 | } 21 | 22 | getEnv() { 23 | Object.keys(process.env).forEach(i => { 24 | console.log('>>> ENV:', i, process.env[i]) 25 | global[i.toUpperCase()] = process.env[i]; 26 | }); 27 | } 28 | 29 | } 30 | -------------------------------------------------------------------------------- /frontend/config/WebpackConfigCommon.js: -------------------------------------------------------------------------------- 1 | import path from 'path'; 2 | import CopyWebpackPlugin from 'copy-webpack-plugin'; 3 | 4 | export default class WebpackConfigCommon { 5 | constructor(parent) { 6 | this.parent = parent; 7 | this.build(); 8 | } 9 | 10 | build() { 11 | this.config = { 12 | 13 | plugins: [ 14 | new CopyWebpackPlugin({ 15 | patterns: [ 16 | { 17 | from: path.resolve(this.parent.appPath, './public'), 18 | to: '.' 19 | } 20 | ] 21 | }) 22 | ], 23 | 24 | resolve: { 25 | alias: { 26 | '~': path.resolve(this.parent.appPath, './src'), 27 | }, 28 | }, 29 | 30 | module: { 31 | rules: [ 32 | { 33 | test: /\.(ico|jpg|jpeg|png|gif|eot|otf|webp|svg)(\?.*)?$/, 34 | use: { 35 | loader: 'file-loader', 36 | options: { 37 | name: '[path][name].[ext]', 38 | }, 39 | }, 40 | }, 41 | { 42 | test: /\.(woff(2)?|ttf|eot|svg)(\?v=\d+\.\d+\.\d+)?$/, 43 | use: [ 44 | { 45 | loader: 'file-loader', 46 | options: { 47 | name: '[name].[ext]', 48 | outputPath: './prod/css/fonts/' 49 | } 50 | } 51 | ] 52 | } 53 | ], 54 | }, 55 | }; 56 | } 57 | } 58 | 59 | -------------------------------------------------------------------------------- /frontend/config/WebpackConfigDev.js: -------------------------------------------------------------------------------- 1 | import path from 'path'; 2 | import WebpackConfigClass from './WebpackConfigClass.js'; 3 | import StyleLintPlugin from "stylelint-webpack-plugin"; 4 | import ESLintPlugin from "eslint-webpack-plugin"; 5 | 6 | 7 | class WebpackDev extends WebpackConfigClass { 8 | constructor(options) { 9 | super(); 10 | 11 | this.options = options | {silent: false} 12 | this.proxyTargetHost = PROXY_TARGET_HOST || 'localhost'; 13 | this.proxyTargetPort = PROXY_TARGET_PORT || '3000'; 14 | 15 | this.build(); 16 | this.merge(); 17 | 18 | !this.options.silent ? this.run() : false; 19 | } 20 | 21 | build() { 22 | this.config = { 23 | entry: { 24 | app: ['./src/app.js', './src/scss/app.scss'] 25 | }, 26 | 27 | target: 'web', 28 | mode: 'development', 29 | 30 | devtool: 'eval-source-map', 31 | 32 | output: { 33 | path: `${this.appPath}/dist/dev`, 34 | filename: './js/[name].js', 35 | hotUpdateChunkFilename: `../.hot/hot-update.js`, 36 | hotUpdateMainFilename: `../.hot/hot-update.json` 37 | }, 38 | 39 | optimization: { 40 | removeAvailableModules: false, 41 | removeEmptyChunks: false, 42 | splitChunks: false, 43 | }, 44 | 45 | plugins: [ 46 | // js 47 | new ESLintPlugin({ 48 | extensions: 'js', 49 | emitWarning: true, 50 | files: path.resolve(this.appPath, './src'), 51 | }), 52 | 53 | // scss 54 | new StyleLintPlugin({ 55 | configFile: path.resolve(this.appPath, './.stylelintrc'), 56 | files: path.join('src', '**/*.s?(a|c)ss'), 57 | }), 58 | 59 | ], 60 | module: { 61 | rules: [ 62 | { 63 | test: /\.html?$/, 64 | loader: "template-literals-loader" 65 | }, 66 | { 67 | test: /\.scss$/, use: ['style-loader', { 68 | loader: 'file-loader', options: { 69 | name: '[name].css', 70 | outputPath: '../../dist/dev/css/' 71 | } 72 | }, { 73 | loader: 'sass-loader', options: { 74 | sourceMap: true, 75 | }, 76 | }], 77 | }], 78 | }, 79 | 80 | watch: true, 81 | 82 | watchOptions: { 83 | aggregateTimeout: 300, 84 | poll: 300, 85 | ignored: ['**/node_modules'], 86 | } 87 | }; 88 | } 89 | } 90 | 91 | // run it 92 | new WebpackDev(); 93 | 94 | -------------------------------------------------------------------------------- /frontend/config/WebpackConfigProd.js: -------------------------------------------------------------------------------- 1 | import path from 'path'; 2 | import WebpackConfigClass from './WebpackConfigClass.js'; 3 | import StyleLintPlugin from "stylelint-webpack-plugin"; 4 | import ESLintPlugin from "eslint-webpack-plugin"; 5 | import TerserPlugin from 'terser-webpack-plugin'; 6 | import CopyWebpackPlugin from "copy-webpack-plugin"; 7 | import {CleanWebpackPlugin} from "clean-webpack-plugin"; 8 | 9 | class WebpackProd extends WebpackConfigClass { 10 | constructor(options) { 11 | super(); 12 | 13 | this.options = options | {silent: false} 14 | 15 | this.build(); 16 | this.merge(); 17 | !this.options.silent ? this.run() : false; 18 | } 19 | 20 | build() { 21 | this.config = { 22 | entry: { 23 | app: ['./src/app.js', './src/scss/app.scss'] 24 | }, 25 | 26 | target: 'web', 27 | mode: 'production', 28 | 29 | output: { 30 | path: `${this.appPath}/dist/prod`, 31 | filename: './js/[name].js' 32 | }, 33 | 34 | plugins: [ 35 | new ESLintPlugin({ 36 | extensions: 'js', 37 | emitWarning: true, 38 | files: path.resolve(this.appPath, './src'), 39 | }), 40 | 41 | // scss 42 | new StyleLintPlugin({ 43 | configFile: path.resolve(this.appPath, './.stylelintrc'), 44 | files: path.join('src', '**/*.s?(a|c)ss'), 45 | }), 46 | 47 | // 48 | new CleanWebpackPlugin(), 49 | // 50 | new CopyWebpackPlugin({ 51 | patterns: [ 52 | { 53 | from: path.resolve(this.appPath, './public'), 54 | to: '.', 55 | globOptions: { 56 | ignore: ["**/dev.*"], 57 | } 58 | } 59 | ] 60 | }) 61 | ], 62 | 63 | optimization: { 64 | minimize: true, minimizer: [new TerserPlugin({ 65 | parallel: true, 66 | terserOptions: { 67 | mangle: true, 68 | compress: true, // https://github.com/webpack-contrib/terser-webpack-plugin#terseroptions 69 | } 70 | }),], 71 | }, 72 | 73 | module: { 74 | rules: [ 75 | { 76 | test: /\.html?$/, loader: "template-literals-loader" 77 | }, 78 | { 79 | test: /\.scss$/, use: ['style-loader', { 80 | loader: 'file-loader', options: { 81 | name: '[name].css', 82 | outputPath: '../../dist/prod/css/' 83 | } 84 | }, { 85 | loader: 'sass-loader', options: { 86 | sourceMap: false, 87 | }, 88 | }], 89 | }] 90 | } 91 | }; 92 | } 93 | } 94 | 95 | // run it 96 | new WebpackProd(); 97 | 98 | -------------------------------------------------------------------------------- /frontend/config/WebpackRun.js: -------------------------------------------------------------------------------- 1 | import webpack from "webpack"; 2 | import BrowserSync from "./BrowserSync.js"; 3 | import fs from "fs-extra"; 4 | 5 | /** 6 | * i found no way to embed the js bundle 7 | * i tried it as base64, as base64 and uri encoded / decoded 8 | * no way. so it results a base64 css bundle with base64 embedded fonts 9 | * as pack.html 10 | */ 11 | 12 | export default class WebpackRun { 13 | constructor(parent) { 14 | this.parent = parent; 15 | this.bundler = false; 16 | this.server = false; 17 | this.package = {}; 18 | this.hash = false; 19 | this.browserSync = false; 20 | 21 | this.docRoot = `${this.parent.appPath}/dist/prod`; 22 | this.cssRoot = `${this.docRoot}/css`; 23 | this.jsRoot = `${this.docRoot}/js`; 24 | 25 | this.faviconFileName = `${this.docRoot}/favicon.ico`; 26 | this.htmlFileNameFrom = `${this.docRoot}/index_template.html`; 27 | this.htmlFileNameTo = `${this.docRoot}/index.html`; 28 | this.cssFileName = `${this.cssRoot}/app.css`; 29 | this.jsFileName = `${this.jsRoot}/app.js`; 30 | } 31 | 32 | run() { 33 | this.config = this.parent.config; 34 | this.parent.config.mode === 'development' ? this.runDev() : this.runProd(); 35 | } 36 | 37 | runDev() { 38 | this.browserSync = new BrowserSync(this); 39 | 40 | this.bundler = webpack(this.config); 41 | this.watching = this.bundler.watch(this.config.watchOptions, (err, stats) => { 42 | if (err || stats.hasErrors()) { 43 | err ? console.log('>>> ERROR: ', err.message) : null; 44 | stats.hasErrors() ? console.log('>>> ERROR: ', stats.compilation.errors) : null; 45 | } else { 46 | console.log('>>> BUNDLING COMPLETE'); 47 | } 48 | }); 49 | } 50 | 51 | runProd() { 52 | console.log(''); 53 | this.bundler = webpack(this.config, async (err, stats) => { 54 | if (err || stats.hasErrors()) { 55 | console.log('>>> ERROR: ', err.message); 56 | } else { 57 | this.hash = stats.compilation.hash; 58 | console.log('>>> BUNDLING COMPLETE', this.hash); 59 | 60 | //await this.rewriteHTML(/hash/g, parseInt(new Date().getTime() / 1000)); 61 | 62 | // do the base64 packaging 63 | await this.pack(); 64 | console.log('>>> PACK COMPLETE', this.htmlFileNameTo); 65 | 66 | await this.writeHash(); 67 | await this.clean(); 68 | } 69 | }); 70 | } 71 | 72 | // (unused) old style for linked prod index.html 73 | async rewriteHTML(from, to) { 74 | console.log('>>> REWRITE HTML'); 75 | const indexHTMLFile = `${this.parent.appPath}/dist/prod/index.html`; 76 | return fs.readFile(indexHTMLFile, (err, data) => { 77 | const reData = data.toString().replace(from, to); 78 | fs.writeFile(indexHTMLFile, reData); 79 | }); 80 | } 81 | 82 | // read needed data and create the final production index.html 83 | async pack() { 84 | console.log('>>> PACK HTML (base64 encoded fonts in css file, base64 encoded css file as inline data source)'); 85 | this.cssFile = await fs.readFile(this.cssFileName); 86 | this.jsFile = await fs.readFile(this.jsFileName); 87 | this.htmlFile = await fs.readFile(this.htmlFileNameFrom); 88 | this.faviconFile = await fs.readFile(this.faviconFileName); 89 | 90 | await this.packFonts(); 91 | await this.packHTML(); 92 | } 93 | 94 | // replace font urls in the css file with base64 encoded font data 95 | packFonts() { 96 | this.package.fonts = []; 97 | const promises = []; 98 | 99 | const find = new RegExp(/\burl\([^)]+.(woff|woff2)"\)/gi); 100 | 101 | const matches = `${this.cssFile}`.match(find); 102 | matches.forEach(m => { 103 | const cssPath = `${m.replace(`url("./`, '').replace(`")`, '')}`; 104 | const filePath = `${this.cssRoot}/${cssPath}`; 105 | const fileRead = fs.readFile(filePath).then(data => { 106 | const fullData = { 107 | cssPath: cssPath, 108 | cssString: `url("./${cssPath}")`, 109 | filePath: filePath, 110 | data: data.toString('base64') 111 | } 112 | return Promise.resolve(fullData); 113 | }); 114 | promises.push(fileRead); 115 | }); 116 | 117 | return Promise.all(promises) 118 | .then(files => files.forEach(file => this.package.fonts.push(file))) 119 | .then(() => { 120 | this.package.fonts.forEach(font => this.cssFile = `${this.cssFile}`.replace(font.cssString, `url("data:text/css;base64,${font.data}")`)); 121 | return fs.writeFile(this.cssFileName, this.cssFile); 122 | }); 123 | } 124 | 125 | // replace all the things in the html file 126 | packHTML() { 127 | const cssFile = `${Buffer.from(this.cssFile).toString('base64')}`; 128 | const faviconFile = `${this.faviconFile.toString('base64')}`; // not from buffer, because the read results a buffer 129 | 130 | this.htmlFile = `${this.htmlFile}` 131 | .replace('', ``) 132 | .replace('', ``) 133 | .replace('', ``) 134 | 135 | //.replace('', ``) 136 | //.replace('', ``) 137 | ; 138 | 139 | return fs.writeFile(this.htmlFileNameTo, this.htmlFile); 140 | } 141 | 142 | // write a version file with the compilation hash from webpack 143 | writeHash(hash) { 144 | return fs.writeFile(`${this.docRoot}/version`, this.hash || hash); 145 | } 146 | 147 | // delete unused files from prod folder 148 | async clean() { 149 | const proms = []; 150 | 151 | // move the app.js from js folder to doc root 152 | await fs.move(`${this.docRoot}/js/app.js`, `${this.docRoot}/app.js`); 153 | 154 | const files = ['dev.html', 'index_template.html', 'favicon.ico', 'css', 'js']; 155 | files.forEach(file => proms.push(fs.remove(`${this.docRoot}/${file}`))) 156 | return Promise.all(proms); 157 | } 158 | } 159 | -------------------------------------------------------------------------------- /frontend/dist/prod/version: -------------------------------------------------------------------------------- 1 | 444213de7856819f27b9 -------------------------------------------------------------------------------- /frontend/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "raspiscan_frontend", 3 | "version": "0.0.1", 4 | "description": "simple ui to map devices on mqtt chanels", 5 | "main": "index.js", 6 | "type": "module", 7 | "scripts": { 8 | "dev": "node --experimental-modules --experimental-json-modules config/WebpackConfigDev.js", 9 | "build": "node --experimental-modules --experimental-json-modules config/WebpackConfigProd.js" 10 | }, 11 | "author": "", 12 | "license": "ISC", 13 | "dependencies": { 14 | "@glidejs/glide": "^3.6.0", 15 | "ajv": "^8.12.0", 16 | "browser-sync": "^2.27.11", 17 | "clean-webpack-plugin": "^4.0.0", 18 | "copy-webpack-plugin": "^11.0.0", 19 | "css-loader": "^6.7.3", 20 | "es6-templates": "^0.2.3", 21 | "eslint": "^8.33.0", 22 | "eslint-loader": "^3.0.3", 23 | "eslint-webpack-plugin": "^4.0.0", 24 | "extract-loader": "^5.1.0", 25 | "file-loader": "^6.2.0", 26 | "fs-extra": "^11.1.0", 27 | "html-webpack-plugin": "^5.5.0", 28 | "http-proxy-middleware": "^2.0.6", 29 | "i18n-js": "^4.2.3", 30 | "node-sass": "^8.0.0", 31 | "postcss-loader": "^7.0.2", 32 | "postcss-preset-env": "^8.0.1", 33 | "sass-loader": "^13.2.0", 34 | "style-loader": "^3.3.1", 35 | "stylelint": "^14.16.1", 36 | "stylelint-config-standard-scss": "^6.1.0", 37 | "stylelint-webpack-plugin": "^4.0.0", 38 | "swiper": "^9.0.5", 39 | "template-literals-loader": "^1.1.2", 40 | "terser-webpack-plugin": "^5.3.6", 41 | "webpack": "^5.75.0", 42 | "webpack-bundle-analyzer": "^4.7.0", 43 | "webpack-cli": "^5.0.1", 44 | "webpack-dev-server": "^4.11.1", 45 | "webpack-merge": "^5.8.0", 46 | "webpack-stats-plugin": "^1.1.1" 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /frontend/public/css/fonts/Barlow/barlow-v12-latin-100.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/seekwhencer/node-rtl433-ui/964cb51dc3d332a124cac4a1b193c1df7c0399b3/frontend/public/css/fonts/Barlow/barlow-v12-latin-100.woff2 -------------------------------------------------------------------------------- /frontend/public/css/fonts/Barlow/barlow-v12-latin-200.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/seekwhencer/node-rtl433-ui/964cb51dc3d332a124cac4a1b193c1df7c0399b3/frontend/public/css/fonts/Barlow/barlow-v12-latin-200.woff2 -------------------------------------------------------------------------------- /frontend/public/css/fonts/Barlow/barlow-v12-latin-300.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/seekwhencer/node-rtl433-ui/964cb51dc3d332a124cac4a1b193c1df7c0399b3/frontend/public/css/fonts/Barlow/barlow-v12-latin-300.woff2 -------------------------------------------------------------------------------- /frontend/public/css/fonts/Barlow/barlow-v12-latin-500.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/seekwhencer/node-rtl433-ui/964cb51dc3d332a124cac4a1b193c1df7c0399b3/frontend/public/css/fonts/Barlow/barlow-v12-latin-500.woff2 -------------------------------------------------------------------------------- /frontend/public/css/fonts/Barlow/barlow-v12-latin-600.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/seekwhencer/node-rtl433-ui/964cb51dc3d332a124cac4a1b193c1df7c0399b3/frontend/public/css/fonts/Barlow/barlow-v12-latin-600.woff2 -------------------------------------------------------------------------------- /frontend/public/css/fonts/Barlow/barlow-v12-latin-700.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/seekwhencer/node-rtl433-ui/964cb51dc3d332a124cac4a1b193c1df7c0399b3/frontend/public/css/fonts/Barlow/barlow-v12-latin-700.woff2 -------------------------------------------------------------------------------- /frontend/public/css/fonts/Barlow/barlow-v12-latin-800.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/seekwhencer/node-rtl433-ui/964cb51dc3d332a124cac4a1b193c1df7c0399b3/frontend/public/css/fonts/Barlow/barlow-v12-latin-800.woff2 -------------------------------------------------------------------------------- /frontend/public/css/fonts/Barlow/barlow-v12-latin-900.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/seekwhencer/node-rtl433-ui/964cb51dc3d332a124cac4a1b193c1df7c0399b3/frontend/public/css/fonts/Barlow/barlow-v12-latin-900.woff2 -------------------------------------------------------------------------------- /frontend/public/css/fonts/Barlow/barlow-v12-latin-regular.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/seekwhencer/node-rtl433-ui/964cb51dc3d332a124cac4a1b193c1df7c0399b3/frontend/public/css/fonts/Barlow/barlow-v12-latin-regular.woff2 -------------------------------------------------------------------------------- /frontend/public/dev.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | raspiscan 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 25 | 26 | 27 | -------------------------------------------------------------------------------- /frontend/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/seekwhencer/node-rtl433-ui/964cb51dc3d332a124cac4a1b193c1df7c0399b3/frontend/public/favicon.ico -------------------------------------------------------------------------------- /frontend/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | raspiscan 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 24 | 25 | 26 | -------------------------------------------------------------------------------- /frontend/public/index_template.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | raspiscan 6 | 7 | 8 | 9 | 10 | 11 | 12 | 24 | 25 | 26 | -------------------------------------------------------------------------------- /frontend/src/app.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Author: Matthias Kallenbach, Berlin 2023 3 | * 4 | */ 5 | import Main from './lib/Main.js'; 6 | window.APP = Main; 7 | -------------------------------------------------------------------------------- /frontend/src/lib/Global/Events.js: -------------------------------------------------------------------------------- 1 | import {EventEmitter} from 'events'; 2 | 3 | export default class Events { 4 | constructor(parent, options) { 5 | this.event = new EventEmitter(); 6 | } 7 | 8 | on() { 9 | this.event.on.apply(this.event, Array.from(arguments)); 10 | } 11 | 12 | emit() { 13 | this.event.emit.apply(this.event, Array.from(arguments)); 14 | } 15 | 16 | removeListener() { 17 | this.event.removeListener.apply(this.event, Array.from(arguments)); 18 | } 19 | 20 | removeAllListeners() { 21 | this.event.removeAllListeners.apply(this.event, Array.from(arguments)); 22 | } 23 | } 24 | 25 | 26 | -------------------------------------------------------------------------------- /frontend/src/lib/Global/Globals.js: -------------------------------------------------------------------------------- 1 | import './Log.js'; 2 | import './Utils.js'; 3 | import ModuleClass from './Module.js'; 4 | window.MODULECLASS = ModuleClass; 5 | 6 | -------------------------------------------------------------------------------- /frontend/src/lib/Global/Log.js: -------------------------------------------------------------------------------- 1 | export default class Log { 2 | constructor() { 3 | window.LOG_PARENT = console.log; 4 | console.log = this.log; 5 | } 6 | 7 | log() { 8 | if (!window.OPTIONS) 9 | return; 10 | 11 | if (!window.OPTIONS.debug) 12 | return; 13 | 14 | window.LOG_PARENT.apply(this, arguments); 15 | } 16 | } 17 | 18 | window.LOG = new Log().log; 19 | -------------------------------------------------------------------------------- /frontend/src/lib/Global/Module.js: -------------------------------------------------------------------------------- 1 | import {EventEmitter} from 'events'; 2 | 3 | export default class Module { 4 | 5 | constructor(parent, options) { 6 | this.name = 'module'; 7 | this.ready = false; 8 | this.options = options; 9 | this.defaults = {}; 10 | this.event = new EventEmitter(); 11 | 12 | parent ? this.parent = parent : null; 13 | this.parent ? this.parent.app ? this.app = this.parent.app : null : null; 14 | } 15 | 16 | on() { 17 | this.event.on.apply(this.event, Array.from(arguments)); 18 | } 19 | 20 | emit() { 21 | this.event.emit.apply(this.event, Array.from(arguments)); 22 | } 23 | 24 | removeListener() { 25 | this.event.removeListener.apply(this.event, Array.from(arguments)); 26 | } 27 | 28 | removeAllListeners() { 29 | this.event.removeAllListeners.apply(this.event, Array.from(arguments)); 30 | } 31 | 32 | get(match, not) { 33 | return this.items.filter(item => { 34 | if (item.id === match) { 35 | return not !== item.id; 36 | 37 | } 38 | })[0]; 39 | } 40 | 41 | getF(field, match, not) { 42 | return this.items.filter(item => { 43 | if (item[field] === match) { 44 | return not !== item[field]; 45 | 46 | } 47 | })[0]; 48 | } 49 | 50 | toDOM(string) { 51 | // @TODO - not the first -> all !!!. 52 | const body = new DOMParser().parseFromString(string, "text/html").documentElement.querySelector('body'); 53 | 54 | if (body.children.length > 1) { 55 | return body.children; // is a NodeList 56 | } 57 | return body.firstChild; // is a node 58 | } 59 | 60 | /** 61 | * data could be a child or a HTMLCollection 62 | * @param data 63 | */ 64 | append(data, target) { 65 | if (data.length > 1) { 66 | data.forEach(i => target.append(i)); 67 | } else { 68 | target.append(data); 69 | } 70 | 71 | return target; 72 | 73 | } 74 | 75 | fetch(url, requestOptions) { 76 | !requestOptions ? requestOptions = { 77 | method: 'GET' 78 | } : null; 79 | 80 | return fetch(url, requestOptions) 81 | .then(response => { 82 | if (!response.ok) 83 | return Promise.reject(response.statusText); 84 | 85 | return response.json(); 86 | }); 87 | } 88 | 89 | fetchAudio(url, requestOptions) { 90 | !requestOptions ? requestOptions = { 91 | method: 'GET' 92 | } : null; 93 | 94 | return fetch(url, requestOptions) 95 | .then(response => { 96 | if (!response.ok) 97 | return Promise.reject(response.statusText); 98 | 99 | return response.blob(); 100 | }); 101 | } 102 | 103 | } 104 | -------------------------------------------------------------------------------- /frontend/src/lib/Global/Utils.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Wants a valid html string 3 | * Returns a valid DOM Element Tree 4 | * 5 | * @param string 6 | * @returns {ChildNode} 7 | */ 8 | window.toDOM = string => new DOMParser().parseFromString(string, "text/html").documentElement.querySelector('body').firstChild; 9 | 10 | /** 11 | * Wait for n milliseconds 12 | * Returns a promise 13 | * 14 | * @param ms 15 | * @returns {Promise} 16 | */ 17 | window.wait = ms => { 18 | return new Promise(resolve => { 19 | setTimeout(() => { 20 | resolve(); 21 | }, ms); 22 | }); 23 | }; 24 | 25 | window.randomInt = (min, max) => { 26 | min = Math.ceil(min); 27 | max = Math.floor(max); 28 | return Math.floor(Math.random() * (max - min)) + min; 29 | }; 30 | 31 | window.ksortObjArray = (array, key) => { 32 | const compare = (a, b) => { 33 | let comparison = 0; 34 | if (a[key] > b[key]) { 35 | comparison = 1; 36 | } else if (a[key] < b[key]) { 37 | comparison = -1; 38 | } 39 | return comparison; 40 | }; 41 | return array.sort(compare); 42 | }; 43 | -------------------------------------------------------------------------------- /frontend/src/lib/Home/Templates/Device.html: -------------------------------------------------------------------------------- 1 |
2 |
3 |
4 | 5 | 6 | 7 | 8 | 9 | 12 | 15 | 27 | 28 |
ID ${id}PROTOCOL ${protocol}CHANNEL ${channel}MODEL 10 | ${model} 11 | HASH 13 | ${hash} 14 | 16 |
17 | 21 | 25 |
26 |
29 |
30 |
31 |
32 | 33 | 34 | ${fields.map(f => ` 35 | 41 | `).join('')} 42 | 43 |
36 | ${f}
38 | ${data[f]} 40 |
44 |
45 | 46 |
47 |
48 | -------------------------------------------------------------------------------- /frontend/src/lib/Home/Templates/DeviceTopics.html: -------------------------------------------------------------------------------- 1 | ${topics.length > 0 ? 2 | `${topics.map(t => ` 3 | 4 | 5 | 8 | 9 | 10 | `).join('')}` : ``} 11 |
${t.data.field} 6 | ${data[t.data.field]} 7 | ${t.data.topic}
12 | -------------------------------------------------------------------------------- /frontend/src/lib/Home/Templates/Exclude.html: -------------------------------------------------------------------------------- 1 |
2 |
3 |
${field.toUpperCase()}
4 |
${value}
5 | 6 |
7 |
8 | -------------------------------------------------------------------------------- /frontend/src/lib/Home/Templates/Topic.html: -------------------------------------------------------------------------------- 1 |
2 |
3 |
${topic}
4 |
${device ? device.hash : ``}
5 |
6 | ${fields.length > 0 ? 7 | `${fields.map(field => ` 8 | 9 | 10 | 13 | 14 | `).join('')}` : ``} 15 |
${field}${data[field]} 12 |
16 |
17 | 18 |
19 |
20 | -------------------------------------------------------------------------------- /frontend/src/lib/Home/Templates/TopicAdd.html: -------------------------------------------------------------------------------- 1 |
2 |
3 |
${model} 4 |
5 |
${hash} 6 |
7 |
${field} 9 |
10 | 11 | 12 |
13 | -------------------------------------------------------------------------------- /frontend/src/lib/Home/Templates/layout.html: -------------------------------------------------------------------------------- 1 |
2 |
3 |
4 |
5 |
6 |
7 | -------------------------------------------------------------------------------- /frontend/src/lib/Home/devices.js: -------------------------------------------------------------------------------- 1 | import Device from './device.js'; 2 | 3 | export default class Devices extends MODULECLASS { 4 | constructor(parent, options) { 5 | super(parent, options); 6 | this.label = 'DEVICES'; 7 | 8 | this.target = this.parent.target.querySelector('[data-devices]'); 9 | 10 | this.dataSource = {}; 11 | this.data = new Proxy(this.dataSource, { 12 | get: (target, prop, receiver) => { 13 | return target[prop] || this.dataSource[prop]; 14 | }, 15 | set: (target, prop, device) => { 16 | // add or update 17 | if (!target[prop]) { 18 | target[prop] = device; 19 | target[prop].draw(); 20 | } else { 21 | //Object.keys(device.data).forEach(d => this.data[prop].data[d] = device.data[d]); 22 | this.data[prop].update(device.data); 23 | } 24 | return true; 25 | 26 | } 27 | }); 28 | 29 | } 30 | 31 | startInterval() { 32 | if (this.interval) 33 | clearInterval(this.interval); 34 | 35 | this.interval = setInterval(() => this.getAll(), 1000); 36 | } 37 | 38 | getAll() { 39 | if (!this.parent.parent.navigation.refresh) { 40 | return Promise.resolve(false); 41 | } 42 | 43 | return this.fetch(`${this.app.urlBase}/devices`).then(raw => { 44 | this.raw = raw.data; 45 | this.raw.forEach(deviceData => this.addDevice(deviceData)); 46 | 47 | //@TODO check if some devices where dropped 48 | this.checkRemoved(); 49 | 50 | this.order(this.parent.parent.navigation.orderBy || 'time'); 51 | this.emit('complete'); 52 | return Promise.resolve(true); 53 | }); 54 | } 55 | 56 | addDevice(deviceData) { 57 | const device = new Device(this, deviceData); 58 | this.data[device.data.hash] = device; 59 | } 60 | 61 | order(by) { 62 | const orderValues = Object.keys(this.data).map(hash => `A${this.data[hash].data[by].toString().padStart('100','0')}__${this.data[hash].data.hash}`); 63 | 64 | //LOG(this.label, 'ORDER', by, 'VALUES', orderValues); 65 | orderValues.sort(); 66 | orderValues.reverse(); 67 | 68 | let i = 0; 69 | orderValues.forEach(value => { 70 | const hash = value.split('__')[1]; 71 | const device = this.data[hash]; 72 | device.target.style.order = i; 73 | i++; 74 | }); 75 | } 76 | 77 | keys() { 78 | return Object.keys(this.data); 79 | } 80 | 81 | checkRemoved() { 82 | this.keys().forEach(hash => this.data[hash].checkRemoved()); 83 | } 84 | 85 | removeDevice(device) { 86 | delete this.data[device.data.hash]; 87 | } 88 | 89 | get models() { 90 | return this.data.map(device => device.data.model); 91 | } 92 | 93 | set models(val) { 94 | // nothing 95 | } 96 | 97 | } 98 | -------------------------------------------------------------------------------- /frontend/src/lib/Home/excludes.js: -------------------------------------------------------------------------------- 1 | import ExcludeTemplate from './Templates/Exclude.html'; 2 | 3 | export default class Excludes extends MODULECLASS { 4 | constructor(parent, options) { 5 | super(parent, options); 6 | this.label = 'EXCLUDES'; 7 | this.parent = parent; 8 | 9 | this.target = this.parent.target.querySelector('[data-excludes]'); 10 | this.target.innerHTML = ''; 11 | 12 | this.data = []; 13 | this.getAll(); 14 | } 15 | 16 | getAll() { 17 | return this.fetch(`${this.app.urlBase}/excludes`).then(raw => { 18 | this.data = raw.data; 19 | this.draw(); 20 | this.emit('complete'); 21 | return Promise.resolve(true); 22 | }); 23 | } 24 | 25 | draw() { 26 | this.target.innerHTML = ''; 27 | this.data.forEach(exclude => { 28 | const data = {}; 29 | exclude.model ? data.field = 'model' : null; 30 | exclude.hash ? data.field = 'hash' : null; 31 | data.value = exclude.model || exclude.hash; 32 | 33 | const element = toDOM(ExcludeTemplate({ 34 | scope: data 35 | } 36 | )); 37 | element.querySelector('[data-remove-button]').onclick = () => this.removeExclude(data); 38 | this.target.append(element); 39 | }); 40 | } 41 | 42 | removeExclude(data) { 43 | LOG(this.label, 'REMOVE', data); 44 | 45 | return this.fetch(`${this.app.urlBase}/exclude/remove`, { 46 | method: 'POST', 47 | headers: { 48 | 'Content-Type': 'application/json' 49 | }, 50 | body: JSON.stringify(data) 51 | }).then(response => { 52 | LOG(this.label, 'UPDATED:', response.data, ''); 53 | 54 | if (response.data === true) { 55 | this.getAll(); 56 | } 57 | 58 | return Promise.resolve(true); 59 | }); 60 | } 61 | 62 | show() { 63 | this.target.classList.add('active'); 64 | } 65 | 66 | hide() { 67 | this.target.classList.remove('active'); 68 | } 69 | 70 | } 71 | -------------------------------------------------------------------------------- /frontend/src/lib/Home/index.js: -------------------------------------------------------------------------------- 1 | import Tab from '../Tab.js'; 2 | import LayoutTemplate from "./Templates/layout.html"; 3 | import Devices from './devices.js'; 4 | import Topics from './topics.js'; 5 | import Excludes from './excludes.js'; 6 | 7 | export default class Home extends Tab { 8 | constructor(parent, options) { 9 | super(parent, options); 10 | this.label = 'HOME' 11 | this.tab = 'home'; 12 | 13 | this.target = this.toDOM(LayoutTemplate({ 14 | scope: { 15 | icons: this.app.icons 16 | } 17 | })); 18 | this.parent.target.append(this.target); 19 | 20 | this.topics = new Topics(this); 21 | this.devices = new Devices(this); 22 | this.excludes = new Excludes(this); 23 | 24 | this.topics.on('complete', () => this.devices.startInterval()); 25 | this.devices.on('complete', () => this.topics.update()); 26 | } 27 | 28 | show() { 29 | super.show(); 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /frontend/src/lib/Home/topic.js: -------------------------------------------------------------------------------- 1 | import TopicTemplate from './Templates/Topic.html'; 2 | 3 | export default class Topic extends MODULECLASS { 4 | constructor(parent, options) { 5 | super(parent, options); 6 | this.topics = parent; 7 | this.label = 'TOPIC'; 8 | 9 | this.dataSource = options; 10 | this.data = new Proxy(this.dataSource, { 11 | get: (target, prop, receiver) => { 12 | return target[prop] || this.dataSource[prop]; 13 | }, 14 | set: (target, prop, value) => { 15 | if (target[prop] === value) 16 | return true; 17 | 18 | target[prop] = value; 19 | 20 | this.emit('updated', prop, value); 21 | this.emit(prop, value); 22 | 23 | return true; 24 | } 25 | }); 26 | } 27 | 28 | draw() { 29 | const data = {...this.data}; 30 | delete data.topic; 31 | 32 | this.target = this.toDOM(TopicTemplate({ 33 | scope: { 34 | topic: this.data.topic, 35 | device: this.device.data || false, 36 | icons: this.app.icons, 37 | data: data, 38 | fields: Object.keys(data) 39 | } 40 | })); 41 | this.parent.target.append(this.target); 42 | 43 | this.removeButton = this.target.querySelector('[data-remove-button]'); 44 | this.removeButton.onclick = () => this.remove(); 45 | } 46 | 47 | keys() { 48 | return Object.keys({...this.data}); 49 | } 50 | 51 | update() { 52 | if (!this.device) 53 | return; 54 | 55 | const target = this.target.querySelector('[data-topic-device]'); 56 | if (target.innerHTML === this.device.data.hash) 57 | return; 58 | 59 | target.innerHTML = this.device.data.hash; 60 | } 61 | 62 | remove() { 63 | LOG(this.label, 'REMOVE', this.data.topic); 64 | 65 | const postData = { 66 | topic: this.data.topic 67 | } 68 | 69 | return this.fetch(`${this.app.urlBase}/topic/remove`, { 70 | method: 'POST', 71 | headers: { 72 | 'Content-Type': 'application/json' 73 | }, 74 | body: JSON.stringify(postData) 75 | }).then(response => { 76 | LOG(this.label, 'UPDATED:', response.data, ''); 77 | 78 | if (response.data === true) { 79 | this.topics.removeTopic(this.data.topic); 80 | } 81 | 82 | return Promise.resolve(true); 83 | }); 84 | } 85 | 86 | delete() { 87 | this.target.remove(); 88 | } 89 | 90 | get devices() { 91 | return this.topics.devices; 92 | } 93 | 94 | set devices(val) { 95 | 96 | } 97 | 98 | get device() { 99 | const t = {...this.data}; // make a copy 100 | // without the topic and the value field 101 | delete t.topic; 102 | delete t.field; 103 | let matchDevice = false; 104 | this.topics.parent.devices.keys().forEach(hash => { 105 | const device = this.topics.parent.devices.data[hash]; 106 | let matchA = '', matchB = ''; 107 | 108 | Object.keys(t).forEach(key => { 109 | matchA += t[key]; 110 | matchB += device.data[key]; 111 | }); 112 | 113 | if (`${matchA}` === `${matchB}`) { 114 | matchDevice = device; 115 | } 116 | }); 117 | 118 | return matchDevice; 119 | } 120 | 121 | set device(val) { 122 | } 123 | } 124 | -------------------------------------------------------------------------------- /frontend/src/lib/Home/topics.js: -------------------------------------------------------------------------------- 1 | import Topic from "./topic.js"; 2 | import TopicAddTemplate from './Templates/TopicAdd.html'; 3 | 4 | 5 | export default class Topics extends MODULECLASS { 6 | constructor(parent, options) { 7 | super(parent, options); 8 | this.label = 'TOPICS'; 9 | this.parent = parent; 10 | 11 | this.target = this.parent.target.querySelector('[data-topics]'); 12 | this.target.innerHTML = ''; 13 | 14 | this.targetAddTopic = this.parent.target.querySelector('[data-topic-add]'); 15 | 16 | this.dataSource = {}; 17 | this.data = new Proxy(this.dataSource, { 18 | get: (target, prop, receiver) => { 19 | return target[prop] || this.dataSource[prop]; 20 | }, 21 | set: (target, prop, topic) => { 22 | // add or update 23 | if (!target[prop]) { 24 | target[prop] = topic; 25 | topic.draw(); 26 | } else { 27 | Object.keys(topic.data).forEach(d => this.data[prop].data[d] = topic.data[d]); 28 | } 29 | return true; 30 | 31 | } 32 | }); 33 | this.getAll(); 34 | } 35 | 36 | getAll() { 37 | return this.fetch(`${this.app.urlBase}/topics`).then(raw => { 38 | this.raw = raw.data; 39 | this.raw.forEach(topicsData => { 40 | const topic = new Topic(this, topicsData); 41 | this.data[topic.data.topic] = topic; 42 | }); 43 | this.emit('complete'); 44 | return Promise.resolve(true); 45 | }); 46 | } 47 | 48 | keys() { 49 | return Object.keys({...this.data}); 50 | } 51 | 52 | update() { 53 | this.keys().forEach(topic => this.data[topic].update()); 54 | } 55 | 56 | //@TODO 57 | addTopic(deviceHash, field) { 58 | LOG(this.label, 'ADD TOPIC', deviceHash, field); 59 | 60 | const topicData = { 61 | field: field, 62 | model: this.devices.data[deviceHash].data.model, 63 | hash: deviceHash 64 | }; 65 | 66 | const targetAddTopic = this.toDOM(TopicAddTemplate({ 67 | scope: topicData 68 | })); 69 | this.targetAddTopic.replaceChildren(targetAddTopic); 70 | 71 | const addTopicButton = this.targetAddTopic.querySelector('[data-add-button]'); 72 | addTopicButton.onclick = () => this.submitAddTopic(topicData); 73 | } 74 | 75 | removeTopic(topic) { 76 | this.data[topic].delete(); 77 | delete this.data[topic]; 78 | this.getAll().then(() => { 79 | this.devices.keys().forEach(hash => this.devices.data[hash].drawTopics()); 80 | }); 81 | 82 | } 83 | 84 | submitAddTopic(data) { 85 | const topic = this.targetAddTopic.querySelector('input').value; 86 | LOG(this.label, 'ADD TOPIC', topic, data); 87 | 88 | const postData = { 89 | topic: topic, 90 | hash: data.hash, 91 | field: data.field 92 | } 93 | 94 | return this.fetch(`${this.app.urlBase}/topic/add`, { 95 | method: 'POST', 96 | headers: { 97 | 'Content-Type': 'application/json' 98 | }, 99 | body: JSON.stringify(postData) 100 | }).then(response => { 101 | LOG(this.label, 'UPDATED:', response.data, ''); 102 | 103 | if (response.data === true) { 104 | this.getAll().then(() => { 105 | this.devices.keys().forEach(hash => this.devices.data[hash].drawTopics()); 106 | this.targetAddTopic.innerHTML = ''; 107 | }); 108 | } 109 | 110 | return Promise.resolve(true); 111 | }); 112 | } 113 | 114 | show() { 115 | this.target.classList.add('active'); 116 | this.targetAddTopic.classList.add('active'); 117 | } 118 | 119 | hide() { 120 | this.target.classList.remove('active'); 121 | this.targetAddTopic.classList.remove('active'); 122 | } 123 | 124 | get devices() { 125 | return this.parent.devices; 126 | } 127 | 128 | set devices(val) { 129 | } 130 | } 131 | -------------------------------------------------------------------------------- /frontend/src/lib/Icons/book.html: -------------------------------------------------------------------------------- 1 | 8 | 14 | 15 | -------------------------------------------------------------------------------- /frontend/src/lib/Icons/check.html: -------------------------------------------------------------------------------- 1 | 8 | 12 | 18 | 19 | -------------------------------------------------------------------------------- /frontend/src/lib/Icons/close.html: -------------------------------------------------------------------------------- 1 | 8 | 12 | 18 | 19 | -------------------------------------------------------------------------------- /frontend/src/lib/Icons/eye.html: -------------------------------------------------------------------------------- 1 | 8 | 14 | 20 | 21 | -------------------------------------------------------------------------------- /frontend/src/lib/Icons/eye_alt.html: -------------------------------------------------------------------------------- 1 | 8 | 12 | 18 | 19 | -------------------------------------------------------------------------------- /frontend/src/lib/Icons/heart.html: -------------------------------------------------------------------------------- 1 | 8 | 14 | 15 | -------------------------------------------------------------------------------- /frontend/src/lib/Icons/home.html: -------------------------------------------------------------------------------- 1 | 8 | 14 | 15 | -------------------------------------------------------------------------------- /frontend/src/lib/Icons/index.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Icons from : https://github.com/astrit/css.gg 3 | * 4 | * using here as html template to inline the svg data 5 | */ 6 | 7 | import IconMusic from "./music.html"; 8 | import IconBook from "./book.html"; 9 | import IconPodcast from "./podcast.html"; 10 | import IconEye from "./eye.html"; 11 | import IconPen from "./pen.html"; 12 | import IconHeart from "./heart.html"; 13 | import IconMouth from "./mouth.html"; 14 | import IconOptions from "./options.html"; 15 | import IconUser from "./user.html"; 16 | import IconHome from "./home.html"; 17 | 18 | import IconPlay from "./play.html"; 19 | import IconPause from "./pause.html"; 20 | import IconStop from "./stop.html"; 21 | import IconSkipPrev from "./skip-prev.html"; 22 | import IconSkipNext from "./skip-next.html"; 23 | import IconCheck from "./check.html"; 24 | import IconClose from "./close.html"; 25 | import IconPlus from "./plus.html"; 26 | 27 | const music = scope => { 28 | typeof scope === 'undefined' ? scope = {} : null; 29 | return IconMusic({ 30 | scope: { 31 | ...scope, 32 | height: scope.height || 24, 33 | width: scope.width || 24 34 | } 35 | }); 36 | } 37 | 38 | const book = scope => { 39 | typeof scope === 'undefined' ? scope = {} : null; 40 | return IconBook({ 41 | scope: { 42 | ...scope, 43 | height: scope.height || 24, 44 | width: scope.width || 24 45 | } 46 | }); 47 | } 48 | 49 | const podcast = scope => { 50 | typeof scope === 'undefined' ? scope = {} : null; 51 | return IconPodcast({ 52 | scope: { 53 | ...scope, 54 | height: scope.height || 24, 55 | width: scope.width || 24 56 | } 57 | }); 58 | } 59 | const eye = scope => { 60 | typeof scope === 'undefined' ? scope = {} : null; 61 | return IconEye({ 62 | scope: { 63 | ...scope, 64 | height: scope.height || 24, 65 | width: scope.width || 24 66 | } 67 | }); 68 | } 69 | 70 | const pen = scope => { 71 | typeof scope === 'undefined' ? scope = {} : null; 72 | return IconPen({ 73 | scope: { 74 | ...scope, 75 | height: scope.height || 24, 76 | width: scope.width || 24 77 | } 78 | }); 79 | } 80 | 81 | const heart = scope => { 82 | typeof scope === 'undefined' ? scope = {} : null; 83 | return IconHeart({ 84 | scope: { 85 | ...scope, 86 | height: scope.height || 24, 87 | width: scope.width || 24 88 | } 89 | }); 90 | } 91 | const mouth = scope => { 92 | typeof scope === 'undefined' ? scope = {} : null; 93 | return IconMouth({ 94 | scope: { 95 | ...scope, 96 | height: scope.height || 24, 97 | width: scope.width || 24 98 | } 99 | }); 100 | } 101 | const options = scope => { 102 | typeof scope === 'undefined' ? scope = {} : null; 103 | return IconOptions({ 104 | scope: { 105 | ...scope, 106 | height: scope.height || 24, 107 | width: scope.width || 24 108 | } 109 | }); 110 | } 111 | 112 | const user = scope => { 113 | typeof scope === 'undefined' ? scope = {} : null; 114 | return IconUser({ 115 | scope: { 116 | ...scope, 117 | height: scope.height || 24, 118 | width: scope.width || 24 119 | } 120 | }); 121 | } 122 | 123 | const home = scope => { 124 | typeof scope === 'undefined' ? scope = {} : null; 125 | return IconHome({ 126 | scope: { 127 | ...scope, 128 | height: scope.height || 24, 129 | width: scope.width || 24 130 | } 131 | }); 132 | } 133 | 134 | const play = scope => { 135 | typeof scope === 'undefined' ? scope = {} : null; 136 | return IconPlay({ 137 | scope: { 138 | ...scope, 139 | height: scope.height || 24, 140 | width: scope.width || 24 141 | } 142 | }); 143 | } 144 | 145 | const pause = scope => { 146 | typeof scope === 'undefined' ? scope = {} : null; 147 | return IconPause({ 148 | scope: { 149 | ...scope, 150 | height: scope.height || 24, 151 | width: scope.width || 24 152 | } 153 | }); 154 | } 155 | 156 | const stop = scope => { 157 | typeof scope === 'undefined' ? scope = {} : null; 158 | return IconStop({ 159 | scope: { 160 | ...scope, 161 | height: scope.height || 24, 162 | width: scope.width || 24 163 | } 164 | }); 165 | } 166 | 167 | const skipPrev = scope => { 168 | typeof scope === 'undefined' ? scope = {} : null; 169 | return IconSkipPrev({ 170 | scope: { 171 | ...scope, 172 | height: scope.height || 24, 173 | width: scope.width || 24 174 | } 175 | }); 176 | } 177 | 178 | const skipNext = scope => { 179 | typeof scope === 'undefined' ? scope = {} : null; 180 | return IconSkipNext({ 181 | scope: { 182 | ...scope, 183 | height: scope.height || 24, 184 | width: scope.width || 24 185 | } 186 | }); 187 | } 188 | 189 | const check = scope => { 190 | typeof scope === 'undefined' ? scope = {} : null; 191 | return IconCheck({ 192 | scope: { 193 | ...scope, 194 | height: scope.height || 24, 195 | width: scope.width || 24 196 | } 197 | }); 198 | } 199 | 200 | const close = scope => { 201 | typeof scope === 'undefined' ? scope = {} : null; 202 | return IconClose({ 203 | scope: { 204 | ...scope, 205 | height: scope.height || 24, 206 | width: scope.width || 24 207 | } 208 | }); 209 | } 210 | 211 | const plus = scope => { 212 | typeof scope === 'undefined' ? scope = {} : null; 213 | return IconPlus({ 214 | scope: { 215 | ...scope, 216 | height: scope.height || 24, 217 | width: scope.width || 24 218 | } 219 | }); 220 | } 221 | 222 | 223 | export { 224 | home as home, 225 | options as options, 226 | user as user, 227 | music as music, 228 | book as book, 229 | podcast as podcast, 230 | eye as eye, 231 | pen as pen, 232 | heart as heart, 233 | mouth as mouth, 234 | play as play, 235 | pause as pause, 236 | stop as stop, 237 | skipPrev as skipPrev, 238 | skipNext as skipNext, 239 | check as check, 240 | close as close, 241 | plus as plus 242 | 243 | } 244 | 245 | -------------------------------------------------------------------------------- /frontend/src/lib/Icons/mouth.html: -------------------------------------------------------------------------------- 1 | 8 | 12 | 16 | 20 | 26 | 27 | -------------------------------------------------------------------------------- /frontend/src/lib/Icons/music.html: -------------------------------------------------------------------------------- 1 | 8 | 14 | 15 | -------------------------------------------------------------------------------- /frontend/src/lib/Icons/options.html: -------------------------------------------------------------------------------- 1 | 8 | 14 | 20 | 21 | -------------------------------------------------------------------------------- /frontend/src/lib/Icons/pause.html: -------------------------------------------------------------------------------- 1 | 8 | 9 | 10 | 16 | 17 | -------------------------------------------------------------------------------- /frontend/src/lib/Icons/pen.html: -------------------------------------------------------------------------------- 1 | 8 | 14 | 18 | 19 | -------------------------------------------------------------------------------- /frontend/src/lib/Icons/play.html: -------------------------------------------------------------------------------- 1 | 8 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /frontend/src/lib/Icons/plus.html: -------------------------------------------------------------------------------- 1 | 8 | 14 | 20 | 21 | -------------------------------------------------------------------------------- /frontend/src/lib/Icons/podcast.html: -------------------------------------------------------------------------------- 1 | 8 | 12 | 16 | 20 | 21 | -------------------------------------------------------------------------------- /frontend/src/lib/Icons/skip-next.html: -------------------------------------------------------------------------------- 1 | 8 | 14 | 15 | -------------------------------------------------------------------------------- /frontend/src/lib/Icons/skip-prev.html: -------------------------------------------------------------------------------- 1 | 8 | 14 | 20 | 21 | -------------------------------------------------------------------------------- /frontend/src/lib/Icons/stop.html: -------------------------------------------------------------------------------- 1 | 8 | 9 | 15 | 16 | -------------------------------------------------------------------------------- /frontend/src/lib/Icons/svg/book.svg: -------------------------------------------------------------------------------- 1 | 8 | 14 | 15 | -------------------------------------------------------------------------------- /frontend/src/lib/Icons/svg/check.svg: -------------------------------------------------------------------------------- 1 | 8 | 12 | 18 | 19 | -------------------------------------------------------------------------------- /frontend/src/lib/Icons/svg/close.svg: -------------------------------------------------------------------------------- 1 | 8 | 12 | 18 | 19 | -------------------------------------------------------------------------------- /frontend/src/lib/Icons/svg/eye.svg: -------------------------------------------------------------------------------- 1 | 8 | 14 | 20 | 21 | -------------------------------------------------------------------------------- /frontend/src/lib/Icons/svg/eye_alt.svg: -------------------------------------------------------------------------------- 1 | 8 | 12 | 18 | 19 | -------------------------------------------------------------------------------- /frontend/src/lib/Icons/svg/heart.svg: -------------------------------------------------------------------------------- 1 | 8 | 14 | 15 | -------------------------------------------------------------------------------- /frontend/src/lib/Icons/svg/home.svg: -------------------------------------------------------------------------------- 1 | 8 | 14 | 15 | -------------------------------------------------------------------------------- /frontend/src/lib/Icons/svg/mouth.svg: -------------------------------------------------------------------------------- 1 | 8 | 12 | 16 | 20 | 26 | 27 | -------------------------------------------------------------------------------- /frontend/src/lib/Icons/svg/music.svg: -------------------------------------------------------------------------------- 1 | 8 | 14 | 15 | -------------------------------------------------------------------------------- /frontend/src/lib/Icons/svg/options.svg: -------------------------------------------------------------------------------- 1 | 8 | 14 | 20 | 21 | -------------------------------------------------------------------------------- /frontend/src/lib/Icons/svg/pause.svg: -------------------------------------------------------------------------------- 1 | 8 | 9 | 10 | 16 | 17 | -------------------------------------------------------------------------------- /frontend/src/lib/Icons/svg/pen.svg: -------------------------------------------------------------------------------- 1 | 8 | 14 | 18 | 19 | -------------------------------------------------------------------------------- /frontend/src/lib/Icons/svg/play.svg: -------------------------------------------------------------------------------- 1 | 8 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /frontend/src/lib/Icons/svg/plus.svg: -------------------------------------------------------------------------------- 1 | 8 | 14 | 20 | 21 | -------------------------------------------------------------------------------- /frontend/src/lib/Icons/svg/podcast.svg: -------------------------------------------------------------------------------- 1 | 8 | 12 | 16 | 20 | 21 | -------------------------------------------------------------------------------- /frontend/src/lib/Icons/svg/skip-next.svg: -------------------------------------------------------------------------------- 1 | 8 | 14 | 15 | -------------------------------------------------------------------------------- /frontend/src/lib/Icons/svg/skip-prev.svg: -------------------------------------------------------------------------------- 1 | 8 | 14 | 20 | 21 | -------------------------------------------------------------------------------- /frontend/src/lib/Icons/svg/stop.svg: -------------------------------------------------------------------------------- 1 | 8 | 9 | 15 | 16 | -------------------------------------------------------------------------------- /frontend/src/lib/Icons/svg/user.svg: -------------------------------------------------------------------------------- 1 | 8 | 14 | 18 | 19 | -------------------------------------------------------------------------------- /frontend/src/lib/Icons/user.html: -------------------------------------------------------------------------------- 1 | 8 | 14 | 18 | 19 | -------------------------------------------------------------------------------- /frontend/src/lib/Locale/Translations.js: -------------------------------------------------------------------------------- 1 | import DE from './de.json'; 2 | import EN from './en.json'; 3 | 4 | export default { 5 | 'de' : DE, 6 | 'en' : EN 7 | } 8 | -------------------------------------------------------------------------------- /frontend/src/lib/Locale/de.json: -------------------------------------------------------------------------------- 1 | { 2 | "Hello World": "Hallo Welt", 3 | "Hello": "Hallo", 4 | "Pin": "Pin", 5 | "setup add artist text 1": "Hier eine Spotify-Url zu einem Künstler eingeben. Zum Beispiel: ", 6 | "setup add artist text 2": "
https://open.spotify.com/artist/4DZ94rcOtqQo9NtE3DIho5?si=-6MUOsVMQZG7hyaD_6F6xg
", 7 | "setup add artist text 3": "Es werden alle Alben des Künstlers und deren Tracks in die lokale Datenbank übertragen. Wärend des Vorgangs werden auch alle Künstler- und Album-Bilder heruntergeladen und zwischengespeichert.", 8 | "setup add artist text 4": "Dieser Vorgang kann je nach Umfang eine Weile dauern. Achtung: es kann gut sein, dass nur eine geringe Anzahl an Künstlern dennoch eine erhebliche Menge an Tacks beinhalten.", 9 | "setup add artist text 5": "Um einen Künstler-Link zu erhalten, gehe in Spotify auf einen Künstler, wähle teilen und Link zu Künstler*in kopieren. Den Link aus der Zwischenablage nun hier in das Feld einfügen (STRG + v)", 10 | "setup add artist text 6": "Konnte ein Vorgang nicht beendet werden, setzt ein Neustart den Vorgang fort. Ist ein Vorgang beendet, erscheint bei den Künstler*innen ein neuer Eintrag mit Bild.", 11 | "Download": "Download", 12 | "Image url placeholder": "Bild-URL", 13 | "Ok": "Ok", 14 | "Save": "abspeichern", 15 | "Edit artist": "Künstler bearbeiten", 16 | "Edit album": "Album bearbeiten", 17 | "icon description book": "Hörbuch", 18 | "icon description check": "Okay", 19 | "icon description close": "Schließen oder entfernen", 20 | "icon description eye": "Unsichtbar oder sichtbar machen", 21 | "icon description heart": "Als Lieblingsalbum markieren", 22 | "icon description home": "Diese Ansicht hier", 23 | "icon description mouth": "Vorlesen", 24 | "icon description music": "Musik", 25 | "icon description options": "Optionen und Einstellungen", 26 | "icon description pause": "Pause", 27 | "icon description pen": "Bearbeiten", 28 | "icon description play": "Play. Titel oder ersten Titel eines Albums abspielen", 29 | "icon description plus": "Hinzufügen", 30 | "icon description podcast": "Podcast", 31 | "icon description skipPrev": "eins zurück", 32 | "icon description skipNext": "eins weiter", 33 | "icon description stop": "stop", 34 | "icon description user": "Figuren und Darsteller", 35 | "add topic": "hinzufügen", 36 | "remove topic": "entfernen", 37 | "exclude device": "Gerät ausschießen", 38 | "exclude model": "Modell ausschießen", 39 | "refresh devices" : "Geräte automatisch aktualisieren", 40 | "topics": "Topics", 41 | "excludes": "Ausschlüsse", 42 | "remove exclude": "entfernen", 43 | "order by": "sortieren", 44 | "time": "Zeit", 45 | "count": "Zähler", 46 | "forget": "vergessen" 47 | } 48 | -------------------------------------------------------------------------------- /frontend/src/lib/Locale/en.json: -------------------------------------------------------------------------------- 1 | { 2 | "Hello World": "Hallo Welt", 3 | "Hello": "Hallo", 4 | "Pin": "Pin", 5 | "setup add artist text 1": "Enter artist's Spotify-url. For example: ", 6 | "setup add artist text 2": "
https://open.spotify.com/artist/4DZ94rcOtqQo9NtE3DIho5?si=-6MUOsVMQZG7hyaD_6F6xg
", 7 | "setup add artist text 3": "All of the artist's albums and their tracks are transferred to the local database. All artist and album images will also be downloaded and cached during the process.\n\n", 8 | "setup add artist text 4": "Depending on the scope, this process can take a while. Warning: it may well be that only a small number of artists still contain a significant amount of tacks.", 9 | "setup add artist text 5": "To get an artist link, go to an artist in Spotify, select share and copy link to artist. Paste the link from the clipboard into the field here(STRG + v)", 10 | "setup add artist text 6": "If a process could not be completed, a restart continues the process. When a process is complete, a new entry with a picture appears for the artists.", 11 | "Download": "Download", 12 | "Image url placeholder": "Image-URL", 13 | "Ok": "Ok", 14 | "Save": "save", 15 | "Edit artist": "edit artist", 16 | "Edit album": "edit album", 17 | "icon description book": "Audiobook", 18 | "icon description check": "Okay", 19 | "icon description close": "close or remove", 20 | "icon description eye": "make invisible", 21 | "icon description heart": "mark as favorite", 22 | "icon description home": "this view here", 23 | "icon description mouth": "read", 24 | "icon description music": "music", 25 | "icon description options": "options and preferences", 26 | "icon description pause": "Pause", 27 | "icon description pen": "edit", 28 | "icon description play": "Play.", 29 | "icon description plus": "add", 30 | "icon description podcast": "Podcast", 31 | "icon description skipPrev": "one back", 32 | "icon description skipNext": "one forward", 33 | "icon description stop": "stop", 34 | "icon description user": "Figures and artists", 35 | "add topic": "add", 36 | "remove topic": "remove", 37 | "exclude device": "exclude", 38 | "exclude model": "exclude", 39 | "refresh devices": "refresh devices", 40 | "topics": "Topics", 41 | "excludes": "Excludes", 42 | "remove exclude": "remove exclude", 43 | "order by": "order by", 44 | "time": "time", 45 | "count": "count", 46 | "forget": "forget" 47 | } 48 | -------------------------------------------------------------------------------- /frontend/src/lib/Locale/index.js: -------------------------------------------------------------------------------- 1 | import Translations from './Translations.js'; 2 | import {I18n} from "i18n-js"; 3 | 4 | export default class Locale extends MODULECLASS { 5 | constructor(parent) { 6 | super(parent); 7 | 8 | this.i18n = new I18n(); 9 | 10 | Object.keys(Translations).forEach(locale => this.i18n.store({ 11 | [locale]: Translations[locale] 12 | })); 13 | 14 | this.i18n.locale = 'de'; 15 | 16 | // map it globally 17 | window._T = (text, options) => this.i18n.t(text, options); 18 | window._L = (to, value) => this.i18n.l(to, value); 19 | 20 | } 21 | 22 | setLocale(key) { 23 | this.i18n.locale = key; 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /frontend/src/lib/Main.js: -------------------------------------------------------------------------------- 1 | import './Global/Globals.js'; 2 | 3 | import Locale from './Locale/index.js'; 4 | import * as Icons from './Icons/index.js'; 5 | 6 | import Navigation from './Navigation/index.js'; 7 | 8 | // tabs 9 | import Home from './Home/index.js'; 10 | 11 | export default class Main extends MODULECLASS { 12 | constructor(options) { 13 | super(); 14 | 15 | return new Promise((resolve, reject) => { 16 | this.label = 'APP'; 17 | LOG(this.label, 'INIT'); 18 | 19 | this.app = this; 20 | this.options = options; 21 | 22 | this.icons = Icons; 23 | 24 | this.mediaBaseUrl = `${window.location.origin}/media`; 25 | this.apiBaseUrl = `${window.location.origin}/api`; 26 | this.wsBaseUrl = `${window.location.host}/live`; 27 | this.urlBase = `${this.apiBaseUrl}`; 28 | 29 | this.rootElement = this.options.target; 30 | this.target = this.rootElement; 31 | 32 | LOG(this.label, 'API BASE URL:', this.urlBase); 33 | 34 | // this class 35 | this.on('ready', () => { 36 | this.showTab('home'); 37 | resolve(this) 38 | }); 39 | 40 | // on a tab change 41 | this.on('tab', tab => { 42 | // display a tab 43 | this.showTab(tab); 44 | 45 | // dummy 46 | this.emit(`tab-${tab}`); 47 | }); 48 | 49 | // things 50 | this.locale = new Locale(this); 51 | this.navigation = new Navigation(this); 52 | 53 | // tabs 54 | this.tabs = { 55 | home: new Home(this) 56 | } 57 | 58 | this.ws = new WebSocket(`ws://${this.wsBaseUrl}`); 59 | 60 | this.ws.onopen = (e) => { 61 | this.ws.send("My name is John"); 62 | }; 63 | 64 | this.ws.onmessage = (event) => { 65 | LOG(`[message] Data received from server: ${event.data}`); 66 | }; 67 | 68 | this.ws.onclose = (event) => { 69 | if (event.wasClean) { 70 | LOG(`[close] Connection closed cleanly, code=${event.code} reason=${event.reason}`); 71 | } else { 72 | // e.g. server process killed or network down 73 | // event.code is usually 1006 in this case 74 | LOG('[close] Connection died'); 75 | } 76 | }; 77 | 78 | this.ws.onerror = (error) => { 79 | LOG(`[error]`); 80 | }; 81 | 82 | // finally ;) 83 | this.emit('ready'); 84 | 85 | }); 86 | } 87 | 88 | showTab(tab) { 89 | this.tabs[tab].show(); 90 | } 91 | 92 | } 93 | 94 | 95 | 96 | -------------------------------------------------------------------------------- /frontend/src/lib/Navigation/Templates/navigation.html: -------------------------------------------------------------------------------- 1 | 33 | -------------------------------------------------------------------------------- /frontend/src/lib/Navigation/index.js: -------------------------------------------------------------------------------- 1 | import NavigationTemplate from './Templates/navigation.html'; 2 | 3 | export default class Navigation extends MODULECLASS { 4 | constructor(parent, options) { 5 | super(parent, options); 6 | this.label = 'NAVIGATION'; 7 | LOG(this.label, 'INIT'); 8 | 9 | this.target = this.toDOM(NavigationTemplate({ 10 | scope: {} 11 | })); 12 | this.parent.target.append(this.target); 13 | 14 | this.refreshSwitch = this.target.querySelector('[data-navigation-refresh]'); 15 | this.refreshSwitch.onclick = () => this.toggleRefresh(); 16 | 17 | this.sideSwitch = this.target.querySelector('[data-navigation-side]'); 18 | this.sideSwitch.onclick = () => this.toggleSide(); 19 | 20 | this.orderSwitch = this.target.querySelector('[data-navigation-order]'); 21 | this.orderSwitch.onclick = () => this.toggleOrdering(); 22 | 23 | this.forgetSwitch = this.target.querySelector('[data-navigation-forget]'); 24 | this.forgetSwitch.onclick = () => this.toggleForget(); 25 | 26 | this.refresh = true; 27 | this.forget = true; 28 | } 29 | 30 | toggleRefresh() { 31 | this.refresh ? this.refresh = false : this.refresh = true; 32 | LOG(this.label, 'TOGGLE REFRESH', this.refresh); 33 | } 34 | 35 | toggleSide() { 36 | this.side ? this.side = false : this.side = true; 37 | LOG(this.label, 'TOGGLE SIDE', this.side); 38 | 39 | if (!this.side) { 40 | this.parent.tabs.home.topics.show(); 41 | this.parent.tabs.home.excludes.hide(); 42 | } else { 43 | this.parent.tabs.home.topics.hide(); 44 | this.parent.tabs.home.excludes.show(); 45 | } 46 | } 47 | 48 | toggleOrdering() { 49 | this.order ? this.order = false : this.order = true; 50 | LOG(this.label, 'TOGGLE ORDERING', this.order); 51 | } 52 | 53 | toggleForget() { 54 | return this.fetch(`${this.app.urlBase}/devices/forget`).then(raw => this.forget = raw.data); 55 | } 56 | 57 | //// getter 'n setter 58 | 59 | get refresh() { 60 | return this._refresh; 61 | } 62 | 63 | set refresh(val) { 64 | this._refresh = val; 65 | this.refresh ? this.refreshSwitch.classList.add('active') : this.refreshSwitch.classList.remove('active'); 66 | } 67 | 68 | get side() { 69 | return this._side; 70 | } 71 | 72 | set side(val) { 73 | this._side = val; 74 | this.side ? this.sideSwitch.classList.add('active') : this.sideSwitch.classList.remove('active'); 75 | } 76 | 77 | get order() { 78 | return this._order; 79 | } 80 | 81 | set order(val) { 82 | this._order = val; 83 | this.orderBy = this.order ? 'count' : 'time'; 84 | this.parent.tabs.home.devices.order(this.orderBy); 85 | this.order ? this.orderSwitch.classList.add('active') : this.orderSwitch.classList.remove('active'); 86 | } 87 | 88 | get forget() { 89 | return this._forget; 90 | } 91 | 92 | set forget(val) { 93 | this._forget = val; 94 | this.forget ? this.forgetSwitch.classList.add('active') : this.forgetSwitch.classList.remove('active'); 95 | } 96 | 97 | 98 | } 99 | -------------------------------------------------------------------------------- /frontend/src/lib/Tab.js: -------------------------------------------------------------------------------- 1 | export default class Tab extends MODULECLASS { 2 | constructor(parent, options) { 3 | super(parent, options); 4 | 5 | this.on('draw', () => { 6 | this.target.classList.add('active'); 7 | }); 8 | } 9 | 10 | show() { 11 | LOG(this.label, 'SHOW TAB', this.tab); 12 | 13 | this.hideAll(); 14 | this.target.classList.add('active'); 15 | } 16 | 17 | hideAll() { 18 | // hide all tabs 19 | Object.keys(this.app.tabs).forEach(tab => { 20 | tab !== this.tab ? this.app.tabs[tab].hide() : null; 21 | }); 22 | } 23 | 24 | hide() { 25 | this.target.classList.remove('active'); 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /frontend/src/scss/app.scss: -------------------------------------------------------------------------------- 1 | @import "global/_index.scss"; 2 | @import "modules/_index.scss"; 3 | -------------------------------------------------------------------------------- /frontend/src/scss/global/_index.scss: -------------------------------------------------------------------------------- 1 | @import "colors.scss"; 2 | @import "typo.scss"; 3 | @import "responsive.scss"; 4 | @import "base.scss"; 5 | @import "tab.scss"; 6 | @import "button.scss"; 7 | @import "form.scss"; 8 | @import "modal.scss"; 9 | @import "background.scss"; 10 | -------------------------------------------------------------------------------- /frontend/src/scss/global/background.scss: -------------------------------------------------------------------------------- 1 | .background { 2 | position: fixed; 3 | z-index: 0; 4 | width: 100%; 5 | height: 100%; 6 | background-position: center; 7 | background-size: cover; 8 | filter: blur(3px) saturate(0) contrast(100%); 9 | opacity: 0.3; 10 | mix-blend-mode: overlay; 11 | transform: rotateY(180deg); 12 | } 13 | -------------------------------------------------------------------------------- /frontend/src/scss/global/base.scss: -------------------------------------------------------------------------------- 1 | body, 2 | html { 3 | margin: 0; 4 | color: #ddd; 5 | height: 100%; 6 | width: 100%; 7 | overflow: hidden; 8 | } 9 | 10 | body { 11 | font-size: $fs-m; 12 | } 13 | 14 | html { 15 | background-color: #000; 16 | background-image: linear-gradient(180deg, rgba($color-gray-b, 1) 0%, rgba($color-gray-c, 0.5) 100%); 17 | } 18 | 19 | * { 20 | font-family: $font-family; 21 | } 22 | 23 | ul { 24 | margin: 0; 25 | padding: 0; 26 | 27 | li { 28 | margin: 0; 29 | padding: 0; 30 | list-style: none; 31 | } 32 | } 33 | 34 | -------------------------------------------------------------------------------- /frontend/src/scss/global/button.scss: -------------------------------------------------------------------------------- 1 | button { 2 | border: none; 3 | background-color: rgba(255, 255, 255, 0.6); 4 | font-size: 1em; 5 | line-height: 1em; 6 | margin: 0; 7 | padding: 3px 6px; 8 | border-radius: 3px; 9 | } 10 | -------------------------------------------------------------------------------- /frontend/src/scss/global/colors.scss: -------------------------------------------------------------------------------- 1 | $color-pink: rgb(181, 0, 255); 2 | $color-ocean: #01172f; 3 | $color-terra: #4c212a; 4 | $color-leaf: #008e86; 5 | $color-cloud: #0077bd; 6 | $color-splash: #126df6; 7 | $color-warn: #700; 8 | $color-orange: #b70; 9 | $color-primary: $color-leaf; 10 | $color-text: #999; 11 | $color-bg: #ddd; 12 | 13 | // dark, bright 14 | $color-primary-dark: desaturate(darken($color-primary, 10%), 10%); 15 | $color-primary-darkest: desaturate(darken($color-primary, 30%), 5%); 16 | $color-primary-blackhole: desaturate(darken($color-primary, 50%), 5%); 17 | $color-primary-bright: desaturate(lighten($color-primary, 3%), 3%); 18 | 19 | // ocean 20 | $color-ocean-dark: desaturate(darken($color-ocean, 5%), 5%); 21 | $color-ocean-bright: desaturate(lighten($color-ocean, 3%), 3%); 22 | $color-ocean-dark: desaturate(darken($color-ocean, 5%), 5%); 23 | $color-ocean-bright: desaturate(lighten($color-ocean, 3%), 3%); 24 | 25 | // terra 26 | $color-terra-dark: desaturate(darken($color-terra, 3%), 3%); 27 | $color-terra-bright: desaturate(lighten($color-terra, 3%), 3%); 28 | 29 | // leaf 30 | $color-leaf-dark: desaturate(darken($color-leaf, 3%), 3%); 31 | $color-leaf-bright: desaturate(lighten($color-leaf, 5%), 5%); 32 | 33 | // cloud 34 | $color-cloud-dark: desaturate(darken($color-cloud, 5%), 5%); 35 | $color-cloud-bright: desaturate(lighten($color-cloud, 5%), 5%); 36 | 37 | // splash 38 | $color-splash-dark: desaturate(darken($color-splash, 3%), 3%); 39 | $color-splash-blackhole: desaturate(darken($color-splash, 40%), 5%); 40 | $color-splash-bright: desaturate(lighten($color-splash, 3%), 3%); 41 | 42 | // gray 43 | $color-gray-a: #111; 44 | $color-gray-b: #333; 45 | $color-gray-c: #555; 46 | $color-gray-d: #777; 47 | 48 | // matte 49 | $color-matte-dark: rgba(0, 0, 0, 0.2); 50 | $color-matte-darker: rgba(0, 0, 0, 0.4); 51 | $color-matte-darkest: rgba(0, 0, 0, 0.6); 52 | $color-matte-doom: rgba(0, 0, 0, 0.8); 53 | $color-matte-bright: rgba(255, 255, 255, 0.2); 54 | $color-matte-brighter: rgba(255, 255, 255, 0.4); 55 | $color-matte-brightest: rgba(255, 255, 255, 0.6); 56 | $color-matte-bloom: rgba(255, 255, 255, 0.8); 57 | -------------------------------------------------------------------------------- /frontend/src/scss/global/form.scss: -------------------------------------------------------------------------------- 1 | input[type=text] { 2 | width: 50vw; 3 | font-size: $fs-l; 4 | line-height: $fs-xl; 5 | border: none; 6 | color: white; 7 | padding: 0 15px; 8 | border-radius: 5px; 9 | position: relative; 10 | background-color: rgba(0, 0, 0, 0.2); 11 | 12 | &:focus { 13 | border: none; 14 | } 15 | 16 | &:focus-visible { 17 | border: none; 18 | outline: 2px solid $color-primary-dark; 19 | background-color: rgba(0, 0, 0, 0.2); 20 | } 21 | 22 | &.update { 23 | background: $color-leaf; 24 | outline: none; 25 | } 26 | } 27 | 28 | .form { 29 | &--element { 30 | &__text { 31 | label { 32 | width: 100%; 33 | display: block; 34 | opacity: 0.5; 35 | } 36 | 37 | input[type=text] { 38 | 39 | } 40 | } 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /frontend/src/scss/global/modal.scss: -------------------------------------------------------------------------------- 1 | .modal { 2 | position: fixed; 3 | z-index: 1000; 4 | width: 100%; 5 | height: 100%; 6 | background-image: linear-gradient(180deg, rgba(2, 0, 36, 0.9) 100%, rgba(181, 0, 255, 0.9) 0%); 7 | 8 | button { 9 | cursor: pointer; 10 | 11 | &[data-button=close] { 12 | position: absolute; 13 | right: 20px; 14 | top: 20px; 15 | background-color: rgba(255, 255, 255, 0.1); 16 | 17 | svg { 18 | width: 50px; 19 | height: 50px; 20 | margin: 10px; 21 | color: white; 22 | } 23 | 24 | &:hover { 25 | background-color: $color-primary; 26 | 27 | svg { 28 | color: white; 29 | } 30 | } 31 | } 32 | 33 | &[data-button=submit] { 34 | position: absolute; 35 | margin: auto; 36 | bottom: 20vh; 37 | font-size: $fs-xl; 38 | line-height: $fs-xl; 39 | font-weight: bold; 40 | background-color: $color-primary-dark; 41 | color: white; 42 | } 43 | } 44 | 45 | &--title { 46 | font-weight: 600; 47 | margin-top: 40px; 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /frontend/src/scss/global/responsive.scss: -------------------------------------------------------------------------------- 1 | $screen-xxs-min: 320px; 2 | $screen-xs-min: 640px; 3 | $screen-sm-min: 768px; 4 | $screen-md-min: 1024px; 5 | $screen-lg-min: 1280px; 6 | $screen-xl-min: 1440px; 7 | $screen-xxs-max: ($screen-xs-min - 1); 8 | $screen-xs-max: ($screen-sm-min - 1); 9 | $screen-sm-max: ($screen-md-min - 1); 10 | $screen-md-max: ($screen-lg-min - 1); 11 | $screen-lg-max: ($screen-xl-min - 1); 12 | $media-tv: "only screen and (max-width: #{$screen-xl-min})"; 13 | $media-tv-min: "only screen and (min-width: #{$screen-xl-min})"; 14 | $media-desktop: "only screen and (max-width: #{$screen-lg-min})"; 15 | $media-desktop-min: "only screen and (min-width: #{$screen-lg-min})"; 16 | $media-tablet: "only screen and (max-width: #{$screen-md-min})"; 17 | $media-tablet-min: "only screen and (min-width: #{$screen-md-min})"; 18 | $media-tablet-min-max: "only screen and (max-width: #{$screen-md-min}) and (min-width: #{$screen-sm-min})"; 19 | $media-tablet-landscape: "only screen and (max-width: #{$screen-md-min}) and (min-width: #{$screen-sm-min}) and (orientation: landscape)"; 20 | $media-tablet-max: "only screen and (max-width: #{$screen-md-max})"; 21 | $media-smartphone: "only screen and (max-width: #{$screen-sm-min})"; 22 | $media-smartphone-min: "only screen and (min-width: #{$screen-sm-min})"; 23 | $media-phone: "only screen and (max-width: #{$screen-xs-min})"; 24 | $media-phone-min: "only screen and (min-width: #{$screen-xs-min})"; 25 | $media-watch: "only screen and (max-width: #{$screen-xxs-min})"; 26 | $landscape: "and (orientation: landscape)"; 27 | $portrait: "and (orientation: portrait)"; 28 | 29 | @mixin media-tv() { 30 | @media #{$media-tv} { 31 | @content; 32 | } 33 | } 34 | 35 | @mixin media-tv-min() { 36 | @media #{$media-tv-min} { 37 | @content; 38 | } 39 | } 40 | 41 | @mixin media-desktop() { 42 | @media #{$media-desktop} { 43 | @content; 44 | } 45 | } 46 | 47 | @mixin media-desktop-min() { 48 | @media #{$media-desktop-min} { 49 | @content; 50 | } 51 | } 52 | 53 | @mixin media-tablet() { 54 | @media #{$media-tablet} { 55 | @content; 56 | } 57 | } 58 | 59 | @mixin media-tablet-min() { 60 | @media #{$media-tablet-min} { 61 | @content; 62 | } 63 | } 64 | 65 | @mixin media-tablet-min-max() { 66 | @media #{$media-tablet-min-max} { 67 | @content; 68 | } 69 | } 70 | 71 | @mixin media-tablet-max() { 72 | @media #{$media-tablet-max} { 73 | @content; 74 | } 75 | } 76 | 77 | @mixin media-tablet-landscape() { 78 | @media #{$media-tablet-landscape} { 79 | @content; 80 | } 81 | } 82 | 83 | @mixin media-smartphone() { 84 | @media #{$media-smartphone} { 85 | @content; 86 | } 87 | } 88 | 89 | @mixin media-smartphone-min() { 90 | @media #{$media-smartphone-min} { 91 | @content; 92 | } 93 | } 94 | 95 | @mixin media-phone() { 96 | @media #{$media-phone} { 97 | @content; 98 | } 99 | } 100 | 101 | @mixin media-phone-min() { 102 | @media #{$media-phone-min} { 103 | @content; 104 | } 105 | } 106 | 107 | @mixin media-watch() { 108 | @media #{$media-watch} { 109 | @content; 110 | } 111 | } 112 | -------------------------------------------------------------------------------- /frontend/src/scss/global/tab.scss: -------------------------------------------------------------------------------- 1 | [data-tab] { 2 | transform: translate3d(0, -100vh, 0); 3 | transition: all ease-out 0.5s; 4 | opacity: 0; 5 | position: fixed; 6 | top: 90px; 7 | height: calc(100% - 90px); // minus navigation and player 8 | width: 100%; 9 | 10 | &.active { 11 | opacity: 1; 12 | display: block; 13 | transform: translate3d(0, 0, 0); 14 | transition: all ease-out 0.2s; 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /frontend/src/scss/global/typo.scss: -------------------------------------------------------------------------------- 1 | // primary 2 | $font-family-primary: "Barlow"; 3 | $font-path-primary: "./fonts/Barlow"; 4 | 5 | // secondary 6 | $font-family-secondary: "Oswald"; 7 | $font-path-secondary: "./fonts/Oswald"; 8 | 9 | // family 10 | $font-family: $font-family-primary; 11 | 12 | // woff2 mixin 13 | @mixin font-face($family, $file-name, $font-path, $weight) { 14 | $filepath: $font-path + "/" + $file-name + ".woff2"; 15 | 16 | @font-face { 17 | font-family: "#{$family}"; 18 | font-weight: $weight; 19 | font-display: swap; 20 | src: url($filepath) format("woff2"); 21 | } 22 | } 23 | 24 | // includes 25 | @include font-face($font-family-primary, "barlow-v12-latin-100", $font-path-primary, 100); 26 | @include font-face($font-family-primary, "barlow-v12-latin-200", $font-path-primary, 200); 27 | @include font-face($font-family-primary, "barlow-v12-latin-300", $font-path-primary, 300); 28 | @include font-face($font-family-primary, "barlow-v12-latin-500", $font-path-primary, 500); 29 | @include font-face($font-family-primary, "barlow-v12-latin-600", $font-path-primary, 600); 30 | @include font-face($font-family-primary, "barlow-v12-latin-700", $font-path-primary, 700); 31 | 32 | // sizes 33 | $fs-xxs: 0.6em; 34 | $fs-xs: 0.7em; 35 | $fs-s: 1em; 36 | $fs-m: 1.25em; 37 | $fs-l: 1.5em; 38 | $fs-xl: 2em; 39 | $fs-xxl: 3em; 40 | $fs-huge: 4em; 41 | $fs-omg: 6em; 42 | $fs-jesus: 8em; 43 | -------------------------------------------------------------------------------- /frontend/src/scss/modules/_index.scss: -------------------------------------------------------------------------------- 1 | @import "./navigation/_index.scss"; 2 | @import "./home/_index.scss"; 3 | @import "./devices/_index.scss"; 4 | @import "./topics/_index.scss"; 5 | @import "./excludes/_index.scss"; 6 | 7 | -------------------------------------------------------------------------------- /frontend/src/scss/modules/devices/_index.scss: -------------------------------------------------------------------------------- 1 | .devices { 2 | width: calc(100% - 500px); 3 | height: 100%; 4 | position: absolute; 5 | display: flex; 6 | flex-direction: row; 7 | flex-wrap: wrap; 8 | overflow-y: auto; 9 | align-content: flex-start; 10 | 11 | &--device { 12 | width: 100%; 13 | display: block; 14 | margin-bottom: 10px; 15 | border-bottom: 1px solid $color-gray-c; 16 | 17 | &__body { 18 | overflow: hidden; 19 | display: block; 20 | background-color: rgba($color-gray-a, 0.1); 21 | padding: 3px; 22 | } 23 | 24 | &__id { 25 | font-size: $fs-xs; 26 | display: block; 27 | overflow: hidden; 28 | background-color: $color-gray-c; 29 | text-shadow: 1px 1px rgba(0, 0, 0, 0.5); 30 | 31 | table { 32 | height: 30px; 33 | width: 100%; 34 | } 35 | 36 | td { 37 | text-align: center; 38 | font-weight: 900; 39 | padding-right: 50px; 40 | position: relative; 41 | 42 | span { 43 | font-weight: 300; 44 | } 45 | } 46 | 47 | thead { 48 | td { 49 | text-align: center; 50 | font-weight: 300; 51 | } 52 | } 53 | } 54 | 55 | &__model { 56 | font-size: $fs-xxs; 57 | padding: 5px; 58 | text-align: center; 59 | } 60 | 61 | &__topics { 62 | padding: 3px; 63 | margin: 5px 0; 64 | width: 500px; 65 | float: left; 66 | 67 | table { 68 | width: 100%; 69 | } 70 | 71 | .devices--device__value { 72 | &.updated { 73 | animation: blink-animation 0.25s infinite; 74 | transition-duration: 0s; 75 | 76 | @keyframes blink-animation { 77 | 0% { 78 | background-color: black; 79 | } 80 | 81 | 50% { 82 | background-color: $color-splash; 83 | } 84 | 85 | 100% { 86 | background-color: $color-splash; 87 | } 88 | } 89 | } 90 | } 91 | } 92 | 93 | &__properties { 94 | overflow-y: auto; 95 | height: 100%; 96 | width: calc(100% - 506px); 97 | border-radius: 5px; 98 | 99 | table { 100 | width: 100%; 101 | } 102 | } 103 | 104 | &__property { 105 | font-size: $fs-xxs; 106 | padding: 3px; 107 | 108 | &:hover { 109 | cursor: pointer; 110 | background-color: $color-orange; 111 | border-radius: 3px; 112 | color: black; 113 | } 114 | } 115 | 116 | &__value { 117 | font-size: $fs-xxs; 118 | transition: all 0.2s ease-in-out; 119 | padding: 2px 4px; 120 | border-radius: 3px; 121 | font-weight: 900; 122 | background-color: rgba($color-gray-a, 0.5); 123 | text-align: center; 124 | 125 | &.updated { 126 | background-color: $color-splash; 127 | transition: all 0.2s ease-in-out; 128 | } 129 | } 130 | 131 | &__field { 132 | font-size: $fs-xxs; 133 | font-weight: 900; 134 | margin-top: 10px; 135 | width: 30%; 136 | 137 | &.updated { 138 | background-color: $color-splash; 139 | transition: all 0.2s ease-in-out; 140 | } 141 | } 142 | 143 | &__topic { 144 | font-size: $fs-xxs; 145 | width: 60%; 146 | } 147 | 148 | &__td-model { 149 | width: 20%; 150 | } 151 | 152 | &__td-hash { 153 | width: 30%; 154 | } 155 | 156 | &__td-options { 157 | width: 30%; 158 | } 159 | 160 | &__options { 161 | position: absolute; 162 | right: 0; 163 | top: 0; 164 | width: 100%; 165 | display: flex; 166 | align-items: center; 167 | justify-content: right; 168 | height: 100%; 169 | 170 | button { 171 | margin: 0 5px 0 0; 172 | cursor: pointer; 173 | background-color: $color-warn; 174 | color: white; 175 | font-size: $fs-s; 176 | 177 | &:hover { 178 | background-color: white; 179 | color: $color-primary-dark; 180 | } 181 | } 182 | } 183 | 184 | &.active { 185 | .devices--device__body { 186 | opacity: 1; 187 | background-color: rgba(white, 0.05); 188 | } 189 | 190 | &:hover { 191 | opacity: 1; 192 | 193 | .devices--device__options { 194 | opacity: 1; 195 | } 196 | } 197 | 198 | .devices--device__options { 199 | opacity: 0.2; 200 | } 201 | } 202 | 203 | &:hover { 204 | .devices--device__body { 205 | background-color: rgba($color-primary, 0.8); 206 | } 207 | } 208 | } 209 | } 210 | -------------------------------------------------------------------------------- /frontend/src/scss/modules/excludes/_index.scss: -------------------------------------------------------------------------------- 1 | .excludes { 2 | width: 500px; 3 | right: 0; 4 | position: absolute; 5 | height: 100%; 6 | overflow-x: auto; 7 | display: none; 8 | 9 | &.active { 10 | display: block; 11 | } 12 | 13 | &--exclude { 14 | margin-bottom: 5px; 15 | position: relative; 16 | 17 | &__body { 18 | padding: 5px 10px; 19 | background-color: rgba($color-primary-dark, 0.5); 20 | } 21 | 22 | &__field { 23 | font-weight: 300; 24 | font-size: $fs-xs; 25 | } 26 | 27 | &__value { 28 | font-weight: 900; 29 | font-size: $fs-s; 30 | } 31 | 32 | &__button { 33 | font-size: $fs-xxs; 34 | position: absolute; 35 | right: 20px; 36 | bottom: 10px; 37 | padding: 5px 10px; 38 | cursor: pointer; 39 | background-color: rgba($color-warn, 0.6); 40 | color: white; 41 | 42 | &:hover { 43 | background-color: white; 44 | color: $color-primary-dark; 45 | } 46 | } 47 | 48 | &:hover { 49 | background-color: rgba($color-primary, 0.8); 50 | } 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /frontend/src/scss/modules/home/_index.scss: -------------------------------------------------------------------------------- 1 | .home { 2 | &--buttons { 3 | margin-top: 20vh; 4 | text-align: center; 5 | padding: 0 100px; 6 | 7 | &__button { 8 | margin: 0 20px 20px 0; 9 | background-color: rgba($color-primary-dark, 0.4); 10 | cursor: pointer; 11 | 12 | svg { 13 | width: 100px; 14 | height: 100px; 15 | color: white; 16 | } 17 | 18 | &:hover, 19 | &:active &:focus, 20 | &:focus-visible { 21 | background-color: $color-primary; 22 | } 23 | } 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /frontend/src/scss/modules/navigation/_index.scss: -------------------------------------------------------------------------------- 1 | .navigation { 2 | position: fixed; 3 | width: 100%; 4 | height: 88px; 5 | z-index: 1000; 6 | background: rgba(0, 0, 0, 0.7); 7 | border-bottom: 2px solid $color-primary-dark; 8 | top: 0; 9 | overflow: hidden; 10 | display: block; 11 | 12 | &--refresh, 13 | &--side, 14 | &--order, 15 | &--forget { 16 | position: absolute; 17 | top: 25px; 18 | left: 25px; 19 | background-color: rgba(white, 0.1); 20 | padding: 2px 10px; 21 | border-radius: 22px; 22 | display: flex; 23 | align-items: center; 24 | justify-content: right; 25 | 26 | &__switch { 27 | position: relative; 28 | width: 50px; 29 | height: 26px; 30 | display: inline-block; 31 | background-color: $color-warn; 32 | border-radius: 50px; 33 | cursor: pointer; 34 | transition: all 0.2s ease-in-out; 35 | margin: 5px; 36 | 37 | &:before { 38 | position: absolute; 39 | content: ""; 40 | left: 4px; 41 | top: 4px; 42 | width: 18px; 43 | height: 18px; 44 | background-color: white; 45 | border-radius: 18px; 46 | transition: all 0.2s ease-in-out; 47 | } 48 | 49 | &.active { 50 | background-color: $color-primary; 51 | transition: all 0.2s ease-in-out; 52 | 53 | &:before { 54 | transform: translateX(24px); 55 | transition: all 0.2s ease-in-out; 56 | } 57 | } 58 | } 59 | 60 | &__label { 61 | font-size: $fs-xs; 62 | line-height: 20px; 63 | display: inline-block; 64 | margin: 5px; 65 | } 66 | } 67 | 68 | &--side { 69 | left: inherit; 70 | right: 20px; 71 | 72 | &__switch { 73 | background-color: $color-primary; 74 | } 75 | } 76 | 77 | &--order, 78 | &--forget { 79 | top: 25px; 80 | left: 50%; 81 | transform: translateX(calc(-100% - 20px)); 82 | 83 | &__title { 84 | position: absolute; 85 | top: -15px; 86 | left: 50%; 87 | transform: translateX(-50%); 88 | font-size: $fs-xxs; 89 | } 90 | 91 | &__switch { 92 | background-color: $color-primary; 93 | } 94 | } 95 | 96 | &--forget { 97 | transform: translateX(10px); 98 | 99 | &__switch { 100 | background-color: $color-warn; 101 | } 102 | } 103 | } 104 | -------------------------------------------------------------------------------- /frontend/src/scss/modules/topics/_index.scss: -------------------------------------------------------------------------------- 1 | @import "./topics.scss"; 2 | @import "./add.scss"; 3 | -------------------------------------------------------------------------------- /frontend/src/scss/modules/topics/add.scss: -------------------------------------------------------------------------------- 1 | .topic-add { 2 | width: 500px; 3 | right: 0; 4 | position: absolute; 5 | height: 200px; 6 | top: 0; 7 | overflow-x: auto; 8 | background-color: $color-orange; 9 | color: black; 10 | display: none; 11 | 12 | &.active { 13 | display: block; 14 | } 15 | 16 | &--body { 17 | padding: 10px; 18 | } 19 | 20 | &--topic { 21 | display: block; 22 | overflow: hidden; 23 | 24 | input { 25 | font-size: $fs-s; 26 | width: 100%; 27 | padding: 0 10px; 28 | border-radius: 5px; 29 | 30 | &:focus-visible { 31 | background-color: $color-primary-dark; 32 | border-radius: 5px; 33 | } 34 | } 35 | } 36 | 37 | &--device-model { 38 | margin-top: 20px; 39 | } 40 | 41 | &--device-hash { 42 | font-size: $fs-xs; 43 | } 44 | 45 | &--field { 46 | font-size: $fs-l; 47 | font-weight: 900; 48 | } 49 | 50 | &--button { 51 | position: absolute; 52 | right: 20px; 53 | bottom: 20px; 54 | background-color: $color-primary-dark; 55 | color: white; 56 | padding: 10px 20px; 57 | cursor: pointer; 58 | 59 | &:hover { 60 | background-color: $color-primary; 61 | } 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /frontend/src/scss/modules/topics/topics.scss: -------------------------------------------------------------------------------- 1 | .topics { 2 | width: 500px; 3 | right: 0; 4 | position: absolute; 5 | height: calc(100% - 200px); 6 | top: 200px; 7 | overflow-x: auto; 8 | display: none; 9 | 10 | &.active { 11 | display: block; 12 | } 13 | 14 | &--topic { 15 | margin-bottom: 5px; 16 | position: relative; 17 | 18 | &__body { 19 | padding: 5px; 20 | background-color: rgba($color-primary-dark, 0.5); 21 | } 22 | 23 | &__topic { 24 | padding: 0 5px; 25 | font-weight: 900; 26 | font-size: $fs-xs; 27 | } 28 | 29 | &__device { 30 | padding: 0 5px; 31 | font-size: $fs-xxs; 32 | font-weight: 300; 33 | color: rgba(white, 0.3); 34 | } 35 | 36 | &__fields { 37 | padding: 5px; 38 | font-size: $fs-xxs; 39 | } 40 | 41 | &__field { 42 | font-weight: 400; 43 | } 44 | 45 | &__value { 46 | font-weight: 900; 47 | } 48 | 49 | &__button { 50 | font-size: $fs-xxs; 51 | position: absolute; 52 | right: 20px; 53 | bottom: 20px; 54 | padding: 5px 10px; 55 | cursor: pointer; 56 | background-color: rgba($color-warn, 0.6); 57 | color: white; 58 | 59 | &:hover { 60 | background-color: white; 61 | color: $color-primary-dark; 62 | } 63 | } 64 | 65 | &:hover { 66 | background-color: rgba($color-primary, 0.8); 67 | } 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /rtl_433/rtl-sdr.rules: -------------------------------------------------------------------------------- 1 | # 2 | # Copyright 2012-2013 Osmocom rtl-sdr project 3 | # 4 | # This program is free software: you can redistribute it and/or modify 5 | # it under the terms of the GNU General Public License as published by 6 | # the Free Software Foundation, either version 3 of the License, or 7 | # (at your option) any later version. 8 | # 9 | # This program is distributed in the hope that it will be useful, 10 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 | # GNU General Public License for more details. 13 | # 14 | # You should have received a copy of the GNU General Public License 15 | # along with this program. If not, see . 16 | # 17 | 18 | # original RTL2832U vid/pid (hama nano, for example) 19 | SUBSYSTEMS=="usb", ATTRS{idVendor}=="0bda", ATTRS{idProduct}=="2832", MODE:="0666" 20 | 21 | # RTL2832U OEM vid/pid, e.g. ezcap EzTV668 (E4000), Newsky TV28T (E4000/R820T) etc. 22 | SUBSYSTEMS=="usb", ATTRS{idVendor}=="0bda", ATTRS{idProduct}=="2838", MODE:="0666" 23 | 24 | # DigitalNow Quad DVB-T PCI-E card (4x FC0012?) 25 | SUBSYSTEMS=="usb", ATTRS{idVendor}=="0413", ATTRS{idProduct}=="6680", MODE:="0666" 26 | 27 | # Leadtek WinFast DTV Dongle mini D (FC0012) 28 | SUBSYSTEMS=="usb", ATTRS{idVendor}=="0413", ATTRS{idProduct}=="6f0f", MODE:="0666" 29 | 30 | # Genius TVGo DVB-T03 USB dongle (Ver. B) 31 | SUBSYSTEMS=="usb", ATTRS{idVendor}=="0458", ATTRS{idProduct}=="707f", MODE:="0666" 32 | 33 | # Terratec Cinergy T Stick Black (rev 1) (FC0012) 34 | SUBSYSTEMS=="usb", ATTRS{idVendor}=="0ccd", ATTRS{idProduct}=="00a9", MODE:="0666" 35 | 36 | # Terratec NOXON rev 1 (FC0013) 37 | SUBSYSTEMS=="usb", ATTRS{idVendor}=="0ccd", ATTRS{idProduct}=="00b3", MODE:="0666" 38 | 39 | # Terratec Deutschlandradio DAB Stick (FC0013) 40 | SUBSYSTEMS=="usb", ATTRS{idVendor}=="0ccd", ATTRS{idProduct}=="00b4", MODE:="0666" 41 | 42 | # Terratec NOXON DAB Stick - Radio Energy (FC0013) 43 | SUBSYSTEMS=="usb", ATTRS{idVendor}=="0ccd", ATTRS{idProduct}=="00b5", MODE:="0666" 44 | 45 | # Terratec Media Broadcast DAB Stick (FC0013) 46 | SUBSYSTEMS=="usb", ATTRS{idVendor}=="0ccd", ATTRS{idProduct}=="00b7", MODE:="0666" 47 | 48 | # Terratec BR DAB Stick (FC0013) 49 | SUBSYSTEMS=="usb", ATTRS{idVendor}=="0ccd", ATTRS{idProduct}=="00b8", MODE:="0666" 50 | 51 | # Terratec WDR DAB Stick (FC0013) 52 | SUBSYSTEMS=="usb", ATTRS{idVendor}=="0ccd", ATTRS{idProduct}=="00b9", MODE:="0666" 53 | 54 | # Terratec MuellerVerlag DAB Stick (FC0013) 55 | SUBSYSTEMS=="usb", ATTRS{idVendor}=="0ccd", ATTRS{idProduct}=="00c0", MODE:="0666" 56 | 57 | # Terratec Fraunhofer DAB Stick (FC0013) 58 | SUBSYSTEMS=="usb", ATTRS{idVendor}=="0ccd", ATTRS{idProduct}=="00c6", MODE:="0666" 59 | 60 | # Terratec Cinergy T Stick RC (Rev.3) (E4000) 61 | SUBSYSTEMS=="usb", ATTRS{idVendor}=="0ccd", ATTRS{idProduct}=="00d3", MODE:="0666" 62 | 63 | # Terratec T Stick PLUS (E4000) 64 | SUBSYSTEMS=="usb", ATTRS{idVendor}=="0ccd", ATTRS{idProduct}=="00d7", MODE:="0666" 65 | 66 | # Terratec NOXON rev 2 (E4000) 67 | SUBSYSTEMS=="usb", ATTRS{idVendor}=="0ccd", ATTRS{idProduct}=="00e0", MODE:="0666" 68 | 69 | # PixelView PV-DT235U(RN) (FC0012) 70 | SUBSYSTEMS=="usb", ATTRS{idVendor}=="1554", ATTRS{idProduct}=="5020", MODE:="0666" 71 | 72 | # Astrometa DVB-T/DVB-T2 (R828D) 73 | SUBSYSTEMS=="usb", ATTRS{idVendor}=="15f4", ATTRS{idProduct}=="0131", MODE:="0666" 74 | 75 | # Compro Videomate U620F (E4000) 76 | SUBSYSTEMS=="usb", ATTRS{idVendor}=="185b", ATTRS{idProduct}=="0620", MODE:="0666" 77 | 78 | # Compro Videomate U650F (E4000) 79 | SUBSYSTEMS=="usb", ATTRS{idVendor}=="185b", ATTRS{idProduct}=="0650", MODE:="0666" 80 | 81 | # Compro Videomate U680F (E4000) 82 | SUBSYSTEMS=="usb", ATTRS{idVendor}=="185b", ATTRS{idProduct}=="0680", MODE:="0666" 83 | 84 | # GIGABYTE GT-U7300 (FC0012) 85 | SUBSYSTEMS=="usb", ATTRS{idVendor}=="1b80", ATTRS{idProduct}=="d393", MODE:="0666" 86 | 87 | # DIKOM USB-DVBT HD 88 | SUBSYSTEMS=="usb", ATTRS{idVendor}=="1b80", ATTRS{idProduct}=="d394", MODE:="0666" 89 | 90 | # Peak 102569AGPK (FC0012) 91 | SUBSYSTEMS=="usb", ATTRS{idVendor}=="1b80", ATTRS{idProduct}=="d395", MODE:="0666" 92 | 93 | # KWorld KW-UB450-T USB DVB-T Pico TV (TUA9001) 94 | SUBSYSTEMS=="usb", ATTRS{idVendor}=="1b80", ATTRS{idProduct}=="d397", MODE:="0666" 95 | 96 | # Zaapa ZT-MINDVBZP (FC0012) 97 | SUBSYSTEMS=="usb", ATTRS{idVendor}=="1b80", ATTRS{idProduct}=="d398", MODE:="0666" 98 | 99 | # SVEON STV20 DVB-T USB & FM (FC0012) 100 | SUBSYSTEMS=="usb", ATTRS{idVendor}=="1b80", ATTRS{idProduct}=="d39d", MODE:="0666" 101 | 102 | # Twintech UT-40 (FC0013) 103 | SUBSYSTEMS=="usb", ATTRS{idVendor}=="1b80", ATTRS{idProduct}=="d3a4", MODE:="0666" 104 | 105 | # ASUS U3100MINI_PLUS_V2 (FC0013) 106 | SUBSYSTEMS=="usb", ATTRS{idVendor}=="1b80", ATTRS{idProduct}=="d3a8", MODE:="0666" 107 | 108 | # SVEON STV27 DVB-T USB & FM (FC0013) 109 | SUBSYSTEMS=="usb", ATTRS{idVendor}=="1b80", ATTRS{idProduct}=="d3af", MODE:="0666" 110 | 111 | # SVEON STV21 DVB-T USB & FM 112 | SUBSYSTEMS=="usb", ATTRS{idVendor}=="1b80", ATTRS{idProduct}=="d3b0", MODE:="0666" 113 | 114 | # Dexatek DK DVB-T Dongle (Logilink VG0002A) (FC2580) 115 | SUBSYSTEMS=="usb", ATTRS{idVendor}=="1d19", ATTRS{idProduct}=="1101", MODE:="0666" 116 | 117 | # Dexatek DK DVB-T Dongle (MSI DigiVox mini II V3.0) 118 | SUBSYSTEMS=="usb", ATTRS{idVendor}=="1d19", ATTRS{idProduct}=="1102", MODE:="0666" 119 | 120 | # Dexatek DK 5217 DVB-T Dongle (FC2580) 121 | SUBSYSTEMS=="usb", ATTRS{idVendor}=="1d19", ATTRS{idProduct}=="1103", MODE:="0666" 122 | 123 | # MSI DigiVox Micro HD (FC2580) 124 | SUBSYSTEMS=="usb", ATTRS{idVendor}=="1d19", ATTRS{idProduct}=="1104", MODE:="0666" 125 | 126 | # Sweex DVB-T USB (FC0012) 127 | SUBSYSTEMS=="usb", ATTRS{idVendor}=="1f4d", ATTRS{idProduct}=="a803", MODE:="0666" 128 | 129 | # GTek T803 (FC0012) 130 | SUBSYSTEMS=="usb", ATTRS{idVendor}=="1f4d", ATTRS{idProduct}=="b803", MODE:="0666" 131 | 132 | # Lifeview LV5TDeluxe (FC0012) 133 | SUBSYSTEMS=="usb", ATTRS{idVendor}=="1f4d", ATTRS{idProduct}=="c803", MODE:="0666" 134 | 135 | # MyGica TD312 (FC0012) 136 | SUBSYSTEMS=="usb", ATTRS{idVendor}=="1f4d", ATTRS{idProduct}=="d286", MODE:="0666" 137 | 138 | # PROlectrix DV107669 (FC0012) 139 | SUBSYSTEMS=="usb", ATTRS{idVendor}=="1f4d", ATTRS{idProduct}=="d803", MODE:="0666" 140 | -------------------------------------------------------------------------------- /server/.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ 3 | [ 4 | "@babel/preset-env", 5 | { 6 | "targets": { 7 | "node": "16", 8 | "esmodules": true 9 | } 10 | } 11 | ] 12 | ], 13 | "plugins": [ 14 | "@babel/plugin-proposal-object-rest-spread", 15 | "@babel/plugin-transform-classes", 16 | "@babel/plugin-transform-runtime", 17 | "@babel/plugin-transform-regenerator", 18 | "@babel/plugin-syntax-import-assertions" 19 | ] 20 | } -------------------------------------------------------------------------------- /server/.dockerignore: -------------------------------------------------------------------------------- 1 | config/*.json 2 | config/*.conf 3 | dist 4 | .babelrc 5 | testing.js 6 | webpack-app-pkg.config.js 7 | .env 8 | 9 | !config/*.example 10 | !config/types.json -------------------------------------------------------------------------------- /server/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:16-alpine 2 | 3 | ARG RTL433_RELEASE_TAG=22.11 4 | ARG FRONTEND_COMMIT_ID=d8f7c4cb50388c13046b0f53703009fc5aca31c6 5 | 6 | VOLUME ["/raspiscan/server/node_modules", "/raspiscan/shared/node_modules"] 7 | 8 | RUN apk add --no-cache build-base libtool libusb-dev librtlsdr-dev rtl-sdr cmake git curl 9 | 10 | # get rtl_433 source from release tag 11 | WORKDIR /home/node 12 | RUN curl -L https://github.com/merbanan/rtl_433/archive/refs/tags/${RTL433_RELEASE_TAG}.tar.gz -o rtl_433-${RTL433_RELEASE_TAG}.tar.gz 13 | RUN tar -xvf rtl_433-${RTL433_RELEASE_TAG}.tar.gz 14 | 15 | WORKDIR /home/node/rtl_433-${RTL433_RELEASE_TAG} 16 | RUN mkdir build 17 | 18 | WORKDIR /home/node/rtl_433-${RTL433_RELEASE_TAG}/build 19 | RUN cmake ../ 20 | RUN make 21 | RUN make install 22 | 23 | # 24 | WORKDIR /raspiscan/shared 25 | COPY shared/package.json . 26 | RUN npm install 27 | COPY shared . 28 | 29 | WORKDIR /raspiscan/server 30 | COPY server/package.json . 31 | RUN npm install 32 | COPY server . 33 | 34 | COPY server/config/default.conf.example config/default.conf 35 | COPY server/config/mapping.json.example config/mapping.json 36 | COPY server/config/excludes.json.example config/excludes.json 37 | 38 | # obsolete, because there is no polyfill for Proxy() 39 | #RUN npm install pkg -g 40 | 41 | WORKDIR / 42 | RUN chown -R 1000:1000 /root/.npm 43 | 44 | COPY rtl_433/rtl_433.conf /etc/rtl_433/rtl_433.conf 45 | COPY rtl_433/rtl-sdr.rules /etc/udev/rules.d/rtl-sdr.rules 46 | 47 | WORKDIR /usr/local/bin 48 | COPY server/entrypoint.sh . 49 | RUN chmod +x entrypoint.sh 50 | ENTRYPOINT ["entrypoint.sh"] 51 | 52 | WORKDIR /raspiscan/server 53 | RUN mkdir frontend 54 | RUN curl https://github.com/seekwhencer/node-rtl433-ui/blob/${FRONTEND_COMMIT_ID}/index.html -o frontend/index.html 55 | RUN curl https://github.com/seekwhencer/node-rtl433-ui/blob/${FRONTEND_COMMIT_ID}/app.js -o frontend/app.js 56 | 57 | WORKDIR /raspiscan 58 | COPY .env.example .env 59 | 60 | -------------------------------------------------------------------------------- /server/app.js: -------------------------------------------------------------------------------- 1 | import './lib/Globals.js'; 2 | import Config from '../shared/lib/Config.js'; 3 | import WebServer from './lib/Server/index.js'; 4 | import Mqtt from './lib/Mqtt/index.js'; 5 | import RTL433 from './lib/RTL433/index.js' 6 | 7 | export default class App extends MODULECLASS { 8 | constructor() { 9 | super(); 10 | 11 | global.APP = this; 12 | 13 | return new Config(this) 14 | .then(config => { 15 | global.CONF = config; 16 | global.CONFIG = config.configData; 17 | return new WebServer(this); 18 | }) 19 | .then(webserver => { 20 | global.APP.WEBSERVER = webserver; 21 | return new Mqtt(this); 22 | }) 23 | .then(mqtt => { 24 | global.APP.MQTT = mqtt; 25 | return new RTL433(this); 26 | }) 27 | .then(rtl433 => { 28 | global.APP.RTL433 = rtl433; 29 | return Promise.resolve(this); 30 | }); 31 | } 32 | } -------------------------------------------------------------------------------- /server/config/default.conf.example: -------------------------------------------------------------------------------- 1 | # for development 2 | DEBUG=true 3 | # webserver 4 | SERVER_PORT=3000 5 | # secons to forget a unmapped device 6 | DEVICE_UNMAPPED_AGE_MAX=120 7 | # mqtt broker 8 | MQTT_HOST=CHANGE_ME 9 | MQTT_PORT=1883 10 | MQTT_CLIENT_ID=raspiscan 11 | -------------------------------------------------------------------------------- /server/config/excludes.json.example: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "model": "Toyota" 4 | } 5 | ] -------------------------------------------------------------------------------- /server/config/mapping.json.example: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "topic" : "sensors/room/sleeping/temperature", 4 | "field" : "temperature_C", 5 | "id" : 94, 6 | "channel" : 1, 7 | "protocol" : 91 8 | }, 9 | { 10 | "topic" : "sensors/room/sleeping/humidity", 11 | "field" : "humidity", 12 | "id" : 94, 13 | "channel" : 1, 14 | "protocol" : 91 15 | } 16 | ] -------------------------------------------------------------------------------- /server/config/types.json: -------------------------------------------------------------------------------- 1 | { 2 | "boolean": [ 3 | "DEBUG" 4 | ], 5 | "int": [ 6 | "SERVER_PORT", 7 | "MQTT_PORT" 8 | ] 9 | } 10 | -------------------------------------------------------------------------------- /server/entrypoint.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | set -e 3 | 4 | # Run command with node if the first argument contains a "-" or is not a system command. The last 5 | # part inside the "{}" is a workaround for the following bug in ash/dash: 6 | # https://bugs.debian.org/cgi-bin/bugreport.cgi?bug=874264 7 | if [ "${1#-}" != "${1}" ] || [ -z "$(command -v "${1}")" ] || { [ -f "${1}" ] && ! [ -x "${1}" ]; }; then 8 | set -- node "$@" 9 | fi 10 | 11 | exec "$@" 12 | -------------------------------------------------------------------------------- /server/index.js: -------------------------------------------------------------------------------- 1 | import App from './app.js'; 2 | 3 | new App().then(() => { 4 | LOG(''); 5 | LOG('//////////////////'); 6 | LOG('RUNNING:', PACKAGE.name); 7 | LOG('VERSION:', PACKAGE.version); 8 | LOG('ENVIRONMENT:', ENVIRONMENT); 9 | LOG('/////////'); 10 | LOG(''); 11 | }); 12 | -------------------------------------------------------------------------------- /server/lib/Globals.js: -------------------------------------------------------------------------------- 1 | import '../../shared/lib/Utils.js'; 2 | import Module from '../../shared/lib/Module.js'; 3 | import Package from '../package.json' assert { type: "json" }; 4 | import path from 'path'; 5 | import * as Ramda from 'ramda'; 6 | 7 | !process.env.DEBUG ? global.DEBUG = true : process.env.DEBUG === 'true' ? global.DEBUG = true : global.DEBUG = false; 8 | process.env.ENVIRONMENT ? global.ENVIRONMENT = process.env.ENVIRONMENT : global.ENVIRONMENT = 'default'; 9 | global.APP_DIR = path.resolve(process.env.PWD); 10 | 11 | 12 | console.log(process.env.DEBUG) 13 | import Log from '../../shared/lib/Log.js'; 14 | 15 | global.LOG = new Log().log; 16 | global.ERROR = new Log().error; 17 | 18 | process.on('uncaughtException', error => LOG('ERROR:', error)); 19 | process.on('SIGINT', () => { 20 | try { 21 | // to some global things here 22 | } catch (e) { 23 | // ... 24 | } 25 | // some graceful exit code 26 | setTimeout(() => { 27 | process.exit(0); 28 | }, 500); // wait 2 seconds 29 | }); 30 | process.stdin.resume(); 31 | 32 | global.PACKAGE = Package; 33 | global.R = Ramda; 34 | global.MODULECLASS = Module; 35 | -------------------------------------------------------------------------------- /server/lib/Mqtt/Client.js: -------------------------------------------------------------------------------- 1 | import * as mqtt from "mqtt" 2 | 3 | export default class MqttClient extends MODULECLASS { 4 | constructor(parent) { 5 | super(parent); 6 | 7 | return new Promise((resolve, reject) => { 8 | this.label = 'MQTT CLIENT' 9 | this.url = `mqtt://${MQTT_HOST}:${MQTT_PORT}`; 10 | LOG(this.label, 'INIT ON', this.url, 'AS', `${MQTT_CLIENT_ID}`); 11 | 12 | this.parent = parent; 13 | 14 | this.options = { 15 | connection: { 16 | clientId: `${MQTT_CLIENT_ID}`, 17 | reconnectPeriod: 1000, 18 | connectTimeout: 30 * 1000 19 | //... 20 | } 21 | } 22 | 23 | // events 24 | this.on('connect', () => { 25 | this.publish('app', 'welcome'); 26 | LOG(this.label, 'CONNECTED TO:', this.url, 'AS', `${MQTT_CLIENT_ID}`); 27 | resolve(this); 28 | }); 29 | 30 | this.on('error', error => { 31 | LOG(this.label, 'ERROR', this.url, 'AS', `${MQTT_CLIENT_ID}`); 32 | ERROR(this.label, error, ''); 33 | }); 34 | 35 | this.on('message', (topic, buffer) => { 36 | this.message(topic, buffer); 37 | }); 38 | 39 | // connect finally 40 | this.connect(); 41 | }); 42 | } 43 | 44 | connect() { 45 | // connecting 46 | this.connection = mqtt.connect(this.url, this.options.connection); 47 | 48 | // add events 49 | this.connection.on('connect', () => this.emit('connect')); 50 | this.connection.on('reconnect', () => this.emit('reconnect')); 51 | this.connection.on('close', () => this.emit('close')); 52 | this.connection.on('disconnect', (packet) => this.emit('disconnect', packet)); 53 | this.connection.on('offline', () => this.emit('offline')); 54 | this.connection.on('error', (error) => this.emit('error', error)); 55 | this.connection.on('message', (topic, buffer) => this.emit('message', topic, buffer)); 56 | 57 | // subscribe all topics 58 | // this.subscribe('#'); 59 | } 60 | 61 | subscribe(topic) { 62 | this.connection.subscribe(topic, err => this.error(err)); 63 | } 64 | 65 | publish(topic, data) { 66 | this.connection.publish(topic, data); 67 | } 68 | 69 | message(topic, buffer) { 70 | LOG(this.label, topic.toString(), buffer.toString()); 71 | } 72 | 73 | disconnect() { 74 | this.connection.end(); 75 | } 76 | 77 | reconnect() { 78 | this.connection.reconnect(); 79 | } 80 | 81 | error(err) { 82 | if (err) { 83 | LOG(this.label, err); 84 | } 85 | } 86 | 87 | } -------------------------------------------------------------------------------- /server/lib/Mqtt/index.js: -------------------------------------------------------------------------------- 1 | import MqttClient from './Client.js'; 2 | 3 | export default class Mqtt extends MODULECLASS { 4 | constructor(parent) { 5 | super(parent); 6 | 7 | return new Promise((resolve, reject) => { 8 | this.label = 'MQTT' 9 | LOG(this.label, 'INIT'); 10 | this.parent = parent; 11 | 12 | new MqttClient(this) 13 | .then(client => { 14 | this.client = client; 15 | resolve(this); 16 | }); 17 | }); 18 | } 19 | 20 | publish(topic, data) { 21 | this.client.publish(topic, data); 22 | } 23 | 24 | subscribe(topic) { 25 | this.client.subscribe(topic, err => this.error(err)); 26 | } 27 | } -------------------------------------------------------------------------------- /server/lib/RTL433/Device.js: -------------------------------------------------------------------------------- 1 | import Crypto from 'crypto'; 2 | import DeviceTopic from './DeviceTopic.js'; 3 | 4 | export default class RTL433Device extends MODULECLASS { 5 | constructor(parent, deviceData) { 6 | super(parent); 7 | this.parent = parent; 8 | this.label = 'RTL433 DEVICE'; 9 | 10 | // a device can have multiple topics on different value fields 11 | this.topics = []; 12 | 13 | this.dataSource = deviceData; 14 | this.data = new Proxy(this.dataSource, { 15 | get: (target, prop, receiver) => { 16 | return target[prop] || this.dataSource[prop]; 17 | }, 18 | set: (target, prop, value) => { 19 | if (target[prop] !== value) { 20 | target[prop] = value; 21 | 22 | // emit the prop 23 | this.emit(prop, value); 24 | } 25 | return true; 26 | } 27 | }); 28 | 29 | this.data.hash = `${Crypto.createHash('md5').update(`${this.data.id}${this.data.model}${this.data.channel}${this.data.protocol}${this.data.button}`).digest("hex")}`; 30 | this.time_create = this.data.time; 31 | this.getTopics(); 32 | } 33 | 34 | /** 35 | * find all topics for the device 36 | */ 37 | getTopics() { 38 | this.removeTopics(); 39 | 40 | this.topics = []; 41 | this.topicsMapping.forEach(topic => { 42 | const t = {...topic}; // make a copy 43 | 44 | // without the topic and the value field 45 | delete t.topic; 46 | delete t.field; 47 | 48 | let matchA = '', matchB = ''; 49 | 50 | // all other matching definitions like `id`, `protocol` or `channel` 51 | Object.keys(t).forEach(key => { 52 | matchA += t[key]; 53 | matchB += this.data[key]; 54 | }); 55 | 56 | if (`${matchA}` === `${matchB}`) { 57 | const deviceTopic = new DeviceTopic(this, topic); 58 | this.topics.push(deviceTopic); 59 | } 60 | }); 61 | } 62 | 63 | /** 64 | * remove the existing event listeners for property update 65 | */ 66 | removeTopics() { 67 | this.topics.forEach(topic => { 68 | const field = topic.data.field; 69 | 70 | //const eventNames = this.eventNames(); 71 | //const events = this._events(this); 72 | //LOG(this.label, 'EVENTS', eventNames, events, ''); 73 | 74 | this.removeAllListeners(field); 75 | this.topics = this.topics.filter(t => topic.data.topic !== t.data.topic); 76 | }); 77 | } 78 | 79 | emitInitial() { 80 | // emit on create 81 | Object.keys(this.dataSource).forEach(field => this.emit(field, this.data[field])); 82 | } 83 | 84 | checkExcluded() { 85 | if (this.excludes.hashes.includes(this.data.hash) || this.excludes.models.includes(this.data.model)) { 86 | // remove the device 87 | this.remove(); 88 | } 89 | } 90 | 91 | remove() { 92 | this.removeTopics(); 93 | Object.keys(this.data).forEach(field => this.removeAllListeners(field)); 94 | this.parent.removeDevice(this); 95 | } 96 | 97 | checkAge() { 98 | const create = new Date(this.time_create).getTime(); 99 | const now = Date.now(); 100 | this.data.age = parseInt((now - create) / 1000); 101 | 102 | if (this.topics.length === 0) { 103 | this.data.livetime = (DEVICE_UNMAPPED_AGE_MAX || 120) - this.data.age; 104 | } else { 105 | this.data.livetime = 'always'; 106 | } 107 | 108 | if (this.data.livetime < 0) 109 | this.data.livetime = 0; 110 | 111 | if (this.data.livetime === 0 && this.parent.forget) 112 | this.remove(); 113 | } 114 | 115 | get topicsMapping() { 116 | return this.parent.topics.data; 117 | } 118 | 119 | set topicsMapping(val) { 120 | // nothing 121 | } 122 | 123 | get excludes() { 124 | return this.parent.excludes; 125 | } 126 | 127 | set excludes(val) { 128 | // nothing 129 | } 130 | 131 | 132 | } -------------------------------------------------------------------------------- /server/lib/RTL433/DeviceTopic.js: -------------------------------------------------------------------------------- 1 | export default class RTL433DeviceTopic extends MODULECLASS { 2 | constructor(parent, topicData) { 3 | super(parent); 4 | this.label = 'RTL433 DEVICE TOPIC'; 5 | this.device = parent; 6 | 7 | this.dataSource = topicData; 8 | this.data = new Proxy(this.dataSource, { 9 | get: (target, prop, receiver) => { 10 | return target[prop] || this.dataSource[prop]; 11 | }, 12 | set: (target, prop, value) => { 13 | target[prop] = value; 14 | return true; 15 | } 16 | }); 17 | 18 | this.device.on(this.data.field, value => this.onProperty(value)); 19 | } 20 | 21 | onProperty(value) { 22 | 23 | SERVER_LOG_TOPICS ? LOG(this.label, 'GOT', value, this.data.field, this.data.topic, JSON.stringify(this.device.data)) : null; 24 | this.publish(value); 25 | 26 | /*APP.WEBSERVER.sendWS(JSON.stringify({ 27 | hash: this.device.data.hash, 28 | topic: this.data.topic, 29 | field: this.data.field, 30 | value: value 31 | }));*/ 32 | } 33 | 34 | publish(value) { 35 | APP.MQTT.publish(this.data.topic, `${value}`); 36 | } 37 | } -------------------------------------------------------------------------------- /server/lib/RTL433/Excludes.js: -------------------------------------------------------------------------------- 1 | import fs from 'fs-extra'; 2 | import path from 'path'; 3 | 4 | export default class RTL433Excludes extends MODULECLASS { 5 | constructor(parent, options) { 6 | super(parent); 7 | this.parent = parent; 8 | 9 | this.label = 'RTL433 EXCLUDES'; 10 | LOG(this.label, 'INIT'); 11 | 12 | this.excludesFile = path.resolve(`${CONF.path}/excludes.json`); 13 | this.data = []; 14 | 15 | this.load(); 16 | } 17 | 18 | load() { 19 | LOG(this.label, 'LOAD', this.excludesFile); 20 | return fs.readFile(this.excludesFile, (err, data) => this.data = JSON.parse(data.toString())); 21 | } 22 | 23 | write() { 24 | LOG(this.label, 'WRITE', this.excludesFile); 25 | return fs.writeFile(this.excludesFile, JSON.stringify(this.data), (err, data) => { 26 | }); 27 | } 28 | 29 | add(data) { 30 | return new Promise((resolve, reject) => { 31 | if (data.model) 32 | if (this.models.includes(data.model)) { 33 | resolve(false); 34 | return; 35 | } 36 | 37 | if (data.hash) 38 | if (this.hashes.includes(data.hash)) { 39 | resolve(false); 40 | return; 41 | } 42 | 43 | const newExclude = { 44 | model: data.model, 45 | hash: data.hash 46 | } 47 | this.data.push(newExclude); 48 | this.write(); 49 | 50 | resolve(true); 51 | }); 52 | } 53 | 54 | remove(data) { 55 | return new Promise((resolve, reject) => { 56 | LOG(this.label, 'REMOVE', data, ''); 57 | 58 | this.data = this.data.filter(ex => ex[data.field] !== data.value); 59 | this.write(); 60 | 61 | resolve(true); 62 | }); 63 | } 64 | 65 | contains(device) { 66 | return this.models.includes(device.data.model) || this.hashes.includes(device.data.hash); 67 | } 68 | 69 | get models() { 70 | return this.data.map(ex => ex.model) || []; 71 | } 72 | 73 | set models(val) { 74 | // nothing 75 | } 76 | 77 | get hashes() { 78 | return this.data.map(ex => ex.hash) || []; 79 | } 80 | 81 | set hashes(val) { 82 | // nothing 83 | } 84 | } -------------------------------------------------------------------------------- /server/lib/RTL433/Topics.js: -------------------------------------------------------------------------------- 1 | import fs from 'fs-extra'; 2 | import path from 'path'; 3 | 4 | export default class RTL433Topics extends MODULECLASS { 5 | constructor(parent, options) { 6 | super(parent); 7 | this.parent = parent; 8 | 9 | this.label = 'RTL433 TOPICS'; 10 | LOG(this.label, 'INIT'); 11 | 12 | this.topicsMappingFile = path.resolve(`${CONF.path}/mapping.json`); 13 | this.data = []; 14 | 15 | this.load(); 16 | } 17 | 18 | load() { 19 | LOG(this.label, 'LOAD', this.topicsMappingFile); 20 | return fs.readFile(this.topicsMappingFile, (err, data) => this.data = JSON.parse(data.toString())); 21 | } 22 | 23 | write() { 24 | LOG(this.label, 'WRITE', this.topicsMappingFile); 25 | return fs.writeFile(this.topicsMappingFile, JSON.stringify(this.data), (err, data) => { 26 | }); 27 | } 28 | 29 | add(data) { 30 | return new Promise((resolve, reject) => { 31 | if (`${data.topic}`.trim() === '') { 32 | resolve(false); 33 | return; 34 | } 35 | 36 | const exists = this.data.filter(t => t.topic === data.topic)[0] || false; 37 | if (exists) { 38 | resolve(false); 39 | return; 40 | } 41 | 42 | const newTopic = { 43 | topic: data.topic, 44 | hash: data.hash, 45 | field: data.field 46 | }; 47 | 48 | this.data.push(newTopic); 49 | this.write(); 50 | 51 | this.parent.devices[data.hash].getTopics(); 52 | resolve(true); 53 | }); 54 | } 55 | 56 | remove(topic){ 57 | return new Promise((resolve, reject) => { 58 | if (`${topic}`.trim() === '') { 59 | resolve(false); 60 | return; 61 | } 62 | const exists = this.data.filter(t => t.topic === topic)[0] || false; 63 | if (!exists) { 64 | resolve(false); 65 | return; 66 | } 67 | this.data = this.data.filter(t => t.topic !== topic); 68 | 69 | // @TODO async 70 | this.write(); 71 | this.load(); 72 | 73 | this.parent.getTopics(); 74 | 75 | resolve(true); 76 | }); 77 | } 78 | } -------------------------------------------------------------------------------- /server/lib/RTL433/index.js: -------------------------------------------------------------------------------- 1 | import fs from 'fs-extra'; 2 | import path from 'path'; 3 | import {spawn} from 'child_process'; 4 | import Device from './Device.js'; 5 | import Topics from './Topics.js'; 6 | import Excludes from './Excludes.js'; 7 | 8 | export default class RTL433 extends MODULECLASS { 9 | constructor(parent) { 10 | super(); 11 | return new Promise((resolve, reject) => { 12 | this.debug = DEBUG || false; 13 | this.parent = parent; 14 | 15 | this.label = 'RTL433'; 16 | LOG(this.label, 'INIT'); 17 | 18 | this.bin = '/usr/local/bin/rtl_433'; 19 | this.raw = ''; 20 | 21 | this.on('device-added', device => { 22 | // emit initial data "change" for all data properties 23 | device.emitInitial(); 24 | device.data.count = 1; 25 | device.checkAge(); 26 | //APP.WEBSERVER.sendWS(JSON.stringify(device.data)); 27 | }); 28 | 29 | this.on('device-updated', device => { 30 | device.data.count++; 31 | device.checkAge(); 32 | //console.log(); 33 | //console.log(this.label, 'DEVICES UPDATED:', JSON.stringify(device.data)); 34 | }); 35 | 36 | // 37 | this.topics = new Topics(this); 38 | this.excludes = new Excludes(this); 39 | 40 | this.devicesSource = {}; 41 | this.devices = new Proxy(this.devicesSource, { 42 | 43 | get: (target, prop, receiver) => { 44 | return target[prop]; 45 | }, 46 | 47 | set: (target, prop, device) => { 48 | if (this.excludes.contains(device)) 49 | return true; 50 | 51 | if (device.data.model === undefined) 52 | return true; 53 | 54 | if (!Object.keys(this.devices).includes(prop)) { // add if not exists 55 | target[prop] = device; 56 | this.emit('device-added', device); 57 | } else { 58 | Object.keys(device.data).forEach(field => this.devices[prop].data[field] = device.data[field]); 59 | this.emit('device-updated', this.devices[prop]); 60 | } 61 | return true; 62 | 63 | } 64 | }); 65 | 66 | this.start(); 67 | 68 | // check every second, if an unmapped device is over age 69 | this.forget = true; 70 | this.checkAgeInterval = setInterval(() => this.checkAge(), 1000); 71 | 72 | resolve(this); 73 | }); 74 | } 75 | 76 | keys() { 77 | return Object.keys(this.devices); 78 | } 79 | 80 | start() { 81 | const processOptions = ['-F', 'json']; 82 | LOG(this.label, 'STARTING WITH OPTIONS', JSON.stringify(processOptions)); 83 | this.process = spawn(this.bin, processOptions); 84 | this.process.stdout.setEncoding('utf8'); 85 | this.process.stdout.on('data', chunk => this.parseChunk(chunk)); 86 | } 87 | 88 | parseChunk(chunk) { 89 | this.raw += chunk; 90 | const rowsRaw = this.raw.split("\n"); 91 | rowsRaw.forEach(rowRaw => { 92 | let row = false; 93 | 94 | try { 95 | row = JSON.parse(rowRaw); 96 | } catch (e) { 97 | //console.log('NOT PARSED:', rowRaw); 98 | } 99 | 100 | if (row) { 101 | // create a new device and try to add if not exists. if exists, it will be updated 102 | const device = new Device(this, row); 103 | this.devices[device.data.hash] = device; 104 | 105 | // extract the parsed row from raw data 106 | this.raw = this.raw.replace(`${rowRaw}\n`, ''); 107 | } 108 | }); 109 | } 110 | 111 | 112 | reload() { 113 | this.topics.load(); 114 | this.excludes.load(); 115 | } 116 | 117 | addTopic(data) { 118 | return this.topics.add(data); 119 | } 120 | 121 | removeTopic(topic) { 122 | return this.topics.remove(topic); 123 | } 124 | 125 | getTopics() { 126 | this.keys().forEach(hash => this.devices[hash].getTopics()); 127 | } 128 | 129 | addExclude(data) { 130 | return this.excludes.add(data).then(ok => { 131 | this.removeExcluded(); 132 | return Promise.resolve(ok); 133 | }); 134 | } 135 | 136 | removeExcluded() { 137 | this.keys().forEach(hash => this.devices[hash].checkExcluded()); 138 | } 139 | 140 | removeDevice(device) { 141 | SERVER_LOG_TOPICS ? LOG(this.label, 'DELETED', device.data.model, device.data.hash) : null; 142 | delete this.devices[device.data.hash]; 143 | } 144 | 145 | removeExclude(data) { 146 | return this.excludes.remove(data); 147 | } 148 | 149 | checkAge() { 150 | this.keys().forEach(hash => this.devices[hash].checkAge()); 151 | } 152 | 153 | toggleForget() { 154 | this.forget ? this.forget = false : this.forget = true; 155 | } 156 | } -------------------------------------------------------------------------------- /server/lib/Server/Route.js: -------------------------------------------------------------------------------- 1 | import bodyParser from 'body-parser'; 2 | 3 | export default class Route extends MODULECLASS { 4 | constructor(parent, options) { 5 | super(parent, options); 6 | this.router = EXPRESS.Router(); 7 | 8 | this.jsonParser = bodyParser.json(); 9 | this.urlencodedParser = bodyParser.urlencoded({ extended: false }); 10 | } 11 | 12 | nicePath(path) { 13 | return decodeURI(path).replace(/^\//, '').replace(/\/$/, ''); 14 | } 15 | 16 | extractPath(path, subtract) { 17 | return this.nicePath(path).replace(new RegExp(`${subtract}`, ''), '').split('/'); 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /server/lib/Server/base64.js: -------------------------------------------------------------------------------- 1 | export default 'U2NoZWlzcyBkaWUgV2FuZCBhbg=='; -------------------------------------------------------------------------------- /server/lib/Server/index.js: -------------------------------------------------------------------------------- 1 | import express from 'express'; 2 | import path from 'path'; 3 | import fs from 'fs-extra'; 4 | import expressWs from 'express-ws'; 5 | import * as Routes from './routes/index.js'; 6 | 7 | export default class WebServer extends MODULECLASS { 8 | constructor(parent) { 9 | super(parent); 10 | 11 | return new Promise((resolve, reject) => { 12 | this.label = 'WEBSERVER'; 13 | LOG(this.label, 'INIT'); 14 | 15 | this.parent = parent; 16 | this.port = SERVER_PORT || 3000; 17 | 18 | // the websocket connections (not in use) 19 | this.wsConnections = []; 20 | 21 | NODE_ENV === 'production' ? this.env = 'prod' : this.env = 'dev'; 22 | 23 | // set the frontend statics source path 24 | this.documentRoot = path.resolve(`${APP_DIR}/${SERVER_FRONTEND_PATH}`); 25 | 26 | // set express globally 27 | global.EXPRESS = express; 28 | 29 | this.create().then(() => resolve(this)); 30 | 31 | }); 32 | } 33 | 34 | create() { 35 | this.engine = EXPRESS(); 36 | this.ws = expressWs(this.engine); 37 | 38 | // websocket connection 39 | this.engine.ws('/live', (ws, req) => { 40 | 41 | this.wsConnections.push(ws); 42 | 43 | ws.on('message', msg => { 44 | LOG(this.label, 'WEBSOCKET MESSAGE INCOME', msg); 45 | //ws.send(msg); 46 | }); 47 | }); 48 | 49 | // statics 50 | LOG(this.label, 'STATICS FOLDER', this.documentRoot); 51 | this.engine.use(express.static(this.documentRoot)); 52 | 53 | 54 | // favicon 55 | this.engine.get('/favicon.ico', (req, res) => { 56 | if (fs.existsSync(icon)) { 57 | res.setHeader('Content-Type', 'application/json'); 58 | res.sendFile(icon); 59 | } else { 60 | res.end(); 61 | } 62 | }); 63 | 64 | // the routes 65 | Object.keys(Routes).forEach(route => this.engine.use(`/api/`, new Routes[route](this))); 66 | 67 | // start 68 | return new Promise((resolve, reject) => { 69 | this.engine.listen(this.port, () => { 70 | LOG(this.label, 'IS LISTENING ON PORT:', this.port); 71 | resolve(); 72 | }); 73 | }); 74 | 75 | } 76 | 77 | sendWS(msg) { 78 | this.wsConnections.forEach(ws => ws.send(msg)); 79 | } 80 | } -------------------------------------------------------------------------------- /server/lib/Server/routes/Device.js: -------------------------------------------------------------------------------- 1 | import Route from '../Route.js'; 2 | 3 | export default class extends Route { 4 | constructor(parent, options) { 5 | super(parent, options); 6 | 7 | this.router.post('/device/exclude', this.jsonParser); 8 | this.router.post('/device/exclude', (req, res) => { 9 | const params = req.body; 10 | 11 | APP.RTL433.addExclude(params).then(data => { 12 | res.json({ 13 | message: 'exclude device', 14 | data: data 15 | }); 16 | }); 17 | }); 18 | 19 | 20 | return this.router; 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /server/lib/Server/routes/Devices.js: -------------------------------------------------------------------------------- 1 | import Route from '../Route.js'; 2 | 3 | export default class extends Route { 4 | constructor(parent, options) { 5 | super(parent, options); 6 | 7 | this.router.get('/devices', (req, res) => { 8 | const devices = Object.keys(APP.RTL433.devices).map(hash => { 9 | return { 10 | ...APP.RTL433.devices[hash].data, 11 | topics: APP.RTL433.devices[hash].topics.map(t => { 12 | return { 13 | topic: t.data.topic, 14 | field: t.data.field 15 | } 16 | }) 17 | }; 18 | }); 19 | res.json({ 20 | message: 'all devices', 21 | data: devices 22 | }); 23 | }); 24 | 25 | this.router.get('/devices/reload', (req, res) => { 26 | APP.RTL433.reload(); 27 | 28 | res.json({ 29 | message: 'reload', 30 | data: {} 31 | }); 32 | }); 33 | 34 | this.router.get('/devices/forget', (req, res) => { 35 | APP.RTL433.toggleForget(); 36 | 37 | res.json({ 38 | message: 'reload', 39 | data: APP.RTL433.forget 40 | }); 41 | }); 42 | 43 | return this.router; 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /server/lib/Server/routes/Exclude.js: -------------------------------------------------------------------------------- 1 | import Route from '../Route.js'; 2 | 3 | export default class extends Route { 4 | constructor(parent, options) { 5 | super(parent, options); 6 | 7 | this.router.post('/exclude/remove', this.jsonParser); 8 | this.router.post('/exclude/remove', (req, res) => { 9 | const params = req.body; 10 | 11 | APP.RTL433.removeExclude(params).then(data => { 12 | res.json({ 13 | message: 'remve exclude', 14 | data: data 15 | }); 16 | }); 17 | }); 18 | 19 | return this.router; 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /server/lib/Server/routes/Excludes.js: -------------------------------------------------------------------------------- 1 | import Route from '../Route.js'; 2 | 3 | export default class extends Route { 4 | constructor(parent, options) { 5 | super(parent, options); 6 | 7 | this.router.get('/excludes', (req, res) => { 8 | const topics = APP.RTL433.excludes.data; 9 | res.json({ 10 | message: 'all excludes', 11 | data: topics 12 | }); 13 | }); 14 | 15 | return this.router; 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /server/lib/Server/routes/Home.js: -------------------------------------------------------------------------------- 1 | import Route from '../Route.js'; 2 | 3 | export default class extends Route { 4 | constructor(parent, options) { 5 | super(parent, options); 6 | 7 | this.router.get('/', (req, res) => { 8 | res.json({ 9 | home: "test" 10 | }); 11 | }); 12 | 13 | return this.router; 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /server/lib/Server/routes/Topic.js: -------------------------------------------------------------------------------- 1 | import Route from '../Route.js'; 2 | 3 | export default class extends Route { 4 | constructor(parent, options) { 5 | super(parent, options); 6 | 7 | this.router.post('/topic/add', this.jsonParser); 8 | this.router.post('/topic/add', (req, res) => { 9 | const params = req.body; 10 | 11 | APP.RTL433.addTopic(params).then(data => { 12 | res.json({ 13 | message: 'add topic', 14 | data: data 15 | }); 16 | }); 17 | 18 | }); 19 | 20 | this.router.post('/topic/remove', this.jsonParser); 21 | this.router.post('/topic/remove', (req, res) => { 22 | const topic = req.body.topic; 23 | 24 | APP.RTL433.removeTopic(topic).then(data => { 25 | res.json({ 26 | message: 'remove topic', 27 | data: data 28 | }); 29 | }); 30 | 31 | }); 32 | 33 | return this.router; 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /server/lib/Server/routes/Topics.js: -------------------------------------------------------------------------------- 1 | import Route from '../Route.js'; 2 | 3 | export default class extends Route { 4 | constructor(parent, options) { 5 | super(parent, options); 6 | 7 | this.router.get('/topics', (req, res) => { 8 | const topics = APP.RTL433.topics.data; 9 | res.json({ 10 | message: 'all topics', 11 | data: topics 12 | }); 13 | }); 14 | 15 | return this.router; 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /server/lib/Server/routes/index.js: -------------------------------------------------------------------------------- 1 | import HomeRoutes from './Home.js'; 2 | import DevicesRoutes from './Devices.js'; 3 | import DeviceRoutes from './Device.js'; 4 | import TopicRoutes from './Topic.js'; 5 | import TopicsRoutes from './Topics.js'; 6 | import ExcludeRoutes from './Exclude.js'; 7 | import ExcludesRoutes from './Excludes.js'; 8 | 9 | export { 10 | HomeRoutes, 11 | DeviceRoutes, 12 | DevicesRoutes, 13 | TopicRoutes, 14 | TopicsRoutes, 15 | ExcludeRoutes, 16 | ExcludesRoutes 17 | }; 18 | -------------------------------------------------------------------------------- /server/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name" : "raspiscan_server", 3 | "version" : "0.0.1", 4 | "description" : "", 5 | "main" : "index.js", 6 | "type" : "module", 7 | "scripts" : { 8 | "start" : "node --experimental-modules --experimental-json-modules index.js", 9 | "build" : "npm run build:arm", 10 | "babelize" : "node --experimental-modules --experimental-json-modules config/webpack-app-pkg.config.js", 11 | "build:arm" : "npm run babelize && node node_modules/pkg/lib-es5/bin.js dist/app.js --output server-arm64 --targets node16-linux-arm64", 12 | "build:linux" : "npm run babelize && node node_modules/pkg/lib-es5/bin.js dist/app.js --output server-linux64 --targets node14-linux-x64", 13 | "build:win" : "npm run babelize && node node_modules/pkg/lib-es5/bin.js dist/app.js --output server-win64 --targets node14-win-x64" 14 | }, 15 | "author" : "", 16 | "license" : "ISC", 17 | "pkg" : { 18 | "scripts" : "dist/app.js", 19 | "assets" : "", 20 | "targets" : [ 21 | "node16-alpine-armv7" 22 | ], 23 | "outputPath" : "./" 24 | }, 25 | "compilerOptions" : { 26 | "target" : "es2015", 27 | "moduleResolution" : "node" 28 | }, 29 | "dependencies" : { 30 | "dateformat" : "^5.0.2", 31 | "dotenv" : "^16.0.1", 32 | "express" : "^4.17.3", 33 | "express-ws" : "^5.0.2", 34 | "form-data" : "^4.0.0", 35 | "fs-extra" : "^10.0.0", 36 | "got" : "^12.0.0", 37 | "mqtt" : "^4.3.7", 38 | "pkg" : "^5.5.1", 39 | "ramda" : "^0.28.0", 40 | "ws" : "^8.3.0" 41 | }, 42 | "devDependencies" : { 43 | "@babel/core" : "^7.16.5", 44 | "@babel/plugin-proposal-object-rest-spread" : "^7.16.5", 45 | "@babel/plugin-syntax-top-level-await" : "^7.14.5", 46 | "@babel/plugin-transform-regenerator" : "^7.16.5", 47 | "@babel/plugin-transform-runtime" : "^7.16.5", 48 | "@babel/preset-env" : "^7.16.5", 49 | "babel-loader" : "^8.2.3", 50 | "webpack" : "^5.65.0", 51 | "webpack-cli" : "^4.9.1" 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /server/testing.js: -------------------------------------------------------------------------------- 1 | import {spawn} from 'child_process'; 2 | 3 | const bin = '/usr/local/bin/rtl_433'; 4 | const processOptions = ['-F', 'json']; 5 | const process = spawn(bin, processOptions); 6 | 7 | let raw = ''; 8 | const rows = []; 9 | 10 | process.stdout.setEncoding('utf8'); 11 | process.stdout.on('data', chunk => { 12 | raw += chunk; 13 | raw = parseRaw(raw); 14 | console.log('RAW EXTRACTED REST:', raw.length, raw ); 15 | }); 16 | 17 | const parseRaw = (raw) => { 18 | const rowsRaw = raw.split("\n"); 19 | 20 | console.log(); 21 | console.log('>>>> PARSE RAW', rowsRaw.length); 22 | 23 | rowsRaw.forEach(rowRaw => { 24 | try { 25 | const row = JSON.parse(rowRaw); 26 | addRow(row); 27 | raw = raw.replace(`${rowRaw}\n`, ''); // extract the parsed row from raw data 28 | } catch (e) { 29 | console.log('NOT PARSED:', rowRaw); 30 | } 31 | }); 32 | return raw; 33 | } 34 | 35 | const addRow = (row) => { 36 | console.log('> ROW:', JSON.stringify(row)); 37 | } 38 | -------------------------------------------------------------------------------- /server/webpack-app-pkg.config.js: -------------------------------------------------------------------------------- 1 | import webpack from 'webpack'; 2 | import path from "path"; 3 | 4 | const config = { 5 | target: "node", 6 | mode: 'production', 7 | entry: './index.js', 8 | output: { 9 | filename: 'dist/app.js', 10 | path: path.resolve(process.env.PWD), 11 | publicPath: '/', 12 | }, 13 | node: { 14 | __dirname: false, 15 | __filename: false 16 | }, 17 | experiments: { 18 | topLevelAwait: true, 19 | }, 20 | plugins: [ 21 | new webpack.DefinePlugin({ "global.GENTLY": false }), // hack for formidable 22 | { 23 | apply: (compiler) => { 24 | compiler.hooks.afterEmit.tap('Complete', (compilation) => { 25 | console.log('>>> BUNDLING COMPLETE'); 26 | }); 27 | } 28 | } 29 | ], 30 | module: { 31 | rules: [ 32 | { 33 | test: /\.js$/, 34 | exclude: /(node_modules|bower_components)/, 35 | use: { 36 | loader: "babel-loader", 37 | options: { 38 | presets: ["@babel/preset-env"], 39 | } 40 | } 41 | } 42 | ] 43 | } 44 | }; 45 | 46 | const bundler = webpack(config, (err, stats) => { 47 | if (err || stats.hasErrors()) { 48 | console.log('>>> ERROR: ', err, stats.compilation); 49 | } 50 | }); 51 | -------------------------------------------------------------------------------- /setup.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # 4 | # run this script on the raspberry pi directly 5 | # 6 | 7 | # load .env file 8 | loadConfig() { 9 | DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" 10 | export $(egrep -v '^#' "${DIR}/.env" | xargs) 11 | } 12 | 13 | # update the host system 14 | update() { 15 | sudo apt-get update -y 16 | sudo apt-get upgrade -y 17 | } 18 | 19 | # docker installation 20 | installDocker() { 21 | sudo apt-get remove docker docker-engine docker.io containerd runc -y 22 | sudo apt-get update -y 23 | sudo curl -sSL https://get.docker.com | sh 24 | sudo usermod -a -G docker pi 25 | } 26 | 27 | installDockerCompose() { 28 | sudo curl -L "${DOCKER_COMPOSE_SOURCE}" -o "/usr/bin/docker-compose" 29 | sudo chmod +x /usr/bin/docker-compose 30 | } 31 | 32 | # create docker volumes 33 | createVolumes() { 34 | echo "" 35 | echo "Creating docker volumes" 36 | echo "" 37 | 38 | docker volume create ${PROJECT_NAME}_tmp 39 | } 40 | 41 | 42 | 43 | #---------------------------------------------------------------------------------------------------------------------- 44 | 45 | loadConfig 46 | 47 | echo "" 48 | echo "Update system?" 49 | select yn in "Yes" "No"; do 50 | case $yn in 51 | Yes ) update; break;; 52 | No ) break;; 53 | esac 54 | done 55 | 56 | echo "" 57 | echo "Install Docker?" 58 | select yn in "Yes" "No"; do 59 | case $yn in 60 | Yes ) installDocker; break;; 61 | No ) break;; 62 | esac 63 | done 64 | 65 | echo "" 66 | echo "Install Docker Compose?" 67 | select yn in "Yes" "No"; do 68 | case $yn in 69 | Yes ) installDockerCompose; break;; 70 | No ) break;; 71 | esac 72 | done 73 | 74 | echo "" 75 | echo "Create Docker Volumes?" 76 | select yn in "Yes" "No"; do 77 | case $yn in 78 | Yes ) createVolumes; break;; 79 | No ) break;; 80 | esac 81 | done 82 | 83 | echo "" -------------------------------------------------------------------------------- /setupBuildx.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # 4 | # run this script on a docker host, 5 | # who can use buildx. not the raspberry pi. 6 | # 7 | 8 | # load .env file 9 | loadConfig() { 10 | DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" 11 | export $(egrep -v '^#' "${DIR}/.env" | xargs) 12 | } 13 | 14 | setup() { 15 | docker run --rm --privileged multiarch/qemu-user-static:${DOCKER_QEMU_VERSION} --reset -p yes 16 | docker buildx create --name=${DOCKER_BUILDX_NAME} 17 | } 18 | 19 | loadConfig 20 | setup 21 | -------------------------------------------------------------------------------- /shared/lib/Config.js: -------------------------------------------------------------------------------- 1 | import Module from './Module.js'; 2 | import fs from 'fs-extra'; 3 | import path from 'path'; 4 | import dotenv from 'dotenv'; 5 | 6 | export default class Config extends Module { 7 | constructor() { 8 | super(); 9 | 10 | return new Promise((resolve, reject) => { 11 | this.label = 'CONFIG'; 12 | LOG(this.label, 'INIT'); 13 | 14 | try { 15 | this.configDir = SERVER_CONFIG_DIR; 16 | } catch(e) { 17 | this.configDir = 'config'; 18 | } 19 | 20 | this.path = path.resolve(`${APP_DIR}/${this.configDir}`); 21 | 22 | this.typesFile = `${this.path}/types.json`; 23 | this.typeDefinitions = fs.readJsonSync(this.typesFile); 24 | this.flattenTypes(); 25 | 26 | this.configFile = `${this.path}/${ENVIRONMENT}.conf`; 27 | this.envFile = `${path.resolve(`${APP_DIR}/..`)}/.env`; 28 | 29 | this.on('loaded', () => { 30 | LOG(this.label, 'LOADING COMPLETE', {verbose: 2}); 31 | }); 32 | 33 | this.data = { 34 | env: {}, 35 | envFile: {}, 36 | configFile: {} 37 | }; 38 | 39 | this.configData = new Proxy({}, { 40 | get: (target, prop, receiver) => { 41 | return this.convertTypeRead(target[prop] || this.data.env[prop] || this.data.configFile[prop] || this.data.envFile[prop], prop); 42 | }, 43 | set: (target, prop, value) => { 44 | target[prop] = this.convertTypeWrite(value, prop); 45 | return true; 46 | } 47 | }); 48 | 49 | this.load().then(success => { 50 | // map the config data into the global scope 51 | // @TODO - filter allowed 52 | this.properties.forEach(prop => global[prop] = this.configData[prop]); 53 | 54 | resolve(this); 55 | }); 56 | }); 57 | } 58 | 59 | load() { 60 | this.data.env = process.env; 61 | 62 | return this 63 | .loadConfigFile(this.configFile) 64 | .then(data => { 65 | this.data.configFile = data; 66 | return this.loadConfigFile(this.envFile) 67 | }) 68 | .then(data => { 69 | this.data.envFile = data; 70 | return Promise.resolve(true); 71 | }); 72 | } 73 | 74 | loadConfigFile(configFile) { 75 | return fs.readFile(configFile) 76 | .then(configData => dotenv.parse(configData)) 77 | .catch(err => { 78 | ERROR(this.label, err); 79 | }); 80 | } 81 | 82 | reload() { 83 | return this.load(); 84 | } 85 | 86 | /** 87 | * expand comma separated values to an array 88 | * @TODO unused 89 | */ 90 | expandArrays() { 91 | const envKeys = Object.keys(this.configData); 92 | envKeys.forEach(k => { 93 | const split = this.configData[k].split(','); 94 | if (split.length > 1) { 95 | const arrayData = []; 96 | split.forEach(s => arrayData.push(s.trim())); 97 | this.configData[k] = arrayData; 98 | } 99 | }); 100 | } 101 | 102 | flattenTypes() { 103 | this.types = {}; 104 | Object.keys(this.typeDefinitions).forEach(type => { 105 | this.typeDefinitions[type].forEach(prop => { 106 | this.types[prop] = type; 107 | }); 108 | }); 109 | } 110 | 111 | convertTypeRead(value, property) { 112 | if (value === undefined) 113 | return; 114 | 115 | const type = this.types[property]; 116 | 117 | if (value.toLowerCase) { 118 | if (value.toLowerCase() === 'true' || value.toLowerCase() === 'yes') { 119 | return true; 120 | } 121 | if (value.toLowerCase() === 'false' || value.toLowerCase() === 'no') { 122 | return false; 123 | } 124 | } 125 | 126 | if (type === 'boolean') { 127 | if (value === '1') { 128 | return true; 129 | } 130 | if (value === '0') { 131 | return false; 132 | } 133 | } 134 | 135 | if (type === 'int') 136 | return parseInt(value); 137 | 138 | return value; 139 | } 140 | 141 | convertTypeWrite(value, property) { 142 | const type = this.types[property]; 143 | 144 | if (type === 'boolean') { 145 | if (value === true || value === 'true') { 146 | return '1'; 147 | } 148 | if (value === false || value === 'false') { 149 | return '0'; 150 | } 151 | } 152 | 153 | if (type === 'int') 154 | return value.toString(); 155 | 156 | return value; 157 | } 158 | 159 | get properties() { 160 | const props = []; 161 | Object.keys(this.data).forEach(key => Object.keys(this.data[key]).forEach(prop => !props.includes(prop) ? props.push(prop) : null)) 162 | props.sort(); 163 | return props; 164 | } 165 | 166 | set properties(val) { 167 | 168 | } 169 | 170 | } 171 | -------------------------------------------------------------------------------- /shared/lib/Events.js: -------------------------------------------------------------------------------- 1 | import {EventEmitter} from 'events'; 2 | 3 | export default class Events { 4 | constructor(parent, options) { 5 | this.event = new EventEmitter(); 6 | } 7 | 8 | on() { 9 | this.event.on.apply(this.event, Array.from(arguments)); 10 | } 11 | 12 | emit() { 13 | this.event.emit.apply(this.event, Array.from(arguments)); 14 | } 15 | 16 | removeListener() { 17 | this.event.removeListener.apply(this.event, Array.from(arguments)); 18 | } 19 | 20 | removeAllListeners() { 21 | this.event.removeAllListeners.apply(this.event, Array.from(arguments)); 22 | } 23 | 24 | eventNames() { 25 | return this.event.eventNames.apply(this.event, Array.from(arguments)); 26 | } 27 | 28 | _events() { 29 | return this.event._events; 30 | } 31 | 32 | listeners() { 33 | return this.event.listeners.apply(this.event, Array.from(arguments)); 34 | } 35 | } -------------------------------------------------------------------------------- /shared/lib/Log.js: -------------------------------------------------------------------------------- 1 | import dateFormat from 'dateformat'; 2 | import Module from './Module.js'; 3 | 4 | export default class Log extends Module { 5 | constructor(args) { 6 | super(args); 7 | this.label = 'LOGGER'; 8 | } 9 | 10 | log() { 11 | if (global.DEBUG === false) { 12 | return false; 13 | } 14 | let output = [ 15 | '[', 16 | dateFormat(new Date(), "H:MM:ss - d.m.yyyy"), 17 | ']' 18 | ].concat(Array.from(arguments)); 19 | console.log.apply(console, output); 20 | } 21 | 22 | error() { 23 | if (global.DEBUG === false) { 24 | return false; 25 | } 26 | let output = [ 27 | '[', 28 | dateFormat(new Date(), "H:MM:ss - d.m.yyyy"), 29 | ']' 30 | ].concat(Array.from(arguments)); 31 | console.error.apply(console, output); 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /shared/lib/Module.js: -------------------------------------------------------------------------------- 1 | import Events from './Events.js'; 2 | import Crypto from 'crypto'; 3 | import {spawn} from 'child_process'; 4 | 5 | export default class Module extends Events { 6 | constructor(parent, options) { 7 | super(); 8 | this.items = []; 9 | parent ? this.parent = parent : null; 10 | this.parent ? this.parent.app ? this.app = this.parent.app : null : null; 11 | this.id = `${Crypto.createHash('md5').update(`${Date.now()}`).digest("hex")}`; // @TODO random hash 12 | } 13 | 14 | /** 15 | * get one, the first item by: 16 | * 17 | * @param match 18 | * @param field 19 | * @param not 20 | * @returns {*} 21 | */ 22 | one(match, field, not) { 23 | return this.get(match, field, not)[0]; 24 | } 25 | 26 | /** 27 | * get many items by: 28 | * 29 | * @param match 30 | * @param field 31 | * @param not 32 | * @returns {*[]} 33 | */ 34 | many(match, field, not) { 35 | return this.get(match, field, not); 36 | } 37 | 38 | /** 39 | * get some items by: 40 | * 41 | * @param match 42 | * @param field 43 | * @param not 44 | * @returns {*[]} 45 | */ 46 | get(match, field, not) { 47 | !field ? field = 'id' : null; 48 | return this.items.filter(item => { 49 | if (item['field'] === match) { 50 | return not !== item['field']; 51 | } 52 | }); 53 | } 54 | 55 | /** 56 | * 57 | * @param bin 58 | * @param params 59 | * @returns {Promise} 60 | */ 61 | command(bin, params) { 62 | return new Promise((resolve, reject) => { 63 | LOG('MODULE COMMAND()', bin, JSON.stringify(params)); 64 | let data = ''; 65 | const process = spawn(bin, params); 66 | process.stdout.on('data', chunk => data += chunk); 67 | process.stdout.on('end', () => resolve(data)); 68 | }); 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /shared/lib/Utils.js: -------------------------------------------------------------------------------- 1 | import path from 'path'; 2 | import fs from 'fs-extra'; 3 | 4 | global.P = (dir, rootDir) => { 5 | if (dir.substring(0, 1) === '/') { 6 | return path.resolve(dir); 7 | } else { 8 | if (rootDir) { 9 | return path.resolve(`${rootDir}/${dir}`); 10 | } else { 11 | return path.resolve(`${APP_DIR}/${dir}`); 12 | } 13 | 14 | } 15 | }; 16 | 17 | global.PROP = (target, field, options) => { 18 | Object.defineProperty(target, field, { 19 | ...{ 20 | enumerable: false, 21 | configurable: false, 22 | writable: true, 23 | value: false 24 | }, 25 | ...options 26 | }); 27 | }; 28 | 29 | global.FOLDERSYNC = function (folder) { 30 | if (fs.existsSync(folder)) { 31 | const dir = fs.readdirSync(folder); 32 | return dir; 33 | } 34 | }; 35 | 36 | global.ksortObjArray = (array, key) => { 37 | const compare = (a, b) => { 38 | let comparison = 0; 39 | if (a[key] > b[key]) { 40 | comparison = 1; 41 | } else if (a[key] < b[key]) { 42 | comparison = -1; 43 | } 44 | return comparison; 45 | }; 46 | return array.sort(compare); 47 | }; 48 | -------------------------------------------------------------------------------- /shared/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "node-rtl433-mqtt-shared", 3 | "version": "1.0.0", 4 | "description": "", 5 | "main": "index.js", 6 | "type": "module", 7 | "scripts": { 8 | "test": "echo \"Error: no test specified\" && exit 1" 9 | }, 10 | "author": "", 11 | "license": "ISC", 12 | "dependencies": { 13 | "dateformat": "^5.0.2", 14 | "dotenv": "^10.0.0", 15 | "exif": "^0.6.0", 16 | "fs-extra": "^10.0.0", 17 | "ini": "^3.0.0" 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /zigbee2mqtt/configuration.yaml.example: -------------------------------------------------------------------------------- 1 | # Let new devices join our zigbee network 2 | permit_join: true 3 | 4 | mqtt: 5 | base_topic: zigbee 6 | server: mqtt://192.168.xxx.xxx:1886 7 | client_id: change_my_name 8 | version: 5 9 | 10 | serial: 11 | port: /dev/ttyUSB0 12 | # adapter: ezsp 13 | 14 | frontend: 15 | port: 7080 16 | host: 0.0.0.0 17 | 18 | advanced: 19 | # network_key: GENERATE 20 | output: attribute 21 | log_level: debug --------------------------------------------------------------------------------