├── .devcontainer ├── Dockerfile ├── devcontainer.json ├── docker-compose.yml ├── mosquitoMqttConfig │ ├── mosquitto.conf │ └── mosquitto.passwd ├── settings.json └── triggers.json ├── .dockerignore ├── .eslintrc.json ├── .gitattributes ├── .github ├── FUNDING.yml ├── ISSUE_TEMPLATE │ ├── bug_report.md │ ├── feature_request.md │ └── support-request.md ├── dependabot.yml ├── pull_request_template.md └── workflows │ ├── codeql-analysis.yml │ ├── dockerimage.yml │ ├── nodejs.yml │ └── release.yml ├── .gitignore ├── .markdownlint.json ├── .prettierrc ├── .vscode ├── launch.json ├── settings.json └── tasks.json ├── CHANGELOG.md ├── CONTRIBUTING.md ├── COPYRIGHT_TEMPLATE ├── Dockerfile ├── FONT_LICENSE ├── LICENSE ├── README.md ├── docs ├── .gitignore └── _config.yml ├── fonts └── CascadiaCode.ttf ├── jest.config.js ├── package-lock.json ├── package.json ├── sampleConfiguration ├── aiinput │ ├── Cat_20200523-074700.jpg │ ├── Cat_20200523-075000.jpg │ ├── Cat_20200523-075100.jpg │ ├── Dog_20200523-075000.jpg │ └── dogcamera │ │ └── Dog_20200523-075000.jpg ├── docker-compose.yml ├── settings.json └── triggers.json ├── src ├── DeepStack.ts ├── LocalStorageManager.ts ├── Log.ts ├── MqttRouter.ts ├── MustacheFormatter.ts ├── Rect.ts ├── Settings.ts ├── Trigger.ts ├── TriggerManager.ts ├── WebServer.ts ├── controllers │ ├── motion.ts │ └── statistics.ts ├── handlers │ ├── annotationManager │ │ └── AnnotationManager.ts │ ├── mqttManager │ │ ├── IMqttHandlerConfigJson.ts │ │ ├── IMqttManagerConfigJson.ts │ │ ├── IMqttMessageConfigJson.ts │ │ ├── MqttHandlerConfig.ts │ │ ├── MqttManager.ts │ │ └── MqttMessageConfig.ts │ ├── pushbulletManager │ │ ├── IPushbulletManagerConfigJson.ts │ │ ├── IPushoverConfigJson.ts │ │ ├── PushbulletConfig.ts │ │ └── PushbulletManager.ts │ ├── pushoverManager │ │ ├── IPushoverConfigJson.ts │ │ ├── IPushoverManagerConfigJson.ts │ │ ├── PushoverConfig.ts │ │ └── PushoverManager.ts │ ├── telegramManager │ │ ├── ITelegramConfigJson.ts │ │ ├── ITelegramManagerConfigJson.ts │ │ ├── TelegramConfig.ts │ │ └── TelegramManager.ts │ └── webRequest │ │ ├── IWebRequestHandlerJson.ts │ │ ├── WebRequestConfig.ts │ │ └── WebRequestHandler.ts ├── helpers.ts ├── main.ts ├── pushbulletClient │ ├── IUploadRequestResponse.ts │ ├── PushbulletClient.ts │ └── PushbulletMessage.ts ├── pushoverClient │ ├── PushoverClient.ts │ └── PushoverMessage.ts ├── routes │ ├── motion.ts │ └── statistics.ts ├── schemaValidator.ts ├── schemas │ ├── maskConfiguration.schema.json │ ├── mqttHandlerConfiguration.schema.json │ ├── mqttManagerConfiguration.schema.json │ ├── pushbulletHandlerConfiguration.schema.json │ ├── pushbulletManagerConfiguration.schema.json │ ├── pushoverHandlerConfiguration.schema.json │ ├── pushoverManagerConfiguration.schema.json │ ├── settings.schema.json │ ├── telegramHandlerConfiguration.schema.json │ ├── telegramManagerConfiguration.schema.json │ ├── triggerConfiguration.schema.json │ └── webRequestHandlerConfig.schema.json ├── types │ ├── IConfiguration.ts │ ├── IDeepStackPrediction.ts │ ├── IDeepStackResponse.ts │ ├── ISettingsConfigJson.ts │ ├── ITriggerConfigJson.ts │ ├── ITriggerJson.ts │ └── ITriggerStatistics.ts └── typings │ └── pureimage.d.ts ├── tests ├── Rect.test.ts ├── Trigger.test.ts ├── handlers │ ├── MqttConfig.test.ts │ ├── TelegramConfig.test.ts │ └── WebRequestConfig.test.ts ├── helpers.test.ts ├── jest.setup.ts └── triggers.json ├── tsconfig.json └── webpack.config.js /.devcontainer/Dockerfile: -------------------------------------------------------------------------------- 1 | # Install necessary additional things via a Dockerfile so they are there before VSCode tries to do things 2 | # This is most important for git: if git is installed via postCreateCommand it actually isn't there in 3 | # time for first container creation. VSCode will attempt to copy the .gitconfig file and fail 4 | # because git isn't installed 5 | FROM node:12 6 | 7 | ARG NPM_GLOBAL=/usr/local/share/npm-global 8 | ARG USERNAME=node 9 | 10 | RUN apt-get update && apt-get install -y git 11 | 12 | # Set up the npm install directory so it works with the "node" user 13 | RUN mkdir -p ${NPM_GLOBAL} \ 14 | && chown ${USERNAME}:root ${NPM_GLOBAL} \ 15 | && npm config -g set prefix ${NPM_GLOBAL} 16 | 17 | # Create the vscode workspace and .git folder and set the owner to 18 | # the node user so git commands work 19 | # Create the local storage folder and set ownership of it so once a volume 20 | # is mounted there the permissions are correct 21 | RUN mkdir -p /workspace/.git \ 22 | && mkdir -p /node-deepstackai-trigger \ 23 | && chown -R ${USERNAME}:${USERNAME} /workspace/.git \ 24 | && chown -R ${USERNAME}:${USERNAME} /node-deepstackai-trigger 25 | 26 | ENV CHOKIDAR_USEPOLLING=true 27 | USER node -------------------------------------------------------------------------------- /.devcontainer/devcontainer.json: -------------------------------------------------------------------------------- 1 | // For format details, see https://aka.ms/vscode-remote/devcontainer.json or this file's README at: 2 | // https://github.com/microsoft/vscode-dev-containers/tree/v0.117.1/containers/docker-existing-docker-compose 3 | // If you want to run as a non-root user in the container, see .devcontainer/docker-compose.yml. 4 | { 5 | "name": "Trigger", 6 | 7 | // Update the 'dockerComposeFile' list if you have more compose files or use different names. 8 | // The .devcontainer/docker-compose.yml file contains any overrides you need/want to make. 9 | "dockerComposeFile": ["docker-compose.yml"], 10 | 11 | // The 'service' property is the name of the service for the container that VS Code should 12 | // use. Update this value and .devcontainer/docker-compose.yml to the real service name. 13 | "service": "trigger", 14 | 15 | // The optional 'workspaceFolder' property is the path VS Code should open by default when 16 | // connected. This is typically a file mount in .devcontainer/docker-compose.yml 17 | "workspaceFolder": "/workspace", 18 | 19 | // Set *default* container specific settings.json values on container create. 20 | "settings": { 21 | "terminal.integrated.shell.linux": null, 22 | "editor.tabSize": 2, 23 | "editor.formatOnSave": true, 24 | "importSorter.generalConfiguration.sortOnBeforeSave": true 25 | }, 26 | 27 | // Add the IDs of extensions you want installed when the container is created. 28 | "extensions": [ 29 | "dbaeumer.vscode-eslint", 30 | "davidanson.vscode-markdownlint", 31 | "esbenp.prettier-vscode", 32 | "github.vscode-pull-request-github", 33 | "streetsidesoftware.code-spell-checker" 34 | ], 35 | 36 | // Use 'forwardPorts' to make a list of ports inside the container available locally. 37 | // "forwardPorts": [] 38 | 39 | // Uncomment the next line if you want start specific services in your Docker Compose config. 40 | // "runServices": [], 41 | 42 | // Uncomment the next line if you want to keep your containers running after VS Code shuts down. 43 | // "shutdownAction": "none", 44 | 45 | // Uncomment the next line to run commands after the container is created - for example installing git. 46 | "postCreateCommand": "npm install", 47 | 48 | // Uncomment to connect as a non-root user. See https://aka.ms/vscode-remote/containers/non-root. 49 | "remoteUser": "node" 50 | } 51 | -------------------------------------------------------------------------------- /.devcontainer/docker-compose.yml: -------------------------------------------------------------------------------- 1 | --- 2 | #------------------------------------------------------------------------------------------------------------- 3 | 4 | #------------------------------------------------------------------------------------------------------------- 5 | # Copyright (c) Microsoft Corporation. All rights reserved. 6 | # Licensed under the MIT License. See https://go.microsoft.com/fwlink/?linkid=2090316 for license information. 7 | version: "3.3" 8 | 9 | services: 10 | trigger: 11 | environment: 12 | - TZ=America/Los_Angeles 13 | build: 14 | context: . 15 | dockerfile: Dockerfile # Apply development-only overrides to the base image 16 | depends_on: 17 | - deepstack-ai 18 | volumes: 19 | - ..:/workspace:cached 20 | - ../sampleConfiguration/aiinput:/aiinput 21 | - localtriggerstorage:/node-deepstackai-trigger 22 | secrets: 23 | - settings 24 | - triggers 25 | ports: 26 | - 4242:4242 27 | 28 | # Overrides default command so things don't shut down after the process ends. 29 | command: /bin/sh -c "while sleep 1000; do :; done" 30 | 31 | mqtt: 32 | image: eclipse-mosquitto:latest 33 | ports: 34 | - 1883:1883 35 | # For testing purposes enable basic authentication via configuration files 36 | # For auth testing use user and pass for the username and password 37 | volumes: 38 | - ./mosquitoMqttConfig:/mosquitto/config 39 | 40 | deepstack-ai: 41 | image: deepquestai/deepstack:latest 42 | volumes: 43 | - localstorage:/datastore 44 | environment: 45 | - VISION-DETECTION=True 46 | 47 | volumes: 48 | localstorage: 49 | localtriggerstorage: 50 | 51 | secrets: 52 | settings: 53 | file: ./settings.json 54 | triggers: 55 | file: ./triggers.json 56 | -------------------------------------------------------------------------------- /.devcontainer/mosquitoMqttConfig/mosquitto.conf: -------------------------------------------------------------------------------- 1 | # The password file contains a single test user account 2 | # username: user 3 | # password: pass 4 | password_file /mosquitto/config/mosquitto.passwd 5 | allow_anonymous true 6 | -------------------------------------------------------------------------------- /.devcontainer/mosquitoMqttConfig/mosquitto.passwd: -------------------------------------------------------------------------------- 1 | user:$6$1XSIRI9AwDwwx0xU$2OnatlT5sK4l1WMa+hPFY8idDmmrJxEWdVq8cYjWjbWy/5xYlRgJCZOJWgJud8bPJwBDbfKA5/82zGzAFU+zMg== 2 | -------------------------------------------------------------------------------- /.devcontainer/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://raw.githubusercontent.com/danecreekphotography/node-deepstackai-trigger/main/src/schemas/settings.schema.json", 3 | "deepstackUri": "http://deepstack-ai:5000/", 4 | "processExistingImages": true, 5 | "verbose": true, 6 | "enableWebServer": true, 7 | "mqtt": { 8 | "uri": "mqtt://mqtt:1883", 9 | "username": "user", 10 | "password": "pass" 11 | }, 12 | "pushbullet": { 13 | "accessToken": "access token here", 14 | "enabled": false 15 | }, 16 | "pushover": { 17 | "apiKey": "api key here", 18 | "userKey": "user key here", 19 | "enabled": false 20 | }, 21 | "telegram": { 22 | "botToken": "insert bot token here", 23 | "enabled": false 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /.devcontainer/triggers.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://raw.githubusercontent.com/danecreekphotography/node-deepstackai-trigger/main/src/schemas/triggerConfiguration.schema.json", 3 | "triggers": [ 4 | { 5 | "name": "Cat detector", 6 | "watchPattern": "/aiinput/Cat*.jpg", 7 | "enabled": true, 8 | "threshold": { 9 | "minimum": 10, 10 | "maximum": 90 11 | }, 12 | "handlers": { 13 | "webRequest": { 14 | "triggerUris": ["http://localhost:81/admin?trigger&camera=Cat"] 15 | }, 16 | "mqtt": { 17 | "topic": "aimotion/triggers/cat" 18 | }, 19 | "telegram": { 20 | "chatIds": [1] 21 | } 22 | }, 23 | "watchObjects": ["cat"] 24 | }, 25 | { 26 | "name": "Dog detector", 27 | "watchPattern": "/aiinput/**/Dog*.jpg", 28 | "enabled": true, 29 | "threshold": { 30 | "minimum": 50, 31 | "maximum": 100 32 | }, 33 | "activateRegions": [ 34 | { 35 | "xMinimum": 124, 36 | "xMaximum": 200, 37 | "yMinimum": 30, 38 | "yMaximum": 200 39 | } 40 | ], 41 | "handlers": { 42 | "webRequest": { 43 | "triggerUris": ["http://localhost:81/admin?trigger&camera=Dog&memo={{formattedPredictions}}"] 44 | }, 45 | "mqtt": { 46 | "messages": [{ "topic": "aimotion/triggers/dog" }] 47 | }, 48 | "pushover": { 49 | "userKeys": ["1"] 50 | }, 51 | "telegram": { 52 | "chatIds": [1], 53 | "annotateImage": true 54 | } 55 | }, 56 | "watchObjects": ["dog"] 57 | } 58 | ] 59 | } 60 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | npm-debug.log 3 | docker-compose.yaml 4 | Dockerfile 5 | .dockerignore 6 | .devcontainer 7 | .github 8 | .vscode 9 | .env 10 | .gitattributes 11 | .prettierrc 12 | dist 13 | config 14 | images -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "node": true, 4 | "es6": true 5 | }, 6 | "extends": [ 7 | "prettier", 8 | "eslint:recommended", 9 | "plugin:@typescript-eslint/eslint-recommended", 10 | "plugin:@typescript-eslint/recommended" 11 | ], 12 | "globals": { 13 | "Atomics": "readonly", 14 | "SharedArrayBuffer": "readonly" 15 | }, 16 | "parser": "@typescript-eslint/parser", 17 | "parserOptions": { 18 | "ecmaVersion": 11, 19 | "sourceType": "module" 20 | }, 21 | "plugins": ["@typescript-eslint", "notice"], 22 | "rules": { 23 | "no-console": "error", 24 | "notice/notice": [ 25 | "error", 26 | { 27 | "templateFile": "COPYRIGHT_TEMPLATE", 28 | "onNonMatchingHeader": "replace" 29 | } 30 | ] 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | * text=auto eol=lf 2 | *.{cmd,[cC][mM][dD]} text eol=crlf 3 | *.{bat,[bB][aA][tT]} text eol=crlf -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2] 4 | patreon: # Replace with a single Patreon username 5 | open_collective: # Replace with a single Open Collective username 6 | ko_fi: # Replace with a single Ko-fi username 7 | tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel 8 | community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry 9 | liberapay: # Replace with a single Liberapay username 10 | issuehunt: # Replace with a single IssueHunt username 11 | otechie: # Replace with a single Otechie username 12 | custom: ['https://www.paypal.com/donate?hosted_button_id=D8WH4FRJQHN7A'] 13 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: bug 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | A clear and concise description of what the bug is. 12 | 13 | **Log entries** 14 | Please copy and paste the log messages from the Docker output 15 | 16 | **Installation details** 17 | - OS: [e.g. Mac, Windows 10] 18 | - Docker setup [e.g. sample docker-compose.yaml, Portainer, UnRAID, etc] 19 | 20 | **Additional context** 21 | Add any other context about the problem here. 22 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Is your feature request related to a problem? Please describe.** 11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 12 | 13 | **Describe the solution you'd like** 14 | A clear and concise description of what you want to happen. 15 | 16 | **Describe alternatives you've considered** 17 | A clear and concise description of any alternative solutions or features you've considered. 18 | 19 | **Additional context** 20 | Add any other context or screenshots about the feature request here. 21 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/support-request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Support request 3 | about: For help getting your configuration up and running 4 | title: '' 5 | labels: Support 6 | assignees: '' 7 | 8 | --- 9 | 10 | **What trouble are you having?** 11 | 12 | A clear and concise description of what you're stuck on. 13 | 14 | **Have you tried with the sample files?** 15 | 16 | - [Yes/No] 17 | 18 | **Have you tried the steps in the troubleshooting guide?** 19 | 20 | https://github.com/danecreekphotography/node-deepstackai-trigger/wiki/Troubleshooting 21 | 22 | - [Yes/No] 23 | 24 | **Log entries** 25 | 26 | Please copy and paste all the log messages from the Docker output 27 | 28 | **Installation details** 29 | 30 | - OS: [e.g. Mac, Windows 10] 31 | - Docker setup [e.g. sample docker-compose.yaml, Portainer, UnRAID, etc] 32 | 33 | **Additional context** 34 | 35 | Add any other context about the problem here. 36 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # Set update schedule for GitHub Actions 2 | 3 | version: 2 4 | updates: 5 | 6 | - package-ecosystem: "github-actions" 7 | directory: "/" 8 | schedule: 9 | # Check for updates to GitHub Actions every weekday 10 | interval: "daily" -------------------------------------------------------------------------------- /.github/pull_request_template.md: -------------------------------------------------------------------------------- 1 | # Fixes #insert_issue_number 2 | 3 | ## Description of changes 4 | 5 | ## Checklist 6 | 7 | If your change touches anything under src or the README.md file 8 | these items must be done: 9 | 10 | - [ ] User-facing change description added to unreleased section of CHANGELOG.md 11 | -------------------------------------------------------------------------------- /.github/workflows/codeql-analysis.yml: -------------------------------------------------------------------------------- 1 | name: "CodeQL" 2 | 3 | on: 4 | push: 5 | branches: [main] 6 | pull_request: 7 | # The branches below must be a subset of the branches above 8 | branches: [main] 9 | schedule: 10 | - cron: '0 1 * * 1' 11 | 12 | jobs: 13 | analyze: 14 | name: Analyze 15 | runs-on: ubuntu-latest 16 | 17 | strategy: 18 | fail-fast: false 19 | matrix: 20 | # Override automatic language detection by changing the below list 21 | # Supported options are ['csharp', 'cpp', 'go', 'java', 'javascript', 'python'] 22 | language: ['javascript'] 23 | # Learn more... 24 | # https://docs.github.com/en/github/finding-security-vulnerabilities-and-errors-in-your-code/configuring-code-scanning#overriding-automatic-language-detection 25 | 26 | steps: 27 | - name: Checkout repository 28 | uses: actions/checkout@v3 29 | with: 30 | # We must fetch at least the immediate parents so that if this is 31 | # a pull request then we can checkout the head. 32 | fetch-depth: 2 33 | 34 | # If this run was triggered by a pull request event, then checkout 35 | # the head of the pull request instead of the merge commit. 36 | - run: git checkout HEAD^2 37 | if: ${{ github.event_name == 'pull_request' }} 38 | 39 | # Initializes the CodeQL tools for scanning. 40 | - name: Initialize CodeQL 41 | uses: github/codeql-action/init@v2 42 | with: 43 | languages: ${{ matrix.language }} 44 | 45 | # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). 46 | # If this step fails, then you should remove it and run the build manually (see below) 47 | - name: Autobuild 48 | uses: github/codeql-action/autobuild@v2 49 | 50 | # ℹ️ Command-line programs to run using the OS shell. 51 | # 📚 https://git.io/JvXDl 52 | 53 | # ✏️ If the Autobuild fails above, remove it and uncomment the following three lines 54 | # and modify them (or add more) to build your code if your project 55 | # uses a compiled language 56 | 57 | #- run: | 58 | # make bootstrap 59 | # make release 60 | 61 | - name: Perform CodeQL Analysis 62 | uses: github/codeql-action/analyze@v2 63 | -------------------------------------------------------------------------------- /.github/workflows/dockerimage.yml: -------------------------------------------------------------------------------- 1 | name: Docker Image CI 2 | 3 | on: 4 | push: 5 | branches: [main] 6 | pull_request: 7 | branches: [main] 8 | 9 | jobs: 10 | build: 11 | runs-on: ubuntu-latest 12 | 13 | steps: 14 | - uses: actions/checkout@v3 15 | - name: Build the Docker image 16 | run: docker build . --file Dockerfile --tag nodeblueirisdeepstackai:$(date +%s) 17 | -------------------------------------------------------------------------------- /.github/workflows/nodejs.yml: -------------------------------------------------------------------------------- 1 | # This workflow will do a clean install of node dependencies, build the source code and run tests across different versions of node 2 | # For more information see: https://help.github.com/actions/language-and-framework-guides/using-nodejs-with-github-actions 3 | 4 | name: Node.js CI 5 | 6 | on: 7 | push: 8 | branches: [main] 9 | pull_request: 10 | branches: [main] 11 | 12 | jobs: 13 | build: 14 | runs-on: ubuntu-latest 15 | 16 | strategy: 17 | matrix: 18 | node-version: [12.x] 19 | 20 | steps: 21 | - uses: actions/checkout@v3 22 | 23 | - uses: actions/cache@v3 24 | with: 25 | path: ~/.npm 26 | key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }} 27 | restore-keys: | 28 | ${{ runner.os }}-node- 29 | 30 | - name: Use Node.js ${{ matrix.node-version }} 31 | uses: actions/setup-node@v3 32 | with: 33 | node-version: ${{ matrix.node-version }} 34 | 35 | - run: npm ci 36 | - run: npm run lint 37 | - run: npm test 38 | - run: npm run webpack 39 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Create and publish a Docker image 2 | 3 | on: 4 | release: 5 | types: [published] 6 | 7 | env: 8 | REGISTRY: ghcr.io 9 | IMAGE_NAME: ${{ github.repository }} 10 | 11 | jobs: 12 | build-and-push-image: 13 | runs-on: ubuntu-latest 14 | permissions: 15 | contents: read 16 | packages: write 17 | 18 | steps: 19 | - name: Checkout repository 20 | uses: actions/checkout@v3 21 | 22 | - name: Log in to the Container registry 23 | uses: docker/login-action@f4ef78c080cd8ba55a85445d5b36e214a81df20a 24 | with: 25 | registry: ${{ env.REGISTRY }} 26 | username: ${{ github.actor }} 27 | password: ${{ secrets.GITHUB_TOKEN }} 28 | 29 | - name: Extract metadata (tags, labels) for Docker 30 | id: meta 31 | uses: docker/metadata-action@57396166ad8aefe6098280995947635806a0e6ea 32 | with: 33 | images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} 34 | 35 | - name: Build and push Docker image 36 | uses: docker/build-push-action@c56af957549030174b10d6867f20e78cfd7debc5 37 | with: 38 | context: . 39 | push: true 40 | tags: ${{ steps.meta.outputs.tags }} 41 | labels: ${{ steps.meta.outputs.labels }} 42 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | lerna-debug.log* 8 | 9 | # build output 10 | dist/ 11 | 12 | # Diagnostic reports (https://nodejs.org/api/report.html) 13 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 14 | 15 | # Runtime data 16 | pids 17 | *.pid 18 | *.seed 19 | *.pid.lock 20 | 21 | # Directory for instrumented libs generated by jscoverage/JSCover 22 | lib-cov 23 | 24 | # Coverage directory used by tools like istanbul 25 | coverage 26 | *.lcov 27 | 28 | # nyc test coverage 29 | .nyc_output 30 | 31 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 32 | .grunt 33 | 34 | # Bower dependency directory (https://bower.io/) 35 | bower_components 36 | 37 | # node-waf configuration 38 | .lock-wscript 39 | 40 | # Compiled binary addons (https://nodejs.org/api/addons.html) 41 | build/Release 42 | 43 | # Dependency directories 44 | node_modules/ 45 | jspm_packages/ 46 | 47 | # TypeScript cache 48 | *.tsbuildinfo 49 | 50 | # Optional npm cache directory 51 | .npm 52 | 53 | # Optional eslint cache 54 | .eslintcache 55 | 56 | # Microbundle cache 57 | .rpt2_cache/ 58 | .rts2_cache_cjs/ 59 | .rts2_cache_es/ 60 | .rts2_cache_umd/ 61 | 62 | # Optional REPL history 63 | .node_repl_history 64 | 65 | # Output of 'npm pack' 66 | *.tgz 67 | 68 | # Yarn Integrity file 69 | .yarn-integrity 70 | 71 | # dotenv environment variables file 72 | .env 73 | .env.test 74 | 75 | # parcel-bundler cache (https://parceljs.org/) 76 | .cache 77 | 78 | # Next.js build output 79 | .next 80 | 81 | # Nuxt.js build / generate output 82 | .nuxt 83 | dist 84 | 85 | # Gatsby files 86 | .cache/ 87 | # Comment in the public line in if your project uses Gatsby and not Next.js 88 | # https://nextjs.org/blog/next-9-1#public-directory-support 89 | # public 90 | 91 | # vuepress build output 92 | .vuepress/dist 93 | 94 | # Serverless directories 95 | .serverless/ 96 | 97 | # FuseBox cache 98 | .fusebox/ 99 | 100 | # DynamoDB Local files 101 | .dynamodb/ 102 | 103 | # TernJS port file 104 | .tern-port 105 | 106 | # Stores VSCode versions used for testing VSCode extensions 107 | .vscode-test 108 | 109 | # sample configuration files 110 | triggers.json 111 | settings.json -------------------------------------------------------------------------------- /.markdownlint.json: -------------------------------------------------------------------------------- 1 | { 2 | "line-length": false, 3 | "no-duplicate-header": false 4 | } 5 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "printWidth": 120, 3 | "trailingComma": "all" 4 | } 5 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | // Use IntelliSense to learn about possible attributes. 3 | // Hover to view descriptions of existing attributes. 4 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 5 | "version": "0.2.0", 6 | "configurations": [ 7 | { 8 | "type": "node", 9 | "request": "launch", 10 | "preLaunchTask": "npm:build", 11 | "name": "Launch Program", 12 | "skipFiles": ["/**"], 13 | "program": "${workspaceFolder}/dist/src/main.js", 14 | "sourceMaps": true, 15 | "outFiles": ["${workspaceFolder}/dist/**/*.js"] 16 | }, 17 | { 18 | "type": "node", 19 | "request": "launch", 20 | "name": "Jest Tests", 21 | "program": "${workspaceRoot}/node_modules/jest/bin/jest.js", 22 | "args": ["-i"], 23 | "preLaunchTask": "npm:build", 24 | "outFiles": ["${workspaceRoot}/dist/**/*.js"], 25 | "sourceMaps": true 26 | } 27 | ] 28 | } 29 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "cSpell.words": [ 3 | "AABBCCDDEEFFG", 4 | "DEEPSTACK", 5 | "Enns", 6 | "Ghhiijjkkllmm", 7 | "MQTT", 8 | "NTBA", 9 | "aiinput", 10 | "aimotion", 11 | "ajvkeywords", 12 | "awaitwritefinish", 13 | "bufferutil", 14 | "cascadia", 15 | "cashregister", 16 | "chokidar", 17 | "chokidar awaitwritefinish", 18 | "cooldown", 19 | "cooldowns", 20 | "danecreekphotography", 21 | "davidanson", 22 | "dbaeumer", 23 | "deepquestai", 24 | "deepstackai", 25 | "devcontainer", 26 | "dggf", 27 | "esbenp", 28 | "localstorage", 29 | "localtriggerstorage", 30 | "markdownlint", 31 | "mosquitto", 32 | "mqttpacket", 33 | "mqtts", 34 | "mutli", 35 | "pianobar", 36 | "postversion", 37 | "pureimage", 38 | "pushbullet", 39 | "spacealarm", 40 | "streetsidesoftware", 41 | "updown" 42 | ] 43 | } 44 | -------------------------------------------------------------------------------- /.vscode/tasks.json: -------------------------------------------------------------------------------- 1 | { 2 | // See https://go.microsoft.com/fwlink/?LinkId=733558 3 | // for the documentation about the tasks.json format 4 | "version": "2.0.0", 5 | "tasks": [ 6 | { 7 | "type": "npm", 8 | "script": "build", 9 | "label": "npm:build", 10 | "group": { 11 | "kind": "build", 12 | "isDefault": true 13 | }, 14 | "problemMatcher": ["$tsc"] 15 | }, 16 | { 17 | "type": "npm", 18 | "script": "lint", 19 | "problemMatcher": ["$eslint-stylish"], 20 | "label": "npm:lint", 21 | "detail": "npm run lint:eslint && npm run markdownlint" 22 | }, 23 | { 24 | "type": "npm", 25 | "script": "test", 26 | "problemMatcher": ["$eslint-stylish"], 27 | "label": "npm:test", 28 | "detail": "Runs jest unit tests" 29 | } 30 | ] 31 | } 32 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing to node-deepstackai-trigger 2 | 3 | Contributions are welcome to the project, either via feature requests, issue reports, or pull requests. 4 | 5 | The project was designed from the start to be easy to clone and run to simplify experimenting and 6 | trying out different changes. There's nothing worse than coming across a nifty project on GitHub 7 | only to spend hours fighting with various dependencies to get it working. 8 | 9 | If you would like to contribute changes to the repo please start by assigning the issue to yourself 10 | in github. Good issues for first time contributors are tagged accordingly in the issues list. Don't 11 | forget to read the [opening pull requests](#opening-pull-requests) guidelines below. 12 | 13 | ## Seting up the project 14 | 15 | The setup takes less than five minutes if you use [Visual Studio Code](http://code.visualstudio.com/) 16 | and the [Remote - Containers](https://marketplace.visualstudio.com/items?itemName=ms-vscode-remote.remote-containers) extension. 17 | 18 | Here's what to do: 19 | 20 | 1. Make sure you have Docker installed (you likely already do if you found this repo) 21 | 2. Clone the repo 22 | 3. Open the folder in Visual Studio Code 23 | 4. When prompted re-open the folder in a Dev Container 24 | 25 | That's it. If you press F5 to start debugging it should launch everything and start spitting out log 26 | messages in the output window. The errors about not being able to connect to a web address are 27 | expected in the development environment (see issue [#10](https://github.com/danecreekphotography/node-deepstackai-trigger/issues/10) 28 | if you'd like to help make those go away.) 29 | 30 | If you are on Windows you'll need to be running Windows Subsystem for Linux 2 before this 31 | all works. Until that's available in the public release in late May you'll need to 32 | install the [Insiders Slow Ring](https://docs.microsoft.com/en-us/windows/wsl/wsl2-index) build. This does work, as it's how the 33 | entire project was initially written and tested. 34 | 35 | Note that while you may be tempted to try and open this project using the _Remote-Containers: Open Repository in Container_ 36 | command it won't work as the extnesion doesn't currently support docker-compose.yml-based projects. 37 | 38 | ## A note about developing with Telegram 39 | 40 | Due to how Telegram bot keys are assigned, the development configuration has Telegram support disabled by default. 41 | If you want to mess around with Telegram while working in the repo you'll need to enable it following the steps 42 | in the [README.md](README.md). The one difference is you should enable it in `.devcontainer/docker-compose.yml`, not 43 | the one in the `sampleConfig` directory. To set up `botToken` and `chatIds` edit the `telegram.conf` and `triggers.conf` 44 | files located in `sampleConfiguration`. 45 | 46 | ## Opening pull requests 47 | 48 | Feel free to open pull requests but please keep the following in mind: 49 | 50 | - All pull requests must reference an issue 51 | - All pull requests must be made from a feature branch, not main 52 | - If your change touches anything under the `src/` directory or the `README.md` file add 53 | a user-facing explanation of the change to [CHANGELOG.md](CHANGELOG.md) under the **Unreleased** 54 | header. These notes will get applied to the next release version for you. 55 | - Pull requests must pass the automatic Docker and node.js build checks 56 | - The repo is set up to auto-apply Prettier, ESLint, and Markdownlint rules. If you're using VS Code 57 | in a dev container the necessary extensions will automatically be installed to keep this clean prior 58 | to opening the pull request. 59 | -------------------------------------------------------------------------------- /COPYRIGHT_TEMPLATE: -------------------------------------------------------------------------------- 1 | /*--------------------------------------------------------------------------------------------- 2 | * Copyright (c) Neil Enns. All rights reserved. 3 | * Licensed under the MIT License. See LICENSE in the project root for license information. 4 | *--------------------------------------------------------------------------------------------*/ 5 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # This temporary image is used to produce the build. 2 | FROM node:12 as build 3 | RUN mkdir -p /home/node/app/fonts && chown -R node:node /home/node/app 4 | 5 | WORKDIR /home/node/app 6 | COPY package*.json ./ 7 | USER node 8 | # These have to copy before npm ci because npm ci runs tsc 9 | COPY --chown=node:node . . 10 | RUN npm ci --no-optional 11 | RUN npm run webpack 12 | 13 | # This is the final production image 14 | FROM node:slim 15 | 16 | # Pre-create the temporary storage folder so it has the right user 17 | # permissions on it after volume mount 18 | RUN mkdir -p /node-deepstackai-trigger && chown -R node:node /node-deepstackai-trigger 19 | RUN mkdir -p /home/node/app/public && chown -R node:node /home/node/app/public 20 | 21 | WORKDIR /home/node/app 22 | USER node 23 | COPY --from=build --chown=node:node /home/node/app/dist/bundle.js . 24 | COPY --from=build --chown=node:node /home/node/app/README.md . 25 | COPY --from=build --chown=node:node /home/node/app/LICENSE . 26 | COPY --from=build --chown=node:node /home/node/app/FONT_LICENSE . 27 | COPY --from=build --chown=node:node /home/node/app/fonts/CascadiaCode.ttf ./fonts/CascadiaCode.ttf 28 | 29 | # The static files for directory display by the Express web server need to get copied over. 30 | COPY --from=build --chown=node:node /home/node/app/node_modules/serve-index/public ./public 31 | 32 | # Enable polling for watching files by default since it appears that's 33 | # the only way to have file detection work in a Docker container. 34 | # This can always be set to false in docker-compose.yml later if necessary. 35 | ENV CHOKIDAR_USEPOLLING=true 36 | 37 | ENTRYPOINT [ "node", "--no-deprecation", "bundle.js" ] -------------------------------------------------------------------------------- /FONT_LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2019 - Present, Microsoft Corporation, 2 | with Reserved Font Name Cascadia Code. 3 | 4 | This Font Software is licensed under the SIL Open Font License, Version 1.1. 5 | This license is copied below, and is also available with a FAQ at: 6 | http://scripts.sil.org/OFL 7 | 8 | 9 | ----------------------------------------------------------- 10 | SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 11 | ----------------------------------------------------------- 12 | 13 | PREAMBLE 14 | The goals of the Open Font License (OFL) are to stimulate worldwide 15 | development of collaborative font projects, to support the font creation 16 | efforts of academic and linguistic communities, and to provide a free and 17 | open framework in which fonts may be shared and improved in partnership 18 | with others. 19 | 20 | The OFL allows the licensed fonts to be used, studied, modified and 21 | redistributed freely as long as they are not sold by themselves. The 22 | fonts, including any derivative works, can be bundled, embedded, 23 | redistributed and/or sold with any software provided that any reserved 24 | names are not used by derivative works. The fonts and derivatives, 25 | however, cannot be released under any other type of license. The 26 | requirement for fonts to remain under this license does not apply 27 | to any document created using the fonts or their derivatives. 28 | 29 | DEFINITIONS 30 | "Font Software" refers to the set of files released by the Copyright 31 | Holder(s) under this license and clearly marked as such. This may 32 | include source files, build scripts and documentation. 33 | 34 | "Reserved Font Name" refers to any names specified as such after the 35 | copyright statement(s). 36 | 37 | "Original Version" refers to the collection of Font Software components as 38 | distributed by the Copyright Holder(s). 39 | 40 | "Modified Version" refers to any derivative made by adding to, deleting, 41 | or substituting -- in part or in whole -- any of the components of the 42 | Original Version, by changing formats or by porting the Font Software to a 43 | new environment. 44 | 45 | "Author" refers to any designer, engineer, programmer, technical 46 | writer or other person who contributed to the Font Software. 47 | 48 | PERMISSION & CONDITIONS 49 | Permission is hereby granted, free of charge, to any person obtaining 50 | a copy of the Font Software, to use, study, copy, merge, embed, modify, 51 | redistribute, and sell modified and unmodified copies of the Font 52 | Software, subject to the following conditions: 53 | 54 | 1) Neither the Font Software nor any of its individual components, 55 | in Original or Modified Versions, may be sold by itself. 56 | 57 | 2) Original or Modified Versions of the Font Software may be bundled, 58 | redistributed and/or sold with any software, provided that each copy 59 | contains the above copyright notice and this license. These can be 60 | included either as stand-alone text files, human-readable headers or 61 | in the appropriate machine-readable metadata fields within text or 62 | binary files as long as those fields can be easily viewed by the user. 63 | 64 | 3) No Modified Version of the Font Software may use the Reserved Font 65 | Name(s) unless explicit written permission is granted by the corresponding 66 | Copyright Holder. This restriction only applies to the primary font name as 67 | presented to the users. 68 | 69 | 4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font 70 | Software shall not be used to promote, endorse or advertise any 71 | Modified Version, except to acknowledge the contribution(s) of the 72 | Copyright Holder(s) and the Author(s) or with their explicit written 73 | permission. 74 | 75 | 5) The Font Software, modified or unmodified, in part or in whole, 76 | must be distributed entirely under this license, and must not be 77 | distributed under any other license. The requirement for fonts to 78 | remain under this license does not apply to any document created 79 | using the Font Software. 80 | 81 | TERMINATION 82 | This license becomes null and void if any of the above conditions are 83 | not met. 84 | 85 | DISCLAIMER 86 | THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 87 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF 88 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT 89 | OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE 90 | COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, 91 | INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL 92 | DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING 93 | FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM 94 | OTHER DEALINGS IN THE FONT SOFTWARE. 95 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Neil Enns 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # DeepStack AI Triggers 2 | 3 | > [!NOTE] 4 | > This project is no longer maintained. BlueIris has comprehensive support for AI detection, and many 5 | > cameras include AI detection capabilities. 6 | 7 | [![Open in Visual Studio Code](https://open.vscode.dev/badges/open-in-vscode.svg)](https://open.vscode.dev/danecreekphotography/node-deepstackai-trigger) 8 | 9 | This system uses Docker containers to run [DeepStack AI](https://deepstack.cc/) and process images 10 | from a watch folder, then fires a set of registered triggers to make web request calls, send MQTT 11 | events, and send Telegram messages when specified objects are detected in the images. 12 | 13 | This project was heavily inspired by GentlePumpkin's post on [ipcamtalk.com](https://ipcamtalk.com/threads/tool-tutorial-free-ai-person-detection-for-blue-iris.37330/) 14 | that triggers BlueIris video survelliance using DeepStack as the motion sensing system. 15 | 16 | ## Quick start - basic web requests 17 | 18 | The following five steps are all that's required to start using AI to analyze images and 19 | then call a web URL, e.g. triggering a [BlueIris camera](https://github.com/danecreekphotography/node-deepstackai-trigger/wiki/Integrating-with-BlueIris) to record. 20 | 21 | 1. Install [Docker](http://www.docker.com/) 22 | 2. Copy the `docker-compose.yml`, `settings.json` and `triggers.json` files from the [sampleConfiguration](https://github.com/danecreekphotography/node-deepstackai-trigger/tree/master/sampleConfiguration) directory locally. 23 | 3. Edit the `docker-compose.yml` file to modify the mount point for source images and set the timezone. 24 | 4. Edit `triggers.json` to [define the triggers](https://github.com/danecreekphotography/node-deepstackai-trigger/wiki/Defining-triggers) you want to use. 25 | 5. Run `docker-compose up` from within the folder that contains your `docker-compose.yml` file to start the system running. 26 | 27 | Setting the timezone via the `TZ` environment variable in `docker-compose.yml` is important for 28 | every thing to work smoothly. By default Docker containers are in UTC and that messes up 29 | logic to skip existing images on restart. A list of valid timezones is available on 30 | [Wikipedia](https://en.wikipedia.org/wiki/List_of_tz_database_time_zones). Use any value 31 | from the `TZ database name` column. 32 | 33 | Editing the .json files in [Visual Studio Code](https://code.visualstudio.com/) or some other editor 34 | that understands JSON schemas is recommended: you'll get full auto-complete and documentation as 35 | you type. 36 | 37 | Having trouble? Check the logs output from Docker for any errors the system may throw. 38 | The [troubleshooting](https://github.com/danecreekphotography/node-deepstackai-trigger/wiki/Troubleshooting) 39 | page has tips for resolving common deployment problems. 40 | 41 | ## Quick start - enabling MQTT, Pushbullet, Pushover and Telegram 42 | 43 | To enable the different push notifications handlers: 44 | 45 | 1. Edit `settings.json` to specify [specify the connection information](https://github.com/danecreekphotography/node-deepstackai-trigger/wiki/Configuration#) for the service. 46 | 2. Edit `triggers.json` to add [mqtt](https://github.com/danecreekphotography/node-deepstackai-trigger/wiki/Defining-triggers#defining-mqtt-handlers), [Pushbullet](https://github.com/danecreekphotography/node-deepstackai-trigger/wiki/Defining-triggers#defining-pushbullet-handlers), [Pushover](https://github.com/danecreekphotography/node-deepstackai-trigger/wiki/Defining-triggers#defining-pushover-handlers), or [Telegram](https://github.com/danecreekphotography/node-deepstackai-trigger/wiki/Defining-triggers#defining-telegram-handlers) handlers. 47 | 48 | ## Learning more 49 | 50 | The [project wiki](https://github.com/danecreekphotography/node-deepstackai-trigger/wiki) has complete documentation on: 51 | 52 | - [Configuring the system](https://github.com/danecreekphotography/node-deepstackai-trigger/wiki/Configuration) 53 | - [Defining triggers](https://github.com/danecreekphotography/node-deepstackai-trigger/wiki/Defining-triggers) 54 | - [Deploying to Synology or Unraid](https://github.com/danecreekphotography/node-deepstackai-trigger/wiki/Deploying-to-Synology-and-Unraid) 55 | - [Integrating with BlueIris](https://github.com/danecreekphotography/node-deepstackai-trigger/wiki/Integrating-with-BlueIris) 56 | - [Integrating with HomeAssistant](https://github.com/danecreekphotography/node-deepstackai-trigger/wiki/Integrating-with-HomeAssistant) 57 | - [Troubleshooting](https://github.com/danecreekphotography/node-deepstackai-trigger/wiki/Troubleshooting) 58 | 59 | ## Supported Docker image tags 60 | 61 | The following tags are available in the [repository](https://github.com/neilenns/ambientweather2mqtt/pkgs/container/ambientweather2mqtt): 62 | 63 | | Tag name | Description | 64 | | ----------- | -------------------------------------------------------------------------------------------------------------------------- | 65 | | `latest` | The latest released build. | 66 | | `` | The specific [released version](https://github.com/danecreekphotography/node-deepstackai-trigger/releases), e.g. `v1.5.0`. | 67 | -------------------------------------------------------------------------------- /docs/.gitignore: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/neilenns/node-deepstackai-trigger/9025f3609c2081b11f25d6008c60fea0da8cb141/docs/.gitignore -------------------------------------------------------------------------------- /docs/_config.yml: -------------------------------------------------------------------------------- 1 | theme: jekyll-theme-slate -------------------------------------------------------------------------------- /fonts/CascadiaCode.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/neilenns/node-deepstackai-trigger/9025f3609c2081b11f25d6008c60fea0da8cb141/fonts/CascadiaCode.ttf -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | /*--------------------------------------------------------------------------------------------- 2 | * Copyright (c) Neil Enns. All rights reserved. 3 | * Licensed under the MIT License. See LICENSE in the project root for license information. 4 | *--------------------------------------------------------------------------------------------*/ 5 | module.exports = { 6 | preset: "ts-jest", 7 | testEnvironment: "node", 8 | modulePathIgnorePatterns: ["/dist/"], 9 | setupFiles: ["/tests/jest.setup.ts"], 10 | }; 11 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "node-deepstackai-trigger", 3 | "version": "5.8.7", 4 | "description": "Detects motion using DeepStack AI and calls registered triggers based on trigger rules.", 5 | "main": "dist/src/main.js", 6 | "files": [ 7 | "dist/**/*" 8 | ], 9 | "scripts": { 10 | "build": "tsc", 11 | "lint": "npm run lint:eslint && npm run lint:markdown", 12 | "lint:eslint": "eslint -c .eslintrc.json --ext .ts src/", 13 | "lint:markdown": "node ./node_modules/markdownlint-cli/markdownlint.js **/*.md --ignore node_modules", 14 | "format": "prettier --write \"src/**/*.ts\" \"src/**/*.js\" \"tests/**/*.ts\" \"tests/**/*.js\"", 15 | "prepare": "npm run build", 16 | "prepublishOnly": "npm run lint", 17 | "postversion": "git push", 18 | "start": "node dist/src/main.js", 19 | "test": "jest", 20 | "webpack": "npx webpack --mode=production", 21 | "webpack:dev": "npx webpack --mode=development" 22 | }, 23 | "repository": { 24 | "type": "git", 25 | "url": "git+https://github.com/neile/node-deepstackai-trigger" 26 | }, 27 | "author": "", 28 | "license": "ISC", 29 | "bugs": { 30 | "url": "https://github.com/neile/node-deepstackai-trigger/Issues" 31 | }, 32 | "homepage": "https://github.com/neile/node-deepstackai-trigger#readme", 33 | "dependencies": { 34 | "ajv": "^6.12.4", 35 | "ajv-keywords": "^3.4.1", 36 | "async-mqtt": "^2.6.0", 37 | "bufferutil": "^4.0.1", 38 | "chalk": "^3.0.0", 39 | "chokidar": "^3.4.2", 40 | "commander": "^4.1.1", 41 | "emitter": "^0.0.2", 42 | "express": "^4.17.1", 43 | "glob": "^7.1.6", 44 | "http-terminator": "^2.0.3", 45 | "jsonc-parser": "^2.2.1", 46 | "moment": "^2.29.4", 47 | "mustache": "^4.0.1", 48 | "node-telegram-bot-api": "^0.50.0", 49 | "pureimage": "^0.2.1", 50 | "pushover-notifications": "^1.2.2", 51 | "request": "^2.88.2", 52 | "request-promise-native": "^1.0.8", 53 | "safe-regex": "^2.1.1", 54 | "serve-index": "^1.9.1", 55 | "utf-8-validate": "^5.0.2" 56 | }, 57 | "devDependencies": { 58 | "@types/ajv-keywords": "^3.4.0", 59 | "@types/babel__core": "^7.1.14", 60 | "@types/express": "^4.17.6", 61 | "@types/glob": "^7.1.3", 62 | "@types/http-terminator": "^2.0.1", 63 | "@types/jest": "^25.2.3", 64 | "@types/mkdirp": "^1.0.1", 65 | "@types/mustache": "^4.0.1", 66 | "@types/node": "^13.13.12", 67 | "@types/node-telegram-bot-api": "^0.40.3", 68 | "@types/request": "^2.48.5", 69 | "@types/request-promise-native": "^1.0.17", 70 | "@types/safe-regex": "^1.1.2", 71 | "@types/serve-index": "^1.7.30", 72 | "@types/ws": "^8.5.3", 73 | "@typescript-eslint/eslint-plugin": "^3.9.1", 74 | "@typescript-eslint/eslint-plugin-tslint": "^3.9.1", 75 | "@typescript-eslint/parser": "^3.9.1", 76 | "eslint": "^7.7.0", 77 | "eslint-config-prettier": "^6.11.0", 78 | "eslint-plugin-notice": "^0.9.10", 79 | "file-loader": "^6.0.0", 80 | "jest": "^26.4.0", 81 | "jest-cli": "^26.4.0", 82 | "markdownlint-cli": "^0.32.1", 83 | "prettier": "^1.19.1", 84 | "ts-jest": "^26.2.0", 85 | "ts-loader": "^7.0.5", 86 | "tslint": "^5.20.1", 87 | "tslint-config-prettier": "^1.18.0", 88 | "typescript": "^3.9.7", 89 | "webpack": "^5.74.0", 90 | "webpack-cli": "^4.10.0" 91 | } 92 | } 93 | -------------------------------------------------------------------------------- /sampleConfiguration/aiinput/Cat_20200523-074700.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/neilenns/node-deepstackai-trigger/9025f3609c2081b11f25d6008c60fea0da8cb141/sampleConfiguration/aiinput/Cat_20200523-074700.jpg -------------------------------------------------------------------------------- /sampleConfiguration/aiinput/Cat_20200523-075000.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/neilenns/node-deepstackai-trigger/9025f3609c2081b11f25d6008c60fea0da8cb141/sampleConfiguration/aiinput/Cat_20200523-075000.jpg -------------------------------------------------------------------------------- /sampleConfiguration/aiinput/Cat_20200523-075100.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/neilenns/node-deepstackai-trigger/9025f3609c2081b11f25d6008c60fea0da8cb141/sampleConfiguration/aiinput/Cat_20200523-075100.jpg -------------------------------------------------------------------------------- /sampleConfiguration/aiinput/Dog_20200523-075000.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/neilenns/node-deepstackai-trigger/9025f3609c2081b11f25d6008c60fea0da8cb141/sampleConfiguration/aiinput/Dog_20200523-075000.jpg -------------------------------------------------------------------------------- /sampleConfiguration/aiinput/dogcamera/Dog_20200523-075000.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/neilenns/node-deepstackai-trigger/9025f3609c2081b11f25d6008c60fea0da8cb141/sampleConfiguration/aiinput/dogcamera/Dog_20200523-075000.jpg -------------------------------------------------------------------------------- /sampleConfiguration/docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: "3.8" 2 | services: 3 | trigger: 4 | volumes: 5 | # Change d:/myfolder/myimages to point to the folder that will have the images 6 | # to analyze. Only change the local path that is before the :/aiinput portion. 7 | # Don't change the :/aiinput part. For example, if you are on Windows and your 8 | # images are stored locally in d:/blueiris/capturedImages your final line should 9 | # look like this: 10 | # d:/blueIris/capturedImages:/aiinput 11 | - d:/myfolder/myimages:/aiinput 12 | 13 | environment: 14 | # Change this to match the timezone the images are produced in, 15 | # Typically this will be the timezone of the machine running 16 | # the Docker container. For a list of valid timezone values 17 | # see https://en.wikipedia.org/wiki/List_of_tz_database_time_zones. 18 | # The value to use is in the "TZ database name" column. 19 | - TZ=America/Los_Angeles 20 | 21 | ports: 22 | # This port is used by the local web server when annotated images are enabled. 23 | # If you change the port used by the local web server in the settings.json file 24 | # this also has to change to match. 25 | - 4242:4242 26 | 27 | # ------------------------------------------------------------------------ 28 | # Don't change anything below this line unless you know what you are doing 29 | secrets: 30 | - triggers 31 | - settings 32 | image: ghcr.io/neilenns/node-deepstackai-trigger:latest 33 | restart: always 34 | depends_on: 35 | - deepstack-ai 36 | 37 | deepstack-ai: 38 | image: deepquestai/deepstack:latest 39 | restart: always 40 | volumes: 41 | - localstorage:/datastore 42 | environment: 43 | - VISION-DETECTION=True 44 | 45 | volumes: 46 | localstorage: 47 | 48 | secrets: 49 | settings: 50 | # This should point to the location of the settings.json configuration file 51 | file: ./settings.json 52 | triggers: 53 | # This should point to the location of the triggers.json configuration file 54 | file: ./triggers.json 55 | -------------------------------------------------------------------------------- /sampleConfiguration/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | // Leave this line alone. It enables Intellisense when editing this file in Visual Studio Code. 3 | "$schema": "https://raw.githubusercontent.com/danecreekphotography/node-deepstackai-trigger/main/src/schemas/settings.schema.json", 4 | 5 | // This is the default uri for Deepstack when deploying using the docker-compose.yaml file 6 | // provided in this sampleConfiguration folder. If you have another deployment of Deepstack 7 | // running elsewhere change this setting to point to the correction location. 8 | "deepstackUri": "http://deepstack-ai:5000/", 9 | 10 | // Set this to true to enable annotated images for use in trigger handlers. 11 | // There is a performance penalty to using this, leave it off unless 12 | // you really want to see the annotated images. 13 | "enableAnnotations": false, 14 | 15 | // Set this to true to enable the internal web server for remote access 16 | // to processed and annotated images. 17 | "enableWebServer": false, 18 | 19 | // Enables verbose logging. Useful when setting up the system to ensure 20 | // everything is running correctly. 21 | "verbose": true, 22 | 23 | // Set this to true if your images are stored in a remote folder that's 24 | // mounted as a network share and then mapped to the Docker image. 25 | "awaitWriteFinish": false, 26 | 27 | // Provides the configuration details for your MQTT server. To enable 28 | // mqtt set the uri, username (if required), password (if required), 29 | // and set enabled to true. 30 | "mqtt": { 31 | "uri": "mqtt://mqtt:1883", 32 | //"username": "user", 33 | //"password": "pass", 34 | "enabled": false 35 | }, 36 | 37 | // Provides the configuration details for Telegram bot messages. 38 | // Set the botToken and enabled to true to use Telegram. 39 | "telegram": { 40 | "botToken": "insert bot token here", 41 | "enabled": false 42 | }, 43 | 44 | // Provides the configuration details for Pushbullet notifications. 45 | // Set the accessToken and enabled to true to use Pushbullet. 46 | "pushbullet": { 47 | "accessToken": "access token here", 48 | "enabled": false 49 | }, 50 | 51 | // Provides the configuration details for Pushover notifications. 52 | // Set the apiKey, userKey, and enabled to true to use Pushover. 53 | "pushover": { 54 | "apiKey": "api key here", 55 | "userKey": "user key here", 56 | "enabled": false 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /sampleConfiguration/triggers.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://raw.githubusercontent.com/danecreekphotography/node-deepstackai-trigger/main/src/schemas/triggerConfiguration.schema.json", 3 | "triggers": [ 4 | { 5 | "name": "Cat detector", 6 | "watchPattern": "/aiinput/Cat*.jpg", 7 | "enabled": true, 8 | "threshold": { 9 | "minimum": 10, 10 | "maximum": 90 11 | }, 12 | "handlers": { 13 | "webRequest": { 14 | "triggerUris": ["http://localhost:81/admin?trigger&camera=Cat"] 15 | }, 16 | "mqtt": { 17 | "topic": "aimotion/triggers/cat" 18 | }, 19 | "telegram": { 20 | "chatIds": [1] 21 | } 22 | }, 23 | "watchObjects": ["cat"] 24 | }, 25 | { 26 | "name": "Dog detector", 27 | "watchPattern": "/aiinput/**/Dog*.jpg", 28 | "enabled": true, 29 | "threshold": { 30 | "minimum": 50, 31 | "maximum": 100 32 | }, 33 | "handlers": { 34 | "webRequest": { 35 | "triggerUris": ["http://localhost:81/admin?trigger&camera=Dog&memo={{formattedPredictions}}"] 36 | }, 37 | "mqtt": { 38 | "messages": [{ "topic": "aimotion/triggers/dog" }] 39 | }, 40 | "pushover": { 41 | "userKeys": ["1"] 42 | }, 43 | "telegram": { 44 | "chatIds": [1], 45 | "annotateImage": true 46 | } 47 | }, 48 | "watchObjects": ["dog"] 49 | } 50 | ] 51 | } 52 | -------------------------------------------------------------------------------- /src/DeepStack.ts: -------------------------------------------------------------------------------- 1 | /*--------------------------------------------------------------------------------------------- 2 | * Copyright (c) Neil Enns. All rights reserved. 3 | * Licensed under the MIT License. See LICENSE in the project root for license information. 4 | *--------------------------------------------------------------------------------------------*/ 5 | import * as fs from "fs"; 6 | import * as JSONC from "jsonc-parser"; 7 | import * as Settings from "./Settings"; 8 | 9 | import request from "request-promise-native"; 10 | import IDeepStackResponse from "./types/IDeepStackResponse"; 11 | 12 | export default async function analyzeImage(fileName: string, endpoint?: string): Promise { 13 | // This method of calling DeepStack comes from https://nodejs.deepstack.cc/ 14 | const imageStream = fs.createReadStream(fileName); 15 | const form = { image: imageStream }; 16 | 17 | const deepstackUri = endpoint 18 | ? new URL(endpoint, Settings.deepstackUri) 19 | : new URL("/v1/vision/detection", Settings.deepstackUri); 20 | 21 | const rawResponse = await request 22 | .post({ 23 | formData: form, 24 | uri: deepstackUri.toString(), 25 | }) 26 | .catch(e => { 27 | throw Error(`Failed to call DeepStack at ${Settings.deepstackUri}: ${e.error}`); 28 | }); 29 | 30 | return JSONC.parse(rawResponse) as IDeepStackResponse; 31 | } 32 | -------------------------------------------------------------------------------- /src/LocalStorageManager.ts: -------------------------------------------------------------------------------- 1 | /*--------------------------------------------------------------------------------------------- 2 | * Copyright (c) Neil Enns. All rights reserved. 3 | * Licensed under the MIT License. See LICENSE in the project root for license information. 4 | *--------------------------------------------------------------------------------------------*/ 5 | 6 | import * as log from "./Log"; 7 | import * as Settings from "./Settings"; 8 | 9 | import mkdirp from "mkdirp"; 10 | import path from "path"; 11 | import { promises as fsPromise } from "fs"; 12 | 13 | export enum Locations { 14 | Annotations = "annotations", 15 | Snapshots = "snapshots", 16 | Originals = "originals", 17 | } 18 | 19 | /** 20 | * Number of milliseconds in a minute 21 | */ 22 | const _millisecondsInAMinute = 1000 * 60; 23 | 24 | /** 25 | * The background timer that runs the local file purge. 26 | */ 27 | let _backgroundTimer: NodeJS.Timeout; 28 | 29 | /** 30 | * Local location where all web images are stored. 31 | */ 32 | export const localStoragePath = "/node-deepstackai-trigger"; 33 | 34 | /** 35 | * Creates the data storage directory for the web images 36 | */ 37 | export async function initializeStorage(): Promise { 38 | log.verbose("Local storage", `Creating local storage folders in ${localStoragePath}.`); 39 | 40 | await mkdirp(path.join(localStoragePath, Locations.Annotations)); 41 | await mkdirp(path.join(localStoragePath, Locations.Snapshots)); 42 | await mkdirp(path.join(localStoragePath, Locations.Originals)); 43 | } 44 | 45 | /** 46 | * Takes a local storage location and the full path to an original file and returns the full path for that 47 | * same base filename name on local storage. 48 | * @param location The location in local storage to map the file to 49 | * @param fileName The full path to the original file 50 | */ 51 | export function mapToLocalStorage(location: Locations, fileName: string): string { 52 | return path.join(localStoragePath, location, path.basename(fileName)); 53 | } 54 | 55 | /** 56 | * Copies a file to local storage. 57 | * @param location The location in local storage to copy the file to 58 | * @param fileName The file to copy 59 | */ 60 | export async function copyToLocalStorage(location: Locations, fileName: string): Promise { 61 | const localFileName = path.join(localStoragePath, location, path.basename(fileName)); 62 | await fsPromise.copyFile(fileName, localFileName).catch(e => { 63 | log.warn("Local storage", `Unable to copy to local storage: ${e.message}`); 64 | }); 65 | 66 | return localFileName; 67 | } 68 | 69 | /** 70 | * Starts a background task that purges old files from local storage. 71 | */ 72 | export function startBackgroundPurge(): void { 73 | if (Settings.purgeInterval > 0) { 74 | log.verbose( 75 | "Local storage", 76 | `Enabling background purge every ${Settings.purgeInterval} minutes for files older than ${Settings.purgeAge} minutes.`, 77 | ); 78 | purgeOldFiles(); 79 | } 80 | else { 81 | log.verbose( 82 | "Local storage", 83 | `Background purge is disabled via settings.`, 84 | ); 85 | } 86 | } 87 | 88 | /** 89 | * Stops the background purge process from running. 90 | */ 91 | export function stopBackgroundPurge(): void { 92 | clearTimeout(_backgroundTimer); 93 | log.verbose("Local storage", `Background purge stopped.`); 94 | } 95 | 96 | /** 97 | * Purges files older than the purgeThreshold from local storage. 98 | */ 99 | async function purgeOldFiles(): Promise { 100 | log.verbose("Local storage", "Running purge"); 101 | 102 | // Do annotations first. 103 | let purgeDir = path.join(localStoragePath, Locations.Annotations); 104 | await Promise.all( 105 | (await fsPromise.readdir(purgeDir)).map(async fileName => await purgeFile(path.join(purgeDir, fileName))), 106 | ); 107 | 108 | // Now do snapshots. 109 | purgeDir = path.join(localStoragePath, Locations.Snapshots); 110 | await Promise.all( 111 | (await fsPromise.readdir(purgeDir)).map(async fileName => await purgeFile(path.join(purgeDir, fileName))), 112 | ); 113 | 114 | // Now do originals. 115 | purgeDir = path.join(localStoragePath, Locations.Originals); 116 | await Promise.all( 117 | (await fsPromise.readdir(purgeDir)).map(async fileName => await purgeFile(path.join(purgeDir, fileName))), 118 | ); 119 | 120 | log.verbose("Local storage", "Purge complete"); 121 | 122 | if (Settings.purgeInterval > 0 ) { 123 | _backgroundTimer = setTimeout(purgeOldFiles, Settings.purgeInterval * _millisecondsInAMinute); 124 | } 125 | } 126 | 127 | /** 128 | * Purges an individual file that meets the purge criteria. 129 | * @param fullLocalPath The full path and filename to purge 130 | */ 131 | async function purgeFile(fullLocalPath: string): Promise { 132 | const lastAccessTime = (await fsPromise.stat(fullLocalPath)).atime; 133 | 134 | const minutesSinceLastAccess = (new Date().getTime() - lastAccessTime.getTime()) / _millisecondsInAMinute; 135 | 136 | if (minutesSinceLastAccess > Settings.purgeAge) { 137 | await fsPromise.unlink(fullLocalPath); 138 | log.verbose("Local storage", `Purging ${fullLocalPath}. Age: ${minutesSinceLastAccess.toFixed(0)} minutes.`); 139 | } 140 | } 141 | -------------------------------------------------------------------------------- /src/Log.ts: -------------------------------------------------------------------------------- 1 | /*--------------------------------------------------------------------------------------------- 2 | * Copyright (c) Neil Enns. All rights reserved. 3 | * Licensed under the MIT License. See LICENSE in the project root for license information. 4 | *--------------------------------------------------------------------------------------------*/ 5 | 6 | // whole point of having this file: to wrap console.log calls. 7 | // Everywhere else console.log() is a build breaking error to prevent 8 | // accidental use of it. 9 | /* eslint-disable no-console */ 10 | import chalk from "chalk"; 11 | import moment from "moment"; 12 | import * as Settings from "./Settings"; 13 | 14 | /** 15 | * Formats a message for output to the logs. 16 | * @param source The source of the message 17 | * @param message The message 18 | */ 19 | function formatMessage(source: string, message: string) { 20 | return `${moment().format()} [${source}] ${message}`; 21 | } 22 | 23 | export function verbose(source: string, message: string): void { 24 | if (!Settings.verbose) { 25 | return; 26 | } 27 | 28 | info(source, message); 29 | } 30 | /** 31 | * Logs an informational message to the console. 32 | * @param source The source of the message 33 | * @param message The message 34 | */ 35 | export function info(source: string, message: string): void { 36 | console.log(formatMessage(source, message)); 37 | } 38 | 39 | /** 40 | * Logs a warning message to the console. 41 | * @param source The source of the message 42 | * @param message The message 43 | */ 44 | export function warn(source: string, message: string): void { 45 | console.log(chalk.yellow(formatMessage(source, message))); 46 | } 47 | 48 | /** 49 | * Logs an error message to the console. 50 | * @param source The source of the message 51 | * @param message The message 52 | */ 53 | export function error(source: string, message: string): void { 54 | console.log(chalk.red(formatMessage(source, message))); 55 | } 56 | -------------------------------------------------------------------------------- /src/MqttRouter.ts: -------------------------------------------------------------------------------- 1 | /*--------------------------------------------------------------------------------------------- 2 | * Copyright (c) Neil Enns. All rights reserved. 3 | * Licensed under the MIT License. See LICENSE in the project root for license information. 4 | *--------------------------------------------------------------------------------------------*/ 5 | 6 | import * as log from "./Log"; 7 | import * as MqttManager from "./handlers/mqttManager/MqttManager"; 8 | import * as TriggerManager from "./TriggerManager"; 9 | 10 | const _statusResetTopic = "node-deepstackai-trigger/statistics/reset"; 11 | const _triggerResetTopic = "node-deepstackai-trigger/statistics/trigger/reset"; 12 | const _triggerMotionTopic = "node-deepstackai-trigger/motion"; 13 | 14 | export async function initialize(): Promise { 15 | if (!MqttManager.isEnabled) { 16 | return; 17 | } 18 | 19 | const client = MqttManager.client; 20 | 21 | try { 22 | log.info("Mqtt router", `Subscribing to ${_statusResetTopic}.`); 23 | await client.subscribe(_statusResetTopic); 24 | 25 | log.info("Mqtt router", `Subscribing to ${_triggerResetTopic}.`); 26 | await client.subscribe(_triggerResetTopic); 27 | 28 | log.info("Mqtt router", `Subscribing to ${_triggerMotionTopic}.`); 29 | await client.subscribe(_triggerMotionTopic); 30 | 31 | client.on("message", (topic, message) => processReceivedMessage(topic, message)); 32 | } catch (e) { 33 | log.warn("Mqtt router", `Unable to subscribe to topics: ${e}`); 34 | } 35 | } 36 | 37 | /** 38 | * Takes a topic and a message and maps it to the right action 39 | * @param topic The topic received 40 | * @param message The message received 41 | */ 42 | function processReceivedMessage(topic: string, message: Buffer): void { 43 | log.info("Mqtt router", `Received message: ${_statusResetTopic}`); 44 | 45 | if (topic === _statusResetTopic) { 46 | log.verbose("Mqtt router", `Received overall statistics reset request.`); 47 | TriggerManager.resetOverallStatistics(); 48 | return; 49 | } 50 | 51 | // All topics after this point must have a trigger name in the message 52 | let triggerName: string; 53 | try { 54 | triggerName = JSON.parse(message.toString())?.name; 55 | } catch (e) { 56 | log.warn("Mqtt router", `Unable to process incoming message: ${e}`); 57 | return; 58 | } 59 | 60 | if (!triggerName) { 61 | log.warn("Mqtt router", `Received a statistics reset request but no trigger name was provided`); 62 | } 63 | 64 | // Now that the name exists process the remaining topics 65 | if (topic === _triggerResetTopic) { 66 | log.verbose("Mqtt router", `Received trigger statistics reset request for ${triggerName}.`); 67 | 68 | TriggerManager.resetTriggerStatistics(triggerName); 69 | return; 70 | } 71 | 72 | if (topic === _triggerMotionTopic) { 73 | log.verbose("Mqtt router", `Received motion event for ${triggerName}.`); 74 | 75 | TriggerManager.activateWebTrigger(triggerName); 76 | return; 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /src/MustacheFormatter.ts: -------------------------------------------------------------------------------- 1 | /*--------------------------------------------------------------------------------------------- 2 | * Copyright (c) Neil Enns. All rights reserved. 3 | * Licensed under the MIT License. See LICENSE in the project root for license information. 4 | *--------------------------------------------------------------------------------------------*/ 5 | 6 | import Mustache from "mustache"; 7 | import path from "path"; 8 | 9 | import Trigger from "./Trigger"; 10 | import IDeepStackPrediction from "./types/IDeepStackPrediction"; 11 | 12 | export function formatPredictions(predictions: IDeepStackPrediction[]): string { 13 | return predictions 14 | .map(prediction => { 15 | return `${prediction.label} (${(prediction.confidence * 100).toFixed(0)}%)`; 16 | }) 17 | .join(", "); 18 | } 19 | 20 | export function formatStatistics(triggeredCount: number, analyzedFilesCount: number): string { 21 | return `Triggered count: ${triggeredCount} Analyzed file count: ${analyzedFilesCount}`; 22 | } 23 | 24 | function optionallyEncode(value: string, urlEncode: boolean): string { 25 | return urlEncode ? encodeURIComponent(value) : value; 26 | } 27 | /** 28 | * Replaces mustache templates in a string with values 29 | * @param template The template string to format 30 | * @param fileName The filename of the image being analyzed 31 | * @param trigger The trigger that was fired 32 | * @param predictions The predictions returned by the AI system 33 | */ 34 | export function format( 35 | template: string, 36 | fileName: string, 37 | trigger: Trigger, 38 | predictions: IDeepStackPrediction[], 39 | urlEncode = false, 40 | ): string { 41 | // Populate the payload wih the mustache template 42 | const view = { 43 | fileName: optionallyEncode(fileName, urlEncode), 44 | baseName: optionallyEncode(path.basename(fileName), urlEncode), 45 | predictions: optionallyEncode(JSON.stringify(predictions), urlEncode), 46 | analysisDurationMs: trigger.analysisDuration, 47 | formattedPredictions: optionallyEncode(formatPredictions(predictions), urlEncode), 48 | formattedStatistics: optionallyEncode( 49 | formatStatistics(trigger.triggeredCount, trigger.analyzedFilesCount), 50 | urlEncode, 51 | ), 52 | triggeredCount: trigger.triggeredCount, 53 | analyzedFilesCount: trigger.analyzedFilesCount, 54 | state: "on", 55 | name: trigger.name, 56 | }; 57 | 58 | // Turn off escaping 59 | Mustache.escape = text => { 60 | return text; 61 | }; 62 | 63 | return Mustache.render(template, view); 64 | } 65 | -------------------------------------------------------------------------------- /src/Rect.ts: -------------------------------------------------------------------------------- 1 | /*--------------------------------------------------------------------------------------------- 2 | * Copyright (c) Neil Enns. All rights reserved. 3 | * Licensed under the MIT License. See LICENSE in the project root for license information. 4 | *--------------------------------------------------------------------------------------------*/ 5 | /** 6 | * A simple class that represents a rectangle 7 | * via two points. 8 | */ 9 | export default class Rect { 10 | public xMinimum: number; 11 | public yMinimum: number; 12 | public xMaximum: number; 13 | public yMaximum: number; 14 | 15 | /** 16 | * Checks to see whether rectangles overlap 17 | * @param otherRect The rectangle to check against 18 | * @returns True if any portion of the rectangles overlap 19 | */ 20 | public overlaps(otherRect: Rect): boolean { 21 | // left, top, right, bottom 22 | // const predictedRect = new Rect(31, 125, 784, 1209); 23 | // testRect = new Rect(31, 125, 40, 160); 24 | 25 | const aLeftOfB = this.xMaximum < otherRect.xMinimum; 26 | const aRightOfB = this.xMinimum > otherRect.xMaximum; 27 | const aAboveB = this.yMinimum > otherRect.yMaximum; 28 | const aBelowB = this.yMaximum < otherRect.yMinimum; 29 | 30 | return !(aLeftOfB || aRightOfB || aAboveB || aBelowB); 31 | } 32 | 33 | public toString = (): string => { 34 | return `(${this.xMinimum}, ${this.yMinimum}, ${this.xMaximum}, ${this.yMaximum})`; 35 | }; 36 | 37 | constructor(xMinimum: number, yMinimum: number, xMaximum: number, yMaximum: number) { 38 | this.xMinimum = xMinimum; 39 | this.xMaximum = xMaximum; 40 | this.yMinimum = yMinimum; 41 | this.yMaximum = yMaximum; 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /src/Settings.ts: -------------------------------------------------------------------------------- 1 | /*--------------------------------------------------------------------------------------------- 2 | * Copyright (c) Neil Enns. All rights reserved. 3 | * Licensed under the MIT License. See LICENSE in the project root for license information. 4 | *--------------------------------------------------------------------------------------------*/ 5 | 6 | import * as helpers from "./helpers"; 7 | import * as log from "./Log"; 8 | 9 | import IMqttManagerConfigJson from "./handlers/mqttManager/IMqttManagerConfigJson"; 10 | import IPushbulletManagerConfigJson from "./handlers/pushbulletManager/IPushbulletManagerConfigJson"; 11 | import IPushoverManagerConfigJson from "./handlers/pushoverManager/IPushoverManagerConfigJson"; 12 | import ISettingsConfigJson from "./types/ISettingsConfigJson"; 13 | import ITelegramManagerConfigJson from "./handlers/telegramManager/ITelegramManagerConfigJson"; 14 | import IConfiguration from "./types/IConfiguration"; 15 | 16 | export let awaitWriteFinish: boolean; 17 | export let deepstackUri: string; 18 | export let enableAnnotations: boolean; 19 | export let enableWebServer: boolean; 20 | export let mqtt: IMqttManagerConfigJson; 21 | export let port: number; 22 | export let processExistingImages: boolean; 23 | export let purgeAge: number; 24 | export let purgeInterval: number; 25 | export let pushbullet: IPushbulletManagerConfigJson; 26 | export let pushover: IPushoverManagerConfigJson; 27 | export let telegram: ITelegramManagerConfigJson; 28 | export let verbose: boolean; 29 | 30 | /** 31 | * Takes an object with a path to a configuration file and path to a secrets file and loads all of the settings from it. 32 | * @param configurations A configuration object with the path to the configuration file and path to the secrets file 33 | * @returns A configuration object with path to the loaded configuration file and path to the loaded secrets file 34 | */ 35 | export function loadConfiguration(configurations: IConfiguration[]): IConfiguration { 36 | let settingsConfigJson: ISettingsConfigJson; 37 | let loadedConfiguration: IConfiguration; 38 | 39 | // Look through the list of possible loadable config files and try loading 40 | // them in turn until a valid one is found. 41 | configurations.some(configuration => { 42 | settingsConfigJson = helpers.readSettings( 43 | "Settings", 44 | configuration.baseFilePath, 45 | configuration.secretsFilePath, 46 | ); 47 | 48 | if (!settingsConfigJson) { 49 | return false; 50 | } 51 | 52 | loadedConfiguration = configuration; 53 | return true; 54 | }); 55 | 56 | // At this point there were no loadable files so bail. 57 | if (!settingsConfigJson) { 58 | throw Error("Unable to find any settings file."); 59 | } 60 | 61 | awaitWriteFinish = settingsConfigJson.awaitWriteFinish ?? false; 62 | deepstackUri = settingsConfigJson.deepstackUri; 63 | enableAnnotations = settingsConfigJson.enableAnnotations ?? false; 64 | // For backwards compatibility reasons enableWebServer is automatically true 65 | // when enableAnnotations is true. 66 | enableWebServer = enableAnnotations ? true : settingsConfigJson.enableWebServer ?? false; 67 | mqtt = settingsConfigJson.mqtt; 68 | port = settingsConfigJson.port ?? 4242; 69 | processExistingImages = settingsConfigJson.processExistingImages ?? false; 70 | purgeAge = settingsConfigJson.purgeAge ?? 30; 71 | purgeInterval = settingsConfigJson.purgeInterval ?? 60; 72 | pushbullet = settingsConfigJson.pushbullet; 73 | pushover = settingsConfigJson.pushover; 74 | telegram = settingsConfigJson.telegram; 75 | verbose = settingsConfigJson.verbose ?? false; 76 | 77 | log.info("Settings", `Loaded settings from ${loadedConfiguration.baseFilePath}`); 78 | 79 | return loadedConfiguration; 80 | } 81 | -------------------------------------------------------------------------------- /src/TriggerManager.ts: -------------------------------------------------------------------------------- 1 | /*--------------------------------------------------------------------------------------------- 2 | * Copyright (c) Neil Enns. All rights reserved. 3 | * Licensed under the MIT License. See LICENSE in the project root for license information. 4 | *--------------------------------------------------------------------------------------------*/ 5 | import * as helpers from "./helpers"; 6 | import * as log from "./Log"; 7 | import * as MQTTManager from "./handlers/mqttManager/MqttManager"; 8 | 9 | import ITriggerConfigJson from "./types/ITriggerConfigJson"; 10 | import glob from "glob" 11 | import MqttHandlerConfig from "./handlers/mqttManager/MqttHandlerConfig"; 12 | import PushbulletConfig from "./handlers/pushbulletManager/PushbulletConfig"; 13 | import PushoverConfig from "./handlers/pushoverManager/PushoverConfig"; 14 | import Rect from "./Rect"; 15 | import TelegramConfig from "./handlers/telegramManager/TelegramConfig"; 16 | import Trigger from "./Trigger"; 17 | import WebRequestConfig from "./handlers/webRequest/WebRequestConfig"; 18 | import ITriggerStatistics from "./types/ITriggerStatistics"; 19 | import IConfiguration from "./types/IConfiguration"; 20 | 21 | /** 22 | * Provides a running total of the number of times an image caused triggers 23 | * to fire. Use the incrementTriggeredCount() method to update the total. 24 | */ 25 | export let triggeredCount = 0; 26 | 27 | /** 28 | * Provides a running total of the number of files analyzed. 29 | * Use the incrementAnalyzedFiles() method to update the total. 30 | */ 31 | export let analyzedFilesCount = 0; 32 | 33 | /** 34 | * The list of all the triggers managed by this. 35 | */ 36 | export let triggers: Trigger[]; 37 | 38 | /** 39 | * Takes a path to a configuration file and loads all of the triggers from it. 40 | * @param configFilePath The path to the configuration file 41 | * @returns The path to the loaded configuration file 42 | */ 43 | export function loadConfiguration(configurations: IConfiguration[]): IConfiguration { 44 | let loadedConfiguration: IConfiguration; 45 | let triggerConfigJson: ITriggerConfigJson; 46 | 47 | // Reset triggers to empty in case this is getting hot reloaded 48 | triggers = []; 49 | 50 | // Look through the list of possible loadable config files and try loading 51 | // them in turn until a valid one is found. 52 | configurations.some(configuration => { 53 | triggerConfigJson = helpers.readSettings( 54 | "Triggers", 55 | configuration.baseFilePath, 56 | configuration.secretsFilePath, 57 | ); 58 | 59 | if (!triggerConfigJson) { 60 | return false; 61 | } 62 | 63 | loadedConfiguration = configuration; 64 | return true; 65 | }); 66 | 67 | // At this point there were no loadable files so bail. 68 | if (!triggerConfigJson) { 69 | throw Error( 70 | "Unable to find a trigger configuration file. Verify the trigger secret points to a file " + 71 | "called triggers.json or that the /config mount point contains a file called triggers.json.", 72 | ); 73 | } 74 | 75 | log.info("Triggers", `Loaded configuration from ${loadedConfiguration.baseFilePath}`); 76 | 77 | triggers = triggerConfigJson.triggers.map(triggerJson => { 78 | log.info("Triggers", `Loaded configuration for ${triggerJson.name}`); 79 | const configuredTrigger = new Trigger({ 80 | customEndpoint: triggerJson.customEndpoint, 81 | cooldownTime: triggerJson.cooldownTime, 82 | enabled: triggerJson.enabled ?? true, // If it isn't specified then enable the camera 83 | name: triggerJson.name, 84 | snapshotUri: triggerJson.snapshotUri, 85 | threshold: { 86 | minimum: triggerJson?.threshold?.minimum ?? 0, // If it isn't specified then just assume always trigger. 87 | maximum: triggerJson?.threshold?.maximum ?? 100, // If it isn't specified then just assume always trigger. 88 | }, 89 | watchPattern: triggerJson.watchPattern, 90 | watchObjects: triggerJson.watchObjects, 91 | }); 92 | 93 | // Set up the masks as real objects 94 | configuredTrigger.masks = triggerJson.masks?.map( 95 | mask => new Rect(mask.xMinimum, mask.yMinimum, mask.xMaximum, mask.yMaximum), 96 | ); 97 | // Set up the masks as real objects 98 | configuredTrigger.activateRegions = triggerJson.activateRegions?.map( 99 | mask => new Rect(mask.xMinimum, mask.yMinimum, mask.xMaximum, mask.yMaximum), 100 | ); 101 | 102 | // Set up the handlers 103 | if (triggerJson.handlers.mqtt) { 104 | configuredTrigger.mqttHandlerConfig = new MqttHandlerConfig(triggerJson.handlers.mqtt); 105 | } 106 | if (triggerJson.handlers.pushbullet) { 107 | configuredTrigger.pushbulletConfig = new PushbulletConfig(triggerJson.handlers.pushbullet); 108 | } 109 | if (triggerJson.handlers.pushover) { 110 | configuredTrigger.pushoverConfig = new PushoverConfig(triggerJson.handlers.pushover); 111 | } 112 | if (triggerJson.handlers.telegram) { 113 | configuredTrigger.telegramConfig = new TelegramConfig(triggerJson.handlers.telegram); 114 | } 115 | if (triggerJson.handlers.webRequest) { 116 | configuredTrigger.webRequestHandlerConfig = new WebRequestConfig(triggerJson.handlers.webRequest); 117 | } 118 | 119 | return configuredTrigger; 120 | }); 121 | 122 | return loadedConfiguration; 123 | } 124 | 125 | /** 126 | * Explicitly activates a trigger by name where the image must first be retrieved from a web address. 127 | * @param triggerName The name of the trigger to activate 128 | */ 129 | export async function activateWebTrigger(triggerName: string): Promise { 130 | // Find the trigger to activate. Do it case insensitive to avoid annoying 131 | // errors when the trigger name is capitalized slightly differently. 132 | const triggerToActivate = triggers.find(trigger => { 133 | return trigger.name.toLowerCase() === triggerName.toLowerCase(); 134 | }); 135 | 136 | if (!triggerToActivate) { 137 | log.warn("Trigger manager", `No trigger found matching ${triggerName}`); 138 | return; 139 | } 140 | 141 | log.verbose("Trigger manager", `Activating ${triggerToActivate.name} based on a web request.`); 142 | 143 | const fileName = await triggerToActivate.downloadWebImage(); 144 | 145 | // If no file name came back that means download failed for reason so just give up. 146 | if (!fileName) { 147 | return; 148 | } 149 | 150 | return triggerToActivate.processImage(fileName); 151 | } 152 | 153 | /** 154 | * Start all registered triggers watching for changes. 155 | */ 156 | export function startWatching(): void { 157 | triggers?.map(trigger => trigger.startWatching()); 158 | } 159 | 160 | /** 161 | * Stops all registered triggers from watching for changes. 162 | */ 163 | export async function stopWatching(): Promise { 164 | if (!triggers) { 165 | return; 166 | } 167 | 168 | return Promise.all(triggers.map(trigger => trigger.stopWatching())); 169 | } 170 | 171 | /** 172 | * Adds one to the triggered count total. 173 | */ 174 | export function incrementTriggeredCount(): void { 175 | triggeredCount += 1; 176 | } 177 | 178 | /** 179 | * Adds one to the false positive count total. 180 | */ 181 | export function incrementAnalyzedFilesCount(): void { 182 | analyzedFilesCount += 1; 183 | } 184 | 185 | /** 186 | * Gets a trigger's statistics 187 | * @param triggerName The name of the trigger to get the statistics for 188 | * @returns The triggers new statistics 189 | */ 190 | export function getTriggerStatistics(triggerName: string): ITriggerStatistics { 191 | return triggers 192 | .find(trigger => { 193 | return trigger.name.toLowerCase() === triggerName.toLowerCase(); 194 | }) 195 | ?.getStatistics(); 196 | } 197 | 198 | /** 199 | * Resets a trigger's statistics 200 | * @param triggerName The name of the trigger to reset the statistics for 201 | * @returns The trigger's new statistics 202 | */ 203 | export function resetTriggerStatistics(triggerName: string): ITriggerStatistics { 204 | const trigger = triggers.find(trigger => { 205 | return trigger.name.toLowerCase() === triggerName.toLowerCase(); 206 | }); 207 | 208 | if (!trigger) { 209 | return; 210 | } 211 | 212 | trigger.resetStatistics(); 213 | 214 | return trigger.getStatistics(); 215 | } 216 | 217 | /** 218 | * Returns the overall statistics 219 | */ 220 | export function getAllTriggerStatistics(): ITriggerStatistics[] { 221 | if (!triggers) { 222 | return; 223 | } 224 | 225 | return triggers.map(trigger => { 226 | return trigger.getStatistics(); 227 | }); 228 | } 229 | 230 | /** 231 | * Resets the statistics on every registered trigger. 232 | * @returns The new trigger statistics 233 | */ 234 | export function resetAllTriggerStatistics(): ITriggerStatistics[] { 235 | if (!triggers) { 236 | return; 237 | } 238 | 239 | return triggers.map(trigger => { 240 | return trigger.resetStatistics(); 241 | }); 242 | } 243 | 244 | /** 245 | * Gets the overall statistics for the system. 246 | * @returns The overall statistics for the system. 247 | */ 248 | export function getOverallStatistics(): ITriggerStatistics { 249 | return { 250 | analyzedFilesCount, 251 | triggeredCount, 252 | }; 253 | } 254 | 255 | /** 256 | * Resets the overall statistics, publishing the updated MQTT message if necessary. 257 | * @returns The new overall statistics 258 | */ 259 | export function resetOverallStatistics(): ITriggerStatistics { 260 | analyzedFilesCount = 0; 261 | triggeredCount = 0; 262 | 263 | MQTTManager.publishStatisticsMessage(triggeredCount, analyzedFilesCount); 264 | return getOverallStatistics(); 265 | } 266 | 267 | /** 268 | * Checks the watch folder on each trigger to see if there are images in it. If 269 | * not throws a warning. 270 | * @returns True if all the watch locations are valid, false otherwise. 271 | */ 272 | export function verifyTriggerWatchLocations(): boolean { 273 | const invalidWatchLocations = triggers?.filter(trigger => { 274 | let files: string[]; 275 | 276 | try { 277 | files = glob.sync(trigger.watchPattern); 278 | } catch (e) { 279 | log.warn( 280 | "Trigger manager", 281 | `Unable to read contents of watch folder ${trigger.watchPattern} for trigger ${trigger.name}. Check and make sure the image folder is mounted properly. ${e}`, 282 | ); 283 | return true; 284 | } 285 | 286 | log.verbose("Trigger manager", `There are ${files.length} images waiting in ${trigger.watchPattern} for ${trigger.name}.`); 287 | return false; 288 | }); 289 | 290 | // If no invalid watch locations were found then we're good to go and return true 291 | return invalidWatchLocations.length == 0; 292 | } 293 | -------------------------------------------------------------------------------- /src/WebServer.ts: -------------------------------------------------------------------------------- 1 | /*--------------------------------------------------------------------------------------------- 2 | * Copyright (c) Neil Enns. All rights reserved. 3 | * Licensed under the MIT License. See LICENSE in the project root for license information. 4 | *--------------------------------------------------------------------------------------------*/ 5 | import * as LocalStorageManager from "./LocalStorageManager"; 6 | import * as log from "./Log"; 7 | import * as Settings from "./Settings"; 8 | 9 | import { createHttpTerminator, HttpTerminator } from "http-terminator"; 10 | import express from "express"; 11 | import motionRouter from "./routes/motion"; 12 | import path from "path"; 13 | import { Server } from "http"; 14 | import statisticsRouter from "./routes/statistics"; 15 | import serveIndex from "serve-index"; 16 | 17 | const app = express(); 18 | let server: Server; 19 | let httpTerminator: HttpTerminator; 20 | 21 | export function startApp(): void { 22 | const annotatedImagePath = path.join(LocalStorageManager.localStoragePath, LocalStorageManager.Locations.Annotations); 23 | const originalsImagePath = path.join(LocalStorageManager.localStoragePath, LocalStorageManager.Locations.Originals); 24 | 25 | app.use("/", express.static(annotatedImagePath)); 26 | app.use( 27 | "/annotations", 28 | express.static(annotatedImagePath), 29 | serveIndex(annotatedImagePath, { icons: true, view: "details" }), 30 | ); 31 | app.use( 32 | "/originals", 33 | express.static(originalsImagePath), 34 | serveIndex(originalsImagePath, { icons: true, view: "details" }), 35 | ); 36 | app.use("/motion", motionRouter); 37 | app.use("/statistics", statisticsRouter); 38 | 39 | try { 40 | server = app.listen(Settings.port, () => log.info("Web server", `Listening at http://localhost:${Settings.port}`)); 41 | httpTerminator = createHttpTerminator({ 42 | server, 43 | }); 44 | } catch (e) { 45 | log.warn("Web server", `Unable to start web server: ${e.error}`); 46 | } 47 | } 48 | 49 | export async function stopApp(): Promise { 50 | if (server) { 51 | log.verbose("Web server", "Stopping."); 52 | await httpTerminator.terminate(); 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /src/controllers/motion.ts: -------------------------------------------------------------------------------- 1 | /*--------------------------------------------------------------------------------------------- 2 | * Copyright (c) Neil Enns. All rights reserved. 3 | * Licensed under the MIT License. See LICENSE in the project root for license information. 4 | *--------------------------------------------------------------------------------------------*/ 5 | import express from "express"; 6 | import * as log from "../Log"; 7 | import * as TriggerManager from "../TriggerManager"; 8 | 9 | export function handleMotionEvent(req: express.Request, res: express.Response): void { 10 | log.verbose("Web server", `Received motion event for ${req.params.triggerName}`); 11 | 12 | TriggerManager.activateWebTrigger(req.params.triggerName); 13 | 14 | res.json({ trigger: req.params.triggerName }); 15 | } 16 | -------------------------------------------------------------------------------- /src/controllers/statistics.ts: -------------------------------------------------------------------------------- 1 | /*--------------------------------------------------------------------------------------------- 2 | * Copyright (c) Neil Enns. All rights reserved. 3 | * Licensed under the MIT License. See LICENSE in the project root for license information. 4 | *--------------------------------------------------------------------------------------------*/ 5 | import express from "express"; 6 | import * as log from "../Log"; 7 | import * as TriggerManager from "../TriggerManager"; 8 | 9 | export function getAllTriggerStatistics(req: express.Request, res: express.Response): void { 10 | log.verbose("Web server", `Received statistics request for all triggers.`); 11 | 12 | res.json(TriggerManager.getAllTriggerStatistics()); 13 | } 14 | 15 | export function resetAllTriggerStatistics(req: express.Request, res: express.Response): void { 16 | log.verbose("Web server", `Received statistics reset request for all triggers.`); 17 | 18 | res.json(TriggerManager.resetAllTriggerStatistics()); 19 | } 20 | 21 | export function getTriggerStatistics(req: express.Request, res: express.Response): void { 22 | log.verbose("Web server", `Received statistics request for ${req.params.triggerName}.`); 23 | 24 | res.json(TriggerManager.getTriggerStatistics(req.params.triggerName)); 25 | } 26 | 27 | export function resetTriggerStatistics(req: express.Request, res: express.Response): void { 28 | log.verbose("Web server", `Received statistics reset request for ${req.params.triggerName}.`); 29 | 30 | res.json(TriggerManager.resetTriggerStatistics(req.params.triggerName)); 31 | } 32 | 33 | export function getOverallStatistics(req: express.Request, res: express.Response): void { 34 | log.verbose("Web server", `Received overall statistics request.`); 35 | 36 | res.json(TriggerManager.getOverallStatistics()); 37 | } 38 | 39 | export function resetOverallStatistics(req: express.Request, res: express.Response): void { 40 | log.verbose("Web server", `Received overall statistics reset request.`); 41 | 42 | res.json(TriggerManager.resetOverallStatistics()); 43 | } 44 | -------------------------------------------------------------------------------- /src/handlers/annotationManager/AnnotationManager.ts: -------------------------------------------------------------------------------- 1 | /*--------------------------------------------------------------------------------------------- 2 | * Copyright (c) Neil Enns. All rights reserved. 3 | * Licensed under the MIT License. See LICENSE in the project root for license information. 4 | *--------------------------------------------------------------------------------------------*/ 5 | 6 | import Trigger from "../../Trigger"; 7 | import IDeepStackPrediction from "../../types/IDeepStackPrediction"; 8 | import PImage from "pureimage"; 9 | 10 | import * as LocalStorageManager from "../../LocalStorageManager"; 11 | import * as log from "../../Log"; 12 | import * as fs from "fs"; 13 | import * as settings from "../../Settings"; 14 | 15 | export async function initialize(): Promise { 16 | // The font gets loaded once here to work around race condition issues that were happening. 17 | // Solution comes from https://github.com/joshmarinacci/node-pureimage/issues/52#issuecomment-368066557 18 | await PImage.registerFont("./fonts/CascadiaCode.ttf", "Cascadia Code").load(); 19 | } 20 | 21 | /** 22 | * Generates an annotated image based on a list of predictions and 23 | * saves it to local storage, if the enabled flag is true on 24 | * annotationManager. If false does nothing and returns immediately. 25 | * @param fileName Filename of the image to annotate 26 | * @param trigger Trigger that fired 27 | * @param predictions List of matching predictions 28 | */ 29 | export async function processTrigger( 30 | fileName: string, 31 | trigger: Trigger, 32 | predictions: IDeepStackPrediction[], 33 | ): Promise { 34 | if (!settings.enableAnnotations) { 35 | return; 36 | } 37 | 38 | log.verbose("Annotations", `Annotating ${fileName}`); 39 | const outputFileName = LocalStorageManager.mapToLocalStorage(LocalStorageManager.Locations.Annotations, fileName); 40 | 41 | const decodedImage = await PImage.decodeJPEGFromStream(fs.createReadStream(fileName)); 42 | const context = decodedImage.getContext("2d"); 43 | context.strokeStyle = "rgba(255,0,0,0.75)"; 44 | context.fillStyle = "rgba(255,0,0,0.75)"; 45 | context.font = "18pt Cascadia Code"; 46 | context.fontBaseline = "top"; 47 | 48 | predictions.map(prediction => { 49 | const width = prediction.x_max - prediction.x_min; 50 | const height = prediction.y_max - prediction.y_min; 51 | context.strokeRect(prediction.x_min, prediction.y_min, width, height); 52 | context.fillText( 53 | `${prediction.label} (${(prediction.confidence * 100).toFixed(0)}%)`, 54 | prediction.x_min + 10, 55 | prediction.y_min + 24, 56 | ); 57 | }); 58 | 59 | await PImage.encodeJPEGToStream(decodedImage, fs.createWriteStream(outputFileName), 75); 60 | log.verbose("Annotations", `Done annotating ${fileName}`); 61 | } 62 | -------------------------------------------------------------------------------- /src/handlers/mqttManager/IMqttHandlerConfigJson.ts: -------------------------------------------------------------------------------- 1 | /*--------------------------------------------------------------------------------------------- 2 | * Copyright (c) Neil Enns. All rights reserved. 3 | * Licensed under the MIT License. See LICENSE in the project root for license information. 4 | *--------------------------------------------------------------------------------------------*/ 5 | import IMqttMessageConfigJson from "./IMqttMessageConfigJson"; 6 | 7 | export default interface IMqttHandlerConfigJson { 8 | topic?: string; 9 | messages?: IMqttMessageConfigJson[]; 10 | enabled?: boolean; 11 | } 12 | -------------------------------------------------------------------------------- /src/handlers/mqttManager/IMqttManagerConfigJson.ts: -------------------------------------------------------------------------------- 1 | /*--------------------------------------------------------------------------------------------- 2 | * Copyright (c) Neil Enns. All rights reserved. 3 | * Licensed under the MIT License. See LICENSE in the project root for license information. 4 | *--------------------------------------------------------------------------------------------*/ 5 | export default interface IMqttManagerConfigJson { 6 | enabled?: boolean; 7 | password: string; 8 | rejectUnauthorized: boolean; 9 | retain?: boolean; 10 | uri: string; 11 | username: string; 12 | } 13 | -------------------------------------------------------------------------------- /src/handlers/mqttManager/IMqttMessageConfigJson.ts: -------------------------------------------------------------------------------- 1 | /*--------------------------------------------------------------------------------------------- 2 | * Copyright (c) Neil Enns. All rights reserved. 3 | * Licensed under the MIT License. See LICENSE in the project root for license information. 4 | *--------------------------------------------------------------------------------------------*/ 5 | export default interface IMqttMessageConfigJson { 6 | topic: string; 7 | payload?: string; 8 | offDelay?: number; 9 | } 10 | -------------------------------------------------------------------------------- /src/handlers/mqttManager/MqttHandlerConfig.ts: -------------------------------------------------------------------------------- 1 | /*--------------------------------------------------------------------------------------------- 2 | * Copyright (c) Neil Enns. All rights reserved. 3 | * Licensed under the MIT License. See LICENSE in the project root for license information. 4 | *--------------------------------------------------------------------------------------------*/ 5 | import MqttMessageConfig from "./MqttMessageConfig"; 6 | 7 | export default class MqttHandlerConfig { 8 | public topic?: string; 9 | public messages: MqttMessageConfig[]; 10 | public enabled: boolean; 11 | 12 | constructor(init?: Partial) { 13 | Object.assign(this, init); 14 | 15 | // Clear out anything that might have been set by Object.assign(). 16 | this.messages = []; 17 | 18 | // Create the messages. Done this way to get real objects 19 | // with a constructor that sets default values, instead of 20 | // raw property assignment done by Object.assign(). 21 | init?.messages?.map(rawMessage => { 22 | this.messages.push(new MqttMessageConfig(rawMessage)); 23 | }); 24 | 25 | // For backwards compatibility with installations prior to 26 | // allowing an array of messages this still supports specifying 27 | // a single topic. If that topic exists turn it into a 28 | // message config object. 29 | if (init?.topic) { 30 | this.messages.push(new MqttMessageConfig({ topic: this.topic })); 31 | } 32 | 33 | // Default for enabled is true if it isn't specified in the config file 34 | this.enabled = init?.enabled ?? true; 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /src/handlers/mqttManager/MqttManager.ts: -------------------------------------------------------------------------------- 1 | /*--------------------------------------------------------------------------------------------- 2 | * Copyright (c) Neil Enns. All rights reserved. 3 | * Licensed under the MIT License. See LICENSE in the project root for license information. 4 | *--------------------------------------------------------------------------------------------*/ 5 | import * as log from "../../Log"; 6 | import * as mustacheFormatter from "../../MustacheFormatter"; 7 | 8 | import IDeepStackPrediction from "../../types/IDeepStackPrediction"; 9 | import MQTT from "async-mqtt"; 10 | import { mqtt as settings } from "../../Settings"; 11 | import MqttMessageConfig from "./MqttMessageConfig"; 12 | import path from "path"; 13 | import Trigger from "../../Trigger"; 14 | 15 | export let client: MQTT.AsyncClient; 16 | export let isEnabled = false; 17 | export let retain = false; 18 | 19 | const _statusTopic = "node-deepstackai-trigger/status"; 20 | const _statisticsTopicPrefix = "node-deepstackai-trigger/statistics"; 21 | 22 | const _timers = new Map(); 23 | 24 | /** 25 | * Initializes the MQTT using settings from the global Settings module. 26 | */ 27 | export async function initialize(): Promise { 28 | if (!settings) { 29 | log.info("MQTT", "No MQTT settings specified. MQTT is disabled."); 30 | return; 31 | } 32 | 33 | // The enabled setting is true by default 34 | isEnabled = settings.enabled ?? true; 35 | 36 | if (!isEnabled) { 37 | log.info("MQTT", "MQTT is disabled via settings."); 38 | return; 39 | } 40 | 41 | if (settings.retain) { 42 | retain = settings.retain; 43 | log.info("MQTT", "Retain flag set in configuration. All messages will be published with retain turned on."); 44 | } 45 | 46 | client = await MQTT.connectAsync(settings.uri, { 47 | username: settings.username, 48 | password: settings.password, 49 | clientId: "node-deepstackai-trigger", 50 | rejectUnauthorized: settings.rejectUnauthorized ?? true, 51 | will: { 52 | topic: _statusTopic, 53 | payload: JSON.stringify({ state: "offline" }), 54 | qos: 2, 55 | retain: retain, 56 | }, 57 | }).catch(e => { 58 | isEnabled = false; 59 | throw new Error(`[MQTT] Unable to connect: ${e.message}`); 60 | }); 61 | 62 | log.info("MQTT", `Connected to MQTT server ${settings.uri}`); 63 | } 64 | 65 | export async function processTrigger( 66 | fileName: string, 67 | trigger: Trigger, 68 | // eslint-disable-next-line @typescript-eslint/no-unused-vars 69 | predictions: IDeepStackPrediction[], 70 | ): Promise { 71 | if (!isEnabled) { 72 | return []; 73 | } 74 | 75 | // It's possible to not set up an mqtt handler on a trigger or to disable it, so don't 76 | // process if that's the case. 77 | if (!trigger?.mqttHandlerConfig?.enabled) { 78 | return []; 79 | } 80 | 81 | // If for some reason we wound up with no messages configured do nothing. 82 | // This should never happen due to schema validation but better safe than crashing. 83 | if (!trigger?.mqttHandlerConfig?.messages) { 84 | return []; 85 | } 86 | 87 | return Promise.all([ 88 | // Publish all the detection messages 89 | ...trigger.mqttHandlerConfig?.messages.map(message => { 90 | return publishDetectionMessage(fileName, trigger, message, predictions); 91 | }), 92 | // Then publish the statistics message 93 | publishTriggerStatisticsMessage(trigger), 94 | ]); 95 | } 96 | 97 | async function publishDetectionMessage( 98 | fileName: string, 99 | trigger: Trigger, 100 | messageConfig: MqttMessageConfig, 101 | predictions: IDeepStackPrediction[], 102 | ): Promise { 103 | log.verbose("MQTT", `${fileName}: Publishing event to ${messageConfig.topic}`); 104 | 105 | // If an off delay is configured set up a timer to send the off message in the requested number of seconds 106 | if (messageConfig.offDelay) { 107 | const existingTimer = _timers.get(messageConfig.topic); 108 | 109 | // Cancel any timer that may still be running for the same topic 110 | if (existingTimer) { 111 | clearTimeout(existingTimer); 112 | } 113 | 114 | // Set the new timer 115 | _timers.set(messageConfig.topic, setTimeout(publishOffEvent, messageConfig.offDelay * 1000, messageConfig.topic)); 116 | } 117 | 118 | // Build the detection payload 119 | const detectionPayload = messageConfig.payload 120 | ? mustacheFormatter.format(messageConfig.payload, fileName, trigger, predictions) 121 | : JSON.stringify({ 122 | analysisDurationMs: trigger.analysisDuration, 123 | basename: path.basename(fileName), 124 | fileName, 125 | formattedPredictions: mustacheFormatter.formatPredictions(predictions), 126 | name: trigger.name, 127 | predictions, 128 | state: "on", 129 | }); 130 | 131 | return client.publish(messageConfig.topic, detectionPayload, { retain: retain }); 132 | } 133 | 134 | /** 135 | * Publishes the current statistics for the trigger to all registered MQTT messages on the handler 136 | * @param trigger The trigger to publish the statistics for 137 | */ 138 | export async function publishTriggerStatisticsMessage(trigger: Trigger): Promise { 139 | // It's possible to not set up an mqtt handler on a trigger or to disable it, so don't 140 | // process if that's the case. 141 | if (!trigger?.mqttHandlerConfig?.enabled) { 142 | return; 143 | } 144 | 145 | // If for some reason we wound up with no messages configured do nothing. 146 | // This should never happen due to schema validation but better safe than crashing. 147 | if (!trigger?.mqttHandlerConfig?.messages) { 148 | return; 149 | } 150 | 151 | // Send just the statistics 152 | return client.publish( 153 | path.join(_statisticsTopicPrefix, "trigger"), 154 | JSON.stringify({ 155 | analyzedFilesCount: trigger.analyzedFilesCount, 156 | formattedStatistics: mustacheFormatter.formatStatistics(trigger.triggeredCount, trigger.analyzedFilesCount), 157 | name: trigger.name, 158 | triggerCount: trigger.triggeredCount, 159 | }), 160 | { retain: retain }, 161 | ); 162 | } 163 | 164 | /** 165 | * Publishes statistics to MQTT 166 | * @param triggerCount Trigger count 167 | * @param analyzedFilesCount False positive count 168 | */ 169 | export async function publishStatisticsMessage( 170 | triggerCount: number, 171 | analyzedFilesCount: number, 172 | ): Promise { 173 | // Don't send anything if MQTT isn't enabled 174 | if (!client) { 175 | return []; 176 | } 177 | 178 | return [ 179 | await client.publish( 180 | _statusTopic, 181 | JSON.stringify({ 182 | // Ensures the status still reflects as up and running for people 183 | // that have an MQTT binary sensor in Home Assistant 184 | analyzedFilesCount, 185 | formattedStatistics: mustacheFormatter.formatStatistics(triggerCount, analyzedFilesCount), 186 | state: "online", 187 | triggerCount, 188 | }), 189 | { retain: retain }, 190 | ), 191 | ]; 192 | } 193 | 194 | /** 195 | * Sends a simple message indicating the service is up and running 196 | */ 197 | export async function publishServerState(state: string, details?: string): Promise { 198 | // Don't do anything if the MQTT client wasn't configured 199 | if (!client) { 200 | return; 201 | } 202 | 203 | return client.publish(_statusTopic, JSON.stringify({ state, details }), { retain: retain }); 204 | } 205 | 206 | /** 207 | * Sends a message indicating the motion for a particular trigger has stopped 208 | * @param topic The topic to publish the message on 209 | */ 210 | async function publishOffEvent(topic: string): Promise { 211 | return await client.publish(topic, JSON.stringify({ state: "off" }), { retain: retain }); 212 | } 213 | -------------------------------------------------------------------------------- /src/handlers/mqttManager/MqttMessageConfig.ts: -------------------------------------------------------------------------------- 1 | /*--------------------------------------------------------------------------------------------- 2 | * Copyright (c) Neil Enns. All rights reserved. 3 | * Licensed under the MIT License. See LICENSE in the project root for license information. 4 | *--------------------------------------------------------------------------------------------*/ 5 | export default class MqttMessageConfig { 6 | public topic: string; 7 | public offDelay?: number; 8 | public payload?: string; 9 | 10 | constructor(init?: Partial) { 11 | Object.assign(this, init); 12 | 13 | // Default offDelay is 30 seconds if not specified 14 | this.offDelay = init?.offDelay ?? 30; 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /src/handlers/pushbulletManager/IPushbulletManagerConfigJson.ts: -------------------------------------------------------------------------------- 1 | /*--------------------------------------------------------------------------------------------- 2 | * Copyright (c) Neil Enns. All rights reserved. 3 | * Licensed under the MIT License. See LICENSE in the project root for license information. 4 | *--------------------------------------------------------------------------------------------*/ 5 | export default interface IPushbulletManagerConfigJson { 6 | accessToken: string; 7 | enabled?: boolean; 8 | } 9 | -------------------------------------------------------------------------------- /src/handlers/pushbulletManager/IPushoverConfigJson.ts: -------------------------------------------------------------------------------- 1 | /*--------------------------------------------------------------------------------------------- 2 | * Copyright (c) Neil Enns. All rights reserved. 3 | * Licensed under the MIT License. See LICENSE in the project root for license information. 4 | *--------------------------------------------------------------------------------------------*/ 5 | export default interface IPushbulletConfigJson { 6 | annotateImage?: boolean; 7 | caption?: string; 8 | cooldownTime?: number; 9 | enabled?: boolean; 10 | title?: string; 11 | } 12 | -------------------------------------------------------------------------------- /src/handlers/pushbulletManager/PushbulletConfig.ts: -------------------------------------------------------------------------------- 1 | /*--------------------------------------------------------------------------------------------- 2 | * Copyright (c) Neil Enns. All rights reserved. 3 | * Licensed under the MIT License. See LICENSE in the project root for license information. 4 | *--------------------------------------------------------------------------------------------*/ 5 | export default class PushoverConfig { 6 | public annotateImage?: boolean; 7 | public caption?: string; 8 | public cooldownTime: number; 9 | public enabled: boolean; 10 | public title?: string; 11 | 12 | constructor(init?: Partial) { 13 | Object.assign(this, init); 14 | 15 | // Default for enabled is true if it isn't specified in the config file 16 | this.enabled = init?.enabled ?? true; 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /src/handlers/pushbulletManager/PushbulletManager.ts: -------------------------------------------------------------------------------- 1 | /*--------------------------------------------------------------------------------------------- 2 | * Copyright (c) Neil Enns. All rights reserved. 3 | * Licensed under the MIT License. See LICENSE in the project root for license information. 4 | *--------------------------------------------------------------------------------------------*/ 5 | 6 | import * as LocalStorageManager from "../../LocalStorageManager"; 7 | import * as log from "../../Log"; 8 | import * as mustacheFormatter from "../../MustacheFormatter"; 9 | import * as Settings from "../../Settings"; 10 | 11 | import IDeepStackPrediction from "../../types/IDeepStackPrediction"; 12 | import Trigger from "../../Trigger"; 13 | import PushbulletClient from "../../pushbulletClient/PushbulletClient"; 14 | import PushbulletMessage from "../../pushbulletClient/PushbulletMessage"; 15 | 16 | let _isEnabled = false; 17 | let _pushClient: PushbulletClient; 18 | 19 | // Tracks the last time each trigger fired, for use when calculating cooldown time windows 20 | const _cooldowns = new Map(); 21 | 22 | export async function initialize(): Promise { 23 | if (!Settings.pushbullet) { 24 | log.info("Pushbullet", "No Pushbullet settings specified. Pushbullet is disabled."); 25 | return; 26 | } 27 | 28 | // The enabled setting is true by default 29 | _isEnabled = Settings.pushbullet.enabled ?? true; 30 | 31 | if (!_isEnabled) { 32 | log.info("Pushbullet", "Pushbullet is disabled via settings."); 33 | return; 34 | } 35 | 36 | _pushClient = new PushbulletClient({ 37 | accessToken: Settings.pushbullet.accessToken, 38 | }); 39 | 40 | log.info("Pushbullet", `Pushbullet enabled.`); 41 | } 42 | 43 | export async function processTrigger( 44 | fileName: string, 45 | trigger: Trigger, 46 | predictions: IDeepStackPrediction[], 47 | ): Promise { 48 | if (!_isEnabled) { 49 | return; 50 | } 51 | 52 | // It's possible to not set up a Pushbullet handler on a trigger or to disable it, so don't 53 | // process if that's the case. 54 | if (!trigger?.pushbulletConfig?.enabled) { 55 | return; 56 | } 57 | 58 | // Don't send if within the cooldown time. 59 | if (!passesCooldownTime(fileName, trigger)) { 60 | return; 61 | } 62 | 63 | // Save the trigger's last fire time. 64 | _cooldowns.set(trigger, new Date()); 65 | 66 | // Do mustache variable replacement if a custom caption was provided. 67 | const caption = trigger.pushbulletConfig.caption 68 | ? mustacheFormatter.format(trigger.pushbulletConfig.caption, fileName, trigger, predictions) 69 | : trigger.name; 70 | 71 | // Do mustache variable replacement if a custom title was provided. 72 | const title = trigger.pushbulletConfig.title 73 | ? mustacheFormatter.format(trigger.pushbulletConfig.title, fileName, trigger, predictions) 74 | : undefined; 75 | 76 | // Figure out the path to the file to send based on whether 77 | // annotated images were requested in the config. 78 | const imageFileName = 79 | trigger.pushbulletConfig.annotateImage && Settings.enableAnnotations 80 | ? LocalStorageManager.mapToLocalStorage(LocalStorageManager.Locations.Annotations, fileName) 81 | : fileName; 82 | 83 | // Throw a warning if annotations was requested but it wasn't enabled in settings 84 | if (trigger.pushbulletConfig.annotateImage && !Settings.enableAnnotations) { 85 | log.warn( 86 | "Pushbullet", 87 | `annotateImage is enabled on the trigger however enableAnnotations isn't true in settings.json. Make sure enableAnnotations is set to true to use annotated images.`, 88 | ); 89 | } 90 | 91 | // Build the Pushbullet message options. 92 | const pushbulletMessage = new PushbulletMessage({ 93 | body: caption, 94 | imageFileName: imageFileName, 95 | title: title, 96 | }); 97 | 98 | try { 99 | // This returns an array to keep it consistent with all the other managers. 100 | return [await sendPushbulletMessage(pushbulletMessage)]; 101 | } catch (e) { 102 | log.warn("Pushbullet", `Unable to send message: ${e.error}`); 103 | return; 104 | } 105 | } 106 | 107 | /** 108 | * Sends a message to Pushbullet. 109 | * @param message The message to send 110 | */ 111 | async function sendPushbulletMessage(message: PushbulletMessage): Promise { 112 | log.verbose("Pushbullet", `Sending message`); 113 | return await _pushClient.push(message); 114 | } 115 | 116 | /** 117 | * Checks to see if a trigger fired within the cooldown window 118 | * specified for the Pushbullet handler. 119 | * @param fileName The filename of the image that fired the trigger 120 | * @param trigger The trigger 121 | * @returns true if the trigger happened outside of the cooldown window 122 | */ 123 | function passesCooldownTime(fileName: string, trigger: Trigger): boolean { 124 | const lastTriggerTime = _cooldowns.get(trigger); 125 | 126 | // If this was never triggered then no cooldown applies. 127 | if (!lastTriggerTime) { 128 | return true; 129 | } 130 | 131 | // getTime() returns milliseconds so divide by 1000 to get seconds 132 | const secondsSinceLastTrigger = (trigger.receivedDate.getTime() - lastTriggerTime.getTime()) / 1000; 133 | 134 | if (secondsSinceLastTrigger < trigger.pushbulletConfig.cooldownTime) { 135 | log.verbose( 136 | `Pushbullet`, 137 | `${fileName}: Skipping sending message as the cooldown period of ${trigger.pushbulletConfig.cooldownTime} seconds hasn't expired.`, 138 | ); 139 | return false; 140 | } 141 | 142 | return true; 143 | } 144 | -------------------------------------------------------------------------------- /src/handlers/pushoverManager/IPushoverConfigJson.ts: -------------------------------------------------------------------------------- 1 | /*--------------------------------------------------------------------------------------------- 2 | * Copyright (c) Neil Enns. All rights reserved. 3 | * Licensed under the MIT License. See LICENSE in the project root for license information. 4 | *--------------------------------------------------------------------------------------------*/ 5 | export default interface IPushoverConfigJson { 6 | caption?: string; 7 | userKeys: string[]; 8 | annotateImage?: boolean; 9 | cooldownTime: number; 10 | sound?: string; 11 | } 12 | -------------------------------------------------------------------------------- /src/handlers/pushoverManager/IPushoverManagerConfigJson.ts: -------------------------------------------------------------------------------- 1 | /*--------------------------------------------------------------------------------------------- 2 | * Copyright (c) Neil Enns. All rights reserved. 3 | * Licensed under the MIT License. See LICENSE in the project root for license information. 4 | *--------------------------------------------------------------------------------------------*/ 5 | export default interface IPushoverManagerConfigJson { 6 | apiKey: string; 7 | enabled?: boolean; 8 | userKey: string; 9 | } 10 | -------------------------------------------------------------------------------- /src/handlers/pushoverManager/PushoverConfig.ts: -------------------------------------------------------------------------------- 1 | /*--------------------------------------------------------------------------------------------- 2 | * Copyright (c) Neil Enns. All rights reserved. 3 | * Licensed under the MIT License. See LICENSE in the project root for license information. 4 | *--------------------------------------------------------------------------------------------*/ 5 | export default class PushoverConfig { 6 | public userKeys: string[]; 7 | public caption?: string; 8 | public enabled: boolean; 9 | public cooldownTime: number; 10 | public annotateImage?: boolean; 11 | public sound?: string; 12 | 13 | constructor(init?: Partial) { 14 | Object.assign(this, init); 15 | 16 | // Default for enabled is true if it isn't specified in the config file 17 | this.enabled = init?.enabled ?? true; 18 | 19 | // Default for the sound is Pushover default if undefined 20 | this.sound = init?.sound ?? "pushover"; 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/handlers/pushoverManager/PushoverManager.ts: -------------------------------------------------------------------------------- 1 | /*--------------------------------------------------------------------------------------------- 2 | * Copyright (c) Neil Enns. All rights reserved. 3 | * Licensed under the MIT License. See LICENSE in the project root for license information. 4 | *--------------------------------------------------------------------------------------------*/ 5 | 6 | import * as LocalStorageManager from "../../LocalStorageManager"; 7 | import * as log from "../../Log"; 8 | import * as mustacheFormatter from "../../MustacheFormatter"; 9 | import * as Settings from "../../Settings"; 10 | 11 | import IDeepStackPrediction from "../../types/IDeepStackPrediction"; 12 | import PushoverClient from "../../pushoverClient/PushoverClient"; 13 | import PushoverMessage from "../../pushoverClient/PushoverMessage"; 14 | import Trigger from "../../Trigger"; 15 | 16 | let _isEnabled = false; 17 | let _pushClient: PushoverClient; 18 | 19 | // Tracks the last time each trigger fired, for use when calculating cooldown time windows 20 | const _cooldowns = new Map(); 21 | 22 | export async function initialize(): Promise { 23 | if (!Settings.pushover) { 24 | log.info("Pushover", "No Pushover settings specified. Pushover is disabled."); 25 | return; 26 | } 27 | 28 | // The enabled setting is true by default 29 | _isEnabled = Settings.pushover.enabled ?? true; 30 | 31 | if (!_isEnabled) { 32 | log.info("Pushover", "Pushover is disabled via settings."); 33 | return; 34 | } 35 | 36 | _pushClient = new PushoverClient({ 37 | apiKey: Settings.pushover.apiKey, 38 | userKey: Settings.pushover.userKey, 39 | }); 40 | 41 | log.info("Pushover", `Pushover enabled.`); 42 | } 43 | 44 | export async function processTrigger( 45 | fileName: string, 46 | trigger: Trigger, 47 | predictions: IDeepStackPrediction[], 48 | ): Promise { 49 | if (!_isEnabled) { 50 | return; 51 | } 52 | 53 | // It's possible to not set up a Pushover handler on a trigger or to disable it, so don't 54 | // process if that's the case. 55 | if (!trigger?.pushoverConfig?.enabled) { 56 | return; 57 | } 58 | 59 | // Don't send if within the cooldown time. 60 | if (!passesCooldownTime(fileName, trigger)) { 61 | return; 62 | } 63 | 64 | // Save the trigger's last fire time. 65 | _cooldowns.set(trigger, new Date()); 66 | 67 | // Do mustache variable replacement if a custom caption was provided. 68 | const caption = trigger.pushoverConfig.caption 69 | ? mustacheFormatter.format(trigger.pushoverConfig.caption, fileName, trigger, predictions) 70 | : trigger.name; 71 | 72 | // Throw a warning if annotations was requested but it wasn't enabled in settings 73 | if (trigger.pushoverConfig.annotateImage && !Settings.enableAnnotations) { 74 | log.warn( 75 | "Pushover", 76 | `annotateImage is enabled on the trigger however enableAnnotations isn't true in settings.json. Make sure enableAnnotations is set to true to use annotated images.`, 77 | ); 78 | } 79 | 80 | // Figure out the path to the file to send based on whether 81 | // annotated images were requested in the config. 82 | const imageFileName = 83 | trigger.pushoverConfig.annotateImage && Settings.enableAnnotations 84 | ? LocalStorageManager.mapToLocalStorage(LocalStorageManager.Locations.Annotations, fileName) 85 | : fileName; 86 | 87 | // Build the pushover message options. 88 | const pushoverMessage = new PushoverMessage({ 89 | imageFileName: imageFileName, 90 | message: caption, 91 | sound: trigger.pushoverConfig.sound, 92 | }); 93 | 94 | // Send all the messages. 95 | try { 96 | return Promise.all( 97 | trigger.pushoverConfig.userKeys.map(user => { 98 | pushoverMessage.userKey = user; 99 | sendPushoverMessage(pushoverMessage); 100 | }), 101 | ); 102 | } catch (e) { 103 | log.warn("Pushover", `Unable to send message: ${e.error}`); 104 | return; 105 | } 106 | } 107 | 108 | async function sendPushoverMessage(message: PushoverMessage): Promise { 109 | log.verbose("Pushover", `Sending message to ${message.userKey}`); 110 | 111 | return await _pushClient.send(message); 112 | } 113 | 114 | /** 115 | * Checks to see if a trigger fired within the cooldown window 116 | * specified for the Pushover handler. 117 | * @param fileName The filename of the image that fired the trigger 118 | * @param trigger The trigger 119 | * @returns true if the trigger happened outside of the cooldown window 120 | */ 121 | function passesCooldownTime(fileName: string, trigger: Trigger): boolean { 122 | const lastTriggerTime = _cooldowns.get(trigger); 123 | 124 | // If this was never triggered then no cooldown applies. 125 | if (!lastTriggerTime) { 126 | return true; 127 | } 128 | 129 | // getTime() returns milliseconds so divide by 1000 to get seconds 130 | const secondsSinceLastTrigger = (trigger.receivedDate.getTime() - lastTriggerTime.getTime()) / 1000; 131 | 132 | if (secondsSinceLastTrigger < trigger.pushoverConfig.cooldownTime) { 133 | log.verbose( 134 | `Pushover`, 135 | `${fileName}: Skipping sending message as the cooldown period of ${trigger.pushoverConfig.cooldownTime} seconds hasn't expired.`, 136 | ); 137 | return false; 138 | } 139 | 140 | return true; 141 | } 142 | -------------------------------------------------------------------------------- /src/handlers/telegramManager/ITelegramConfigJson.ts: -------------------------------------------------------------------------------- 1 | /*--------------------------------------------------------------------------------------------- 2 | * Copyright (c) Neil Enns. All rights reserved. 3 | * Licensed under the MIT License. See LICENSE in the project root for license information. 4 | *--------------------------------------------------------------------------------------------*/ 5 | export default interface ITelegramConfigJson { 6 | caption?: string; 7 | chatIds: number[]; 8 | annotateImage?: boolean; 9 | cooldownTime: number; 10 | } 11 | -------------------------------------------------------------------------------- /src/handlers/telegramManager/ITelegramManagerConfigJson.ts: -------------------------------------------------------------------------------- 1 | /*--------------------------------------------------------------------------------------------- 2 | * Copyright (c) Neil Enns. All rights reserved. 3 | * Licensed under the MIT License. See LICENSE in the project root for license information. 4 | *--------------------------------------------------------------------------------------------*/ 5 | export default interface ITelegramManagerConfigJson { 6 | botToken: string; 7 | enabled?: boolean; 8 | } 9 | -------------------------------------------------------------------------------- /src/handlers/telegramManager/TelegramConfig.ts: -------------------------------------------------------------------------------- 1 | /*--------------------------------------------------------------------------------------------- 2 | * Copyright (c) Neil Enns. All rights reserved. 3 | * Licensed under the MIT License. See LICENSE in the project root for license information. 4 | *--------------------------------------------------------------------------------------------*/ 5 | export default class TelegramConfig { 6 | public chatIds: number[]; 7 | public caption?: string; 8 | public enabled: boolean; 9 | public cooldownTime: number; 10 | public annotateImage?: boolean; 11 | 12 | constructor(init?: Partial) { 13 | Object.assign(this, init); 14 | 15 | // Default for enabled is true if it isn't specified in the config file 16 | this.enabled = init?.enabled ?? true; 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /src/handlers/telegramManager/TelegramManager.ts: -------------------------------------------------------------------------------- 1 | /*--------------------------------------------------------------------------------------------- 2 | * Copyright (c) Neil Enns. All rights reserved. 3 | * Licensed under the MIT License. See LICENSE in the project root for license information. 4 | *--------------------------------------------------------------------------------------------*/ 5 | // See https://github.com/yagop/node-telegram-bot-api/blob/master/doc/usage.md#file-options-metadata 6 | process.env.NTBA_FIX_350 = "true"; 7 | 8 | import * as LocalStorageManager from "../../LocalStorageManager"; 9 | import * as log from "../../Log"; 10 | import * as mustacheFormatter from "../../MustacheFormatter"; 11 | import * as Settings from "../../Settings"; 12 | 13 | import IDeepStackPrediction from "../../types/IDeepStackPrediction"; 14 | import { promises as fsPromise } from "fs"; 15 | import TelegramBot from "node-telegram-bot-api"; 16 | import Trigger from "../../Trigger"; 17 | 18 | let isEnabled = false; 19 | let telegramBot: TelegramBot; 20 | 21 | // Tracks the last time each trigger fired, for use when calculating cooldown time windows 22 | const cooldowns = new Map(); 23 | 24 | export async function initialize(): Promise { 25 | if (!Settings.telegram) { 26 | log.info("Telegram", "No Telegram settings specified. Telegram is disabled."); 27 | return; 28 | } 29 | 30 | // The enabled setting is true by default 31 | isEnabled = Settings.telegram.enabled ?? true; 32 | 33 | if (!isEnabled) { 34 | log.info("Telegram", "Telegram is disabled via settings."); 35 | return; 36 | } 37 | 38 | telegramBot = new TelegramBot(Settings.telegram.botToken, { 39 | filepath: false, 40 | }); 41 | 42 | log.info("Telegram", "Telegram enabled."); 43 | } 44 | 45 | export async function processTrigger( 46 | fileName: string, 47 | trigger: Trigger, 48 | predictions: IDeepStackPrediction[], 49 | ): Promise { 50 | if (!isEnabled) { 51 | return []; 52 | } 53 | 54 | // It's possible to not set up an Telegram handler on a trigger or to disable it, so don't 55 | // process if that's the case. 56 | if (!trigger?.telegramConfig?.enabled) { 57 | return []; 58 | } 59 | 60 | // Don't send if within the cooldown time 61 | if (!passesCooldownTime(fileName, trigger)) { 62 | return []; 63 | } 64 | 65 | // Save the trigger's last fire time 66 | cooldowns.set(trigger, new Date()); 67 | 68 | // Do mustache variable replacement if a custom caption was provided. 69 | const caption = trigger.telegramConfig.caption 70 | ? mustacheFormatter.format(trigger.telegramConfig.caption, fileName, trigger, predictions) 71 | : trigger.name; 72 | 73 | // Throw a warning if annotations was requested but it wasn't enabled in settings 74 | if (trigger.telegramConfig.annotateImage && !Settings.enableAnnotations) { 75 | log.warn( 76 | "Telegram", 77 | `annotateImage is enabled on the trigger however enableAnnotations isn't true in settings.json. Make sure enableAnnotations is set to true to use annotated images.`, 78 | ); 79 | } 80 | 81 | // Figure out the path to the file to send based on whether 82 | // annotated images were requested in the config. 83 | const imageFileName = 84 | trigger.telegramConfig.annotateImage && Settings.enableAnnotations 85 | ? LocalStorageManager.mapToLocalStorage(LocalStorageManager.Locations.Annotations, fileName) 86 | : fileName; 87 | 88 | // Send all the messages 89 | try { 90 | return Promise.all( 91 | trigger.telegramConfig.chatIds.map(chatId => sendTelegramMessage(caption, imageFileName, chatId)), 92 | ); 93 | } catch (e) { 94 | log.warn("Telegram", `Unable to send message: ${e.error}`); 95 | return []; 96 | } 97 | } 98 | 99 | async function sendTelegramMessage( 100 | triggerName: string, 101 | fileName: string, 102 | chatId: number, 103 | ): Promise { 104 | log.verbose("Telegram", `Sending message to ${chatId}`); 105 | 106 | const imageBuffer = await fsPromise.readFile(fileName).catch(e => { 107 | log.warn("Telegram", `Unable to load file: ${e.message}`); 108 | return undefined; 109 | }); 110 | 111 | const message = telegramBot 112 | .sendPhoto(chatId, imageBuffer, { 113 | caption: triggerName, 114 | }) 115 | .catch(e => { 116 | log.warn("Telegram", `Unable to send message: ${e.message}`); 117 | return undefined; 118 | }); 119 | 120 | return message; 121 | } 122 | 123 | /** 124 | * Checks to see if a trigger fired within the cooldown window 125 | * specified for the Telegram handler. 126 | * @param fileName The filename of the image that fired the trigger 127 | * @param trigger The trigger 128 | * @returns true if the trigger happened outside of the cooldown window 129 | */ 130 | function passesCooldownTime(fileName: string, trigger: Trigger): boolean { 131 | const lastTriggerTime = cooldowns.get(trigger); 132 | 133 | // If this was never triggered then no cooldown applies. 134 | if (!lastTriggerTime) { 135 | return true; 136 | } 137 | 138 | // getTime() returns milliseconds so divide by 1000 to get seconds 139 | const secondsSinceLastTrigger = (trigger.receivedDate.getTime() - lastTriggerTime.getTime()) / 1000; 140 | 141 | if (secondsSinceLastTrigger < trigger.telegramConfig.cooldownTime) { 142 | log.verbose( 143 | `Telegram`, 144 | `${fileName}: Skipping sending message as the cooldown period of ${trigger.telegramConfig.cooldownTime} seconds hasn't expired.`, 145 | ); 146 | return false; 147 | } 148 | 149 | return true; 150 | } 151 | -------------------------------------------------------------------------------- /src/handlers/webRequest/IWebRequestHandlerJson.ts: -------------------------------------------------------------------------------- 1 | /*--------------------------------------------------------------------------------------------- 2 | * Copyright (c) Neil Enns. All rights reserved. 3 | * Licensed under the MIT License. See LICENSE in the project root for license information. 4 | *--------------------------------------------------------------------------------------------*/ 5 | export default interface IWebRequestHandlerJson { 6 | triggerUris: string[]; 7 | } 8 | -------------------------------------------------------------------------------- /src/handlers/webRequest/WebRequestConfig.ts: -------------------------------------------------------------------------------- 1 | /*--------------------------------------------------------------------------------------------- 2 | * Copyright (c) Neil Enns. All rights reserved. 3 | * Licensed under the MIT License. See LICENSE in the project root for license information. 4 | *--------------------------------------------------------------------------------------------*/ 5 | export default class WebRequestConfig { 6 | public triggerUris: string[]; 7 | public enabled: boolean; 8 | 9 | constructor(init?: Partial) { 10 | Object.assign(this, init); 11 | 12 | // Default for enabled is true if it isn't specified in the config file 13 | this.enabled = init?.enabled ?? true; 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /src/handlers/webRequest/WebRequestHandler.ts: -------------------------------------------------------------------------------- 1 | /*--------------------------------------------------------------------------------------------- 2 | * Copyright (c) Neil Enns. All rights reserved. 3 | * Licensed under the MIT License. See LICENSE in the project root for license information. 4 | *--------------------------------------------------------------------------------------------*/ 5 | import request from "request-promise-native"; 6 | 7 | import * as log from "../../Log"; 8 | import Trigger from "../../Trigger"; 9 | import IDeepStackPrediction from "../../types/IDeepStackPrediction"; 10 | import * as mustacheFormatter from "../../MustacheFormatter"; 11 | 12 | /** 13 | * Handles calling a list of web URLs. 14 | */ 15 | export async function processTrigger( 16 | fileName: string, 17 | trigger: Trigger, 18 | // eslint-disable-next-line @typescript-eslint/no-unused-vars 19 | predictions: IDeepStackPrediction[], 20 | ): Promise { 21 | // It's possible to not set up a web request handler on a trigger or to disable it, so don't 22 | // process if that's the case. 23 | if (!trigger?.webRequestHandlerConfig?.enabled) { 24 | return []; 25 | } 26 | 27 | return Promise.all( 28 | trigger.webRequestHandlerConfig.triggerUris?.map(uri => { 29 | const formattedUri = mustacheFormatter.format(uri, fileName, trigger, predictions, true); 30 | return callTriggerUri(fileName, trigger, formattedUri); 31 | }), 32 | ); 33 | } 34 | 35 | /** 36 | * Calls a single trigger uri, handling failures if any. 37 | * @param uri The uri to trigger 38 | */ 39 | async function callTriggerUri(fileName: string, trigger: Trigger, uri: string): Promise { 40 | log.verbose("Web request", `${fileName}: Calling trigger uri ${uri}`); 41 | try { 42 | await request.get(uri); 43 | } catch (e) { 44 | log.warn("Web request", `${fileName}: Failed to call trigger uri ${uri}: ${e}`); 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /src/helpers.ts: -------------------------------------------------------------------------------- 1 | /*--------------------------------------------------------------------------------------------- 2 | * Copyright (c) Neil Enns. All rights reserved. 3 | * Licensed under the MIT License. See LICENSE in the project root for license information. 4 | *--------------------------------------------------------------------------------------------*/ 5 | import * as fs from "fs"; 6 | import * as JSONC from "jsonc-parser"; 7 | import Mustache from "mustache"; 8 | import * as log from "./Log"; 9 | 10 | function readFile(serviceName: string, fileType: string, filePath: string): string { 11 | try { 12 | return fs.readFileSync(filePath, "utf-8"); 13 | } catch (e) { 14 | log.warn(serviceName, `Unable to read the ${fileType} file: ${e.message}.`); 15 | return null; 16 | } 17 | } 18 | 19 | function parseFile(serviceName: string, fileType: string, filePath: string) { 20 | const file = readFile(serviceName, fileType, filePath); 21 | return file ? JSONC.parse(file) : null; 22 | } 23 | 24 | /** 25 | * Mustache renders secrets into settings and validates settings with JSON schema, then returns it as a typed object. 26 | * @param settings The settings of type T 27 | * @param secrets An object of strings representing secrets 28 | * @type T The type the settings should return as. 29 | */ 30 | function replaceSecrets(settings: T, secrets: { a: string }) { 31 | // If no secrets were provided don't attempt to do a replacement 32 | if (!secrets) return settings; 33 | 34 | return JSONC.parse(Mustache.render(JSON.stringify(settings), secrets)); 35 | } 36 | 37 | /** 38 | * Loads a settings file and validates it with JSON schema, then returns it as a typed object. 39 | * @param serviceName The name of the service loading the settings. Used in log messages. 40 | * @param settingsFileName The path to the file to load. 41 | * @type T The type the settings should return as. 42 | */ 43 | export function readSettings(serviceName: string, serviceFilePath: string, secretsFilePath = ""): T { 44 | const settings = parseFile(serviceName, "settings", serviceFilePath); 45 | if (!settings) { 46 | log.warn(serviceName, `Unable to load file ${serviceFilePath}.`); 47 | return null; 48 | } 49 | const secrets = parseFile(serviceName, "secrets", secretsFilePath); 50 | return replaceSecrets(settings, secrets); 51 | } 52 | -------------------------------------------------------------------------------- /src/main.ts: -------------------------------------------------------------------------------- 1 | /*--------------------------------------------------------------------------------------------- 2 | * Copyright (c) Neil Enns. All rights reserved. 3 | * Licensed under the MIT License. See LICENSE in the project root for license information. 4 | *--------------------------------------------------------------------------------------------*/ 5 | 6 | // See https://github.com/yagop/node-telegram-bot-api/issues/319 7 | process.env.NTBA_FIX_319 = "true"; 8 | 9 | import * as AnnotationManager from "./handlers/annotationManager/AnnotationManager"; 10 | import * as chokidar from "chokidar"; 11 | import * as LocalStorageManager from "./LocalStorageManager"; 12 | import * as log from "./Log"; 13 | import * as MqttManager from "./handlers/mqttManager/MqttManager"; 14 | import * as MqttRouter from "./MqttRouter"; 15 | import * as PushbulletManager from "./handlers/pushbulletManager/PushbulletManager"; 16 | import * as PushoverManager from "./handlers/pushoverManager/PushoverManager"; 17 | import * as Settings from "./Settings"; 18 | import * as TelegramManager from "./handlers/telegramManager/TelegramManager"; 19 | import * as TriggerManager from "./TriggerManager"; 20 | import * as WebServer from "./WebServer"; 21 | import IConfiguration from "./types/IConfiguration"; 22 | 23 | import npmPackageInfo from "../package.json"; 24 | 25 | // Health message is sent via MQTT every 60 seconds 26 | const healthWaitTime = 60 * 1000; 27 | // If startup fails restart is reattempted 5 times every 30 seconds. 28 | const restartAttemptWaitTime = 30 * 1000; 29 | const maxRestartAttempts = 5; 30 | 31 | // The list of settings file watchers, used to stop them on hot reloading of settings. 32 | const watchers: chokidar.FSWatcher[] = []; 33 | 34 | let healthTimer: NodeJS.Timeout; 35 | let restartAttemptCount = 0; 36 | let restartTimer: NodeJS.Timeout; 37 | let settingsConfiguration: IConfiguration; 38 | let triggersConfiguration: IConfiguration; 39 | 40 | function validateEnvironmentVariables(): boolean { 41 | let isValid = true; 42 | 43 | if (!process.env.TZ) { 44 | log.error("Main", "Required environment variable TZ is missing."); 45 | isValid = false; 46 | } 47 | 48 | return isValid; 49 | } 50 | 51 | async function startup(): Promise { 52 | log.info("Main", "****************************************"); 53 | log.info("Main", `Starting up version ${npmPackageInfo.version}`); 54 | log.info("Main", `Timezone offset is ${new Date().getTimezoneOffset()}`); 55 | log.info("Main", `Current time is ${new Date()}`); 56 | 57 | try { 58 | // Load the settings file. 59 | settingsConfiguration = Settings.loadConfiguration([ 60 | { 61 | baseFilePath: "/run/secrets/settings", 62 | secretsFilePath: "/run/secrets/secrets", 63 | }, 64 | { 65 | baseFilePath: "/config/settings.json", 66 | secretsFilePath: "/config/secrets.json", 67 | }, 68 | ]); 69 | 70 | // MQTT manager loads first so if it succeeds but other things fail we can report the failures via MQTT. 71 | await MqttManager.initialize(); 72 | 73 | // Check the environment variables are right. 74 | if (!validateEnvironmentVariables()) { 75 | throw Error( 76 | `At least one required environment variable is missing. Ensure all required environment variables are set then run again.`, 77 | ); 78 | } 79 | 80 | // To make things simpler just enable local storage all the time. It won't 81 | // do anything harmful if it's unused, just the occasional background purge 82 | // that runs. 83 | await LocalStorageManager.initializeStorage(); 84 | LocalStorageManager.startBackgroundPurge(); 85 | 86 | if (Settings.enableAnnotations) { 87 | log.info("Main", "Annotated image generation enabled."); 88 | await AnnotationManager.initialize(); 89 | } 90 | 91 | // Enable the web server. 92 | if (Settings.enableWebServer) { 93 | log.info("Main", "Web server enabled."); 94 | WebServer.startApp(); 95 | } 96 | 97 | // Load the trigger configuration. Verifying the location is just a quick check for basic 98 | // mounting issues. It doesn't stop the startup of the system since globs are valid in 99 | // watchObject paths and that's not something that can easily be verified. 100 | triggersConfiguration = TriggerManager.loadConfiguration([ 101 | { 102 | baseFilePath: "/run/secrets/triggers", 103 | secretsFilePath: "/run/secrets/secrets", 104 | }, 105 | { 106 | baseFilePath: "/config/triggers.json", 107 | secretsFilePath: "/config/secrets.json", 108 | }, 109 | ]); 110 | TriggerManager.verifyTriggerWatchLocations(); 111 | 112 | // Initialize the other handler managers. MQTT got done earlier 113 | // since it does double-duty and sends overall status messages for the system. 114 | await PushbulletManager.initialize(); 115 | await PushoverManager.initialize(); 116 | await TelegramManager.initialize(); 117 | 118 | // Start listening for MQTT events 119 | await MqttRouter.initialize(); 120 | 121 | // Start watching 122 | TriggerManager.startWatching(); 123 | 124 | // Notify it's up and running 125 | await sendHealthMessage(); 126 | 127 | // Start watching for config file changes 128 | startWatching(); 129 | 130 | // At this point startup succeeded so reset the restart count. This is in case 131 | // later hot reloads cause something to break, it should still support multiple 132 | // restarts. 133 | restartAttemptCount = 0; 134 | 135 | log.info("Main", "****************************************"); 136 | log.info("Main", "Up and running!"); 137 | } catch (e) { 138 | log.error("Main", e.message); 139 | log.error("Main", "****************************************"); 140 | log.error( 141 | "Main", 142 | "Startup failed due to errors. For troubleshooting assistance see https://github.com/danecreekphotography/node-deepstackai-trigger/wiki/Troubleshooting.", 143 | ); 144 | 145 | // Notify it's not up and running 146 | clearTimeout(healthTimer); 147 | await MqttManager.publishServerState("offline", e.message); 148 | 149 | // Shutdown the web server plus other things that may have spun up successfully. 150 | await shutdown(); 151 | 152 | restartAttemptCount++; 153 | 154 | // Try starting again in a little bit. 155 | if (restartAttemptCount < maxRestartAttempts) { 156 | log.info( 157 | "Main", 158 | `Startup reattempt ${restartAttemptCount} of ${maxRestartAttempts} in ${restartAttemptWaitTime / 159 | 1000} seconds.`, 160 | ); 161 | restartTimer = setTimeout(startup, restartAttemptWaitTime); 162 | } else { 163 | log.error( 164 | "Main", 165 | `Startup failed ${maxRestartAttempts} times. For troubleshooting assistance see https://github.com/danecreekphotography/node-deepstackai-trigger/wiki/Troubleshooting.`, 166 | ); 167 | return; 168 | } 169 | } 170 | } 171 | 172 | /** 173 | * Sends a health message via MQTT every n seconds to indicate the server is online. 174 | */ 175 | async function sendHealthMessage(): Promise { 176 | if (!MqttManager.isEnabled) { 177 | return; 178 | } 179 | 180 | await MqttManager.publishServerState("online"); 181 | 182 | healthTimer = setTimeout(sendHealthMessage, healthWaitTime); 183 | } 184 | 185 | /** 186 | * Shuts down all registered file system watchers and the web server 187 | */ 188 | async function shutdown(): Promise { 189 | clearTimeout(restartTimer); 190 | 191 | // Shut down things that are running 192 | await stopWatching(); 193 | await TriggerManager.stopWatching(); 194 | await WebServer.stopApp(); 195 | } 196 | 197 | /** 198 | * Shuts everything down and then restarts the service with a new settings file. 199 | * @param path The path to the settings file that changed. 200 | */ 201 | async function hotLoadSettings(configuration: IConfiguration) { 202 | log.info("Main", `${configuration.baseFilePath} change detected, reloading.`); 203 | 204 | await shutdown(); 205 | await startup(); 206 | } 207 | 208 | /** 209 | * Reloads the list of triggers. 210 | * @param path The path to the trigger file that changed. 211 | */ 212 | async function hotLoadTriggers(configuration: IConfiguration) { 213 | log.info("Main", `${configuration.baseFilePath} change detected, reloading.`); 214 | 215 | // Shut down things that are running 216 | await TriggerManager.stopWatching(); 217 | 218 | // Load the trigger configuration. Verifying the location is just a quick check for basic 219 | // mounting issues. It doesn't stop the startup of the system since globs are valid in 220 | // watchObject paths and that's not something that can easily be verified. 221 | TriggerManager.loadConfiguration([configuration]); 222 | TriggerManager.verifyTriggerWatchLocations(); 223 | 224 | TriggerManager.startWatching(); 225 | } 226 | 227 | /** 228 | * Starts watching for changes to settings files 229 | */ 230 | function startWatching(): void { 231 | const settingsFilePath = settingsConfiguration.baseFilePath; 232 | try { 233 | if (settingsFilePath) { 234 | watchers.push( 235 | chokidar 236 | .watch(settingsFilePath, { awaitWriteFinish: Settings.awaitWriteFinish }) 237 | .on("change", () => hotLoadSettings(settingsConfiguration)), 238 | ); 239 | log.verbose("Main", `Watching for changes to ${settingsFilePath}`); 240 | } 241 | } catch (e) { 242 | log.warn("Main", `Unable to watch for changes to ${settingsFilePath}: ${e}`); 243 | } 244 | 245 | const triggersFilePath = triggersConfiguration.baseFilePath; 246 | try { 247 | if (triggersFilePath) { 248 | watchers.push( 249 | chokidar 250 | .watch(triggersFilePath, { awaitWriteFinish: Settings.awaitWriteFinish }) 251 | .on("change", () => hotLoadTriggers(triggersConfiguration)), 252 | ); 253 | log.verbose("Main", `Watching for changes to ${triggersFilePath}`); 254 | } 255 | } catch (e) { 256 | log.warn("Main", `Unable to watch for changes to ${triggersFilePath}: ${e}`); 257 | } 258 | } 259 | 260 | /** 261 | * Stops watching for settings file changes. 262 | */ 263 | async function stopWatching(): Promise { 264 | return Promise.all( 265 | watchers.map(async watcher => { 266 | await watcher.close(); 267 | }), 268 | ); 269 | } 270 | 271 | /** 272 | * Shut down gracefully when requested. 273 | */ 274 | async function handleDeath(): Promise { 275 | log.info("Main", "Shutting down."); 276 | await shutdown(); 277 | process.exit(); 278 | } 279 | 280 | function registerForDeath(): void { 281 | process.on("SIGINT", handleDeath); 282 | process.on("SIGTERM", handleDeath); 283 | process.on("SIGQUIT", handleDeath); 284 | process.on("SIGBREAK", handleDeath); 285 | } 286 | 287 | async function main(): Promise { 288 | registerForDeath(); 289 | 290 | await startup(); 291 | 292 | // Spin in circles waiting for new files to arrive. 293 | wait(); 294 | } 295 | 296 | function wait() { 297 | setTimeout(wait, 1000); 298 | } 299 | 300 | main(); 301 | -------------------------------------------------------------------------------- /src/pushbulletClient/IUploadRequestResponse.ts: -------------------------------------------------------------------------------- 1 | /*--------------------------------------------------------------------------------------------- 2 | * Copyright (c) Neil Enns. All rights reserved. 3 | * Licensed under the MIT License. See LICENSE in the project root for license information. 4 | *--------------------------------------------------------------------------------------------*/ 5 | 6 | /** 7 | * The response from an upload-request call to the Pushbullet API. 8 | */ 9 | export default interface IUploadRequestResponse { 10 | file_name: string; 11 | file_type: string; 12 | file_url: string; 13 | upload_url: string; 14 | } 15 | -------------------------------------------------------------------------------- /src/pushbulletClient/PushbulletClient.ts: -------------------------------------------------------------------------------- 1 | /*--------------------------------------------------------------------------------------------- 2 | * Copyright (c) Neil Enns. All rights reserved. 3 | * Licensed under the MIT License. See LICENSE in the project root for license information. 4 | *--------------------------------------------------------------------------------------------*/ 5 | import * as fs from "fs"; 6 | import * as log from "../Log"; 7 | import * as path from "path"; 8 | import IUploadRequestResponse from "./IUploadRequestResponse"; 9 | import PushbulletMessage from "./PushbulletMessage"; 10 | import request from "request-promise-native"; 11 | 12 | /** 13 | * A basic client for sending messages to the Pushbullet REST API 14 | */ 15 | export default class PushbulletClient { 16 | /** 17 | * The access token registered for the application 18 | */ 19 | public accessToken: string; 20 | 21 | constructor(init?: Partial) { 22 | Object.assign(this, init); 23 | } 24 | 25 | /** 26 | * Asynchronously sends a message to the Pushbullet REST API. 27 | * @param message The PushbulletMessage to send 28 | */ 29 | public async push(message: PushbulletMessage): Promise { 30 | if (!this.accessToken) { 31 | throw Error("accessToken must be set before calling send()."); 32 | } 33 | 34 | // Sending an image with Pushbullet is absolute nonsense. 35 | // Call the method that encapsulates the nonsense before sending the actual message. 36 | const imageDetails = await this.uploadFile(message); 37 | 38 | const body = { 39 | type: "file", 40 | title: message.title, 41 | body: message.body, 42 | file_name: imageDetails.file_name, 43 | file_type: imageDetails.file_type, 44 | file_url: imageDetails.file_url, 45 | }; 46 | 47 | return await request 48 | .post({ 49 | body: body, 50 | uri: "https://api.pushbullet.com/v2/pushes", 51 | headers: { 52 | "Access-Token": this.accessToken, 53 | }, 54 | json: true, 55 | }) 56 | .catch(e => { 57 | log.error("Pushbullet", `Failed to call Pushbullet: ${e.error}`); 58 | return; 59 | }); 60 | } 61 | 62 | /** 63 | * Sending an image with Pushbullet is just crazy silly roundabout. First 64 | * you have to say you're going to upload the file and get back the URL 65 | * to upload to, THEN you can actually upload the file. Oh, and then 66 | * you have to remember all the data you got back to send a message that 67 | * uses that file. Nonsense!!! 68 | * @param message The message with the file details to upload 69 | * @returns The response from Pushbullet that has the required details to send 70 | * a message with the image. 71 | */ 72 | private async uploadFile(message: PushbulletMessage): Promise { 73 | // First step is to request the upload location 74 | const response = (await request 75 | .post({ 76 | body: { 77 | file_name: path.basename(message.imageFileName), 78 | file_type: "image/jpeg", 79 | }, 80 | uri: "https://api.pushbullet.com/v2/upload-request", 81 | headers: { 82 | "Access-Token": this.accessToken, 83 | }, 84 | json: true, 85 | }) 86 | .catch(e => { 87 | log.error("Pushbullet", `Failed to upload image: ${JSON.stringify(e.error)}`); 88 | return; 89 | })) as IUploadRequestResponse; 90 | 91 | // Once that's done the actual file can be sent as a mutli-part message. 92 | await request 93 | .post({ 94 | formData: { 95 | file: fs.createReadStream(message.imageFileName), 96 | }, 97 | uri: response.upload_url, 98 | }) 99 | .catch(e => { 100 | log.error("Pushover", `Failed to call Pushover: ${e.error}`); 101 | return; 102 | }); 103 | 104 | return response; 105 | } 106 | } 107 | -------------------------------------------------------------------------------- /src/pushbulletClient/PushbulletMessage.ts: -------------------------------------------------------------------------------- 1 | /*--------------------------------------------------------------------------------------------- 2 | * Copyright (c) Neil Enns. All rights reserved. 3 | * Licensed under the MIT License. See LICENSE in the project root for license information. 4 | *--------------------------------------------------------------------------------------------*/ 5 | 6 | /** 7 | * A Pushbullet REST API message 8 | */ 9 | export default class PushbulletMessage { 10 | /** 11 | * Body of the message. 12 | */ 13 | public body: string; 14 | /** 15 | * The image file to attach to the message. 16 | */ 17 | public imageFileName: string; 18 | /** 19 | * Title of the message. 20 | */ 21 | public title: string; 22 | 23 | constructor(init?: Partial) { 24 | Object.assign(this, init); 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/pushoverClient/PushoverClient.ts: -------------------------------------------------------------------------------- 1 | /*--------------------------------------------------------------------------------------------- 2 | * Copyright (c) Neil Enns. All rights reserved. 3 | * Licensed under the MIT License. See LICENSE in the project root for license information. 4 | *--------------------------------------------------------------------------------------------*/ 5 | import PushoverMessage from "./PushoverMessage"; 6 | import request from "request-promise-native"; 7 | import * as log from "../Log"; 8 | import fs from "fs"; 9 | 10 | /** 11 | * A basic client for sending messages to the Pushover REST API 12 | */ 13 | export default class PushoverClient { 14 | /** 15 | * The api key/token registered for the application 16 | */ 17 | public apiKey: string; 18 | /** 19 | * The user key/token that registered the application 20 | */ 21 | public userKey: string; 22 | 23 | constructor(init?: Partial) { 24 | Object.assign(this, init); 25 | } 26 | 27 | /** 28 | * Asynchronously sends a message to the Pushover REST API. 29 | * @param message The PushoverMessage to send 30 | */ 31 | public async send(message: PushoverMessage): Promise { 32 | if (!this.apiKey) { 33 | throw Error("apiKey must be set before calling send()."); 34 | } 35 | 36 | if (!this.userKey) { 37 | throw Error("userKey must be set before calling send()."); 38 | } 39 | 40 | const form = { 41 | token: this.apiKey, 42 | user: this.userKey, 43 | message: message.message, 44 | sound: message.sound, 45 | attachment: fs.createReadStream(message.imageFileName), 46 | }; 47 | 48 | return await request 49 | .post({ 50 | formData: form, 51 | uri: "https://api.pushover.net/1/messages.json", 52 | }) 53 | .catch(e => { 54 | log.error("Pushover", `Failed to call Pushover: ${e.error}`); 55 | return; 56 | }); 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /src/pushoverClient/PushoverMessage.ts: -------------------------------------------------------------------------------- 1 | /*--------------------------------------------------------------------------------------------- 2 | * Copyright (c) Neil Enns. All rights reserved. 3 | * Licensed under the MIT License. See LICENSE in the project root for license information. 4 | *--------------------------------------------------------------------------------------------*/ 5 | 6 | /** 7 | * A Pushover REST API message 8 | */ 9 | export default class PushoverMessage { 10 | /** 11 | * Text of the message. 12 | */ 13 | public message: string; 14 | /** 15 | * The user key/token for the message recipient. 16 | */ 17 | public userKey?: string; 18 | /** 19 | * The image file to attach to the message. 20 | */ 21 | public imageFileName: string; 22 | /** 23 | * The Pushover notification sound to play. Default is "pushover". 24 | */ 25 | public sound?: string; 26 | 27 | constructor(init?: Partial) { 28 | Object.assign(this, init); 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/routes/motion.ts: -------------------------------------------------------------------------------- 1 | /*--------------------------------------------------------------------------------------------- 2 | * Copyright (c) Neil Enns. All rights reserved. 3 | * Licensed under the MIT License. See LICENSE in the project root for license information. 4 | *--------------------------------------------------------------------------------------------*/ 5 | import express from "express"; 6 | import * as motionController from "../controllers/motion"; 7 | 8 | const router = express.Router(); 9 | 10 | // Set up the routes 11 | router.get("/:triggerName", motionController.handleMotionEvent); 12 | 13 | // Export it for use elsewhere 14 | export default router; 15 | -------------------------------------------------------------------------------- /src/routes/statistics.ts: -------------------------------------------------------------------------------- 1 | /*--------------------------------------------------------------------------------------------- 2 | * Copyright (c) Neil Enns. All rights reserved. 3 | * Licensed under the MIT License. See LICENSE in the project root for license information. 4 | *--------------------------------------------------------------------------------------------*/ 5 | import express from "express"; 6 | import * as statisticsController from "../controllers/statistics"; 7 | 8 | const router = express.Router(); 9 | 10 | // Set up the routes 11 | router.get("/trigger/:triggerName", statisticsController.getTriggerStatistics); 12 | router.get("/trigger/:triggerName/reset", statisticsController.resetTriggerStatistics); 13 | router.get("/", statisticsController.getOverallStatistics); 14 | router.get("/all", statisticsController.getAllTriggerStatistics); 15 | router.get("/all/reset", statisticsController.resetAllTriggerStatistics); 16 | router.get("/reset", statisticsController.resetOverallStatistics); 17 | 18 | // Export it for use elsewhere 19 | export default router; 20 | -------------------------------------------------------------------------------- /src/schemaValidator.ts: -------------------------------------------------------------------------------- 1 | /*--------------------------------------------------------------------------------------------- 2 | * Copyright (c) Neil Enns. All rights reserved. 3 | * Licensed under the MIT License. See LICENSE in the project root for license information. 4 | *--------------------------------------------------------------------------------------------*/ 5 | import * as log from "./Log"; 6 | 7 | import Ajv from "ajv"; 8 | import ajvkeywords from "ajv-keywords"; 9 | import maskConfiguration from "./schemas/maskConfiguration.schema.json"; 10 | import mqttHandlerConfiguration from "./schemas/mqttHandlerConfiguration.schema.json"; 11 | import mqttManagerConfiguration from "./schemas/mqttManagerConfiguration.schema.json"; 12 | import pushbulletHandlerConfiguration from "./schemas/pushbulletHandlerConfiguration.schema.json"; 13 | import pushbulletManagerConfiguration from "./schemas/pushbulletManagerConfiguration.schema.json"; 14 | import pushoverHandlerConfiguration from "./schemas/pushoverHandlerConfiguration.schema.json"; 15 | import pushoverManagerConfiguration from "./schemas/pushoverManagerConfiguration.schema.json"; 16 | import settingsSchema from "./schemas/settings.schema.json"; 17 | import telegramHandlerConfiguration from "./schemas/telegramHandlerConfiguration.schema.json"; 18 | import telegramManagerConfiguration from "./schemas/telegramManagerConfiguration.schema.json"; 19 | import triggerSchema from "./schemas/triggerConfiguration.schema.json"; 20 | import webRequestHandlerConfig from "./schemas/webRequestHandlerConfig.schema.json"; 21 | 22 | /** 23 | * Validates an object against a schema file 24 | * @param schemaFileName The path to the schema file 25 | * @param obj The object to validate 26 | * @returns True if valid, false otherwise. 27 | */ 28 | export default async function validateJsonAgainstSchema( 29 | schemaFileName: Record, 30 | jsonObject: unknown, 31 | ): Promise { 32 | const validator = new Ajv(); 33 | 34 | ajvkeywords(validator, "transform"); 35 | 36 | // Register all the schemas that get used with this app. It doesn't matter 37 | // if they are for different schema files/uses, ajv only loads them when 38 | // actually required by the file being processed. 39 | validator.addSchema( 40 | maskConfiguration, 41 | "https://raw.githubusercontent.com/danecreekphotography/node-blueiris-deepstack-ai/main/src/schemas/maskConfiguration.schema.json", 42 | ); 43 | validator.addSchema( 44 | mqttHandlerConfiguration, 45 | "https://raw.githubusercontent.com/danecreekphotography/node-blueiris-deepstack-ai/main/src/schemas/mqttHandlerConfiguration.schema.json", 46 | ); 47 | validator.addSchema( 48 | mqttManagerConfiguration, 49 | "https://raw.githubusercontent.com/danecreekphotography/node-blueiris-deepstack-ai/main/src/schemas/mqttManagerConfiguration.schema.json", 50 | ); 51 | validator.addSchema( 52 | pushbulletHandlerConfiguration, 53 | "https://raw.githubusercontent.com/danecreekphotography/node-blueiris-deepstack-ai/main/src/schemas/pushbulletHandlerConfiguration.schema.json", 54 | ); 55 | validator.addSchema( 56 | pushbulletManagerConfiguration, 57 | "https://raw.githubusercontent.com/danecreekphotography/node-blueiris-deepstack-ai/main/src/schemas/pushbulletManagerConfiguration.schema.json", 58 | ); 59 | validator.addSchema( 60 | pushoverHandlerConfiguration, 61 | "https://raw.githubusercontent.com/danecreekphotography/node-blueiris-deepstack-ai/main/src/schemas/pushoverHandlerConfiguration.schema.json", 62 | ); 63 | validator.addSchema( 64 | pushoverManagerConfiguration, 65 | "https://raw.githubusercontent.com/danecreekphotography/node-blueiris-deepstack-ai/main/src/schemas/pushoverManagerConfiguration.schema.json", 66 | ); 67 | validator.addSchema( 68 | settingsSchema, 69 | "https://raw.githubusercontent.com/danecreekphotography/node-blueiris-deepstack-ai/main/src/schemas/settings.schema.json", 70 | ); 71 | validator.addSchema( 72 | triggerSchema, 73 | "https://raw.githubusercontent.com/danecreekphotography/node-blueiris-deepstack-ai/main/src/schemas/triggerConfiguration.schema.json", 74 | ); 75 | validator.addSchema( 76 | telegramManagerConfiguration, 77 | "https://raw.githubusercontent.com/danecreekphotography/node-blueiris-deepstack-ai/main/src/schemas/telegramManagerConfiguration.schema.json", 78 | ); 79 | validator.addSchema( 80 | telegramHandlerConfiguration, 81 | "https://raw.githubusercontent.com/danecreekphotography/node-blueiris-deepstack-ai/main/src/schemas/telegramHandlerConfiguration.schema.json", 82 | ); 83 | validator.addSchema( 84 | webRequestHandlerConfig, 85 | "https://raw.githubusercontent.com/danecreekphotography/node-blueiris-deepstack-ai/main/src/schemas/webRequestHandlerConfig.schema.json", 86 | ); 87 | 88 | const isValid = await validator.validate(schemaFileName, jsonObject); 89 | 90 | if (!isValid) { 91 | validator?.errors.map(error => { 92 | log.error("Schema Validator", `${error?.dataPath}: ${error?.message}`); 93 | }); 94 | } 95 | 96 | return isValid; 97 | } 98 | -------------------------------------------------------------------------------- /src/schemas/maskConfiguration.schema.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "http://json-schema.org/draft-07/schema", 3 | "$id": "https://raw.githubusercontent.com/danecreekphotography/node-deepstackai-trigger/main/src/schemas/maskConfiguration.schema.json", 4 | "type": "object", 5 | "title": "The configuration for a trigger mask", 6 | "description": "Defines a rectangle to mask out detected objects.", 7 | "required": ["xMinimum", "yMinimum", "xMaximum", "yMaximum"], 8 | "additionalProperties": false, 9 | "properties": { 10 | "$schema": { 11 | "type": "string", 12 | "description": "Reference to the schema for the JSON. Set this to the example value to get full Intellisense support in editors that support it.", 13 | "examples": [ 14 | "https://raw.githubusercontent.com/danecreekphotography/node-deepstackai-trigger/main/src/schemas/settings.schema.json" 15 | ] 16 | }, 17 | "xMinimum": { 18 | "description": "The x coordinate of the top right of the mask rectangle.", 19 | "type": "number", 20 | "minimum": 0, 21 | "examples": [10] 22 | }, 23 | "yMinimum": { 24 | "description": "The y coordinate of the top right of the mask rectangle.", 25 | "type": "number", 26 | "minimum": 0, 27 | "examples": [40] 28 | }, 29 | "xMaximum": { 30 | "description": "The x coordinate of the bottom left of the mask rectangle.", 31 | "type": "number", 32 | "minimum": 0, 33 | "examples": [200] 34 | }, 35 | "yMaximum": { 36 | "description": "The y coordinate of the top right of the mask rectangle.", 37 | "type": "number", 38 | "minimum": 0, 39 | "examples": [240] 40 | } 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /src/schemas/mqttHandlerConfiguration.schema.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "http://json-schema.org/draft-07/schema", 3 | "$id": "https://raw.githubusercontent.com/danecreekphotography/node-deepstackai-trigger/main/src/schemas/mqttHandlerConfiguration.schema.json", 4 | "type": "object", 5 | "title": "The configuration for an MQTT trigger handler", 6 | "description": "Defines the MQTT information sent with trigger notifications.", 7 | "anyOf": [{ "required": ["topic"] }, { "required": ["messages"] }], 8 | "additionalProperties": false, 9 | "properties": { 10 | "topic": { 11 | "description": "The topic to send when this handler is triggered.", 12 | "type": "string", 13 | "minLength": 1, 14 | "examples": ["aimotion/trigger/dog"] 15 | }, 16 | "messages": { 17 | "description": "A list of MQTT messages to send when the trigger is activated.", 18 | "type": "array", 19 | "minItems": 1, 20 | "items": { 21 | "type": "object", 22 | "required": ["topic"], 23 | "properties": { 24 | "topic": { 25 | "description": "The topic to send when this handler is triggered.", 26 | "type": "string", 27 | "minLength": 1, 28 | "examples": ["aimotion/trigger/dog"] 29 | }, 30 | "offDelay": { 31 | "description": "Number of seconds of no motion to wait before sending an MQTT state off message. Set to 0 to disable sending off messages. Default is 30 seconds.", 32 | "type": "number", 33 | "default": 30, 34 | "examples": ["0", "300"] 35 | }, 36 | "payload": { 37 | "description": "The payload to send with the MQTT message. Optional. If omitted a payload containing the prediction information and image file path will be sent.", 38 | "type": "string", 39 | "examples": ["{\"camera\": \"FrontDoor\", \"state\": \"on\"}"] 40 | } 41 | } 42 | }, 43 | "enabled": { 44 | "description": "Enables the MQTT handler on this trigger. Default is true.", 45 | "type": "boolean", 46 | "default": "true", 47 | "examples": ["false"] 48 | } 49 | } 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /src/schemas/mqttManagerConfiguration.schema.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "http://json-schema.org/draft-07/schema", 3 | "$id": "https://raw.githubusercontent.com/danecreekphotography/node-deepstackai-trigger/main/src/schemas/mqttManagerConfiguration.schema.json", 4 | "type": "object", 5 | "title": "The configuration for an MQTT server", 6 | "description": "Defines the connection information for an MQTT server that will receive trigger notifications.", 7 | "required": ["uri"], 8 | "additionalProperties": false, 9 | "properties": { 10 | "uri": { 11 | "description": "The uri of the MQTT server.", 12 | "type": "string", 13 | "examples": ["http://localhost:1883"] 14 | }, 15 | "username": { 16 | "description": "The username to connect with.", 17 | "type": "string", 18 | "examples": ["user"] 19 | }, 20 | "password": { 21 | "description": "The password to connect with.", 22 | "type": "string", 23 | "examples": ["pass"] 24 | }, 25 | "retain": { 26 | "description": "If true all MQTT messages are published with the retain flag set. Default false.", 27 | "type": "boolean", 28 | "default": false, 29 | "examples": ["true"] 30 | }, 31 | "rejectUnauthorized": { 32 | "description": "Controls whether connections to mqtts:// servers should allow self-signed certificates. Set to false if your MQTT certificates are self-signed and are getting connection errors.", 33 | "type": "boolean", 34 | "default": true, 35 | "examples": ["false"] 36 | }, 37 | "enabled": { 38 | "description": "Enables MQTT events.", 39 | "type": "boolean", 40 | "default": true, 41 | "examples": ["false"] 42 | } 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /src/schemas/pushbulletHandlerConfiguration.schema.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "http://json-schema.org/draft-07/schema", 3 | "$id": "https://raw.githubusercontent.com/danecreekphotography/node-deepstackai-trigger/main/src/schemas/pushbulletHandlerConfiguration.schema.json", 4 | "type": "object", 5 | "title": "The configuration for a Pushbullet trigger handler", 6 | "description": "Defines the Pushbullet messages to send.", 7 | "additionalProperties": false, 8 | "properties": { 9 | "caption": { 10 | "description": "Caption to send with the message. Default is the name of the trigger.", 11 | "type": "string", 12 | "minLength": 1, 13 | "examples": ["Front door: {{formattedPredictions}}"] 14 | }, 15 | "annotateImage": { 16 | "description": "Set to true to send an image with annotations overlaid for detected objects.", 17 | "type": "boolean", 18 | "default": false, 19 | "examples": [true] 20 | }, 21 | "cooldownTime": { 22 | "description": "Number of seconds required between sending notifications to the listed users.", 23 | "type": "number", 24 | "default": 0, 25 | "minimum": 0, 26 | "maximum": 600, 27 | "examples": [5] 28 | }, 29 | "enabled": { 30 | "description": "Enables the Pushover handler on this trigger. Default is true.", 31 | "type": "boolean", 32 | "default": "true", 33 | "examples": ["false"] 34 | }, 35 | "title": { 36 | "description": "Title to send with the message.", 37 | "type": "string", 38 | "minLength": 1, 39 | "examples": ["Motion detected"] 40 | } 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /src/schemas/pushbulletManagerConfiguration.schema.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "http://json-schema.org/draft-07/schema", 3 | "$id": "https://raw.githubusercontent.com/danecreekphotography/node-deepstackai-trigger/main/src/schemas/pushbulletManagerConfiguration.schema.json", 4 | "type": "object", 5 | "title": "The configuration for Pushbullet", 6 | "description": "Defines the connection information for Pushbullet to send trigger notifications.", 7 | "required": ["accessToken"], 8 | "additionalProperties": false, 9 | "properties": { 10 | "accessToken": { 11 | "description": "The access token for Pushbullet.", 12 | "type": "string", 13 | "examples": ["op.owie239rksdfanvxo8yuaeklfhalsdf"] 14 | }, 15 | "enabled": { 16 | "description": "Enables Pushbullet notifications.", 17 | "type": "boolean", 18 | "default": true, 19 | "examples": ["false"] 20 | } 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/schemas/pushoverHandlerConfiguration.schema.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "http://json-schema.org/draft-07/schema", 3 | "$id": "https://raw.githubusercontent.com/danecreekphotography/node-deepstackai-trigger/main/src/schemas/pushoverHandlerConfiguration.schema.json", 4 | "type": "object", 5 | "title": "The configuration for a Pushover trigger handler", 6 | "description": "Defines the Pushover messages to send.", 7 | "required": ["userKeys"], 8 | "additionalProperties": false, 9 | "properties": { 10 | "userKeys": { 11 | "description": "The list of user keys to send notifications to when this handler is triggered.", 12 | "type": "array", 13 | "items": { 14 | "type": "string" 15 | }, 16 | "minItems": 1, 17 | "uniqueItems": true, 18 | "examples": ["u6wer89dggf7a7zbn8f86zq6ijx1kg2"] 19 | }, 20 | "caption": { 21 | "description": "Caption to send with the message. Default is the name of the trigger.", 22 | "type": "string", 23 | "minLength": 1, 24 | "examples": ["Front door: {{formattedPredictions}}"] 25 | }, 26 | "sound": { 27 | "description": "Pushover notification sound to play. If unspecified the Pushover default is used. See https://pushover.net/api#sounds to play each of the sounds.", 28 | "type": "string", 29 | "examples": ["magic"], 30 | "enum": [ 31 | "pushover", 32 | "bike", 33 | "bugle", 34 | "cashregister", 35 | "classical", 36 | "cosmic", 37 | "falling", 38 | "gamelan", 39 | "incoming", 40 | "intermission", 41 | "magic", 42 | "mechanical", 43 | "pianobar", 44 | "siren", 45 | "spacealarm", 46 | "tugboat", 47 | "alien", 48 | "climb", 49 | "persistent", 50 | "echo", 51 | "updown", 52 | "vibrate", 53 | "none" 54 | ] 55 | }, 56 | "annotateImage": { 57 | "description": "Set to true to send an image with annotations overlaid for detected objects.", 58 | "type": "boolean", 59 | "default": false, 60 | "examples": [true] 61 | }, 62 | "cooldownTime": { 63 | "description": "Number of seconds required between sending notifications to the listed users.", 64 | "type": "number", 65 | "default": 0, 66 | "minimum": 0, 67 | "maximum": 600, 68 | "examples": [5] 69 | }, 70 | "enabled": { 71 | "description": "Enables the Pushover handler on this trigger. Default is true.", 72 | "type": "boolean", 73 | "default": "true", 74 | "examples": ["false"] 75 | } 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /src/schemas/pushoverManagerConfiguration.schema.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "http://json-schema.org/draft-07/schema", 3 | "$id": "https://raw.githubusercontent.com/danecreekphotography/node-deepstackai-trigger/main/src/schemas/pushoverManagerConfiguration.schema.json", 4 | "type": "object", 5 | "title": "The configuration for Pushover", 6 | "description": "Defines the connection information for Pushover to send trigger notifications.", 7 | "required": ["apiKey", "userKey"], 8 | "additionalProperties": false, 9 | "properties": { 10 | "apiKey": { 11 | "description": "The API key for Pushover.", 12 | "type": "string", 13 | "examples": ["atddasdflj2vet213874ljsdg12"] 14 | }, 15 | "userKey": { 16 | "description": "The user key for Pushover.", 17 | "type": "string", 18 | "examples": ["u6asdf8zqd7g7zbn8f86zq6ijx1kg2"] 19 | }, 20 | "enabled": { 21 | "description": "Enables Pushover notifications.", 22 | "type": "boolean", 23 | "default": true, 24 | "examples": ["false"] 25 | } 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/schemas/settings.schema.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "http://json-schema.org/draft-07/schema", 3 | "$id": "https://raw.githubusercontent.com/danecreekphotography/node-deepstackai-trigger/main/src/schemas/settings.schema.json", 4 | "type": "object", 5 | "title": "Settings for the trigger engine.", 6 | "description": "Configures options for various system-level controls.", 7 | "uniqueItems": true, 8 | "additionalProperties": false, 9 | "required": ["deepstackUri"], 10 | "properties": { 11 | "$schema": { 12 | "type": "string", 13 | "description": "Reference to the schema for the JSON. Set this to the example value to get full Intellisense support in editors that support it.", 14 | "examples": [ 15 | "https://raw.githubusercontent.com/danecreekphotography/node-deepstackai-trigger/main/src/schemas/settings.schema.json" 16 | ] 17 | }, 18 | "awaitWriteFinish": { 19 | "type": "boolean", 20 | "description": "Waits for writes to finish before analyzing images. This is useful if the images live on a network drive that's mounted to Docker, but comes with a performance penalty. Disabled by default.", 21 | "default": false, 22 | "examples": [true] 23 | }, 24 | "enableAnnotations": { 25 | "type": "boolean", 26 | "description": "Enables generation of annotated images. Disabled by default.", 27 | "default": false, 28 | "examples": [true] 29 | }, 30 | "enableWebServer": { 31 | "type": "boolean", 32 | "description": "Enables the local web server. Disabled by default.", 33 | "default": false, 34 | "examples": [true] 35 | }, 36 | "deepstackUri": { 37 | "type": "string", 38 | "description": "The address of the Deepstack AI processing server.", 39 | "examples": ["http://deepstack-ai:5000/"] 40 | }, 41 | "port": { 42 | "type": "number", 43 | "description": "The port the local web server attaches to. Only used when enableAnnotations is true, and also requires the port be exposed in the Docker configuration.", 44 | "default": 4242, 45 | "examples": [9050] 46 | }, 47 | "processExistingImages": { 48 | "type": "boolean", 49 | "description": "Enables processing of images that exist in the input folder at startup. Primarily useful during development. Disabled by default.", 50 | "default": false, 51 | "examples": [true] 52 | }, 53 | "purgeAge": { 54 | "type": "number", 55 | "description": "Sets the time, in minutes, an image can go without being accessed before it is purged from local storage. Only used when enableAnnotations is true.", 56 | "default": 30, 57 | "examples": [true] 58 | }, 59 | "purgeInterval": { 60 | "type": "number", 61 | "description": "Sets the frequency, in minutes, that the purge process runs. Only used when enableAnnotations is true.", 62 | "default": 60, 63 | "examples": [300] 64 | }, 65 | "verbose": { 66 | "type": "boolean", 67 | "description": "Enables verbose logging.", 68 | "default": false, 69 | "examples": [true] 70 | }, 71 | "mqtt": { 72 | "description": "Enables and configures MQTT events.", 73 | "$ref": "https://raw.githubusercontent.com/danecreekphotography/node-deepstackai-trigger/main/src/schemas/mqttManagerConfiguration.schema.json" 74 | }, 75 | "telegram": { 76 | "description": "Enables and configures Telegram bot messages.", 77 | "$ref": "https://raw.githubusercontent.com/danecreekphotography/node-deepstackai-trigger/main/src/schemas/telegramManagerConfiguration.schema.json" 78 | }, 79 | "pushbullet": { 80 | "description": "Enables and configures Pushbullet messages.", 81 | "$ref": "https://raw.githubusercontent.com/danecreekphotography/node-deepstackai-trigger/main/src/schemas/pushbulletManagerConfiguration.schema.json" 82 | }, 83 | "pushover": { 84 | "description": "Enables and configures Pushover messages.", 85 | "$ref": "https://raw.githubusercontent.com/danecreekphotography/node-deepstackai-trigger/main/src/schemas/pushoverManagerConfiguration.schema.json" 86 | } 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /src/schemas/telegramHandlerConfiguration.schema.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "http://json-schema.org/draft-07/schema", 3 | "$id": "https://raw.githubusercontent.com/danecreekphotography/node-deepstackai-trigger/main/src/schemas/telegramHandlerConfiguration.schema.json", 4 | "type": "object", 5 | "title": "The configuration for a Telegram trigger handler", 6 | "description": "Defines the Telegram chats to send notifications to.", 7 | "required": ["chatIds"], 8 | "additionalProperties": false, 9 | "properties": { 10 | "chatIds": { 11 | "description": "The list of chat IDs to send notifications to when this handler is triggered.", 12 | "type": "array", 13 | "items": { 14 | "type": "number" 15 | }, 16 | "minItems": 1, 17 | "uniqueItems": true 18 | }, 19 | "caption": { 20 | "description": "Caption to send with the image message", 21 | "type": "string", 22 | "minLength": 1, 23 | "examples": ["Front door: {{formattedPredictions}}"] 24 | }, 25 | "annotateImage": { 26 | "description": "Set to true to send an image with annotations overlaid for detected objects.", 27 | "type": "boolean", 28 | "default": false, 29 | "examples": [true] 30 | }, 31 | "cooldownTime": { 32 | "description": "Number of seconds required between sending notifications to the listed chats.", 33 | "type": "number", 34 | "default": 0, 35 | "minimum": 0, 36 | "maximum": 600, 37 | "examples": [5] 38 | }, 39 | "enabled": { 40 | "description": "Enables the telegram handler on this trigger. Default is true.", 41 | "type": "boolean", 42 | "default": "true", 43 | "examples": ["false"] 44 | } 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /src/schemas/telegramManagerConfiguration.schema.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "http://json-schema.org/draft-07/schema", 3 | "$id": "https://raw.githubusercontent.com/danecreekphotography/node-deepstackai-trigger/main/src/schemas/telegramManagerConfiguration.schema.json", 4 | "type": "object", 5 | "title": "The configuration for Telegram", 6 | "description": "Defines the connection information for Telegram to send trigger notifications.", 7 | "required": ["botToken"], 8 | "additionalProperties": false, 9 | "properties": { 10 | "botToken": { 11 | "description": "The access token for Telegram.", 12 | "type": "string", 13 | "examples": ["1122334455:AABBCCDDEEFFGGhhiijjkkllmm"] 14 | }, 15 | "enabled": { 16 | "description": "Enables Telegram bot messages.", 17 | "type": "boolean", 18 | "default": true, 19 | "examples": ["false"] 20 | } 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/schemas/triggerConfiguration.schema.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "http://json-schema.org/draft-07/schema", 3 | "$id": "https://raw.githubusercontent.com/danecreekphotography/node-deepstackai-trigger/main/src/schemas/triggerConfiguration.schema.json", 4 | "type": "object", 5 | "title": "A list of triggers", 6 | "description": "Defines the triggers that are used for responding to DeepStack AI predictions.", 7 | "required": ["triggers"], 8 | "additionalProperties": false, 9 | "properties": { 10 | "$schema": { 11 | "type": "string", 12 | "description": "Reference to the schema for the JSON. Set this to the example value to get full Intellisense support in editors that support it.", 13 | "examples": [ 14 | "https://raw.githubusercontent.com/danecreekphotography/node-deepstackai-trigger/main/src/schemas/settings.schema.json" 15 | ] 16 | }, 17 | "triggers": { 18 | "$id": "#/properties/triggers", 19 | "type": "array", 20 | "title": "The list of triggers", 21 | "description": "Add one trigger for each different type of prediction to respond to.", 22 | "additionalProperties": false, 23 | "items": { 24 | "title": "Trigger", 25 | "description": "A trigger that responds to DeepStack predictions.", 26 | "type": "object", 27 | "allOf": [ 28 | { "anyOf": [{ "required": ["watchPattern"] }, { "required": ["snapshotUri"] }] }, 29 | { "oneOf": [{ "required": ["watchObjects"] }, { "required": ["customEndpoint"] }] }, 30 | { "required": ["handlers", "name"] } 31 | ], 32 | "additionalProperties": false, 33 | "properties": { 34 | "name": { 35 | "description": "A friendly name for the trigger", 36 | "type": "string" 37 | }, 38 | "watchPattern": { 39 | "description": "The file name pattern this trigger watches for to start processing.", 40 | "examples": ["images/Dog*.jpg"], 41 | "type": "string" 42 | }, 43 | "cooldownTime": { 44 | "description": "Number of seconds required between detecting images that match the watchPattern before processing another image.", 45 | "examples": [5], 46 | "default": 1, 47 | "exclusiveMinimum": 0, 48 | "maximum": 600, 49 | "type": "integer" 50 | }, 51 | "customEndpoint": { 52 | "description": "Custom endpoint for DeepStack, used to call a custom model. When specified watchObjects is ignored.", 53 | "examples": ["/v1/vision/custom/my_custom_model_name?image"], 54 | "type": "string" 55 | }, 56 | "enabled": { 57 | "description": "Set to true to enable the trigger. If false the trigger will be ignored.", 58 | "examples": [true], 59 | "default": true, 60 | "type": "boolean" 61 | }, 62 | "snapshotUri": { 63 | "description": "Address to call to retrieve a camera snapshot when manually triggered.", 64 | "type": "string", 65 | "examples": ["http://localhost:83/image/FrontDoorSD"] 66 | }, 67 | "threshold": { 68 | "description": "Sets the minimum and maximum prediction confidence required to fire the trigger.", 69 | "type": "object", 70 | "required": ["minimum", "maximum"], 71 | "additionalProperties": false, 72 | "properties": { 73 | "minimum": { 74 | "description": "The minimum value required to fire the trigger, inclusive.", 75 | "default": 0, 76 | "examples": [50], 77 | "type": "integer", 78 | "minimum": 0, 79 | "maximum": 100 80 | }, 81 | "maximum": { 82 | "description": "The maximum value required to fire the trigger, inclusive.", 83 | "examples": [100], 84 | "default": 100, 85 | "type": "integer", 86 | "minimum": 0, 87 | "maximum": 100 88 | } 89 | } 90 | }, 91 | "handlers": { 92 | "description": "The list of handlers to call and their configurations", 93 | "type": "object", 94 | "minItems": 1, 95 | "uniqueItems": true, 96 | "additionalProperties": false, 97 | "properties": { 98 | "webRequest": { 99 | "description": "Sets the configuration for a web request handler.", 100 | "$ref": "https://raw.githubusercontent.com/danecreekphotography/node-deepstackai-trigger/main/src/schemas/webRequestHandlerConfig.schema.json" 101 | }, 102 | "mqtt": { 103 | "description": "Sets the configuration for an MQTT event.", 104 | "$ref": "https://raw.githubusercontent.com/danecreekphotography/node-deepstackai-trigger/main/src/schemas/mqttHandlerConfiguration.schema.json" 105 | }, 106 | "pushbullet": { 107 | "description": "Sets the configuration for a Pushbullet message.", 108 | "$ref": "https://raw.githubusercontent.com/danecreekphotography/node-deepstackai-trigger/main/src/schemas/pushbulletHandlerConfiguration.schema.json" 109 | }, 110 | "pushover": { 111 | "description": "Sets the configuration for a Pushover message.", 112 | "$ref": "https://raw.githubusercontent.com/danecreekphotography/node-deepstackai-trigger/main/src/schemas/pushoverHandlerConfiguration.schema.json" 113 | }, 114 | "telegram": { 115 | "description": "Sets the configuration for a Telegram message.", 116 | "$ref": "https://raw.githubusercontent.com/danecreekphotography/node-deepstackai-trigger/main/src/schemas/telegramHandlerConfiguration.schema.json" 117 | } 118 | } 119 | }, 120 | "masks": { 121 | "description": "A list of rectangles that mask out detected objects to prevent the trigger from firing.", 122 | "type": "array", 123 | "minItems": 1, 124 | "items": { 125 | "$ref": "https://raw.githubusercontent.com/danecreekphotography/node-deepstackai-trigger/main/src/schemas/maskConfiguration.schema.json" 126 | } 127 | }, 128 | "activateRegions": { 129 | "description": "A list of rectangles that activate the trigger if a detected object overlaps the rectangle.", 130 | "type": "array", 131 | "minItems": 1, 132 | "items": { 133 | "$ref": "https://raw.githubusercontent.com/danecreekphotography/node-deepstackai-trigger/main/src/schemas/maskConfiguration.schema.json" 134 | } 135 | }, 136 | "watchObjects": { 137 | "description": "A list of objects the trigger will activate on.", 138 | "type": "array", 139 | "items": { 140 | "type": "string", 141 | "transform": ["toLowerCase"], 142 | "enum": [ 143 | "person", 144 | "bicycle", 145 | "car", 146 | "motorcycle", 147 | "airplane", 148 | "bus", 149 | "train", 150 | "truck", 151 | "boat", 152 | "traffic light", 153 | "fire hydrant", 154 | "stop_sign", 155 | "parking meter", 156 | "bench", 157 | "bird", 158 | "cat", 159 | "dog", 160 | "horse", 161 | "sheep", 162 | "cow", 163 | "elephant", 164 | "bear", 165 | "zebra", 166 | "giraffe", 167 | "backpack", 168 | "umbrella", 169 | "handbag", 170 | "tie", 171 | "suitcase", 172 | "frisbee", 173 | "skis", 174 | "snowboard", 175 | "sports ball", 176 | "kite", 177 | "baseball bat", 178 | "baseball glove", 179 | "skateboard", 180 | "surfboard", 181 | "tennis racket", 182 | "bottle", 183 | "wine glass", 184 | "cup", 185 | "fork", 186 | "knife", 187 | "spoon", 188 | "bowl", 189 | "banana", 190 | "apple", 191 | "sandwich", 192 | "orange", 193 | "broccoli", 194 | "carrot", 195 | "hot dog", 196 | "pizza", 197 | "donut", 198 | "cake", 199 | "chair", 200 | "couch", 201 | "potted plant", 202 | "bed", 203 | "dining table", 204 | "toilet", 205 | "tv", 206 | "laptop", 207 | "mouse", 208 | "remote", 209 | "keyboard", 210 | "cell phone", 211 | "microwave", 212 | "oven", 213 | "toaster", 214 | "sink", 215 | "refrigerator", 216 | "book", 217 | "clock", 218 | "vase", 219 | "scissors", 220 | "teddy bear", 221 | "hair dryer", 222 | "toothbrush" 223 | ] 224 | }, 225 | "minItems": 1, 226 | "uniqueItems": true 227 | } 228 | } 229 | } 230 | } 231 | } 232 | } 233 | -------------------------------------------------------------------------------- /src/schemas/webRequestHandlerConfig.schema.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "http://json-schema.org/draft-07/schema", 3 | "$id": "https://raw.githubusercontent.com/danecreekphotography/node-deepstackai-trigger/main/src/schemas/webRequestHandlerConfig.schema.json", 4 | "type": "object", 5 | "title": "The configuration for a web request handler", 6 | "description": "Defines the settings for a trigger handler that calls a list of URIs.", 7 | "required": ["triggerUris"], 8 | "additionalProperties": false, 9 | "properties": { 10 | "triggerUris": { 11 | "description": "A list of URIs to call when the trigger is activated.", 12 | "type": "array", 13 | "items": { 14 | "type": "string" 15 | }, 16 | "minItems": 1, 17 | "uniqueItems": true 18 | }, 19 | "enabled": { 20 | "description": "Enables the web request handler on this trigger. Default is true.", 21 | "type": "boolean", 22 | "default": "true", 23 | "examples": ["false"] 24 | } 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/types/IConfiguration.ts: -------------------------------------------------------------------------------- 1 | /*--------------------------------------------------------------------------------------------- 2 | * Copyright (c) Neil Enns. All rights reserved. 3 | * Licensed under the MIT License. See LICENSE in the project root for license information. 4 | *--------------------------------------------------------------------------------------------*/ 5 | export default interface IConfiguration { 6 | baseFilePath: string; 7 | secretsFilePath: string; 8 | } 9 | -------------------------------------------------------------------------------- /src/types/IDeepStackPrediction.ts: -------------------------------------------------------------------------------- 1 | /*--------------------------------------------------------------------------------------------- 2 | * Copyright (c) Neil Enns. All rights reserved. 3 | * Licensed under the MIT License. See LICENSE in the project root for license information. 4 | *--------------------------------------------------------------------------------------------*/ 5 | export default interface IDeepStackPrediction { 6 | confidence: number; 7 | label: string; 8 | x_min: number; 9 | x_max: number; 10 | y_min: number; 11 | y_max: number; 12 | } 13 | -------------------------------------------------------------------------------- /src/types/IDeepStackResponse.ts: -------------------------------------------------------------------------------- 1 | /*--------------------------------------------------------------------------------------------- 2 | * Copyright (c) Neil Enns. All rights reserved. 3 | * Licensed under the MIT License. See LICENSE in the project root for license information. 4 | *--------------------------------------------------------------------------------------------*/ 5 | import IDeepStackPrediction from "./IDeepStackPrediction"; 6 | 7 | export default interface IDeepStackResponse { 8 | success: boolean; 9 | predictions: IDeepStackPrediction[]; 10 | } 11 | -------------------------------------------------------------------------------- /src/types/ISettingsConfigJson.ts: -------------------------------------------------------------------------------- 1 | /*--------------------------------------------------------------------------------------------- 2 | * Copyright (c) Neil Enns. All rights reserved. 3 | * Licensed under the MIT License. See LICENSE in the project root for license information. 4 | *--------------------------------------------------------------------------------------------*/ 5 | import IMqttManagerConfigJson from "../handlers/mqttManager/IMqttManagerConfigJson"; 6 | import ITelegramManagerConfigJson from "../handlers/telegramManager/ITelegramManagerConfigJson"; 7 | import IPushbulletManagerConfigJson from "../handlers/pushbulletManager/IPushbulletManagerConfigJson"; 8 | import IPushoverManagerConfigJson from "../handlers/pushoverManager/IPushoverManagerConfigJson"; 9 | 10 | export default interface ISettingsConfigJson { 11 | awaitWriteFinish?: boolean; 12 | deepstackUri: string; 13 | enableAnnotations?: boolean; 14 | enableWebServer?: boolean; 15 | mqtt?: IMqttManagerConfigJson; 16 | port?: number; 17 | processExistingImages?: boolean; 18 | purgeAge?: number; 19 | purgeInterval?: number; 20 | pushbullet?: IPushbulletManagerConfigJson; 21 | pushover?: IPushoverManagerConfigJson; 22 | telegram?: ITelegramManagerConfigJson; 23 | verbose?: boolean; 24 | } 25 | -------------------------------------------------------------------------------- /src/types/ITriggerConfigJson.ts: -------------------------------------------------------------------------------- 1 | /*--------------------------------------------------------------------------------------------- 2 | * Copyright (c) Neil Enns. All rights reserved. 3 | * Licensed under the MIT License. See LICENSE in the project root for license information. 4 | *--------------------------------------------------------------------------------------------*/ 5 | import ITriggerJson from "./ITriggerJson"; 6 | 7 | export default interface ITriggerConfigJson { 8 | triggers: ITriggerJson[]; 9 | } 10 | -------------------------------------------------------------------------------- /src/types/ITriggerJson.ts: -------------------------------------------------------------------------------- 1 | /*--------------------------------------------------------------------------------------------- 2 | * Copyright (c) Neil Enns. All rights reserved. 3 | * Licensed under the MIT License. See LICENSE in the project root for license information. 4 | *--------------------------------------------------------------------------------------------*/ 5 | import IMqttHandlerConfigJson from "../handlers/mqttManager/IMqttHandlerConfigJson"; 6 | import IPushoverConfigJson from "../handlers/pushoverManager/IPushoverConfigJson"; 7 | import ITelegramConfigJson from "../handlers/telegramManager/ITelegramConfigJson"; 8 | import IWebRequestHandlerJson from "../handlers/webRequest/IWebRequestHandlerJson"; 9 | import Rect from "../Rect"; 10 | import IPushbulletConfigJson from "../handlers/pushbulletManager/IPushoverConfigJson"; 11 | 12 | export default interface ITriggerJson { 13 | cooldownTime: number; 14 | customEndpoint?: string; 15 | enabled: boolean; 16 | name: string; 17 | snapshotUri: string; 18 | threshold: { 19 | minimum: number; 20 | maximum: number; 21 | }; 22 | watchPattern?: string; 23 | watchObjects: string[]; 24 | 25 | // Handler settings 26 | handlers: { 27 | webRequest: IWebRequestHandlerJson; 28 | mqtt: IMqttHandlerConfigJson; 29 | telegram: ITelegramConfigJson; 30 | pushbullet: IPushbulletConfigJson; 31 | pushover: IPushoverConfigJson; 32 | }; 33 | 34 | masks: Rect[]; 35 | activateRegions: Rect[]; 36 | } 37 | -------------------------------------------------------------------------------- /src/types/ITriggerStatistics.ts: -------------------------------------------------------------------------------- 1 | /*--------------------------------------------------------------------------------------------- 2 | * Copyright (c) Neil Enns. All rights reserved. 3 | * Licensed under the MIT License. See LICENSE in the project root for license information. 4 | *--------------------------------------------------------------------------------------------*/ 5 | export default interface ITriggerStatistics { 6 | analyzedFilesCount: number; 7 | name?: string; 8 | triggeredCount: number; 9 | } 10 | -------------------------------------------------------------------------------- /src/typings/pureimage.d.ts: -------------------------------------------------------------------------------- 1 | /*--------------------------------------------------------------------------------------------- 2 | * Copyright (c) Neil Enns. All rights reserved. 3 | * Licensed under the MIT License. See LICENSE in the project root for license information. 4 | *--------------------------------------------------------------------------------------------*/ 5 | declare module "pureimage"; 6 | -------------------------------------------------------------------------------- /tests/Rect.test.ts: -------------------------------------------------------------------------------- 1 | /*--------------------------------------------------------------------------------------------- 2 | * Copyright (c) Neil Enns. All rights reserved. 3 | * Licensed under the MIT License. See LICENSE in the project root for license information. 4 | *--------------------------------------------------------------------------------------------*/ 5 | import Rect from "../src/Rect"; 6 | 7 | test("Verify mask overlaps", () => { 8 | const predictedRect = new Rect(31, 125, 784, 1209); 9 | 10 | let testRect: Rect; 11 | 12 | // Completely outside the top left 13 | testRect = new Rect(0, 0, 10, 10); 14 | expect(predictedRect.overlaps(testRect)).toBe(false); 15 | 16 | // Completely outside the top right 17 | testRect = new Rect(785, 126, 10, 10); 18 | expect(predictedRect.overlaps(testRect)).toBe(false); 19 | 20 | // Overlaps a bit right on an edge 21 | testRect = new Rect(31, 125, 40, 160); 22 | expect(predictedRect.overlaps(testRect)).toBe(true); 23 | 24 | // Completely inside 25 | testRect = new Rect(40, 160, 50, 1200); 26 | expect(predictedRect.overlaps(testRect)).toBe(true); 27 | 28 | // Extends beyond bottom right 29 | testRect = new Rect(200, 200, 800, 1300); 30 | expect(predictedRect.overlaps(testRect)).toBe(true); 31 | 32 | // Extends beyond top left 33 | testRect = new Rect(0, 0, 40, 160); 34 | expect(predictedRect.overlaps(testRect)).toBe(true); 35 | 36 | // Exact duplicate 37 | testRect = predictedRect; 38 | expect(predictedRect.overlaps(testRect)).toBe(true); 39 | }); 40 | -------------------------------------------------------------------------------- /tests/Trigger.test.ts: -------------------------------------------------------------------------------- 1 | /*--------------------------------------------------------------------------------------------- 2 | * Copyright (c) Neil Enns. All rights reserved. 3 | * Licensed under the MIT License. See LICENSE in the project root for license information. 4 | *--------------------------------------------------------------------------------------------*/ 5 | import Trigger from "../src/Trigger"; 6 | import validateJsonAgainstSchema from "../src/schemaValidator"; 7 | import triggerConfigJson from "../src/schemas/triggerConfiguration.schema.json"; 8 | import triggerJson from "./triggers.json"; 9 | 10 | test("Verify isRegisteredForObject()", () => { 11 | // Empty constructor should default to enabled true 12 | const trigger = new Trigger(); 13 | trigger.name = "Trigger.test.ts"; 14 | 15 | trigger.watchObjects = ["dog"]; 16 | expect(trigger.isRegisteredForObject("unit test", "dog")).toBe(true); 17 | 18 | trigger.watchObjects = []; 19 | expect(trigger.isRegisteredForObject("unit test", "dog")).toBe(false); 20 | 21 | trigger.watchObjects = undefined; 22 | expect(trigger.isRegisteredForObject("unit test", "dog")).toBe(false); 23 | 24 | trigger.watchObjects = null; 25 | expect(trigger.isRegisteredForObject("unit test", "dog")).toBe(false); 26 | 27 | trigger.watchObjects = ["DoG"]; 28 | expect(trigger.isRegisteredForObject("unit test", "dog")).toBe(true); 29 | 30 | trigger.watchObjects = ["dog"]; 31 | expect(trigger.isRegisteredForObject("unit test", "doG")).toBe(true); 32 | 33 | trigger.watchObjects = ["cat", "elephant"]; 34 | expect(trigger.isRegisteredForObject("unit test", "dog")).toBe(false); 35 | 36 | trigger.watchObjects = ["cat", "elephant"]; 37 | expect(trigger.isRegisteredForObject("unit test", undefined)).toBe(false); 38 | }); 39 | 40 | test("Verify isRegisteredForObject()", async () => { 41 | await expect(validateJsonAgainstSchema(triggerJson, triggerConfigJson)).resolves.toEqual(true); 42 | 43 | // Check that case doesn't matter for string arrays that take an enum 44 | triggerJson.triggers[0].watchObjects = ["dOg"]; 45 | await expect(validateJsonAgainstSchema(triggerJson, triggerConfigJson)).resolves.toEqual(true); 46 | }); 47 | -------------------------------------------------------------------------------- /tests/handlers/MqttConfig.test.ts: -------------------------------------------------------------------------------- 1 | /*--------------------------------------------------------------------------------------------- 2 | * Copyright (c) Neil Enns. All rights reserved. 3 | * Licensed under the MIT License. See LICENSE in the project root for license information. 4 | *--------------------------------------------------------------------------------------------*/ 5 | import MqttHandleConfig from "../../src/handlers/mqttManager/MqttHandlerConfig"; 6 | 7 | test("Verify MQTT handler configuration", () => { 8 | // Empty constructor should default to enabled true 9 | let config = new MqttHandleConfig(); 10 | expect(config.enabled).toBe(true); 11 | 12 | // Undefined enabled should be true 13 | config = new MqttHandleConfig({ enabled: undefined }); 14 | expect(config.enabled).toBe(true); 15 | 16 | // Explicitly set enabled true should be true 17 | config = new MqttHandleConfig({ enabled: true }); 18 | expect(config.enabled).toBe(true); 19 | 20 | // Explicitly set enabled false should be false 21 | config = new MqttHandleConfig({ enabled: false }); 22 | expect(config.enabled).toBe(false); 23 | 24 | // Undefined offDelay should default to 30 25 | config = new MqttHandleConfig({ messages: [{ offDelay: undefined, topic: "" }] }); 26 | expect(config.messages[0].offDelay).toBe(30); 27 | 28 | // 0 offDelay should be 0 29 | config = new MqttHandleConfig({ messages: [{ offDelay: 0, topic: "" }] }); 30 | expect(config.messages[0].offDelay).toBe(0); 31 | 32 | // 60 offDelay should be 60 33 | config = new MqttHandleConfig({ messages: [{ offDelay: 60, topic: "" }] }); 34 | expect(config.messages[0].offDelay).toBe(60); 35 | }); 36 | -------------------------------------------------------------------------------- /tests/handlers/TelegramConfig.test.ts: -------------------------------------------------------------------------------- 1 | /*--------------------------------------------------------------------------------------------- 2 | * Copyright (c) Neil Enns. All rights reserved. 3 | * Licensed under the MIT License. See LICENSE in the project root for license information. 4 | *--------------------------------------------------------------------------------------------*/ 5 | import TelegramConfig from "../../src/handlers/telegramManager/TelegramConfig"; 6 | 7 | test("Verify Telegram handler configuration", () => { 8 | // Empty constructor should default to enabled true 9 | let config = new TelegramConfig(); 10 | expect(config.enabled).toBe(true); 11 | 12 | // Undefined enabled should be true 13 | config = new TelegramConfig({ enabled: undefined }); 14 | expect(config.enabled).toBe(true); 15 | 16 | // Explicitly set enabled true should be true 17 | config = new TelegramConfig({ enabled: true }); 18 | expect(config.enabled).toBe(true); 19 | 20 | // Explicitly set enabled false should be false 21 | config = new TelegramConfig({ enabled: false }); 22 | expect(config.enabled).toBe(false); 23 | }); 24 | -------------------------------------------------------------------------------- /tests/handlers/WebRequestConfig.test.ts: -------------------------------------------------------------------------------- 1 | /*--------------------------------------------------------------------------------------------- 2 | * Copyright (c) Neil Enns. All rights reserved. 3 | * Licensed under the MIT License. See LICENSE in the project root for license information. 4 | *--------------------------------------------------------------------------------------------*/ 5 | import WebRequestConfig from "../../src/handlers/webRequest/WebRequestConfig"; 6 | 7 | test("Verify web request handler configuration", () => { 8 | // Empty constructor should default to enabled true 9 | let config = new WebRequestConfig(); 10 | expect(config.enabled).toBe(true); 11 | 12 | // Undefined enabled should be true 13 | config = new WebRequestConfig({ enabled: undefined }); 14 | expect(config.enabled).toBe(true); 15 | 16 | // Explicitly set enabled true should be true 17 | config = new WebRequestConfig({ enabled: true }); 18 | expect(config.enabled).toBe(true); 19 | 20 | // Explicitly set enabled false should be false 21 | config = new WebRequestConfig({ enabled: false }); 22 | expect(config.enabled).toBe(false); 23 | }); 24 | -------------------------------------------------------------------------------- /tests/helpers.test.ts: -------------------------------------------------------------------------------- 1 | /*--------------------------------------------------------------------------------------------- 2 | * Copyright (c) Neil Enns. All rights reserved. 3 | * Licensed under the MIT License. See LICENSE in the project root for license information. 4 | *--------------------------------------------------------------------------------------------*/ 5 | import { closeSync, existsSync, openSync, unlinkSync, writeFileSync } from "fs"; 6 | import * as helpers from "./../src/helpers"; 7 | 8 | describe("helpers", () => { 9 | const serviceName = "Settings"; 10 | const settingsFilePath = `${__dirname}/settings.json`; 11 | const secretsFilePath = `${__dirname}/secrets.json`; 12 | //eslint-disable-next-line no-console 13 | console.log = jest.fn(); 14 | 15 | beforeEach(() => { 16 | closeSync(openSync(settingsFilePath, "w")); 17 | }); 18 | 19 | afterEach(() => { 20 | existsSync(settingsFilePath) && unlinkSync(settingsFilePath); 21 | existsSync(secretsFilePath) && unlinkSync(secretsFilePath); 22 | }); 23 | 24 | test("Verify can load settings.json", () => { 25 | const expectedSettings = { foo: "bar" }; 26 | writeFileSync(settingsFilePath, JSON.stringify(expectedSettings)); 27 | 28 | const actualSettings = helpers.readSettings(serviceName, settingsFilePath); 29 | 30 | expect(actualSettings).toEqual(expectedSettings); 31 | }); 32 | 33 | test("Verify can load settings.json with secrets", () => { 34 | const secrets = { someSecret: "bar" }; 35 | writeFileSync(secretsFilePath, JSON.stringify(secrets)); 36 | const settings = { foo: "{{someSecret}}" }; 37 | writeFileSync(settingsFilePath, JSON.stringify(settings)); 38 | 39 | const actualSettings = helpers.readSettings(serviceName, settingsFilePath, secretsFilePath); 40 | 41 | expect(actualSettings).toEqual({ foo: "bar" }); 42 | }); 43 | 44 | test("Verify can load settings.json with secrets that are urls", () => { 45 | const secrets = { someSecret: "http://127.0.0.1:5000/" }; 46 | writeFileSync(secretsFilePath, JSON.stringify(secrets)); 47 | const settings = { foo: "{{{someSecret}}}" }; 48 | writeFileSync(settingsFilePath, JSON.stringify(settings)); 49 | 50 | const actualSettings = helpers.readSettings(serviceName, settingsFilePath, secretsFilePath); 51 | 52 | expect(actualSettings).toEqual({ foo: "http://127.0.0.1:5000/" }); 53 | }); 54 | 55 | test("Verify secret is rendered empty if it doesn't exist'", () => { 56 | const secrets = {}; 57 | writeFileSync(secretsFilePath, JSON.stringify(secrets)); 58 | const settings = { foo: "{{someSecret}}" }; 59 | writeFileSync(settingsFilePath, JSON.stringify(settings)); 60 | 61 | const actualSettings = helpers.readSettings(serviceName, settingsFilePath, secretsFilePath); 62 | 63 | expect(actualSettings).toEqual({ foo: "" }); 64 | }); 65 | 66 | test("Verify cannot load settings.json because it does not exist", () => { 67 | unlinkSync(settingsFilePath); 68 | 69 | try { 70 | helpers.readSettings(serviceName, settingsFilePath); 71 | } catch (error) { 72 | //eslint-disable-next-line no-console 73 | expect(console.log).toHaveBeenCalledWith( 74 | expect.stringContaining(`[${serviceName}] Unable to read the settings file: ENOENT: no such file or directory`), 75 | ); 76 | expect(error.message).toBe(`[${serviceName}] Unable to load file ${settingsFilePath}.`); 77 | } 78 | }); 79 | 80 | test("Verify cannot load secrets.json because it does not exist", () => { 81 | const expectedSettings = { foo: "bar" }; 82 | writeFileSync(settingsFilePath, JSON.stringify(expectedSettings)); 83 | 84 | const actualSettings = helpers.readSettings(serviceName, settingsFilePath); 85 | 86 | //eslint-disable-next-line no-console 87 | expect(console.log).toHaveBeenCalledWith( 88 | expect.stringContaining(`[${serviceName}] Unable to read the secrets file: ENOENT: no such file or directory`), 89 | ); 90 | expect(actualSettings).toEqual(expectedSettings); 91 | }); 92 | 93 | test("Verify logs with message if settings.json empty", () => { 94 | const expectedSettings = ""; 95 | writeFileSync(settingsFilePath, expectedSettings); 96 | 97 | helpers.readSettings(serviceName, settingsFilePath); 98 | 99 | //eslint-disable-next-line no-console 100 | expect(console.log).toHaveBeenCalledWith( 101 | expect.stringContaining(`[${serviceName}] Unable to read the settings file: ENOENT: no such file or directory`), 102 | ); 103 | }); 104 | 105 | test("Verify logs with message with message if settings.json empty", () => { 106 | const expectedSettings = ""; 107 | writeFileSync(settingsFilePath, expectedSettings); 108 | 109 | try { 110 | helpers.readSettings(serviceName, settingsFilePath); 111 | } catch (error) { 112 | expect(error.message).toBe(`[${serviceName}] Unable to load file ${settingsFilePath}.`); 113 | } 114 | }); 115 | }); 116 | -------------------------------------------------------------------------------- /tests/jest.setup.ts: -------------------------------------------------------------------------------- 1 | /*--------------------------------------------------------------------------------------------- 2 | * Copyright (c) Neil Enns. All rights reserved. 3 | * Licensed under the MIT License. See LICENSE in the project root for license information. 4 | *--------------------------------------------------------------------------------------------*/ 5 | // This is required because the project uses request-promise-native 6 | // and some dependency somewhere uses request-promise. 7 | // See https://github.com/request/request-promise/issues/247. 8 | jest.mock("request-promise"); 9 | 10 | // Ensure there's no warning from the telegram bot module. 11 | process.env.NTBA_FIX_319 = "true"; 12 | -------------------------------------------------------------------------------- /tests/triggers.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://raw.githubusercontent.com/danecreekphotography/node-deepstackai-trigger/main/src/schemas/triggerConfiguration.schema.json", 3 | "triggers": [ 4 | { 5 | "name": "Dog detector", 6 | "watchPattern": "/aiinput/Dog*.jpg", 7 | "enabled": true, 8 | "threshold": { 9 | "minimum": 50, 10 | "maximum": 100 11 | }, 12 | "handlers": { 13 | "webRequest": { 14 | "triggerUris": ["http://localhost:81/admin?trigger&camera=Dog"] 15 | }, 16 | "mqtt": { 17 | "messages": [{ "topic": "aimotion/triggers/dog" }] 18 | }, 19 | "telegram": { 20 | "chatIds": [1], 21 | "cooldownTime": 60 22 | } 23 | }, 24 | "watchObjects": ["dog"] 25 | } 26 | ] 27 | } 28 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "noImplicitAny": true, 4 | "target": "es5", 5 | "esModuleInterop": true, 6 | "outDir": "./dist", 7 | "sourceMap": true, 8 | "resolveJsonModule": true 9 | }, 10 | "include": ["src", "package.json"], 11 | "exclude": ["node_modules"] 12 | } 13 | -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | /*--------------------------------------------------------------------------------------------- 2 | * Copyright (c) Neil Enns. All rights reserved. 3 | * Licensed under the MIT License. See LICENSE in the project root for license information. 4 | *--------------------------------------------------------------------------------------------*/ 5 | const path = require("path"); 6 | 7 | module.exports = { 8 | entry: "./src/main.ts", 9 | target: "node", 10 | node: { 11 | // From https://github.com/webpack/webpack/issues/1599#issuecomment-186841345 12 | // This makes __dirname valid in the Docker image and means serve-index 13 | // can read its files from the public folder. 14 | __dirname: false, 15 | }, 16 | externals: { 17 | fsevents: "fsevents", 18 | }, 19 | module: { 20 | rules: [ 21 | { 22 | test: /\.tsx?$/, 23 | use: "ts-loader", 24 | exclude: /node_modules/, 25 | }, 26 | ], 27 | }, 28 | resolve: { 29 | fallback: { 30 | fsevents: false 31 | }, 32 | extensions: [".ts", ".js"], 33 | }, 34 | output: { 35 | filename: "bundle.js", 36 | path: path.resolve(__dirname, "dist"), 37 | }, 38 | }; 39 | --------------------------------------------------------------------------------